From efba197044dac76f019ef6fb089e72bb4bf65411 Mon Sep 17 00:00:00 2001 From: charlag Date: Mon, 26 Feb 2024 23:47:52 +0100 Subject: [PATCH 01/11] Implement machine translation for posts --- app/.idea/workspace.xml | 40 + .../58.json | 1040 +++++++++++++++++ .../tusky/adapter/StatusBaseViewHolder.java | 58 +- .../adapter/StatusDetailedViewHolder.java | 3 + .../conversation/ConversationsFragment.kt | 19 +- .../components/instanceinfo/InstanceInfo.kt | 3 +- .../instanceinfo/InstanceInfoRepository.kt | 9 +- .../components/search/SearchViewModel.kt | 32 +- .../fragments/SearchStatusesFragment.kt | 56 +- .../components/timeline/TimelineFragment.kt | 61 +- .../timeline/TimelineTypeMappers.kt | 10 +- .../viewmodel/CachedTimelineViewModel.kt | 43 +- .../viewmodel/NetworkTimelineViewModel.kt | 46 +- .../timeline/viewmodel/TimelineViewModel.kt | 5 +- .../viewthread/ViewThreadFragment.kt | 46 +- .../viewthread/ViewThreadViewModel.kt | 48 +- .../keylesspalace/tusky/db/AppDatabase.java | 5 +- .../keylesspalace/tusky/db/InstanceEntity.kt | 6 +- .../com/keylesspalace/tusky/entity/Status.kt | 3 + .../keylesspalace/tusky/entity/Translation.kt | 25 + .../tusky/fragment/NotificationsFragment.java | 160 +-- .../keylesspalace/tusky/fragment/SFragment.kt | 342 +++--- .../interfaces/StatusActionListener.java | 3 +- .../tusky/network/MastodonApi.kt | 8 + .../tusky/usecase/TimelineCases.kt | 8 + .../keylesspalace/tusky/util/ResultUtils.kt | 9 + .../keylesspalace/tusky/util/ViewDataUtils.kt | 7 +- .../tusky/viewdata/StatusViewData.kt | 47 +- app/src/main/res/layout/item_status.xml | 34 +- .../main/res/layout/item_status_detailed.xml | 32 +- app/src/main/res/menu/status_more.xml | 26 +- .../main/res/menu/status_more_for_user.xml | 2 +- app/src/main/res/values/strings.xml | 6 + .../components/compose/ComposeActivityTest.kt | 2 +- 34 files changed, 1943 insertions(+), 301 deletions(-) create mode 100644 app/.idea/workspace.xml create mode 100644 app/schemas/com.keylesspalace.tusky.db.AppDatabase/58.json create mode 100644 app/src/main/java/com/keylesspalace/tusky/entity/Translation.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/util/ResultUtils.kt diff --git a/app/.idea/workspace.xml b/app/.idea/workspace.xml new file mode 100644 index 0000000000..f57fdbacc6 --- /dev/null +++ b/app/.idea/workspace.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + 1606153579073 + + + + \ No newline at end of file diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/58.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/58.json new file mode 100644 index 0000000000..65aa67d6c1 --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/58.json @@ -0,0 +1,1040 @@ +{ + "formatVersion": 1, + "database": { + "version": 58, + "identityHash": "1d0e1cdf0b4c3f787333b9abf3b2b26a", + "entities": [ + { + "tableName": "DraftEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountId` INTEGER NOT NULL, `inReplyToId` TEXT, `content` TEXT, `contentWarning` TEXT, `sensitive` INTEGER NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `poll` TEXT, `failedToSend` INTEGER NOT NULL, `failedToSendNew` INTEGER NOT NULL, `scheduledAt` TEXT, `language` TEXT, `statusId` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentWarning", + "columnName": "contentWarning", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "failedToSend", + "columnName": "failedToSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "failedToSendNew", + "columnName": "failedToSendNew", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scheduledAt", + "columnName": "scheduledAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "statusId", + "columnName": "statusId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "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, `clientId` TEXT, `clientSecret` TEXT, `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, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsSubscriptions` INTEGER NOT NULL, `notificationsSignUps` INTEGER NOT NULL, `notificationsUpdates` INTEGER NOT NULL, `notificationsReports` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `defaultPostLanguage` TEXT NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `notificationMarkerId` TEXT NOT NULL DEFAULT '0', `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL, `oauthScopes` TEXT NOT NULL, `unifiedPushUrl` TEXT NOT NULL, `pushPubKey` TEXT NOT NULL, `pushPrivKey` TEXT NOT NULL, `pushAuth` TEXT NOT NULL, `pushServerKey` TEXT NOT NULL, `lastVisibleHomeTimelineStatusId` TEXT, `locked` INTEGER NOT NULL DEFAULT 0, `hasDirectMessageBadge` INTEGER NOT NULL DEFAULT 0, `isShowHomeBoosts` INTEGER NOT NULL, `isShowHomeReplies` INTEGER NOT NULL, `isShowHomeSelfBoosts` INTEGER 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": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "clientSecret", + "columnName": "clientSecret", + "affinity": "TEXT", + "notNull": false + }, + { + "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": "notificationsFollowRequested", + "columnName": "notificationsFollowRequested", + "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": "notificationsSubscriptions", + "columnName": "notificationsSubscriptions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSignUps", + "columnName": "notificationsSignUps", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsUpdates", + "columnName": "notificationsUpdates", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReports", + "columnName": "notificationsReports", + "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": "defaultPostLanguage", + "columnName": "defaultPostLanguage", + "affinity": "TEXT", + "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": "notificationMarkerId", + "columnName": "notificationMarkerId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'0'" + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabPreferences", + "columnName": "tabPreferences", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsFilter", + "columnName": "notificationsFilter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "oauthScopes", + "columnName": "oauthScopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unifiedPushUrl", + "columnName": "unifiedPushUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPubKey", + "columnName": "pushPubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPrivKey", + "columnName": "pushPrivKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushAuth", + "columnName": "pushAuth", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushServerKey", + "columnName": "pushServerKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastVisibleHomeTimelineStatusId", + "columnName": "lastVisibleHomeTimelineStatusId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "hasDirectMessageBadge", + "columnName": "hasDirectMessageBadge", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isShowHomeBoosts", + "columnName": "isShowHomeBoosts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isShowHomeReplies", + "columnName": "isShowHomeReplies", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isShowHomeSelfBoosts", + "columnName": "isShowHomeSelfBoosts", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_AccountEntity_domain_accountId", + "unique": true, + "columnNames": [ + "domain", + "accountId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `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, `minPollDuration` INTEGER, `maxPollDuration` INTEGER, `charactersReservedPerUrl` INTEGER, `version` TEXT, `videoSizeLimit` INTEGER, `imageSizeLimit` INTEGER, `imageMatrixLimit` INTEGER, `maxMediaAttachments` INTEGER, `maxFields` INTEGER, `maxFieldNameLength` INTEGER, `maxFieldValueLength` INTEGER, `translationEnabled` 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 + }, + { + "fieldPath": "minPollDuration", + "columnName": "minPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollDuration", + "columnName": "maxPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "charactersReservedPerUrl", + "columnName": "charactersReservedPerUrl", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "videoSizeLimit", + "columnName": "videoSizeLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "imageSizeLimit", + "columnName": "imageSizeLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "imageMatrixLimit", + "columnName": "imageMatrixLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMediaAttachments", + "columnName": "maxMediaAttachments", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFields", + "columnName": "maxFields", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFieldNameLength", + "columnName": "maxFieldNameLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFieldValueLength", + "columnName": "maxFieldValueLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "translationEnabled", + "columnName": "translationEnabled", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "instance" + ] + }, + "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, `editedAt` INTEGER, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `repliesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT, `mentions` TEXT, `tags` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `card` TEXT, `language` TEXT, `filtered` 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": "editedAt", + "columnName": "editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "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": "repliesCount", + "columnName": "repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reblogged", + "columnName": "reblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarked", + "columnName": "bookmarked", + "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": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "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 + }, + { + "fieldPath": "muted", + "columnName": "muted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "expanded", + "columnName": "expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentCollapsed", + "columnName": "contentCollapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentShowing", + "columnName": "contentShowing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pinned", + "columnName": "pinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "card", + "columnName": "card", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filtered", + "columnName": "filtered", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `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": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `order` INTEGER 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_editedAt` INTEGER, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_repliesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_tags` TEXT, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, `s_language` TEXT, PRIMARY KEY(`id`, `accountId`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "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.editedAt", + "columnName": "s_editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastStatus.emojis", + "columnName": "s_emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.favouritesCount", + "columnName": "s_favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.repliesCount", + "columnName": "s_repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.favourited", + "columnName": "s_favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.bookmarked", + "columnName": "s_bookmarked", + "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.tags", + "columnName": "s_tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.showingHiddenContent", + "columnName": "s_showingHiddenContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.expanded", + "columnName": "s_expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsed", + "columnName": "s_collapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.muted", + "columnName": "s_muted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.poll", + "columnName": "s_poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.language", + "columnName": "s_language", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "accountId" + ] + }, + "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, '1d0e1cdf0b4c3f787333b9abf3b2b26a')" + ] + } +} \ No newline at end of file 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 71d24f1823..5c52e91414 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java @@ -48,6 +48,7 @@ import com.keylesspalace.tusky.entity.FilterResult; import com.keylesspalace.tusky.entity.HashTag; import com.keylesspalace.tusky.entity.Status; +import com.keylesspalace.tusky.entity.Translation; import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.util.AbsoluteTimeFormatter; import com.keylesspalace.tusky.util.AttachmentHelper; @@ -66,11 +67,13 @@ import com.keylesspalace.tusky.viewdata.PollViewData; import com.keylesspalace.tusky.viewdata.PollViewDataKt; import com.keylesspalace.tusky.viewdata.StatusViewData; +import com.keylesspalace.tusky.viewdata.TranslationViewData; import java.text.NumberFormat; import java.util.Collections; import java.util.Date; import java.util.List; +import java.util.Locale; import at.connyduck.sparkbutton.SparkButton; import at.connyduck.sparkbutton.helpers.Utils; @@ -120,6 +123,9 @@ public static class Key { protected final TextView filteredPlaceholderLabel; protected final Button filteredPlaceholderShowButton; protected final ConstraintLayout statusContainer; + private final TextView translationStatusView; + private final Button untranslateButton; + private final NumberFormat numberFormat = NumberFormat.getNumberInstance(); private final AbsoluteTimeFormatter absoluteTimeFormatter = new AbsoluteTimeFormatter(); @@ -182,6 +188,9 @@ protected StatusBaseViewHolder(@NonNull View itemView) { pollOptions.setLayoutManager(new LinearLayoutManager(pollOptions.getContext())); ((DefaultItemAnimator) pollOptions.getItemAnimator()).setSupportsChangeAnimations(false); + translationStatusView = itemView.findViewById(R.id.status_translation_status); + untranslateButton = itemView.findViewById(R.id.status_button_untranslate); + this.avatarRadius48dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_48dp); this.avatarRadius36dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_36dp); this.avatarRadius24dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_24dp); @@ -213,7 +222,7 @@ protected void setSpoilerAndContent(@NonNull StatusViewData.Concrete status, final @NonNull StatusActionListener listener) { Status actionable = status.getActionable(); - String spoilerText = actionable.getSpoilerText(); + String spoilerText = status.getSpoilerText(); List emojis = actionable.getEmojis(); boolean sensitive = !TextUtils.isEmpty(spoilerText); @@ -273,7 +282,7 @@ private void setTextVisible(boolean sensitive, List mentions = actionable.getMentions(); List tags = actionable.getTags(); List emojis = actionable.getEmojis(); - PollViewData poll = PollViewDataKt.toViewData(actionable.getPoll()); + PollViewData poll = PollViewDataKt.toViewData(status.getPoll()); if (expanded) { CharSequence emojifiedText = CustomEmojiHelper.emojify(content, emojis, this.content, statusDisplayOptions.animateEmojis()); @@ -779,7 +788,7 @@ public void setupWithStatus(@NonNull StatusViewData.Concrete status, setReblogged(actionable.getReblogged()); setFavourited(actionable.getFavourited()); setBookmarked(actionable.getBookmarked()); - List attachments = actionable.getAttachments(); + List attachments = status.getAttachments(); boolean sensitive = actionable.getSensitive(); if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) { setMediaPreviews(attachments, sensitive, listener, status.isShowingContent(), statusDisplayOptions.useBlurhash()); @@ -802,6 +811,9 @@ public void setupWithStatus(@NonNull StatusViewData.Concrete status, setupButtons(listener, actionable.getAccount().getId(), status.getContent().toString(), statusDisplayOptions); + + setTranslationStatus(status, listener); + setRebloggingEnabled(actionable.rebloggingAllowed(), actionable.getVisibility()); setSpoilerAndContent(status, statusDisplayOptions, listener); @@ -827,6 +839,30 @@ public void setupWithStatus(@NonNull StatusViewData.Concrete status, } } + private void setTranslationStatus(StatusViewData.Concrete status, StatusActionListener listener) { + var translationViewData = status.getTranslation(); + if (translationViewData != null) { + if (translationViewData instanceof TranslationViewData.Loaded) { + Translation translation = ((TranslationViewData.Loaded) translationViewData).getData(); + translationStatusView.setVisibility(View.VISIBLE); + // FIXME error handling + var locale = new Locale(translation.getDetectedSourceLanguage()); + translationStatusView.setText(translationStatusView.getContext().getString(R.string.label_translated, locale.getDisplayName(), translation.getProvider())); + untranslateButton.setVisibility(View.VISIBLE); + untranslateButton.setOnClickListener((v) -> listener.onUntranslate(getBindingAdapterPosition())); + } else { + translationStatusView.setVisibility(View.VISIBLE); + translationStatusView.setText(R.string.label_translating); + untranslateButton.setVisibility(View.INVISIBLE); + untranslateButton.setOnClickListener(null); + } + } else { + translationStatusView.setVisibility(View.GONE); + untranslateButton.setVisibility(View.GONE); + untranslateButton.setOnClickListener(null); + } + } + private void setupFilterPlaceholder(StatusViewData.Concrete status, StatusActionListener listener, StatusDisplayOptions displayOptions) { if (status.getFilterAction() != Filter.Action.WARN) { showFilteredPlaceholder(false); @@ -866,7 +902,7 @@ private void setDescriptionForStatus(@NonNull StatusViewData.Concrete status, String description = context.getString(R.string.description_status, actionable.getAccount().getDisplayName(), getContentWarningDescription(context, status), - (TextUtils.isEmpty(actionable.getSpoilerText()) || !actionable.getSensitive() || status.isExpanded() ? status.getContent() : ""), + (TextUtils.isEmpty(status.getSpoilerText()) || !actionable.getSensitive() || status.isExpanded() ? status.getContent() : ""), getCreatedAtDescription(actionable.getCreatedAt(), statusDisplayOptions), actionable.getEditedAt() != null ? context.getString(R.string.description_post_edited) : "", getReblogDescription(context, status), @@ -895,12 +931,12 @@ private static CharSequence getReblogDescription(Context context, } private static CharSequence getMediaDescription(Context context, - @NonNull StatusViewData.Concrete status) { - if (status.getActionable().getAttachments().isEmpty()) { + @NonNull StatusViewData.Concrete viewData) { + if (viewData.getAttachments().isEmpty()) { return ""; } StringBuilder mediaDescriptions = CollectionsKt.fold( - status.getActionable().getAttachments(), + viewData.getAttachments(), new StringBuilder(), (builder, a) -> { if (a.getDescription() == null) { @@ -917,8 +953,8 @@ private static CharSequence getMediaDescription(Context context, private static CharSequence getContentWarningDescription(Context context, @NonNull StatusViewData.Concrete status) { - if (!TextUtils.isEmpty(status.getActionable().getSpoilerText())) { - return context.getString(R.string.description_post_cw, status.getActionable().getSpoilerText()); + if (!TextUtils.isEmpty(status.getSpoilerText())) { + return context.getString(R.string.description_post_cw, status.getSpoilerText()); } else { return ""; } @@ -954,7 +990,7 @@ protected static CharSequence getVisibilityDescription(@NonNull Context context, private CharSequence getPollDescription(@NonNull StatusViewData.Concrete status, Context context, StatusDisplayOptions statusDisplayOptions) { - PollViewData poll = PollViewDataKt.toViewData(status.getActionable().getPoll()); + PollViewData poll = PollViewDataKt.toViewData(status.getPoll()); if (poll == null) { return ""; } else { @@ -981,7 +1017,7 @@ protected CharSequence getFavsText(@NonNull Context context, int count) { } @NonNull - protected CharSequence getReblogsText (@NonNull Context context, int count) { + protected CharSequence getReblogsText(@NonNull Context context, int count) { String countString = numberFormat.format(count); return HtmlCompat.fromHtml(context.getResources().getQuantityString(R.plurals.reblogs, count, countString), HtmlCompat.FROM_HTML_MODE_LEGACY); } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java index d8921c28ea..bb5a78e4eb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java @@ -9,11 +9,13 @@ import android.text.style.DynamicDrawableSpan; import android.text.style.ImageSpan; import android.view.View; +import android.widget.Button; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.content.res.AppCompatResources; +import androidx.appcompat.widget.ViewUtils; import androidx.recyclerview.widget.RecyclerView; import com.keylesspalace.tusky.R; @@ -23,6 +25,7 @@ import com.keylesspalace.tusky.util.LinkHelper; import com.keylesspalace.tusky.util.NoUnderlineURLSpan; import com.keylesspalace.tusky.util.StatusDisplayOptions; +import com.keylesspalace.tusky.util.ViewExtensionsKt; import com.keylesspalace.tusky.viewdata.StatusViewData; import java.text.DateFormat; 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 1dff9e6549..caa264d8f9 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 @@ -136,7 +136,11 @@ class ConversationsFragment : if (loadState.isAnyLoading()) { lifecycleScope.launch { - eventHub.dispatch(ConversationsLoadingEvent(accountManager.activeAccount?.accountId ?: "")) + eventHub.dispatch( + ConversationsLoadingEvent( + accountManager.activeAccount?.accountId ?: "" + ) + ) } } @@ -153,12 +157,14 @@ class ConversationsFragment : binding.statusView.showHelp(R.string.help_empty_conversations) } } + is LoadState.Error -> { binding.statusView.show() binding.statusView.setup( (loadState.refresh as LoadState.Error).error ) { refreshContent() } } + is LoadState.Loading -> { binding.progressBar.show() } @@ -242,6 +248,7 @@ class ConversationsFragment : refreshContent() true } + else -> false } } @@ -256,7 +263,8 @@ class ConversationsFragment : (binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false - binding.recyclerView.adapter = adapter.withLoadStateFooter(ConversationLoadStateAdapter(adapter::retry)) + binding.recyclerView.adapter = + adapter.withLoadStateFooter(ConversationLoadStateAdapter(adapter::retry)) } private fun refreshContent() { @@ -284,6 +292,8 @@ class ConversationsFragment : } } + override val onMoreTranslate: ((translate: Boolean, position: Int) -> Unit)? = null + override fun onMore(view: View, position: Int) { adapter.peek(position)?.let { conversation -> @@ -386,6 +396,10 @@ class ConversationsFragment : } } + override fun onUntranslate(position: Int) { + // not needed + } + private fun deleteConversation(conversation: ConversationViewData) { AlertDialog.Builder(requireContext()) .setMessage(R.string.dialog_delete_conversation_warning) @@ -402,6 +416,7 @@ class ConversationsFragment : PrefKeys.FAB_HIDE -> { hideFab = sharedPreferences.getBoolean(PrefKeys.FAB_HIDE, false) } + PrefKeys.MEDIA_PREVIEW_ENABLED -> { val enabled = accountManager.activeAccount!!.mediaPreviewEnabled val oldMediaPreviewEnabled = adapter.mediaPreviewEnabled diff --git a/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfo.kt b/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfo.kt index 582df02e8d..33a1122f3a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfo.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfo.kt @@ -29,5 +29,6 @@ data class InstanceInfo( val maxFields: Int, val maxFieldNameLength: Int?, val maxFieldValueLength: Int?, - val version: String? + val version: String?, + val translationEnabled: Boolean?, ) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfoRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfoRepository.kt index b0bbff827a..6b0d5f2612 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfoRepository.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfoRepository.kt @@ -77,7 +77,8 @@ class InstanceInfoRepository @Inject constructor( maxMediaAttachments = instance.configuration?.statuses?.maxMediaAttachments ?: DEFAULT_MAX_MEDIA_ATTACHMENTS, maxFields = instance.pleroma?.metadata?.fieldLimits?.maxFields, maxFieldNameLength = instance.pleroma?.metadata?.fieldLimits?.nameLength, - maxFieldValueLength = instance.pleroma?.metadata?.fieldLimits?.valueLength + maxFieldValueLength = instance.pleroma?.metadata?.fieldLimits?.valueLength, + translationEnabled = instance.configuration?.translation?.enabled ) dao.upsert(instanceEntity) instanceEntity @@ -109,7 +110,8 @@ class InstanceInfoRepository @Inject constructor( maxFields = instanceInfo?.maxFields ?: DEFAULT_MAX_ACCOUNT_FIELDS, maxFieldNameLength = instanceInfo?.maxFieldNameLength, maxFieldValueLength = instanceInfo?.maxFieldValueLength, - version = instanceInfo?.version + version = instanceInfo?.version, + translationEnabled = instanceInfo?.translationEnabled ) } } @@ -133,7 +135,8 @@ class InstanceInfoRepository @Inject constructor( maxMediaAttachments = instance.configuration?.statuses?.maxMediaAttachments ?: instance.maxMediaAttachments, maxFields = instance.pleroma?.metadata?.fieldLimits?.maxFields, maxFieldNameLength = instance.pleroma?.metadata?.fieldLimits?.nameLength, - maxFieldValueLength = instance.pleroma?.metadata?.fieldLimits?.valueLength + maxFieldValueLength = instance.pleroma?.metadata?.fieldLimits?.valueLength, + translationEnabled = null, ) dao.upsert(instanceEntity) instanceEntity diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt index 1dd8d85a64..369da05cca 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt @@ -23,7 +23,9 @@ import androidx.paging.PagingConfig import androidx.paging.cachedIn import at.connyduck.calladapter.networkresult.NetworkResult import at.connyduck.calladapter.networkresult.fold +import at.connyduck.calladapter.networkresult.map import at.connyduck.calladapter.networkresult.onFailure +import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository import com.keylesspalace.tusky.components.search.adapter.SearchPagingSourceFactory import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountManager @@ -31,8 +33,10 @@ import com.keylesspalace.tusky.entity.DeletedStatus import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.usecase.TimelineCases +import com.keylesspalace.tusky.util.asResult import com.keylesspalace.tusky.util.toViewData import com.keylesspalace.tusky.viewdata.StatusViewData +import com.keylesspalace.tusky.viewdata.TranslationViewData import javax.inject.Inject import kotlinx.coroutines.Deferred import kotlinx.coroutines.async @@ -41,7 +45,8 @@ import kotlinx.coroutines.launch class SearchViewModel @Inject constructor( mastodonApi: MastodonApi, private val timelineCases: TimelineCases, - private val accountManager: AccountManager + private val accountManager: AccountManager, + private val instanceInfoRepository: InstanceInfoRepository, ) : ViewModel() { var currentQuery: String = "" @@ -193,6 +198,31 @@ class SearchViewModel @Inject constructor( } } + suspend fun supportsTranslation(): Boolean = + instanceInfoRepository.getInstanceInfo().translationEnabled == true + + suspend fun translate(statusViewData: StatusViewData.Concrete): Result { + updateStatusViewData(statusViewData.copy(translation = TranslationViewData.Loading)) + return timelineCases.translate(statusViewData.actionableId) + .map { translation -> + updateStatusViewData( + statusViewData.copy( + translation = TranslationViewData.Loaded( + translation + ) + ) + ) + } + .onFailure { + updateStatusViewData(statusViewData.copy(translation = null)) + } + .asResult() + } + + fun untranslate(statusViewData: StatusViewData.Concrete) { + updateStatusViewData(statusViewData.copy(translation = null)) + } + private fun updateStatusViewData(newStatusViewData: StatusViewData.Concrete) { val idx = loadedStatuses.indexOfFirst { it.id == newStatusViewData.id } if (idx >= 0) { 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 24f3065de6..e8a10c9fec 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 @@ -62,6 +62,7 @@ import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation import com.keylesspalace.tusky.view.showMuteAccountDialog import com.keylesspalace.tusky.viewdata.AttachmentViewData import com.keylesspalace.tusky.viewdata.StatusViewData +import java.util.Locale import javax.inject.Inject import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch @@ -102,7 +103,8 @@ class SearchStatusesFragment : SearchFragment(), Status DividerItemDecoration.VERTICAL ) ) - binding.searchRecyclerView.layoutManager = LinearLayoutManager(binding.searchRecyclerView.context) + binding.searchRecyclerView.layoutManager = + LinearLayoutManager(binding.searchRecyclerView.context) return SearchStatusesAdapter(statusDisplayOptions, this) } @@ -131,7 +133,7 @@ class SearchStatusesFragment : SearchFragment(), Status } override fun onMore(view: View, position: Int) { - searchAdapter.peek(position)?.status?.let { + searchAdapter.peek(position)?.let { more(it, view, position) } } @@ -159,6 +161,7 @@ class SearchStatusesFragment : SearchFragment(), Status startActivity(intent) } } + Attachment.Type.UNKNOWN -> { context?.openLink(actionable.attachments[attachmentIndex].url) } @@ -215,6 +218,12 @@ class SearchStatusesFragment : SearchFragment(), Status } } + override fun onUntranslate(position: Int) { + searchAdapter.peek(position)?.let { + viewModel.untranslate(it) + } + } + companion object { fun newInstance() = SearchStatusesFragment() } @@ -244,7 +253,8 @@ class SearchStatusesFragment : SearchFragment(), Status bottomSheetActivity?.startActivityWithSlideInAnimation(intent) } - private fun more(status: Status, view: View, position: Int) { + private fun more(statusViewData: StatusViewData.Concrete, view: View, position: Int) = lifecycleScope.launch { + val status = statusViewData.status val id = status.actionableId val accountId = status.actionableStatus.account.id val accountUsername = status.actionableStatus.account.username @@ -266,12 +276,14 @@ class SearchStatusesFragment : SearchFragment(), Status ) menu.add(0, R.id.pin, 1, textId) } + Status.Visibility.PRIVATE -> { var reblogged = status.reblogged if (status.reblog != null) reblogged = status.reblog.reblogged menu.findItem(R.id.status_reblog_private).isVisible = !reblogged menu.findItem(R.id.status_unreblog_private).isVisible = reblogged } + Status.Visibility.UNKNOWN, Status.Visibility.DIRECT -> { } // Ignore } @@ -289,7 +301,8 @@ class SearchStatusesFragment : SearchFragment(), Status openAsItem.title = openAsText } - val mutable = statusIsByCurrentUser || accountIsInMentions(viewModel.activeAccount, status.mentions) + val mutable = + statusIsByCurrentUser || accountIsInMentions(viewModel.activeAccount, status.mentions) val muteConversationItem = popup.menu.findItem(R.id.status_mute_conversation).apply { isVisible = mutable } @@ -303,6 +316,10 @@ class SearchStatusesFragment : SearchFragment(), Status ) } + val translateItem = popup.menu.findItem(R.id.status_translate) + translateItem.isVisible = status.language != Locale.getDefault().language && viewModel.supportsTranslation() + translateItem.setTitle(if (statusViewData.translation != null) R.string.action_show_original else R.string.action_translate) + popup.setOnMenuItemClickListener { item -> when (item.itemId) { R.id.post_share_content -> { @@ -324,6 +341,7 @@ class SearchStatusesFragment : SearchFragment(), Status ) return@setOnMenuItemClickListener true } + R.id.post_share_link -> { val sendIntent = Intent() sendIntent.action = Intent.ACTION_SEND @@ -337,6 +355,7 @@ class SearchStatusesFragment : SearchFragment(), Status ) return@setOnMenuItemClickListener true } + R.id.status_copy_link -> { val clipboard = requireActivity().getSystemService( Context.CLIPBOARD_SERVICE @@ -344,56 +363,85 @@ class SearchStatusesFragment : SearchFragment(), Status clipboard.setPrimaryClip(ClipData.newPlainText(null, statusUrl)) return@setOnMenuItemClickListener true } + R.id.status_open_as -> { showOpenAsDialog(statusUrl!!, item.title) return@setOnMenuItemClickListener true } + R.id.status_download_media -> { requestDownloadAllMedia(status) return@setOnMenuItemClickListener true } + R.id.status_mute_conversation -> { searchAdapter.peek(position)?.let { foundStatus -> viewModel.muteConversation(foundStatus, status.muted != true) } return@setOnMenuItemClickListener true } + R.id.status_mute -> { onMute(accountId, accountUsername) return@setOnMenuItemClickListener true } + R.id.status_block -> { onBlock(accountId, accountUsername) return@setOnMenuItemClickListener true } + R.id.status_report -> { openReportPage(accountId, accountUsername, id) return@setOnMenuItemClickListener true } + R.id.status_unreblog_private -> { onReblog(false, position) return@setOnMenuItemClickListener true } + R.id.status_reblog_private -> { onReblog(true, position) return@setOnMenuItemClickListener true } + R.id.status_delete -> { showConfirmDeleteDialog(id, position) return@setOnMenuItemClickListener true } + R.id.status_delete_and_redraft -> { showConfirmEditDialog(id, position, status) return@setOnMenuItemClickListener true } + R.id.status_edit -> { editStatus(id, position, status) return@setOnMenuItemClickListener true } + R.id.pin -> { viewModel.pinAccount(status, !status.isPinned()) return@setOnMenuItemClickListener true } + + R.id.status_translate -> { + if (statusViewData.translation != null) { + viewModel.untranslate(statusViewData) + } else { + lifecycleScope.launch { + viewModel.translate(statusViewData) + .onFailure { + Snackbar.make( + requireView(), + getString(R.string.ui_error_translate, it.message), + Snackbar.LENGTH_LONG + ).show() + } + } + } + } } false } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index 8650c34352..e10d5f6042 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -38,6 +38,7 @@ import androidx.recyclerview.widget.SimpleItemAnimator import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener import at.connyduck.sparkbutton.helpers.Utils import com.google.android.material.color.MaterialColors +import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.R import com.keylesspalace.tusky.adapter.StatusBaseViewHolder import com.keylesspalace.tusky.appstore.EventHub @@ -70,6 +71,7 @@ import com.keylesspalace.tusky.util.updateRelativeTimePeriodically import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.viewdata.AttachmentViewData import com.keylesspalace.tusky.viewdata.StatusViewData +import com.keylesspalace.tusky.viewdata.TranslationViewData import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.utils.colorInt @@ -238,12 +240,14 @@ class TimelineFragment : } } } + is LoadState.Error -> { binding.statusView.show() binding.statusView.setup( (loadState.refresh as LoadState.Error).error ) { onRefresh() } } + is LoadState.Loading -> { binding.progressBar.show() } @@ -306,6 +310,7 @@ class TimelineFragment : is PreferenceChangedEvent -> { onPreferenceChanged(event.preferenceKey) } + is StatusComposedEvent -> { val status = event.status handleStatusComposeEvent(status) @@ -348,6 +353,7 @@ class TimelineFragment : false } } + else -> false } } @@ -415,6 +421,17 @@ class TimelineFragment : adapter.refresh() } + override val onMoreTranslate = + { translate: Boolean, position: Int -> + if (translate) { + onTranslate(position) + } else { + onUntranslate( + position + ) + } + } + override fun onReply(position: Int) { val status = adapter.peek(position)?.asStatusOrNull() ?: return super.reply(status.status) @@ -425,6 +442,25 @@ class TimelineFragment : viewModel.reblog(reblog, status) } + private fun onTranslate(position: Int) { + val status = adapter.peek(position)?.asStatusOrNull() ?: return + lifecycleScope.launch { + viewModel.translate(status) + .onFailure { + Snackbar.make( + requireView(), + getString(R.string.ui_error_translate, it.message), + Snackbar.LENGTH_LONG + ).show() + } + } + } + + override fun onUntranslate(position: Int) { + val status = adapter.peek(position)?.asStatusOrNull() ?: return + viewModel.untranslate(status) + } + override fun onFavourite(favourite: Boolean, position: Int) { val status = adapter.peek(position)?.asStatusOrNull() ?: return viewModel.favorite(favourite, status) @@ -447,7 +483,12 @@ class TimelineFragment : override fun onMore(view: View, position: Int) { val status = adapter.peek(position)?.asStatusOrNull() ?: return - super.more(status.status, view, position) + super.more( + status.status, + view, + position, + (status.translation as? TranslationViewData.Loaded)?.data + ) } override fun onOpenReblog(position: Int) { @@ -480,7 +521,8 @@ class TimelineFragment : override fun onLoadMore(position: Int) { val placeholder = adapter.peek(position)?.asPlaceholderOrNull() ?: return loadMorePosition = position - statusIdBelowLoadMore = if (position + 1 < adapter.itemCount) adapter.peek(position + 1)?.id else null + statusIdBelowLoadMore = + if (position + 1 < adapter.itemCount) adapter.peek(position + 1)?.id else null viewModel.loadMore(placeholder.id) } @@ -533,6 +575,7 @@ class TimelineFragment : PrefKeys.FAB_HIDE -> { hideFab = sharedPreferences.getBoolean(PrefKeys.FAB_HIDE, false) } + PrefKeys.MEDIA_PREVIEW_ENABLED -> { val enabled = accountManager.activeAccount!!.mediaPreviewEnabled val oldMediaPreviewEnabled = adapter.mediaPreviewEnabled @@ -541,6 +584,7 @@ class TimelineFragment : adapter.notifyItemRangeChanged(0, adapter.itemCount) } } + PrefKeys.READING_ORDER -> { readingOrder = ReadingOrder.from( sharedPreferences.getString(PrefKeys.READING_ORDER, null) @@ -555,10 +599,12 @@ class TimelineFragment : TimelineViewModel.Kind.PUBLIC_FEDERATED, TimelineViewModel.Kind.PUBLIC_LOCAL, TimelineViewModel.Kind.PUBLIC_TRENDING_STATUSES -> adapter.refresh() + TimelineViewModel.Kind.USER, TimelineViewModel.Kind.USER_WITH_REPLIES -> if (status.account.id == viewModel.id) { adapter.refresh() } + TimelineViewModel.Kind.TAG, TimelineViewModel.Kind.FAVOURITES, TimelineViewModel.Kind.LIST, @@ -583,13 +629,14 @@ class TimelineFragment : override fun onPause() { super.onPause() - (binding.recyclerView.layoutManager as? LinearLayoutManager)?.findFirstVisibleItemPosition()?.let { position -> - if (position != RecyclerView.NO_POSITION) { - adapter.snapshot().getOrNull(position)?.id?.let { statusId -> - viewModel.saveReadingPosition(statusId) + (binding.recyclerView.layoutManager as? LinearLayoutManager)?.findFirstVisibleItemPosition() + ?.let { position -> + if (position != RecyclerView.NO_POSITION) { + adapter.snapshot().getOrNull(position)?.id?.let { statusId -> + viewModel.saveReadingPosition(statusId) + } } } - } } override fun onResume() { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt index b09a59aee1..8ad59036e7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt @@ -29,6 +29,7 @@ import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.viewdata.StatusViewData +import com.keylesspalace.tusky.viewdata.TranslationViewData import java.util.Date private const val TAG = "TimelineTypeMappers" @@ -155,7 +156,7 @@ fun Status.toEntity( ) } -fun TimelineStatusWithAccount.toViewData(gson: Gson, isDetailed: Boolean = false): StatusViewData { +fun TimelineStatusWithAccount.toViewData(gson: Gson, isDetailed: Boolean = false, translation: TranslationViewData? = null): StatusViewData { if (this.account == null) { Log.d(TAG, "Constructing Placeholder(${this.status.serverId}, ${this.status.expanded})") return StatusViewData.Placeholder(this.status.serverId, this.status.expanded) @@ -199,7 +200,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson, isDetailed: Boolean = false card = card, repliesCount = status.repliesCount, language = status.language, - filtered = status.filtered + filtered = status.filtered, ) } val status = if (reblog != null) { @@ -244,7 +245,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson, isDetailed: Boolean = false inReplyToId = status.inReplyToId, inReplyToAccountId = status.inReplyToAccountId, reblog = null, - content = status.content.orEmpty(), + content = translation?.data?.content ?: status.content.orEmpty(), createdAt = Date(status.createdAt), editedAt = status.editedAt?.let { Date(it) }, emojis = emojis, @@ -274,6 +275,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson, isDetailed: Boolean = false isExpanded = this.status.expanded, isShowingContent = this.status.contentShowing, isCollapsed = this.status.contentCollapsed, - isDetailed = isDetailed + isDetailed = isDetailed, + translation = translation, ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt index 331af2702c..636587b24a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt @@ -26,6 +26,8 @@ import androidx.paging.cachedIn import androidx.paging.filter import androidx.paging.map import androidx.room.withTransaction +import at.connyduck.calladapter.networkresult.map +import at.connyduck.calladapter.networkresult.onFailure import com.google.gson.Gson import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder.NEWEST_FIRST @@ -44,12 +46,15 @@ import com.keylesspalace.tusky.network.FilterModel import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.usecase.TimelineCases import com.keylesspalace.tusky.util.EmptyPagingSource +import com.keylesspalace.tusky.util.asResult import com.keylesspalace.tusky.viewdata.StatusViewData +import com.keylesspalace.tusky.viewdata.TranslationViewData import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.asExecutor +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import retrofit2.HttpException @@ -76,6 +81,9 @@ class CachedTimelineViewModel @Inject constructor( private var currentPagingSource: PagingSource? = null + /** Map from status id to translation. */ + private val translations = MutableStateFlow(mapOf()) + @OptIn(ExperimentalPagingApi::class) override val statuses = Pager( config = PagingConfig(pageSize = LOAD_AT_ONCE), @@ -91,15 +99,24 @@ class CachedTimelineViewModel @Inject constructor( } } ).flow - .map { pagingData -> + // Apply cachedIn() early to be able to combine with translation flow. + // This will not cache ViewData's but practically we don't need this. + // If you notice that this flow is used in more than once place consider + // adding another cachedIn() for the overall result. + .cachedIn(viewModelScope) + .combine(translations) { pagingData, translations -> pagingData.map(Dispatchers.Default.asExecutor()) { timelineStatus -> - timelineStatus.toViewData(gson) + val translation = translations[timelineStatus.status.serverId] + timelineStatus.toViewData( + gson, + isDetailed = false, + translation = translation + ) }.filter(Dispatchers.Default.asExecutor()) { statusViewData -> shouldFilterStatus(statusViewData) != Filter.Action.HIDE } } .flowOn(Dispatchers.Default) - .cachedIn(viewModelScope) override fun updatePoll(newPoll: Poll, status: StatusViewData.Concrete) { // handled by CacheUpdater @@ -276,8 +293,24 @@ class CachedTimelineViewModel @Inject constructor( } } + override suspend fun translate(status: StatusViewData.Concrete): Result { + translations.value = translations.value + (status.id to TranslationViewData.Loading) + return timelineCases.translate(status.actionableId) + .map { translation -> + translations.value = + translations.value + (status.id to TranslationViewData.Loaded(translation)) + } + .onFailure { + translations.value = translations.value - status.id + } + .asResult() + } + + override fun untranslate(status: StatusViewData.Concrete) { + translations.value = translations.value - status.id + } + companion object { private const val TAG = "CachedTimelineViewModel" - private const val MAX_STATUSES_IN_CACHE = 1000 } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt index ebfebd58e5..ef9da7f388 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt @@ -23,6 +23,8 @@ import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.cachedIn import androidx.paging.filter +import at.connyduck.calladapter.networkresult.map +import at.connyduck.calladapter.networkresult.onFailure import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.db.AccountManager @@ -32,11 +34,13 @@ import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.FilterModel import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.usecase.TimelineCases +import com.keylesspalace.tusky.util.asResult import com.keylesspalace.tusky.util.getDomain import com.keylesspalace.tusky.util.isLessThan import com.keylesspalace.tusky.util.isLessThanOrEqual import com.keylesspalace.tusky.util.toViewData import com.keylesspalace.tusky.viewdata.StatusViewData +import com.keylesspalace.tusky.viewdata.TranslationViewData import java.io.IOException import javax.inject.Inject import kotlinx.coroutines.Dispatchers @@ -145,7 +149,8 @@ class NetworkTimelineViewModel @Inject constructor( try { val placeholderIndex = statusData.indexOfFirst { it is StatusViewData.Placeholder && it.id == placeholderId } - statusData[placeholderIndex] = StatusViewData.Placeholder(placeholderId, isLoading = true) + statusData[placeholderIndex] = + StatusViewData.Placeholder(placeholderId, isLoading = true) val idAbovePlaceholder = statusData.getOrNull(placeholderIndex - 1)?.id @@ -178,7 +183,9 @@ class NetworkTimelineViewModel @Inject constructor( val overlappedFrom = statusData.indexOfFirst { it.asStatusOrNull()?.id?.isLessThanOrEqual(firstId) ?: false } - val overlappedTo = statusData.indexOfFirst { it.asStatusOrNull()?.id?.isLessThan(lastId) ?: false } + val overlappedTo = statusData.indexOfFirst { + it.asStatusOrNull()?.id?.isLessThan(lastId) ?: false + } if (overlappedFrom < overlappedTo) { data.mapIndexed { i, status -> @@ -198,12 +205,18 @@ class NetworkTimelineViewModel @Inject constructor( statusData.removeAll { status -> when (status) { - is StatusViewData.Placeholder -> lastId.isLessThan(status.id) && status.id.isLessThanOrEqual(firstId) - is StatusViewData.Concrete -> lastId.isLessThan(status.id) && status.id.isLessThanOrEqual(firstId) + is StatusViewData.Placeholder -> lastId.isLessThan(status.id) && status.id.isLessThanOrEqual( + firstId + ) + + is StatusViewData.Concrete -> lastId.isLessThan(status.id) && status.id.isLessThanOrEqual( + firstId + ) } } } else { - data[data.size - 1] = StatusViewData.Placeholder(statuses.last().id, isLoading = false) + data[data.size - 1] = + StatusViewData.Placeholder(statuses.last().id, isLoading = false) } } @@ -258,6 +271,22 @@ class NetworkTimelineViewModel @Inject constructor( currentSource?.invalidate() } + override suspend fun translate(status: StatusViewData.Concrete): Result { + status.copy(translation = TranslationViewData.Loading).update() + return timelineCases.translate(status.actionableId) + .map { translation -> + status.copy(translation = TranslationViewData.Loaded(translation)).update() + } + .onFailure { + status.update() + } + .asResult() + } + + override fun untranslate(status: StatusViewData.Concrete) { + status.copy(translation = null).update() + } + @Throws(IOException::class, HttpException::class) suspend fun fetchStatusesForKind( fromId: String?, @@ -273,6 +302,7 @@ class NetworkTimelineViewModel @Inject constructor( val additionalHashtags = tags.subList(1, tags.size) api.hashtagTimeline(firstHashtag, additionalHashtags, null, fromId, uptoId, limit) } + Kind.USER -> api.accountStatuses( id!!, fromId, @@ -282,6 +312,7 @@ class NetworkTimelineViewModel @Inject constructor( onlyMedia = null, pinned = null ) + Kind.USER_PINNED -> api.accountStatuses( id!!, fromId, @@ -291,6 +322,7 @@ class NetworkTimelineViewModel @Inject constructor( onlyMedia = null, pinned = true ) + Kind.USER_WITH_REPLIES -> api.accountStatuses( id!!, fromId, @@ -300,6 +332,7 @@ class NetworkTimelineViewModel @Inject constructor( onlyMedia = null, pinned = null ) + Kind.FAVOURITES -> api.favourites(fromId, uptoId, limit) Kind.BOOKMARKS -> api.bookmarks(fromId, uptoId, limit) Kind.LIST -> api.listTimeline(id!!, fromId, uptoId, limit) @@ -308,7 +341,8 @@ class NetworkTimelineViewModel @Inject constructor( } private fun StatusViewData.Concrete.update() { - val position = statusData.indexOfFirst { viewData -> viewData.asStatusOrNull()?.id == this.id } + val position = + statusData.indexOfFirst { viewData -> viewData.asStatusOrNull()?.id == this.id } statusData[position] = this currentSource?.invalidate() } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt index d5482431e2..23a7bf4bde 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt @@ -52,7 +52,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch abstract class TimelineViewModel( - private val timelineCases: TimelineCases, + protected val timelineCases: TimelineCases, private val api: MastodonApi, private val eventHub: EventHub, protected val accountManager: AccountManager, @@ -312,6 +312,9 @@ abstract class TimelineViewModel( } } + abstract suspend fun translate(status: StatusViewData.Concrete): Result + abstract fun untranslate(status: StatusViewData.Concrete) + companion object { private const val TAG = "TimelineVM" internal const val LOAD_AT_ONCE = 30 diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt index 2617557c7a..a7e400316b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt @@ -56,6 +56,7 @@ import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.viewdata.AttachmentViewData.Companion.list import com.keylesspalace.tusky.viewdata.StatusViewData +import com.keylesspalace.tusky.viewdata.TranslationViewData import javax.inject.Inject import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.awaitCancellation @@ -166,6 +167,7 @@ class ViewThreadFragment : initialProgressBar = getProgressBarJob(binding.initialProgressBar, 500) initialProgressBar.start() } + is ThreadUiState.LoadingThread -> { if (uiState.statusViewDatum == null) { // no detailed statuses available, e.g. because author is blocked @@ -189,6 +191,7 @@ class ViewThreadFragment : binding.recyclerView.show() binding.statusView.hide() } + is ThreadUiState.Error -> { Log.w(TAG, "failed to load status", uiState.throwable) initialProgressBar.cancel() @@ -204,6 +207,7 @@ class ViewThreadFragment : uiState.throwable ) { viewModel.retry(thisThreadsStatusId) } } + is ThreadUiState.Success -> { if (uiState.statusViewData.none { viewData -> viewData.isDetailed }) { // no detailed statuses available, e.g. because author is blocked @@ -231,6 +235,7 @@ class ViewThreadFragment : binding.recyclerView.show() binding.statusView.hide() } + is ThreadUiState.Refreshing -> { threadProgressBar.cancel() } @@ -270,14 +275,17 @@ class ViewThreadFragment : viewModel.toggleRevealButton() true } + R.id.action_open_in_web -> { context?.openLink(requireArguments().getString(URL_EXTRA)!!) true } + R.id.action_refresh -> { onRefresh() true } + else -> false } } @@ -323,6 +331,36 @@ class ViewThreadFragment : viewModel.reblog(reblog, status) } + override val onMoreTranslate: ((translate: Boolean, position: Int) -> Unit) = + { translate: Boolean, position: Int -> + if (translate) { + onTranslate(position) + } else { + onUntranslate( + position + ) + } + } + + private fun onTranslate(position: Int) { + val status = adapter.currentList[position] + lifecycleScope.launch { + viewModel.translate(status) + .onFailure { + Snackbar.make( + requireView(), + getString(R.string.ui_error_translate, it.message), + Snackbar.LENGTH_LONG + ).show() + } + } + } + + override fun onUntranslate(position: Int) { + val status = adapter.currentList[position] + viewModel.untranslate(status) + } + override fun onFavourite(favourite: Boolean, position: Int) { val status = adapter.currentList[position] viewModel.favorite(favourite, status) @@ -334,7 +372,13 @@ class ViewThreadFragment : } override fun onMore(view: View, position: Int) { - super.more(adapter.currentList[position].status, view, position) + val viewData = adapter.currentList[position] + super.more( + viewData.status, + view, + position, + (viewData.translation as? TranslationViewData.Loaded)?.data + ) } override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt index a5607f354c..be5c2d89c5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt @@ -21,6 +21,8 @@ import androidx.lifecycle.viewModelScope import at.connyduck.calladapter.networkresult.fold import at.connyduck.calladapter.networkresult.getOrElse import at.connyduck.calladapter.networkresult.getOrThrow +import at.connyduck.calladapter.networkresult.map +import at.connyduck.calladapter.networkresult.onFailure import com.google.gson.Gson import com.keylesspalace.tusky.appstore.BlockEvent import com.keylesspalace.tusky.appstore.EventHub @@ -37,9 +39,11 @@ import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.FilterModel import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.usecase.TimelineCases +import com.keylesspalace.tusky.util.asResult import com.keylesspalace.tusky.util.isHttpNotFound import com.keylesspalace.tusky.util.toViewData import com.keylesspalace.tusky.viewdata.StatusViewData +import com.keylesspalace.tusky.viewdata.TranslationViewData import javax.inject.Inject import kotlinx.coroutines.Job import kotlinx.coroutines.async @@ -110,7 +114,7 @@ class ViewThreadViewModel @Inject constructor( Log.d(TAG, "Loaded status from local timeline") val viewData = timelineStatus.toViewData( gson, - isDetailed = true + isDetailed = true, ) as StatusViewData.Concrete // Return the correct status, depending on which one matched. If you do not do @@ -154,8 +158,10 @@ class ViewThreadViewModel @Inject constructor( val contextResult = contextCall.await() contextResult.fold({ statusContext -> - val ancestors = statusContext.ancestors.map { status -> status.toViewData() }.filter() - val descendants = statusContext.descendants.map { status -> status.toViewData() }.filter() + val ancestors = + statusContext.ancestors.map { status -> status.toViewData() }.filter() + val descendants = + statusContext.descendants.map { status -> status.toViewData() }.filter() val statuses = ancestors + detailedStatus + descendants _uiState.value = ThreadUiState.Success( @@ -189,6 +195,7 @@ class ViewThreadViewModel @Inject constructor( is ThreadUiState.Success -> uiState.statusViewData.find { status -> status.isDetailed } + is ThreadUiState.LoadingThread -> uiState.statusViewDatum else -> null } @@ -281,13 +288,38 @@ class ViewThreadViewModel @Inject constructor( } } + suspend fun translate(status: StatusViewData.Concrete): Result { + updateStatusViewData(status.id) { viewData -> + viewData.copy(translation = TranslationViewData.Loading) + } + return timelineCases.translate(status.actionableId) + .map { translation -> + updateStatusViewData(status.id) { viewData -> + viewData.copy(translation = TranslationViewData.Loaded(translation)) + } + } + .onFailure { + updateStatusViewData(status.id) { viewData -> + viewData.copy(translation = null) + } + } + .asResult() + } + + fun untranslate(status: StatusViewData.Concrete) { + updateStatusViewData(status.id) { viewData -> + viewData.copy(translation = null) + } + } + private fun handleStatusChangedEvent(status: Status) { updateStatusViewData(status.id) { viewData -> status.toViewData( isShowingContent = viewData.isShowingContent, isExpanded = viewData.isExpanded, isCollapsed = viewData.isCollapsed, - isDetailed = viewData.isDetailed + isDetailed = viewData.isDetailed, + translation = viewData.translation, ) } } @@ -307,7 +339,8 @@ class ViewThreadViewModel @Inject constructor( updateSuccess { uiState -> val statuses = uiState.statusViewData val detailedIndex = statuses.indexOfFirst { status -> status.isDetailed } - val repliedIndex = statuses.indexOfFirst { status -> eventStatus.inReplyToId == status.id } + val repliedIndex = + statuses.indexOfFirst { status -> eventStatus.inReplyToId == status.id } if (detailedIndex != -1 && repliedIndex >= detailedIndex) { // there is a new reply to the detailed status or below -> display it val newStatuses = statuses.subList(0, repliedIndex + 1) + @@ -339,12 +372,14 @@ class ViewThreadViewModel @Inject constructor( }, revealButton = RevealButtonState.REVEAL ) + RevealButtonState.REVEAL -> uiState.copy( statusViewData = uiState.statusViewData.map { viewData -> viewData.copy(isExpanded = true) }, revealButton = RevealButtonState.HIDE ) + else -> uiState } } @@ -441,7 +476,8 @@ class ViewThreadViewModel @Inject constructor( it.id == this.id } return toViewData( - isShowingContent = oldStatus?.isShowingContent ?: (alwaysShowSensitiveMedia || !actionableStatus.sensitive), + isShowingContent = oldStatus?.isShowingContent + ?: (alwaysShowSensitiveMedia || !actionableStatus.sensitive), isExpanded = oldStatus?.isExpanded ?: alwaysOpenSpoiler, isCollapsed = oldStatus?.isCollapsed ?: !isDetailed, isDetailed = oldStatus?.isDetailed ?: isDetailed 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 08014baa99..39261cb555 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java +++ b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java @@ -44,13 +44,14 @@ }, // Note: Starting with version 54, database versions in Tusky are always even. // This is to reserve odd version numbers for use by forks. - version = 56, + version = 58, autoMigrations = { @AutoMigration(from = 48, to = 49), @AutoMigration(from = 49, to = 50, spec = AppDatabase.MIGRATION_49_50.class), @AutoMigration(from = 50, to = 51), @AutoMigration(from = 51, to = 52), - @AutoMigration(from = 53, to = 54) // hasDirectMessageBadge in AccountEntity + @AutoMigration(from = 53, to = 54), // hasDirectMessageBadge in AccountEntity + @AutoMigration(from = 56, to = 58) // translationEnabled in InstanceEntity/InstanceInfoEntity } ) public abstract class AppDatabase extends RoomDatabase { diff --git a/app/src/main/java/com/keylesspalace/tusky/db/InstanceEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/InstanceEntity.kt index efcfe5278c..4db2ee0507 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/InstanceEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/InstanceEntity.kt @@ -38,7 +38,8 @@ data class InstanceEntity( val maxMediaAttachments: Int?, val maxFields: Int?, val maxFieldNameLength: Int?, - val maxFieldValueLength: Int? + val maxFieldValueLength: Int?, + val translationEnabled: Boolean?, ) @TypeConverters(Converters::class) @@ -62,5 +63,6 @@ data class InstanceInfoEntity( val maxMediaAttachments: Int?, val maxFields: Int?, val maxFieldNameLength: Int?, - val maxFieldValueLength: Int? + val maxFieldValueLength: Int?, + val translationEnabled: Boolean?, ) 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 18d5faec56..ffcd1f60b7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt @@ -49,8 +49,11 @@ data class Status( val pinned: Boolean?, val muted: Boolean?, val poll: Poll?, + /** Preview card for links included within status content. */ val card: Card?, + /** ISO 639 language code for this status. */ val language: String?, + /** If the current token has an authorized user: The filter and keywords that matched this status. */ val filtered: List? ) { diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Translation.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Translation.kt new file mode 100644 index 0000000000..1eee91d97f --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Translation.kt @@ -0,0 +1,25 @@ +package com.keylesspalace.tusky.entity + +import com.google.gson.annotations.SerializedName + +data class MediaTranslation( + val id: String, + val description: String, +) + +/** + * Represents the result of machine translating some status content. + * + * See [doc](https://docs.joinmastodon.org/entities/Translation/). + */ +data class Translation( + val content: String, + @SerializedName("spoiler_warning") + val spoilerWarning: String?, + val poll: List?, + @SerializedName("media_attachments") + val mediaAttachments: List, + @SerializedName("detected_source_language") + val detectedSourceLanguage: String, + val provider: String, +) 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 84a464f6e0..5f0879d79d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java @@ -109,16 +109,17 @@ import kotlin.Unit; import kotlin.collections.CollectionsKt; import kotlin.jvm.functions.Function1; +import kotlin.jvm.functions.Function2; import kotlinx.coroutines.Job; public class NotificationsFragment extends SFragment implements - SwipeRefreshLayout.OnRefreshListener, - StatusActionListener, - NotificationsAdapter.NotificationActionListener, - AccountActionListener, - Injectable, - MenuProvider, - ReselectableFragment { + SwipeRefreshLayout.OnRefreshListener, + StatusActionListener, + NotificationsAdapter.NotificationActionListener, + AccountActionListener, + Injectable, + MenuProvider, + ReselectableFragment { private static final String TAG = "NotificationF"; // logging tag private static final int LOAD_AT_ONCE = 30; @@ -171,7 +172,7 @@ private Placeholder(long id) { // Each element is either a Notification for loading data or a Placeholder private final PairedList, NotificationViewData> notifications - = new PairedList<>(new Function<>() { + = new PairedList<>(new Function<>() { @Override public NotificationViewData apply(Either input) { if (input.isRight()) { @@ -227,36 +228,36 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c layoutManager = new LinearLayoutManager(context); binding.recyclerView.setLayoutManager(layoutManager); binding.recyclerView.setAccessibilityDelegateCompat( - new ListStatusAccessibilityDelegate(binding.recyclerView, this, (pos) -> { - NotificationViewData notification = notifications.getPairedItemOrNull(pos); - // We support replies only for now - if (notification instanceof NotificationViewData.Concrete) { - return ((NotificationViewData.Concrete) notification).getStatusViewData(); - } else { - return null; - } - })); + new ListStatusAccessibilityDelegate(binding.recyclerView, this, (pos) -> { + NotificationViewData notification = notifications.getPairedItemOrNull(pos); + // We support replies only for now + if (notification instanceof NotificationViewData.Concrete) { + return ((NotificationViewData.Concrete) notification).getStatusViewData(); + } else { + return null; + } + })); binding.recyclerView.addItemDecoration(new DividerItemDecoration(context, DividerItemDecoration.VERTICAL)); StatusDisplayOptions statusDisplayOptions = new StatusDisplayOptions( - preferences.getBoolean("animateGifAvatars", false), - accountManager.getActiveAccount().getMediaPreviewEnabled(), - preferences.getBoolean("absoluteTimeView", false), - preferences.getBoolean("showBotOverlay", true), - preferences.getBoolean("useBlurhash", true), - CardViewMode.NONE, - preferences.getBoolean("confirmReblogs", true), - preferences.getBoolean("confirmFavourites", false), - preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), - preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), - preferences.getBoolean(PrefKeys.SHOW_STATS_INLINE, false), - accountManager.getActiveAccount().getAlwaysShowSensitiveMedia(), - accountManager.getActiveAccount().getAlwaysOpenSpoiler() + preferences.getBoolean("animateGifAvatars", false), + accountManager.getActiveAccount().getMediaPreviewEnabled(), + preferences.getBoolean("absoluteTimeView", false), + preferences.getBoolean("showBotOverlay", true), + preferences.getBoolean("useBlurhash", true), + CardViewMode.NONE, + preferences.getBoolean("confirmReblogs", true), + preferences.getBoolean("confirmFavourites", false), + preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), + preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), + preferences.getBoolean(PrefKeys.SHOW_STATS_INLINE, false), + accountManager.getActiveAccount().getAlwaysShowSensitiveMedia(), + accountManager.getActiveAccount().getAlwaysOpenSpoiler() ); adapter = new NotificationsAdapter(accountManager.getActiveAccount().getAccountId(), - dataSource, statusDisplayOptions, this, this, this); + dataSource, statusDisplayOptions, this, this, this); alwaysShowSensitiveMedia = accountManager.getActiveAccount().getAlwaysShowSensitiveMedia(); alwaysOpenSpoiler = accountManager.getActiveAccount().getAlwaysOpenSpoiler(); binding.recyclerView.setAdapter(adapter); @@ -314,7 +315,7 @@ public boolean onMenuItemSelected(@NonNull MenuItem menuItem) { private void updateFilterVisibility() { CoordinatorLayout.LayoutParams params = - (CoordinatorLayout.LayoutParams) binding.swipeRefreshLayout.getLayoutParams(); + (CoordinatorLayout.LayoutParams) binding.swipeRefreshLayout.getLayoutParams(); if (showNotificationsFilter && !showingError) { binding.appBarOptions.setExpanded(true, false); binding.appBarOptions.setVisibility(View.VISIBLE); @@ -330,10 +331,10 @@ private void updateFilterVisibility() { private void confirmClearNotifications() { new AlertDialog.Builder(requireContext()) - .setMessage(R.string.notification_clear_text) - .setPositiveButton(android.R.string.ok, (DialogInterface dia, int which) -> clearNotifications()) - .setNegativeButton(android.R.string.cancel, null) - .show(); + .setMessage(R.string.notification_clear_text) + .setPositiveButton(android.R.string.ok, (DialogInterface dia, int which) -> clearNotifications()) + .setNegativeButton(android.R.string.cancel, null) + .show(); } @Override @@ -408,6 +409,12 @@ public void onRefresh() { sendFetchNotificationsRequest(null, topId, FetchEnd.TOP, -1); } + @Nullable + @Override + protected Function2 getOnMoreTranslate() { + return null; + } + @Override public void onReply(int position) { super.reply(notifications.get(position).asRight().getStatus()); @@ -490,7 +497,7 @@ private void setVoteForPoll(Status status, Poll poll) { @Override public void onMore(@NonNull View view, int position) { Notification notification = notifications.get(position).asRight(); - super.more(notification.getStatus(), view, position); + super.more(notification.getStatus(), view, position, null); } @Override @@ -525,10 +532,6 @@ public void onContentHiddenChange(boolean isShowing, int position) { updateViewDataAt(position, (vd) -> vd.copyWithShowingContent(isShowing)); } - private void setPinForStatus(String statusId, boolean pinned) { - updateStatus(statusId, status -> status.copyWithPinned(pinned)); - } - @Override public void onLoadMore(int position) { // Check bounds before accessing list, @@ -542,7 +545,7 @@ public void onLoadMore(int position) { sendFetchNotificationsRequest(previous.getId(), next.getId(), FetchEnd.MIDDLE, position); Placeholder placeholder = notifications.get(position).asLeft(); NotificationViewData notificationViewData = - new NotificationViewData.Placeholder(placeholder.id, true); + new NotificationViewData.Placeholder(placeholder.id, true); notifications.setPairedItem(position, notificationViewData); updateAdapter(); } else { @@ -555,10 +558,15 @@ public void onContentCollapsedChange(boolean isCollapsed, int position) { updateViewDataAt(position, (vd) -> vd.copyWithCollapsed(isCollapsed)); } + @Override + public void onUntranslate(int position) { + // not needed + } + private void updateStatus(String statusId, Function mapper) { int index = CollectionsKt.indexOfFirst(this.notifications, (s) -> s.isRight() && - s.asRight().getStatus() != null && - s.asRight().getStatus().getId().equals(statusId)); + s.asRight().getStatus() != null && + s.asRight().getStatus().getId().equals(statusId)); if (index == -1) return; // We have quite some graph here: @@ -580,12 +588,12 @@ private void updateStatus(String statusId, Function mapper) { Status oldStatus = notifications.get(index).asRight().getStatus(); NotificationViewData.Concrete oldViewData = - (NotificationViewData.Concrete) this.notifications.getPairedItem(index); + (NotificationViewData.Concrete) this.notifications.getPairedItem(index); Status newStatus = mapper.apply(oldStatus); Notification newNotification = this.notifications.get(index).asRight() - .copyWithStatus(newStatus); + .copyWithStatus(newStatus); StatusViewData.Concrete newStatusViewData = - Objects.requireNonNull(oldViewData.getStatusViewData()).copyWithStatus(newStatus); + Objects.requireNonNull(oldViewData.getStatusViewData()).copyWithStatus(newStatus); NotificationViewData.Concrete newViewData = oldViewData.copyWithStatus(newStatusViewData); notifications.set(index, new Either.Right<>(newNotification)); @@ -598,10 +606,10 @@ private void updateViewDataAt(int position, Function mapper) { if (position < 0 || position >= notifications.size()) { String message = String.format( - Locale.getDefault(), - "Tried to access out of bounds status position: %d of %d", - position, - notifications.size() - 1 + Locale.getDefault(), + "Tried to access out of bounds status position: %d of %d", + position, + notifications.size() - 1 ); Log.e(TAG, message); return; @@ -615,7 +623,7 @@ private void updateViewDataAt(int position, if (oldStatusViewData == null) return; NotificationViewData.Concrete newViewData = - oldViewData.copyWithStatus(mapper.apply(oldStatusViewData)); + oldViewData.copyWithStatus(mapper.apply(oldStatusViewData)); notifications.setPairedItem(position, newViewData); updateAdapter(); @@ -680,17 +688,17 @@ private void showFilterMenu() { View view = LayoutInflater.from(getContext()).inflate(R.layout.notifications_filter, (ViewGroup) getView(), false); final ListView listView = view.findViewById(R.id.listView); view.findViewById(R.id.buttonApply) - .setOnClickListener(v -> { - SparseBooleanArray checkedItems = listView.getCheckedItemPositions(); - Set excludes = new HashSet<>(); - for (int i = 0; i < notificationsList.size(); i++) { - if (!checkedItems.get(i, false)) - excludes.add(notificationsList.get(i)); - } - window.dismiss(); - applyFilterChanges(excludes); + .setOnClickListener(v -> { + SparseBooleanArray checkedItems = listView.getCheckedItemPositions(); + Set excludes = new HashSet<>(); + for (int i = 0; i < notificationsList.size(); i++) { + if (!checkedItems.get(i, false)) + excludes.add(notificationsList.get(i)); + } + window.dismiss(); + applyFilterChanges(excludes); - }); + }); listView.setAdapter(adapter); listView.setChoiceMode(ListView.CHOICE_MODE_MULTIPLE); @@ -757,7 +765,7 @@ private void loadNotificationsFilter() { if (account != null) { notificationFilter.clear(); notificationFilter.addAll(NotificationTypeConverterKt.deserialize( - account.getNotificationsFilter())); + account.getNotificationsFilter())); } } @@ -880,7 +888,7 @@ private void onLoadMore() { final Placeholder placeholder = newPlaceholder(); notifications.add(new Either.Left<>(placeholder)); NotificationViewData viewData = - new NotificationViewData.Placeholder(placeholder.id, true); + new NotificationViewData.Placeholder(placeholder.id, true); notifications.setPairedItem(notifications.size() - 1, viewData); updateAdapter(); } @@ -957,7 +965,7 @@ private void onFetchNotificationsSuccess(List notifications, Strin case BOTTOM: { if (!this.notifications.isEmpty() - && !this.notifications.get(this.notifications.size() - 1).isRight()) { + && !this.notifications.get(this.notifications.size() - 1).isRight()) { this.notifications.remove(this.notifications.size() - 1); updateAdapter(); } @@ -997,7 +1005,7 @@ private void onFetchNotificationsFailure(Throwable throwable, FetchEnd fetchEnd, if (fetchEnd == FetchEnd.MIDDLE && !notifications.get(position).isRight()) { Placeholder placeholder = notifications.get(position).asLeft(); NotificationViewData placeholderVD = - new NotificationViewData.Placeholder(placeholder.id, false); + new NotificationViewData.Placeholder(placeholder.id, false); notifications.setPairedItem(position, placeholderVD); updateAdapter(); } else if (this.notifications.isEmpty()) { @@ -1060,7 +1068,7 @@ private void update(@Nullable List newNotifications, @Nullable Str bottomId = fromId; } List> liftedNew = - liftNotificationList(newNotifications); + liftNotificationList(newNotifications); if (notifications.isEmpty()) { notifications.addAll(liftedNew); } else { @@ -1119,7 +1127,7 @@ private void replacePlaceholderWithNotifications(List newNotificat } private final Function1> notificationLifter = - Either.Right::new; + Either.Right::new; private List> liftNotificationList(List list) { return CollectionsKt.map(list, notificationLifter); @@ -1144,11 +1152,11 @@ private Pair findReplyPosition(@NonNull String statusId) for (int i = 0; i < notifications.size(); i++) { Notification notification = notifications.get(i).asRightOrNull(); if (notification != null - && notification.getStatus() != null - && notification.getType() == Notification.Type.MENTION - && (statusId.equals(notification.getStatus().getId()) - || (notification.getStatus().getReblog() != null - && statusId.equals(notification.getStatus().getReblog().getId())))) { + && notification.getStatus() != null + && notification.getType() == Notification.Type.MENTION + && (statusId.equals(notification.getStatus().getId()) + || (notification.getStatus().getReblog() != null + && statusId.equals(notification.getStatus().getReblog().getId())))) { return new Pair<>(i, notification); } } @@ -1190,8 +1198,8 @@ public void onChanged(int position, int count, Object payload) { }; private final AsyncListDiffer - differ = new AsyncListDiffer<>(listUpdateCallback, - new AsyncDifferConfig.Builder<>(diffCallback).build()); + differ = new AsyncListDiffer<>(listUpdateCallback, + new AsyncDifferConfig.Builder<>(diffCallback).build()); private final NotificationsAdapter.AdapterDataSource dataSource = new NotificationsAdapter.AdapterDataSource<>() { @@ -1207,7 +1215,7 @@ public NotificationViewData getItemAt(int pos) { }; private static final DiffUtil.ItemCallback diffCallback - = new DiffUtil.ItemCallback<>() { + = new DiffUtil.ItemCallback<>() { @Override public boolean areItemsTheSame(NotificationViewData oldItem, NotificationViewData newItem) { diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.kt index 1acaa65d4b..50b7ba57c5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.kt @@ -46,12 +46,14 @@ import com.keylesspalace.tusky.ViewMediaActivity.Companion.newIntent import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.components.compose.ComposeActivity.Companion.startIntent import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeOptions +import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository import com.keylesspalace.tusky.components.report.ReportActivity.Companion.getIntent import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.entity.Translation import com.keylesspalace.tusky.interfaces.AccountSelectionListener import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.usecase.TimelineCases @@ -60,6 +62,7 @@ import com.keylesspalace.tusky.util.parseAsMastodonHtml import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation import com.keylesspalace.tusky.view.showMuteAccountDialog import com.keylesspalace.tusky.viewdata.AttachmentViewData +import java.util.Locale import javax.inject.Inject import kotlinx.coroutines.launch @@ -72,6 +75,10 @@ import kotlinx.coroutines.launch abstract class SFragment : Fragment(), Injectable { protected abstract fun removeItem(position: Int) protected abstract fun onReblog(reblog: Boolean, position: Int) + + /** `null` if translation is not supported on this screen */ + protected abstract val onMoreTranslate: ((translate: Boolean, position: Int) -> Unit)? + private lateinit var bottomSheetActivity: BottomSheetActivity @Inject @@ -83,6 +90,9 @@ abstract class SFragment : Fragment(), Injectable { @Inject lateinit var timelineCases: TimelineCases + @Inject + lateinit var instanceInfoRepository: InstanceInfoRepository + override fun startActivity(intent: Intent) { requireActivity().startActivityWithSlideInAnimation(intent) } @@ -140,170 +150,201 @@ abstract class SFragment : Fragment(), Injectable { requireActivity().startActivity(intent) } - protected fun more(status: Status, view: View, position: Int) { - val id = status.actionableId - val accountId = status.actionableStatus.account.id - val accountUsername = status.actionableStatus.account.username - val statusUrl = status.actionableStatus.url - var loggedInAccountId: String? = null - val activeAccount = accountManager.activeAccount - if (activeAccount != null) { - loggedInAccountId = activeAccount.accountId - } - val popup = PopupMenu(requireContext(), view) - // Give a different menu depending on whether this is the user's own toot or not. - val statusIsByCurrentUser = loggedInAccountId != null && loggedInAccountId == accountId - if (statusIsByCurrentUser) { - popup.inflate(R.menu.status_more_for_user) - val menu = popup.menu - when (status.visibility) { - Status.Visibility.PUBLIC, Status.Visibility.UNLISTED -> { - menu.add( - 0, - R.id.pin, - 1, - getString( - if (status.isPinned()) R.string.unpin_action else R.string.pin_action + protected fun more(status: Status, view: View, position: Int, translation: Translation?) = + lifecycleScope.launch { + val id = status.actionableId + val accountId = status.actionableStatus.account.id + val accountUsername = status.actionableStatus.account.username + val statusUrl = status.actionableStatus.url + var loggedInAccountId: String? = null + val activeAccount = accountManager.activeAccount + if (activeAccount != null) { + loggedInAccountId = activeAccount.accountId + } + val popup = PopupMenu(requireContext(), view) + // Give a different menu depending on whether this is the user's own toot or not. + val statusIsByCurrentUser = loggedInAccountId != null && loggedInAccountId == accountId + if (statusIsByCurrentUser) { + popup.inflate(R.menu.status_more_for_user) + val menu = popup.menu + when (status.visibility) { + Status.Visibility.PUBLIC, Status.Visibility.UNLISTED -> { + menu.add( + 0, + R.id.pin, + 1, + getString( + if (status.isPinned()) R.string.unpin_action else R.string.pin_action + ) ) - ) - } - Status.Visibility.PRIVATE -> { - val reblogged = status.reblog?.reblogged ?: status.reblogged - menu.findItem(R.id.status_reblog_private).isVisible = !reblogged - menu.findItem(R.id.status_unreblog_private).isVisible = reblogged + } + + Status.Visibility.PRIVATE -> { + val reblogged = status.reblog?.reblogged ?: status.reblogged + menu.findItem(R.id.status_reblog_private).isVisible = !reblogged + menu.findItem(R.id.status_unreblog_private).isVisible = reblogged + } + + else -> {} } - else -> {} + } else { + popup.inflate(R.menu.status_more) + popup.menu.findItem(R.id.status_download_media).isVisible = + status.attachments.isNotEmpty() } - } else { - popup.inflate(R.menu.status_more) - popup.menu.findItem(R.id.status_download_media).isVisible = status.attachments.isNotEmpty() - } - val menu = popup.menu - val openAsItem = menu.findItem(R.id.status_open_as) - val openAsText = (activity as BaseActivity?)?.openAsText - if (openAsText == null) { - openAsItem.isVisible = false - } else { - openAsItem.title = openAsText - } - val muteConversationItem = menu.findItem(R.id.status_mute_conversation) - val mutable = statusIsByCurrentUser || accountIsInMentions(activeAccount, status.mentions) - muteConversationItem.isVisible = mutable - if (mutable) { - muteConversationItem.setTitle( - if (status.muted != true) { - R.string.action_mute_conversation - } else { - R.string.action_unmute_conversation - } - ) - } - popup.setOnMenuItemClickListener { item: MenuItem -> - when (item.itemId) { - R.id.post_share_content -> { - val statusToShare = status.reblog ?: status - val sendIntent = Intent().apply { - action = Intent.ACTION_SEND - type = "text/plain" - putExtra( - Intent.EXTRA_TEXT, - "${statusToShare.account.username} - ${statusToShare.content.parseAsMastodonHtml()}" - ) - putExtra(Intent.EXTRA_SUBJECT, statusUrl) + val menu = popup.menu + val openAsItem = menu.findItem(R.id.status_open_as) + val openAsText = (activity as BaseActivity?)?.openAsText + if (openAsText == null) { + openAsItem.isVisible = false + } else { + openAsItem.title = openAsText + } + val muteConversationItem = menu.findItem(R.id.status_mute_conversation) + val mutable = + statusIsByCurrentUser || accountIsInMentions(activeAccount, status.mentions) + muteConversationItem.isVisible = mutable + if (mutable) { + muteConversationItem.setTitle( + if (status.muted != true) { + R.string.action_mute_conversation + } else { + R.string.action_unmute_conversation } - startActivity( - Intent.createChooser( - sendIntent, - resources.getText(R.string.send_post_content_to) + ) + } + + val translateItem = menu.findItem(R.id.status_translate) + translateItem.isVisible = + onMoreTranslate != null && status.language != Locale.getDefault().language && instanceInfoRepository.getInstanceInfo().translationEnabled == true + translateItem.setTitle(if (translation != null) R.string.action_show_original else R.string.action_translate) + + popup.setOnMenuItemClickListener { item: MenuItem -> + when (item.itemId) { + R.id.post_share_content -> { + val statusToShare = status.reblog ?: status + val sendIntent = Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra( + Intent.EXTRA_TEXT, + "${statusToShare.account.username} - ${statusToShare.content.parseAsMastodonHtml()}" + ) + putExtra(Intent.EXTRA_SUBJECT, statusUrl) + } + startActivity( + Intent.createChooser( + sendIntent, + resources.getText(R.string.send_post_content_to) + ) ) - ) - return@setOnMenuItemClickListener true - } - R.id.post_share_link -> { - val sendIntent = Intent().apply { - action = Intent.ACTION_SEND - putExtra(Intent.EXTRA_TEXT, statusUrl) - type = "text/plain" + return@setOnMenuItemClickListener true } - startActivity( - Intent.createChooser( - sendIntent, - resources.getText(R.string.send_post_link_to) + + R.id.post_share_link -> { + val sendIntent = Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_TEXT, statusUrl) + type = "text/plain" + } + startActivity( + Intent.createChooser( + sendIntent, + resources.getText(R.string.send_post_link_to) + ) ) - ) - return@setOnMenuItemClickListener true - } - R.id.status_copy_link -> { - ( - requireActivity().getSystemService( - Context.CLIPBOARD_SERVICE - ) as ClipboardManager - ).apply { - setPrimaryClip(ClipData.newPlainText(null, statusUrl)) + return@setOnMenuItemClickListener true } - return@setOnMenuItemClickListener true - } - R.id.status_open_as -> { - showOpenAsDialog(statusUrl, item.title) - return@setOnMenuItemClickListener true - } - R.id.status_download_media -> { - requestDownloadAllMedia(status) - return@setOnMenuItemClickListener true - } - R.id.status_mute -> { - onMute(accountId, accountUsername) - return@setOnMenuItemClickListener true - } - R.id.status_block -> { - onBlock(accountId, accountUsername) - return@setOnMenuItemClickListener true - } - R.id.status_report -> { - openReportPage(accountId, accountUsername, id) - return@setOnMenuItemClickListener true - } - R.id.status_unreblog_private -> { - onReblog(false, position) - return@setOnMenuItemClickListener true - } - R.id.status_reblog_private -> { - onReblog(true, position) - return@setOnMenuItemClickListener true - } - R.id.status_delete -> { - showConfirmDeleteDialog(id, position) - return@setOnMenuItemClickListener true - } - R.id.status_delete_and_redraft -> { - showConfirmEditDialog(id, position, status) - return@setOnMenuItemClickListener true - } - R.id.status_edit -> { - editStatus(id, status) - return@setOnMenuItemClickListener true - } - R.id.pin -> { - lifecycleScope.launch { - timelineCases.pin(status.id, !status.isPinned()).onFailure { e: Throwable -> - val message = e.message - ?: getString(if (status.isPinned()) R.string.failed_to_unpin else R.string.failed_to_pin) - Snackbar.make(requireView(), message, Snackbar.LENGTH_LONG).show() + + R.id.status_copy_link -> { + ( + requireActivity().getSystemService( + Context.CLIPBOARD_SERVICE + ) as ClipboardManager + ).apply { + setPrimaryClip(ClipData.newPlainText(null, statusUrl)) } + return@setOnMenuItemClickListener true } - return@setOnMenuItemClickListener true - } - R.id.status_mute_conversation -> { - lifecycleScope.launch { - timelineCases.muteConversation(status.id, status.muted != true) + + R.id.status_open_as -> { + showOpenAsDialog(statusUrl, item.title) + return@setOnMenuItemClickListener true + } + + R.id.status_download_media -> { + requestDownloadAllMedia(status) + return@setOnMenuItemClickListener true + } + + R.id.status_mute -> { + onMute(accountId, accountUsername) + return@setOnMenuItemClickListener true + } + + R.id.status_block -> { + onBlock(accountId, accountUsername) + return@setOnMenuItemClickListener true + } + + R.id.status_report -> { + openReportPage(accountId, accountUsername, id) + return@setOnMenuItemClickListener true + } + + R.id.status_unreblog_private -> { + onReblog(false, position) + return@setOnMenuItemClickListener true + } + + R.id.status_reblog_private -> { + onReblog(true, position) + return@setOnMenuItemClickListener true + } + + R.id.status_delete -> { + showConfirmDeleteDialog(id, position) + return@setOnMenuItemClickListener true + } + + R.id.status_delete_and_redraft -> { + showConfirmEditDialog(id, position, status) + return@setOnMenuItemClickListener true + } + + R.id.status_edit -> { + editStatus(id, status) + return@setOnMenuItemClickListener true + } + + R.id.pin -> { + lifecycleScope.launch { + timelineCases.pin(status.id, !status.isPinned()) + .onFailure { e: Throwable -> + val message = e.message + ?: getString(if (status.isPinned()) R.string.failed_to_unpin else R.string.failed_to_pin) + Snackbar.make(requireView(), message, Snackbar.LENGTH_LONG) + .show() + } + } + return@setOnMenuItemClickListener true + } + + R.id.status_mute_conversation -> { + lifecycleScope.launch { + timelineCases.muteConversation(status.id, status.muted != true) + } + return@setOnMenuItemClickListener true + } + + R.id.status_translate -> { + onMoreTranslate?.invoke(translation == null, position) } - return@setOnMenuItemClickListener true } + false } - false + popup.show() } - popup.show() - } private fun onMute(accountId: String, accountUsername: String) { showMuteAccountDialog( @@ -346,6 +387,7 @@ abstract class SFragment : Fragment(), Injectable { startActivity(intent) } } + Attachment.Type.UNKNOWN -> { requireContext().openLink(attachment.url) } 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 e142683a16..75f6ed7490 100644 --- a/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java +++ b/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java @@ -64,7 +64,8 @@ default void onShowFavs(int position) {} void onVoteInPoll(int position, @NonNull List choices); default void onShowEdits(int position) {} - + void clearWarningAction(int position); + void onUntranslate(int position); } diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt index 51e3d983de..cb4b69e00e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -45,6 +45,7 @@ import com.keylesspalace.tusky.entity.StatusContext import com.keylesspalace.tusky.entity.StatusEdit import com.keylesspalace.tusky.entity.StatusSource import com.keylesspalace.tusky.entity.TimelineAccount +import com.keylesspalace.tusky.entity.Translation import com.keylesspalace.tusky.entity.TrendingTag import okhttp3.MultipartBody import okhttp3.RequestBody @@ -703,4 +704,11 @@ interface MastodonApi { @Query("limit") limit: Int? = null, @Query("offset") offset: String? = null ): Response> + + @FormUrlEncoded + @POST("api/v1/statuses/{id}/translate") + suspend fun translate( + @Path("id") statusId: String, + @Field("lang") targetLanguage: String? + ): NetworkResult } diff --git a/app/src/main/java/com/keylesspalace/tusky/usecase/TimelineCases.kt b/app/src/main/java/com/keylesspalace/tusky/usecase/TimelineCases.kt index acb60b7a5b..1cd35b5732 100644 --- a/app/src/main/java/com/keylesspalace/tusky/usecase/TimelineCases.kt +++ b/app/src/main/java/com/keylesspalace/tusky/usecase/TimelineCases.kt @@ -33,9 +33,11 @@ import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Relationship import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.entity.Translation import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.Single import com.keylesspalace.tusky.util.getServerErrorMessage +import java.util.Locale import javax.inject.Inject import okhttp3.ResponseBody import retrofit2.Response @@ -184,6 +186,12 @@ class TimelineCases @Inject constructor( return Single { mastodonApi.clearNotifications() } } + suspend fun translate( + statusId: String + ): NetworkResult { + return mastodonApi.translate(statusId, Locale.getDefault().language) + } + companion object { private const val TAG = "TimelineCases" } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ResultUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/ResultUtils.kt new file mode 100644 index 0000000000..a89ec149e0 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/ResultUtils.kt @@ -0,0 +1,9 @@ +package com.keylesspalace.tusky.util + +import at.connyduck.calladapter.networkresult.NetworkResult +import at.connyduck.calladapter.networkresult.fold + +fun NetworkResult.asResult(): Result = fold( + { Result.success(it) }, + { Result.failure(it) }, +) diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt index dc1ee9431a..0975b00f10 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt @@ -41,20 +41,23 @@ import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.TrendingTag import com.keylesspalace.tusky.viewdata.NotificationViewData import com.keylesspalace.tusky.viewdata.StatusViewData +import com.keylesspalace.tusky.viewdata.TranslationViewData import com.keylesspalace.tusky.viewdata.TrendingViewData fun Status.toViewData( isShowingContent: Boolean, isExpanded: Boolean, isCollapsed: Boolean, - isDetailed: Boolean = false + isDetailed: Boolean = false, + translation: TranslationViewData? = null, ): StatusViewData.Concrete { return StatusViewData.Concrete( status = this, isShowingContent = isShowingContent, isCollapsed = isCollapsed, isExpanded = isExpanded, - isDetailed = isDetailed + isDetailed = isDetailed, + translation = translation, ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt index 870d7b16d3..6ae4ea4915 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt @@ -15,11 +15,24 @@ package com.keylesspalace.tusky.viewdata import android.text.Spanned +import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.entity.Translation import com.keylesspalace.tusky.util.parseAsMastodonHtml import com.keylesspalace.tusky.util.shouldTrimStatus +sealed class TranslationViewData { + abstract val data: Translation? + + data class Loaded(override val data: Translation) : TranslationViewData() + + data object Loading : TranslationViewData() { + override val data: Translation? + get() = null + } +} + /** * Created by charlag on 11/07/2017. * @@ -41,12 +54,28 @@ sealed class StatusViewData { * @return Whether the post is collapsed or fully expanded. */ val isCollapsed: Boolean, - val isDetailed: Boolean = false + val isDetailed: Boolean = false, + val translation: TranslationViewData? = null, ) : StatusViewData() { override val id: String get() = status.id - val content: Spanned = status.actionableStatus.content.parseAsMastodonHtml() + val content: Spanned = + (translation?.data?.content ?: actionable.content).parseAsMastodonHtml() + + val attachments = + actionable.attachments.translated { translation -> map { it.translated(translation) } } + + val spoilerText = + actionable.spoilerText.translated { translation -> translation.spoilerWarning ?: this } + + val poll = actionable.poll?.translated { translation -> + val translatedOptionsText = translation.poll ?: return@translated this + val translatedOptions = options.zip(translatedOptionsText) { option, translatedText -> + option.copy(title = translatedText) + } + copy(options = translatedOptions) + } /** * Specifies whether the content of this post is long enough to be automatically @@ -91,6 +120,20 @@ sealed class StatusViewData { fun copyWithCollapsed(isCollapsed: Boolean): Concrete { return copy(isCollapsed = isCollapsed) } + + private fun Attachment.translated(translation: Translation): Attachment { + val translatedDescription = + translation.mediaAttachments.find { it.id == id }?.description + ?: return this + return copy(description = translatedDescription) + } + + private inline fun T.translated(mapper: T.(Translation) -> T): T = + if (translation is TranslationViewData.Loaded) { + mapper(translation.data) + } else { + this + } } data class Placeholder( diff --git a/app/src/main/res/layout/item_status.xml b/app/src/main/res/layout/item_status.xml index a89a06828d..544d47f11c 100644 --- a/app/src/main/res/layout/item_status.xml +++ b/app/src/main/res/layout/item_status.xml @@ -310,6 +310,38 @@ app:layout_constraintTop_toBottomOf="@id/status_poll_button" tools:text="7 votes • 7 hours remaining" /> +