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. 4
      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_5_6, AppDatabase.MIGRATION_6_7, AppDatabase.MIGRATION_7_8,
AppDatabase.MIGRATION_8_9, AppDatabase.MIGRATION_9_10, AppDatabase.MIGRATION_10_11, 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_11_12, AppDatabase.MIGRATION_12_13, AppDatabase.MIGRATION_10_13,
AppDatabase.MIGRATION_13_14) AppDatabase.MIGRATION_13_14, AppDatabase.MIGRATION_14_15)
.build(); .build();
accountManager = new AccountManager(appDatabase); accountManager = new AccountManager(appDatabase);
serviceLocator = new ServiceLocator() { serviceLocator = new ServiceLocator() {

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

@ -7,8 +7,11 @@ import android.text.Spanned;
import android.text.TextUtils; import android.text.TextUtils;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.widget.Button;
import android.widget.ImageButton; import android.widget.ImageButton;
import android.widget.ImageView; import android.widget.ImageView;
import android.widget.RadioButton;
import android.widget.RadioGroup;
import android.widget.TextView; import android.widget.TextView;
import android.widget.ToggleButton; 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.Focus;
import com.keylesspalace.tusky.entity.Attachment.MetaData; import com.keylesspalace.tusky.entity.Attachment.MetaData;
import com.keylesspalace.tusky.entity.Emoji; 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.entity.Status;
import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.util.CustomEmojiHelper; import com.keylesspalace.tusky.util.CustomEmojiHelper;
@ -31,6 +36,7 @@ import com.mikepenz.iconics.utils.Utils;
import java.text.NumberFormat; import java.text.NumberFormat;
import java.text.SimpleDateFormat; import java.text.SimpleDateFormat;
import java.util.Collections;
import java.util.Date; import java.util.Date;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
@ -70,6 +76,12 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
public TextView content; public TextView content;
public TextView contentWarningDescription; public TextView contentWarningDescription;
private TextView[] pollResults;
private TextView pollDescription;
private RadioGroup pollRadioGroup;
private RadioButton[] pollRadioOptions;
private Button pollButton;
private boolean useAbsoluteTime; private boolean useAbsoluteTime;
private SimpleDateFormat shortSdf; private SimpleDateFormat shortSdf;
private SimpleDateFormat longSdf; private SimpleDateFormat longSdf;
@ -109,6 +121,25 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
contentWarningButton = itemView.findViewById(R.id.status_content_warning_button); contentWarningButton = itemView.findViewById(R.id.status_content_warning_button);
avatarInset = itemView.findViewById(R.id.status_avatar_inset); 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; this.useAbsoluteTime = useAbsoluteTime;
shortSdf = new SimpleDateFormat("HH:mm:ss", Locale.getDefault()); shortSdf = new SimpleDateFormat("HH:mm:ss", Locale.getDefault());
longSdf = new SimpleDateFormat("MM/dd 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) { private String getAbsoluteTime(@Nullable Date createdAt) {
String time; String time;
if (createdAt != null) { if (createdAt != null) {
if (System.currentTimeMillis() - createdAt.getTime() > 86400000L) { if (android.text.format.DateUtils.isToday(createdAt.getTime())) {
time = longSdf.format(createdAt);
} else {
time = shortSdf.format(createdAt); time = shortSdf.format(createdAt);
} else {
time = longSdf.format(createdAt);
} }
} else { } else {
time = "??:??:??"; time = "??:??:??";
@ -588,6 +619,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
setSpoilerAndContent(status.isExpanded(), status.getContent(), status.getSpoilerText(), status.getMentions(), status.getStatusEmojis(), listener); setSpoilerAndContent(status.isExpanded(), status.getContent(), status.getSpoilerText(), status.getMentions(), status.getStatusEmojis(), listener);
setContentDescription(status); setContentDescription(status);
setupPoll(status.getPoll(),status.getStatusEmojis(), listener);
// Workaround for RecyclerView 1.0.0 / androidx.core 1.0.0 // Workaround for RecyclerView 1.0.0 / androidx.core 1.0.0
// RecyclerView tries to set AccessibilityDelegateCompat to null // RecyclerView tries to set AccessibilityDelegateCompat to null
// but ViewCompat code replaces is with the default one. RecyclerView never // but ViewCompat code replaces is with the default one. RecyclerView never
@ -717,4 +751,124 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
return ""; 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.TabData
import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
data class FavoriteEvent(val statusId: String, val favourite: Boolean) : Dispatchable data class FavoriteEvent(val statusId: String, val favourite: Boolean) : Dispatchable
@ -13,4 +14,5 @@ data class StatusDeletedEvent(val statusId: String) : Dispatchable
data class StatusComposedEvent(val status: Status) : Dispatchable data class StatusComposedEvent(val status: Status) : Dispatchable
data class ProfileEditedEvent(val newProfileData: Account) : Dispatchable data class ProfileEditedEvent(val newProfileData: Account) : Dispatchable
data class PreferenceChangedEvent(val preferenceKey: String) : Dispatchable data class PreferenceChangedEvent(val preferenceKey: String) : Dispatchable
data class MainTabsChangedEvent(val newTabs: List<TabData>) : 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 showingHiddenContent: Boolean,
val expanded: Boolean, val expanded: Boolean,
val collapsible: 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 */ /** 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 (expanded != other.expanded) return false
if (collapsible != other.collapsible) return false if (collapsible != other.collapsible) return false
if (collapsed != other.collapsed) return false if (collapsed != other.collapsed) return false
if (poll != other.poll) return false
return true return true
} }
@ -127,6 +129,7 @@ data class ConversationStatusEntity(
result = 31 * result + expanded.hashCode() result = 31 * result + expanded.hashCode()
result = 31 * result + collapsible.hashCode() result = 31 * result + collapsible.hashCode()
result = 31 * result + collapsed.hashCode() result = 31 * result + collapsed.hashCode()
result = 31 * result + poll.hashCode()
return result return result
} }
@ -151,7 +154,8 @@ data class ConversationStatusEntity(
attachments = attachments, attachments = attachments,
mentions = mentions, mentions = mentions,
application = null, application = null,
pinned = false) pinned = false,
poll = poll)
} }
} }
@ -172,7 +176,8 @@ fun Status.toEntity() =
false, false,
false, false,
!SmartLengthInputFilter.hasBadRatio(content, SmartLengthInputFilter.LENGTH_DEFAULT), !SmartLengthInputFilter.hasBadRatio(content, SmartLengthInputFilter.LENGTH_DEFAULT),
true true,
poll
) )

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

@ -18,9 +18,11 @@ package com.keylesspalace.tusky.components.conversation
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.preference.PreferenceManager import android.preference.PreferenceManager
import android.util.Log
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders import androidx.lifecycle.ViewModelProviders
import androidx.paging.PagedList 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.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.fragment.SFragment import com.keylesspalace.tusky.fragment.SFragment
import com.keylesspalace.tusky.fragment.SearchFragment
import com.keylesspalace.tusky.interfaces.ReselectableFragment import com.keylesspalace.tusky.interfaces.ReselectableFragment
import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.interfaces.StatusActionListener
import com.keylesspalace.tusky.util.NetworkState import com.keylesspalace.tusky.util.NetworkState
import com.keylesspalace.tusky.util.ThemeUtils import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.hide 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 kotlinx.android.synthetic.main.fragment_timeline.*
import javax.inject.Inject import javax.inject.Inject
@ -187,6 +193,10 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
jumpToTop() jumpToTop()
} }
override fun onVoteInPoll(position: Int, choices: MutableList<Int>) {
viewModel.voteInPoll(position, choices)
}
companion object { companion object {
fun newInstance() = ConversationsFragment() 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) { fun expandHiddenStatus(expanded: Boolean, position: Int) {
conversations.value?.getOrNull(position)?.let { conversation -> conversations.value?.getOrNull(position)?.let { conversation ->
val newConversation = conversation.copy( val newConversation = conversation.copy(

@ -30,7 +30,7 @@ import androidx.annotation.NonNull;
@Database(entities = {TootEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class, @Database(entities = {TootEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class,
TimelineAccountEntity.class, ConversationEntity.class TimelineAccountEntity.class, ConversationEntity.class
}, version = 14) }, version = 15)
public abstract class AppDatabase extends RoomDatabase { public abstract class AppDatabase extends RoomDatabase {
public abstract TootDao tootDao(); 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) { public static final Migration MIGRATION_13_14 = new Migration(13, 14) {
@Override @Override
public void migrate(@NonNull SupportSQLiteDatabase database) { 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 @Override
public void migrate(@NonNull SupportSQLiteDatabase database) { public void migrate(@NonNull SupportSQLiteDatabase database) {
MIGRATION_11_12.migrate(database); database.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `poll` TEXT");
MIGRATION_12_13.migrate(database); 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.createTabDataFromId
import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.json.SpannedTypeAdapter import com.keylesspalace.tusky.json.SpannedTypeAdapter
import com.keylesspalace.tusky.util.HtmlUtils import com.keylesspalace.tusky.util.HtmlUtils
@ -135,4 +136,14 @@ class Converters {
return HtmlUtils.fromHtml(spannedString) 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 mentions: String?,
val application: String?, val application: String?,
val reblogServerId: String?, // if it has a reblogged status, it's id is stored here 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( @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>, @SerializedName("media_attachments") var attachments: ArrayList<Attachment>,
val mentions: Array<Mention>, val mentions: Array<Mention>,
val application: Application?, val application: Application?,
var pinned: Boolean? var pinned: Boolean?,
val poll: Poll?
) { ) {
val actionableId: String val actionableId: String
@ -161,5 +162,6 @@ data class Status(
companion object { companion object {
const val MAX_MEDIA_ATTACHMENTS = 4 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.db.AccountManager;
import com.keylesspalace.tusky.di.Injectable; import com.keylesspalace.tusky.di.Injectable;
import com.keylesspalace.tusky.entity.Notification; import com.keylesspalace.tusky.entity.Notification;
import com.keylesspalace.tusky.entity.Poll;
import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.interfaces.ActionButtonActivity; import com.keylesspalace.tusky.interfaces.ActionButtonActivity;
import com.keylesspalace.tusky.interfaces.ReselectableFragment; import com.keylesspalace.tusky.interfaces.ReselectableFragment;
@ -428,6 +429,24 @@ public class NotificationsFragment extends SFragment implements
updateAdapter(); 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 @Override
public void onMore(@NonNull View view, int position) { public void onMore(@NonNull View view, int position) {
Notification notification = notifications.get(position).asRight(); Notification notification = notifications.get(position).asRight();

@ -232,10 +232,6 @@ class SearchFragment : SFragment(), StatusActionListener {
searchRecyclerView.post { searchAdapter.notifyItemChanged(position, updatedStatus) } searchRecyclerView.post { searchAdapter.notifyItemChanged(position, updatedStatus) }
} }
companion object {
const val TAG = "SearchFragment"
}
override fun onViewAccount(id: String) { override fun onViewAccount(id: String) {
val intent = AccountActivity.getIntent(requireContext(), id) val intent = AccountActivity.getIntent(requireContext(), id)
startActivity(intent) startActivity(intent)
@ -247,4 +243,28 @@ class SearchFragment : SFragment(), StatusActionListener {
startActivity(intent) 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.db.AccountManager;
import com.keylesspalace.tusky.di.Injectable; import com.keylesspalace.tusky.di.Injectable;
import com.keylesspalace.tusky.entity.Filter; import com.keylesspalace.tusky.entity.Filter;
import com.keylesspalace.tusky.entity.Poll;
import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.interfaces.ActionButtonActivity; import com.keylesspalace.tusky.interfaces.ActionButtonActivity;
import com.keylesspalace.tusky.interfaces.ReselectableFragment; import com.keylesspalace.tusky.interfaces.ReselectableFragment;
@ -620,6 +621,34 @@ public class TimelineFragment extends SFragment implements
updateAdapter(); 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 @Override
public void onMore(@NonNull View view, final int position) { public void onMore(@NonNull View view, final int position) {
super.more(statuses.get(position).asRight(), view, 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.appstore.StatusDeletedEvent;
import com.keylesspalace.tusky.di.Injectable; import com.keylesspalace.tusky.di.Injectable;
import com.keylesspalace.tusky.entity.Card; import com.keylesspalace.tusky.entity.Card;
import com.keylesspalace.tusky.entity.Poll;
import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.entity.StatusContext; import com.keylesspalace.tusky.entity.StatusContext;
import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.interfaces.StatusActionListener;
@ -393,6 +394,33 @@ public final class ViewThreadFragment extends SFragment implements
adapter.setStatuses(statuses.getPairedCopy()); 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) { private void removeAllByAccountId(String accountId) {
Status status = null; Status status = null;
if (!statuses.isEmpty()) { if (!statuses.isEmpty()) {

@ -17,6 +17,8 @@ package com.keylesspalace.tusky.interfaces;
import android.view.View; import android.view.View;
import java.util.List;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
@ -58,4 +60,6 @@ public interface StatusActionListener extends LinkListener {
*/ */
default void onShowFavs(int position) {} 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.Instance;
import com.keylesspalace.tusky.entity.MastoList; import com.keylesspalace.tusky.entity.MastoList;
import com.keylesspalace.tusky.entity.Notification; import com.keylesspalace.tusky.entity.Notification;
import com.keylesspalace.tusky.entity.Poll;
import com.keylesspalace.tusky.entity.Relationship; import com.keylesspalace.tusky.entity.Relationship;
import com.keylesspalace.tusky.entity.SearchResults; import com.keylesspalace.tusky.entity.SearchResults;
import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.entity.Status;
@ -382,4 +383,11 @@ public interface MastodonApi {
Call<ResponseBody> deleteFilter( Call<ResponseBody> deleteFilter(
@Path("id") String id @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 package com.keylesspalace.tusky.network
import com.keylesspalace.tusky.appstore.* import com.keylesspalace.tusky.appstore.*
import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Relationship import com.keylesspalace.tusky.entity.Relationship
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import io.reactivex.Single import io.reactivex.Single
@ -25,6 +26,7 @@ import okhttp3.ResponseBody
import retrofit2.Call import retrofit2.Call
import retrofit2.Callback import retrofit2.Callback
import retrofit2.Response import retrofit2.Response
import java.lang.IllegalStateException
/** /**
* Created by charlag on 3/24/18. * Created by charlag on 3/24/18.
@ -37,6 +39,8 @@ interface TimelineCases {
fun block(id: String) fun block(id: String)
fun delete(id: String) fun delete(id: String)
fun pin(status: Status, pin: Boolean) fun pin(status: Status, pin: Boolean)
fun voteInPoll(status: Status, choices: List<Int>): Single<Poll>
} }
class TimelineCasesImpl( class TimelineCasesImpl(
@ -116,4 +120,16 @@ class TimelineCasesImpl(
.addTo(this.cancelDisposable) .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.Gson
import com.google.gson.reflect.TypeToken import com.google.gson.reflect.TypeToken
import com.keylesspalace.tusky.db.* import com.keylesspalace.tusky.db.*
import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.*
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.repository.TimelineRequestMode.DISK import com.keylesspalace.tusky.repository.TimelineRequestMode.DISK
@ -202,6 +200,7 @@ class TimelineRepositoryImpl(
val application = gson.fromJson(status.application, Status.Application::class.java) val application = gson.fromJson(status.application, Status.Application::class.java)
val emojis: List<Emoji> = gson.fromJson(status.emojis, val emojis: List<Emoji> = gson.fromJson(status.emojis,
object : TypeToken<List<Emoji>>() {}.type) ?: listOf() object : TypeToken<List<Emoji>>() {}.type) ?: listOf()
val poll: Poll? = gson.fromJson(status.poll, Poll::class.java)
val reblog = status.reblogServerId?.let { id -> val reblog = status.reblogServerId?.let { id ->
Status( Status(
@ -224,8 +223,8 @@ class TimelineRepositoryImpl(
attachments = attachments, attachments = attachments,
mentions = mentions, mentions = mentions,
application = application, application = application,
pinned = false pinned = false,
poll = poll
) )
} }
val status = if (reblog != null) { val status = if (reblog != null) {
@ -249,7 +248,8 @@ class TimelineRepositoryImpl(
attachments = ArrayList(), attachments = ArrayList(),
mentions = arrayOf(), mentions = arrayOf(),
application = null, application = null,
pinned = false pinned = false,
poll = null
) )
} else { } else {
Status( Status(
@ -272,7 +272,8 @@ class TimelineRepositoryImpl(
attachments = attachments, attachments = attachments,
mentions = mentions, mentions = mentions,
application = application, application = application,
pinned = false pinned = false,
poll = poll
) )
} }
return Either.Right(status) return Either.Right(status)
@ -339,8 +340,8 @@ fun Placeholder.toEntity(timelineUserId: Long): TimelineStatusEntity {
mentions = null, mentions = null,
application = null, application = null,
reblogServerId = null, reblogServerId = null,
reblogAccountId = null reblogAccountId = null,
poll = null
) )
} }
@ -369,7 +370,8 @@ fun Status.toEntity(timelineUserId: Long,
mentions = actionable.mentions.let(gson::toJson), mentions = actionable.mentions.let(gson::toJson),
application = actionable.let(gson::toJson), application = actionable.let(gson::toJson),
reblogServerId = reblog?.id, 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; import com.keylesspalace.tusky.R;
public class DateUtils { 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}, * 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. * but even with the FORMAT_ABBREV_RELATIVE flag it wasn't abbreviating enough.
*/ */
public static String getRelativeTimeSpanString(Context context, long then, long now) { public static String getRelativeTimeSpanString(Context context, long then, long now) {
final long MINUTE = 60; long span = now - then;
final long HOUR = 60 * MINUTE;
final long DAY = 24 * HOUR;
final long YEAR = 365 * DAY;
long span = (now - then) / 1000;
boolean future = false; boolean future = false;
if (span < 0) { if (span < 0) {
future = true; future = true;
span = -span; span = -span;
} }
String format; int format;
if (span < MINUTE) { if (span < MINUTE_IN_MILLIS) {
span /= SECOND_IN_MILLIS;
if (future) { if (future) {
format = context.getString(R.string.abbreviated_in_seconds); format = R.string.abbreviated_in_seconds;
} else { } else {
format = context.getString(R.string.abbreviated_seconds_ago); format = R.string.abbreviated_seconds_ago;
} }
} else if (span < HOUR) { } else if (span < HOUR_IN_MILLIS) {
span /= MINUTE; span /= MINUTE_IN_MILLIS;
if (future) { if (future) {
format = context.getString(R.string.abbreviated_in_minutes); format = R.string.abbreviated_in_minutes;
} else { } else {
format = context.getString(R.string.abbreviated_minutes_ago); format = R.string.abbreviated_minutes_ago;
} }
} else if (span < DAY) { } else if (span < DAY_IN_MILLIS) {
span /= HOUR; span /= HOUR_IN_MILLIS;
if (future) { if (future) {
format = context.getString(R.string.abbreviated_in_hours); format = R.string.abbreviated_in_hours;
} else { } else {
format = context.getString(R.string.abbreviated_hours_ago); format = R.string.abbreviated_hours_ago;
} }
} else if (span < YEAR) { } else if (span < YEAR_IN_MILLIS) {
span /= DAY; span /= DAY_IN_MILLIS;
if (future) { if (future) {
format = context.getString(R.string.abbreviated_in_days); format = R.string.abbreviated_in_days;
} else { } else {
format = context.getString(R.string.abbreviated_days_ago); format = R.string.abbreviated_days_ago;
} }
} else { } else {
span /= YEAR; span /= YEAR_IN_MILLIS;
if (future) { if (future) {
format = context.getString(R.string.abbreviated_in_years); format = R.string.abbreviated_in_years;
} else { } else {
format = context.getString(R.string.abbreviated_years_ago); format = R.string.abbreviated_years_ago;
} }
} }
return String.format(format, span); return context.getString(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 SmartLengthInputFilter.LENGTH_DEFAULT
)) ))
.setCollapsed(true) .setCollapsed(true)
.setPoll(visibleStatus.getPoll())
.setIsBot(visibleStatus.getAccount().getBot()) .setIsBot(visibleStatus.getAccount().getBot())
.createStatusViewData(); .createStatusViewData();
} }

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

@ -327,6 +327,149 @@
</androidx.constraintlayout.widget.ConstraintLayout> </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 <ImageButton
android:id="@+id/status_reply" android:id="@+id/status_reply"
style="?attr/image_button_style" style="?attr/image_button_style"
@ -341,7 +484,7 @@
app:layout_constraintEnd_toStartOf="@id/status_inset" app:layout_constraintEnd_toStartOf="@id/status_inset"
app:layout_constraintHorizontal_chainStyle="spread_inside" app:layout_constraintHorizontal_chainStyle="spread_inside"
app:layout_constraintStart_toStartOf="@id/status_display_name" 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" /> app:srcCompat="@drawable/ic_reply_24dp" />
<at.connyduck.sparkbutton.SparkButton <at.connyduck.sparkbutton.SparkButton

@ -335,6 +335,151 @@
</androidx.constraintlayout.widget.ConstraintLayout> </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 <TextView
android:id="@+id/status_timestamp_info" android:id="@+id/status_timestamp_info"
android:layout_width="0dp" android:layout_width="0dp"
@ -346,7 +491,7 @@
android:textSize="?attr/status_text_medium" android:textSize="?attr/status_text_medium"
app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="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" /> tools:text="21 Dec 2018 18:45" />
<View <View
@ -355,8 +500,8 @@
android:layout_height="1dp" android:layout_height="1dp"
android:layout_below="@id/status_timestamp_info" android:layout_below="@id/status_timestamp_info"
android:layout_marginTop="6dp" android:layout_marginTop="6dp"
android:importantForAccessibility="no"
android:background="?android:attr/listDivider" android:background="?android:attr/listDivider"
android:importantForAccessibility="no"
android:paddingStart="16dp" android:paddingStart="16dp"
android:paddingEnd="16dp" android:paddingEnd="16dp"
app:layout_constraintTop_toBottomOf="@id/status_timestamp_info" /> app:layout_constraintTop_toBottomOf="@id/status_timestamp_info" />
@ -403,8 +548,8 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="1dp" android:layout_height="1dp"
android:layout_marginTop="6dp" android:layout_marginTop="6dp"
android:importantForAccessibility="no"
android:background="?android:attr/listDivider" android:background="?android:attr/listDivider"
android:importantForAccessibility="no"
android:paddingStart="16dp" android:paddingStart="16dp"
android:paddingEnd="16dp" android:paddingEnd="16dp"
app:layout_constraintTop_toBottomOf="@id/status_counters_barrier" /> app:layout_constraintTop_toBottomOf="@id/status_counters_barrier" />

@ -78,6 +78,8 @@
<item name="minTouchTargetSize">32dp</item> <!-- this affects RadioButton size --> <item name="minTouchTargetSize">32dp</item> <!-- this affects RadioButton size -->
<item name="pollOptionBackgroundColor">@color/color_primary_dark</item>
</style> </style>
<style name="TuskyImageButton.Dark" parent="@style/Widget.MaterialComponents.Button.UnelevatedButton"> <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_medium" format="dimension" />
<attr name="status_text_large" format="dimension" /> <attr name="status_text_large" format="dimension" />
<attr name="pollOptionBackgroundColor" format="reference|color" />
</resources> </resources>

@ -317,6 +317,12 @@
<string name="abbreviated_minutes_ago">%dm</string> <string name="abbreviated_minutes_ago">%dm</string>
<string name="abbreviated_seconds_ago">%ds</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="follows_you">Follows you</string>
<string name="pref_title_alway_show_sensitive_media">Always show sensitive content</string> <string name="pref_title_alway_show_sensitive_media">Always show sensitive content</string>
<string name="title_media">Media</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="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> </resources>

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

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

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

Loading…
Cancel
Save