diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/15.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/15.json new file mode 100644 index 00000000..eb57584e --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/15.json @@ -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\")" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.java b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.java index 2144b7d7..c7b37e59 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.java +++ b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.java @@ -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() { diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/SearchResultsAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/SearchResultsAdapter.java index 144f2b82..a9dd5dc4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/SearchResultsAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/SearchResultsAdapter.java @@ -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) { diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java index e3cd009e..4ab22f94 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java @@ -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 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 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); + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt b/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt index 15c5b40e..ecec9412 100644 --- a/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt +++ b/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt @@ -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 @@ -13,4 +14,5 @@ data class StatusDeletedEvent(val statusId: String) : Dispatchable 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) : Dispatchable \ No newline at end of file +data class MainTabsChangedEvent(val newTabs: List) : Dispatchable +data class PollVoteEvent(val statusId: String, val poll: Poll) : Dispatchable \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt index 959c586a..76848310 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt @@ -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 ) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java index 985b51eb..dc34f77e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java @@ -102,6 +102,8 @@ public class ConversationViewHolder extends StatusBaseViewHolder { setAvatars(conversation.getAccounts()); + setupPoll(status.getPoll(), status.getEmojis(), listener); + } private void setConversationName(List accounts) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt index b9933dc8..3c465910 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt @@ -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) { + viewModel.voteInPoll(position, choices) + } + companion object { fun newInstance() = ConversationsFragment() } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt index 9b6c6928..924c5c74 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt @@ -67,6 +67,25 @@ class ConversationsViewModel @Inject constructor( } + fun voteInPoll(position: Int, choices: MutableList) { + 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( diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java index a4c859c9..fe93c752 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java +++ b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java @@ -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,13 +256,6 @@ public abstract class AppDatabase extends RoomDatabase { } }; - public static final Migration MIGRATION_13_14 = new Migration(13, 14) { - @Override - public void migrate(@NonNull SupportSQLiteDatabase database) { - database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `notificationsFilter` TEXT NOT NULL DEFAULT '[]'"); - } - }; - public static final Migration MIGRATION_10_13 = new Migration(10, 13) { @Override public void migrate(@NonNull SupportSQLiteDatabase database) { @@ -271,4 +264,19 @@ public abstract class AppDatabase extends RoomDatabase { } }; + public static final Migration MIGRATION_13_14 = new Migration(13, 14) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `notificationsFilter` TEXT NOT NULL DEFAULT '[]'"); + } + }; + + public static final Migration MIGRATION_14_15 = new Migration(14, 15) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `poll` TEXT"); + database.execSQL("ALTER TABLE `ConversationEntity` ADD COLUMN `s_poll` TEXT"); + } + }; + } \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt b/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt index 21eee4d7..4dad5a08 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt @@ -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) + } + } \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt index bf06b034..1dfe1d1f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt @@ -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( diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Poll.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Poll.kt new file mode 100644 index 00000000..55193404 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Poll.kt @@ -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, + val voted: Boolean +) { + + fun votedCopy(choices: List): 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 +) \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt index f6eba693..2b58d67b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt @@ -39,7 +39,8 @@ data class Status( @SerializedName("media_attachments") var attachments: ArrayList, val mentions: Array, 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 } } diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java index a424c985..c3ae4d2c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java @@ -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 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(); diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/SearchFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/SearchFragment.kt index a2ad598e..b3082101 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/SearchFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/SearchFragment.kt @@ -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) { + 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" + } + } diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java index 3e164c94..57b57821 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java @@ -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 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 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); diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java index e3845e4d..826b2d12 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java @@ -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 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()) { diff --git a/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java b/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java index 480a146a..ec7bcc21 100644 --- a/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java +++ b/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java @@ -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 choices); + } diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java index 2d80c4ad..309f2e68 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java @@ -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 deleteFilter( @Path("id") String id ); + + @FormUrlEncoded + @POST("api/v1/polls/{id}/votes") + Single voteInPoll( + @Path("id") String id, + @Field("choices[]") List choices + ); } diff --git a/app/src/main/java/com/keylesspalace/tusky/network/TimelineCases.kt b/app/src/main/java/com/keylesspalace/tusky/network/TimelineCases.kt index 97ad9b7c..9b1ca9dc 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/TimelineCases.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/TimelineCases.kt @@ -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): Single + } class TimelineCasesImpl( @@ -116,4 +120,16 @@ class TimelineCasesImpl( .addTo(this.cancelDisposable) } + override fun voteInPoll(status: Status, choices: List): Single { + 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)) + } + } + } \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/repository/TimelineRepository.kt b/app/src/main/java/com/keylesspalace/tusky/repository/TimelineRepository.kt index 30093a2a..842efecd 100644 --- a/app/src/main/java/com/keylesspalace/tusky/repository/TimelineRepository.kt +++ b/app/src/main/java/com/keylesspalace/tusky/repository/TimelineRepository.kt @@ -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 = gson.fromJson(status.emojis, object : TypeToken>() {}.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) ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/DateUtils.java b/app/src/main/java/com/keylesspalace/tusky/util/DateUtils.java index 3d32d08a..eed333e4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/DateUtils.java +++ b/app/src/main/java/com/keylesspalace/tusky/util/DateUtils.java @@ -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 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); + } + } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.java b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.java index 6c616885..9a6b2e3a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.java +++ b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.java @@ -63,8 +63,8 @@ public final class ViewDataUtils { SmartLengthInputFilter.LENGTH_DEFAULT )) .setCollapsed(true) + .setPoll(visibleStatus.getPoll()) .setIsBot(visibleStatus.getAccount().getBot()) - .createStatusViewData(); } diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.java b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.java index 18c56381..f4183a9d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.java +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.java @@ -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 statusEmojis, List 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); } } } diff --git a/app/src/main/res/drawable/poll_option_background.xml b/app/src/main/res/drawable/poll_option_background.xml new file mode 100644 index 00000000..48c9d3b6 --- /dev/null +++ b/app/src/main/res/drawable/poll_option_background.xml @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/app/src/main/res/drawable/poll_option_shape.xml b/app/src/main/res/drawable/poll_option_shape.xml new file mode 100644 index 00000000..018d6ea7 --- /dev/null +++ b/app/src/main/res/drawable/poll_option_shape.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_conversation.xml b/app/src/main/res/layout/item_conversation.xml index 3e81f53c..04d40799 100644 --- a/app/src/main/res/layout/item_conversation.xml +++ b/app/src/main/res/layout/item_conversation.xml @@ -341,6 +341,149 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -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" /> diff --git a/app/src/main/res/values-night/styles.xml b/app/src/main/res/values-night/styles.xml index d86235c5..ea7d7be1 100644 --- a/app/src/main/res/values-night/styles.xml +++ b/app/src/main/res/values-night/styles.xml @@ -78,6 +78,8 @@ 32dp + @color/color_primary_dark +