Polls part 1 - displaying in timelines and voting (#1200)

* add entity classes

* change data models and add database migration

* add polls to StatusViewData

* show poll results

* add methods for vote handling

* add voting interface

* enable voting in TimelineFragment

* update polls immediately

* enable custom emojis for poll options

* enable voting from search fragment

* add voting layout to detailed statuses

* fix tests

* enable voting in ViewThreadFragment

* enable voting in ConversationsFragment

* small refactor for StatusBaseViewHolder
main
Konrad Pozniak 6 years ago committed by GitHub
parent fe0c9d19b4
commit b1e68dfc38
  1. 674
      app/schemas/com.keylesspalace.tusky.db.AppDatabase/15.json
  2. 2
      app/src/main/java/com/keylesspalace/tusky/TuskyApplication.java
  3. 1
      app/src/main/java/com/keylesspalace/tusky/adapter/SearchResultsAdapter.java
  4. 160
      app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java
  5. 2
      app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt
  6. 11
      app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt
  7. 2
      app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java
  8. 10
      app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt
  9. 19
      app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt
  10. 16
      app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java
  11. 11
      app/src/main/java/com/keylesspalace/tusky/db/Converters.kt
  12. 3
      app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt
  13. 33
      app/src/main/java/com/keylesspalace/tusky/entity/Poll.kt
  14. 4
      app/src/main/java/com/keylesspalace/tusky/entity/Status.kt
  15. 19
      app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java
  16. 28
      app/src/main/java/com/keylesspalace/tusky/fragment/SearchFragment.kt
  17. 29
      app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java
  18. 28
      app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java
  19. 4
      app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java
  20. 8
      app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java
  21. 16
      app/src/main/java/com/keylesspalace/tusky/network/TimelineCases.kt
  22. 22
      app/src/main/java/com/keylesspalace/tusky/repository/TimelineRepository.kt
  23. 79
      app/src/main/java/com/keylesspalace/tusky/util/DateUtils.java
  24. 2
      app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.java
  25. 24
      app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.java
  26. 6
      app/src/main/res/drawable/poll_option_background.xml
  27. 6
      app/src/main/res/drawable/poll_option_shape.xml
  28. 145
      app/src/main/res/layout/item_conversation.xml
  29. 145
      app/src/main/res/layout/item_status.xml
  30. 151
      app/src/main/res/layout/item_status_detailed.xml
  31. 2
      app/src/main/res/values-night/styles.xml
  32. 2
      app/src/main/res/values/attrs.xml
  33. 23
      app/src/main/res/values/strings.xml
  34. 2
      app/src/main/res/values/styles.xml
  35. 3
      app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt
  36. 3
      app/src/test/java/com/keylesspalace/tusky/fragment/TimelineRepositoryTest.kt

@ -0,0 +1,674 @@
{
"formatVersion": 1,
"database": {
"version": 15,
"identityHash": "6a01315ce9f7d402cb61e611140e3c0a",
"entities": [
{
"tableName": "TootEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `text` TEXT, `urls` TEXT, `descriptions` TEXT, `contentWarning` TEXT, `inReplyToId` TEXT, `inReplyToText` TEXT, `inReplyToUsername` TEXT, `visibility` INTEGER)",
"fields": [
{
"fieldPath": "uid",
"columnName": "uid",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "text",
"columnName": "text",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "urls",
"columnName": "urls",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "descriptions",
"columnName": "descriptions",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "contentWarning",
"columnName": "contentWarning",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "inReplyToId",
"columnName": "inReplyToId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "inReplyToText",
"columnName": "inReplyToText",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "inReplyToUsername",
"columnName": "inReplyToUsername",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "visibility",
"columnName": "visibility",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"uid"
],
"autoGenerate": true
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "AccountEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "domain",
"columnName": "domain",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accessToken",
"columnName": "accessToken",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isActive",
"columnName": "isActive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "profilePictureUrl",
"columnName": "profilePictureUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "notificationsEnabled",
"columnName": "notificationsEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsMentioned",
"columnName": "notificationsMentioned",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsFollowed",
"columnName": "notificationsFollowed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsReblogged",
"columnName": "notificationsReblogged",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsFavorited",
"columnName": "notificationsFavorited",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationSound",
"columnName": "notificationSound",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationVibration",
"columnName": "notificationVibration",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationLight",
"columnName": "notificationLight",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "defaultPostPrivacy",
"columnName": "defaultPostPrivacy",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "defaultMediaSensitivity",
"columnName": "defaultMediaSensitivity",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "alwaysShowSensitiveMedia",
"columnName": "alwaysShowSensitiveMedia",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "mediaPreviewEnabled",
"columnName": "mediaPreviewEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastNotificationId",
"columnName": "lastNotificationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "activeNotifications",
"columnName": "activeNotifications",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "emojis",
"columnName": "emojis",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "tabPreferences",
"columnName": "tabPreferences",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "notificationsFilter",
"columnName": "notificationsFilter",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_AccountEntity_domain_accountId",
"unique": true,
"columnNames": [
"domain",
"accountId"
],
"createSql": "CREATE UNIQUE INDEX `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)"
}
],
"foreignKeys": []
},
{
"tableName": "InstanceEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, PRIMARY KEY(`instance`))",
"fields": [
{
"fieldPath": "instance",
"columnName": "instance",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "emojiList",
"columnName": "emojiList",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "maximumTootCharacters",
"columnName": "maximumTootCharacters",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"instance"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "TimelineStatusEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT, `visibility` INTEGER, `attachments` TEXT, `mentions` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
"fields": [
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "timelineUserId",
"columnName": "timelineUserId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "authorServerId",
"columnName": "authorServerId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "inReplyToId",
"columnName": "inReplyToId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "inReplyToAccountId",
"columnName": "inReplyToAccountId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "emojis",
"columnName": "emojis",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogsCount",
"columnName": "reblogsCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "favouritesCount",
"columnName": "favouritesCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "reblogged",
"columnName": "reblogged",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "favourited",
"columnName": "favourited",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sensitive",
"columnName": "sensitive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "spoilerText",
"columnName": "spoilerText",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "visibility",
"columnName": "visibility",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "mentions",
"columnName": "mentions",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "application",
"columnName": "application",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogServerId",
"columnName": "reblogServerId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogAccountId",
"columnName": "reblogAccountId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "poll",
"columnName": "poll",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"serverId",
"timelineUserId"
],
"autoGenerate": false
},
"indices": [
{
"name": "index_TimelineStatusEntity_authorServerId_timelineUserId",
"unique": false,
"columnNames": [
"authorServerId",
"timelineUserId"
],
"createSql": "CREATE INDEX `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)"
}
],
"foreignKeys": [
{
"table": "TimelineAccountEntity",
"onDelete": "NO ACTION",
"onUpdate": "NO ACTION",
"columns": [
"authorServerId",
"timelineUserId"
],
"referencedColumns": [
"serverId",
"timelineUserId"
]
}
]
},
{
"tableName": "TimelineAccountEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))",
"fields": [
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "timelineUserId",
"columnName": "timelineUserId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "localUsername",
"columnName": "localUsername",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "avatar",
"columnName": "avatar",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "emojis",
"columnName": "emojis",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"serverId",
"timelineUserId"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "ConversationEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsible` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accounts",
"columnName": "accounts",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "unread",
"columnName": "unread",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.id",
"columnName": "s_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.url",
"columnName": "s_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastStatus.inReplyToId",
"columnName": "s_inReplyToId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastStatus.inReplyToAccountId",
"columnName": "s_inReplyToAccountId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastStatus.account",
"columnName": "s_account",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.content",
"columnName": "s_content",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.createdAt",
"columnName": "s_createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.emojis",
"columnName": "s_emojis",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.favouritesCount",
"columnName": "s_favouritesCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.favourited",
"columnName": "s_favourited",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.sensitive",
"columnName": "s_sensitive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.spoilerText",
"columnName": "s_spoilerText",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.attachments",
"columnName": "s_attachments",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.mentions",
"columnName": "s_mentions",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.showingHiddenContent",
"columnName": "s_showingHiddenContent",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.expanded",
"columnName": "s_expanded",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.collapsible",
"columnName": "s_collapsible",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.collapsed",
"columnName": "s_collapsed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.poll",
"columnName": "s_poll",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id",
"accountId"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, \"6a01315ce9f7d402cb61e611140e3c0a\")"
]
}
}

@ -74,7 +74,7 @@ public class TuskyApplication extends Application implements HasActivityInjector
AppDatabase.MIGRATION_5_6, AppDatabase.MIGRATION_6_7, AppDatabase.MIGRATION_7_8,
AppDatabase.MIGRATION_8_9, AppDatabase.MIGRATION_9_10, AppDatabase.MIGRATION_10_11,
AppDatabase.MIGRATION_11_12, AppDatabase.MIGRATION_12_13, AppDatabase.MIGRATION_10_13,
AppDatabase.MIGRATION_13_14)
AppDatabase.MIGRATION_13_14, AppDatabase.MIGRATION_14_15)
.build();
accountManager = new AccountManager(appDatabase);
serviceLocator = new ServiceLocator() {

@ -143,6 +143,7 @@ public class SearchResultsAdapter extends RecyclerView.Adapter {
public void updateStatusAtPosition(StatusViewData.Concrete status, int position) {
concreteStatusList.set(position - accountList.size(), status);
notifyItemChanged(position);
}
public void removeStatusAtPosition(int position) {

@ -7,8 +7,11 @@ import android.text.Spanned;
import android.text.TextUtils;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.RadioButton;
import android.widget.RadioGroup;
import android.widget.TextView;
import android.widget.ToggleButton;
@ -18,6 +21,8 @@ import com.keylesspalace.tusky.entity.Attachment;
import com.keylesspalace.tusky.entity.Attachment.Focus;
import com.keylesspalace.tusky.entity.Attachment.MetaData;
import com.keylesspalace.tusky.entity.Emoji;
import com.keylesspalace.tusky.entity.Poll;
import com.keylesspalace.tusky.entity.PollOption;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.util.CustomEmojiHelper;
@ -31,6 +36,7 @@ import com.mikepenz.iconics.utils.Utils;
import java.text.NumberFormat;
import java.text.SimpleDateFormat;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Locale;
@ -70,6 +76,12 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
public TextView content;
public TextView contentWarningDescription;
private TextView[] pollResults;
private TextView pollDescription;
private RadioGroup pollRadioGroup;
private RadioButton[] pollRadioOptions;
private Button pollButton;
private boolean useAbsoluteTime;
private SimpleDateFormat shortSdf;
private SimpleDateFormat longSdf;
@ -109,6 +121,25 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
contentWarningButton = itemView.findViewById(R.id.status_content_warning_button);
avatarInset = itemView.findViewById(R.id.status_avatar_inset);
pollResults = new TextView[] {
itemView.findViewById(R.id.status_poll_option_result_0),
itemView.findViewById(R.id.status_poll_option_result_1),
itemView.findViewById(R.id.status_poll_option_result_2),
itemView.findViewById(R.id.status_poll_option_result_3)
};
pollDescription = itemView.findViewById(R.id.status_poll_description);
pollRadioGroup = itemView.findViewById(R.id.status_poll_radio_group);
pollRadioOptions = new RadioButton[] {
pollRadioGroup.findViewById(R.id.status_poll_radio_button_0),
pollRadioGroup.findViewById(R.id.status_poll_radio_button_1),
pollRadioGroup.findViewById(R.id.status_poll_radio_button_2),
pollRadioGroup.findViewById(R.id.status_poll_radio_button_3)
};
pollButton = itemView.findViewById(R.id.status_poll_button);
this.useAbsoluteTime = useAbsoluteTime;
shortSdf = new SimpleDateFormat("HH:mm:ss", Locale.getDefault());
longSdf = new SimpleDateFormat("MM/dd HH:mm:ss", Locale.getDefault());
@ -218,10 +249,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
private String getAbsoluteTime(@Nullable Date createdAt) {
String time;
if (createdAt != null) {
if (System.currentTimeMillis() - createdAt.getTime() > 86400000L) {
time = longSdf.format(createdAt);
} else {
if (android.text.format.DateUtils.isToday(createdAt.getTime())) {
time = shortSdf.format(createdAt);
} else {
time = longSdf.format(createdAt);
}
} else {
time = "??:??:??";
@ -588,6 +619,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
setSpoilerAndContent(status.isExpanded(), status.getContent(), status.getSpoilerText(), status.getMentions(), status.getStatusEmojis(), listener);
setContentDescription(status);
setupPoll(status.getPoll(),status.getStatusEmojis(), listener);
// Workaround for RecyclerView 1.0.0 / androidx.core 1.0.0
// RecyclerView tries to set AccessibilityDelegateCompat to null
// but ViewCompat code replaces is with the default one. RecyclerView never
@ -717,4 +751,124 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
return "";
}
}
protected void setupPoll(Poll poll, List<Emoji> emojis, StatusActionListener listener) {
if(poll == null) {
for(TextView pollResult: pollResults) {
pollResult.setVisibility(View.GONE);
}
pollDescription.setVisibility(View.GONE);
pollRadioGroup.setVisibility(View.GONE);
for(RadioButton radioButton: pollRadioOptions) {
radioButton.setVisibility(View.GONE);
}
pollButton.setVisibility(View.GONE);
} else {
Context context = pollDescription.getContext();
List<PollOption> options = poll.getOptions();
if(poll.getExpired() || poll.getVoted()) {
// no voting possible
for(int i = 0; i < Status.MAX_POLL_OPTIONS; i++) {
if(i < options.size()) {
long percent = calculatePollPercent(options.get(i).getVotesCount(), poll.getVotesCount());
String pollOptionText = context.getString(R.string.poll_option_format, percent, options.get(i).getTitle());
pollResults[i].setText(CustomEmojiHelper.emojifyText(HtmlUtils.fromHtml(pollOptionText), emojis, pollResults[i]));
pollResults[i].setVisibility(View.VISIBLE);
int level = (int) percent * 100;
pollResults[i].getBackground().setLevel(level);
} else {
pollResults[i].setVisibility(View.GONE);
}
}
pollRadioGroup.setVisibility(View.GONE);
for(RadioButton radioButton: pollRadioOptions) {
radioButton.setVisibility(View.GONE);
}
pollButton.setVisibility(View.GONE);
} else {
// voting possible
for(TextView pollResult: pollResults) {
pollResult.setVisibility(View.GONE);
}
pollRadioGroup.setVisibility(View.VISIBLE);
pollRadioGroup.clearCheck();
pollButton.setVisibility(View.VISIBLE);
for(int i = 0; i < Status.MAX_POLL_OPTIONS; i++) {
if(i < options.size()) {
pollRadioOptions[i].setText(CustomEmojiHelper.emojifyString(options.get(i).getTitle(), emojis, pollRadioOptions[i]));
pollRadioOptions[i].setVisibility(View.VISIBLE);
} else {
pollRadioOptions[i].setVisibility(View.GONE);
}
}
}
pollDescription.setVisibility(View.VISIBLE);
String votes = numberFormat.format(poll.getVotesCount());
String votesText = context.getResources().getQuantityString(R.plurals.poll_info_votes, poll.getVotesCount(), votes);
CharSequence pollDurationInfo;
if(poll.getExpired()) {
pollDurationInfo = context.getString(R.string.poll_info_closed);
} else {
if(useAbsoluteTime) {
pollDurationInfo = context.getString(R.string.poll_info_time_absolute, getAbsoluteTime(poll.getExpiresAt()));
} else {
String pollDuration = DateUtils.formatDuration(pollDescription.getContext(), poll.getExpiresAt().getTime(), System.currentTimeMillis());
pollDurationInfo = context.getString(R.string.poll_info_time_relative, pollDuration);
}
}
String pollInfo = pollDescription.getContext().getString(R.string.poll_info_format, votesText, pollDurationInfo);
pollDescription.setText(pollInfo);
pollButton.setOnClickListener(v -> {
int selectedRadioButtonIndex;
switch (pollRadioGroup.getCheckedRadioButtonId()) {
case R.id.status_poll_radio_button_0:
selectedRadioButtonIndex = 0;
break;
case R.id.status_poll_radio_button_1:
selectedRadioButtonIndex = 1;
break;
case R.id.status_poll_radio_button_2:
selectedRadioButtonIndex = 2;
break;
case R.id.status_poll_radio_button_3:
selectedRadioButtonIndex = 3;
break;
default:
return;
}
listener.onVoteInPoll(getAdapterPosition(), Collections.singletonList(selectedRadioButtonIndex));
});
}
}
private static long calculatePollPercent(int votes, int totalVotes) {
if(votes == 0) {
return 0;
}
return Math.round(votes / (double) totalVotes * 100);
}
}

@ -2,6 +2,7 @@ package com.keylesspalace.tusky.appstore
import com.keylesspalace.tusky.TabData
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Status
data class FavoriteEvent(val statusId: String, val favourite: Boolean) : Dispatchable
@ -14,3 +15,4 @@ data class StatusComposedEvent(val status: Status) : Dispatchable
data class ProfileEditedEvent(val newProfileData: Account) : Dispatchable
data class PreferenceChangedEvent(val preferenceKey: String) : Dispatchable
data class MainTabsChangedEvent(val newTabs: List<TabData>) : Dispatchable
data class PollVoteEvent(val statusId: String, val poll: Poll) : Dispatchable

@ -76,7 +76,8 @@ data class ConversationStatusEntity(
val showingHiddenContent: Boolean,
val expanded: Boolean,
val collapsible: Boolean,
val collapsed: Boolean
val collapsed: Boolean,
val poll: Poll?
) {
/** its necessary to override this because Spanned.equals does not work as expected */
@ -104,6 +105,7 @@ data class ConversationStatusEntity(
if (expanded != other.expanded) return false
if (collapsible != other.collapsible) return false
if (collapsed != other.collapsed) return false
if (poll != other.poll) return false
return true
}
@ -127,6 +129,7 @@ data class ConversationStatusEntity(
result = 31 * result + expanded.hashCode()
result = 31 * result + collapsible.hashCode()
result = 31 * result + collapsed.hashCode()
result = 31 * result + poll.hashCode()
return result
}
@ -151,7 +154,8 @@ data class ConversationStatusEntity(
attachments = attachments,
mentions = mentions,
application = null,
pinned = false)
pinned = false,
poll = poll)
}
}
@ -172,7 +176,8 @@ fun Status.toEntity() =
false,
false,
!SmartLengthInputFilter.hasBadRatio(content, SmartLengthInputFilter.LENGTH_DEFAULT),
true
true,
poll
)

@ -102,6 +102,8 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
setAvatars(conversation.getAccounts());
setupPoll(status.getPoll(), status.getEmojis(), listener);
}
private void setConversationName(List<ConversationAccountEntity> accounts) {

@ -18,9 +18,11 @@ package com.keylesspalace.tusky.components.conversation
import android.content.Intent
import android.os.Bundle
import android.preference.PreferenceManager
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders
import androidx.paging.PagedList
@ -34,11 +36,15 @@ import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.fragment.SFragment
import com.keylesspalace.tusky.fragment.SearchFragment
import com.keylesspalace.tusky.interfaces.ReselectableFragment
import com.keylesspalace.tusky.interfaces.StatusActionListener
import com.keylesspalace.tusky.util.NetworkState
import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.hide
import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider
import com.uber.autodispose.autoDisposable
import io.reactivex.android.schedulers.AndroidSchedulers
import kotlinx.android.synthetic.main.fragment_timeline.*
import javax.inject.Inject
@ -187,6 +193,10 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
jumpToTop()
}
override fun onVoteInPoll(position: Int, choices: MutableList<Int>) {
viewModel.voteInPoll(position, choices)
}
companion object {
fun newInstance() = ConversationsFragment()
}

@ -67,6 +67,25 @@ class ConversationsViewModel @Inject constructor(
}
fun voteInPoll(position: Int, choices: MutableList<Int>) {
conversations.value?.getOrNull(position)?.let { conversation ->
timelineCases.voteInPoll(conversation.lastStatus.toStatus(), choices)
.flatMap { poll ->
val newConversation = conversation.copy(
lastStatus = conversation.lastStatus.copy(poll = poll)
)
Single.fromCallable {
database.conversationDao().insert(newConversation)
}
}
.subscribeOn(Schedulers.io())
.doOnError { t -> Log.w("ConversationViewModel", "Failed to favourite conversation", t) }
.subscribe()
.addTo(disposables)
}
}
fun expandHiddenStatus(expanded: Boolean, position: Int) {
conversations.value?.getOrNull(position)?.let { conversation ->
val newConversation = conversation.copy(

@ -30,7 +30,7 @@ import androidx.annotation.NonNull;
@Database(entities = {TootEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class,
TimelineAccountEntity.class, ConversationEntity.class
}, version = 14)
}, version = 15)
public abstract class AppDatabase extends RoomDatabase {
public abstract TootDao tootDao();
@ -256,6 +256,14 @@ public abstract class AppDatabase extends RoomDatabase {
}
};
public static final Migration MIGRATION_10_13 = new Migration(10, 13) {
@Override
public void migrate(@NonNull SupportSQLiteDatabase database) {
MIGRATION_11_12.migrate(database);
MIGRATION_12_13.migrate(database);
}
};
public static final Migration MIGRATION_13_14 = new Migration(13, 14) {
@Override
public void migrate(@NonNull SupportSQLiteDatabase database) {
@ -263,11 +271,11 @@ public abstract class AppDatabase extends RoomDatabase {
}
};
public static final Migration MIGRATION_10_13 = new Migration(10, 13) {
public static final Migration MIGRATION_14_15 = new Migration(14, 15) {
@Override
public void migrate(@NonNull SupportSQLiteDatabase database) {
MIGRATION_11_12.migrate(database);
MIGRATION_12_13.migrate(database);
database.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `poll` TEXT");
database.execSQL("ALTER TABLE `ConversationEntity` ADD COLUMN `s_poll` TEXT");
}
};

@ -24,6 +24,7 @@ import com.keylesspalace.tusky.components.conversation.ConversationAccountEntity
import com.keylesspalace.tusky.createTabDataFromId
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.json.SpannedTypeAdapter
import com.keylesspalace.tusky.util.HtmlUtils
@ -135,4 +136,14 @@ class Converters {
return HtmlUtils.fromHtml(spannedString)
}
@TypeConverter
fun pollToJson(poll: Poll?): String? {
return gson.toJson(poll)
}
@TypeConverter
fun jsonToPoll(pollJson: String?): Poll? {
return gson.fromJson(pollJson, Poll::class.java)
}
}

@ -49,7 +49,8 @@ data class TimelineStatusEntity(
val mentions: String?,
val application: String?,
val reblogServerId: String?, // if it has a reblogged status, it's id is stored here
val reblogAccountId: String?
val reblogAccountId: String?,
val poll: String?
)
@Entity(

@ -0,0 +1,33 @@
package com.keylesspalace.tusky.entity
import com.google.gson.annotations.SerializedName
import java.util.*
data class Poll(
val id: String,
@SerializedName("expires_at") val expiresAt: Date?,
val expired: Boolean,
val multiple: Boolean,
@SerializedName("votes_count") val votesCount: Int,
val options: List<PollOption>,
val voted: Boolean
) {
fun votedCopy(choices: List<Int>): Poll {
val newOptions = options.mapIndexed { index, option ->
if(choices.contains(index)) {
option.copy(votesCount = option.votesCount + 1)
} else {
option
}
}
return copy(options = newOptions, votesCount = votesCount + 1, voted = true)
}
}
data class PollOption(
val title: String,
@SerializedName("votes_count") val votesCount: Int
)

@ -39,7 +39,8 @@ data class Status(
@SerializedName("media_attachments") var attachments: ArrayList<Attachment>,
val mentions: Array<Mention>,
val application: Application?,
var pinned: Boolean?
var pinned: Boolean?,
val poll: Poll?
) {
val actionableId: String
@ -161,5 +162,6 @@ data class Status(
companion object {
const val MAX_MEDIA_ATTACHMENTS = 4
const val MAX_POLL_OPTIONS = 4
}
}

@ -46,6 +46,7 @@ import com.keylesspalace.tusky.db.AccountEntity;
import com.keylesspalace.tusky.db.AccountManager;
import com.keylesspalace.tusky.di.Injectable;
import com.keylesspalace.tusky.entity.Notification;
import com.keylesspalace.tusky.entity.Poll;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.interfaces.ActionButtonActivity;
import com.keylesspalace.tusky.interfaces.ReselectableFragment;
@ -428,6 +429,24 @@ public class NotificationsFragment extends SFragment implements
updateAdapter();
}
public void onVoteInPoll(int position, @NonNull List<Integer> choices) {
final Notification notification = notifications.get(position).asRight();
final Status status = notification.getStatus();
timelineCases.voteInPoll(status, choices)
.observeOn(AndroidSchedulers.mainThread())
.as(autoDisposable(from(this)))
.subscribe(
(newPoll) -> setVoteForPoll(position, newPoll),
(t) -> Log.d(TAG,
"Failed to vote in poll: " + status.getId(), t)
);
}
private void setVoteForPoll(int position, Poll poll) {
// TODO
}
@Override
public void onMore(@NonNull View view, int position) {
Notification notification = notifications.get(position).asRight();

@ -232,10 +232,6 @@ class SearchFragment : SFragment(), StatusActionListener {
searchRecyclerView.post { searchAdapter.notifyItemChanged(position, updatedStatus) }
}
companion object {
const val TAG = "SearchFragment"
}
override fun onViewAccount(id: String) {
val intent = AccountActivity.getIntent(requireContext(), id)
startActivity(intent)
@ -247,4 +243,28 @@ class SearchFragment : SFragment(), StatusActionListener {
startActivity(intent)
}
override fun onVoteInPoll(position: Int, choices: MutableList<Int>) {
val status = searchAdapter.getStatusAtPosition(position)
if (status != null) {
timelineCases.voteInPoll(status, choices)
.observeOn(AndroidSchedulers.mainThread())
.autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))
.subscribe({poll ->
val viewData = ViewDataUtils.statusToViewData(
status,
alwaysShowSensitiveMedia
)
val newViewData = StatusViewData.Builder(viewData)
.setPoll(poll)
.createStatusViewData()
searchAdapter.updateStatusAtPosition(newViewData, position)
}, { t -> Log.d(TAG, "Failed to vote in poll " + status.id, t) })
}
}
companion object {
const val TAG = "SearchFragment"
}
}

@ -45,6 +45,7 @@ import com.keylesspalace.tusky.appstore.UnfollowEvent;
import com.keylesspalace.tusky.db.AccountManager;
import com.keylesspalace.tusky.di.Injectable;
import com.keylesspalace.tusky.entity.Filter;
import com.keylesspalace.tusky.entity.Poll;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.interfaces.ActionButtonActivity;
import com.keylesspalace.tusky.interfaces.ReselectableFragment;
@ -620,6 +621,34 @@ public class TimelineFragment extends SFragment implements
updateAdapter();
}
public void onVoteInPoll(int position, @NonNull List<Integer> choices) {
final Status status = statuses.get(position).asRight();
setVoteForPoll(position, status, status.getPoll().votedCopy(choices));
timelineCases.voteInPoll(status, choices)
.observeOn(AndroidSchedulers.mainThread())
.as(autoDisposable(from(this)))
.subscribe(
(newPoll) -> setVoteForPoll(position, status, newPoll),
(t) -> Log.d(TAG,
"Failed to vote in poll: " + status.getId(), t)
);
}
private void setVoteForPoll(int position, Status status, Poll newPoll) {
Pair<StatusViewData.Concrete, Integer> actual =
findStatusAndPosition(position, status);
if (actual == null) return;
StatusViewData newViewData = new StatusViewData
.Builder(actual.first)
.setPoll(newPoll)
.createStatusViewData();
statuses.setPairedItem(actual.second, newViewData);
updateAdapter();
}
@Override
public void onMore(@NonNull View view, final int position) {
super.more(statuses.get(position).asRight(), view, position);

@ -42,6 +42,7 @@ import com.keylesspalace.tusky.appstore.StatusComposedEvent;
import com.keylesspalace.tusky.appstore.StatusDeletedEvent;
import com.keylesspalace.tusky.di.Injectable;
import com.keylesspalace.tusky.entity.Card;
import com.keylesspalace.tusky.entity.Poll;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.entity.StatusContext;
import com.keylesspalace.tusky.interfaces.StatusActionListener;
@ -393,6 +394,33 @@ public final class ViewThreadFragment extends SFragment implements
adapter.setStatuses(statuses.getPairedCopy());
}
public void onVoteInPoll(int position, @NonNull List<Integer> choices) {
final Status status = statuses.get(position).getActionableStatus();
setVoteForPoll(position, status.getPoll().votedCopy(choices));
timelineCases.voteInPoll(status, choices)
.observeOn(AndroidSchedulers.mainThread())
.as(autoDisposable(from(this)))
.subscribe(
(newPoll) -> setVoteForPoll(position, newPoll),
(t) -> Log.d(TAG,
"Failed to vote in poll: " + status.getId(), t)
);
}
private void setVoteForPoll(int position, Poll newPoll) {
StatusViewData.Concrete viewData = statuses.getPairedItem(position);
StatusViewData.Concrete newViewData = new StatusViewData.Builder(viewData)
.setPoll(newPoll)
.createStatusViewData();
statuses.setPairedItem(position, newViewData);
adapter.setItem(position, newViewData, true);
}
private void removeAllByAccountId(String accountId) {
Status status = null;
if (!statuses.isEmpty()) {

@ -17,6 +17,8 @@ package com.keylesspalace.tusky.interfaces;
import android.view.View;
import java.util.List;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@ -58,4 +60,6 @@ public interface StatusActionListener extends LinkListener {
*/
default void onShowFavs(int position) {}
void onVoteInPoll(int position, @NonNull List<Integer> choices);
}

@ -26,6 +26,7 @@ import com.keylesspalace.tusky.entity.Filter;
import com.keylesspalace.tusky.entity.Instance;
import com.keylesspalace.tusky.entity.MastoList;
import com.keylesspalace.tusky.entity.Notification;
import com.keylesspalace.tusky.entity.Poll;
import com.keylesspalace.tusky.entity.Relationship;
import com.keylesspalace.tusky.entity.SearchResults;
import com.keylesspalace.tusky.entity.Status;
@ -382,4 +383,11 @@ public interface MastodonApi {
Call<ResponseBody> deleteFilter(
@Path("id") String id
);
@FormUrlEncoded
@POST("api/v1/polls/{id}/votes")
Single<Poll> voteInPoll(
@Path("id") String id,
@Field("choices[]") List<Integer> choices
);
}

@ -16,6 +16,7 @@
package com.keylesspalace.tusky.network
import com.keylesspalace.tusky.appstore.*
import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Relationship
import com.keylesspalace.tusky.entity.Status
import io.reactivex.Single
@ -25,6 +26,7 @@ import okhttp3.ResponseBody
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import java.lang.IllegalStateException
/**
* Created by charlag on 3/24/18.
@ -37,6 +39,8 @@ interface TimelineCases {
fun block(id: String)
fun delete(id: String)
fun pin(status: Status, pin: Boolean)
fun voteInPoll(status: Status, choices: List<Int>): Single<Poll>
}
class TimelineCasesImpl(
@ -116,4 +120,16 @@ class TimelineCasesImpl(
.addTo(this.cancelDisposable)
}
override fun voteInPoll(status: Status, choices: List<Int>): Single<Poll> {
val pollId = status.actionableStatus.poll?.id
if(pollId == null || choices.isEmpty()) {
return Single.error(IllegalStateException())
}
return mastodonApi.voteInPoll(pollId, choices).doAfterSuccess {
eventHub.dispatch(PollVoteEvent(status.id, it))
}
}
}

@ -4,9 +4,7 @@ import android.text.SpannedString
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import com.keylesspalace.tusky.db.*
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.*
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.repository.TimelineRequestMode.DISK
@ -202,6 +200,7 @@ class TimelineRepositoryImpl(
val application = gson.fromJson(status.application, Status.Application::class.java)
val emojis: List<Emoji> = gson.fromJson(status.emojis,
object : TypeToken<List<Emoji>>() {}.type) ?: listOf()
val poll: Poll? = gson.fromJson(status.poll, Poll::class.java)
val reblog = status.reblogServerId?.let { id ->
Status(
@ -224,8 +223,8 @@ class TimelineRepositoryImpl(
attachments = attachments,
mentions = mentions,
application = application,
pinned = false
pinned = false,
poll = poll
)
}
val status = if (reblog != null) {
@ -249,7 +248,8 @@ class TimelineRepositoryImpl(
attachments = ArrayList(),
mentions = arrayOf(),
application = null,
pinned = false
pinned = false,
poll = null
)
} else {
Status(
@ -272,7 +272,8 @@ class TimelineRepositoryImpl(
attachments = attachments,
mentions = mentions,
application = application,
pinned = false
pinned = false,
poll = poll
)
}
return Either.Right(status)
@ -339,8 +340,8 @@ fun Placeholder.toEntity(timelineUserId: Long): TimelineStatusEntity {
mentions = null,
application = null,
reblogServerId = null,
reblogAccountId = null
reblogAccountId = null,
poll = null
)
}
@ -369,7 +370,8 @@ fun Status.toEntity(timelineUserId: Long,
mentions = actionable.mentions.let(gson::toJson),
application = actionable.let(gson::toJson),
reblogServerId = reblog?.id,
reblogAccountId = reblog?.let { this.account.id }
reblogAccountId = reblog?.let { this.account.id },
poll = actionable.poll.let(gson::toJson)
)
}

@ -20,57 +20,86 @@ import android.content.Context;
import com.keylesspalace.tusky.R;
public class DateUtils {
private static final long SECOND_IN_MILLIS = 1000;
private static final long MINUTE_IN_MILLIS = SECOND_IN_MILLIS * 60;
private static final long HOUR_IN_MILLIS = MINUTE_IN_MILLIS * 60;
private static final long DAY_IN_MILLIS = HOUR_IN_MILLIS * 24;
private static final long YEAR_IN_MILLIS = DAY_IN_MILLIS * 365;
/**
* This is a rough duplicate of {@link android.text.format.DateUtils#getRelativeTimeSpanString},
* but even with the FORMAT_ABBREV_RELATIVE flag it wasn't abbreviating enough.
*/
public static String getRelativeTimeSpanString(Context context, long then, long now) {
final long MINUTE = 60;
final long HOUR = 60 * MINUTE;
final long DAY = 24 * HOUR;
final long YEAR = 365 * DAY;
long span = (now - then) / 1000;
long span = now - then;
boolean future = false;
if (span < 0) {
future = true;
span = -span;
}
String format;
if (span < MINUTE) {
int format;
if (span < MINUTE_IN_MILLIS) {
span /= SECOND_IN_MILLIS;
if (future) {
format = context.getString(R.string.abbreviated_in_seconds);
format = R.string.abbreviated_in_seconds;
} else {
format = context.getString(R.string.abbreviated_seconds_ago);
format = R.string.abbreviated_seconds_ago;
}
} else if (span < HOUR) {
span /= MINUTE;
} else if (span < HOUR_IN_MILLIS) {
span /= MINUTE_IN_MILLIS;
if (future) {
format = context.getString(R.string.abbreviated_in_minutes);
format = R.string.abbreviated_in_minutes;
} else {
format = context.getString(R.string.abbreviated_minutes_ago);
format = R.string.abbreviated_minutes_ago;
}
} else if (span < DAY) {
span /= HOUR;
} else if (span < DAY_IN_MILLIS) {
span /= HOUR_IN_MILLIS;
if (future) {
format = context.getString(R.string.abbreviated_in_hours);
format = R.string.abbreviated_in_hours;
} else {
format = context.getString(R.string.abbreviated_hours_ago);
format = R.string.abbreviated_hours_ago;
}
} else if (span < YEAR) {
span /= DAY;
} else if (span < YEAR_IN_MILLIS) {
span /= DAY_IN_MILLIS;
if (future) {
format = context.getString(R.string.abbreviated_in_days);
format = R.string.abbreviated_in_days;
} else {
format = context.getString(R.string.abbreviated_days_ago);
format = R.string.abbreviated_days_ago;
}
} else {
span /= YEAR;
span /= YEAR_IN_MILLIS;
if (future) {
format = context.getString(R.string.abbreviated_in_years);
format = R.string.abbreviated_in_years;
} else {
format = context.getString(R.string.abbreviated_years_ago);
format = R.string.abbreviated_years_ago;
}
}
return context.getString(format, span);
}
return String.format(format, span);
public static String formatDuration(Context context, long then, long now) {
long span = then - now;
if (span < 0) {
span = 0;
}
int format;
if (span < MINUTE_IN_MILLIS) {
span /= SECOND_IN_MILLIS;
format = R.string.timespan_seconds;
} else if (span < HOUR_IN_MILLIS) {
span /= MINUTE_IN_MILLIS;
format = R.string.timespan_minutes;
} else if (span < DAY_IN_MILLIS) {
span /= HOUR_IN_MILLIS;
format = R.string.timespan_hours;
} else {
span /= DAY_IN_MILLIS;
format = R.string.timespan_days;
}
return context.getString(format, span);
}
}

@ -63,8 +63,8 @@ public final class ViewDataUtils {
SmartLengthInputFilter.LENGTH_DEFAULT
))
.setCollapsed(true)
.setPoll(visibleStatus.getPoll())
.setIsBot(visibleStatus.getAccount().getBot())
.createStatusViewData();
}

@ -23,6 +23,7 @@ import android.text.Spanned;
import com.keylesspalace.tusky.entity.Attachment;
import com.keylesspalace.tusky.entity.Card;
import com.keylesspalace.tusky.entity.Emoji;
import com.keylesspalace.tusky.entity.Poll;
import com.keylesspalace.tusky.entity.Status;
import java.util.ArrayList;
@ -87,6 +88,8 @@ public abstract class StatusViewData {
private final Card card;
private final boolean isCollapsible; /** Whether the status meets the requirement to be collapse */
final boolean isCollapsed; /** Whether the status is shown partially or fully */
@Nullable
private final Poll poll;
private final boolean isBot;
public Concrete(String id, Spanned content, boolean reblogged, boolean favourited,
@ -96,7 +99,8 @@ public abstract class StatusViewData {
Date createdAt, int reblogsCount, int favouritesCount, @Nullable String inReplyToId,
@Nullable Status.Mention[] mentions, String senderId, boolean rebloggingEnabled,
Status.Application application, List<Emoji> statusEmojis, List<Emoji> accountEmojis, @Nullable Card card,
boolean isCollapsible, boolean isCollapsed, boolean isBot) {
boolean isCollapsible, boolean isCollapsed, @Nullable Poll poll, boolean isBot) {
this.id = id;
if (Build.VERSION.SDK_INT == 23) {
// https://github.com/tuskyapp/Tusky/issues/563
@ -132,6 +136,7 @@ public abstract class StatusViewData {
this.card = card;
this.isCollapsible = isCollapsible;
this.isCollapsed = isCollapsed;
this.poll = poll;
this.isBot = isBot;
}
@ -267,6 +272,11 @@ public abstract class StatusViewData {
return isCollapsed;
}
@Nullable
public Poll getPoll() {
return poll;
}
@Override public long getViewDataId() {
// Chance of collision is super low and impact of mistake is low as well
return id.hashCode();
@ -302,7 +312,8 @@ public abstract class StatusViewData {
Objects.equals(application, concrete.application) &&
Objects.equals(statusEmojis, concrete.statusEmojis) &&
Objects.equals(accountEmojis, concrete.accountEmojis) &&
Objects.equals(card, concrete.card)
Objects.equals(card, concrete.card) &&
Objects.equals(poll, concrete.poll)
&& isCollapsed == concrete.isCollapsed;
}
@ -407,6 +418,7 @@ public abstract class StatusViewData {
private Card card;
private boolean isCollapsible; /** Whether the status meets the requirement to be collapsed */
private boolean isCollapsed; /** Whether the status is shown partially or fully */
private Poll poll;
private boolean isBot;
public Builder() {
@ -441,6 +453,7 @@ public abstract class StatusViewData {
card = viewData.getCard();
isCollapsible = viewData.isCollapsible();
isCollapsed = viewData.isCollapsed();
poll = viewData.poll;
isBot = viewData.isBot();
}
@ -603,6 +616,11 @@ public abstract class StatusViewData {
return this;
}
public Builder setPoll(Poll poll) {
this.poll = poll;
return this;
}
public StatusViewData.Concrete createStatusViewData() {
if (this.statusEmojis == null) statusEmojis = Collections.emptyList();
if (this.accountEmojis == null) accountEmojis = Collections.emptyList();
@ -612,7 +630,7 @@ public abstract class StatusViewData {
attachments, rebloggedByUsername, rebloggedAvatar, isSensitive, isExpanded,
isShowingContent, userFullName, nickname, avatar, createdAt, reblogsCount,
favouritesCount, inReplyToId, mentions, senderId, rebloggingEnabled, application,
statusEmojis, accountEmojis, card, isCollapsible, isCollapsed, isBot);
statusEmojis, accountEmojis, card, isCollapsible, isCollapsed, poll, isBot);
}
}
}

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<clip
xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@drawable/poll_option_shape"
android:clipOrientation="horizontal"
android:gravity="left|clip_horizontal|fill_vertical"/>

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape
xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="?attr/pollOptionBackgroundColor" />
</shape>

@ -341,6 +341,149 @@
</androidx.constraintlayout.widget.ConstraintLayout>
<TextView
android:id="@+id/status_poll_option_result_0"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:background="@drawable/poll_option_background"
android:ellipsize="end"
android:lines="1"
android:paddingStart="6dp"
android:paddingTop="2dp"
android:paddingEnd="6dp"
android:paddingBottom="2dp"
android:textColor="?android:attr/textColorPrimary"
android:textSize="?attr/status_text_medium"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@id/status_display_name"
app:layout_constraintTop_toBottomOf="@id/status_media_preview_container"
tools:text="40%" />
<TextView
android:id="@+id/status_poll_option_result_1"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:background="@drawable/poll_option_background"
android:ellipsize="end"
android:lines="1"
android:paddingStart="6dp"
android:paddingTop="2dp"
android:paddingEnd="6dp"
android:paddingBottom="2dp"
android:textColor="?android:attr/textColorPrimary"
android:textSize="?attr/status_text_medium"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@id/status_display_name"
app:layout_constraintTop_toBottomOf="@id/status_poll_option_result_0"
tools:text="10%" />
<TextView
android:id="@+id/status_poll_option_result_2"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:background="@drawable/poll_option_background"
android:ellipsize="end"
android:lines="1"
android:paddingStart="6dp"
android:paddingTop="2dp"
android:paddingEnd="6dp"
android:paddingBottom="2dp"
android:textColor="?android:attr/textColorPrimary"
android:textSize="?attr/status_text_medium"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@id/status_display_name"
app:layout_constraintTop_toBottomOf="@id/status_poll_option_result_1"
tools:text="20%" />
<TextView
android:id="@+id/status_poll_option_result_3"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:background="@drawable/poll_option_background"
android:ellipsize="end"
android:lines="1"
android:paddingStart="6dp"
android:paddingTop="2dp"
android:paddingEnd="6dp"
android:paddingBottom="2dp"
android:textColor="?android:attr/textColorPrimary"
android:textSize="?attr/status_text_medium"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@id/status_display_name"
app:layout_constraintTop_toBottomOf="@id/status_poll_option_result_2"
tools:text="30%" />
<RadioGroup
android:id="@+id/status_poll_radio_group"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@id/status_display_name"
app:layout_constraintTop_toBottomOf="@id/status_poll_option_result_3">
<RadioButton
android:id="@+id/status_poll_radio_button_0"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="?attr/status_text_medium"
tools:text="Option 1" />
<RadioButton
android:id="@+id/status_poll_radio_button_1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="?attr/status_text_medium"
tools:text="Option 2" />
<RadioButton
android:id="@+id/status_poll_radio_button_2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="?attr/status_text_medium"
tools:text="Option 3" />
<RadioButton
android:id="@+id/status_poll_radio_button_3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="?attr/status_text_medium"
tools:text="Option 4" />
</RadioGroup>
<!-- using AppCompatButton because we don't want the inflater to turn it into a MaterialButton -->
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/status_poll_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/content_warning_button"
android:gravity="center"
android:minWidth="150dp"
android:minHeight="0dp"
android:paddingLeft="16dp"
android:paddingTop="4dp"
android:paddingRight="16dp"
android:paddingBottom="4dp"
android:layout_marginTop="4dp"
android:text="@string/poll_vote"
android:textSize="?attr/status_text_medium"
app:layout_constraintStart_toStartOf="@id/status_display_name"
app:layout_constraintTop_toBottomOf="@id/status_poll_radio_group" />
<TextView
android:id="@+id/status_poll_description"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@id/status_display_name"
app:layout_constraintTop_toBottomOf="@id/status_poll_button"
tools:text="7 votes • 7 hours remaining" />
<ImageButton
android:id="@+id/status_reply"
style="?attr/image_button_style"
@ -354,7 +497,7 @@
app:layout_constraintEnd_toStartOf="@id/status_favourite"
app:layout_constraintHorizontal_chainStyle="spread_inside"
app:layout_constraintStart_toStartOf="@id/status_display_name"
app:layout_constraintTop_toBottomOf="@id/status_media_preview_container"
app:layout_constraintTop_toBottomOf="@id/status_poll_description"
app:srcCompat="@drawable/ic_reply_24dp" />
<at.connyduck.sparkbutton.SparkButton

@ -327,6 +327,149 @@
</androidx.constraintlayout.widget.ConstraintLayout>
<TextView
android:id="@+id/status_poll_option_result_0"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:background="@drawable/poll_option_background"
android:ellipsize="end"
android:lines="1"
android:paddingStart="6dp"
android:paddingTop="2dp"
android:paddingEnd="6dp"
android:paddingBottom="2dp"
android:textColor="?android:attr/textColorPrimary"
android:textSize="?attr/status_text_medium"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@id/status_display_name"
app:layout_constraintTop_toBottomOf="@id/status_media_preview_container"
tools:text="40%" />
<TextView
android:id="@+id/status_poll_option_result_1"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:background="@drawable/poll_option_background"
android:ellipsize="end"
android:lines="1"
android:paddingStart="6dp"
android:paddingTop="2dp"
android:paddingEnd="6dp"
android:paddingBottom="2dp"
android:textColor="?android:attr/textColorPrimary"
android:textSize="?attr/status_text_medium"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@id/status_display_name"
app:layout_constraintTop_toBottomOf="@id/status_poll_option_result_0"
tools:text="10%" />
<TextView
android:id="@+id/status_poll_option_result_2"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:background="@drawable/poll_option_background"
android:ellipsize="end"
android:lines="1"
android:paddingStart="6dp"
android:paddingTop="2dp"
android:paddingEnd="6dp"
android:paddingBottom="2dp"
android:textColor="?android:attr/textColorPrimary"
android:textSize="?attr/status_text_medium"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@id/status_display_name"
app:layout_constraintTop_toBottomOf="@id/status_poll_option_result_1"
tools:text="20%" />
<TextView
android:id="@+id/status_poll_option_result_3"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:background="@drawable/poll_option_background"
android:ellipsize="end"
android:lines="1"
android:paddingStart="6dp"
android:paddingTop="2dp"
android:paddingEnd="6dp"
android:paddingBottom="2dp"
android:textColor="?android:attr/textColorPrimary"
android:textSize="?attr/status_text_medium"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@id/status_display_name"
app:layout_constraintTop_toBottomOf="@id/status_poll_option_result_2"
tools:text="30%" />
<RadioGroup
android:id="@+id/status_poll_radio_group"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@id/status_display_name"
app:layout_constraintTop_toBottomOf="@id/status_poll_option_result_3">
<RadioButton
android:id="@+id/status_poll_radio_button_0"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="?attr/status_text_medium"
tools:text="Option 1" />
<RadioButton
android:id="@+id/status_poll_radio_button_1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="?attr/status_text_medium"
tools:text="Option 2" />
<RadioButton
android:id="@+id/status_poll_radio_button_2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="?attr/status_text_medium"
tools:text="Option 3" />
<RadioButton
android:id="@+id/status_poll_radio_button_3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="?attr/status_text_medium"
tools:text="Option 4" />
</RadioGroup>
<!-- using AppCompatButton because we don't want the inflater to turn it into a MaterialButton -->
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/status_poll_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/content_warning_button"
android:gravity="center"
android:minWidth="150dp"
android:minHeight="0dp"
android:paddingLeft="16dp"
android:paddingTop="4dp"
android:paddingRight="16dp"
android:paddingBottom="4dp"
android:layout_marginTop="4dp"
android:text="@string/poll_vote"
android:textSize="?attr/status_text_medium"
app:layout_constraintStart_toStartOf="@id/status_display_name"
app:layout_constraintTop_toBottomOf="@id/status_poll_radio_group" />
<TextView
android:id="@+id/status_poll_description"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@id/status_display_name"
app:layout_constraintTop_toBottomOf="@id/status_poll_button"
tools:text="7 votes • 7 hours remaining" />
<ImageButton
android:id="@+id/status_reply"
style="?attr/image_button_style"
@ -341,7 +484,7 @@
app:layout_constraintEnd_toStartOf="@id/status_inset"
app:layout_constraintHorizontal_chainStyle="spread_inside"
app:layout_constraintStart_toStartOf="@id/status_display_name"
app:layout_constraintTop_toBottomOf="@id/status_media_preview_container"
app:layout_constraintTop_toBottomOf="@id/status_poll_description"
app:srcCompat="@drawable/ic_reply_24dp" />
<at.connyduck.sparkbutton.SparkButton

@ -335,6 +335,151 @@
</androidx.constraintlayout.widget.ConstraintLayout>
<TextView
android:id="@+id/status_poll_option_result_0"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:background="@drawable/poll_option_background"
android:ellipsize="end"
android:lines="1"
android:paddingStart="8dp"
android:paddingTop="4dp"
android:paddingEnd="8dp"
android:paddingBottom="4dp"
android:textColor="?android:attr/textColorPrimary"
android:textSize="?attr/status_text_medium"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/status_media_preview_container"
tools:text="40%" />
<TextView
android:id="@+id/status_poll_option_result_1"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:background="@drawable/poll_option_background"
android:ellipsize="end"
android:lines="1"
android:paddingStart="8dp"
android:paddingTop="4dp"
android:paddingEnd="8dp"
android:paddingBottom="4dp"
android:textColor="?android:attr/textColorPrimary"
android:textSize="?attr/status_text_medium"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/status_poll_option_result_0"
tools:text="10%" />
<TextView
android:id="@+id/status_poll_option_result_2"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:background="@drawable/poll_option_background"
android:ellipsize="end"
android:lines="1"
android:paddingStart="8dp"
android:paddingTop="4dp"
android:paddingEnd="8dp"
android:paddingBottom="4dp"
android:textColor="?android:attr/textColorPrimary"
android:textSize="?attr/status_text_medium"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/status_poll_option_result_1"
tools:text="20%" />
<TextView
android:id="@+id/status_poll_option_result_3"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:background="@drawable/poll_option_background"
android:ellipsize="end"
android:lines="1"
android:paddingStart="8dp"
android:paddingTop="4dp"
android:paddingEnd="8dp"
android:paddingBottom="4dp"
android:textColor="?android:attr/textColorPrimary"
android:textSize="?attr/status_text_medium"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/status_poll_option_result_2"
tools:text="30%" />
<RadioGroup
android:id="@+id/status_poll_radio_group"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/status_poll_option_result_3">
<RadioButton
android:id="@+id/status_poll_radio_button_0"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="?attr/status_text_medium"
tools:text="Option 1" />
<RadioButton
android:id="@+id/status_poll_radio_button_1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="?attr/status_text_medium"
tools:text="Option 2" />
<RadioButton
android:id="@+id/status_poll_radio_button_2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="?attr/status_text_medium"
tools:text="Option 3" />
<RadioButton
android:id="@+id/status_poll_radio_button_3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="?attr/status_text_medium"
tools:text="Option 4" />
</RadioGroup>
<!-- using AppCompatButton because we don't want the inflater to turn it into a MaterialButton -->
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/status_poll_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:background="?attr/content_warning_button"
android:gravity="center"
android:minWidth="150dp"
android:minHeight="0dp"
android:paddingLeft="16dp"
android:paddingTop="4dp"
android:paddingRight="16dp"
android:paddingBottom="4dp"
android:text="@string/poll_vote"
android:textSize="?attr/status_text_medium"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/status_poll_radio_group" />
<TextView
android:id="@+id/status_poll_description"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:textSize="?attr/status_text_medium"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/status_poll_button"
tools:text="7 votes • 7 hours remaining" />
<TextView
android:id="@+id/status_timestamp_info"
android:layout_width="0dp"
@ -346,7 +491,7 @@
android:textSize="?attr/status_text_medium"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@id/status_media_preview_container"
app:layout_constraintTop_toBottomOf="@id/status_poll_description"
tools:text="21 Dec 2018 18:45" />
<View
@ -355,8 +500,8 @@
android:layout_height="1dp"
android:layout_below="@id/status_timestamp_info"
android:layout_marginTop="6dp"
android:importantForAccessibility="no"
android:background="?android:attr/listDivider"
android:importantForAccessibility="no"
android:paddingStart="16dp"
android:paddingEnd="16dp"
app:layout_constraintTop_toBottomOf="@id/status_timestamp_info" />
@ -403,8 +548,8 @@
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="6dp"
android:importantForAccessibility="no"
android:background="?android:attr/listDivider"
android:importantForAccessibility="no"
android:paddingStart="16dp"
android:paddingEnd="16dp"
app:layout_constraintTop_toBottomOf="@id/status_counters_barrier" />

@ -78,6 +78,8 @@
<item name="minTouchTargetSize">32dp</item> <!-- this affects RadioButton size -->
<item name="pollOptionBackgroundColor">@color/color_primary_dark</item>
</style>
<style name="TuskyImageButton.Dark" parent="@style/Widget.MaterialComponents.Button.UnelevatedButton">

@ -45,4 +45,6 @@
<attr name="status_text_medium" format="dimension" />
<attr name="status_text_large" format="dimension" />
<attr name="pollOptionBackgroundColor" format="reference|color" />
</resources>

@ -317,6 +317,12 @@
<string name="abbreviated_minutes_ago">%dm</string>
<string name="abbreviated_seconds_ago">%ds</string>
<!--These are for timestamps on polls -->
<string name="timespan_days">%d days</string>
<string name="timespan_hours">%d hours</string>
<string name="timespan_minutes">%d minutes</string>
<string name="timespan_seconds">%d seconds</string>
<string name="follows_you">Follows you</string>
<string name="pref_title_alway_show_sensitive_media">Always show sensitive content</string>
<string name="title_media">Media</string>
@ -476,4 +482,21 @@
<string name="notification_clear_text">Are you sure you want to permanently clear all your notifications?</string>
<string name="poll_info_format">
<!-- 15 votes • 1 hour left -->
%1$s • %2$s</string>
<plurals name="poll_info_votes">
<item quantity="one">%s vote</item>
<item quantity="other">%s votes</item>
</plurals>
<string name="poll_info_time_relative">%s left</string>
<string name="poll_info_time_absolute">ends at %s</string>
<string name="poll_info_closed">closed</string>
<string name="poll_option_format">
<!-- 15% vote for this! -->
&lt;b>%1$d%%&lt;/b> %2$s</string>
<string name="poll_vote">Vote</string>
</resources>

@ -140,6 +140,8 @@
<item name="minTouchTargetSize">32dp</item> <!-- this affects RadioButton size -->
<item name="pollOptionBackgroundColor">@color/color_primary_dark_light</item>
</style>
<style name="TuskyImageButton.Light" parent="@style/Widget.MaterialComponents.Button.UnelevatedButton">

@ -84,7 +84,8 @@ class BottomSheetActivityTest {
ArrayList(),
arrayOf(),
null,
pinned = false
pinned = false,
poll = null
)
private val statusCallback = FakeSearchResults(status)

@ -305,7 +305,8 @@ class TimelineRepositoryTest {
inReplyToId = null,
pinned = false,
reblog = null,
url = "http://example.com/statuses/$id"
url = "http://example.com/statuses/$id",
poll = null
)
}

Loading…
Cancel
Save