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 000000000..65aa67d6c --- /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/AboutActivity.kt b/app/src/main/java/com/keylesspalace/tusky/AboutActivity.kt index 214110d98..d0a7fcc00 100644 --- a/app/src/main/java/com/keylesspalace/tusky/AboutActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/AboutActivity.kt @@ -55,7 +55,7 @@ class AboutActivity : BottomSheetActivity(), Injectable { lifecycleScope.launch { accountManager.activeAccount?.let { account -> - val instanceInfo = instanceInfoRepository.getInstanceInfo() + val instanceInfo = instanceInfoRepository.getUpdatedInstanceInfoOrFallback() binding.accountInfo.text = getString( R.string.about_account_info, account.username, 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 71d24f182..f4b0eb236 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.Filter; 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; @@ -56,6 +57,7 @@ import com.keylesspalace.tusky.util.CompositeWithOpaqueBackground; import com.keylesspalace.tusky.util.CustomEmojiHelper; import com.keylesspalace.tusky.util.ImageLoadingHelper; import com.keylesspalace.tusky.util.LinkHelper; +import com.keylesspalace.tusky.util.LocaleUtilsKt; import com.keylesspalace.tusky.util.NumberUtils; import com.keylesspalace.tusky.util.StatusDisplayOptions; import com.keylesspalace.tusky.util.TimestampUtils; @@ -66,11 +68,13 @@ import com.keylesspalace.tusky.viewdata.PollOptionViewData; 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 +124,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { 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 +189,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { 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 +223,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { 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 +283,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { 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 +789,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { 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 +812,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { setupButtons(listener, actionable.getAccount().getId(), status.getContent().toString(), statusDisplayOptions); + + setTranslationStatus(status, listener); + setRebloggingEnabled(actionable.rebloggingAllowed(), actionable.getVisibility()); setSpoilerAndContent(status, statusDisplayOptions, listener); @@ -827,6 +840,29 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } } + 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); + var langName = LocaleUtilsKt.localeNameForUntrustedISO639LangCode(translation.getDetectedSourceLanguage()); + translationStatusView.setText(translationStatusView.getContext().getString(R.string.label_translated, langName, 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.GONE); + 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); @@ -864,27 +900,57 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { Status actionable = status.getActionable(); String description = context.getString(R.string.description_status, + // 1 display_name actionable.getAccount().getDisplayName(), + // 2 CW? getContentWarningDescription(context, status), - (TextUtils.isEmpty(actionable.getSpoilerText()) || !actionable.getSensitive() || status.isExpanded() ? status.getContent() : ""), + // 3 content? + (TextUtils.isEmpty(status.getSpoilerText()) || !actionable.getSensitive() || status.isExpanded() ? status.getContent() : ""), + // 4 date getCreatedAtDescription(actionable.getCreatedAt(), statusDisplayOptions), + // 5 edited? actionable.getEditedAt() != null ? context.getString(R.string.description_post_edited) : "", + // 6 reposted_by? getReblogDescription(context, status), + // 7 username actionable.getAccount().getUsername(), + // 8 reposted actionable.getReblogged() ? context.getString(R.string.description_post_reblogged) : "", + // 9 favorited actionable.getFavourited() ? context.getString(R.string.description_post_favourited) : "", + // 10 bookmarked actionable.getBookmarked() ? context.getString(R.string.description_post_bookmarked) : "", + // 11 media getMediaDescription(context, status), + // 12 visibility getVisibilityDescription(context, actionable.getVisibility()), + // 13 fav_number getFavsText(context, actionable.getFavouritesCount()), + // 14 reblog_number getReblogsText(context, actionable.getReblogsCount()), - getPollDescription(status, context, statusDisplayOptions) + // 15 poll? + getPollDescription(status, context, statusDisplayOptions), + // 16 translated? + getTranslatedDescription(context, status.getTranslation()) ); itemView.setContentDescription(description); } + private String getTranslatedDescription(Context context, TranslationViewData translationViewData) { + if (translationViewData == null) { + return ""; + } else if (translationViewData instanceof TranslationViewData.Loading) { + return context.getString(R.string.label_translating); + } else { + Translation translation = ((TranslationViewData.Loaded) translationViewData).getData(); + var langName = LocaleUtilsKt.localeNameForUntrustedISO639LangCode(translation.getDetectedSourceLanguage()); + return context.getString(R.string.label_translated, langName, translation.getProvider()); + } + } + private static CharSequence getReblogDescription(Context context, @NonNull StatusViewData.Concrete status) { + @Nullable Status reblog = status.getRebloggingStatus(); if (reblog != null) { return context @@ -895,12 +961,12 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } 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 +983,8 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { 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 +1020,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { 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 +1047,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } @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 d8921c28e..bb5a78e4e 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.method.LinkMovementMethod; 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.CardViewMode; 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/compose/ComposeViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt index 3c64e43b7..639779eaa 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt @@ -80,7 +80,7 @@ class ComposeViewModel @Inject constructor( private var currentContent: String? = "" private var currentContentWarning: String? = "" - val instanceInfo: SharedFlow = instanceInfoRepo::getInstanceInfo.asFlow() + val instanceInfo: SharedFlow = instanceInfoRepo::getUpdatedInstanceInfoOrFallback.asFlow() .shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1) val emoji: SharedFlow> = instanceInfoRepo::getEmojis.asFlow() 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 1dff9e654..caa264d8f 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 582df02e8..33a1122f3 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 b0bbff827..3d04278e8 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 @@ -16,28 +16,64 @@ package com.keylesspalace.tusky.components.instanceinfo import android.util.Log -import at.connyduck.calladapter.networkresult.fold +import at.connyduck.calladapter.networkresult.NetworkResult import at.connyduck.calladapter.networkresult.getOrElse +import at.connyduck.calladapter.networkresult.getOrThrow +import at.connyduck.calladapter.networkresult.map import at.connyduck.calladapter.networkresult.onSuccess +import at.connyduck.calladapter.networkresult.recover import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.EmojisEntity import com.keylesspalace.tusky.db.InstanceInfoEntity +import com.keylesspalace.tusky.di.ApplicationScope import com.keylesspalace.tusky.entity.Emoji +import com.keylesspalace.tusky.entity.Instance +import com.keylesspalace.tusky.entity.InstanceV1 import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.isHttpNotFound +import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +@Singleton class InstanceInfoRepository @Inject constructor( private val api: MastodonApi, db: AppDatabase, - accountManager: AccountManager + private val accountManager: AccountManager, + @ApplicationScope + private val externalScope: CoroutineScope ) { - private val dao = db.instanceDao() - private val instanceName = accountManager.activeAccount!!.domain + private val instanceName + get() = accountManager.activeAccount!!.domain + + /** In-memory cache for instance data, per instance domain. */ + private var instanceInfoCache = ConcurrentHashMap() + + fun precache() { + // We are avoiding some duplicate work but we are not trying too hard. + // We might request it multiple times in parallel which is not a big problem. + // We might also get the results in random order or write them twice but it's also + // not a problem. + // We are just trying to avoid 2 things: + // - fetching it when we already have it + // - caching default value (we want to rather re-fetch if it fails) + if (instanceInfoCache[instanceName] == null) { + externalScope.launch { + fetchAndPersistInstanceInfo().onSuccess { fetched -> + instanceInfoCache[fetched.instance] = fetched.toInfoOrDefault() + } + } + } + } + + val cachedInstanceInfoOrFallback: InstanceInfo + get() = instanceInfoCache[instanceName] ?: null.toInfoOrDefault() /** * Returns the custom emojis of the instance. @@ -58,97 +94,114 @@ class InstanceInfoRepository @Inject constructor( * Will always try to fetch the most up-to-date data from the api, falls back to cache in case it is not available. * Never throws, returns defaults of vanilla Mastodon in case of error. */ - suspend fun getInstanceInfo(): InstanceInfo = withContext(Dispatchers.IO) { - api.getInstance() - .fold( - { instance -> - val instanceEntity = InstanceInfoEntity( - instance = instanceName, - maximumTootCharacters = instance.configuration?.statuses?.maxCharacters ?: DEFAULT_CHARACTER_LIMIT, - maxPollOptions = instance.configuration?.polls?.maxOptions ?: DEFAULT_MAX_OPTION_COUNT, - maxPollOptionLength = instance.configuration?.polls?.maxCharactersPerOption ?: DEFAULT_MAX_OPTION_LENGTH, - minPollDuration = instance.configuration?.polls?.minExpirationSeconds ?: DEFAULT_MIN_POLL_DURATION, - maxPollDuration = instance.configuration?.polls?.maxExpirationSeconds ?: DEFAULT_MAX_POLL_DURATION, - charactersReservedPerUrl = instance.configuration?.statuses?.charactersReservedPerUrl ?: DEFAULT_CHARACTERS_RESERVED_PER_URL, - version = instance.version, - videoSizeLimit = instance.configuration?.mediaAttachments?.videoSizeLimitBytes?.toInt() ?: DEFAULT_VIDEO_SIZE_LIMIT, - imageSizeLimit = instance.configuration?.mediaAttachments?.imageSizeLimitBytes?.toInt() ?: DEFAULT_IMAGE_SIZE_LIMIT, - imageMatrixLimit = instance.configuration?.mediaAttachments?.imagePixelCountLimit?.toInt() ?: DEFAULT_IMAGE_MATRIX_LIMIT, - 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 - ) - dao.upsert(instanceEntity) - instanceEntity - }, - { throwable -> - if (throwable.isHttpNotFound()) { - getInstanceInfoV1() - } else { - Log.w( - TAG, - "failed to instance, falling back to cache and default values", - throwable - ) - dao.getInstanceInfo(instanceName) - } - } - ).let { instanceInfo: InstanceInfoEntity? -> - InstanceInfo( - maxChars = instanceInfo?.maximumTootCharacters ?: DEFAULT_CHARACTER_LIMIT, - pollMaxOptions = instanceInfo?.maxPollOptions ?: DEFAULT_MAX_OPTION_COUNT, - pollMaxLength = instanceInfo?.maxPollOptionLength ?: DEFAULT_MAX_OPTION_LENGTH, - pollMinDuration = instanceInfo?.minPollDuration ?: DEFAULT_MIN_POLL_DURATION, - pollMaxDuration = instanceInfo?.maxPollDuration ?: DEFAULT_MAX_POLL_DURATION, - charactersReservedPerUrl = instanceInfo?.charactersReservedPerUrl ?: DEFAULT_CHARACTERS_RESERVED_PER_URL, - videoSizeLimit = instanceInfo?.videoSizeLimit ?: DEFAULT_VIDEO_SIZE_LIMIT, - imageSizeLimit = instanceInfo?.imageSizeLimit ?: DEFAULT_IMAGE_SIZE_LIMIT, - imageMatrixLimit = instanceInfo?.imageMatrixLimit ?: DEFAULT_IMAGE_MATRIX_LIMIT, - maxMediaAttachments = instanceInfo?.maxMediaAttachments ?: DEFAULT_MAX_MEDIA_ATTACHMENTS, - maxFields = instanceInfo?.maxFields ?: DEFAULT_MAX_ACCOUNT_FIELDS, - maxFieldNameLength = instanceInfo?.maxFieldNameLength, - maxFieldValueLength = instanceInfo?.maxFieldValueLength, - version = instanceInfo?.version - ) - } - } - - private suspend fun getInstanceInfoV1(): InstanceInfoEntity? = withContext(Dispatchers.IO) { - api.getInstanceV1() - .fold( - { instance -> - val instanceEntity = InstanceInfoEntity( - instance = instanceName, - maximumTootCharacters = instance.configuration?.statuses?.maxCharacters ?: instance.maxTootChars, - maxPollOptions = instance.configuration?.polls?.maxOptions ?: instance.pollConfiguration?.maxOptions, - maxPollOptionLength = instance.configuration?.polls?.maxCharactersPerOption ?: instance.pollConfiguration?.maxOptionChars, - minPollDuration = instance.configuration?.polls?.minExpiration ?: instance.pollConfiguration?.minExpiration, - maxPollDuration = instance.configuration?.polls?.maxExpiration ?: instance.pollConfiguration?.maxExpiration, - charactersReservedPerUrl = instance.configuration?.statuses?.charactersReservedPerUrl, - version = instance.version, - videoSizeLimit = instance.configuration?.mediaAttachments?.videoSizeLimit ?: instance.uploadLimit, - imageSizeLimit = instance.configuration?.mediaAttachments?.imageSizeLimit ?: instance.uploadLimit, - imageMatrixLimit = instance.configuration?.mediaAttachments?.imageMatrixLimit, - 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 - ) - dao.upsert(instanceEntity) - instanceEntity - }, - { throwable -> + suspend fun getUpdatedInstanceInfoOrFallback(): InstanceInfo = + withContext(Dispatchers.IO) { + fetchAndPersistInstanceInfo() + .getOrElse { throwable -> Log.w( TAG, - "failed to instance, falling back to cache and default values", + "failed to load instance, falling back to cache and default values", throwable ) dao.getInstanceInfo(instanceName) } - ) + }.toInfoOrDefault() + + private suspend fun InstanceInfoRepository.fetchAndPersistInstanceInfo(): NetworkResult = + fetchRemoteInstanceInfo() + .onSuccess { instanceInfoEntity -> + dao.upsert(instanceInfoEntity) + } + + private suspend fun fetchRemoteInstanceInfo(): NetworkResult { + val instance = this.instanceName + return api.getInstance() + .map { it.toEntity() } + .recover { t -> + if (t.isHttpNotFound()) { + api.getInstanceV1().map { it.toEntity(instance) }.getOrThrow() + } else { + throw t + } + } } + private fun InstanceInfoEntity?.toInfoOrDefault() = InstanceInfo( + maxChars = this?.maximumTootCharacters ?: DEFAULT_CHARACTER_LIMIT, + pollMaxOptions = this?.maxPollOptions ?: DEFAULT_MAX_OPTION_COUNT, + pollMaxLength = this?.maxPollOptionLength ?: DEFAULT_MAX_OPTION_LENGTH, + pollMinDuration = this?.minPollDuration ?: DEFAULT_MIN_POLL_DURATION, + pollMaxDuration = this?.maxPollDuration ?: DEFAULT_MAX_POLL_DURATION, + charactersReservedPerUrl = this?.charactersReservedPerUrl + ?: DEFAULT_CHARACTERS_RESERVED_PER_URL, + videoSizeLimit = this?.videoSizeLimit ?: DEFAULT_VIDEO_SIZE_LIMIT, + imageSizeLimit = this?.imageSizeLimit ?: DEFAULT_IMAGE_SIZE_LIMIT, + imageMatrixLimit = this?.imageMatrixLimit ?: DEFAULT_IMAGE_MATRIX_LIMIT, + maxMediaAttachments = this?.maxMediaAttachments + ?: DEFAULT_MAX_MEDIA_ATTACHMENTS, + maxFields = this?.maxFields ?: DEFAULT_MAX_ACCOUNT_FIELDS, + maxFieldNameLength = this?.maxFieldNameLength, + maxFieldValueLength = this?.maxFieldValueLength, + version = this?.version, + translationEnabled = this?.translationEnabled + ) + + private fun Instance.toEntity() = InstanceInfoEntity( + instance = domain, + maximumTootCharacters = this.configuration?.statuses?.maxCharacters + ?: DEFAULT_CHARACTER_LIMIT, + maxPollOptions = this.configuration?.polls?.maxOptions ?: DEFAULT_MAX_OPTION_COUNT, + maxPollOptionLength = this.configuration?.polls?.maxCharactersPerOption + ?: DEFAULT_MAX_OPTION_LENGTH, + minPollDuration = this.configuration?.polls?.minExpirationSeconds + ?: DEFAULT_MIN_POLL_DURATION, + maxPollDuration = this.configuration?.polls?.maxExpirationSeconds + ?: DEFAULT_MAX_POLL_DURATION, + charactersReservedPerUrl = this.configuration?.statuses?.charactersReservedPerUrl + ?: DEFAULT_CHARACTERS_RESERVED_PER_URL, + version = this.version, + videoSizeLimit = this.configuration?.mediaAttachments?.videoSizeLimitBytes?.toInt() + ?: DEFAULT_VIDEO_SIZE_LIMIT, + imageSizeLimit = this.configuration?.mediaAttachments?.imageSizeLimitBytes?.toInt() + ?: DEFAULT_IMAGE_SIZE_LIMIT, + imageMatrixLimit = this.configuration?.mediaAttachments?.imagePixelCountLimit?.toInt() + ?: DEFAULT_IMAGE_MATRIX_LIMIT, + maxMediaAttachments = this.configuration?.statuses?.maxMediaAttachments + ?: DEFAULT_MAX_MEDIA_ATTACHMENTS, + maxFields = this.pleroma?.metadata?.fieldLimits?.maxFields, + maxFieldNameLength = this.pleroma?.metadata?.fieldLimits?.nameLength, + maxFieldValueLength = this.pleroma?.metadata?.fieldLimits?.valueLength, + translationEnabled = this.configuration?.translation?.enabled + ) + + private fun InstanceV1.toEntity(instanceName: String) = + InstanceInfoEntity( + instance = instanceName, + maximumTootCharacters = this.configuration?.statuses?.maxCharacters + ?: this.maxTootChars, + maxPollOptions = this.configuration?.polls?.maxOptions + ?: this.pollConfiguration?.maxOptions, + maxPollOptionLength = this.configuration?.polls?.maxCharactersPerOption + ?: this.pollConfiguration?.maxOptionChars, + minPollDuration = this.configuration?.polls?.minExpiration + ?: this.pollConfiguration?.minExpiration, + maxPollDuration = this.configuration?.polls?.maxExpiration + ?: this.pollConfiguration?.maxExpiration, + charactersReservedPerUrl = this.configuration?.statuses?.charactersReservedPerUrl, + version = this.version, + videoSizeLimit = this.configuration?.mediaAttachments?.videoSizeLimit + ?: this.uploadLimit, + imageSizeLimit = this.configuration?.mediaAttachments?.imageSizeLimit + ?: this.uploadLimit, + imageMatrixLimit = this.configuration?.mediaAttachments?.imageMatrixLimit, + maxMediaAttachments = this.configuration?.statuses?.maxMediaAttachments + ?: this.maxMediaAttachments, + maxFields = this.pleroma?.metadata?.fieldLimits?.maxFields, + maxFieldNameLength = this.pleroma?.metadata?.fieldLimits?.nameLength, + maxFieldValueLength = this.pleroma?.metadata?.fieldLimits?.valueLength, + translationEnabled = null, + ) + companion object { private const val TAG = "InstanceInfoRepo" 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 1dd8d85a6..ba075449e 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 @@ -33,6 +35,7 @@ import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.usecase.TimelineCases 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,9 +44,14 @@ 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() { + init { + instanceInfoRepository.precache() + } + var currentQuery: String = "" var currentSearchFieldContent: String? = null @@ -193,6 +201,30 @@ class SearchViewModel @Inject constructor( } } + fun supportsTranslation(): Boolean = + instanceInfoRepository.cachedInstanceInfoOrFallback.translationEnabled == true + + suspend fun translate(statusViewData: StatusViewData.Concrete): NetworkResult { + 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)) + } + } + + 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 24f3065de..73b628a49 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 @@ -39,6 +39,7 @@ import androidx.preference.PreferenceManager import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import at.connyduck.calladapter.networkresult.fold +import at.connyduck.calladapter.networkresult.onFailure import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.R @@ -56,12 +57,14 @@ import com.keylesspalace.tusky.interfaces.AccountSelectionListener import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.util.CardViewMode +import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.openLink 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 @@ -96,13 +99,24 @@ class SearchStatusesFragment : SearchFragment(), Status openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler ) + binding.searchRecyclerView.setAccessibilityDelegateCompat( + ListStatusAccessibilityDelegate(binding.searchRecyclerView, this) { pos -> + if (pos in 0 until adapter.itemCount) { + adapter.peek(pos) + } else { + null + } + } + ) + binding.searchRecyclerView.addItemDecoration( DividerItemDecoration( binding.searchRecyclerView.context, DividerItemDecoration.VERTICAL ) ) - binding.searchRecyclerView.layoutManager = LinearLayoutManager(binding.searchRecyclerView.context) + binding.searchRecyclerView.layoutManager = + LinearLayoutManager(binding.searchRecyclerView.context) return SearchStatusesAdapter(statusDisplayOptions, this) } @@ -131,7 +145,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 +173,7 @@ class SearchStatusesFragment : SearchFragment(), Status startActivity(intent) } } + Attachment.Type.UNKNOWN -> { context?.openLink(actionable.attachments[attachmentIndex].url) } @@ -215,6 +230,12 @@ class SearchStatusesFragment : SearchFragment(), Status } } + override fun onUntranslate(position: Int) { + searchAdapter.peek(position)?.let { + viewModel.untranslate(it) + } + } + companion object { fun newInstance() = SearchStatusesFragment() } @@ -244,7 +265,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) { + val status = statusViewData.status val id = status.actionableId val accountId = status.actionableStatus.account.id val accountUsername = status.actionableStatus.account.username @@ -266,12 +288,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 +313,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 +328,14 @@ class SearchStatusesFragment : SearchFragment(), Status ) } + // translation not there for your own posts + popup.menu.findItem(R.id.status_translate)?.let { translateItem -> + translateItem.isVisible = + !status.language.equals(Locale.getDefault().language, ignoreCase = true) && + 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 +357,7 @@ class SearchStatusesFragment : SearchFragment(), Status ) return@setOnMenuItemClickListener true } + R.id.post_share_link -> { val sendIntent = Intent() sendIntent.action = Intent.ACTION_SEND @@ -337,6 +371,7 @@ class SearchStatusesFragment : SearchFragment(), Status ) return@setOnMenuItemClickListener true } + R.id.status_copy_link -> { val clipboard = requireActivity().getSystemService( Context.CLIPBOARD_SERVICE @@ -344,56 +379,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 8650c3435..4eb95f2bb 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 @@ -36,8 +36,10 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.SimpleItemAnimator import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener +import at.connyduck.calladapter.networkresult.onFailure 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 +72,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 +241,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 +311,7 @@ class TimelineFragment : is PreferenceChangedEvent -> { onPreferenceChanged(event.preferenceKey) } + is StatusComposedEvent -> { val status = event.status handleStatusComposeEvent(status) @@ -348,6 +354,7 @@ class TimelineFragment : false } } + else -> false } } @@ -415,6 +422,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 +443,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 +484,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 +522,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 +576,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 +585,7 @@ class TimelineFragment : adapter.notifyItemRangeChanged(0, adapter.itemCount) } } + PrefKeys.READING_ORDER -> { readingOrder = ReadingOrder.from( sharedPreferences.getString(PrefKeys.READING_ORDER, null) @@ -555,10 +600,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 +630,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 b09a59aee..8ad59036e 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 331af2702..1bda78ac0 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,9 @@ import androidx.paging.cachedIn import androidx.paging.filter import androidx.paging.map import androidx.room.withTransaction +import at.connyduck.calladapter.networkresult.NetworkResult +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 @@ -45,11 +48,13 @@ import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.usecase.TimelineCases import com.keylesspalace.tusky.util.EmptyPagingSource 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,23 @@ class CachedTimelineViewModel @Inject constructor( } } + override suspend fun translate(status: StatusViewData.Concrete): NetworkResult { + 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 + } + } + + 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 ebfebd58e..b5e9c6bdd 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,9 @@ import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.cachedIn import androidx.paging.filter +import at.connyduck.calladapter.networkresult.NetworkResult +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 @@ -37,6 +40,7 @@ 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,21 @@ class NetworkTimelineViewModel @Inject constructor( currentSource?.invalidate() } + override suspend fun translate(status: StatusViewData.Concrete): NetworkResult { + status.copy(translation = TranslationViewData.Loading).update() + return timelineCases.translate(status.actionableId) + .map { translation -> + status.copy(translation = TranslationViewData.Loaded(translation)).update() + } + .onFailure { + status.update() + } + } + + override fun untranslate(status: StatusViewData.Concrete) { + status.copy(translation = null).update() + } + @Throws(IOException::class, HttpException::class) suspend fun fetchStatusesForKind( fromId: String?, @@ -273,6 +301,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 +311,7 @@ class NetworkTimelineViewModel @Inject constructor( onlyMedia = null, pinned = null ) + Kind.USER_PINNED -> api.accountStatuses( id!!, fromId, @@ -291,6 +321,7 @@ class NetworkTimelineViewModel @Inject constructor( onlyMedia = null, pinned = true ) + Kind.USER_WITH_REPLIES -> api.accountStatuses( id!!, fromId, @@ -300,6 +331,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 +340,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 d5482431e..3d02b90dc 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 @@ -20,6 +20,7 @@ import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.PagingData +import at.connyduck.calladapter.networkresult.NetworkResult import at.connyduck.calladapter.networkresult.fold import at.connyduck.calladapter.networkresult.getOrElse import at.connyduck.calladapter.networkresult.getOrThrow @@ -52,7 +53,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 +313,9 @@ abstract class TimelineViewModel( } } + abstract suspend fun translate(status: StatusViewData.Concrete): NetworkResult + 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 2617557c7..e38d7f364 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 @@ -35,6 +35,7 @@ import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.SimpleItemAnimator import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener +import at.connyduck.calladapter.networkresult.onFailure import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.R import com.keylesspalace.tusky.components.accountlist.AccountListActivity @@ -56,6 +57,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 +168,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 +192,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 +208,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 +236,7 @@ class ViewThreadFragment : binding.recyclerView.show() binding.statusView.hide() } + is ThreadUiState.Refreshing -> { threadProgressBar.cancel() } @@ -270,14 +276,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 +332,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 +373,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 a5607f354..b864b3720 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 @@ -18,9 +18,12 @@ package com.keylesspalace.tusky.components.viewthread import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import at.connyduck.calladapter.networkresult.NetworkResult 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 @@ -40,6 +43,7 @@ import com.keylesspalace.tusky.usecase.TimelineCases 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,37 @@ class ViewThreadViewModel @Inject constructor( } } + suspend fun translate(status: StatusViewData.Concrete): NetworkResult { + 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) + } + } + } + + 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 +338,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 +371,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 +475,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 08014baa9..39261cb55 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 @@ import java.io.File; }, // 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 efcfe5278..4db2ee050 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 18d5faec5..ffcd1f60b 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 000000000..1eee91d97 --- /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 84a464f6e..ac81fb832 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java @@ -109,6 +109,7 @@ import at.connyduck.sparkbutton.helpers.Utils; 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 @@ -408,6 +409,12 @@ public class NotificationsFragment extends SFragment implements 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 @@ public class NotificationsFragment extends SFragment implements @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 class NotificationsFragment extends SFragment implements 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, @@ -555,6 +558,11 @@ public class NotificationsFragment extends SFragment implements 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 && 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 1acaa65d4..2daeec40f 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) } @@ -96,6 +106,13 @@ abstract class SFragment : Fragment(), Injectable { } } + override fun onResume() { + super.onResume() + + // make sure we have instance info for when we'll need it + instanceInfoRepository.precache() + } + protected fun openReblog(status: Status?) { if (status == null) return bottomSheetActivity.viewAccount(status.account.id) @@ -140,7 +157,7 @@ abstract class SFragment : Fragment(), Injectable { requireActivity().startActivity(intent) } - protected fun more(status: Status, view: View, position: Int) { + protected fun more(status: Status, view: View, position: Int, translation: Translation?) { val id = status.actionableId val accountId = status.actionableStatus.account.id val accountUsername = status.actionableStatus.account.username @@ -167,16 +184,19 @@ abstract class SFragment : Fragment(), Injectable { ) ) } + 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 { popup.inflate(R.menu.status_more) - popup.menu.findItem(R.id.status_download_media).isVisible = status.attachments.isNotEmpty() + 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) @@ -187,7 +207,8 @@ abstract class SFragment : Fragment(), Injectable { openAsItem.title = openAsText } val muteConversationItem = menu.findItem(R.id.status_mute_conversation) - val mutable = statusIsByCurrentUser || accountIsInMentions(activeAccount, status.mentions) + val mutable = + statusIsByCurrentUser || accountIsInMentions(activeAccount, status.mentions) muteConversationItem.isVisible = mutable if (mutable) { muteConversationItem.setTitle( @@ -198,6 +219,15 @@ abstract class SFragment : Fragment(), Injectable { } ) } + + // translation not there for your own posts + menu.findItem(R.id.status_translate)?.let { translateItem -> + translateItem.isVisible = onMoreTranslate != null && + !status.language.equals(Locale.getDefault().language, ignoreCase = true) && + instanceInfoRepository.cachedInstanceInfoOrFallback.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 -> { @@ -219,6 +249,7 @@ abstract class SFragment : Fragment(), Injectable { ) return@setOnMenuItemClickListener true } + R.id.post_share_link -> { val sendIntent = Intent().apply { action = Intent.ACTION_SEND @@ -233,6 +264,7 @@ abstract class SFragment : Fragment(), Injectable { ) return@setOnMenuItemClickListener true } + R.id.status_copy_link -> { ( requireActivity().getSystemService( @@ -243,62 +275,80 @@ abstract class SFragment : Fragment(), Injectable { } 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() - } + 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) + } } false } @@ -346,6 +396,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 e142683a1..75f6ed749 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 @@ public interface StatusActionListener extends LinkListener { 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 51e3d983d..cb4b69e00 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 acb60b7a5..1cd35b573 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/LocaleUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/LocaleUtils.kt index 3ad6c1dae..105f99ced 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/LocaleUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/LocaleUtils.kt @@ -80,3 +80,8 @@ fun getLocaleList(initialLanguages: List): List { ensureLanguagesAreFirst(locales, initialLanguages) return locales } + +fun localeNameForUntrustedISO639LangCode(code: String): String { + // It seems like it never throws? + return Locale(code).displayName +} 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 dc1ee9431..0975b00f1 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 870d7b16d..f2f60cb1d 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: List = + actionable.attachments.translated { translation -> map { it.translated(translation) } } + + val spoilerText: String = + 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/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt index 9c74a3d85..1a1b7957a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt @@ -70,7 +70,7 @@ class EditProfileViewModel @Inject constructor( val headerData = MutableLiveData() val saveData = MutableLiveData>() - val instanceData: Flow = instanceInfoRepo::getInstanceInfo.asFlow() + val instanceData: Flow = instanceInfoRepo::getUpdatedInstanceInfoOrFallback.asFlow() .shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1) val isChanged = MutableStateFlow(false) diff --git a/app/src/main/res/layout/item_status.xml b/app/src/main/res/layout/item_status.xml index a89a06828..c022c2dba 100644 --- a/app/src/main/res/layout/item_status.xml +++ b/app/src/main/res/layout/item_status.xml @@ -103,6 +103,47 @@ app:layout_constraintTop_toTopOf="@id/status_display_name" tools:text="13:37" /> +