From 51c685249229bf96f22a7a7d60e4730143602a20 Mon Sep 17 00:00:00 2001 From: Konrad Pozniak Date: Thu, 22 Aug 2019 20:30:08 +0200 Subject: [PATCH] Create polls (#1452) * add AddPollDialog * add support for pleroma poll options * add PollPreviewView * add Poll support to drafts * add license header, cleanup * rename drawable files to correct size * fix tests * fix bug with Poll having wrong duration after delete&redraft * add input validation * grey out poll button when its disabled * code cleanup & small improvements --- .../19.json | 711 ++++++++++++++++++ .../keylesspalace/tusky/ComposeActivity.java | 137 +++- .../tusky/SavedTootActivity.java | 1 + .../keylesspalace/tusky/TuskyApplication.java | 2 +- .../tusky/adapter/AddPollOptionsAdapter.kt | 92 +++ .../adapter/PreviewPollOptionsAdapter.kt | 70 ++ .../fragments/SearchStatusesFragment.kt | 1 + .../keylesspalace/tusky/db/AppDatabase.java | 12 +- .../keylesspalace/tusky/db/InstanceEntity.kt | 5 +- .../keylesspalace/tusky/db/TootEntity.java | 26 +- .../keylesspalace/tusky/entity/Instance.kt | 7 +- .../keylesspalace/tusky/entity/NewStatus.kt | 37 + .../com/keylesspalace/tusky/entity/Poll.kt | 8 + .../tusky/fragment/SFragment.java | 12 +- .../tusky/network/MastodonApi.java | 12 +- .../receiver/SendStatusBroadcastReceiver.kt | 1 + .../tusky/service/SendTootService.kt | 23 +- .../tusky/util/SaveTootHelper.java | 25 +- .../keylesspalace/tusky/view/AddPollDialog.kt | 102 +++ .../tusky/view/PollPreviewView.kt | 64 ++ .../ic_check_box_outline_blank_18dp.xml | 9 + .../ic_radio_button_unchecked_18dp.xml | 9 + app/src/main/res/layout/activity_compose.xml | 9 + app/src/main/res/layout/dialog_add_poll.xml | 57 ++ .../main/res/layout/item_add_poll_option.xml | 32 + .../res/layout/item_poll_preview_option.xml | 10 + app/src/main/res/layout/view_poll_preview.xml | 37 + app/src/main/res/values-ar/strings.xml | 2 +- app/src/main/res/values-bn-rIN/strings.xml | 2 +- app/src/main/res/values-ca/strings.xml | 2 +- app/src/main/res/values-cs/strings.xml | 2 +- app/src/main/res/values-cy/strings.xml | 2 +- app/src/main/res/values-de/strings.xml | 2 +- app/src/main/res/values-eo/strings.xml | 2 +- app/src/main/res/values-es/strings.xml | 2 +- app/src/main/res/values-eu/strings.xml | 2 +- app/src/main/res/values-fa/strings.xml | 2 +- app/src/main/res/values-fr/strings.xml | 2 +- app/src/main/res/values-hu/strings.xml | 2 +- app/src/main/res/values-it/strings.xml | 2 +- app/src/main/res/values-ja/strings.xml | 2 +- app/src/main/res/values-ko/strings.xml | 2 +- app/src/main/res/values-nl/strings.xml | 2 +- app/src/main/res/values-no-rNB/strings.xml | 2 +- app/src/main/res/values-oc/strings.xml | 2 +- app/src/main/res/values-pl/strings.xml | 2 +- app/src/main/res/values-pt-rBR/strings.xml | 2 +- app/src/main/res/values-ru/strings.xml | 2 +- app/src/main/res/values-sl/strings.xml | 2 +- app/src/main/res/values-sv/strings.xml | 2 +- app/src/main/res/values-ta/strings.xml | 2 +- app/src/main/res/values-tr/strings.xml | 2 +- app/src/main/res/values-zh-rCN/strings.xml | 2 +- app/src/main/res/values-zh-rHK/strings.xml | 2 +- app/src/main/res/values-zh-rMO/strings.xml | 2 +- app/src/main/res/values-zh-rSG/strings.xml | 2 +- app/src/main/res/values-zh-rTW/strings.xml | 2 +- app/src/main/res/values/dimens.xml | 2 + app/src/main/res/values/donottranslate.xml | 23 + app/src/main/res/values/strings.xml | 18 +- .../tusky/ComposeActivityTest.kt | 2 +- 61 files changed, 1540 insertions(+), 76 deletions(-) create mode 100644 app/schemas/com.keylesspalace.tusky.db.AppDatabase/19.json create mode 100644 app/src/main/java/com/keylesspalace/tusky/adapter/AddPollOptionsAdapter.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/adapter/PreviewPollOptionsAdapter.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/entity/NewStatus.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/view/AddPollDialog.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/view/PollPreviewView.kt create mode 100644 app/src/main/res/drawable/ic_check_box_outline_blank_18dp.xml create mode 100644 app/src/main/res/drawable/ic_radio_button_unchecked_18dp.xml create mode 100644 app/src/main/res/layout/dialog_add_poll.xml create mode 100644 app/src/main/res/layout/item_add_poll_option.xml create mode 100644 app/src/main/res/layout/item_poll_preview_option.xml create mode 100644 app/src/main/res/layout/view_poll_preview.xml diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/19.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/19.json new file mode 100644 index 000000000..0d62b126e --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/19.json @@ -0,0 +1,711 @@ +{ + "formatVersion": 1, + "database": { + "version": 19, + "identityHash": "84ebd39cba4d6749251d330851b70e36", + "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, `poll` TEXT)", + "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 + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "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, `notificationsPolls` 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, `alwaysOpenSpoiler` 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": "notificationsPolls", + "columnName": "notificationsPolls", + "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": "alwaysOpenSpoiler", + "columnName": "alwaysOpenSpoiler", + "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, `maxPollOptions` INTEGER, `maxPollOptionLength` 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 + }, + { + "fieldPath": "maxPollOptions", + "columnName": "maxPollOptions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptionLength", + "columnName": "maxPollOptionLength", + "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, `bot` INTEGER 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 + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER", + "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": [] + } + ], + "views": [], + "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, '84ebd39cba4d6749251d330851b70e36')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java b/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java index 5b53b0e66..a626018e9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java @@ -79,6 +79,7 @@ import com.keylesspalace.tusky.entity.Account; import com.keylesspalace.tusky.entity.Attachment; import com.keylesspalace.tusky.entity.Emoji; import com.keylesspalace.tusky.entity.Instance; +import com.keylesspalace.tusky.entity.NewPoll; import com.keylesspalace.tusky.entity.SearchResults; import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.network.MastodonApi; @@ -94,9 +95,11 @@ import com.keylesspalace.tusky.util.SaveTootHelper; import com.keylesspalace.tusky.util.SpanUtilsKt; import com.keylesspalace.tusky.util.StringUtils; import com.keylesspalace.tusky.util.ThemeUtils; +import com.keylesspalace.tusky.view.AddPollDialog; import com.keylesspalace.tusky.view.ComposeOptionsListener; import com.keylesspalace.tusky.view.ComposeOptionsView; import com.keylesspalace.tusky.view.EditTextTyped; +import com.keylesspalace.tusky.view.PollPreviewView; import com.keylesspalace.tusky.view.ProgressImageView; import com.keylesspalace.tusky.view.TootButton; import com.mikepenz.google_material_typeface_library.GoogleMaterial; @@ -190,6 +193,7 @@ public final class ComposeActivity private static final String REPLYING_STATUS_CONTENT_EXTRA = "replying_status_content"; private static final String MEDIA_ATTACHMENTS_EXTRA = "media_attachments"; private static final String SENSITIVE_EXTRA = "sensitive"; + private static final String POLL_EXTRA = "poll"; // Mastodon only counts URLs as this long in terms of status character limits static final int MAXIMUM_URL_LENGTH = 23; // https://github.com/tootsuite/mastodon/blob/1656663/app/models/media_attachment.rb#L94 @@ -213,6 +217,7 @@ public final class ComposeActivity private ImageButton contentWarningButton; private ImageButton emojiButton; private ImageButton hideMediaToggle; + private TextView actionAddPoll; private Button atButton; private Button hashButton; @@ -222,11 +227,14 @@ public final class ComposeActivity private BottomSheetBehavior emojiBehavior; private RecyclerView emojiView; + private PollPreviewView pollPreview; + // this only exists when a status is trying to be sent, but uploads are still occurring private ProgressDialog finishingUploadDialog; private String inReplyToId; private List mediaQueued = new ArrayList<>(); private CountUpDownLatch waitForMediaLatch; + private NewPoll poll; private Status.Visibility statusVisibility; // The current values of the options that will be applied private boolean statusMarkSensitive; // to the status being composed. private boolean statusHideText; @@ -239,6 +247,8 @@ public final class ComposeActivity private List emojiList; private CountDownLatch emojiListRetrievalLatch = new CountDownLatch(1); private int maximumTootCharacters = STATUS_CHARACTER_LIMIT; + private Integer maxPollOptions = null; + private Integer maxPollOptionLength = null; private @Px int thumbnailViewSize; @@ -369,6 +379,7 @@ public final class ComposeActivity TextView actionPhotoTake = findViewById(R.id.action_photo_take); TextView actionPhotoPick = findViewById(R.id.action_photo_pick); + actionAddPoll = findViewById(R.id.action_add_poll); int textColor = ThemeUtils.getColor(this, android.R.attr.textColorTertiary); @@ -378,8 +389,12 @@ public final class ComposeActivity Drawable imageIcon = new IconicsDrawable(this, GoogleMaterial.Icon.gmd_image).color(textColor).sizeDp(18); actionPhotoPick.setCompoundDrawablesRelativeWithIntrinsicBounds(imageIcon, null, null, null); + Drawable pollIcon = new IconicsDrawable(this, GoogleMaterial.Icon.gmd_poll).color(textColor).sizeDp(18); + actionAddPoll.setCompoundDrawablesRelativeWithIntrinsicBounds(pollIcon, null, null, null); + actionPhotoTake.setOnClickListener(v -> initiateCameraApp()); actionPhotoPick.setOnClickListener(v -> onMediaPick()); + actionAddPoll.setOnClickListener(v -> openPollDialog()); thumbnailViewSize = getResources().getDimensionPixelSize(R.dimen.compose_media_preview_size); @@ -507,6 +522,14 @@ public final class ComposeActivity } statusMarkSensitive = intent.getBooleanExtra(SENSITIVE_EXTRA, statusMarkSensitive); + + if(intent.hasExtra(POLL_EXTRA) && (mediaAttachments == null || mediaAttachments.size() == 0)) { + updatePoll(intent.getParcelableExtra(POLL_EXTRA)); + } + + if(mediaAttachments != null && mediaAttachments.size() > 0) { + enablePollButton(false); + } } // After the starting state is finalised, the interface can be set to reflect this state. @@ -901,6 +924,62 @@ public final class ComposeActivity addMediaBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED); } + private void openPollDialog() { + addMediaBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED); + AddPollDialog.showAddPollDialog(this, poll, maxPollOptions, maxPollOptionLength); + } + + public void updatePoll(NewPoll poll) { + this.poll = poll; + + enableButton(pickButton, false, false); + + if(pollPreview == null) { + + pollPreview = new PollPreviewView(this); + + Resources resources = getResources(); + int margin = resources.getDimensionPixelSize(R.dimen.compose_media_preview_margin); + int marginBottom = resources.getDimensionPixelSize(R.dimen.compose_media_preview_margin_bottom); + + LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); + layoutParams.setMargins(margin, margin, margin, marginBottom); + pollPreview.setLayoutParams(layoutParams); + + mediaPreviewBar.addView(pollPreview); + + pollPreview.setOnClickListener(v -> { + PopupMenu popup = new PopupMenu(this, pollPreview); + final int editId = 1; + final int removeId = 2; + popup.getMenu().add(0, editId, 0, R.string.edit_poll); + popup.getMenu().add(0, removeId, 0, R.string.action_remove); + popup.setOnMenuItemClickListener(menuItem -> { + switch (menuItem.getItemId()) { + case editId: + openPollDialog(); + break; + case removeId: + removePoll(); + break; + } + return true; + }); + popup.show(); + }); + } + + pollPreview.setPoll(poll); + + } + + private void removePoll() { + poll = null; + pollPreview = null; + enableButton(pickButton, true, true); + mediaPreviewBar.removeAllViews(); + } + @Override public void onVisibilityChanged(@NonNull Status.Visibility visibility) { composeOptionsBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED); @@ -1005,7 +1084,7 @@ public final class ComposeActivity } Intent sendIntent = SendTootService.sendTootIntent(this, content, spoilerText, - visibility, !mediaUris.isEmpty() && sensitive, mediaIds, mediaUris, mediaDescriptions, inReplyToId, + visibility, !mediaUris.isEmpty() && sensitive, mediaIds, mediaUris, mediaDescriptions, inReplyToId, poll, getIntent().getStringExtra(REPLYING_STATUS_CONTENT_EXTRA), getIntent().getStringExtra(REPLYING_STATUS_AUTHOR_USERNAME_EXTRA), getIntent().getStringExtra(SAVED_JSON_URLS_EXTRA), @@ -1162,6 +1241,18 @@ public final class ComposeActivity colorActive ? android.R.attr.textColorTertiary : R.attr.compose_media_button_disabled_tint); } + private void enablePollButton(boolean enable) { + actionAddPoll.setEnabled(enable); + int textColor; + if(enable) { + textColor = ThemeUtils.getColor(this, android.R.attr.textColorTertiary); + } else { + textColor = ThemeUtils.getColor(this, R.attr.compose_media_button_disabled_tint); + } + actionAddPoll.setTextColor(textColor); + actionAddPoll.getCompoundDrawablesRelative()[0].setColorFilter(textColor, PorterDuff.Mode.SRC_IN); + } + private void addMediaToQueue(QueuedMedia.Type type, Bitmap preview, Uri uri, long mediaSize, @Nullable String description) { addMediaToQueue(null, type, preview, uri, mediaSize, null, description); } @@ -1210,6 +1301,7 @@ public final class ComposeActivity } updateHideMediaToggle(); + enablePollButton(false); if (item.readyStage != QueuedMedia.ReadyStage.UPLOADED) { waitForMediaLatch.countUp(); @@ -1259,7 +1351,7 @@ public final class ComposeActivity final int addCaptionId = 1; final int removeId = 2; popup.getMenu().add(0, addCaptionId, 0, R.string.action_set_caption); - popup.getMenu().add(0, removeId, 0, R.string.action_remove_media); + popup.getMenu().add(0, removeId, 0, R.string.action_remove); popup.setOnMenuItemClickListener(menuItem -> { switch (menuItem.getItemId()) { case addCaptionId: @@ -1378,6 +1470,7 @@ public final class ComposeActivity mediaQueued.remove(item); if (mediaQueued.size() == 0) { updateHideMediaToggle(); + enablePollButton(true); } updateContentDescriptionForAllImages(); enableButton(pickButton, true, true); @@ -1685,8 +1778,9 @@ public final class ComposeActivity boolean contentWarningChanged = contentWarningBar.getVisibility() == View.VISIBLE && !TextUtils.isEmpty(contentWarning) && !startingContentWarning.startsWith(contentWarning.toString()); boolean mediaChanged = !mediaQueued.isEmpty(); + boolean pollChanged = poll != null; - if (textChanged || contentWarningChanged || mediaChanged) { + if (textChanged || contentWarningChanged || mediaChanged || pollChanged) { new AlertDialog.Builder(this) .setMessage(R.string.compose_save_draft) .setPositiveButton(R.string.action_save, (d, w) -> saveDraftAndFinish()) @@ -1722,7 +1816,8 @@ public final class ComposeActivity inReplyToId, getIntent().getStringExtra(REPLYING_STATUS_CONTENT_EXTRA), getIntent().getStringExtra(REPLYING_STATUS_AUTHOR_USERNAME_EXTRA), - statusVisibility); + statusVisibility, + poll); finishWithoutSlideOutAnimation(); } @@ -1808,6 +1903,8 @@ public final class ComposeActivity if (instanceEntity != null) { Integer max = instanceEntity.getMaximumTootCharacters(); maximumTootCharacters = (max == null ? STATUS_CHARACTER_LIMIT : max); + maxPollOptions = instanceEntity.getMaxPollOptions(); + maxPollOptionLength = instanceEntity.getMaxPollOptionLength(); setEmojiList(instanceEntity.getEmojiList()); updateVisibleCharactersLeft(); } @@ -1825,7 +1922,9 @@ public final class ComposeActivity } private void cacheInstanceMetadata(@NotNull AccountEntity activeAccount) { - InstanceEntity instanceEntity = new InstanceEntity(activeAccount.getDomain(), emojiList, maximumTootCharacters); + InstanceEntity instanceEntity = new InstanceEntity( + activeAccount.getDomain(), emojiList, maximumTootCharacters, maxPollOptions, maxPollOptionLength + ); database.instanceDao().insertOrReplace(instanceEntity); } @@ -1840,9 +1939,18 @@ public final class ComposeActivity } private void onFetchInstanceSuccess(Instance instance) { - if (instance != null && instance.getMaxTootChars() != null) { - maximumTootCharacters = instance.getMaxTootChars(); - updateVisibleCharactersLeft(); + if (instance != null) { + + if (instance.getMaxTootChars() != null) { + maximumTootCharacters = instance.getMaxTootChars(); + updateVisibleCharactersLeft(); + } + + if (instance.getPollLimits() != null) { + maxPollOptions = instance.getPollLimits().getMaxOptions(); + maxPollOptionLength = instance.getPollLimits().getMaxOptionChars(); + } + cacheInstanceMetadata(accountManager.getActiveAccount()); } } @@ -1966,7 +2074,8 @@ public final class ComposeActivity private ArrayList mediaAttachments; @Nullable private Boolean sensitive; - + @Nullable + private NewPoll poll; public IntentBuilder savedTootUid(int uid) { this.savedTootUid = uid; @@ -2033,6 +2142,11 @@ public final class ComposeActivity return this; } + public IntentBuilder poll(NewPoll poll) { + this.poll = poll; + return this; + } + public Intent build(Context context) { Intent intent = new Intent(context, ComposeActivity.class); @@ -2073,9 +2187,12 @@ public final class ComposeActivity if (mediaAttachments != null) { intent.putParcelableArrayListExtra(MEDIA_ATTACHMENTS_EXTRA, mediaAttachments); } - if(sensitive != null) { + if (sensitive != null) { intent.putExtra(SENSITIVE_EXTRA, sensitive); } + if (poll != null) { + intent.putExtra(POLL_EXTRA, poll); + } return intent; } } diff --git a/app/src/main/java/com/keylesspalace/tusky/SavedTootActivity.java b/app/src/main/java/com/keylesspalace/tusky/SavedTootActivity.java index 4cc67b8f1..8bf6565bb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/SavedTootActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/SavedTootActivity.java @@ -163,6 +163,7 @@ public final class SavedTootActivity extends BaseActivity implements SavedTootAd .replyingStatusAuthor(item.getInReplyToUsername()) .replyingStatusContent(item.getInReplyToText()) .visibility(item.getVisibility()) + .poll(item.getPoll()) .build(this); startActivity(intent); } diff --git a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.java b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.java index 63178026f..b9092fe7f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.java +++ b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.java @@ -68,7 +68,7 @@ public class TuskyApplication extends Application implements HasAndroidInjector 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_14_15, AppDatabase.MIGRATION_15_16, - AppDatabase.MIGRATION_16_17, AppDatabase.MIGRATION_17_18) + AppDatabase.MIGRATION_16_17, AppDatabase.MIGRATION_17_18, AppDatabase.MIGRATION_18_19) .build(); accountManager = new AccountManager(appDatabase); serviceLocator = new ServiceLocator() { diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/AddPollOptionsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/AddPollOptionsAdapter.kt new file mode 100644 index 000000000..178288748 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/AddPollOptionsAdapter.kt @@ -0,0 +1,92 @@ +/* Copyright 2019 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.adapter + +import android.text.InputFilter +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageButton +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.textfield.TextInputEditText +import com.google.android.material.textfield.TextInputLayout +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.util.onTextChanged +import com.keylesspalace.tusky.util.visible + +class AddPollOptionsAdapter( + private var options: MutableList, + private val maxOptionLength: Int, + private val onOptionRemoved: () -> Unit, + private val onOptionChanged: (Boolean) -> Unit +): RecyclerView.Adapter() { + + val pollOptions: List + get() = options.toList() + + fun addChoice() { + options.add("") + notifyItemInserted(options.size - 1) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val holder = ViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.item_add_poll_option, parent, false)) + holder.editText.filters = arrayOf(InputFilter.LengthFilter(maxOptionLength)) + + holder.editText.onTextChanged { s, _, _, _ -> + val pos = holder.adapterPosition + if(pos != RecyclerView.NO_POSITION) { + options[pos] = s.toString() + onOptionChanged(validateInput()) + } + } + + return holder + } + + override fun getItemCount() = options.size + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.editText.setText(options[position]) + + holder.textInputLayout.hint = holder.textInputLayout.context.getString(R.string.poll_new_choice_hint, position + 1) + + holder.deleteButton.visible(position > 1, View.INVISIBLE) + + holder.deleteButton.setOnClickListener { + holder.editText.clearFocus() + options.removeAt(holder.adapterPosition) + notifyItemRemoved(holder.adapterPosition) + onOptionRemoved() + } + } + + private fun validateInput(): Boolean { + if (options.contains("") || options.distinct().size != options.size) { + return false + } + + return true + } + +} + + +class ViewHolder(itemView: View): RecyclerView.ViewHolder(itemView) { + val textInputLayout: TextInputLayout = itemView.findViewById(R.id.optionTextInputLayout) + val editText: TextInputEditText = itemView.findViewById(R.id.optionEditText) + val deleteButton: ImageButton = itemView.findViewById(R.id.deleteButton) +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/PreviewPollOptionsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/PreviewPollOptionsAdapter.kt new file mode 100644 index 000000000..bb77cd274 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/PreviewPollOptionsAdapter.kt @@ -0,0 +1,70 @@ +/* Copyright 2019 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.adapter + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.core.widget.TextViewCompat +import androidx.recyclerview.widget.RecyclerView +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.util.ThemeUtils + +class PreviewPollOptionsAdapter: RecyclerView.Adapter() { + + private var options: List = emptyList() + private var multiple: Boolean = false + private var clickListener: View.OnClickListener? = null + + fun update(newOptions: List, multiple: Boolean) { + this.options = newOptions + this.multiple = multiple + notifyDataSetChanged() + } + + fun setOnClickListener(l: View.OnClickListener?) { + clickListener = l + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PreviewViewHolder { + return PreviewViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.item_poll_preview_option, parent, false)) + } + + override fun getItemCount() = options.size + + override fun onBindViewHolder(holder: PreviewViewHolder, position: Int) { + val textView = holder.itemView as TextView + + val iconId = if (multiple) { + R.drawable.ic_check_box_outline_blank_18dp + } else { + R.drawable.ic_radio_button_unchecked_18dp + } + + val iconDrawable = ThemeUtils.getTintedDrawable(textView.context, iconId, android.R.attr.textColorTertiary) + + TextViewCompat.setCompoundDrawablesRelativeWithIntrinsicBounds(textView, iconDrawable, null, null, null) + + textView.text = options[position] + + textView.setOnClickListener(clickListener) + } + +} + + +class PreviewViewHolder(itemView: View): RecyclerView.ViewHolder(itemView) \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt index a39d7cdef..bada1fa24 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt @@ -398,6 +398,7 @@ class SearchStatusesFragment : SearchFragment?, - val maximumTootCharacters: Int?) + val maximumTootCharacters: Int?, + val maxPollOptions: Int?, + val maxPollOptionLength: Int? +) diff --git a/app/src/main/java/com/keylesspalace/tusky/db/TootEntity.java b/app/src/main/java/com/keylesspalace/tusky/db/TootEntity.java index 49ebef71a..b4b258d5c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/TootEntity.java +++ b/app/src/main/java/com/keylesspalace/tusky/db/TootEntity.java @@ -15,6 +15,8 @@ package com.keylesspalace.tusky.db; +import com.google.gson.Gson; +import com.keylesspalace.tusky.entity.NewPoll; import com.keylesspalace.tusky.entity.Status; import androidx.annotation.Nullable; @@ -60,9 +62,13 @@ public class TootEntity { @ColumnInfo(name = "visibility") private final Status.Visibility visibility; + @Nullable + @ColumnInfo(name = "poll") + private final NewPoll poll; + public TootEntity(int uid, String text, String urls, String descriptions, String contentWarning, String inReplyToId, @Nullable String inReplyToText, @Nullable String inReplyToUsername, - Status.Visibility visibility) { + Status.Visibility visibility, @Nullable NewPoll poll) { this.uid = uid; this.text = text; this.urls = urls; @@ -72,6 +78,7 @@ public class TootEntity { this.inReplyToText = inReplyToText; this.inReplyToUsername = inReplyToUsername; this.visibility = visibility; + this.poll = poll; } public String getText() { @@ -112,8 +119,15 @@ public class TootEntity { return visibility; } + @Nullable + public NewPoll getPoll() { + return poll; + } + public static final class Converters { + private static final Gson gson = new Gson(); + @TypeConverter public Status.Visibility visibilityFromInt(int number) { return Status.Visibility.byNum(number); @@ -123,5 +137,15 @@ public class TootEntity { public int intFromVisibility(Status.Visibility visibility) { return visibility == null ? Status.Visibility.UNKNOWN.getNum() : visibility.getNum(); } + + @TypeConverter + public String pollToString(NewPoll poll) { + return gson.toJson(poll); + } + + @TypeConverter + public NewPoll stringToPoll(String poll) { + return gson.fromJson(poll, NewPoll.class); + } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt index ef0933f1d..a9f6f499c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt @@ -29,7 +29,8 @@ data class Instance ( val languages: List, @SerializedName("contact_account") val contactAccount: Account, @SerializedName("max_toot_chars") val maxTootChars: Int?, - @SerializedName("max_bio_chars") val maxBioChars: Int? + @SerializedName("max_bio_chars") val maxBioChars: Int?, + @SerializedName("poll_limits") val pollLimits: PollLimits? ) { override fun hashCode(): Int { return uri.hashCode() @@ -44,3 +45,7 @@ data class Instance ( } } +data class PollLimits ( + @SerializedName("max_options") val maxOptions: Int?, + @SerializedName("max_option_chars") val maxOptionChars: Int? +) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/NewStatus.kt b/app/src/main/java/com/keylesspalace/tusky/entity/NewStatus.kt new file mode 100644 index 000000000..1a3226fad --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/NewStatus.kt @@ -0,0 +1,37 @@ +/* Copyright 2019 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.entity + +import android.os.Parcelable +import com.google.gson.annotations.SerializedName +import kotlinx.android.parcel.Parcelize + +data class NewStatus( + val status: String, + @SerializedName("spoiler_text") val warningText: String, + @SerializedName("in_reply_to_id") val inReplyToId: String?, + val visibility: String, + val sensitive: Boolean, + @SerializedName("media_ids") val mediaIds: List?, + val poll: NewPoll? +) + +@Parcelize +data class NewPoll( + val options: List, + @SerializedName("expires_in") val expiresIn: Int, + val multiple: Boolean +): Parcelable \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Poll.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Poll.kt index b5a1fa762..57361d1b0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Poll.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Poll.kt @@ -25,6 +25,14 @@ data class Poll( return copy(options = newOptions, votesCount = votesCount + choices.size, voted = true) } + fun toNewPoll(creationDate: Date) = NewPoll( + options.map { it.title }, + expiresAt?.let { + ((it.time - creationDate.time) / 1000).toInt() + 1 + }?: 3600, + multiple + ) + } data class PollOption( diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java index d2c174383..1efaf1807 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java @@ -364,14 +364,18 @@ public abstract class SFragment extends BaseFragment implements Injectable { timelineCases.delete(id); removeItem(position); - Intent intent = new ComposeActivity.IntentBuilder() + ComposeActivity.IntentBuilder intentBuilder = new ComposeActivity.IntentBuilder() .tootText(getEditableText(status.getContent(), status.getMentions())) .inReplyToId(status.getInReplyToId()) .visibility(status.getVisibility()) .contentWarning(status.getSpoilerText()) .mediaAttachments(status.getAttachments()) - .sensitive(status.getSensitive()) - .build(getContext()); + .sensitive(status.getSensitive()); + if(status.getPoll() != null) { + intentBuilder.poll(status.getPoll().toNewPoll(status.getCreatedAt())); + } + + Intent intent = intentBuilder.build(getContext()); startActivity(intent); }) .setNegativeButton(android.R.string.cancel, null) @@ -470,7 +474,7 @@ public abstract class SFragment extends BaseFragment implements Injectable { boolean shouldFilterStatus(Status status) { return (filterRemoveRegex && (filterRemoveRegexMatcher.reset(status.getActionableStatus().getContent()).find() - || (!status.getSpoilerText().isEmpty() && filterRemoveRegexMatcher.reset(status.getActionableStatus().getSpoilerText()).find()))); + || (!status.getSpoilerText().isEmpty() && filterRemoveRegexMatcher.reset(status.getActionableStatus().getSpoilerText()).find()))); } private void applyFilters(boolean refresh) { 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 043367c6b..4f4df9057 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java @@ -24,6 +24,7 @@ import com.keylesspalace.tusky.entity.Emoji; import com.keylesspalace.tusky.entity.Filter; import com.keylesspalace.tusky.entity.Instance; import com.keylesspalace.tusky.entity.MastoList; +import com.keylesspalace.tusky.entity.NewStatus; import com.keylesspalace.tusky.entity.Notification; import com.keylesspalace.tusky.entity.Poll; import com.keylesspalace.tusky.entity.Relationship; @@ -43,6 +44,7 @@ import okhttp3.RequestBody; import okhttp3.ResponseBody; import retrofit2.Call; import retrofit2.Response; +import retrofit2.http.Body; import retrofit2.http.DELETE; import retrofit2.http.Field; import retrofit2.http.FormUrlEncoded; @@ -126,18 +128,12 @@ public interface MastodonApi { Call updateMedia(@Path("mediaId") String mediaId, @Field("description") String description); - @FormUrlEncoded @POST("api/v1/statuses") Call createStatus( @Header("Authorization") String auth, @Header(DOMAIN_HEADER) String domain, - @Field("status") String text, - @Field("in_reply_to_id") String inReplyToId, - @Field("spoiler_text") String warningText, - @Field("visibility") String visibility, - @Field("sensitive") Boolean sensitive, - @Field("media_ids[]") List mediaIds, - @Header("Idempotency-Key") String idempotencyKey); + @Header("Idempotency-Key") String idempotencyKey, + @Body NewStatus status); @GET("api/v1/statuses/{id}") Call status(@Path("id") String statusId); diff --git a/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt b/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt index 6942f4989..7ae1f6dff 100644 --- a/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt +++ b/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt @@ -95,6 +95,7 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() { citedStatusId, null, null, + null, null, account, 0) context.startService(sendIntent) diff --git a/app/src/main/java/com/keylesspalace/tusky/service/SendTootService.kt b/app/src/main/java/com/keylesspalace/tusky/service/SendTootService.kt index e1e5c6975..ba4d0b982 100644 --- a/app/src/main/java/com/keylesspalace/tusky/service/SendTootService.kt +++ b/app/src/main/java/com/keylesspalace/tusky/service/SendTootService.kt @@ -22,6 +22,8 @@ import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.entity.NewPoll +import com.keylesspalace.tusky.entity.NewStatus import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.SaveTootHelper @@ -131,16 +133,21 @@ class SendTootService : Service(), Injectable { tootToSend.retries++ - val sendCall = mastodonApi.createStatus( - "Bearer " + account.accessToken, - account.domain, + val newStatus = NewStatus( tootToSend.text, - tootToSend.inReplyToId, tootToSend.warningText, + tootToSend.inReplyToId, tootToSend.visibility, tootToSend.sensitive, tootToSend.mediaIds, - tootToSend.idempotencyKey + tootToSend.poll + ) + + val sendCall = mastodonApi.createStatus( + "Bearer " + account.accessToken, + account.domain, + tootToSend.idempotencyKey, + newStatus ) @@ -243,7 +250,8 @@ class SendTootService : Service(), Injectable { toot.inReplyToId, toot.replyingStatusContent, toot.replyingStatusAuthorUsername, - Status.Visibility.byString(toot.visibility)) + Status.Visibility.byString(toot.visibility), + toot.poll) } private fun cancelSendingIntent(tootId: Int): PendingIntent { @@ -277,6 +285,7 @@ class SendTootService : Service(), Injectable { mediaUris: List, mediaDescriptions: List, inReplyToId: String?, + poll: NewPoll?, replyingStatusContent: String?, replyingStatusAuthorUsername: String?, savedJsonUrls: String?, @@ -295,6 +304,7 @@ class SendTootService : Service(), Injectable { mediaUris.map { it.toString() }, mediaDescriptions, inReplyToId, + poll, replyingStatusContent, replyingStatusAuthorUsername, savedJsonUrls, @@ -337,6 +347,7 @@ data class TootToSend(val text: String, val mediaUris: List, val mediaDescriptions: List, val inReplyToId: String?, + val poll: NewPoll?, val replyingStatusContent: String?, val replyingStatusAuthorUsername: String?, val savedJsonUrls: String?, diff --git a/app/src/main/java/com/keylesspalace/tusky/util/SaveTootHelper.java b/app/src/main/java/com/keylesspalace/tusky/util/SaveTootHelper.java index 5a9cdc969..edc8ab92d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/SaveTootHelper.java +++ b/app/src/main/java/com/keylesspalace/tusky/util/SaveTootHelper.java @@ -17,6 +17,7 @@ import com.google.gson.reflect.TypeToken; import com.keylesspalace.tusky.BuildConfig; import com.keylesspalace.tusky.db.TootDao; import com.keylesspalace.tusky.db.TootEntity; +import com.keylesspalace.tusky.entity.NewPoll; import com.keylesspalace.tusky.entity.Status; import java.io.File; @@ -41,17 +42,18 @@ public final class SaveTootHelper { @SuppressLint("StaticFieldLeak") public boolean saveToot(@NonNull String content, - @NonNull String contentWarning, - @Nullable String savedJsonUrls, - @NonNull List mediaUris, - @NonNull List mediaDescriptions, - int savedTootUid, - @Nullable String inReplyToId, - @Nullable String replyingStatusContent, - @Nullable String replyingStatusAuthorUsername, - @NonNull Status.Visibility statusVisibility) { + @NonNull String contentWarning, + @Nullable String savedJsonUrls, + @NonNull List mediaUris, + @NonNull List mediaDescriptions, + int savedTootUid, + @Nullable String inReplyToId, + @Nullable String replyingStatusContent, + @Nullable String replyingStatusAuthorUsername, + @NonNull Status.Visibility statusVisibility, + @Nullable NewPoll poll) { - if (TextUtils.isEmpty(content) && mediaUris.isEmpty()) { + if (TextUtils.isEmpty(content) && mediaUris.isEmpty() && poll == null) { return false; } @@ -86,7 +88,8 @@ public final class SaveTootHelper { inReplyToId, replyingStatusContent, replyingStatusAuthorUsername, - statusVisibility); + statusVisibility, + poll); new AsyncTask() { @Override diff --git a/app/src/main/java/com/keylesspalace/tusky/view/AddPollDialog.kt b/app/src/main/java/com/keylesspalace/tusky/view/AddPollDialog.kt new file mode 100644 index 000000000..f6116f9ee --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/view/AddPollDialog.kt @@ -0,0 +1,102 @@ +/* Copyright 2019 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +@file:JvmName("AddPollDialog") + +package com.keylesspalace.tusky.view + +import androidx.appcompat.app.AlertDialog +import com.keylesspalace.tusky.ComposeActivity +import com.keylesspalace.tusky.adapter.AddPollOptionsAdapter +import com.keylesspalace.tusky.entity.NewPoll +import kotlinx.android.synthetic.main.dialog_add_poll.view.* +import android.view.WindowManager +import com.keylesspalace.tusky.R + +private const val DEFAULT_MAX_OPTION_COUNT = 4 +private const val DEFAULT_MAX_OPTION_LENGTH = 25 + +fun showAddPollDialog( + activity: ComposeActivity, + poll: NewPoll?, + maxOptionCount: Int?, + maxOptionLength: Int? +) { + + val view = activity.layoutInflater.inflate(R.layout.dialog_add_poll, null) + + val dialog = AlertDialog.Builder(activity) + .setIcon(R.drawable.ic_poll_24dp) + .setTitle(R.string.create_poll_title) + .setView(view) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok, null) + .create() + + val adapter = AddPollOptionsAdapter( + options = poll?.options?.toMutableList() ?: mutableListOf("", ""), + maxOptionLength = maxOptionLength ?: DEFAULT_MAX_OPTION_LENGTH, + onOptionRemoved = { + view.addChoiceButton.isEnabled = true + }, + onOptionChanged = { valid -> + dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = valid + } + ) + + view.pollChoices.adapter = adapter + + view.addChoiceButton.setOnClickListener { + if (adapter.itemCount < maxOptionCount ?: DEFAULT_MAX_OPTION_COUNT) { + adapter.addChoice() + } + if (adapter.itemCount >= maxOptionCount ?: DEFAULT_MAX_OPTION_COUNT) { + it.isEnabled = false + } + } + + val pollDurationId = activity.resources.getIntArray(R.array.poll_duration_values).indexOfLast { + it <= poll?.expiresIn ?: 0 + } + + view.pollDurationSpinner.setSelection(pollDurationId) + + view.multipleChoicesCheckBox.isChecked = poll?.multiple ?: false + + dialog.setOnShowListener { + val button = dialog.getButton(AlertDialog.BUTTON_POSITIVE) + button.setOnClickListener { + val selectedPollDurationId = view.pollDurationSpinner.selectedItemPosition + + val pollDuration = activity.resources.getIntArray(R.array.poll_duration_values)[selectedPollDurationId] + + activity.updatePoll( + NewPoll( + options = adapter.pollOptions, + expiresIn = pollDuration, + multiple = view.multipleChoicesCheckBox.isChecked + ) + ) + + dialog.dismiss() + } + } + + dialog.show() + + // make the dialog focusable so the keyboard does not stay behind it + dialog.window?.clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM) + +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/view/PollPreviewView.kt b/app/src/main/java/com/keylesspalace/tusky/view/PollPreviewView.kt new file mode 100644 index 000000000..e82831fd2 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/view/PollPreviewView.kt @@ -0,0 +1,64 @@ +/* Copyright 2019 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.view + +import android.content.Context +import android.util.AttributeSet +import android.widget.LinearLayout +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.adapter.PreviewPollOptionsAdapter +import com.keylesspalace.tusky.entity.NewPoll +import kotlinx.android.synthetic.main.view_poll_preview.view.* + +class PollPreviewView @JvmOverloads constructor( + context: Context?, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0) + : LinearLayout(context, attrs, defStyleAttr) { + + val adapter = PreviewPollOptionsAdapter() + + init { + inflate(context, R.layout.view_poll_preview, this) + + orientation = VERTICAL + + setBackgroundResource(R.drawable.card_frame) + + val padding = resources.getDimensionPixelSize(R.dimen.poll_preview_padding) + + setPadding(padding, padding, padding, padding) + + pollPreviewOptions.adapter = adapter + + } + + fun setPoll(poll: NewPoll){ + adapter.update(poll.options, poll.multiple) + + val pollDurationId = resources.getIntArray(R.array.poll_duration_values).indexOfLast { + it <= poll.expiresIn + } + pollDurationPreview.text = resources.getStringArray(R.array.poll_duration_names)[pollDurationId] + + } + + override fun setOnClickListener(l: OnClickListener?) { + super.setOnClickListener(l) + adapter.setOnClickListener(l) + } + +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_check_box_outline_blank_18dp.xml b/app/src/main/res/drawable/ic_check_box_outline_blank_18dp.xml new file mode 100644 index 000000000..cb610e2d4 --- /dev/null +++ b/app/src/main/res/drawable/ic_check_box_outline_blank_18dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_radio_button_unchecked_18dp.xml b/app/src/main/res/drawable/ic_radio_button_unchecked_18dp.xml new file mode 100644 index 000000000..160b2375e --- /dev/null +++ b/app/src/main/res/drawable/ic_radio_button_unchecked_18dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/activity_compose.xml b/app/src/main/res/layout/activity_compose.xml index 33a5b35f7..b0b5efab3 100644 --- a/app/src/main/res/layout/activity_compose.xml +++ b/app/src/main/res/layout/activity_compose.xml @@ -190,6 +190,15 @@ android:padding="8dp" android:text="@string/action_add_media" android:textSize="?attr/status_text_medium" /> + + + + + + + + +