diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml index f77ebf931..aa4ba092c 100644 --- a/app/lint-baseline.xml +++ b/app/lint-baseline.xml @@ -1,5 +1,5 @@ - + + message="Overriding `@layout/exo_player_control_view` which is marked as private in androidx.media3:media3-ui:1.3.1. If deliberate, use tools:override="true", otherwise pick a different name."> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/60.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/60.json new file mode 100644 index 000000000..03fc70183 --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/60.json @@ -0,0 +1,1325 @@ +{ + "formatVersion": 1, + "database": { + "version": 60, + "identityHash": "1f8ec0c172cc1cae16313d737f6f8e34", + "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, `tuskyAccountId` INTEGER NOT NULL, `authorServerId` TEXT NOT NULL, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `editedAt` INTEGER, `emojis` TEXT NOT NULL, `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 NOT NULL, `mentions` TEXT NOT NULL, `tags` TEXT NOT NULL, `application` TEXT, `poll` TEXT, `muted` INTEGER NOT NULL, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `card` TEXT, `language` TEXT, `filtered` TEXT NOT NULL, PRIMARY KEY(`serverId`, `tuskyAccountId`), FOREIGN KEY(`authorServerId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) 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": "tuskyAccountId", + "columnName": "tuskyAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "editedAt", + "columnName": "editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "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": true + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "muted", + "columnName": "muted", + "affinity": "INTEGER", + "notNull": true + }, + { + "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": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "tuskyAccountId" + ] + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_tuskyAccountId", + "unique": false, + "columnNames": [ + "authorServerId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_tuskyAccountId` ON `${TABLE_NAME}` (`authorServerId`, `tuskyAccountId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `tuskyAccountId` 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`, `tuskyAccountId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tuskyAccountId", + "columnName": "tuskyAccountId", + "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", + "tuskyAccountId" + ] + }, + "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": [] + }, + { + "tableName": "NotificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tuskyAccountId` INTEGER NOT NULL, `type` TEXT, `id` TEXT NOT NULL, `accountId` TEXT, `statusId` TEXT, `reportId` TEXT, `loading` INTEGER NOT NULL, PRIMARY KEY(`id`, `tuskyAccountId`), FOREIGN KEY(`accountId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`statusId`, `tuskyAccountId`) REFERENCES `TimelineStatusEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`reportId`, `tuskyAccountId`) REFERENCES `NotificationReportEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "tuskyAccountId", + "columnName": "tuskyAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "statusId", + "columnName": "statusId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reportId", + "columnName": "reportId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loading", + "columnName": "loading", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "tuskyAccountId" + ] + }, + "indices": [ + { + "name": "index_NotificationEntity_accountId_tuskyAccountId", + "unique": false, + "columnNames": [ + "accountId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_accountId_tuskyAccountId` ON `${TABLE_NAME}` (`accountId`, `tuskyAccountId`)" + }, + { + "name": "index_NotificationEntity_statusId_tuskyAccountId", + "unique": false, + "columnNames": [ + "statusId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_statusId_tuskyAccountId` ON `${TABLE_NAME}` (`statusId`, `tuskyAccountId`)" + }, + { + "name": "index_NotificationEntity_reportId_tuskyAccountId", + "unique": false, + "columnNames": [ + "reportId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_reportId_tuskyAccountId` ON `${TABLE_NAME}` (`reportId`, `tuskyAccountId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "accountId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + }, + { + "table": "TimelineStatusEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "statusId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + }, + { + "table": "NotificationReportEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "reportId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + } + ] + }, + { + "tableName": "NotificationReportEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tuskyAccountId` INTEGER NOT NULL, `serverId` TEXT NOT NULL, `category` TEXT NOT NULL, `statusIds` TEXT, `createdAt` INTEGER NOT NULL, `targetAccountId` TEXT, PRIMARY KEY(`serverId`, `tuskyAccountId`), FOREIGN KEY(`targetAccountId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "tuskyAccountId", + "columnName": "tuskyAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "statusIds", + "columnName": "statusIds", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "targetAccountId", + "columnName": "targetAccountId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "tuskyAccountId" + ] + }, + "indices": [ + { + "name": "index_NotificationReportEntity_targetAccountId_tuskyAccountId", + "unique": false, + "columnNames": [ + "targetAccountId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationReportEntity_targetAccountId_tuskyAccountId` ON `${TABLE_NAME}` (`targetAccountId`, `tuskyAccountId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "targetAccountId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + } + ] + }, + { + "tableName": "HomeTimelineEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tuskyAccountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `statusId` TEXT, `reblogAccountId` TEXT, `loading` INTEGER NOT NULL, PRIMARY KEY(`id`, `tuskyAccountId`), FOREIGN KEY(`statusId`, `tuskyAccountId`) REFERENCES `TimelineStatusEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`reblogAccountId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "tuskyAccountId", + "columnName": "tuskyAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "statusId", + "columnName": "statusId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loading", + "columnName": "loading", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "tuskyAccountId" + ] + }, + "indices": [ + { + "name": "index_HomeTimelineEntity_statusId_tuskyAccountId", + "unique": false, + "columnNames": [ + "statusId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_HomeTimelineEntity_statusId_tuskyAccountId` ON `${TABLE_NAME}` (`statusId`, `tuskyAccountId`)" + }, + { + "name": "index_HomeTimelineEntity_reblogAccountId_tuskyAccountId", + "unique": false, + "columnNames": [ + "reblogAccountId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_HomeTimelineEntity_reblogAccountId_tuskyAccountId` ON `${TABLE_NAME}` (`reblogAccountId`, `tuskyAccountId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineStatusEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "statusId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + }, + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "reblogAccountId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + } + ] + } + ], + "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, '1f8ec0c172cc1cae16313d737f6f8e34')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java index a5bc0b04a..26f63445c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java @@ -42,7 +42,7 @@ import com.google.android.material.color.MaterialColors; import com.google.android.material.snackbar.Snackbar; import com.keylesspalace.tusky.adapter.AccountSelectionAdapter; import com.keylesspalace.tusky.components.login.LoginActivity; -import com.keylesspalace.tusky.db.AccountEntity; +import com.keylesspalace.tusky.db.entity.AccountEntity; import com.keylesspalace.tusky.db.AccountManager; import com.keylesspalace.tusky.di.Injectable; import com.keylesspalace.tusky.interfaces.AccountSelectionListener; diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt index 3adb29f2a..81789396e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt @@ -80,17 +80,17 @@ import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.components.compose.ComposeActivity.Companion.canHandleMimeType import com.keylesspalace.tusky.components.drafts.DraftsActivity import com.keylesspalace.tusky.components.login.LoginActivity -import com.keylesspalace.tusky.components.notifications.NotificationHelper -import com.keylesspalace.tusky.components.notifications.disableAllNotifications -import com.keylesspalace.tusky.components.notifications.enablePushNotificationsWithFallback -import com.keylesspalace.tusky.components.notifications.showMigrationNoticeIfNecessary import com.keylesspalace.tusky.components.preference.PreferencesActivity import com.keylesspalace.tusky.components.scheduled.ScheduledStatusActivity import com.keylesspalace.tusky.components.search.SearchActivity +import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper +import com.keylesspalace.tusky.components.systemnotifications.disableAllNotifications +import com.keylesspalace.tusky.components.systemnotifications.enablePushNotificationsWithFallback +import com.keylesspalace.tusky.components.systemnotifications.showMigrationNoticeIfNecessary import com.keylesspalace.tusky.components.trending.TrendingActivity import com.keylesspalace.tusky.databinding.ActivityMainBinding -import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.DraftsAlert +import com.keylesspalace.tusky.db.entity.AccountEntity import com.keylesspalace.tusky.di.ApplicationScope import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Notification diff --git a/app/src/main/java/com/keylesspalace/tusky/TabData.kt b/app/src/main/java/com/keylesspalace/tusky/TabData.kt index 12c0346cb..7dccd4955 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TabData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TabData.kt @@ -20,10 +20,10 @@ import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.fragment.app.Fragment import com.keylesspalace.tusky.components.conversation.ConversationsFragment +import com.keylesspalace.tusky.components.notifications.NotificationsFragment import com.keylesspalace.tusky.components.timeline.TimelineFragment import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel import com.keylesspalace.tusky.components.trending.TrendingTagsFragment -import com.keylesspalace.tusky.fragment.NotificationsFragment import java.util.Objects /** this would be a good case for a sealed class, but that does not work nice with Room */ diff --git a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt index 0476903a8..bc1dacfce 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt @@ -22,7 +22,7 @@ import androidx.work.Constraints import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager -import com.keylesspalace.tusky.components.notifications.NotificationHelper +import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper import com.keylesspalace.tusky.di.AppInjector import com.keylesspalace.tusky.settings.AppTheme import com.keylesspalace.tusky.settings.NEW_INSTALL_SCHEMA_VERSION @@ -127,12 +127,6 @@ class TuskyApplication : Application(), HasAndroidInjector { editor.remove(PrefKeys.MEDIA_PREVIEW_ENABLED) } - if (oldVersion < 2023072401) { - // The notifications filter / clear options are shown on a menu, not a separate bar, - // the preference to display them is not needed. - editor.remove(PrefKeys.Deprecated.SHOW_NOTIFICATIONS_FILTER) - } - if (oldVersion != NEW_INSTALL_SCHEMA_VERSION && oldVersion < 2023082301) { // Default value for appTheme is now THEME_SYSTEM. If the user is upgrading and // didn't have an explicit preference set use the previous default, so the diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountSelectionAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountSelectionAdapter.kt index e2f461201..d8463134d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountSelectionAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountSelectionAdapter.kt @@ -23,7 +23,7 @@ import android.widget.ArrayAdapter import androidx.preference.PreferenceManager import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ItemAutocompleteAccountBinding -import com.keylesspalace.tusky.db.AccountEntity +import com.keylesspalace.tusky.db.entity.AccountEntity import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.loadAvatar diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt index d4953514e..8c5a37014 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt @@ -21,10 +21,12 @@ import android.text.Spanned import android.text.style.StyleSpan import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.components.notifications.NotificationsViewHolder import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.interfaces.AccountActionListener import com.keylesspalace.tusky.interfaces.LinkListener +import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.loadAvatar @@ -33,12 +35,28 @@ import com.keylesspalace.tusky.util.setClickableText import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.unicodeWrap import com.keylesspalace.tusky.util.visible +import com.keylesspalace.tusky.viewdata.NotificationViewData class FollowRequestViewHolder( private val binding: ItemFollowRequestBinding, + private val accountListener: AccountActionListener, private val linkListener: LinkListener, private val showHeader: Boolean -) : RecyclerView.ViewHolder(binding.root) { +) : RecyclerView.ViewHolder(binding.root), NotificationsViewHolder { + + override fun bind( + viewData: NotificationViewData.Concrete, + payloads: List<*>, + statusDisplayOptions: StatusDisplayOptions + ) { + setupWithAccount( + viewData.account, + statusDisplayOptions.animateAvatars, + statusDisplayOptions.animateEmojis, + statusDisplayOptions.showBotOverlay + ) + setupActionListener(accountListener, viewData.account.id) + } fun setupWithAccount( account: TimelineAccount, diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java deleted file mode 100644 index 8eafc0860..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java +++ /dev/null @@ -1,708 +0,0 @@ -/* Copyright 2021 Tusky Contributors - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . */ - -package com.keylesspalace.tusky.adapter; - -import android.content.Context; -import android.graphics.Color; -import android.graphics.PorterDuff; -import android.graphics.Typeface; -import android.graphics.drawable.Drawable; -import android.text.InputFilter; -import android.text.SpannableStringBuilder; -import android.text.Spanned; -import android.text.TextUtils; -import android.text.style.StyleSpan; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.Button; -import android.widget.ImageView; -import android.widget.TextView; - -import androidx.annotation.ColorRes; -import androidx.annotation.DrawableRes; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.content.ContextCompat; -import androidx.recyclerview.widget.RecyclerView; - -import com.bumptech.glide.Glide; -import com.keylesspalace.tusky.R; -import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding; -import com.keylesspalace.tusky.databinding.ItemReportNotificationBinding; -import com.keylesspalace.tusky.entity.Emoji; -import com.keylesspalace.tusky.entity.Notification; -import com.keylesspalace.tusky.entity.Status; -import com.keylesspalace.tusky.entity.TimelineAccount; -import com.keylesspalace.tusky.interfaces.AccountActionListener; -import com.keylesspalace.tusky.interfaces.LinkListener; -import com.keylesspalace.tusky.interfaces.StatusActionListener; -import com.keylesspalace.tusky.util.AbsoluteTimeFormatter; -import com.keylesspalace.tusky.util.CardViewMode; -import com.keylesspalace.tusky.util.CustomEmojiHelper; -import com.keylesspalace.tusky.util.ImageLoadingHelper; -import com.keylesspalace.tusky.util.LinkHelper; -import com.keylesspalace.tusky.util.SmartLengthInputFilter; -import com.keylesspalace.tusky.util.StatusDisplayOptions; -import com.keylesspalace.tusky.util.StringUtils; -import com.keylesspalace.tusky.util.TimestampUtils; -import com.keylesspalace.tusky.viewdata.NotificationViewData; -import com.keylesspalace.tusky.viewdata.StatusViewData; - -import java.util.Date; -import java.util.List; - -import at.connyduck.sparkbutton.helpers.Utils; - -public class NotificationsAdapter extends RecyclerView.Adapter implements LinkListener{ - - public interface AdapterDataSource { - int getItemCount(); - - T getItemAt(int pos); - } - - - private static final int VIEW_TYPE_STATUS = 0; - private static final int VIEW_TYPE_STATUS_NOTIFICATION = 1; - private static final int VIEW_TYPE_FOLLOW = 2; - private static final int VIEW_TYPE_FOLLOW_REQUEST = 3; - private static final int VIEW_TYPE_PLACEHOLDER = 4; - private static final int VIEW_TYPE_REPORT = 5; - private static final int VIEW_TYPE_UNKNOWN = 6; - - private static final InputFilter[] COLLAPSE_INPUT_FILTER = new InputFilter[]{SmartLengthInputFilter.INSTANCE}; - private static final InputFilter[] NO_INPUT_FILTER = new InputFilter[0]; - - private final String accountId; - private StatusDisplayOptions statusDisplayOptions; - private final StatusActionListener statusListener; - private final NotificationActionListener notificationActionListener; - private final AccountActionListener accountActionListener; - private final AdapterDataSource dataSource; - private final AbsoluteTimeFormatter absoluteTimeFormatter = new AbsoluteTimeFormatter(); - - public NotificationsAdapter(String accountId, - AdapterDataSource dataSource, - StatusDisplayOptions statusDisplayOptions, - StatusActionListener statusListener, - NotificationActionListener notificationActionListener, - AccountActionListener accountActionListener) { - - this.accountId = accountId; - this.dataSource = dataSource; - this.statusDisplayOptions = statusDisplayOptions; - this.statusListener = statusListener; - this.notificationActionListener = notificationActionListener; - this.accountActionListener = accountActionListener; - } - - @NonNull - @Override - public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - LayoutInflater inflater = LayoutInflater.from(parent.getContext()); - switch (viewType) { - case VIEW_TYPE_STATUS: { - View view = inflater - .inflate(R.layout.item_status, parent, false); - return new StatusViewHolder(view); - } - case VIEW_TYPE_STATUS_NOTIFICATION: { - View view = inflater - .inflate(R.layout.item_status_notification, parent, false); - return new StatusNotificationViewHolder(view, statusDisplayOptions, absoluteTimeFormatter); - } - case VIEW_TYPE_FOLLOW: { - View view = inflater - .inflate(R.layout.item_follow, parent, false); - return new FollowViewHolder(view, statusDisplayOptions); - } - case VIEW_TYPE_FOLLOW_REQUEST: { - ItemFollowRequestBinding binding = ItemFollowRequestBinding.inflate(inflater, parent, false); - return new FollowRequestViewHolder(binding, this, true); - } - case VIEW_TYPE_PLACEHOLDER: { - View view = inflater - .inflate(R.layout.item_status_placeholder, parent, false); - return new PlaceholderViewHolder(view); - } - case VIEW_TYPE_REPORT: { - ItemReportNotificationBinding binding = ItemReportNotificationBinding.inflate(inflater, parent, false); - return new ReportNotificationViewHolder(binding); - } - default: - case VIEW_TYPE_UNKNOWN: { - View view = new View(parent.getContext()); - view.setLayoutParams( - new ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - Utils.dpToPx(parent.getContext(), 24) - ) - ); - return new RecyclerView.ViewHolder(view) { - }; - } - } - } - - @Override - public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) { - bindViewHolder(viewHolder, position, null); - } - - @Override - public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position, @NonNull List payloads) { - bindViewHolder(viewHolder, position, payloads); - } - - private void bindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position, @Nullable List payloads) { - Object payloadForHolder = payloads != null && !payloads.isEmpty() ? payloads.get(0) : null; - if (position < this.dataSource.getItemCount()) { - NotificationViewData notification = dataSource.getItemAt(position); - if (notification instanceof NotificationViewData.Placeholder) { - if (payloadForHolder == null) { - NotificationViewData.Placeholder placeholder = ((NotificationViewData.Placeholder) notification); - PlaceholderViewHolder holder = (PlaceholderViewHolder) viewHolder; - holder.setup(statusListener, placeholder.isLoading()); - } - return; - } - NotificationViewData.Concrete concreteNotification = - (NotificationViewData.Concrete) notification; - switch (viewHolder.getItemViewType()) { - case VIEW_TYPE_STATUS: { - StatusViewHolder holder = (StatusViewHolder) viewHolder; - StatusViewData.Concrete status = concreteNotification.getStatusViewData(); - if (status == null) { - /* in some very rare cases servers sends null status even though they should not, - * we have to handle it somehow */ - holder.showStatusContent(false); - } else { - if (payloads == null) { - holder.showStatusContent(true); - } - holder.setupWithStatus(status, statusListener, statusDisplayOptions, payloadForHolder); - } - if (concreteNotification.getType() == Notification.Type.POLL) { - holder.setPollInfo(accountId.equals(concreteNotification.getAccount().getId())); - } else { - holder.hideStatusInfo(); - } - break; - } - case VIEW_TYPE_STATUS_NOTIFICATION: { - StatusNotificationViewHolder holder = (StatusNotificationViewHolder) viewHolder; - StatusViewData.Concrete statusViewData = concreteNotification.getStatusViewData(); - if (payloadForHolder == null) { - if (statusViewData == null) { - /* in some very rare cases servers sends null status even though they should not, - * we have to handle it somehow */ - holder.showNotificationContent(false); - } else { - holder.showNotificationContent(true); - - Status status = statusViewData.getActionable(); - holder.setDisplayName(status.getAccount().getDisplayName(), status.getAccount().getEmojis()); - holder.setUsername(status.getAccount().getUsername()); - holder.setCreatedAt(status.getCreatedAt()); - - if (concreteNotification.getType() == Notification.Type.STATUS || - concreteNotification.getType() == Notification.Type.UPDATE) { - holder.setAvatar(status.getAccount().getAvatar(), status.getAccount().getBot()); - } else { - holder.setAvatars(status.getAccount().getAvatar(), - concreteNotification.getAccount().getAvatar()); - } - } - - holder.setMessage(concreteNotification, statusListener); - holder.setupButtons(notificationActionListener, - concreteNotification.getAccount().getId(), - concreteNotification.getId()); - } else { - if (payloadForHolder instanceof List) - for (Object item : (List) payloadForHolder) { - if (StatusBaseViewHolder.Key.KEY_CREATED.equals(item) && statusViewData != null) { - holder.setCreatedAt(statusViewData.getStatus().getActionableStatus().getCreatedAt()); - } - } - } - break; - } - case VIEW_TYPE_FOLLOW: { - if (payloadForHolder == null) { - FollowViewHolder holder = (FollowViewHolder) viewHolder; - holder.setMessage(concreteNotification.getAccount(), concreteNotification.getType() == Notification.Type.SIGN_UP); - holder.setupButtons(notificationActionListener, concreteNotification.getAccount().getId()); - } - break; - } - case VIEW_TYPE_FOLLOW_REQUEST: { - if (payloadForHolder == null) { - FollowRequestViewHolder holder = (FollowRequestViewHolder) viewHolder; - holder.setupWithAccount(concreteNotification.getAccount(), statusDisplayOptions.animateAvatars(), statusDisplayOptions.animateEmojis(), statusDisplayOptions.showBotOverlay()); - holder.setupActionListener(accountActionListener, concreteNotification.getAccount().getId()); - } - break; - } - case VIEW_TYPE_REPORT: { - if (payloadForHolder == null) { - ReportNotificationViewHolder holder = (ReportNotificationViewHolder) viewHolder; - holder.setupWithReport(concreteNotification.getAccount(), concreteNotification.getReport(), statusDisplayOptions.animateAvatars(), statusDisplayOptions.animateEmojis()); - holder.setupActionListener(notificationActionListener, concreteNotification.getReport().getTargetAccount().getId(), concreteNotification.getAccount().getId(), concreteNotification.getReport().getId()); - } - } - default: - } - } - } - - @Override - public int getItemCount() { - return dataSource.getItemCount(); - } - - public void setMediaPreviewEnabled(boolean mediaPreviewEnabled) { - this.statusDisplayOptions = statusDisplayOptions.copy( - statusDisplayOptions.animateAvatars(), - mediaPreviewEnabled, - statusDisplayOptions.useAbsoluteTime(), - statusDisplayOptions.showBotOverlay(), - statusDisplayOptions.useBlurhash(), - CardViewMode.NONE, - statusDisplayOptions.confirmReblogs(), - statusDisplayOptions.confirmFavourites(), - statusDisplayOptions.hideStats(), - statusDisplayOptions.animateEmojis(), - statusDisplayOptions.showStatsInline(), - statusDisplayOptions.showSensitiveMedia(), - statusDisplayOptions.openSpoiler() - ); - } - - public boolean isMediaPreviewEnabled() { - return this.statusDisplayOptions.mediaPreviewEnabled(); - } - - @Override - public int getItemViewType(int position) { - NotificationViewData notification = dataSource.getItemAt(position); - if (notification instanceof NotificationViewData.Concrete) { - NotificationViewData.Concrete concrete = ((NotificationViewData.Concrete) notification); - switch (concrete.getType()) { - case MENTION: - case POLL: { - return VIEW_TYPE_STATUS; - } - case STATUS: - case FAVOURITE: - case REBLOG: - case UPDATE: { - return VIEW_TYPE_STATUS_NOTIFICATION; - } - case FOLLOW: - case SIGN_UP: { - return VIEW_TYPE_FOLLOW; - } - case FOLLOW_REQUEST: { - return VIEW_TYPE_FOLLOW_REQUEST; - } - case REPORT: { - return VIEW_TYPE_REPORT; - } - default: { - return VIEW_TYPE_UNKNOWN; - } - } - } else if (notification instanceof NotificationViewData.Placeholder) { - return VIEW_TYPE_PLACEHOLDER; - } else { - throw new AssertionError("Unknown notification type"); - } - - - } - - public interface NotificationActionListener { - void onViewAccount(String id); - - void onViewStatusForNotificationId(String notificationId); - - void onViewReport(String reportId); - - void onExpandedChange(boolean expanded, int position); - - /** - * Called when the status {@link android.widget.ToggleButton} responsible for collapsing long - * status content is interacted with. - * - * @param isCollapsed Whether the status content is shown in a collapsed state or fully. - * @param position The position of the status in the list. - */ - void onNotificationContentCollapsedChange(boolean isCollapsed, int position); - } - - private static class FollowViewHolder extends RecyclerView.ViewHolder { - private final TextView message; - private final TextView usernameView; - private final TextView displayNameView; - private final ImageView avatar; - private final StatusDisplayOptions statusDisplayOptions; - - FollowViewHolder(View itemView, StatusDisplayOptions statusDisplayOptions) { - super(itemView); - message = itemView.findViewById(R.id.notification_text); - usernameView = itemView.findViewById(R.id.notification_username); - displayNameView = itemView.findViewById(R.id.notification_display_name); - avatar = itemView.findViewById(R.id.notification_avatar); - this.statusDisplayOptions = statusDisplayOptions; - } - - void setMessage(TimelineAccount account, Boolean isSignUp) { - Context context = message.getContext(); - - String format = context.getString(isSignUp ? R.string.notification_sign_up_format : R.string.notification_follow_format); - String wrappedDisplayName = StringUtils.unicodeWrap(account.getName()); - String wholeMessage = String.format(format, wrappedDisplayName); - CharSequence emojifiedMessage = CustomEmojiHelper.emojify( - wholeMessage, account.getEmojis(), message, statusDisplayOptions.animateEmojis() - ); - message.setText(emojifiedMessage); - - String username = context.getString(R.string.post_username_format, account.getUsername()); - usernameView.setText(username); - - CharSequence emojifiedDisplayName = CustomEmojiHelper.emojify( - wrappedDisplayName, account.getEmojis(), usernameView, statusDisplayOptions.animateEmojis() - ); - - displayNameView.setText(emojifiedDisplayName); - - int avatarRadius = avatar.getContext().getResources() - .getDimensionPixelSize(R.dimen.avatar_radius_42dp); - - ImageLoadingHelper.loadAvatar(account.getAvatar(), avatar, avatarRadius, - statusDisplayOptions.animateAvatars(), null); - - } - - void setupButtons(final NotificationActionListener listener, final String accountId) { - itemView.setOnClickListener(v -> listener.onViewAccount(accountId)); - } - } - - private static class StatusNotificationViewHolder extends RecyclerView.ViewHolder - implements View.OnClickListener { - - private final View container; - private final TextView message; -// private final View statusNameBar; - private final TextView displayName; - private final TextView username; - private final TextView timestampInfo; - private final TextView statusContent; - private final ImageView statusAvatar; - private final ImageView notificationAvatar; - private final TextView contentWarningDescriptionTextView; - private final Button contentWarningButton; - private final Button contentCollapseButton; // TODO: This code SHOULD be based on StatusBaseViewHolder - private final StatusDisplayOptions statusDisplayOptions; - private final AbsoluteTimeFormatter absoluteTimeFormatter; - - private String accountId; - private String notificationId; - private NotificationActionListener notificationActionListener; - private StatusViewData.Concrete statusViewData; - - private final int avatarRadius48dp; - private final int avatarRadius36dp; - private final int avatarRadius24dp; - - StatusNotificationViewHolder( - View itemView, - StatusDisplayOptions statusDisplayOptions, - AbsoluteTimeFormatter absoluteTimeFormatter - ) { - super(itemView); - message = itemView.findViewById(R.id.notification_top_text); -// statusNameBar = itemView.findViewById(R.id.status_name_bar); - displayName = itemView.findViewById(R.id.status_display_name); - username = itemView.findViewById(R.id.status_username); - timestampInfo = itemView.findViewById(R.id.status_meta_info); - statusContent = itemView.findViewById(R.id.notification_content); - statusAvatar = itemView.findViewById(R.id.notification_status_avatar); - notificationAvatar = itemView.findViewById(R.id.notification_notification_avatar); - contentWarningDescriptionTextView = itemView.findViewById(R.id.notification_content_warning_description); - contentWarningButton = itemView.findViewById(R.id.notification_content_warning_button); - contentCollapseButton = itemView.findViewById(R.id.button_toggle_notification_content); - - container = itemView.findViewById(R.id.notification_container); - - this.statusDisplayOptions = statusDisplayOptions; - this.absoluteTimeFormatter = absoluteTimeFormatter; - - int darkerFilter = Color.rgb(123, 123, 123); - statusAvatar.setColorFilter(darkerFilter, PorterDuff.Mode.MULTIPLY); - notificationAvatar.setColorFilter(darkerFilter, PorterDuff.Mode.MULTIPLY); - - itemView.setOnClickListener(this); - message.setOnClickListener(this); - statusContent.setOnClickListener(this); - - 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); - } - - private void showNotificationContent(boolean show) { -// statusNameBar.setVisibility(show ? View.VISIBLE : View.GONE); - contentWarningDescriptionTextView.setVisibility(show ? View.VISIBLE : View.GONE); - contentWarningButton.setVisibility(show ? View.VISIBLE : View.GONE); - statusContent.setVisibility(show ? View.VISIBLE : View.GONE); - statusAvatar.setVisibility(show ? View.VISIBLE : View.GONE); - notificationAvatar.setVisibility(show ? View.VISIBLE : View.GONE); - } - - private void setDisplayName(String name, List emojis) { - CharSequence emojifiedName = CustomEmojiHelper.emojify(name, emojis, displayName, statusDisplayOptions.animateEmojis()); - displayName.setText(emojifiedName); - } - - private void setUsername(String name) { - Context context = username.getContext(); - String format = context.getString(R.string.post_username_format); - String usernameText = String.format(format, name); - username.setText(usernameText); - } - - protected void setCreatedAt(@Nullable Date createdAt) { - if (statusDisplayOptions.useAbsoluteTime()) { - timestampInfo.setText(absoluteTimeFormatter.format(createdAt, true)); - } else { - // This is the visible timestampInfo. - String readout; - /* This one is for screen-readers. Frequently, they would mispronounce timestamps like "17m" - * as 17 meters instead of minutes. */ - CharSequence readoutAloud; - if (createdAt != null) { - long then = createdAt.getTime(); - long now = new Date().getTime(); - readout = TimestampUtils.getRelativeTimeSpanString(timestampInfo.getContext(), then, now); - readoutAloud = android.text.format.DateUtils.getRelativeTimeSpanString(then, now, - android.text.format.DateUtils.SECOND_IN_MILLIS, - android.text.format.DateUtils.FORMAT_ABBREV_RELATIVE); - } else { - // unknown minutes~ - readout = "?m"; - readoutAloud = "? minutes"; - } - timestampInfo.setText(readout); - timestampInfo.setContentDescription(readoutAloud); - } - } - - Drawable getIconWithColor(Context context, @DrawableRes int drawable, @ColorRes int color) { - Drawable icon = ContextCompat.getDrawable(context, drawable); - if (icon != null) { - icon.setColorFilter(context.getColor(color), PorterDuff.Mode.SRC_ATOP); - } - return icon; - } - - void setMessage(NotificationViewData.Concrete notificationViewData, LinkListener listener) { - this.statusViewData = notificationViewData.getStatusViewData(); - - String displayName = StringUtils.unicodeWrap(notificationViewData.getAccount().getName()); - Notification.Type type = notificationViewData.getType(); - - Context context = message.getContext(); - String format; - Drawable icon; - switch (type) { - default: - case FAVOURITE: { - icon = getIconWithColor(context, R.drawable.ic_star_24dp, R.color.tusky_orange); - format = context.getString(R.string.notification_favourite_format); - break; - } - case REBLOG: { - icon = getIconWithColor(context, R.drawable.ic_repeat_24dp, R.color.tusky_blue); - format = context.getString(R.string.notification_reblog_format); - break; - } - case STATUS: { - icon = getIconWithColor(context, R.drawable.ic_home_24dp, R.color.tusky_blue); - format = context.getString(R.string.notification_subscription_format); - break; - } - case UPDATE: { - icon = getIconWithColor(context, R.drawable.ic_edit_24dp, R.color.tusky_blue); - format = context.getString(R.string.notification_update_format); - break; - } - } - message.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null); - String wholeMessage = String.format(format, displayName); - final SpannableStringBuilder str = new SpannableStringBuilder(wholeMessage); - int displayNameIndex = format.indexOf("%1$s"); - str.setSpan( - new StyleSpan(Typeface.BOLD), - displayNameIndex, - displayNameIndex + displayName.length(), - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE - ); - CharSequence emojifiedText = CustomEmojiHelper.emojify( - str, notificationViewData.getAccount().getEmojis(), message, statusDisplayOptions.animateEmojis() - ); - message.setText(emojifiedText); - - if (statusViewData != null) { - boolean hasSpoiler = !TextUtils.isEmpty(statusViewData.getStatus().getSpoilerText()); - contentWarningDescriptionTextView.setVisibility(hasSpoiler ? View.VISIBLE : View.GONE); - contentWarningButton.setVisibility(hasSpoiler ? View.VISIBLE : View.GONE); - if (statusViewData.isExpanded()) { - contentWarningButton.setText(R.string.post_content_warning_show_less); - } else { - contentWarningButton.setText(R.string.post_content_warning_show_more); - } - - contentWarningButton.setOnClickListener(view -> { - if (getBindingAdapterPosition() != RecyclerView.NO_POSITION) { - notificationActionListener.onExpandedChange(!statusViewData.isExpanded(), getBindingAdapterPosition()); - } - statusContent.setVisibility(statusViewData.isExpanded() ? View.GONE : View.VISIBLE); - }); - - setupContentAndSpoiler(listener); - } - - } - - void setupButtons(final NotificationActionListener listener, final String accountId, - final String notificationId) { - this.notificationActionListener = listener; - this.accountId = accountId; - this.notificationId = notificationId; - } - - void setAvatar(@Nullable String statusAvatarUrl, boolean isBot) { - statusAvatar.setPaddingRelative(0, 0, 0, 0); - - ImageLoadingHelper.loadAvatar(statusAvatarUrl, - statusAvatar, avatarRadius48dp, statusDisplayOptions.animateAvatars(), null); - - if (statusDisplayOptions.showBotOverlay() && isBot) { - notificationAvatar.setVisibility(View.VISIBLE); - Glide.with(notificationAvatar) - .load(R.drawable.bot_badge) - .into(notificationAvatar); - - } else { - notificationAvatar.setVisibility(View.GONE); - } - } - - void setAvatars(@Nullable String statusAvatarUrl, @Nullable String notificationAvatarUrl) { - int padding = Utils.dpToPx(statusAvatar.getContext(), 12); - statusAvatar.setPaddingRelative(0, 0, padding, padding); - - ImageLoadingHelper.loadAvatar(statusAvatarUrl, - statusAvatar, avatarRadius36dp, statusDisplayOptions.animateAvatars(), null); - - notificationAvatar.setVisibility(View.VISIBLE); - ImageLoadingHelper.loadAvatar(notificationAvatarUrl, notificationAvatar, - avatarRadius24dp, statusDisplayOptions.animateAvatars(), null); - } - - @Override - public void onClick(View v) { - if (notificationActionListener == null) - return; - - if (v == container || v == statusContent) { - notificationActionListener.onViewStatusForNotificationId(notificationId); - } - else if (v == message) { - notificationActionListener.onViewAccount(accountId); - } - } - - private void setupContentAndSpoiler(final LinkListener listener) { - - boolean shouldShowContentIfSpoiler = statusViewData.isExpanded(); - boolean hasSpoiler = !TextUtils.isEmpty(statusViewData.getStatus(). getSpoilerText()); - if (!shouldShowContentIfSpoiler && hasSpoiler) { - statusContent.setVisibility(View.GONE); - } else { - statusContent.setVisibility(View.VISIBLE); - } - - Spanned content = statusViewData.getContent(); - List emojis = statusViewData.getActionable().getEmojis(); - - if (statusViewData.isCollapsible() && (statusViewData.isExpanded() || !hasSpoiler)) { - contentCollapseButton.setOnClickListener(view -> { - int position = getBindingAdapterPosition(); - if (position != RecyclerView.NO_POSITION && notificationActionListener != null) { - notificationActionListener.onNotificationContentCollapsedChange(!statusViewData.isCollapsed(), position); - } - }); - - contentCollapseButton.setVisibility(View.VISIBLE); - if (statusViewData.isCollapsed()) { - contentCollapseButton.setText(R.string.post_content_warning_show_more); - statusContent.setFilters(COLLAPSE_INPUT_FILTER); - } else { - contentCollapseButton.setText(R.string.post_content_warning_show_less); - statusContent.setFilters(NO_INPUT_FILTER); - } - } else { - contentCollapseButton.setVisibility(View.GONE); - statusContent.setFilters(NO_INPUT_FILTER); - } - - CharSequence emojifiedText = CustomEmojiHelper.emojify( - content, emojis, statusContent, statusDisplayOptions.animateEmojis() - ); - LinkHelper.setClickableText(statusContent, emojifiedText, statusViewData.getActionable().getMentions(), statusViewData.getActionable().getTags(), listener); - - CharSequence emojifiedContentWarning = CustomEmojiHelper.emojify( - statusViewData.getStatus().getSpoilerText(), - statusViewData.getActionable().getEmojis(), - contentWarningDescriptionTextView, - statusDisplayOptions.animateEmojis() - ); - contentWarningDescriptionTextView.setText(emojifiedContentWarning); - } - - } - - - @Override - public void onViewTag(@NonNull String tag) { - - } - - @Override - public void onViewAccount(@NonNull String id) { - - } - - @Override - public void onViewUrl(@NonNull String url) { - - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/PlaceholderViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/PlaceholderViewHolder.kt index c277ea385..88dead0e6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/PlaceholderViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/PlaceholderViewHolder.kt @@ -14,54 +14,34 @@ * see . */ package com.keylesspalace.tusky.adapter -import android.view.View import androidx.recyclerview.widget.RecyclerView -import com.google.android.material.button.MaterialButton -import com.google.android.material.progressindicator.CircularProgressIndicatorSpec -import com.google.android.material.progressindicator.IndeterminateDrawable -import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.databinding.ItemStatusPlaceholderBinding import com.keylesspalace.tusky.interfaces.StatusActionListener +import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.util.visible /** - * Placeholder for different timelines. + * Placeholder for missing parts in timelines. * - * Displays a "Load more" button for a particular status ID, or a - * circular progress wheel if the status' page is being loaded. - * - * The user can only have one "Load more" operation in progress at - * a time (determined by the adapter), so the contents of the view - * and the enabled state is driven by that. + * Displays a "Load more" button to load the gap, or a + * circular progress bar if the missing page is being loaded. */ -class PlaceholderViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { - private val loadMoreButton: MaterialButton = itemView.findViewById(R.id.button_load_more) - private val drawable = IndeterminateDrawable.createCircularDrawable( - itemView.context, - CircularProgressIndicatorSpec(itemView.context, null) - ) +class PlaceholderViewHolder( + private val binding: ItemStatusPlaceholderBinding, + listener: StatusActionListener +) : RecyclerView.ViewHolder(binding.root) { - fun setup(listener: StatusActionListener, loading: Boolean) { - itemView.isEnabled = !loading - loadMoreButton.isEnabled = !loading - - if (loading) { - loadMoreButton.text = "" - loadMoreButton.icon = drawable - return - } - - loadMoreButton.text = itemView.context.getString(R.string.load_more_placeholder_text) - loadMoreButton.icon = null - - // To allow the user to click anywhere in the layout to load more content set the click - // listener on the parent layout instead of loadMoreButton. - // - // See the comments in item_status_placeholder.xml for more details. - itemView.setOnClickListener { - itemView.isEnabled = false - loadMoreButton.isEnabled = false - loadMoreButton.icon = drawable - loadMoreButton.text = "" + init { + binding.loadMoreButton.setOnClickListener { + binding.loadMoreButton.hide() + binding.loadMoreProgressBar.show() listener.onLoadMore(bindingAdapterPosition) } } + + fun setup(loading: Boolean) { + binding.loadMoreButton.visible(!loading) + binding.loadMoreProgressBar.visible(loading) + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/appstore/CacheUpdater.kt b/app/src/main/java/com/keylesspalace/tusky/appstore/CacheUpdater.kt index 627a84344..8a78cb256 100644 --- a/app/src/main/java/com/keylesspalace/tusky/appstore/CacheUpdater.kt +++ b/app/src/main/java/com/keylesspalace/tusky/appstore/CacheUpdater.kt @@ -2,6 +2,9 @@ package com.keylesspalace.tusky.appstore import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.entity.Poll +import com.squareup.moshi.Moshi +import com.squareup.moshi.adapter import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -9,40 +12,64 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch +/** + * Updates the database cache in response to events. + * This is important for the home timeline and notifications to be up to date. + */ +@OptIn(ExperimentalStdlibApi::class) class CacheUpdater @Inject constructor( eventHub: EventHub, accountManager: AccountManager, - appDatabase: AppDatabase + appDatabase: AppDatabase, + moshi: Moshi ) { private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - init { - val timelineDao = appDatabase.timelineDao() + private val timelineDao = appDatabase.timelineDao() + private val statusDao = appDatabase.timelineStatusDao() + private val notificationsDao = appDatabase.notificationsDao() + init { scope.launch { eventHub.events.collect { event -> - val accountId = accountManager.activeAccount?.id ?: return@collect + val tuskyAccountId = accountManager.activeAccount?.id ?: return@collect when (event) { - is StatusChangedEvent -> { - val status = event.status - timelineDao.update( - accountId = accountId, - status = status - ) + is StatusChangedEvent -> statusDao.update( + tuskyAccountId = tuskyAccountId, + status = event.status, + moshi = moshi + ) + + is UnfollowEvent -> timelineDao.removeStatusesAndReblogsByUser(tuskyAccountId, event.accountId) + + is BlockEvent -> removeAllByUser(tuskyAccountId, event.accountId) + is MuteEvent -> removeAllByUser(tuskyAccountId, event.accountId) + + is DomainMuteEvent -> { + timelineDao.deleteAllFromInstance(tuskyAccountId, event.instance) + notificationsDao.deleteAllFromInstance(tuskyAccountId, event.instance) } - is UnfollowEvent -> - timelineDao.removeAllByUser(accountId, event.accountId) - is StatusDeletedEvent -> - timelineDao.delete(accountId, event.statusId) + + is StatusDeletedEvent -> { + timelineDao.deleteAllWithStatus(tuskyAccountId, event.statusId) + notificationsDao.deleteAllWithStatus(tuskyAccountId, event.statusId) + } + is PollVoteEvent -> { - timelineDao.setVoted(accountId, event.statusId, event.poll) + val pollString = moshi.adapter().toJson(event.poll) + statusDao.setVoted(tuskyAccountId, event.statusId, pollString) } } } } } + private suspend fun removeAllByUser(tuskyAccountId: Long, accountId: String) { + timelineDao.removeAllByUser(tuskyAccountId, accountId) + notificationsDao.removeAllByUser(tuskyAccountId, accountId) + } + fun stop() { this.scope.cancel() } diff --git a/app/src/main/java/com/keylesspalace/tusky/appstore/EventsHub.kt b/app/src/main/java/com/keylesspalace/tusky/appstore/EventsHub.kt index 88634681a..511bb26aa 100644 --- a/app/src/main/java/com/keylesspalace/tusky/appstore/EventsHub.kt +++ b/app/src/main/java/com/keylesspalace/tusky/appstore/EventsHub.kt @@ -1,14 +1,10 @@ package com.keylesspalace.tusky.appstore -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.lifecycleScope -import java.util.function.Consumer import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.launch interface Event @@ -21,13 +17,4 @@ class EventHub @Inject constructor() { suspend fun dispatch(event: Event) { _events.emit(event) } - - // TODO remove as soon as NotificationsFragment is Kotlin - fun subscribe(lifecycleOwner: LifecycleOwner, consumer: Consumer) { - lifecycleOwner.lifecycleScope.launch { - events.collect { event -> - consumer.accept(event) - } - } - } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt index 88ea5ba96..a13f3882f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt @@ -71,8 +71,8 @@ import com.keylesspalace.tusky.components.accountlist.AccountListActivity import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.components.report.ReportActivity import com.keylesspalace.tusky.databinding.ActivityAccountBinding -import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.DraftsAlert +import com.keylesspalace.tusky.db.entity.AccountEntity import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Relationship diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaRemoteMediator.kt index 77fb1dfb1..f20f6ae73 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaRemoteMediator.kt @@ -20,7 +20,7 @@ import androidx.paging.LoadType import androidx.paging.PagingState import androidx.paging.RemoteMediator import com.keylesspalace.tusky.components.timeline.util.ifExpected -import com.keylesspalace.tusky.db.AccountEntity +import com.keylesspalace.tusky.db.entity.AccountEntity import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.viewdata.AttachmentViewData import retrofit2.HttpException diff --git a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsAdapter.kt index cdce38142..fc860e59e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsAdapter.kt @@ -44,6 +44,7 @@ class FollowRequestsAdapter( ) return FollowRequestViewHolder( binding, + accountActionListener, linkListener, showHeader = false ) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt index d6bb1321c..8d67c5071 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt @@ -87,8 +87,8 @@ import com.keylesspalace.tusky.components.compose.view.ComposeOptionsListener import com.keylesspalace.tusky.components.compose.view.ComposeScheduleView import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository import com.keylesspalace.tusky.databinding.ActivityComposeBinding -import com.keylesspalace.tusky.db.AccountEntity -import com.keylesspalace.tusky.db.DraftAttachment +import com.keylesspalace.tusky.db.entity.AccountEntity +import com.keylesspalace.tusky.db.entity.DraftAttachment import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.entity.Attachment diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt index d38898c77..e3c0d5cdf 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt @@ -126,7 +126,7 @@ data class ConversationStatusEntity( visibility = Status.Visibility.DIRECT, attachments = attachments, mentions = mentions, - tags = tags, + tags = tags.orEmpty(), application = null, pinned = false, muted = muted, @@ -148,7 +148,7 @@ fun TimelineAccount.toEntity() = ConversationAccountEntity( username = username, displayName = name, avatar = avatar, - emojis = emojis.orEmpty() + emojis = emojis ) fun Status.toEntity(expanded: Boolean, contentShowing: Boolean, contentCollapsed: Boolean) = diff --git a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftHelper.kt b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftHelper.kt index 7cfed4ea8..0cd378024 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftHelper.kt @@ -23,8 +23,8 @@ import androidx.core.content.FileProvider import androidx.core.net.toUri import com.keylesspalace.tusky.BuildConfig import com.keylesspalace.tusky.db.AppDatabase -import com.keylesspalace.tusky.db.DraftAttachment -import com.keylesspalace.tusky.db.DraftEntity +import com.keylesspalace.tusky.db.entity.DraftAttachment +import com.keylesspalace.tusky.db.entity.DraftEntity import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.NewPoll import com.keylesspalace.tusky.entity.Status diff --git a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftMediaAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftMediaAdapter.kt index fd6816b7b..ac631721d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftMediaAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftMediaAdapter.kt @@ -24,7 +24,7 @@ import androidx.recyclerview.widget.RecyclerView import com.bumptech.glide.Glide import com.bumptech.glide.load.engine.DiskCacheStrategy import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.db.DraftAttachment +import com.keylesspalace.tusky.db.entity.DraftAttachment import com.keylesspalace.tusky.view.MediaPreviewImageView class DraftMediaAdapter( diff --git a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsActivity.kt index 1db7982c6..5f83a3a32 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsActivity.kt @@ -32,8 +32,8 @@ import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.databinding.ActivityDraftsBinding -import com.keylesspalace.tusky.db.DraftEntity import com.keylesspalace.tusky.db.DraftsAlert +import com.keylesspalace.tusky.db.entity.DraftEntity import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.util.isHttpNotFound import com.keylesspalace.tusky.util.parseAsMastodonHtml diff --git a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsAdapter.kt index 1c8ddbfed..05d8b826c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsAdapter.kt @@ -22,7 +22,7 @@ import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.databinding.ItemDraftBinding -import com.keylesspalace.tusky.db.DraftEntity +import com.keylesspalace.tusky.db.entity.DraftEntity import com.keylesspalace.tusky.util.BindingHolder import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.show diff --git a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsViewModel.kt index 813a424fa..db46ba1ff 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsViewModel.kt @@ -23,7 +23,7 @@ import androidx.paging.cachedIn import at.connyduck.calladapter.networkresult.NetworkResult import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase -import com.keylesspalace.tusky.db.DraftEntity +import com.keylesspalace.tusky.db.entity.DraftEntity import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi import javax.inject.Inject 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 cd73d3f21..ee2a9a7bf 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 @@ -24,8 +24,8 @@ import at.connyduck.calladapter.networkresult.onSuccess import at.connyduck.calladapter.networkresult.recoverCatching 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.db.entity.EmojisEntity +import com.keylesspalace.tusky.db.entity.InstanceInfoEntity import com.keylesspalace.tusky.di.ApplicationScope import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.Instance diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/FollowViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/FollowViewHolder.kt new file mode 100644 index 000000000..dbdc964c6 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/FollowViewHolder.kt @@ -0,0 +1,69 @@ +/* Copyright 2024 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.notifications + +import androidx.recyclerview.widget.RecyclerView +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.databinding.ItemFollowBinding +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.interfaces.AccountActionListener +import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.util.emojify +import com.keylesspalace.tusky.util.loadAvatar +import com.keylesspalace.tusky.util.unicodeWrap +import com.keylesspalace.tusky.viewdata.NotificationViewData + +class FollowViewHolder( + private val binding: ItemFollowBinding, + private val listener: AccountActionListener, +) : RecyclerView.ViewHolder(binding.root), NotificationsViewHolder { + + override fun bind( + viewData: NotificationViewData.Concrete, + payloads: List<*>, + statusDisplayOptions: StatusDisplayOptions + ) { + val context = itemView.context + val account = viewData.account + val messageTemplate = + context.getString(if (viewData.type == Notification.Type.SIGN_UP) R.string.notification_sign_up_format else R.string.notification_follow_format) + val wrappedDisplayName = account.name.unicodeWrap() + + binding.notificationText.text = messageTemplate.format(wrappedDisplayName) + .emojify(account.emojis, binding.notificationText, statusDisplayOptions.animateEmojis) + + binding.notificationUsername.text = context.getString(R.string.post_username_format, viewData.account.username) + + val emojifiedDisplayName = wrappedDisplayName.emojify( + account.emojis, + binding.notificationDisplayName, + statusDisplayOptions.animateEmojis + ) + binding.notificationDisplayName.text = emojifiedDisplayName + + val avatarRadius = context.resources + .getDimensionPixelSize(R.dimen.avatar_radius_42dp) + loadAvatar( + account.avatar, + binding.notificationAvatar, + avatarRadius, + statusDisplayOptions.animateAvatars, + null + ) + + itemView.setOnClickListener { listener.onViewAccount(account.id) } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationTypeMappers.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationTypeMappers.kt new file mode 100644 index 000000000..d3248a672 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationTypeMappers.kt @@ -0,0 +1,104 @@ +/* Copyright 2024 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.notifications + +import com.keylesspalace.tusky.components.timeline.Placeholder +import com.keylesspalace.tusky.components.timeline.toAccount +import com.keylesspalace.tusky.components.timeline.toStatus +import com.keylesspalace.tusky.db.entity.NotificationDataEntity +import com.keylesspalace.tusky.db.entity.NotificationEntity +import com.keylesspalace.tusky.db.entity.NotificationReportEntity +import com.keylesspalace.tusky.db.entity.TimelineAccountEntity +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.entity.Report +import com.keylesspalace.tusky.viewdata.NotificationViewData +import com.keylesspalace.tusky.viewdata.StatusViewData +import com.keylesspalace.tusky.viewdata.TranslationViewData + +fun Placeholder.toNotificationEntity( + tuskyAccountId: Long +) = NotificationEntity( + id = this.id, + tuskyAccountId = tuskyAccountId, + type = null, + accountId = null, + statusId = null, + reportId = null, + loading = loading +) + +fun Notification.toEntity( + tuskyAccountId: Long +) = NotificationEntity( + tuskyAccountId = tuskyAccountId, + type = type, + id = id, + accountId = account.id, + statusId = status?.id, + reportId = report?.id, + loading = false +) + +fun Report.toEntity( + tuskyAccountId: Long +) = NotificationReportEntity( + tuskyAccountId = tuskyAccountId, + serverId = id, + category = category, + statusIds = statusIds, + createdAt = createdAt, + targetAccountId = targetAccount.id +) + +fun NotificationDataEntity.toViewData( + translation: TranslationViewData? = null +): NotificationViewData { + if (type == null || account == null) { + return NotificationViewData.Placeholder(id = id, isLoading = loading) + } + + return NotificationViewData.Concrete( + id = id, + type = type, + account = account.toAccount(), + statusViewData = if (status != null && statusAccount != null) { + StatusViewData.Concrete( + status = status.toStatus(statusAccount), + isExpanded = this.status.expanded, + isShowingContent = this.status.contentShowing, + isCollapsed = this.status.contentCollapsed, + translation = translation + ) + } else { + null + }, + report = if (report != null && reportTargetAccount != null) { + report.toReport(reportTargetAccount) + } else { + null + } + ) +} + +fun NotificationReportEntity.toReport( + account: TimelineAccountEntity +) = Report( + id = serverId, + category = category, + statusIds = statusIds, + createdAt = createdAt, + targetAccount = account.toAccount() +) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt new file mode 100644 index 000000000..bd03d9e0e --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt @@ -0,0 +1,574 @@ +/* Copyright 2024 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.notifications + +import android.content.DialogInterface +import android.content.SharedPreferences +import android.os.Bundle +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import android.widget.ListView +import android.widget.PopupWindow +import androidx.appcompat.app.AlertDialog +import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.view.MenuProvider +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.paging.LoadState +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.SimpleItemAnimator +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import at.connyduck.calladapter.networkresult.onFailure +import at.connyduck.sparkbutton.helpers.Utils +import com.google.android.material.appbar.AppBarLayout +import com.google.android.material.snackbar.Snackbar +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.adapter.StatusBaseViewHolder +import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.appstore.PreferenceChangedEvent +import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder +import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper +import com.keylesspalace.tusky.databinding.FragmentTimelineNotificationsBinding +import com.keylesspalace.tusky.databinding.NotificationsFilterBinding +import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.di.ViewModelFactory +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.fragment.SFragment +import com.keylesspalace.tusky.interfaces.AccountActionListener +import com.keylesspalace.tusky.interfaces.ActionButtonActivity +import com.keylesspalace.tusky.interfaces.ReselectableFragment +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.StatusProvider +import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.openLink +import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.util.viewBinding +import com.keylesspalace.tusky.viewdata.AttachmentViewData +import com.keylesspalace.tusky.viewdata.NotificationViewData +import com.keylesspalace.tusky.viewdata.TranslationViewData +import javax.inject.Inject +import kotlin.time.DurationUnit +import kotlin.time.toDuration +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +class NotificationsFragment : + SFragment(), + SwipeRefreshLayout.OnRefreshListener, + StatusActionListener, + NotificationActionListener, + AccountActionListener, + MenuProvider, + ReselectableFragment, + Injectable { + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + @Inject + lateinit var preferences: SharedPreferences + + @Inject + lateinit var eventHub: EventHub + + private val binding by viewBinding(FragmentTimelineNotificationsBinding::bind) + + private val viewModel: NotificationsViewModel by viewModels { viewModelFactory } + + private lateinit var adapter: NotificationsPagingAdapter + + private var hideFab: Boolean = false + private var showNotificationsFilterBar: Boolean = true + private var readingOrder: ReadingOrder = ReadingOrder.NEWEST_FIRST + + /** see [com.keylesspalace.tusky.components.timeline.TimelineFragment] for explanation of the load more mechanism */ + private var loadMorePosition: Int? = null + private var statusIdBelowLoadMore: String? = null + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return inflater.inflate(R.layout.fragment_timeline_notifications, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val statusDisplayOptions = StatusDisplayOptions( + animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), + mediaPreviewEnabled = accountManager.activeAccount!!.mediaPreviewEnabled, + useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false), + showBotOverlay = preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true), + useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true), + cardViewMode = if (preferences.getBoolean(PrefKeys.SHOW_CARDS_IN_TIMELINES, false)) { + CardViewMode.INDENTED + } else { + CardViewMode.NONE + }, + confirmReblogs = preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true), + confirmFavourites = preferences.getBoolean(PrefKeys.CONFIRM_FAVOURITES, false), + hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), + animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), + showStatsInline = preferences.getBoolean(PrefKeys.SHOW_STATS_INLINE, false), + showSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia, + openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler + ) + + // setup the notifications filter bar + showNotificationsFilterBar = preferences.getBoolean(PrefKeys.SHOW_NOTIFICATIONS_FILTER, true) + updateFilterBarVisibility() + binding.buttonClear.setOnClickListener { confirmClearNotifications() } + binding.buttonFilter.setOnClickListener { showFilterMenu() } + + // Setup the SwipeRefreshLayout. + binding.swipeRefreshLayout.setOnRefreshListener(this) + binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue) + + // Setup the RecyclerView. + binding.recyclerView.setHasFixedSize(true) + adapter = NotificationsPagingAdapter( + accountId = accountManager.activeAccount!!.accountId, + statusListener = this, + notificationActionListener = this, + accountActionListener = this, + statusDisplayOptions = statusDisplayOptions + ) + binding.recyclerView.layoutManager = LinearLayoutManager(context) + binding.recyclerView.setAccessibilityDelegateCompat( + ListStatusAccessibilityDelegate( + binding.recyclerView, + this, + StatusProvider { pos: Int -> + if (pos in 0 until adapter.itemCount) { + val notification = adapter.peek(pos) + // We support replies only for now + if (notification is NotificationViewData.Concrete) { + return@StatusProvider notification.statusViewData + } else { + return@StatusProvider null + } + } else { + null + } + } + ) + ) + + binding.recyclerView.adapter = adapter + + (binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false + + binding.recyclerView.addItemDecoration( + DividerItemDecoration(context, DividerItemDecoration.VERTICAL) + ) + + hideFab = preferences.getBoolean(PrefKeys.FAB_HIDE, false) + readingOrder = ReadingOrder.from(preferences.getString(PrefKeys.READING_ORDER, null)) + + binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) { + val composeButton = (activity as ActionButtonActivity).actionButton + if (composeButton != null) { + if (hideFab) { + if (dy > 0 && composeButton.isShown) { + composeButton.hide() // hides the button if we're scrolling down + } else if (dy < 0 && !composeButton.isShown) { + composeButton.show() // shows it if we are scrolling up + } + } else if (!composeButton.isShown) { + composeButton.show() + } + } + } + }) + + adapter.addLoadStateListener { loadState -> + if (loadState.refresh != LoadState.Loading && loadState.source.refresh != LoadState.Loading) { + binding.swipeRefreshLayout.isRefreshing = false + } + + binding.statusView.hide() + binding.progressBar.hide() + + if (adapter.itemCount == 0) { + when (loadState.refresh) { + is LoadState.NotLoading -> { + if (loadState.append is LoadState.NotLoading && loadState.source.refresh is LoadState.NotLoading) { + binding.statusView.show() + binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty) + } + } + is LoadState.Error -> { + binding.statusView.show() + binding.statusView.setup((loadState.refresh as LoadState.Error).error) { onRefresh() } + } + is LoadState.Loading -> { + binding.progressBar.show() + } + } + } + } + + adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { + override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { + if (positionStart == 0 && adapter.itemCount != itemCount) { + binding.recyclerView.post { + if (getView() != null) { + binding.recyclerView.scrollBy( + 0, + Utils.dpToPx(binding.recyclerView.context, -30) + ) + } + } + } + if (readingOrder == ReadingOrder.OLDEST_FIRST) { + updateReadingPositionForOldestFirst() + } + } + }) + + viewLifecycleOwner.lifecycleScope.launch { + viewModel.notifications.collectLatest { pagingData -> + adapter.submitData(pagingData) + } + } + + viewLifecycleOwner.lifecycleScope.launch { + eventHub.events.collect { event -> + if (event is PreferenceChangedEvent) { + onPreferenceChanged(event.preferenceKey) + } + } + } + + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + accountManager.activeAccount?.let { account -> + NotificationHelper.clearNotificationsForAccount(requireContext(), account) + } + + val useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false) + while (!useAbsoluteTime) { + adapter.notifyItemRangeChanged( + 0, + adapter.itemCount, + listOf(StatusBaseViewHolder.Key.KEY_CREATED) + ) + delay(1.toDuration(DurationUnit.MINUTES)) + } + } + } + } + + override fun onReselect() { + if (isAdded) { + binding.recyclerView.layoutManager?.scrollToPosition(0) + binding.recyclerView.stopScroll() + } + } + + override fun onRefresh() { + adapter.refresh() + } + + override fun onViewAccount(id: String) { + super.viewAccount(id) + } + + override fun onMute(mute: Boolean, id: String, position: Int, notifications: Boolean) { + // not needed, muting via the more menu on statuses is handled in SFragment + } + + override fun onBlock(block: Boolean, id: String, position: Int) { + // not needed, blocking via the more menu on statuses is handled in SFragment + } + + override fun onRespondToFollowRequest(accept: Boolean, id: String, position: Int) { + val notification = adapter.peek(position) ?: return + viewModel.respondToFollowRequest(accept, accountId = id, notificationId = notification.id) + } + + override fun onViewReport(reportId: String?) { + requireContext().openLink( + "https://${accountManager.activeAccount!!.domain}/admin/reports/$reportId" + ) + } + + override fun onViewTag(tag: String) { + super.viewTag(tag) + } + + override fun onReply(position: Int) { + val status = adapter.peek(position)?.asStatusOrNull() ?: return + super.reply(status.status) + } + + override fun removeItem(position: Int) { + val notification = adapter.peek(position) ?: return + viewModel.remove(notification.id) + } + + override fun onReblog(reblog: Boolean, position: Int) { + val status = adapter.peek(position)?.asStatusOrNull() ?: return + viewModel.reblog(reblog, status) + } + + override val onMoreTranslate: (translate: Boolean, position: Int) -> Unit + get() = { translate: Boolean, position: Int -> + if (translate) { + onTranslate(position) + } else { + onUntranslate(position) + } + } + + 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) + } + + override fun onBookmark(bookmark: Boolean, position: Int) { + val status = adapter.peek(position)?.asStatusOrNull() ?: return + viewModel.bookmark(bookmark, status) + } + + override fun onVoteInPoll(position: Int, choices: List) { + val status = adapter.peek(position)?.asStatusOrNull() ?: return + viewModel.voteInPoll(choices, status) + } + + override fun clearWarningAction(position: Int) { + val status = adapter.peek(position)?.asStatusOrNull() ?: return + viewModel.clearWarning(status) + } + + override fun onMore(view: View, position: Int) { + val status = adapter.peek(position)?.asStatusOrNull() ?: return + super.more( + status.status, + view, + position, + (status.translation as? TranslationViewData.Loaded)?.data + ) + } + + override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { + val status = adapter.peek(position)?.asStatusOrNull()?.status ?: return + super.viewMedia(attachmentIndex, AttachmentViewData.list(status), view) + } + + override fun onViewThread(position: Int) { + val status = adapter.peek(position)?.asStatusOrNull()?.status ?: return + super.viewThread(status.id, status.url) + } + + override fun onOpenReblog(position: Int) { + val status = adapter.peek(position)?.asStatusOrNull() ?: return + super.openReblog(status.status) + } + + override fun onExpandedChange(expanded: Boolean, position: Int) { + val status = adapter.peek(position)?.asStatusOrNull() ?: return + viewModel.changeExpanded(expanded, status) + } + + override fun onContentHiddenChange(isShowing: Boolean, position: Int) { + val status = adapter.peek(position)?.asStatusOrNull() ?: return + viewModel.changeContentShowing(isShowing, status) + } + + 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 + viewModel.loadMore(placeholder.id) + } + + override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { + val status = adapter.peek(position)?.asStatusOrNull() ?: return + viewModel.changeContentCollapsed(isCollapsed, status) + } + + private fun confirmClearNotifications() { + AlertDialog.Builder(requireContext()) + .setMessage(R.string.notification_clear_text) + .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> clearNotifications() } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun clearNotifications() { + viewModel.clearNotifications() + } + + private fun showFilterMenu() { + val notificationTypeList = Notification.Type.visibleTypes.map { type -> + getString(type.uiString) + } + + val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_list_item_multiple_choice, notificationTypeList) + val window = PopupWindow(requireContext()) + val menuBinding = NotificationsFilterBinding.inflate(LayoutInflater.from(requireContext()), binding.root as ViewGroup, false) + + menuBinding.buttonApply.setOnClickListener { + val checkedItems = menuBinding.listView.getCheckedItemPositions() + val excludes = Notification.Type.visibleTypes.filterIndexed { index, _ -> + !checkedItems[index, false] + } + window.dismiss() + viewModel.updateNotificationFilters(excludes.toSet()) + } + + menuBinding.listView.setAdapter(adapter) + menuBinding.listView.setChoiceMode(ListView.CHOICE_MODE_MULTIPLE) + + Notification.Type.visibleTypes.forEachIndexed { index, type -> + menuBinding.listView.setItemChecked(index, !viewModel.filters.value.contains(type)) + } + + window.setContentView(menuBinding.root) + window.isFocusable = true + window.width = ViewGroup.LayoutParams.WRAP_CONTENT + window.height = ViewGroup.LayoutParams.WRAP_CONTENT + window.showAsDropDown(binding.buttonFilter) + } + + private fun onPreferenceChanged(key: String) { + when (key) { + PrefKeys.FAB_HIDE -> { + hideFab = preferences.getBoolean(PrefKeys.FAB_HIDE, false) + } + + PrefKeys.MEDIA_PREVIEW_ENABLED -> { + val enabled = accountManager.activeAccount!!.mediaPreviewEnabled + val oldMediaPreviewEnabled = adapter.mediaPreviewEnabled + if (enabled != oldMediaPreviewEnabled) { + adapter.mediaPreviewEnabled = enabled + } + } + + PrefKeys.SHOW_NOTIFICATIONS_FILTER -> { + if (isAdded) { + showNotificationsFilterBar = preferences.getBoolean(PrefKeys.SHOW_NOTIFICATIONS_FILTER, true) + updateFilterBarVisibility() + } + } + + PrefKeys.READING_ORDER -> { + readingOrder = ReadingOrder.from( + preferences.getString(PrefKeys.READING_ORDER, null) + ) + } + } + } + + private fun updateFilterBarVisibility() { + val params = binding.swipeRefreshLayout.layoutParams as CoordinatorLayout.LayoutParams + if (showNotificationsFilterBar) { + binding.appBarOptions.setExpanded(true, false) + binding.appBarOptions.show() + // Set content behaviour to hide filter on scroll + params.behavior = AppBarLayout.ScrollingViewBehavior() + } else { + binding.appBarOptions.setExpanded(false, false) + binding.appBarOptions.hide() + // Clear behaviour to hide app bar + params.behavior = null + } + } + + private fun updateReadingPositionForOldestFirst() { + var position = loadMorePosition ?: return + val notificationIdBelowLoadMore = statusIdBelowLoadMore ?: return + + var notification: NotificationViewData? + while (adapter.peek(position).let { + notification = it + it != null + } + ) { + if (notification?.id == notificationIdBelowLoadMore) { + val lastVisiblePosition = + (binding.recyclerView.layoutManager as LinearLayoutManager).findLastVisibleItemPosition() + if (position > lastVisiblePosition) { + binding.recyclerView.scrollToPosition(position) + } + break + } + position++ + } + loadMorePosition = null + } + + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.fragment_notifications, menu) + } + + override fun onMenuItemSelected(menuItem: MenuItem) = when (menuItem.itemId) { + R.id.action_refresh -> { + binding.swipeRefreshLayout.isRefreshing = true + onRefresh() + true + } + R.id.action_edit_notification_filter -> { + showFilterMenu() + true + } + R.id.action_clear_notifications -> { + confirmClearNotifications() + true + } + else -> false + } + + companion object { + fun newInstance() = NotificationsFragment() + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingAdapter.kt new file mode 100644 index 000000000..8f131020e --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingAdapter.kt @@ -0,0 +1,197 @@ +/* Copyright 2024 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.notifications + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.paging.PagingDataAdapter +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import com.keylesspalace.tusky.adapter.FollowRequestViewHolder +import com.keylesspalace.tusky.adapter.PlaceholderViewHolder +import com.keylesspalace.tusky.adapter.StatusBaseViewHolder +import com.keylesspalace.tusky.databinding.ItemFollowBinding +import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding +import com.keylesspalace.tusky.databinding.ItemReportNotificationBinding +import com.keylesspalace.tusky.databinding.ItemStatusBinding +import com.keylesspalace.tusky.databinding.ItemStatusNotificationBinding +import com.keylesspalace.tusky.databinding.ItemStatusPlaceholderBinding +import com.keylesspalace.tusky.databinding.ItemUnknownNotificationBinding +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.interfaces.AccountActionListener +import com.keylesspalace.tusky.interfaces.StatusActionListener +import com.keylesspalace.tusky.util.AbsoluteTimeFormatter +import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.viewdata.NotificationViewData + +interface NotificationActionListener { + fun onViewReport(reportId: String?) +} + +interface NotificationsViewHolder { + fun bind( + viewData: NotificationViewData.Concrete, + payloads: List<*>, + statusDisplayOptions: StatusDisplayOptions + ) +} + +class NotificationsPagingAdapter( + private val accountId: String, + private var statusDisplayOptions: StatusDisplayOptions, + private val statusListener: StatusActionListener, + private val notificationActionListener: NotificationActionListener, + private val accountActionListener: AccountActionListener +) : PagingDataAdapter(NotificationsDifferCallback) { + + var mediaPreviewEnabled: Boolean + get() = statusDisplayOptions.mediaPreviewEnabled + set(mediaPreviewEnabled) { + statusDisplayOptions = statusDisplayOptions.copy( + mediaPreviewEnabled = mediaPreviewEnabled + ) + notifyItemRangeChanged(0, itemCount) + } + + private val absoluteTimeFormatter = AbsoluteTimeFormatter() + + init { + stateRestorationPolicy = StateRestorationPolicy.PREVENT_WHEN_EMPTY + } + + override fun getItemViewType(position: Int): Int { + return when (val notification = getItem(position)) { + is NotificationViewData.Concrete -> { + when (notification.type) { + Notification.Type.MENTION, + Notification.Type.POLL -> VIEW_TYPE_STATUS + Notification.Type.STATUS, + Notification.Type.FAVOURITE, + Notification.Type.REBLOG, + Notification.Type.UPDATE -> VIEW_TYPE_STATUS_NOTIFICATION + Notification.Type.FOLLOW, + Notification.Type.SIGN_UP -> VIEW_TYPE_FOLLOW + Notification.Type.FOLLOW_REQUEST -> VIEW_TYPE_FOLLOW_REQUEST + Notification.Type.REPORT -> VIEW_TYPE_REPORT + else -> VIEW_TYPE_UNKNOWN + } + } + is NotificationViewData.Placeholder -> VIEW_TYPE_PLACEHOLDER + null -> throw IllegalStateException("no item at position $position") + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val inflater = LayoutInflater.from(parent.context) + return when (viewType) { + VIEW_TYPE_STATUS -> StatusViewHolder( + ItemStatusBinding.inflate(inflater, parent, false), + statusListener, + accountId + ) + VIEW_TYPE_STATUS_NOTIFICATION -> StatusNotificationViewHolder( + ItemStatusNotificationBinding.inflate(inflater, parent, false), + statusListener, + absoluteTimeFormatter + ) + VIEW_TYPE_FOLLOW -> FollowViewHolder( + ItemFollowBinding.inflate(inflater, parent, false), + accountActionListener + ) + VIEW_TYPE_FOLLOW_REQUEST -> FollowRequestViewHolder( + ItemFollowRequestBinding.inflate(inflater, parent, false), + accountActionListener, + statusListener, + true + ) + VIEW_TYPE_PLACEHOLDER -> PlaceholderViewHolder( + ItemStatusPlaceholderBinding.inflate(inflater, parent, false), + statusListener + ) + VIEW_TYPE_REPORT -> ReportNotificationViewHolder( + ItemReportNotificationBinding.inflate(inflater, parent, false), + notificationActionListener, + accountActionListener + ) + else -> UnknownNotificationViewHolder( + ItemUnknownNotificationBinding.inflate(inflater, parent, false) + ) + } + } + + override fun onBindViewHolder(viewHolder: RecyclerView.ViewHolder, position: Int) { + bindViewHolder(viewHolder, position, emptyList()) + } + + override fun onBindViewHolder( + viewHolder: RecyclerView.ViewHolder, + position: Int, + payloads: List + ) { + bindViewHolder(viewHolder, position, payloads) + } + + private fun bindViewHolder(viewHolder: RecyclerView.ViewHolder, position: Int, payloads: List) { + getItem(position)?.let { notification -> + when (notification) { + is NotificationViewData.Concrete -> + (viewHolder as NotificationsViewHolder).bind(notification, payloads, statusDisplayOptions) + is NotificationViewData.Placeholder -> { + (viewHolder as PlaceholderViewHolder).setup(notification.isLoading) + } + } + } + } + + companion object { + private const val VIEW_TYPE_STATUS = 0 + private const val VIEW_TYPE_STATUS_NOTIFICATION = 1 + private const val VIEW_TYPE_FOLLOW = 2 + private const val VIEW_TYPE_FOLLOW_REQUEST = 3 + private const val VIEW_TYPE_PLACEHOLDER = 4 + private const val VIEW_TYPE_REPORT = 5 + private const val VIEW_TYPE_UNKNOWN = 6 + + val NotificationsDifferCallback = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: NotificationViewData, + newItem: NotificationViewData + ): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame( + oldItem: NotificationViewData, + newItem: NotificationViewData + ): Boolean { + return false // Items are different always. It allows to refresh timestamp on every view holder update + } + + override fun getChangePayload( + oldItem: NotificationViewData, + newItem: NotificationViewData + ): Any? { + return if (oldItem == newItem) { + // If items are equal - update timestamp only + listOf(StatusBaseViewHolder.Key.KEY_CREATED) + } else { + // If items are different - update the whole view holder + null + } + } + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRemoteMediator.kt new file mode 100644 index 000000000..d6627da58 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRemoteMediator.kt @@ -0,0 +1,208 @@ +/* Copyright 2024 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.notifications + +import android.util.Log +import androidx.paging.ExperimentalPagingApi +import androidx.paging.LoadType +import androidx.paging.PagingState +import androidx.paging.RemoteMediator +import androidx.room.withTransaction +import com.keylesspalace.tusky.components.timeline.Placeholder +import com.keylesspalace.tusky.components.timeline.toEntity +import com.keylesspalace.tusky.components.timeline.util.ifExpected +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.db.entity.NotificationDataEntity +import com.keylesspalace.tusky.db.entity.TimelineStatusEntity +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.isLessThan +import retrofit2.HttpException + +@OptIn(ExperimentalPagingApi::class) +class NotificationsRemoteMediator( + private val accountManager: AccountManager, + private val api: MastodonApi, + private val db: AppDatabase, + var excludes: Set +) : RemoteMediator() { + + private var initialRefresh = false + + private val notificationsDao = db.notificationsDao() + private val accountDao = db.timelineAccountDao() + private val statusDao = db.timelineStatusDao() + private val activeAccount = accountManager.activeAccount!! + + override suspend fun load( + loadType: LoadType, + state: PagingState + ): MediatorResult { + if (!activeAccount.isLoggedIn()) { + return MediatorResult.Success(endOfPaginationReached = true) + } + + try { + var dbEmpty = false + + val topPlaceholderId = if (loadType == LoadType.REFRESH) { + notificationsDao.getTopPlaceholderId(activeAccount.id) + } else { + null // don't execute the query if it is not needed + } + + if (!initialRefresh && loadType == LoadType.REFRESH) { + val topId = notificationsDao.getTopId(activeAccount.id) + topId?.let { cachedTopId -> + val notificationResponse = api.notifications( + maxId = cachedTopId, + // so already existing placeholders don't get accidentally overwritten + sinceId = topPlaceholderId, + limit = state.config.pageSize, + excludes = excludes + ) + + val notifications = notificationResponse.body() + if (notificationResponse.isSuccessful && notifications != null) { + db.withTransaction { + replaceNotificationRange(notifications, state) + } + } + } + initialRefresh = true + dbEmpty = topId == null + } + + val notificationResponse = when (loadType) { + LoadType.REFRESH -> { + api.notifications(sinceId = topPlaceholderId, limit = state.config.pageSize, excludes = excludes) + } + LoadType.PREPEND -> { + return MediatorResult.Success(endOfPaginationReached = true) + } + LoadType.APPEND -> { + val maxId = state.pages.findLast { it.data.isNotEmpty() }?.data?.lastOrNull()?.id + api.notifications(maxId = maxId, limit = state.config.pageSize, excludes = excludes) + } + } + + val notifications = notificationResponse.body() + if (!notificationResponse.isSuccessful || notifications == null) { + return MediatorResult.Error(HttpException(notificationResponse)) + } + + db.withTransaction { + val overlappedNotifications = replaceNotificationRange(notifications, state) + + /* In case we loaded a whole page and there was no overlap with existing statuses, + we insert a placeholder because there might be even more unknown statuses */ + if (loadType == LoadType.REFRESH && overlappedNotifications == 0 && notifications.size == state.config.pageSize && !dbEmpty) { + /* This overrides the last of the newly loaded statuses with a placeholder + to guarantee the placeholder has an id that exists on the server as not all + servers handle client generated ids as expected */ + notificationsDao.insertNotification( + Placeholder(notifications.last().id, loading = false).toNotificationEntity(activeAccount.id) + ) + } + } + return MediatorResult.Success(endOfPaginationReached = notifications.isEmpty()) + } catch (e: Exception) { + return ifExpected(e) { + Log.w(TAG, "Failed to load notifications", e) + MediatorResult.Error(e) + } + } + } + + /** + * Deletes all notifications in a given range and inserts new notifications. + * This is necessary so notifications that have been deleted on the server are cleaned up. + * Should be run in a transaction as it executes multiple db updates + * @param notifications the new notifications + * @return the number of old notifications that have been cleared from the database + */ + private suspend fun replaceNotificationRange(notifications: List, state: PagingState): Int { + val overlappedNotifications = if (notifications.isNotEmpty()) { + notificationsDao.deleteRange(activeAccount.id, notifications.last().id, notifications.first().id) + } else { + 0 + } + + for (notification in notifications) { + accountDao.insert(notification.account.toEntity(activeAccount.id)) + notification.report?.let { report -> + accountDao.insert(report.targetAccount.toEntity(activeAccount.id)) + notificationsDao.insertReport(report.toEntity(activeAccount.id)) + } + + // check if we already have one of the newly loaded statuses cached locally + // in case we do, copy the local state (expanded, contentShowing, contentCollapsed) over so it doesn't get lost + var oldStatus: TimelineStatusEntity? = null + for (page in state.pages) { + oldStatus = page.data.find { s -> + s.id == notification.id + }?.status + if (oldStatus != null) break + } + + notification.status?.let { status -> + val expanded = oldStatus?.expanded ?: activeAccount.alwaysOpenSpoiler + val contentShowing = oldStatus?.contentShowing ?: (activeAccount.alwaysShowSensitiveMedia || !status.sensitive) + val contentCollapsed = oldStatus?.contentCollapsed ?: true + + accountDao.insert(status.account.toEntity(activeAccount.id)) + + statusDao.insert( + status.toEntity( + tuskyAccountId = activeAccount.id, + expanded = expanded, + contentShowing = contentShowing, + contentCollapsed = contentCollapsed + ) + ) + } + + notificationsDao.insertNotification( + notification.toEntity( + activeAccount.id + ) + ) + } + notifications.firstOrNull()?.let { notification -> + saveNewestNotificationId(notification) + } + return overlappedNotifications + } + + private fun saveNewestNotificationId(notification: Notification) { + val account = accountManager.activeAccount + // make sure the account we are currently working with is still active + if (account == activeAccount) { + val lastNotificationId: String = activeAccount.lastNotificationId + val newestNotificationId = notification.id + if (lastNotificationId.isLessThan(newestNotificationId)) { + Log.d(TAG, "saving newest noti id: $lastNotificationId for account ${account.id}") + account.lastNotificationId = newestNotificationId + accountManager.saveAccount(account) + } + } + } + + companion object { + private const val TAG = "NotificationsRM" + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt new file mode 100644 index 000000000..78362b897 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt @@ -0,0 +1,406 @@ +/* Copyright 2024 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.notifications + +import android.content.SharedPreferences +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.ExperimentalPagingApi +import androidx.paging.Pager +import androidx.paging.PagingConfig +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.fold +import at.connyduck.calladapter.networkresult.map +import at.connyduck.calladapter.networkresult.onFailure +import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.appstore.PreferenceChangedEvent +import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder +import com.keylesspalace.tusky.components.timeline.Placeholder +import com.keylesspalace.tusky.components.timeline.toEntity +import com.keylesspalace.tusky.components.timeline.util.ifExpected +import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.entity.Filter +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.network.FilterModel +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.usecase.TimelineCases +import com.keylesspalace.tusky.util.EmptyPagingSource +import com.keylesspalace.tusky.util.deserialize +import com.keylesspalace.tusky.util.serialize +import com.keylesspalace.tusky.viewdata.NotificationViewData +import com.keylesspalace.tusky.viewdata.StatusViewData +import com.keylesspalace.tusky.viewdata.TranslationViewData +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.launch +import retrofit2.HttpException + +class NotificationsViewModel @Inject constructor( + private val timelineCases: TimelineCases, + private val api: MastodonApi, + eventHub: EventHub, + private val accountManager: AccountManager, + private val preferences: SharedPreferences, + private val filterModel: FilterModel, + private val db: AppDatabase, +) : ViewModel() { + + private val refreshTrigger = MutableStateFlow(0L) + + private val _filters = MutableStateFlow( + accountManager.activeAccount?.let { account -> deserialize(account.notificationsFilter) } ?: emptySet() + ) + val filters: StateFlow> = _filters.asStateFlow() + + /** Map from notification id to translation. */ + private val translations = MutableStateFlow(mapOf()) + + private var remoteMediator = NotificationsRemoteMediator(accountManager, api, db, filters.value) + + private var readingOrder: ReadingOrder = + ReadingOrder.from(preferences.getString(PrefKeys.READING_ORDER, null)) + + @OptIn(ExperimentalPagingApi::class, ExperimentalCoroutinesApi::class) + val notifications = refreshTrigger.flatMapLatest { + Pager( + config = PagingConfig(pageSize = LOAD_AT_ONCE), + remoteMediator = remoteMediator, + pagingSourceFactory = { + val activeAccount = accountManager.activeAccount + if (activeAccount == null) { + EmptyPagingSource() + } else { + db.notificationsDao().getNotifications(activeAccount.id) + } + } + ).flow + .cachedIn(viewModelScope) + .combine(translations) { pagingData, translations -> + pagingData.map { notification -> + val translation = translations[notification.status?.serverId] + notification.toViewData(translation = translation) + }.filter { notificationViewData -> + shouldFilterStatus(notificationViewData) != Filter.Action.HIDE + } + } + } + .flowOn(Dispatchers.Default) + + init { + viewModelScope.launch { + eventHub.events.collect { event -> + if (event is PreferenceChangedEvent) { + onPreferenceChanged(event.preferenceKey) + } + } + } + } + + fun updateNotificationFilters(newFilters: Set) { + if (newFilters != _filters.value) { + val account = accountManager.activeAccount + if (account != null) { + viewModelScope.launch { + account.notificationsFilter = serialize(newFilters) + accountManager.saveAccount(account) + remoteMediator.excludes = newFilters + // clear the cache to trigger a reload + db.notificationsDao().cleanupNotifications(account.id, 0) + _filters.value = newFilters + } + } + } + } + + private fun shouldFilterStatus(notificationViewData: NotificationViewData): Filter.Action { + return when ((notificationViewData as? NotificationViewData.Concrete)?.type) { + Notification.Type.MENTION, Notification.Type.STATUS, Notification.Type.POLL -> { + notificationViewData.statusViewData?.let { statusViewData -> + statusViewData.filterAction = filterModel.shouldFilterStatus(statusViewData.actionable) + return statusViewData.filterAction + } + Filter.Action.NONE + } + else -> Filter.Action.NONE + } + } + + fun respondToFollowRequest(accept: Boolean, accountId: String, notificationId: String) { + viewModelScope.launch { + if (accept) { + api.authorizeFollowRequest(accountId) + } else { + api.rejectFollowRequest(accountId) + }.fold( + onSuccess = { + // since the follow request has been responded, the notification can be deleted. The Ui will update automatically. + db.notificationsDao().delete(accountManager.activeAccount!!.id, notificationId) + if (accept) { + // refresh the notifications so the new follow notification will be loaded + refreshTrigger.value++ + } + }, + onFailure = { t -> + Log.e(TAG, "Failed to to respond to follow request from account id $accountId.", t) + } + ) + } + } + + fun reblog(reblog: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch { + timelineCases.reblog(status.actionableId, reblog).onFailure { t -> + ifExpected(t) { + Log.w(TAG, "Failed to reblog status " + status.actionableId, t) + } + } + } + + fun favorite(favorite: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch { + timelineCases.favourite(status.actionableId, favorite).onFailure { t -> + ifExpected(t) { + Log.d(TAG, "Failed to favourite status " + status.actionableId, t) + } + } + } + + fun bookmark(bookmark: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch { + timelineCases.bookmark(status.actionableId, bookmark).onFailure { t -> + ifExpected(t) { + Log.d(TAG, "Failed to bookmark status " + status.actionableId, t) + } + } + } + + fun voteInPoll(choices: List, status: StatusViewData.Concrete) = viewModelScope.launch { + val poll = status.status.actionableStatus.poll ?: run { + Log.d(TAG, "No poll on status ${status.id}") + return@launch + } + timelineCases.voteInPoll(status.actionableId, poll.id, choices).onFailure { t -> + ifExpected(t) { + Log.d(TAG, "Failed to vote in poll: " + status.actionableId, t) + } + } + } + + fun changeExpanded(expanded: Boolean, status: StatusViewData.Concrete) { + viewModelScope.launch { + db.timelineStatusDao() + .setExpanded(accountManager.activeAccount!!.id, status.id, expanded) + } + } + + fun changeContentShowing(isShowing: Boolean, status: StatusViewData.Concrete) { + viewModelScope.launch { + db.timelineStatusDao() + .setContentShowing(accountManager.activeAccount!!.id, status.id, isShowing) + } + } + + fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete) { + viewModelScope.launch { + db.timelineStatusDao() + .setContentCollapsed(accountManager.activeAccount!!.id, status.id, isCollapsed) + } + } + + fun remove(notificationId: String) { + viewModelScope.launch { + db.notificationsDao().delete(accountManager.activeAccount!!.id, notificationId) + } + } + + fun clearWarning(status: StatusViewData.Concrete) { + viewModelScope.launch { + db.timelineStatusDao().clearWarning(accountManager.activeAccount!!.id, status.actionableId) + } + } + + fun clearNotifications() { + viewModelScope.launch { + api.clearNotifications().fold( + { + db.notificationsDao().cleanupNotifications(accountManager.activeAccount!!.id, 0) + }, + { t -> + Log.w(TAG, "failed to clear notifications", t) + } + ) + } + } + + suspend fun translate(status: StatusViewData.Concrete): NetworkResult { + translations.value += (status.id to TranslationViewData.Loading) + return timelineCases.translate(status.actionableId) + .map { translation -> + translations.value += (status.id to TranslationViewData.Loaded(translation)) + } + .onFailure { + translations.value -= status.id + } + } + + fun untranslate(status: StatusViewData.Concrete) { + translations.value -= status.id + } + + fun loadMore(placeholderId: String) { + viewModelScope.launch { + try { + val notificationsDao = db.notificationsDao() + + val activeAccount = accountManager.activeAccount!! + + notificationsDao.insertNotification( + Placeholder(placeholderId, loading = true).toNotificationEntity( + activeAccount.id + ) + ) + + val response = db.withTransaction { + val idAbovePlaceholder = notificationsDao.getIdAbove(activeAccount.id, placeholderId) + val idBelowPlaceholder = notificationsDao.getIdBelow(activeAccount.id, placeholderId) + when (readingOrder) { + // Using minId, loads up to LOAD_AT_ONCE statuses with IDs immediately + // after minId and no larger than maxId + ReadingOrder.OLDEST_FIRST -> api.notifications( + maxId = idAbovePlaceholder, + minId = idBelowPlaceholder, + limit = TimelineViewModel.LOAD_AT_ONCE + ) + // Using sinceId, loads up to LOAD_AT_ONCE statuses immediately before + // maxId, and no smaller than minId. + ReadingOrder.NEWEST_FIRST -> api.notifications( + maxId = idAbovePlaceholder, + sinceId = idBelowPlaceholder, + limit = TimelineViewModel.LOAD_AT_ONCE + ) + } + } + + val notifications = response.body() + if (!response.isSuccessful || notifications == null) { + loadMoreFailed(placeholderId, HttpException(response)) + return@launch + } + + val statusDao = db.timelineStatusDao() + val accountDao = db.timelineAccountDao() + + db.withTransaction { + notificationsDao.delete(activeAccount.id, placeholderId) + + val overlappedNotifications = if (notifications.isNotEmpty()) { + notificationsDao.deleteRange( + activeAccount.id, + notifications.last().id, + notifications.first().id + ) + } else { + 0 + } + + for (notification in notifications) { + accountDao.insert(notification.account.toEntity(activeAccount.id)) + notification.report?.let { report -> + accountDao.insert(report.targetAccount.toEntity(activeAccount.id)) + notificationsDao.insertReport(report.toEntity(activeAccount.id)) + } + notification.status?.let { status -> + accountDao.insert(status.account.toEntity(activeAccount.id)) + + statusDao.insert( + status.toEntity( + tuskyAccountId = activeAccount.id, + expanded = activeAccount.alwaysOpenSpoiler, + contentShowing = activeAccount.alwaysShowSensitiveMedia || !status.sensitive, + contentCollapsed = true + ) + ) + } + notificationsDao.insertNotification( + notification.toEntity( + activeAccount.id + ) + ) + } + + /* In case we loaded a whole page and there was no overlap with existing notifications, + we insert a placeholder because there might be even more unknown notifications */ + if (overlappedNotifications == 0 && notifications.size == TimelineViewModel.LOAD_AT_ONCE) { + /* This overrides the first/last of the newly loaded notifications with a placeholder + to guarantee the placeholder has an id that exists on the server as not all + servers handle client generated ids as expected */ + val idToConvert = when (readingOrder) { + ReadingOrder.OLDEST_FIRST -> notifications.first().id + ReadingOrder.NEWEST_FIRST -> notifications.last().id + } + notificationsDao.insertNotification( + Placeholder( + idToConvert, + loading = false + ).toNotificationEntity(activeAccount.id) + ) + } + } + } catch (e: Exception) { + ifExpected(e) { + loadMoreFailed(placeholderId, e) + } + } + } + } + + private suspend fun loadMoreFailed(placeholderId: String, e: Exception) { + Log.w(TAG, "failed loading notifications", e) + val activeAccount = accountManager.activeAccount!! + db.notificationsDao() + .insertNotification( + Placeholder(placeholderId, loading = false).toNotificationEntity(activeAccount.id) + ) + } + + private fun onPreferenceChanged(key: String) { + when (key) { + PrefKeys.READING_ORDER -> { + readingOrder = ReadingOrder.from( + preferences.getString(PrefKeys.READING_ORDER, null) + ) + } + } + } + + companion object { + private const val LOAD_AT_ONCE = 30 + private const val TAG = "NotificationsViewModel" + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/ReportNotificationViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/ReportNotificationViewHolder.kt similarity index 65% rename from app/src/main/java/com/keylesspalace/tusky/adapter/ReportNotificationViewHolder.kt rename to app/src/main/java/com/keylesspalace/tusky/components/notifications/ReportNotificationViewHolder.kt index d4a20821f..df897e243 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/ReportNotificationViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/ReportNotificationViewHolder.kt @@ -13,93 +13,75 @@ * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ -package com.keylesspalace.tusky.adapter +package com.keylesspalace.tusky.components.notifications import android.content.Context -import androidx.core.content.ContextCompat import androidx.recyclerview.widget.RecyclerView -import at.connyduck.sparkbutton.helpers.Utils import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.adapter.NotificationsAdapter.NotificationActionListener import com.keylesspalace.tusky.databinding.ItemReportNotificationBinding -import com.keylesspalace.tusky.entity.Report -import com.keylesspalace.tusky.entity.TimelineAccount +import com.keylesspalace.tusky.interfaces.AccountActionListener +import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.getRelativeTimeSpanString import com.keylesspalace.tusky.util.loadAvatar import com.keylesspalace.tusky.util.unicodeWrap +import com.keylesspalace.tusky.viewdata.NotificationViewData class ReportNotificationViewHolder( - private val binding: ItemReportNotificationBinding -) : RecyclerView.ViewHolder(binding.root) { + private val binding: ItemReportNotificationBinding, + private val listener: NotificationActionListener, + private val accountActionListener: AccountActionListener +) : RecyclerView.ViewHolder(binding.root), NotificationsViewHolder { - fun setupWithReport( - reporter: TimelineAccount, - report: Report, - animateAvatar: Boolean, - animateEmojis: Boolean + override fun bind( + viewData: NotificationViewData.Concrete, + payloads: List<*>, + statusDisplayOptions: StatusDisplayOptions ) { - val reporterName = reporter.name.unicodeWrap().emojify( - reporter.emojis, - itemView, - animateEmojis - ) - val reporteeName = report.targetAccount.name.unicodeWrap().emojify( - report.targetAccount.emojis, - itemView, - animateEmojis - ) - val icon = ContextCompat.getDrawable(itemView.context, R.drawable.ic_flag_24dp) + val report = viewData.report!! + val reporter = viewData.account + + val reporterName = reporter.name.unicodeWrap().emojify(reporter.emojis, itemView, statusDisplayOptions.animateEmojis) + val reporteeName = report.targetAccount.name.unicodeWrap().emojify(report.targetAccount.emojis, itemView, statusDisplayOptions.animateEmojis) - binding.notificationTopText.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null) binding.notificationTopText.text = itemView.context.getString(R.string.notification_header_report_format, reporterName, reporteeName) binding.notificationSummary.text = itemView.context.getString(R.string.notification_summary_report_format, getRelativeTimeSpanString(itemView.context, report.createdAt.time, System.currentTimeMillis()), report.statusIds?.size ?: 0) binding.notificationCategory.text = getTranslatedCategory(itemView.context, report.category) - // Fancy avatar inset - val padding = Utils.dpToPx(binding.notificationReporteeAvatar.context, 12) - binding.notificationReporteeAvatar.setPaddingRelative(0, 0, padding, padding) - loadAvatar( report.targetAccount.avatar, binding.notificationReporteeAvatar, itemView.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp), - animateAvatar + statusDisplayOptions.animateAvatars, ) loadAvatar( reporter.avatar, binding.notificationReporterAvatar, itemView.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_24dp), - animateAvatar + statusDisplayOptions.animateAvatars, ) - } - fun setupActionListener( - listener: NotificationActionListener, - reporteeId: String, - reporterId: String, - reportId: String - ) { binding.notificationReporteeAvatar.setOnClickListener { val position = bindingAdapterPosition if (position != RecyclerView.NO_POSITION) { - listener.onViewAccount(reporteeId) + accountActionListener.onViewAccount(report.targetAccount.id) } } binding.notificationReporterAvatar.setOnClickListener { val position = bindingAdapterPosition if (position != RecyclerView.NO_POSITION) { - listener.onViewAccount(reporterId) + accountActionListener.onViewAccount(reporter.id) } } - itemView.setOnClickListener { listener.onViewReport(reportId) } + itemView.setOnClickListener { listener.onViewReport(report.id) } } private fun getTranslatedCategory(context: Context, rawCategory: String): String { return when (rawCategory) { "violation" -> context.getString(R.string.report_category_violation) "spam" -> context.getString(R.string.report_category_spam) + "legal" -> context.getString(R.string.report_category_legal) "other" -> context.getString(R.string.report_category_other) else -> rawCategory } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusNotificationViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusNotificationViewHolder.kt new file mode 100644 index 000000000..6defe1ac6 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusNotificationViewHolder.kt @@ -0,0 +1,364 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.components.notifications + +import android.content.Context +import android.graphics.PorterDuff +import android.graphics.Typeface +import android.graphics.drawable.Drawable +import android.text.InputFilter +import android.text.Spanned +import android.text.format.DateUtils +import android.text.style.StyleSpan +import android.view.View +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import androidx.core.content.ContextCompat +import androidx.core.text.toSpannable +import androidx.recyclerview.widget.RecyclerView +import at.connyduck.sparkbutton.helpers.Utils +import com.bumptech.glide.Glide +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.adapter.StatusBaseViewHolder +import com.keylesspalace.tusky.databinding.ItemStatusNotificationBinding +import com.keylesspalace.tusky.entity.Emoji +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.interfaces.LinkListener +import com.keylesspalace.tusky.interfaces.StatusActionListener +import com.keylesspalace.tusky.util.AbsoluteTimeFormatter +import com.keylesspalace.tusky.util.SmartLengthInputFilter +import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.util.emojify +import com.keylesspalace.tusky.util.getRelativeTimeSpanString +import com.keylesspalace.tusky.util.loadAvatar +import com.keylesspalace.tusky.util.setClickableText +import com.keylesspalace.tusky.util.unicodeWrap +import com.keylesspalace.tusky.util.visible +import com.keylesspalace.tusky.viewdata.NotificationViewData +import com.keylesspalace.tusky.viewdata.StatusViewData +import java.util.Date + +internal class StatusNotificationViewHolder( + private val binding: ItemStatusNotificationBinding, + private val statusActionListener: StatusActionListener, + private val absoluteTimeFormatter: AbsoluteTimeFormatter +) : NotificationsViewHolder, RecyclerView.ViewHolder(binding.root) { + private val avatarRadius48dp = itemView.context.resources.getDimensionPixelSize( + R.dimen.avatar_radius_48dp + ) + private val avatarRadius36dp = itemView.context.resources.getDimensionPixelSize( + R.dimen.avatar_radius_36dp + ) + private val avatarRadius24dp = itemView.context.resources.getDimensionPixelSize( + R.dimen.avatar_radius_24dp + ) + + override fun bind( + viewData: NotificationViewData.Concrete, + payloads: List<*>, + statusDisplayOptions: StatusDisplayOptions + ) { + val statusViewData = viewData.statusViewData + if (payloads.isEmpty()) { + /* in some very rare cases servers sends null status even though they should not */ + if (statusViewData == null) { + showNotificationContent(false) + } else { + showNotificationContent(true) + val (_, _, account, _, _, _, _, createdAt) = statusViewData.actionable + setDisplayName(account.name, account.emojis, statusDisplayOptions.animateEmojis) + setUsername(account.username) + setCreatedAt(createdAt, statusDisplayOptions.useAbsoluteTime) + if (viewData.type == Notification.Type.STATUS || + viewData.type == Notification.Type.UPDATE + ) { + setAvatar( + account.avatar, + account.bot, + statusDisplayOptions.animateAvatars, + statusDisplayOptions.showBotOverlay + ) + } else { + setAvatars( + account.avatar, + viewData.account.avatar, + statusDisplayOptions.animateAvatars + ) + } + + binding.notificationContainer.setOnClickListener { + statusActionListener.onViewThread(bindingAdapterPosition) + } + binding.notificationContent.setOnClickListener { + statusActionListener.onViewThread(bindingAdapterPosition) + } + binding.notificationTopText.setOnClickListener { + statusActionListener.onViewAccount(viewData.account.id) + } + } + setMessage(viewData, statusActionListener, statusDisplayOptions.animateEmojis) + } else { + for (item in payloads) { + if (StatusBaseViewHolder.Key.KEY_CREATED == item && statusViewData != null) { + setCreatedAt( + statusViewData.status.actionableStatus.createdAt, + statusDisplayOptions.useAbsoluteTime + ) + } + } + } + } + + private fun showNotificationContent(show: Boolean) { + binding.statusDisplayName.visible(show) + binding.statusUsername.visible(show) + binding.statusMetaInfo.visible(show) + binding.notificationContentWarningDescription.visible(show) + binding.notificationContentWarningButton.visible(show) + binding.notificationContent.visible(show) + binding.notificationStatusAvatar.visible(show) + binding.notificationNotificationAvatar.visible(show) + } + + private fun setDisplayName(name: String, emojis: List, animateEmojis: Boolean) { + val emojifiedName = name.emojify(emojis, binding.statusDisplayName, animateEmojis) + binding.statusDisplayName.text = emojifiedName + } + + private fun setUsername(name: String) { + val context = binding.statusUsername.context + val format = context.getString(R.string.post_username_format) + val usernameText = String.format(format, name) + binding.statusUsername.text = usernameText + } + + private fun setCreatedAt(createdAt: Date, useAbsoluteTime: Boolean) { + if (useAbsoluteTime) { + binding.statusMetaInfo.text = absoluteTimeFormatter.format(createdAt, true) + } else { + val readout: String // visible timestamp + val readoutAloud: CharSequence // for screenreaders so they don't mispronounce timestamps like "17m" as 17 meters + + val then = createdAt.time + val now = System.currentTimeMillis() + readout = getRelativeTimeSpanString(binding.statusMetaInfo.context, then, now) + readoutAloud = DateUtils.getRelativeTimeSpanString( + then, + now, + DateUtils.SECOND_IN_MILLIS, + DateUtils.FORMAT_ABBREV_RELATIVE + ) + + binding.statusMetaInfo.text = readout + binding.statusMetaInfo.contentDescription = readoutAloud + } + } + + private fun getIconWithColor( + context: Context, + @DrawableRes drawable: Int, + @ColorRes color: Int + ): Drawable? { + val icon = ContextCompat.getDrawable(context, drawable) + icon?.setColorFilter(context.getColor(color), PorterDuff.Mode.SRC_ATOP) + return icon + } + + private fun setAvatar(statusAvatarUrl: String?, isBot: Boolean, animateAvatars: Boolean, showBotOverlay: Boolean) { + binding.notificationStatusAvatar.setPaddingRelative(0, 0, 0, 0) + loadAvatar( + statusAvatarUrl, + binding.notificationStatusAvatar, + avatarRadius48dp, + animateAvatars + ) + if (showBotOverlay && isBot) { + binding.notificationNotificationAvatar.visibility = View.VISIBLE + Glide.with(binding.notificationNotificationAvatar) + .load(R.drawable.bot_badge) + .into(binding.notificationNotificationAvatar) + } else { + binding.notificationNotificationAvatar.visibility = View.GONE + } + } + + private fun setAvatars(statusAvatarUrl: String?, notificationAvatarUrl: String?, animateAvatars: Boolean) { + val padding = Utils.dpToPx(binding.notificationStatusAvatar.context, 12) + binding.notificationStatusAvatar.setPaddingRelative(0, 0, padding, padding) + loadAvatar( + statusAvatarUrl, + binding.notificationStatusAvatar, + avatarRadius36dp, + animateAvatars + ) + binding.notificationNotificationAvatar.visibility = View.VISIBLE + loadAvatar( + notificationAvatarUrl, + binding.notificationNotificationAvatar, + avatarRadius24dp, + animateAvatars + ) + } + + fun setMessage( + notificationViewData: NotificationViewData.Concrete, + listener: LinkListener, + animateEmojis: Boolean + ) { + val statusViewData = notificationViewData.statusViewData + val displayName = notificationViewData.account.name.unicodeWrap() + val type = notificationViewData.type + val context = binding.notificationTopText.context + val format: String + val icon: Drawable? + when (type) { + Notification.Type.FAVOURITE -> { + icon = getIconWithColor(context, R.drawable.ic_star_24dp, R.color.tusky_orange) + format = context.getString(R.string.notification_favourite_format) + } + Notification.Type.REBLOG -> { + icon = getIconWithColor(context, R.drawable.ic_repeat_24dp, R.color.tusky_blue) + format = context.getString(R.string.notification_reblog_format) + } + Notification.Type.STATUS -> { + icon = getIconWithColor(context, R.drawable.ic_home_24dp, R.color.tusky_blue) + format = context.getString(R.string.notification_subscription_format) + } + Notification.Type.UPDATE -> { + icon = getIconWithColor(context, R.drawable.ic_edit_24dp, R.color.tusky_blue) + format = context.getString(R.string.notification_update_format) + } + else -> { + icon = getIconWithColor(context, R.drawable.ic_star_24dp, R.color.tusky_orange) + format = context.getString(R.string.notification_favourite_format) + } + } + binding.notificationTopText.setCompoundDrawablesWithIntrinsicBounds( + icon, + null, + null, + null + ) + val wholeMessage = String.format(format, displayName).toSpannable() + val displayNameIndex = format.indexOf("%1\$s") + wholeMessage.setSpan( + StyleSpan(Typeface.BOLD), + displayNameIndex, + displayNameIndex + displayName.length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + val emojifiedText = wholeMessage.emojify( + notificationViewData.account.emojis, + binding.notificationTopText, + animateEmojis + ) + binding.notificationTopText.text = emojifiedText + if (statusViewData != null) { + val hasSpoiler = statusViewData.status.spoilerText.isNotEmpty() + binding.notificationContentWarningDescription.visibility = + if (hasSpoiler) View.VISIBLE else View.GONE + binding.notificationContentWarningButton.visibility = + if (hasSpoiler) View.VISIBLE else View.GONE + if (statusViewData.isExpanded) { + binding.notificationContentWarningButton.setText( + R.string.post_content_warning_show_less + ) + } else { + binding.notificationContentWarningButton.setText( + R.string.post_content_warning_show_more + ) + } + binding.notificationContentWarningButton.setOnClickListener { + if (bindingAdapterPosition != RecyclerView.NO_POSITION) { + statusActionListener.onExpandedChange( + !statusViewData.isExpanded, + bindingAdapterPosition + ) + } + binding.notificationContent.visibility = + if (statusViewData.isExpanded) View.GONE else View.VISIBLE + } + setupContentAndSpoiler(listener, statusViewData, animateEmojis) + } + } + + private fun setupContentAndSpoiler( + listener: LinkListener, + statusViewData: StatusViewData.Concrete, + animateEmojis: Boolean + ) { + val shouldShowContentIfSpoiler = statusViewData.isExpanded + val hasSpoiler = statusViewData.status.spoilerText.isNotEmpty() + if (!shouldShowContentIfSpoiler && hasSpoiler) { + binding.notificationContent.visibility = View.GONE + } else { + binding.notificationContent.visibility = View.VISIBLE + } + val content = statusViewData.content + val emojis = statusViewData.actionable.emojis + if (statusViewData.isCollapsible && (statusViewData.isExpanded || !hasSpoiler)) { + binding.buttonToggleNotificationContent.setOnClickListener { + val position = bindingAdapterPosition + if (position != RecyclerView.NO_POSITION) { + statusActionListener.onContentCollapsedChange( + !statusViewData.isCollapsed, + position + ) + } + } + binding.buttonToggleNotificationContent.visibility = View.VISIBLE + if (statusViewData.isCollapsed) { + binding.buttonToggleNotificationContent.setText( + R.string.post_content_warning_show_more + ) + binding.notificationContent.filters = COLLAPSE_INPUT_FILTER + } else { + binding.buttonToggleNotificationContent.setText( + R.string.post_content_warning_show_less + ) + binding.notificationContent.filters = NO_INPUT_FILTER + } + } else { + binding.buttonToggleNotificationContent.visibility = View.GONE + binding.notificationContent.filters = NO_INPUT_FILTER + } + val emojifiedText = content.emojify( + emojis = emojis, + view = binding.notificationContent, + animate = animateEmojis + ) + setClickableText( + binding.notificationContent, + emojifiedText, + statusViewData.actionable.mentions, + statusViewData.actionable.tags, + listener + ) + val emojifiedContentWarning: CharSequence = statusViewData.status.spoilerText.emojify( + statusViewData.actionable.emojis, + binding.notificationContentWarningDescription, + animateEmojis + ) + binding.notificationContentWarningDescription.text = emojifiedContentWarning + } + + companion object { + private val COLLAPSE_INPUT_FILTER: Array = arrayOf(SmartLengthInputFilter) + private val NO_INPUT_FILTER: Array = arrayOf() + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusViewHolder.kt new file mode 100644 index 000000000..fcc289e5c --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusViewHolder.kt @@ -0,0 +1,59 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.components.notifications + +import com.keylesspalace.tusky.adapter.StatusViewHolder +import com.keylesspalace.tusky.databinding.ItemStatusBinding +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.interfaces.StatusActionListener +import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.viewdata.NotificationViewData + +internal class StatusViewHolder( + binding: ItemStatusBinding, + private val statusActionListener: StatusActionListener, + private val accountId: String +) : NotificationsViewHolder, StatusViewHolder(binding.root) { + + override fun bind( + viewData: NotificationViewData.Concrete, + payloads: List<*>, + statusDisplayOptions: StatusDisplayOptions + ) { + val statusViewData = viewData.statusViewData + if (statusViewData == null) { + /* in some very rare cases servers sends null status even though they should not */ + showStatusContent(false) + } else { + if (payloads.isEmpty()) { + showStatusContent(true) + } + setupWithStatus( + statusViewData, + statusActionListener, + statusDisplayOptions, + payloads.firstOrNull() + ) + } + if (viewData.type == Notification.Type.POLL) { + setPollInfo(accountId == viewData.account.id) + } else { + hideStatusInfo() + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/UnknownNotificationViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/UnknownNotificationViewHolder.kt new file mode 100644 index 000000000..3ccc2f2af --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/UnknownNotificationViewHolder.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.components.notifications + +import com.keylesspalace.tusky.adapter.StatusViewHolder +import com.keylesspalace.tusky.databinding.ItemUnknownNotificationBinding +import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.viewdata.NotificationViewData + +internal class UnknownNotificationViewHolder( + binding: ItemUnknownNotificationBinding, +) : NotificationsViewHolder, StatusViewHolder(binding.root) { + + override fun bind( + viewData: NotificationViewData.Concrete, + payloads: List<*>, + statusDisplayOptions: StatusDisplayOptions + ) { + // nothing to do + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt index bcd10db1e..fcd5a3ba4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt @@ -36,7 +36,7 @@ import com.keylesspalace.tusky.components.domainblocks.DomainBlocksActivity import com.keylesspalace.tusky.components.filters.FiltersActivity import com.keylesspalace.tusky.components.followedtags.FollowedTagsActivity import com.keylesspalace.tusky.components.login.LoginActivity -import com.keylesspalace.tusky.components.notifications.currentAccountNeedsMigration +import com.keylesspalace.tusky.components.systemnotifications.currentAccountNeedsMigration import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.entity.Account diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/NotificationPreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/NotificationPreferencesFragment.kt index 83a96ed5b..922d081fb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/NotificationPreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/NotificationPreferencesFragment.kt @@ -18,9 +18,9 @@ package com.keylesspalace.tusky.components.preference import android.os.Bundle import androidx.preference.PreferenceFragmentCompat import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.components.notifications.NotificationHelper -import com.keylesspalace.tusky.db.AccountEntity +import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.db.entity.AccountEntity import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.settings.makePreferenceScreen diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt index a7dc4e92e..3385d079e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt @@ -162,6 +162,13 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable { setTitle(R.string.pref_title_hide_top_toolbar) } + switchPreference { + setDefaultValue(true) + key = PrefKeys.SHOW_NOTIFICATIONS_FILTER + setTitle(R.string.pref_title_show_notifications_filter) + isSingleLineTitle = false + } + switchPreference { setDefaultValue(false) key = PrefKeys.FAB_HIDE 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 ba075449e..6040bac3c 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 @@ -27,8 +27,8 @@ 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 +import com.keylesspalace.tusky.db.entity.AccountEntity import com.keylesspalace.tusky.entity.DeletedStatus import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi 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 abbfea9d0..ac7662cb3 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 @@ -48,8 +48,8 @@ import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeOptions import com.keylesspalace.tusky.components.report.ReportActivity import com.keylesspalace.tusky.components.search.adapter.SearchStatusesAdapter -import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.db.entity.AccountEntity import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status.Mention diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationFetcher.kt b/app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/NotificationFetcher.kt similarity index 98% rename from app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationFetcher.kt rename to app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/NotificationFetcher.kt index 91e97a41d..8e86d5e22 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationFetcher.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/NotificationFetcher.kt @@ -1,4 +1,4 @@ -package com.keylesspalace.tusky.components.notifications +package com.keylesspalace.tusky.components.systemnotifications import android.app.NotificationManager import android.content.Context @@ -6,9 +6,9 @@ import android.util.Log import androidx.annotation.WorkerThread import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.NewNotificationsEvent -import com.keylesspalace.tusky.components.notifications.NotificationHelper.filterNotification -import com.keylesspalace.tusky.db.AccountEntity +import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper.filterNotification import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.db.entity.AccountEntity import com.keylesspalace.tusky.entity.Marker import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.network.MastodonApi diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java b/app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/NotificationHelper.java similarity index 99% rename from app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java rename to app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/NotificationHelper.java index a2059ba64..4a574db9b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java +++ b/app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/NotificationHelper.java @@ -14,7 +14,7 @@ * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ -package com.keylesspalace.tusky.components.notifications; +package com.keylesspalace.tusky.components.systemnotifications; import static com.keylesspalace.tusky.BuildConfig.APPLICATION_ID; import static com.keylesspalace.tusky.util.StatusParsingHelper.parseAsMastodonHtml; @@ -55,7 +55,7 @@ import com.bumptech.glide.request.FutureTarget; import com.keylesspalace.tusky.MainActivity; import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.components.compose.ComposeActivity; -import com.keylesspalace.tusky.db.AccountEntity; +import com.keylesspalace.tusky.db.entity.AccountEntity; import com.keylesspalace.tusky.db.AccountManager; import com.keylesspalace.tusky.entity.Notification; import com.keylesspalace.tusky.entity.Poll; diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/PushNotificationHelper.kt b/app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/PushNotificationHelper.kt similarity index 98% rename from app/src/main/java/com/keylesspalace/tusky/components/notifications/PushNotificationHelper.kt rename to app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/PushNotificationHelper.kt index c89823e6d..4e7c1a682 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/PushNotificationHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/PushNotificationHelper.kt @@ -15,7 +15,7 @@ @file:JvmName("PushNotificationHelper") -package com.keylesspalace.tusky.components.notifications +package com.keylesspalace.tusky.components.systemnotifications import android.app.NotificationManager import android.content.Context @@ -29,8 +29,8 @@ import at.connyduck.calladapter.networkresult.onSuccess import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.R import com.keylesspalace.tusky.components.login.LoginActivity -import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.db.entity.AccountEntity import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.CryptoUtil diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt index 716a30199..bc74d868d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt @@ -24,6 +24,7 @@ import com.keylesspalace.tusky.R import com.keylesspalace.tusky.adapter.PlaceholderViewHolder import com.keylesspalace.tusky.adapter.StatusBaseViewHolder import com.keylesspalace.tusky.adapter.StatusViewHolder +import com.keylesspalace.tusky.databinding.ItemStatusPlaceholderBinding import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.util.StatusDisplayOptions @@ -54,7 +55,8 @@ class TimelinePagingAdapter( } VIEW_TYPE_PLACEHOLDER -> { PlaceholderViewHolder( - inflater.inflate(R.layout.item_status_placeholder, viewGroup, false) + ItemStatusPlaceholderBinding.inflate(inflater, viewGroup, false), + statusListener ) } else -> { @@ -83,7 +85,7 @@ class TimelinePagingAdapter( val status = getItem(position) if (status is StatusViewData.Placeholder) { val holder = viewHolder as PlaceholderViewHolder - holder.setup(statusListener, status.isLoading) + holder.setup(status.isLoading) } else if (status is StatusViewData.Concrete) { val holder = viewHolder as StatusViewHolder holder.setupWithStatus( 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 4f8251d9e..42df6721a 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 @@ -17,45 +17,36 @@ package com.keylesspalace.tusky.components.timeline -import android.util.Log -import com.keylesspalace.tusky.db.TimelineAccountEntity -import com.keylesspalace.tusky.db.TimelineStatusEntity -import com.keylesspalace.tusky.db.TimelineStatusWithAccount -import com.keylesspalace.tusky.entity.Attachment -import com.keylesspalace.tusky.entity.Card -import com.keylesspalace.tusky.entity.Emoji -import com.keylesspalace.tusky.entity.HashTag -import com.keylesspalace.tusky.entity.Poll +import com.keylesspalace.tusky.db.entity.HomeTimelineData +import com.keylesspalace.tusky.db.entity.HomeTimelineEntity +import com.keylesspalace.tusky.db.entity.TimelineAccountEntity +import com.keylesspalace.tusky.db.entity.TimelineStatusEntity 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 com.squareup.moshi.Moshi -import com.squareup.moshi.adapter import java.util.Date -private const val TAG = "TimelineTypeMappers" - data class Placeholder( val id: String, val loading: Boolean ) -fun TimelineAccount.toEntity(accountId: Long, moshi: Moshi): TimelineAccountEntity { +fun TimelineAccount.toEntity(tuskyAccountId: Long): TimelineAccountEntity { return TimelineAccountEntity( serverId = id, - timelineUserId = accountId, + tuskyAccountId = tuskyAccountId, localUsername = localUsername, username = username, displayName = name, url = url, avatar = avatar, - emojis = moshi.adapter>().toJson(emojis), + emojis = emojis, bot = bot ) } -fun TimelineAccountEntity.toAccount(moshi: Moshi): TimelineAccount { +fun TimelineAccountEntity.toAccount(): TimelineAccount { return TimelineAccount( id = serverId, localUsername = localUsername, @@ -65,120 +56,114 @@ fun TimelineAccountEntity.toAccount(moshi: Moshi): TimelineAccount { url = url, avatar = avatar, bot = bot, - emojis = moshi.adapter?>().fromJson(emojis).orEmpty() + emojis = emojis ) } -fun Placeholder.toEntity(timelineUserId: Long): TimelineStatusEntity { - return TimelineStatusEntity( - serverId = this.id, - url = null, - timelineUserId = timelineUserId, - authorServerId = null, - inReplyToId = null, - inReplyToAccountId = null, - content = null, - createdAt = 0L, - editedAt = 0L, - emojis = null, - reblogsCount = 0, - favouritesCount = 0, - reblogged = false, - favourited = false, - bookmarked = false, - sensitive = false, - spoilerText = "", - visibility = Status.Visibility.UNKNOWN, - attachments = null, - mentions = null, - tags = null, - application = null, - reblogServerId = null, +fun Placeholder.toEntity(tuskyAccountId: Long): HomeTimelineEntity { + return HomeTimelineEntity( + id = this.id, + tuskyAccountId = tuskyAccountId, + statusId = null, reblogAccountId = null, - poll = null, - muted = false, - expanded = loading, - contentCollapsed = false, - contentShowing = false, - pinned = false, - card = null, - repliesCount = 0, - language = null, - filtered = emptyList() + loading = this.loading ) } fun Status.toEntity( - timelineUserId: Long, - moshi: Moshi, + tuskyAccountId: Long, expanded: Boolean, contentShowing: Boolean, contentCollapsed: Boolean -): TimelineStatusEntity { - return TimelineStatusEntity( - serverId = this.id, - url = actionableStatus.url, - timelineUserId = timelineUserId, - authorServerId = actionableStatus.account.id, - inReplyToId = actionableStatus.inReplyToId, - inReplyToAccountId = actionableStatus.inReplyToAccountId, - content = actionableStatus.content, - createdAt = actionableStatus.createdAt.time, - editedAt = actionableStatus.editedAt?.time, - emojis = actionableStatus.emojis.let { moshi.adapter>().toJson(it) }, - reblogsCount = actionableStatus.reblogsCount, - favouritesCount = actionableStatus.favouritesCount, - reblogged = actionableStatus.reblogged, - favourited = actionableStatus.favourited, - bookmarked = actionableStatus.bookmarked, - sensitive = actionableStatus.sensitive, - spoilerText = actionableStatus.spoilerText, - visibility = actionableStatus.visibility, - attachments = actionableStatus.attachments.let { moshi.adapter>().toJson(it) }, - mentions = actionableStatus.mentions.let { moshi.adapter>().toJson(it) }, - tags = actionableStatus.tags.let { moshi.adapter?>().toJson(it) }, - application = actionableStatus.application.let { moshi.adapter().toJson(it) }, - reblogServerId = reblog?.id, - reblogAccountId = reblog?.let { this.account.id }, - poll = actionableStatus.poll.let { moshi.adapter().toJson(it) }, - muted = actionableStatus.muted, - expanded = expanded, - contentShowing = contentShowing, - contentCollapsed = contentCollapsed, - pinned = actionableStatus.pinned, - card = actionableStatus.card?.let { moshi.adapter().toJson(it) }, - repliesCount = actionableStatus.repliesCount, - language = actionableStatus.language, - filtered = actionableStatus.filtered - ) -} +) = TimelineStatusEntity( + serverId = id, + url = actionableStatus.url, + tuskyAccountId = tuskyAccountId, + authorServerId = actionableStatus.account.id, + inReplyToId = actionableStatus.inReplyToId, + inReplyToAccountId = actionableStatus.inReplyToAccountId, + content = actionableStatus.content, + createdAt = actionableStatus.createdAt.time, + editedAt = actionableStatus.editedAt?.time, + emojis = actionableStatus.emojis, + reblogsCount = actionableStatus.reblogsCount, + favouritesCount = actionableStatus.favouritesCount, + reblogged = actionableStatus.reblogged, + favourited = actionableStatus.favourited, + bookmarked = actionableStatus.bookmarked, + sensitive = actionableStatus.sensitive, + spoilerText = actionableStatus.spoilerText, + visibility = actionableStatus.visibility, + attachments = actionableStatus.attachments, + mentions = actionableStatus.mentions, + tags = actionableStatus.tags, + application = actionableStatus.application, + poll = actionableStatus.poll, + muted = actionableStatus.muted, + expanded = expanded, + contentShowing = contentShowing, + contentCollapsed = contentCollapsed, + pinned = actionableStatus.pinned, + card = actionableStatus.card, + repliesCount = actionableStatus.repliesCount, + language = actionableStatus.language, + filtered = actionableStatus.filtered +) -fun TimelineStatusWithAccount.toViewData(moshi: Moshi, 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) +fun TimelineStatusEntity.toStatus( + account: TimelineAccountEntity +) = Status( + id = serverId, + url = url, + account = account.toAccount(), + inReplyToId = inReplyToId, + inReplyToAccountId = inReplyToAccountId, + reblog = null, + content = content, + createdAt = Date(createdAt), + editedAt = editedAt?.let { Date(it) }, + emojis = emojis, + reblogsCount = reblogsCount, + favouritesCount = favouritesCount, + reblogged = reblogged, + favourited = favourited, + bookmarked = bookmarked, + sensitive = sensitive, + spoilerText = spoilerText, + visibility = visibility, + attachments = attachments, + mentions = mentions, + tags = tags, + application = application, + pinned = false, + muted = muted, + poll = poll, + card = card, + repliesCount = repliesCount, + language = language, + filtered = filtered, +) + +fun HomeTimelineData.toViewData(isDetailed: Boolean = false, translation: TranslationViewData? = null): StatusViewData { + if (this.account == null || this.status == null) { + return StatusViewData.Placeholder(this.id, loading) } - val attachments: List = status.attachments?.let { moshi.adapter?>().fromJson(it) }.orEmpty() - val mentions: List = status.mentions?.let { moshi.adapter?>().fromJson(it) }.orEmpty() - val tags: List? = status.tags?.let { moshi.adapter?>().fromJson(it) } - val application = status.application?.let { moshi.adapter().fromJson(it) } - val emojis: List = status.emojis?.let { moshi.adapter?>().fromJson(it) }.orEmpty() - val poll: Poll? = status.poll?.let { moshi.adapter().fromJson(it) } - val card: Card? = status.card?.let { moshi.adapter().fromJson(it) } - - val reblog = status.reblogServerId?.let { id -> + val originalStatus = status.toStatus(account) + val status = if (reblogAccount != null) { Status( id = id, - url = status.url, - account = account.toAccount(moshi), + // no url for reblogs + url = null, + account = reblogAccount.toAccount(), inReplyToId = status.inReplyToId, inReplyToAccountId = status.inReplyToAccountId, - reblog = null, - content = status.content.orEmpty(), + reblog = originalStatus, + content = status.content, + // lie but whatever? createdAt = Date(status.createdAt), - editedAt = status.editedAt?.let { Date(it) }, - emojis = emojis, + editedAt = null, + emojis = emptyList(), reblogsCount = status.reblogsCount, favouritesCount = status.favouritesCount, reblogged = status.reblogged, @@ -187,86 +172,22 @@ fun TimelineStatusWithAccount.toViewData(moshi: Moshi, isDetailed: Boolean = fal sensitive = status.sensitive, spoilerText = status.spoilerText, visibility = status.visibility, - attachments = attachments, - mentions = mentions, - tags = tags, - application = application, - pinned = false, - muted = status.muted ?: false, - poll = poll, - card = card, - repliesCount = status.repliesCount, - language = status.language, - filtered = status.filtered.orEmpty(), - ) - } - val status = if (reblog != null) { - Status( - id = status.serverId, - // no url for reblogs - url = null, - account = this.reblogAccount!!.toAccount(moshi), - inReplyToId = null, - inReplyToAccountId = null, - reblog = reblog, - content = "", - // lie but whatever? - createdAt = Date(status.createdAt), - editedAt = null, - emojis = emptyList(), - reblogsCount = 0, - favouritesCount = 0, - reblogged = false, - favourited = false, - bookmarked = false, - sensitive = false, - spoilerText = "", - visibility = status.visibility, attachments = emptyList(), mentions = emptyList(), tags = emptyList(), application = null, - pinned = status.pinned, - muted = status.muted ?: false, + pinned = false, + muted = status.muted, poll = null, card = null, repliesCount = status.repliesCount, language = status.language, - filtered = status.filtered.orEmpty() + filtered = status.filtered, ) } else { - Status( - id = status.serverId, - url = status.url, - account = account.toAccount(moshi), - inReplyToId = status.inReplyToId, - inReplyToAccountId = status.inReplyToAccountId, - reblog = null, - content = translation?.data?.content ?: status.content.orEmpty(), - createdAt = Date(status.createdAt), - editedAt = status.editedAt?.let { Date(it) }, - emojis = emojis, - reblogsCount = status.reblogsCount, - favouritesCount = status.favouritesCount, - reblogged = status.reblogged, - favourited = status.favourited, - bookmarked = status.bookmarked, - sensitive = status.sensitive, - spoilerText = status.spoilerText, - visibility = status.visibility, - attachments = attachments, - mentions = mentions, - tags = tags, - application = application, - pinned = status.pinned, - muted = status.muted ?: false, - poll = poll, - card = card, - repliesCount = status.repliesCount, - language = status.language, - filtered = status.filtered.orEmpty() - ) + originalStatus } + return StatusViewData.Concrete( status = status, isExpanded = this.status.expanded, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt index 625cdf910..73bc5e714 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt @@ -26,11 +26,11 @@ import com.keylesspalace.tusky.components.timeline.toEntity import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase -import com.keylesspalace.tusky.db.TimelineStatusEntity -import com.keylesspalace.tusky.db.TimelineStatusWithAccount +import com.keylesspalace.tusky.db.entity.HomeTimelineData +import com.keylesspalace.tusky.db.entity.HomeTimelineEntity +import com.keylesspalace.tusky.db.entity.TimelineStatusEntity import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi -import com.squareup.moshi.Moshi import retrofit2.HttpException @OptIn(ExperimentalPagingApi::class) @@ -38,17 +38,18 @@ class CachedTimelineRemoteMediator( accountManager: AccountManager, private val api: MastodonApi, private val db: AppDatabase, - private val moshi: Moshi -) : RemoteMediator() { +) : RemoteMediator() { private var initialRefresh = false private val timelineDao = db.timelineDao() + private val statusDao = db.timelineStatusDao() + private val accountDao = db.timelineAccountDao() private val activeAccount = accountManager.activeAccount!! override suspend fun load( loadType: LoadType, - state: PagingState + state: PagingState ): MediatorResult { if (!activeAccount.isLoggedIn()) { return MediatorResult.Success(endOfPaginationReached = true) @@ -111,7 +112,7 @@ class CachedTimelineRemoteMediator( /* This overrides the last of the newly loaded statuses with a placeholder to guarantee the placeholder has an id that exists on the server as not all servers handle client generated ids as expected */ - timelineDao.insertStatus( + timelineDao.insertHomeTimelineItem( Placeholder(statuses.last().id, loading = false).toEntity(activeAccount.id) ) } @@ -134,7 +135,7 @@ class CachedTimelineRemoteMediator( */ private suspend fun replaceStatusRange( statuses: List, - state: PagingState + state: PagingState ): Int { val overlappedStatuses = if (statuses.isNotEmpty()) { timelineDao.deleteRange(activeAccount.id, statuses.last().id, statuses.first().id) @@ -143,9 +144,9 @@ class CachedTimelineRemoteMediator( } for (status in statuses) { - timelineDao.insertAccount(status.account.toEntity(activeAccount.id, moshi)) - status.reblog?.account?.toEntity(activeAccount.id, moshi)?.let { rebloggedAccount -> - timelineDao.insertAccount(rebloggedAccount) + accountDao.insert(status.account.toEntity(activeAccount.id)) + status.reblog?.account?.toEntity(activeAccount.id)?.let { rebloggedAccount -> + accountDao.insert(rebloggedAccount) } // check if we already have one of the newly loaded statuses cached locally @@ -153,31 +154,35 @@ class CachedTimelineRemoteMediator( var oldStatus: TimelineStatusEntity? = null for (page in state.pages) { oldStatus = page.data.find { s -> - s.status.serverId == status.id + s.status?.serverId == status.actionableId }?.status if (oldStatus != null) break } - // The "expanded" property for Placeholders determines whether or not they are - // in the "loading" state, and should not be affected by the account's - // "alwaysOpenSpoiler" preference - val expanded = if (oldStatus?.isPlaceholder == true) { - oldStatus.expanded - } else { - oldStatus?.expanded ?: activeAccount.alwaysOpenSpoiler - } + val expanded = oldStatus?.expanded ?: activeAccount.alwaysOpenSpoiler val contentShowing = oldStatus?.contentShowing ?: (activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive) val contentCollapsed = oldStatus?.contentCollapsed ?: true - timelineDao.insertStatus( - status.toEntity( - timelineUserId = activeAccount.id, - moshi = moshi, + statusDao.insert( + status.actionableStatus.toEntity( + tuskyAccountId = activeAccount.id, expanded = expanded, contentShowing = contentShowing, contentCollapsed = contentCollapsed ) ) + timelineDao.insertHomeTimelineItem( + HomeTimelineEntity( + tuskyAccountId = activeAccount.id, + id = status.id, + statusId = status.actionableId, + reblogAccountId = if (status.reblog != null) { + status.account.id + } else { + null + } + ) + ) } return overlappedStatuses } 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 8177c2fa0..bc3aa9d49 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 @@ -38,20 +38,18 @@ import com.keylesspalace.tusky.components.timeline.toViewData import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase -import com.keylesspalace.tusky.db.TimelineStatusWithAccount +import com.keylesspalace.tusky.db.entity.HomeTimelineData +import com.keylesspalace.tusky.db.entity.HomeTimelineEntity import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Poll -import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.FilterModel import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.usecase.TimelineCases import com.keylesspalace.tusky.util.EmptyPagingSource import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.TranslationViewData -import com.squareup.moshi.Moshi 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 @@ -68,8 +66,7 @@ class CachedTimelineViewModel @Inject constructor( accountManager: AccountManager, sharedPreferences: SharedPreferences, filterModel: FilterModel, - private val db: AppDatabase, - private val moshi: Moshi + private val db: AppDatabase ) : TimelineViewModel( timelineCases, api, @@ -79,7 +76,7 @@ class CachedTimelineViewModel @Inject constructor( filterModel ) { - private var currentPagingSource: PagingSource? = null + private var currentPagingSource: PagingSource? = null /** Map from status id to translation. */ private val translations = MutableStateFlow(mapOf()) @@ -87,13 +84,13 @@ class CachedTimelineViewModel @Inject constructor( @OptIn(ExperimentalPagingApi::class) override val statuses = Pager( config = PagingConfig(pageSize = LOAD_AT_ONCE), - remoteMediator = CachedTimelineRemoteMediator(accountManager, api, db, moshi), + remoteMediator = CachedTimelineRemoteMediator(accountManager, api, db), pagingSourceFactory = { val activeAccount = accountManager.activeAccount if (activeAccount == null) { EmptyPagingSource() } else { - db.timelineDao().getStatuses(activeAccount.id) + db.timelineDao().getHomeTimeline(activeAccount.id) }.also { newPagingSource -> this.currentPagingSource = newPagingSource } @@ -105,14 +102,13 @@ class CachedTimelineViewModel @Inject constructor( // adding another cachedIn() for the overall result. .cachedIn(viewModelScope) .combine(translations) { pagingData, translations -> - pagingData.map(Dispatchers.Default.asExecutor()) { timelineStatus -> - val translation = translations[timelineStatus.status.serverId] - timelineStatus.toViewData( - moshi, + pagingData.map { timelineData -> + val translation = translations[timelineData.status?.serverId] + timelineData.toViewData( isDetailed = false, translation = translation ) - }.filter(Dispatchers.Default.asExecutor()) { statusViewData -> + }.filter { statusViewData -> shouldFilterStatus(statusViewData) != Filter.Action.HIDE } } @@ -124,39 +120,28 @@ class CachedTimelineViewModel @Inject constructor( override fun changeExpanded(expanded: Boolean, status: StatusViewData.Concrete) { viewModelScope.launch { - db.timelineDao().setExpanded(accountManager.activeAccount!!.id, status.id, expanded) + db.timelineStatusDao() + .setExpanded(accountManager.activeAccount!!.id, status.actionableId, expanded) } } override fun changeContentShowing(isShowing: Boolean, status: StatusViewData.Concrete) { viewModelScope.launch { - db.timelineDao() - .setContentShowing(accountManager.activeAccount!!.id, status.id, isShowing) + db.timelineStatusDao() + .setContentShowing(accountManager.activeAccount!!.id, status.actionableId, isShowing) } } override fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete) { viewModelScope.launch { - db.timelineDao() - .setContentCollapsed(accountManager.activeAccount!!.id, status.id, isCollapsed) - } - } - - override fun removeAllByAccountId(accountId: String) { - viewModelScope.launch { - db.timelineDao().removeAllByUser(accountManager.activeAccount!!.id, accountId) - } - } - - override fun removeAllByInstance(instance: String) { - viewModelScope.launch { - db.timelineDao().deleteAllFromInstance(accountManager.activeAccount!!.id, instance) + db.timelineStatusDao() + .setContentCollapsed(accountManager.activeAccount!!.id, status.actionableId, isCollapsed) } } override fun clearWarning(status: StatusViewData.Concrete) { viewModelScope.launch { - db.timelineDao().clearWarning(accountManager.activeAccount!!.id, status.actionableId) + db.timelineStatusDao().clearWarning(accountManager.activeAccount!!.id, status.actionableId) } } @@ -168,10 +153,12 @@ class CachedTimelineViewModel @Inject constructor( viewModelScope.launch { try { val timelineDao = db.timelineDao() + val statusDao = db.timelineStatusDao() + val accountDao = db.timelineAccountDao() val activeAccount = accountManager.activeAccount!! - timelineDao.insertStatus( + timelineDao.insertHomeTimelineItem( Placeholder(placeholderId, loading = true).toEntity( activeAccount.id ) @@ -205,7 +192,7 @@ class CachedTimelineViewModel @Inject constructor( } db.withTransaction { - timelineDao.delete(activeAccount.id, placeholderId) + timelineDao.deleteHomeTimelineItem(activeAccount.id, placeholderId) val overlappedStatuses = if (statuses.isNotEmpty()) { timelineDao.deleteRange( @@ -218,20 +205,31 @@ class CachedTimelineViewModel @Inject constructor( } for (status in statuses) { - timelineDao.insertAccount(status.account.toEntity(activeAccount.id, moshi)) - status.reblog?.account?.toEntity(activeAccount.id, moshi) + accountDao.insert(status.account.toEntity(activeAccount.id)) + status.reblog?.account?.toEntity(activeAccount.id) ?.let { rebloggedAccount -> - timelineDao.insertAccount(rebloggedAccount) + accountDao.insert(rebloggedAccount) } - timelineDao.insertStatus( - status.toEntity( - timelineUserId = activeAccount.id, - moshi = moshi, + statusDao.insert( + status.actionableStatus.toEntity( + tuskyAccountId = activeAccount.id, expanded = activeAccount.alwaysOpenSpoiler, contentShowing = activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive, contentCollapsed = true ) ) + timelineDao.insertHomeTimelineItem( + HomeTimelineEntity( + tuskyAccountId = activeAccount.id, + id = status.id, + statusId = status.actionableId, + reblogAccountId = if (status.reblog != null) { + status.account.id + } else { + null + } + ) + ) } /* In case we loaded a whole page and there was no overlap with existing statuses, @@ -244,7 +242,7 @@ class CachedTimelineViewModel @Inject constructor( OLDEST_FIRST -> statuses.first().id NEWEST_FIRST -> statuses.last().id } - timelineDao.insertStatus( + timelineDao.insertHomeTimelineItem( Placeholder( idToConvert, loading = false @@ -264,17 +262,13 @@ class CachedTimelineViewModel @Inject constructor( Log.w("CachedTimelineVM", "failed loading statuses", e) val activeAccount = accountManager.activeAccount!! db.timelineDao() - .insertStatus(Placeholder(placeholderId, loading = false).toEntity(activeAccount.id)) - } - - override fun handleStatusChangedEvent(status: Status) { - // handled by CacheUpdater + .insertHomeTimelineItem(Placeholder(placeholderId, loading = false).toEntity(activeAccount.id)) } override fun fullReload() { viewModelScope.launch { val activeAccount = accountManager.activeAccount!! - db.timelineDao().removeAll(activeAccount.id) + db.timelineDao().removeAllHomeTimelineItems(activeAccount.id) } } @@ -288,7 +282,7 @@ class CachedTimelineViewModel @Inject constructor( override suspend fun invalidate() { // invalidating when we don't have statuses yet can cause empty timelines because it cancels the network load - if (db.timelineDao().getStatusCount(accountManager.activeAccount!!.id) > 0) { + if (db.timelineDao().getHomeTimelineItemCount(accountManager.activeAccount!!.id) > 0) { currentPagingSource?.invalidate() } } 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 60eed28e0..e2939c97b 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 @@ -26,7 +26,14 @@ 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.BlockEvent +import com.keylesspalace.tusky.appstore.DomainMuteEvent +import com.keylesspalace.tusky.appstore.Event import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.appstore.MuteEvent +import com.keylesspalace.tusky.appstore.StatusChangedEvent +import com.keylesspalace.tusky.appstore.StatusDeletedEvent +import com.keylesspalace.tusky.appstore.UnfollowEvent import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Filter @@ -96,6 +103,48 @@ class NetworkTimelineViewModel @Inject constructor( .flowOn(Dispatchers.Default) .cachedIn(viewModelScope) + init { + viewModelScope.launch { + eventHub.events + .collect { event -> handleEvent(event) } + } + } + + private fun handleEvent(event: Event) { + when (event) { + is StatusChangedEvent -> handleStatusChangedEvent(event.status) + is UnfollowEvent -> { + if (kind == Kind.HOME) { + val id = event.accountId + removeAllByAccountId(id) + } + } + is BlockEvent -> { + if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { + val id = event.accountId + removeAllByAccountId(id) + } + } + is MuteEvent -> { + if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { + val id = event.accountId + removeAllByAccountId(id) + } + } + is DomainMuteEvent -> { + if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { + val instance = event.instance + removeAllByInstance(instance) + } + } + is StatusDeletedEvent -> { + if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { + removeStatusWithId(event.statusId) + } + } + } + } + override fun updatePoll(newPoll: Poll, status: StatusViewData.Concrete) { status.copy( status = status.status.copy(poll = newPoll) @@ -120,7 +169,7 @@ class NetworkTimelineViewModel @Inject constructor( ).update() } - override fun removeAllByAccountId(accountId: String) { + private fun removeAllByAccountId(accountId: String) { statusData.removeAll { vd -> val status = vd.asStatusOrNull()?.status ?: return@removeAll false status.account.id == accountId || status.actionableStatus.account.id == accountId @@ -128,7 +177,7 @@ class NetworkTimelineViewModel @Inject constructor( currentSource?.invalidate() } - override fun removeAllByInstance(instance: String) { + private fun removeAllByInstance(instance: String) { statusData.removeAll { vd -> val status = vd.asStatusOrNull()?.status ?: return@removeAll false getDomain(status.account.url) == instance @@ -241,7 +290,7 @@ class NetworkTimelineViewModel @Inject constructor( currentSource?.invalidate() } - override fun handleStatusChangedEvent(status: Status) { + private fun handleStatusChangedEvent(status: Status) { updateStatusById(status.id) { oldViewData -> status.toViewData( isShowingContent = oldViewData.isShowingContent, 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 3d02b90dc..180e90385 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 @@ -24,24 +24,17 @@ 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 com.keylesspalace.tusky.appstore.BlockEvent -import com.keylesspalace.tusky.appstore.DomainMuteEvent import com.keylesspalace.tusky.appstore.Event import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.FilterUpdatedEvent import com.keylesspalace.tusky.appstore.MuteConversationEvent -import com.keylesspalace.tusky.appstore.MuteEvent import com.keylesspalace.tusky.appstore.PreferenceChangedEvent -import com.keylesspalace.tusky.appstore.StatusChangedEvent -import com.keylesspalace.tusky.appstore.StatusDeletedEvent -import com.keylesspalace.tusky.appstore.UnfollowEvent import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.FilterV1 import com.keylesspalace.tusky.entity.Poll -import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.FilterModel import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.settings.PrefKeys @@ -162,16 +155,10 @@ abstract class TimelineViewModel( abstract fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete) - abstract fun removeAllByAccountId(accountId: String) - - abstract fun removeAllByInstance(instance: String) - abstract fun removeStatusWithId(id: String) abstract fun loadMore(placeholderId: String) - abstract fun handleStatusChangedEvent(status: Status) - abstract fun fullReload() abstract fun clearWarning(status: StatusViewData.Concrete) @@ -240,37 +227,7 @@ abstract class TimelineViewModel( private fun handleEvent(event: Event) { when (event) { - is StatusChangedEvent -> handleStatusChangedEvent(event.status) is MuteConversationEvent -> fullReload() - is UnfollowEvent -> { - if (kind == Kind.HOME) { - val id = event.accountId - removeAllByAccountId(id) - } - } - is BlockEvent -> { - if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { - val id = event.accountId - removeAllByAccountId(id) - } - } - is MuteEvent -> { - if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { - val id = event.accountId - removeAllByAccountId(id) - } - } - is DomainMuteEvent -> { - if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { - val instance = event.instance - removeAllByInstance(instance) - } - } - is StatusDeletedEvent -> { - if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { - removeStatusWithId(event.statusId) - } - } is PreferenceChangedEvent -> { onPreferenceChanged(event.preferenceKey) } 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 795707c8d..394606520 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 @@ -24,12 +24,13 @@ 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 at.connyduck.calladapter.networkresult.onSuccess import com.keylesspalace.tusky.appstore.BlockEvent import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.StatusChangedEvent import com.keylesspalace.tusky.appstore.StatusComposedEvent import com.keylesspalace.tusky.appstore.StatusDeletedEvent -import com.keylesspalace.tusky.components.timeline.toViewData +import com.keylesspalace.tusky.components.timeline.toStatus import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase @@ -108,24 +109,18 @@ class ViewThreadViewModel @Inject constructor( viewModelScope.launch { Log.d(TAG, "Finding status with: $id") val contextCall = async { api.statusContext(id) } - val timelineStatus = db.timelineDao().getStatus(accountManager.activeAccount!!.id, id) + val statusAndAccount = db.timelineStatusDao().getStatusWithAccount(accountManager.activeAccount!!.id, id) - var detailedStatus = if (timelineStatus != null) { + var detailedStatus = if (statusAndAccount != null) { Log.d(TAG, "Loaded status from local timeline") - val viewData = timelineStatus.toViewData( - moshi, + StatusViewData.Concrete( + status = statusAndAccount.first.toStatus(statusAndAccount.second), + isExpanded = statusAndAccount.first.expanded, + isShowingContent = statusAndAccount.first.contentShowing, + isCollapsed = statusAndAccount.first.contentCollapsed, isDetailed = true, - ) as StatusViewData.Concrete - - // Return the correct status, depending on which one matched. If you do not do - // this the status IDs will be different between the status that's displayed with - // ThreadUiState.LoadingThread and ThreadUiState.Success, even though the apparent - // status content is the same. Then the status flickers as it is drawn twice. - if (viewData.actionableId == id) { - viewData.actionable.toViewData(isDetailed = true) - } else { - viewData - } + translation = null + ) } else { Log.d(TAG, "Loaded status from network") val result = api.status(id).getOrElse { exception -> @@ -143,12 +138,13 @@ class ViewThreadViewModel @Inject constructor( // If the detailedStatus was loaded from the database it might be out-of-date // compared to the remote one. Now the user has a working UI do a background fetch // for the status. Ignore errors, the user still has a functioning UI if the fetch - // failed. - if (timelineStatus != null) { - api.status(id).getOrNull()?.let { result -> - db.timelineDao().update( - accountId = accountManager.activeAccount!!.id, - status = result + // failed. Update the database when the fetch was successful. + if (statusAndAccount != null) { + api.status(id).onSuccess { result -> + db.timelineStatusDao().update( + tuskyAccountId = accountManager.activeAccount!!.id, + status = result, + moshi = moshi ) detailedStatus = result.toViewData(isDetailed = true) } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt b/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt index d7397ea4e..1ff06afc1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt @@ -18,6 +18,8 @@ package com.keylesspalace.tusky.db import android.content.Context import android.util.Log import androidx.preference.PreferenceManager +import com.keylesspalace.tusky.db.dao.AccountDao +import com.keylesspalace.tusky.db.entity.AccountEntity import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.settings.PrefKeys 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 39261cb55..7bf0cafce 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java +++ b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java @@ -27,6 +27,21 @@ import androidx.sqlite.db.SupportSQLiteDatabase; import com.keylesspalace.tusky.TabDataKt; import com.keylesspalace.tusky.components.conversation.ConversationEntity; +import com.keylesspalace.tusky.db.dao.AccountDao; +import com.keylesspalace.tusky.db.dao.DraftDao; +import com.keylesspalace.tusky.db.dao.InstanceDao; +import com.keylesspalace.tusky.db.dao.NotificationsDao; +import com.keylesspalace.tusky.db.dao.TimelineAccountDao; +import com.keylesspalace.tusky.db.dao.TimelineDao; +import com.keylesspalace.tusky.db.dao.TimelineStatusDao; +import com.keylesspalace.tusky.db.entity.AccountEntity; +import com.keylesspalace.tusky.db.entity.DraftEntity; +import com.keylesspalace.tusky.db.entity.HomeTimelineEntity; +import com.keylesspalace.tusky.db.entity.InstanceEntity; +import com.keylesspalace.tusky.db.entity.NotificationEntity; +import com.keylesspalace.tusky.db.entity.NotificationReportEntity; +import com.keylesspalace.tusky.db.entity.TimelineAccountEntity; +import com.keylesspalace.tusky.db.entity.TimelineStatusEntity; import java.io.File; @@ -40,11 +55,14 @@ import java.io.File; InstanceEntity.class, TimelineStatusEntity.class, TimelineAccountEntity.class, - ConversationEntity.class + ConversationEntity.class, + NotificationEntity.class, + NotificationReportEntity.class, + HomeTimelineEntity.class }, // Note: Starting with version 54, database versions in Tusky are always even. // This is to reserve odd version numbers for use by forks. - version = 58, + version = 60, autoMigrations = { @AutoMigration(from = 48, to = 49), @AutoMigration(from = 49, to = 50, spec = AppDatabase.MIGRATION_49_50.class), @@ -61,6 +79,9 @@ public abstract class AppDatabase extends RoomDatabase { @NonNull public abstract ConversationsDao conversationDao(); @NonNull public abstract TimelineDao timelineDao(); @NonNull public abstract DraftDao draftDao(); + @NonNull public abstract NotificationsDao notificationsDao(); + @NonNull public abstract TimelineStatusDao timelineStatusDao(); + @NonNull public abstract TimelineAccountDao timelineAccountDao(); public static final Migration MIGRATION_2_3 = new Migration(2, 3) { @Override @@ -698,4 +719,126 @@ public abstract class AppDatabase extends RoomDatabase { database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `isShowHomeSelfBoosts` INTEGER NOT NULL DEFAULT 1"); } }; + + public static final Migration MIGRATION_58_60 = new Migration(58, 60) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + // drop the old tables - they are only caches anyway + database.execSQL("DROP TABLE `TimelineStatusEntity`"); + database.execSQL("DROP TABLE `TimelineAccountEntity`"); + + // create the new tables + database.execSQL(""" + CREATE TABLE IF NOT EXISTS `TimelineAccountEntity` ( + `serverId` TEXT NOT NULL, + `tuskyAccountId` 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`, `tuskyAccountId`) + )""" + ); + database.execSQL(""" + CREATE TABLE IF NOT EXISTS `TimelineStatusEntity` ( + `serverId` TEXT NOT NULL, + `url` TEXT, + `tuskyAccountId` INTEGER NOT NULL, + `authorServerId` TEXT NOT NULL, + `inReplyToId` TEXT, + `inReplyToAccountId` TEXT, + `content` TEXT NOT NULL, + `createdAt` INTEGER NOT NULL, + `editedAt` INTEGER, + `emojis` TEXT NOT NULL, + `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 NOT NULL, + `mentions` TEXT NOT NULL, + `tags` TEXT NOT NULL, + `application` TEXT, + `poll` TEXT, + `muted` INTEGER NOT NULL, + `expanded` INTEGER NOT NULL, + `contentCollapsed` INTEGER NOT NULL, + `contentShowing` INTEGER NOT NULL, + `pinned` INTEGER NOT NULL, + `card` TEXT, `language` TEXT, + `filtered` TEXT NOT NULL, + PRIMARY KEY(`serverId`, `tuskyAccountId`), + FOREIGN KEY(`authorServerId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION + )""" + ); + database.execSQL( + "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_tuskyAccountId` ON `TimelineStatusEntity` (`authorServerId`, `tuskyAccountId`)" + ); + database.execSQL(""" + CREATE TABLE IF NOT EXISTS `HomeTimelineEntity` ( + `tuskyAccountId` INTEGER NOT NULL, + `id` TEXT NOT NULL, + `statusId` TEXT, + `reblogAccountId` TEXT, + `loading` INTEGER NOT NULL, + PRIMARY KEY(`id`, `tuskyAccountId`), + FOREIGN KEY(`statusId`, `tuskyAccountId`) REFERENCES `TimelineStatusEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION, + FOREIGN KEY(`reblogAccountId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION + )""" + ); + database.execSQL( + "CREATE INDEX IF NOT EXISTS `index_HomeTimelineEntity_statusId_tuskyAccountId` ON `HomeTimelineEntity` (`statusId`, `tuskyAccountId`)" + ); + database.execSQL( + "CREATE INDEX IF NOT EXISTS `index_HomeTimelineEntity_reblogAccountId_tuskyAccountId` ON `HomeTimelineEntity` (`reblogAccountId`, `tuskyAccountId`)" + ); + database.execSQL(""" + CREATE TABLE IF NOT EXISTS `NotificationReportEntity`( + `tuskyAccountId` INTEGER NOT NULL, + `serverId` TEXT NOT NULL, + `category` TEXT NOT NULL, + `statusIds` TEXT, + `createdAt` INTEGER NOT NULL, + `targetAccountId` TEXT, + PRIMARY KEY(`serverId`, `tuskyAccountId`), + FOREIGN KEY(`targetAccountId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION + )""" + ); + database.execSQL( + "CREATE INDEX IF NOT EXISTS `index_NotificationReportEntity_targetAccountId_tuskyAccountId` ON `NotificationReportEntity` (`targetAccountId`, `tuskyAccountId`)" + ); + database.execSQL(""" + CREATE TABLE IF NOT EXISTS `NotificationEntity` ( + `tuskyAccountId` INTEGER NOT NULL, + `type` TEXT, + `id` TEXT NOT NULL, + `accountId` TEXT, + `statusId` TEXT, + `reportId` TEXT, + `loading` INTEGER NOT NULL, + PRIMARY KEY(`id`, `tuskyAccountId`), + FOREIGN KEY(`accountId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION, + FOREIGN KEY(`statusId`, `tuskyAccountId`) REFERENCES `TimelineStatusEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION, + FOREIGN KEY(`reportId`, `tuskyAccountId`) REFERENCES `NotificationReportEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION + )""" + ); + database.execSQL( + "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_accountId_tuskyAccountId` ON `NotificationEntity` (`accountId`, `tuskyAccountId`)" + ); + database.execSQL( + "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_statusId_tuskyAccountId` ON `NotificationEntity` (`statusId`, `tuskyAccountId`)" + ); + database.execSQL( + "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_reportId_tuskyAccountId` ON `NotificationEntity` (`reportId`, `tuskyAccountId`)" + ); + } + }; } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt b/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt index 026978802..6280cc5cf 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt @@ -20,6 +20,7 @@ import androidx.room.TypeConverter import com.keylesspalace.tusky.TabData import com.keylesspalace.tusky.components.conversation.ConversationAccountEntity import com.keylesspalace.tusky.createTabDataFromId +import com.keylesspalace.tusky.db.entity.DraftAttachment import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Card import com.keylesspalace.tusky.entity.Emoji @@ -187,4 +188,29 @@ class Converters @Inject constructor( fun cardToJson(card: Card?): String { return moshi.adapter().toJson(card) } + + @TypeConverter + fun jsonToCard(cardJson: String?): Card? { + return cardJson?.let { moshi.adapter().fromJson(cardJson) } + } + + @TypeConverter + fun stringListToJson(list: List?): String? { + return moshi.adapter?>().toJson(list) + } + + @TypeConverter + fun jsonToStringList(listJson: String?): List? { + return listJson?.let { moshi.adapter?>().fromJson(it) } + } + + @TypeConverter + fun applicationToJson(application: Status.Application?): String { + return moshi.adapter().toJson(application) + } + + @TypeConverter + fun jsonToApplication(applicationJson: String?): Status.Application? { + return applicationJson?.let { moshi.adapter().fromJson(it) } + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/DatabaseCleaner.kt b/app/src/main/java/com/keylesspalace/tusky/db/DatabaseCleaner.kt new file mode 100644 index 000000000..0d2ee9c02 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/db/DatabaseCleaner.kt @@ -0,0 +1,66 @@ +/* Copyright 2024 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.db + +import androidx.room.withTransaction +import com.keylesspalace.tusky.db.entity.HomeTimelineEntity +import com.keylesspalace.tusky.db.entity.NotificationEntity +import com.keylesspalace.tusky.db.entity.NotificationReportEntity +import com.keylesspalace.tusky.db.entity.TimelineAccountEntity +import com.keylesspalace.tusky.db.entity.TimelineStatusEntity +import javax.inject.Inject + +class DatabaseCleaner @Inject constructor( + private val db: AppDatabase +) { + /** + * Cleans the [HomeTimelineEntity], [TimelineStatusEntity], [TimelineAccountEntity], [NotificationEntity] and [NotificationReportEntity] tables from old entries. + * Should be regularly run to prevent the database from growing too big. + * @param tuskyAccountId id of the account for which to clean tables + * @param timelineLimit how many timeline items to keep + * @param notificationLimit how many notifications to keep + */ + suspend fun cleanupOldData( + tuskyAccountId: Long, + timelineLimit: Int, + notificationLimit: Int + ) { + db.withTransaction { + // the order here is important - foreign key constraints must not be violated + db.notificationsDao().cleanupNotifications(tuskyAccountId, notificationLimit) + db.notificationsDao().cleanupReports(tuskyAccountId) + db.timelineDao().cleanupHomeTimeline(tuskyAccountId, timelineLimit) + db.timelineStatusDao().cleanupStatuses(tuskyAccountId) + db.timelineAccountDao().cleanupAccounts(tuskyAccountId) + } + } + + /** + * Deletes everything from the [HomeTimelineEntity], [TimelineStatusEntity], [TimelineAccountEntity], [NotificationEntity] and [NotificationReportEntity] tables for one user. + * Intended to be used when a user logs out. + * @param tuskyAccountId id of the account for which to clean tables + */ + suspend fun cleanupEverything(tuskyAccountId: Long) { + db.withTransaction { + // the order here is important - foreign key constraints must not be violated + db.notificationsDao().removeAllNotifications(tuskyAccountId) + db.notificationsDao().removeAllReports(tuskyAccountId) + db.timelineDao().removeAllHomeTimelineItems(tuskyAccountId) + db.timelineStatusDao().removeAllStatuses(tuskyAccountId) + db.timelineAccountDao().removeAllAccounts(tuskyAccountId) + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/db/DraftsAlert.kt b/app/src/main/java/com/keylesspalace/tusky/db/DraftsAlert.kt index 5b2993f68..39f46e9a6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/DraftsAlert.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/DraftsAlert.kt @@ -24,6 +24,7 @@ import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope import com.keylesspalace.tusky.R import com.keylesspalace.tusky.components.drafts.DraftsActivity +import com.keylesspalace.tusky.db.dao.DraftDao import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.launch diff --git a/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt deleted file mode 100644 index 3cd49baac..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt +++ /dev/null @@ -1,346 +0,0 @@ -/* Copyright 2021 Tusky Contributors - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . */ - -package com.keylesspalace.tusky.db - -import androidx.paging.PagingSource -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.OnConflictStrategy.Companion.REPLACE -import androidx.room.Query -import androidx.room.TypeConverters -import com.keylesspalace.tusky.entity.Attachment -import com.keylesspalace.tusky.entity.Card -import com.keylesspalace.tusky.entity.Emoji -import com.keylesspalace.tusky.entity.HashTag -import com.keylesspalace.tusky.entity.Poll -import com.keylesspalace.tusky.entity.Status - -@Dao -abstract class TimelineDao { - - @Insert(onConflict = REPLACE) - abstract suspend fun insertAccount(timelineAccountEntity: TimelineAccountEntity): Long - - @Insert(onConflict = REPLACE) - abstract suspend fun insertStatus(timelineStatusEntity: TimelineStatusEntity): Long - - @Query( - """ -SELECT s.serverId, s.url, s.timelineUserId, -s.authorServerId, s.inReplyToId, s.inReplyToAccountId, s.createdAt, s.editedAt, -s.emojis, s.reblogsCount, s.favouritesCount, s.repliesCount, s.reblogged, s.favourited, s.bookmarked, s.sensitive, -s.spoilerText, s.visibility, s.mentions, s.tags, s.application, s.reblogServerId,s.reblogAccountId, -s.content, s.attachments, s.poll, s.card, s.muted, s.expanded, s.contentShowing, s.contentCollapsed, s.pinned, s.language, s.filtered, -a.serverId as 'a_serverId', a.timelineUserId as 'a_timelineUserId', -a.localUsername as 'a_localUsername', a.username as 'a_username', -a.displayName as 'a_displayName', a.url as 'a_url', a.avatar as 'a_avatar', -a.emojis as 'a_emojis', a.bot as 'a_bot', -rb.serverId as 'rb_serverId', rb.timelineUserId 'rb_timelineUserId', -rb.localUsername as 'rb_localUsername', rb.username as 'rb_username', -rb.displayName as 'rb_displayName', rb.url as 'rb_url', rb.avatar as 'rb_avatar', -rb.emojis as 'rb_emojis', rb.bot as 'rb_bot' -FROM TimelineStatusEntity s -LEFT JOIN TimelineAccountEntity a ON (s.timelineUserId = a.timelineUserId AND s.authorServerId = a.serverId) -LEFT JOIN TimelineAccountEntity rb ON (s.timelineUserId = rb.timelineUserId AND s.reblogAccountId = rb.serverId) -WHERE s.timelineUserId = :account -ORDER BY LENGTH(s.serverId) DESC, s.serverId DESC""" - ) - abstract fun getStatuses(account: Long): PagingSource - - @Query( - """ -SELECT s.serverId, s.url, s.timelineUserId, -s.authorServerId, s.inReplyToId, s.inReplyToAccountId, s.createdAt, s.editedAt, -s.emojis, s.reblogsCount, s.favouritesCount, s.repliesCount, s.reblogged, s.favourited, s.bookmarked, s.sensitive, -s.spoilerText, s.visibility, s.mentions, s.tags, s.application, s.reblogServerId,s.reblogAccountId, -s.content, s.attachments, s.poll, s.card, s.muted, s.expanded, s.contentShowing, s.contentCollapsed, s.pinned, s.language, s.filtered, -a.serverId as 'a_serverId', a.timelineUserId as 'a_timelineUserId', -a.localUsername as 'a_localUsername', a.username as 'a_username', -a.displayName as 'a_displayName', a.url as 'a_url', a.avatar as 'a_avatar', -a.emojis as 'a_emojis', a.bot as 'a_bot', -rb.serverId as 'rb_serverId', rb.timelineUserId 'rb_timelineUserId', -rb.localUsername as 'rb_localUsername', rb.username as 'rb_username', -rb.displayName as 'rb_displayName', rb.url as 'rb_url', rb.avatar as 'rb_avatar', -rb.emojis as 'rb_emojis', rb.bot as 'rb_bot' -FROM TimelineStatusEntity s -LEFT JOIN TimelineAccountEntity a ON (s.timelineUserId = a.timelineUserId AND s.authorServerId = a.serverId) -LEFT JOIN TimelineAccountEntity rb ON (s.timelineUserId = rb.timelineUserId AND s.reblogAccountId = rb.serverId) -WHERE (s.serverId = :statusId OR s.reblogServerId = :statusId) -AND s.authorServerId IS NOT NULL -AND s.timelineUserId = :accountId""" - ) - abstract suspend fun getStatus(accountId: Long, statusId: String): TimelineStatusWithAccount? - - @Query( - """DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND - (LENGTH(serverId) < LENGTH(:maxId) OR LENGTH(serverId) == LENGTH(:maxId) AND serverId <= :maxId) -AND -(LENGTH(serverId) > LENGTH(:minId) OR LENGTH(serverId) == LENGTH(:minId) AND serverId >= :minId) - """ - ) - abstract suspend fun deleteRange(accountId: Long, minId: String, maxId: String): Int - - suspend fun update(accountId: Long, status: Status) { - update( - accountId = accountId, - statusId = status.id, - content = status.content, - editedAt = status.editedAt?.time, - emojis = status.emojis, - reblogsCount = status.reblogsCount, - favouritesCount = status.favouritesCount, - repliesCount = status.repliesCount, - reblogged = status.reblogged, - bookmarked = status.bookmarked, - favourited = status.favourited, - sensitive = status.sensitive, - spoilerText = status.spoilerText, - visibility = status.visibility, - attachments = status.attachments, - mentions = status.mentions, - tags = status.tags, - poll = status.poll, - muted = status.muted, - pinned = status.pinned, - card = status.card, - language = status.language - ) - } - - @Query( - """UPDATE TimelineStatusEntity - SET content = :content, - editedAt = :editedAt, - emojis = :emojis, - reblogsCount = :reblogsCount, - favouritesCount = :favouritesCount, - repliesCount = :repliesCount, - reblogged = :reblogged, - bookmarked = :bookmarked, - favourited = :favourited, - sensitive = :sensitive, - spoilerText = :spoilerText, - visibility = :visibility, - attachments = :attachments, - mentions = :mentions, - tags = :tags, - poll = :poll, - muted = :muted, - pinned = :pinned, - card = :card, - language = :language - WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""" - ) - @TypeConverters(Converters::class) - protected abstract suspend fun update( - accountId: Long, - statusId: String, - content: String?, - editedAt: Long?, - emojis: List, - reblogsCount: Int, - favouritesCount: Int, - repliesCount: Int, - reblogged: Boolean, - bookmarked: Boolean, - favourited: Boolean, - sensitive: Boolean, - spoilerText: String, - visibility: Status.Visibility, - attachments: List, - mentions: List, - tags: List?, - poll: Poll?, - muted: Boolean?, - pinned: Boolean, - card: Card?, - language: String? - ) - - @Query( - """UPDATE TimelineStatusEntity SET bookmarked = :bookmarked -WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""" - ) - abstract suspend fun setBookmarked(accountId: Long, statusId: String, bookmarked: Boolean) - - @Query( - """UPDATE TimelineStatusEntity SET reblogged = :reblogged -WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""" - ) - abstract suspend fun setReblogged(accountId: Long, statusId: String, reblogged: Boolean) - - @Query( - """DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND -(authorServerId = :userId OR reblogAccountId = :userId)""" - ) - abstract suspend fun removeAllByUser(accountId: Long, userId: String) - - /** - * Removes everything in the TimelineStatusEntity and TimelineAccountEntity tables for one user account - * @param accountId id of the account for which to clean tables - */ - suspend fun removeAll(accountId: Long) { - removeAllStatuses(accountId) - removeAllAccounts(accountId) - } - - @Query("DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId") - abstract suspend fun removeAllStatuses(accountId: Long) - - @Query("DELETE FROM TimelineAccountEntity WHERE timelineUserId = :accountId") - abstract suspend fun removeAllAccounts(accountId: Long) - - @Query( - """DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId -AND serverId = :statusId""" - ) - abstract suspend fun delete(accountId: Long, statusId: String) - - /** - * Cleans the TimelineStatusEntity and TimelineAccountEntity tables from old entries. - * @param accountId id of the account for which to clean tables - * @param limit how many statuses to keep - */ - suspend fun cleanup(accountId: Long, limit: Int) { - cleanupStatuses(accountId, limit) - cleanupAccounts(accountId) - } - - /** - * Cleans the TimelineStatusEntity table from old status entries. - * @param accountId id of the account for which to clean statuses - * @param limit how many statuses to keep - */ - @Query( - """DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND serverId NOT IN - (SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT :limit) - """ - ) - abstract suspend fun cleanupStatuses(accountId: Long, limit: Int) - - /** - * Cleans the TimelineAccountEntity table from accounts that are no longer referenced in the TimelineStatusEntity table - * @param accountId id of the user account for which to clean timeline accounts - */ - @Query( - """DELETE FROM TimelineAccountEntity WHERE timelineUserId = :accountId AND serverId NOT IN - (SELECT authorServerId FROM TimelineStatusEntity WHERE timelineUserId = :accountId) - AND serverId NOT IN - (SELECT reblogAccountId FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND reblogAccountId IS NOT NULL)""" - ) - abstract suspend fun cleanupAccounts(accountId: Long) - - @Query( - """UPDATE TimelineStatusEntity SET poll = :poll -WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""" - ) - @TypeConverters(Converters::class) - abstract suspend fun setVoted(accountId: Long, statusId: String, poll: Poll) - - @Query( - """UPDATE TimelineStatusEntity SET expanded = :expanded -WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""" - ) - abstract suspend fun setExpanded(accountId: Long, statusId: String, expanded: Boolean) - - @Query( - """UPDATE TimelineStatusEntity SET contentShowing = :contentShowing -WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""" - ) - abstract suspend fun setContentShowing( - accountId: Long, - statusId: String, - contentShowing: Boolean - ) - - @Query( - """UPDATE TimelineStatusEntity SET contentCollapsed = :contentCollapsed -WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""" - ) - abstract suspend fun setContentCollapsed( - accountId: Long, - statusId: String, - contentCollapsed: Boolean - ) - - @Query( - """UPDATE TimelineStatusEntity SET pinned = :pinned -WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""" - ) - abstract suspend fun setPinned(accountId: Long, statusId: String, pinned: Boolean) - - @Query( - """DELETE FROM TimelineStatusEntity -WHERE timelineUserId = :accountId AND authorServerId IN ( -SELECT serverId FROM TimelineAccountEntity WHERE username LIKE '%@' || :instanceDomain -AND timelineUserId = :accountId -)""" - ) - abstract suspend fun deleteAllFromInstance(accountId: Long, instanceDomain: String) - - @Query( - "UPDATE TimelineStatusEntity SET filtered = NULL WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)" - ) - abstract suspend fun clearWarning(accountId: Long, statusId: String): Int - - @Query( - "SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT 1" - ) - abstract suspend fun getTopId(accountId: Long): String? - - @Query( - "SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND authorServerId IS NULL ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT 1" - ) - abstract suspend fun getTopPlaceholderId(accountId: Long): String? - - /** - * Returns the id directly above [serverId], or null if [serverId] is the id of the top status - */ - @Query( - "SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND (LENGTH(:serverId) < LENGTH(serverId) OR (LENGTH(:serverId) = LENGTH(serverId) AND :serverId < serverId)) ORDER BY LENGTH(serverId) ASC, serverId ASC LIMIT 1" - ) - abstract suspend fun getIdAbove(accountId: Long, serverId: String): String? - - /** - * Returns the ID directly below [serverId], or null if [serverId] is the ID of the bottom - * status - */ - @Query( - "SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND (LENGTH(:serverId) > LENGTH(serverId) OR (LENGTH(:serverId) = LENGTH(serverId) AND :serverId > serverId)) ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT 1" - ) - abstract suspend fun getIdBelow(accountId: Long, serverId: String): String? - - /** - * Returns the id of the next placeholder after [serverId] - */ - @Query( - "SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND authorServerId IS NULL AND (LENGTH(:serverId) > LENGTH(serverId) OR (LENGTH(:serverId) = LENGTH(serverId) AND :serverId > serverId)) ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT 1" - ) - abstract suspend fun getNextPlaceholderIdAfter(accountId: Long, serverId: String): String? - - @Query("SELECT COUNT(*) FROM TimelineStatusEntity WHERE timelineUserId = :accountId") - abstract suspend fun getStatusCount(accountId: Long): Int - - /** Developer tools: Find N most recent status IDs */ - @Query( - "SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT :count" - ) - abstract suspend fun getMostRecentNStatusIds(accountId: Long, count: Int): List - - /** Developer tools: Convert a status to a placeholder */ - @Query("UPDATE TimelineStatusEntity SET authorServerId = NULL WHERE serverId = :serverId") - abstract suspend fun convertStatustoPlaceholder(serverId: String) -} diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AccountDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/dao/AccountDao.kt similarity index 92% rename from app/src/main/java/com/keylesspalace/tusky/db/AccountDao.kt rename to app/src/main/java/com/keylesspalace/tusky/db/dao/AccountDao.kt index 218c9b8f4..b31023173 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AccountDao.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/dao/AccountDao.kt @@ -13,13 +13,14 @@ * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ -package com.keylesspalace.tusky.db +package com.keylesspalace.tusky.db.dao import androidx.room.Dao import androidx.room.Delete import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import com.keylesspalace.tusky.db.entity.AccountEntity @Dao interface AccountDao { diff --git a/app/src/main/java/com/keylesspalace/tusky/db/DraftDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/dao/DraftDao.kt similarity index 95% rename from app/src/main/java/com/keylesspalace/tusky/db/DraftDao.kt rename to app/src/main/java/com/keylesspalace/tusky/db/dao/DraftDao.kt index 4d7239d02..c0f80aa9f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/DraftDao.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/dao/DraftDao.kt @@ -13,13 +13,14 @@ * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ -package com.keylesspalace.tusky.db +package com.keylesspalace.tusky.db.dao import androidx.paging.PagingSource import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import com.keylesspalace.tusky.db.entity.DraftEntity import kotlinx.coroutines.flow.Flow @Dao diff --git a/app/src/main/java/com/keylesspalace/tusky/db/InstanceDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/dao/InstanceDao.kt similarity index 87% rename from app/src/main/java/com/keylesspalace/tusky/db/InstanceDao.kt rename to app/src/main/java/com/keylesspalace/tusky/db/dao/InstanceDao.kt index 317c577ec..9f665e43f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/InstanceDao.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/dao/InstanceDao.kt @@ -13,12 +13,15 @@ * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ -package com.keylesspalace.tusky.db +package com.keylesspalace.tusky.db.dao import androidx.room.Dao import androidx.room.Query import androidx.room.RewriteQueriesToDropUnusedColumns import androidx.room.Upsert +import com.keylesspalace.tusky.db.entity.EmojisEntity +import com.keylesspalace.tusky.db.entity.InstanceEntity +import com.keylesspalace.tusky.db.entity.InstanceInfoEntity @Dao interface InstanceDao { diff --git a/app/src/main/java/com/keylesspalace/tusky/db/dao/NotificationsDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/dao/NotificationsDao.kt new file mode 100644 index 000000000..e429dc20e --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/db/dao/NotificationsDao.kt @@ -0,0 +1,175 @@ +/* Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.db.dao + +import androidx.paging.PagingSource +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy.Companion.REPLACE +import androidx.room.Query +import com.keylesspalace.tusky.db.entity.NotificationDataEntity +import com.keylesspalace.tusky.db.entity.NotificationEntity +import com.keylesspalace.tusky.db.entity.NotificationReportEntity + +@Dao +abstract class NotificationsDao { + + @Insert(onConflict = REPLACE) + abstract suspend fun insertNotification(notificationEntity: NotificationEntity): Long + + @Insert(onConflict = REPLACE) + abstract suspend fun insertReport(notificationReportDataEntity: NotificationReportEntity): Long + + @Query( + """ +SELECT n.tuskyAccountId, n.type, n.id, n.loading, +a.serverId as 'a_serverId', a.tuskyAccountId as 'a_tuskyAccountId', +a.localUsername as 'a_localUsername', a.username as 'a_username', +a.displayName as 'a_displayName', a.url as 'a_url', a.avatar as 'a_avatar', +a.emojis as 'a_emojis', a.bot as 'a_bot', +s.serverId as 's_serverId', s.url as 's_url', s.tuskyAccountId as 's_tuskyAccountId', +s.authorServerId as 's_authorServerId', s.inReplyToId as 's_inReplyToId', s.inReplyToAccountId as 's_inReplyToAccountId', +s.content as 's_content', s.createdAt as 's_createdAt', s.editedAt as 's_editedAt', s.emojis as 's_emojis', s.reblogsCount as 's_reblogsCount', +s.favouritesCount as 's_favouritesCount', s.repliesCount as 's_repliesCount', s.reblogged as 's_reblogged', s.favourited as 's_favourited', +s.bookmarked as 's_bookmarked', s.sensitive as 's_sensitive', s.spoilerText as 's_spoilerText', s.visibility as 's_visibility', +s.mentions as 's_mentions', s.tags as 's_tags', s.application as 's_application', s.content as 's_content', s.attachments as 's_attachments', s.poll as 's_poll', +s.card as 's_card', s.muted as 's_muted', s.expanded as 's_expanded', s.contentShowing as 's_contentShowing', s.contentCollapsed as 's_contentCollapsed', +s.pinned as 's_pinned', s.language as 's_language', s.filtered as 's_filtered', +sa.serverId as 'sa_serverId', sa.tuskyAccountId as 'sa_tuskyAccountId', +sa.localUsername as 'sa_localUsername', sa.username as 'sa_username', +sa.displayName as 'sa_displayName', sa.url as 'sa_url', sa.avatar as 'sa_avatar', +sa.emojis as 'sa_emojis', sa.bot as 'sa_bot', +r.serverId as 'r_serverId', r.tuskyAccountId as 'r_tuskyAccountId', +r.category as 'r_category', r.statusIds as 'r_statusIds', +r.createdAt as 'r_createdAt', r.targetAccountId as 'r_targetAccountId', +ra.serverId as 'ra_serverId', ra.tuskyAccountId as 'ra_tuskyAccountId', +ra.localUsername as 'ra_localUsername', ra.username as 'ra_username', +ra.displayName as 'ra_displayName', ra.url as 'ra_url', ra.avatar as 'ra_avatar', +ra.emojis as 'ra_emojis', ra.bot as 'ra_bot' +FROM NotificationEntity n +LEFT JOIN TimelineAccountEntity a ON (n.tuskyAccountId = a.tuskyAccountId AND n.accountId = a.serverId) +LEFT JOIN TimelineStatusEntity s ON (n.tuskyAccountId = s.tuskyAccountId AND n.statusId = s.serverId) +LEFT JOIN TimelineAccountEntity sa ON (n.tuskyAccountId = sa.tuskyAccountId AND s.authorServerId = sa.serverId) +LEFT JOIN NotificationReportEntity r ON (n.tuskyAccountId = r.tuskyAccountId AND n.reportId = r.serverId) +LEFT JOIN TimelineAccountEntity ra ON (n.tuskyAccountId = ra.tuskyAccountId AND r.targetAccountId = ra.serverId) +WHERE n.tuskyAccountId = :tuskyAccountId +ORDER BY LENGTH(n.id) DESC, n.id DESC""" + ) + abstract fun getNotifications(tuskyAccountId: Long): PagingSource + + @Query( + """DELETE FROM NotificationEntity WHERE tuskyAccountId = :tuskyAccountId AND id = :notificationId""" + ) + abstract suspend fun delete(tuskyAccountId: Long, notificationId: String): Int + + @Query( + """DELETE FROM NotificationEntity WHERE tuskyAccountId = :tuskyAccountId AND + (LENGTH(id) < LENGTH(:maxId) OR LENGTH(id) == LENGTH(:maxId) AND id <= :maxId) +AND +(LENGTH(id) > LENGTH(:minId) OR LENGTH(id) == LENGTH(:minId) AND id >= :minId) + """ + ) + abstract suspend fun deleteRange(tuskyAccountId: Long, minId: String, maxId: String): Int + + @Query( + """DELETE FROM NotificationEntity WHERE tuskyAccountId = :tuskyAccountId""" + ) + internal abstract suspend fun removeAllNotifications(tuskyAccountId: Long) + + /** + * Deletes all NotificationReportEntities for Tusky user with id [tuskyAccountId]. + * Warning: This can violate foreign key constraints if reports are still referenced in the NotificationEntity table. + */ + @Query( + """DELETE FROM NotificationReportEntity WHERE tuskyAccountId = :tuskyAccountId""" + ) + internal abstract suspend fun removeAllReports(tuskyAccountId: Long) + + @Query( + """DELETE FROM NotificationEntity WHERE tuskyAccountId = :tuskyAccountId AND statusId = :statusId""" + ) + abstract suspend fun deleteAllWithStatus(tuskyAccountId: Long, statusId: String) + + /** + * Remove all notifications from user with id [userId] unless they are admin notifications. + */ + @Query( + """DELETE FROM NotificationEntity WHERE tuskyAccountId = :tuskyAccountId AND + statusId IN + (SELECT serverId FROM TimelineStatusEntity WHERE tuskyAccountId = :tuskyAccountId AND + (authorServerId == :userId OR accountId == :userId)) + AND type != "admin.sign_up" AND type != "admin.report" + """ + ) + abstract suspend fun removeAllByUser(tuskyAccountId: Long, userId: String) + + @Query( + """DELETE FROM NotificationEntity + WHERE tuskyAccountId = :tuskyAccountId AND statusId IN ( + SELECT serverId FROM TimelineStatusEntity WHERE tuskyAccountId = :tuskyAccountId AND authorServerId in + ( SELECT serverId FROM TimelineAccountEntity WHERE username LIKE '%@' || :instanceDomain + AND tuskyAccountId = :tuskyAccountId) + OR accountId IN ( SELECT serverId FROM TimelineAccountEntity WHERE username LIKE '%@' || :instanceDomain + AND tuskyAccountId = :tuskyAccountId) + )""" + ) + abstract suspend fun deleteAllFromInstance(tuskyAccountId: Long, instanceDomain: String) + + @Query("SELECT id FROM NotificationEntity WHERE tuskyAccountId = :accountId ORDER BY LENGTH(id) DESC, id DESC LIMIT 1") + abstract suspend fun getTopId(accountId: Long): String? + + @Query("SELECT id FROM NotificationEntity WHERE tuskyAccountId = :accountId AND type IS NULL ORDER BY LENGTH(id) DESC, id DESC LIMIT 1") + abstract suspend fun getTopPlaceholderId(accountId: Long): String? + + /** + * Cleans the NotificationEntity table from old entries. + * @param tuskyAccountId id of the account for which to clean tables + * @param limit how many timeline items to keep + */ + @Query( + """DELETE FROM NotificationEntity WHERE tuskyAccountId = :tuskyAccountId AND id NOT IN + (SELECT id FROM NotificationEntity WHERE tuskyAccountId = :tuskyAccountId ORDER BY LENGTH(id) DESC, id DESC LIMIT :limit) + """ + ) + internal abstract suspend fun cleanupNotifications(tuskyAccountId: Long, limit: Int) + + /** + * Cleans the NotificationReportEntity table from unreferenced entries. + * @param tuskyAccountId id of the account for which to clean the table + */ + @Query( + """DELETE FROM NotificationReportEntity WHERE tuskyAccountId = :tuskyAccountId + AND serverId NOT IN + (SELECT reportId FROM NotificationEntity WHERE tuskyAccountId = :tuskyAccountId and reportId IS NOT NULL)""" + ) + internal abstract suspend fun cleanupReports(tuskyAccountId: Long) + + /** + * Returns the id directly above [id], or null if [id] is the id of the top item + */ + @Query( + "SELECT id FROM NotificationEntity WHERE tuskyAccountId = :tuskyAccountId AND (LENGTH(:id) < LENGTH(id) OR (LENGTH(:id) = LENGTH(id) AND :id < id)) ORDER BY LENGTH(id) ASC, id ASC LIMIT 1" + ) + abstract suspend fun getIdAbove(tuskyAccountId: Long, id: String): String? + + /** + * Returns the ID directly below [id], or null if [id] is the ID of the bottom item + */ + @Query( + "SELECT id FROM NotificationEntity WHERE tuskyAccountId = :tuskyAccountId AND (LENGTH(:id) > LENGTH(id) OR (LENGTH(:id) = LENGTH(id) AND :id > id)) ORDER BY LENGTH(id) DESC, id DESC LIMIT 1" + ) + abstract suspend fun getIdBelow(tuskyAccountId: Long, id: String): String? +} diff --git a/app/src/main/java/com/keylesspalace/tusky/db/dao/TimelineAccountDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/dao/TimelineAccountDao.kt new file mode 100644 index 000000000..700063e00 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/db/dao/TimelineAccountDao.kt @@ -0,0 +1,56 @@ +/* Copyright 2024 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy.Companion.REPLACE +import androidx.room.Query +import com.keylesspalace.tusky.db.entity.TimelineAccountEntity + +@Dao +abstract class TimelineAccountDao { + + @Insert(onConflict = REPLACE) + abstract suspend fun insert(timelineAccountEntity: TimelineAccountEntity): Long + + @Query( + """SELECT * FROM TimelineAccountEntity a + WHERE a.serverId = :accountId + AND a.tuskyAccountId = :tuskyAccountId""" + ) + internal abstract suspend fun getAccount(tuskyAccountId: Long, accountId: String): TimelineAccountEntity? + + @Query("DELETE FROM TimelineAccountEntity WHERE tuskyAccountId = :tuskyAccountId") + abstract suspend fun removeAllAccounts(tuskyAccountId: Long) + + /** + * Cleans the TimelineAccountEntity table from accounts that are no longer referenced by either TimelineStatusEntity, HomeTimelineEntity or NotificationEntity + * @param tuskyAccountId id of the user account for which to clean timeline accounts + */ + @Query( + """DELETE FROM TimelineAccountEntity WHERE tuskyAccountId = :tuskyAccountId + AND serverId NOT IN + (SELECT authorServerId FROM TimelineStatusEntity WHERE tuskyAccountId = :tuskyAccountId) + AND serverId NOT IN + (SELECT reblogAccountId FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND reblogAccountId IS NOT NULL) + AND serverId NOT IN + (SELECT accountId FROM NotificationEntity WHERE tuskyAccountId = :tuskyAccountId AND accountId IS NOT NULL) + AND serverId NOT IN + (SELECT targetAccountId FROM NotificationReportEntity WHERE tuskyAccountId = :tuskyAccountId AND targetAccountId IS NOT NULL)""" + ) + abstract suspend fun cleanupAccounts(tuskyAccountId: Long) +} diff --git a/app/src/main/java/com/keylesspalace/tusky/db/dao/TimelineDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/dao/TimelineDao.kt new file mode 100644 index 000000000..7ae0aac4d --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/db/dao/TimelineDao.kt @@ -0,0 +1,169 @@ +/* Copyright 2021 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.db.dao + +import androidx.paging.PagingSource +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy.Companion.REPLACE +import androidx.room.Query +import com.keylesspalace.tusky.db.entity.HomeTimelineData +import com.keylesspalace.tusky.db.entity.HomeTimelineEntity + +@Dao +abstract class TimelineDao { + + @Insert(onConflict = REPLACE) + abstract suspend fun insertHomeTimelineItem(item: HomeTimelineEntity): Long + + @Query( + """ +SELECT h.id, s.serverId, s.url, s.tuskyAccountId, +s.authorServerId, s.inReplyToId, s.inReplyToAccountId, s.createdAt, s.editedAt, +s.emojis, s.reblogsCount, s.favouritesCount, s.repliesCount, s.reblogged, s.favourited, s.bookmarked, s.sensitive, +s.spoilerText, s.visibility, s.mentions, s.tags, s.application, +s.content, s.attachments, s.poll, s.card, s.muted, s.expanded, s.contentShowing, s.contentCollapsed, s.pinned, s.language, s.filtered, +a.serverId as 'a_serverId', a.tuskyAccountId as 'a_tuskyAccountId', +a.localUsername as 'a_localUsername', a.username as 'a_username', +a.displayName as 'a_displayName', a.url as 'a_url', a.avatar as 'a_avatar', +a.emojis as 'a_emojis', a.bot as 'a_bot', +rb.serverId as 'rb_serverId', rb.tuskyAccountId 'rb_tuskyAccountId', +rb.localUsername as 'rb_localUsername', rb.username as 'rb_username', +rb.displayName as 'rb_displayName', rb.url as 'rb_url', rb.avatar as 'rb_avatar', +rb.emojis as 'rb_emojis', rb.bot as 'rb_bot', +h.loading +FROM HomeTimelineEntity h +LEFT JOIN TimelineStatusEntity s ON (h.statusId = s.serverId AND s.tuskyAccountId = :tuskyAccountId) +LEFT JOIN TimelineAccountEntity a ON (s.authorServerId = a.serverId AND a.tuskyAccountId = :tuskyAccountId) +LEFT JOIN TimelineAccountEntity rb ON (h.reblogAccountId = rb.serverId AND rb.tuskyAccountId = :tuskyAccountId) +WHERE h.tuskyAccountId = :tuskyAccountId +ORDER BY LENGTH(h.id) DESC, h.id DESC""" + ) + abstract fun getHomeTimeline(tuskyAccountId: Long): PagingSource + + @Query( + """DELETE FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND + (LENGTH(id) < LENGTH(:maxId) OR LENGTH(id) == LENGTH(:maxId) AND id <= :maxId) +AND +(LENGTH(id) > LENGTH(:minId) OR LENGTH(id) == LENGTH(:minId) AND id >= :minId) + """ + ) + abstract suspend fun deleteRange(tuskyAccountId: Long, minId: String, maxId: String): Int + + /** + * Remove all home timeline items that are statuses or reblogs by the user with id [userId], including reblogs from other people. + * (e.g. because user was blocked) + */ + @Query( + """DELETE FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND + (statusId IN + (SELECT serverId FROM TimelineStatusEntity WHERE tuskyAccountId = :tuskyAccountId AND authorServerId == :userId) + OR reblogAccountId == :userId) + """ + ) + abstract suspend fun removeAllByUser(tuskyAccountId: Long, userId: String) + + /** + * Remove all home timeline items that are statuses or reblogs by the user with id [userId], but not reblogs from other users. + * (e.g. because user was unfollowed) + */ + @Query( + """DELETE FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND + ((statusId IN + (SELECT serverId FROM TimelineStatusEntity WHERE tuskyAccountId = :tuskyAccountId AND authorServerId == :userId) + AND reblogAccountId IS NULL) + OR reblogAccountId == :userId) + """ + ) + abstract suspend fun removeStatusesAndReblogsByUser(tuskyAccountId: Long, userId: String) + + @Query("DELETE FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId") + abstract suspend fun removeAllHomeTimelineItems(tuskyAccountId: Long) + + @Query( + """DELETE FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND id = :id""" + ) + abstract suspend fun deleteHomeTimelineItem(tuskyAccountId: Long, id: String) + + /** + * Deletes all hometimeline items that reference the status with it [statusId]. They can be regular statuses or reblogs. + */ + @Query( + """DELETE FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND statusId = :statusId""" + ) + abstract suspend fun deleteAllWithStatus(tuskyAccountId: Long, statusId: String) + + /** + * Trims the HomeTimelineEntity table down to [limit] entries by deleting the oldest in case there are more than [limit]. + * @param tuskyAccountId id of the account for which to clean the home timeline + * @param limit how many timeline items to keep + */ + @Query( + """DELETE FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND id NOT IN + (SELECT id FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId ORDER BY LENGTH(id) DESC, id DESC LIMIT :limit) + """ + ) + internal abstract suspend fun cleanupHomeTimeline(tuskyAccountId: Long, limit: Int) + + @Query( + """DELETE FROM HomeTimelineEntity +WHERE tuskyAccountId = :tuskyAccountId AND statusId IN ( +SELECT serverId FROM TimelineStatusEntity WHERE tuskyAccountId = :tuskyAccountId AND authorServerId in +( SELECT serverId FROM TimelineAccountEntity WHERE username LIKE '%@' || :instanceDomain +AND tuskyAccountId = :tuskyAccountId +))""" + ) + abstract suspend fun deleteAllFromInstance(tuskyAccountId: Long, instanceDomain: String) + + @Query( + "SELECT id FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId ORDER BY LENGTH(id) DESC, id DESC LIMIT 1" + ) + abstract suspend fun getTopId(tuskyAccountId: Long): String? + + @Query( + "SELECT id FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND statusId IS NULL ORDER BY LENGTH(id) DESC, id DESC LIMIT 1" + ) + abstract suspend fun getTopPlaceholderId(tuskyAccountId: Long): String? + + /** + * Returns the id directly above [id], or null if [id] is the id of the top item + */ + @Query( + "SELECT id FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND (LENGTH(:id) < LENGTH(id) OR (LENGTH(:id) = LENGTH(id) AND :id < id)) ORDER BY LENGTH(id) ASC, id ASC LIMIT 1" + ) + abstract suspend fun getIdAbove(tuskyAccountId: Long, id: String): String? + + /** + * Returns the ID directly below [id], or null if [id] is the ID of the bottom item + */ + @Query( + "SELECT id FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND (LENGTH(:id) > LENGTH(id) OR (LENGTH(:id) = LENGTH(id) AND :id > id)) ORDER BY LENGTH(id) DESC, id DESC LIMIT 1" + ) + abstract suspend fun getIdBelow(tuskyAccountId: Long, id: String): String? + + @Query("SELECT COUNT(*) FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId") + abstract suspend fun getHomeTimelineItemCount(tuskyAccountId: Long): Int + + /** Developer tools: Find N most recent status IDs */ + @Query( + "SELECT id FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId ORDER BY LENGTH(id) DESC, id DESC LIMIT :count" + ) + abstract suspend fun getMostRecentNHomeTimelineIds(tuskyAccountId: Long, count: Int): List + + /** Developer tools: Convert a home timeline item to a placeholder */ + @Query("UPDATE HomeTimelineEntity SET statusId = NULL, reblogAccountId = NULL WHERE id = :serverId") + abstract suspend fun convertHomeTimelineItemToPlaceholder(serverId: String) +} diff --git a/app/src/main/java/com/keylesspalace/tusky/db/dao/TimelineStatusDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/dao/TimelineStatusDao.kt new file mode 100644 index 000000000..17dbe75dd --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/db/dao/TimelineStatusDao.kt @@ -0,0 +1,279 @@ +/* Copyright 2024 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy.Companion.REPLACE +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.TypeConverters +import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.db.Converters +import com.keylesspalace.tusky.db.entity.TimelineAccountEntity +import com.keylesspalace.tusky.db.entity.TimelineStatusEntity +import com.keylesspalace.tusky.entity.Attachment +import com.keylesspalace.tusky.entity.Card +import com.keylesspalace.tusky.entity.Emoji +import com.keylesspalace.tusky.entity.HashTag +import com.keylesspalace.tusky.entity.Poll +import com.keylesspalace.tusky.entity.Status +import com.squareup.moshi.Moshi +import com.squareup.moshi.adapter + +@Dao +abstract class TimelineStatusDao( + private val db: AppDatabase +) { + + @Insert(onConflict = REPLACE) + abstract suspend fun insert(timelineStatusEntity: TimelineStatusEntity): Long + + @Transaction + open suspend fun getStatusWithAccount(tuskyAccountId: Long, statusId: String): Pair? { + val status = getStatus(tuskyAccountId, statusId) ?: return null + val account = db.timelineAccountDao().getAccount(tuskyAccountId, status.authorServerId) ?: return null + return status to account + } + + @Query( + """ +SELECT * FROM TimelineStatusEntity s +WHERE s.serverId = :statusId +AND s.authorServerId IS NOT NULL +AND s.tuskyAccountId = :tuskyAccountId""" + ) + abstract suspend fun getStatus(tuskyAccountId: Long, statusId: String): TimelineStatusEntity? + + @OptIn(ExperimentalStdlibApi::class) + suspend fun update(tuskyAccountId: Long, status: Status, moshi: Moshi) { + update( + tuskyAccountId = tuskyAccountId, + statusId = status.id, + content = status.content, + editedAt = status.editedAt?.time, + emojis = moshi.adapter?>().toJson(status.emojis), + reblogsCount = status.reblogsCount, + favouritesCount = status.favouritesCount, + repliesCount = status.repliesCount, + reblogged = status.reblogged, + bookmarked = status.bookmarked, + favourited = status.favourited, + sensitive = status.sensitive, + spoilerText = status.spoilerText, + visibility = status.visibility, + attachments = moshi.adapter?>().toJson(status.attachments), + mentions = moshi.adapter?>().toJson(status.mentions), + tags = moshi.adapter?>().toJson(status.tags), + poll = moshi.adapter().toJson(status.poll), + muted = status.muted, + pinned = status.pinned, + card = moshi.adapter().toJson(status.card), + language = status.language + ) + } + + @Query( + """UPDATE TimelineStatusEntity + SET content = :content, + editedAt = :editedAt, + emojis = :emojis, + reblogsCount = :reblogsCount, + favouritesCount = :favouritesCount, + repliesCount = :repliesCount, + reblogged = :reblogged, + bookmarked = :bookmarked, + favourited = :favourited, + sensitive = :sensitive, + spoilerText = :spoilerText, + visibility = :visibility, + attachments = :attachments, + mentions = :mentions, + tags = :tags, + poll = :poll, + muted = :muted, + pinned = :pinned, + card = :card, + language = :language + WHERE tuskyAccountId = :tuskyAccountId AND serverId = :statusId""" + ) + @TypeConverters(Converters::class) + abstract suspend fun update( + tuskyAccountId: Long, + statusId: String, + content: String?, + editedAt: Long?, + emojis: String?, + reblogsCount: Int, + favouritesCount: Int, + repliesCount: Int, + reblogged: Boolean, + bookmarked: Boolean, + favourited: Boolean, + sensitive: Boolean, + spoilerText: String, + visibility: Status.Visibility, + attachments: String?, + mentions: String?, + tags: String?, + poll: String?, + muted: Boolean?, + pinned: Boolean, + card: String?, + language: String? + ) + + @Query( + """UPDATE TimelineStatusEntity SET bookmarked = :bookmarked +WHERE tuskyAccountId = :tuskyAccountId AND serverId = :statusId""" + ) + abstract suspend fun setBookmarked(tuskyAccountId: Long, statusId: String, bookmarked: Boolean) + + @Query( + """UPDATE TimelineStatusEntity SET reblogged = :reblogged +WHERE tuskyAccountId = :tuskyAccountId AND serverId = :statusId""" + ) + abstract suspend fun setReblogged(tuskyAccountId: Long, statusId: String, reblogged: Boolean) + + @Query("DELETE FROM TimelineStatusEntity WHERE tuskyAccountId = :tuskyAccountId") + abstract suspend fun removeAllStatuses(tuskyAccountId: Long) + + @Query( + """DELETE FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND id = :id""" + ) + abstract suspend fun deleteHomeTimelineItem(tuskyAccountId: Long, id: String) + + /** + * Deletes all hometimeline items that reference the status with it [statusId]. They can be regular statuses or reblogs. + */ + @Query( + """DELETE FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND statusId = :statusId""" + ) + abstract suspend fun deleteAllWithStatus(tuskyAccountId: Long, statusId: String) + + /** + * Cleans the TimelineStatusEntity table from unreferenced status entries. + * @param tuskyAccountId id of the account for which to clean statuses + */ + @Query( + """DELETE FROM TimelineStatusEntity WHERE tuskyAccountId = :tuskyAccountId + AND serverId NOT IN + (SELECT statusId FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND statusId IS NOT NULL) + AND serverId NOT IN + (SELECT statusId FROM NotificationEntity WHERE tuskyAccountId = :tuskyAccountId AND statusId IS NOT NULL)""" + ) + internal abstract suspend fun cleanupStatuses(tuskyAccountId: Long) + + @Query( + """UPDATE TimelineStatusEntity SET poll = :poll +WHERE tuskyAccountId = :tuskyAccountId AND serverId = :statusId""" + ) + abstract suspend fun setVoted(tuskyAccountId: Long, statusId: String, poll: String) + + @Query( + """UPDATE TimelineStatusEntity SET expanded = :expanded +WHERE tuskyAccountId = :tuskyAccountId AND serverId = :statusId""" + ) + abstract suspend fun setExpanded(tuskyAccountId: Long, statusId: String, expanded: Boolean) + + @Query( + """UPDATE TimelineStatusEntity SET contentShowing = :contentShowing +WHERE tuskyAccountId = :tuskyAccountId AND serverId = :statusId""" + ) + abstract suspend fun setContentShowing( + tuskyAccountId: Long, + statusId: String, + contentShowing: Boolean + ) + + @Query( + """UPDATE TimelineStatusEntity SET contentCollapsed = :contentCollapsed +WHERE tuskyAccountId = :tuskyAccountId AND serverId = :statusId""" + ) + abstract suspend fun setContentCollapsed( + tuskyAccountId: Long, + statusId: String, + contentCollapsed: Boolean + ) + + @Query( + """UPDATE TimelineStatusEntity SET pinned = :pinned +WHERE tuskyAccountId = :tuskyAccountId AND serverId = :statusId""" + ) + abstract suspend fun setPinned(tuskyAccountId: Long, statusId: String, pinned: Boolean) + + @Query( + """DELETE FROM HomeTimelineEntity +WHERE tuskyAccountId = :tuskyAccountId AND statusId IN ( +SELECT serverId FROM TimelineStatusEntity WHERE tuskyAccountId = :tuskyAccountId AND authorServerId in +( SELECT serverId FROM TimelineAccountEntity WHERE username LIKE '%@' || :instanceDomain +AND tuskyAccountId = :tuskyAccountId +))""" + ) + abstract suspend fun deleteAllFromInstance(tuskyAccountId: Long, instanceDomain: String) + + @Query( + "UPDATE TimelineStatusEntity SET filtered = '[]' WHERE tuskyAccountId = :tuskyAccountId AND serverId = :statusId" + ) + abstract suspend fun clearWarning(tuskyAccountId: Long, statusId: String): Int + + @Query( + "SELECT id FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId ORDER BY LENGTH(id) DESC, id DESC LIMIT 1" + ) + abstract suspend fun getTopId(tuskyAccountId: Long): String? + + @Query( + "SELECT id FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND statusId IS NULL ORDER BY LENGTH(id) DESC, id DESC LIMIT 1" + ) + abstract suspend fun getTopPlaceholderId(tuskyAccountId: Long): String? + + /** + * Returns the id directly above [id], or null if [id] is the id of the top item + */ + @Query( + "SELECT id FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND (LENGTH(:id) < LENGTH(id) OR (LENGTH(:id) = LENGTH(id) AND :id < id)) ORDER BY LENGTH(id) ASC, id ASC LIMIT 1" + ) + abstract suspend fun getIdAbove(tuskyAccountId: Long, id: String): String? + + /** + * Returns the ID directly below [id], or null if [id] is the ID of the bottom item + */ + @Query( + "SELECT id FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND (LENGTH(:id) > LENGTH(id) OR (LENGTH(:id) = LENGTH(id) AND :id > id)) ORDER BY LENGTH(id) DESC, id DESC LIMIT 1" + ) + abstract suspend fun getIdBelow(tuskyAccountId: Long, id: String): String? + + /** + * Returns the id of the next placeholder after [id], or null if there is no placeholder. + */ + @Query( + "SELECT id FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND statusId IS NULL AND (LENGTH(:id) > LENGTH(id) OR (LENGTH(:id) = LENGTH(id) AND :id > id)) ORDER BY LENGTH(id) DESC, id DESC LIMIT 1" + ) + abstract suspend fun getNextPlaceholderIdAfter(tuskyAccountId: Long, id: String): String? + + @Query("SELECT COUNT(*) FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId") + abstract suspend fun getHomeTimelineItemCount(tuskyAccountId: Long): Int + + /** Developer tools: Find N most recent status IDs */ + @Query( + "SELECT id FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId ORDER BY LENGTH(id) DESC, id DESC LIMIT :count" + ) + abstract suspend fun getMostRecentNStatusIds(tuskyAccountId: Long, count: Int): List + + /** Developer tools: Convert a home timeline item to a placeholder */ + @Query("UPDATE HomeTimelineEntity SET statusId = NULL, reblogAccountId = NULL WHERE id = :serverId") + abstract suspend fun convertStatusToPlaceholder(serverId: String) +} diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/entity/AccountEntity.kt similarity index 98% rename from app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt rename to app/src/main/java/com/keylesspalace/tusky/db/entity/AccountEntity.kt index 40098593c..d9d8f15f1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/entity/AccountEntity.kt @@ -13,7 +13,7 @@ * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ -package com.keylesspalace.tusky.db +package com.keylesspalace.tusky.db.entity import androidx.room.ColumnInfo import androidx.room.Entity @@ -21,6 +21,7 @@ import androidx.room.Index import androidx.room.PrimaryKey import androidx.room.TypeConverters import com.keylesspalace.tusky.TabData +import com.keylesspalace.tusky.db.Converters import com.keylesspalace.tusky.defaultTabs import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.Status diff --git a/app/src/main/java/com/keylesspalace/tusky/db/DraftEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/entity/DraftEntity.kt similarity index 95% rename from app/src/main/java/com/keylesspalace/tusky/db/DraftEntity.kt rename to app/src/main/java/com/keylesspalace/tusky/db/entity/DraftEntity.kt index a5928ca0b..18423438f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/DraftEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/entity/DraftEntity.kt @@ -13,7 +13,7 @@ * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ -package com.keylesspalace.tusky.db +package com.keylesspalace.tusky.db.entity import android.net.Uri import android.os.Parcelable @@ -21,6 +21,7 @@ import androidx.core.net.toUri import androidx.room.Entity import androidx.room.PrimaryKey import androidx.room.TypeConverters +import com.keylesspalace.tusky.db.Converters import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.NewPoll import com.keylesspalace.tusky.entity.Status diff --git a/app/src/main/java/com/keylesspalace/tusky/db/entity/HomeTimelineEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/entity/HomeTimelineEntity.kt new file mode 100644 index 000000000..ccbdb44f7 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/db/entity/HomeTimelineEntity.kt @@ -0,0 +1,68 @@ +/* Copyright 2024 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.db.entity + +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index + +/** + * Entity to store an item on the home timeline. Can be a standalone status, a reblog, or a placeholder. + */ +@Entity( + primaryKeys = ["id", "tuskyAccountId"], + foreignKeys = ( + [ + ForeignKey( + entity = TimelineStatusEntity::class, + parentColumns = ["serverId", "tuskyAccountId"], + childColumns = ["statusId", "tuskyAccountId"] + ), + ForeignKey( + entity = TimelineAccountEntity::class, + parentColumns = ["serverId", "tuskyAccountId"], + childColumns = ["reblogAccountId", "tuskyAccountId"] + ) + ] + ), + indices = [ + Index("statusId", "tuskyAccountId"), + Index("reblogAccountId", "tuskyAccountId"), + ] +) +data class HomeTimelineEntity( + val tuskyAccountId: Long, + // the id by which the timeline is sorted + val id: String, + // the id of the status, null when a placeholder + val statusId: String?, + // the id of the account who reblogged the status, null if no reblog + val reblogAccountId: String?, + // only relevant when this is a placeholder + val loading: Boolean = false +) + +/** + * Helper class for queries that return HomeTimelineEntity including all references + */ +data class HomeTimelineData( + val id: String, + @Embedded val status: TimelineStatusEntity?, + @Embedded(prefix = "a_") val account: TimelineAccountEntity?, + @Embedded(prefix = "rb_") val reblogAccount: TimelineAccountEntity?, + val loading: Boolean +) diff --git a/app/src/main/java/com/keylesspalace/tusky/db/InstanceEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/entity/InstanceEntity.kt similarity index 96% rename from app/src/main/java/com/keylesspalace/tusky/db/InstanceEntity.kt rename to app/src/main/java/com/keylesspalace/tusky/db/entity/InstanceEntity.kt index 4db2ee050..fc8cde3b0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/InstanceEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/entity/InstanceEntity.kt @@ -13,11 +13,12 @@ * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ -package com.keylesspalace.tusky.db +package com.keylesspalace.tusky.db.entity import androidx.room.Entity import androidx.room.PrimaryKey import androidx.room.TypeConverters +import com.keylesspalace.tusky.db.Converters import com.keylesspalace.tusky.entity.Emoji @Entity diff --git a/app/src/main/java/com/keylesspalace/tusky/db/entity/NotificationEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/entity/NotificationEntity.kt new file mode 100644 index 000000000..bb47daaca --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/db/entity/NotificationEntity.kt @@ -0,0 +1,107 @@ +/* Copyright 2024 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.db.entity + +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.TypeConverters +import com.keylesspalace.tusky.db.Converters +import com.keylesspalace.tusky.entity.Notification +import java.util.Date + +data class NotificationDataEntity( + // id of the account logged into Tusky this notifications belongs to + val tuskyAccountId: Long, + // null when placeholder + val type: Notification.Type?, + val id: String, + @Embedded(prefix = "a_") val account: TimelineAccountEntity?, + @Embedded(prefix = "s_") val status: TimelineStatusEntity?, + @Embedded(prefix = "sa_") val statusAccount: TimelineAccountEntity?, + @Embedded(prefix = "r_") val report: NotificationReportEntity?, + @Embedded(prefix = "ra_") val reportTargetAccount: TimelineAccountEntity?, + // relevant when it is a placeholder + val loading: Boolean = false +) + +@Entity( + primaryKeys = ["id", "tuskyAccountId"], + foreignKeys = ( + [ + ForeignKey( + entity = TimelineAccountEntity::class, + parentColumns = ["serverId", "tuskyAccountId"], + childColumns = ["accountId", "tuskyAccountId"] + ), + ForeignKey( + entity = TimelineStatusEntity::class, + parentColumns = ["serverId", "tuskyAccountId"], + childColumns = ["statusId", "tuskyAccountId"] + ), + ForeignKey( + entity = NotificationReportEntity::class, + parentColumns = ["serverId", "tuskyAccountId"], + childColumns = ["reportId", "tuskyAccountId"] + ) + ] + ), + indices = [ + Index("accountId", "tuskyAccountId"), + Index("statusId", "tuskyAccountId"), + Index("reportId", "tuskyAccountId"), + ] +) +@TypeConverters(Converters::class) +data class NotificationEntity( + // id of the account logged into Tusky this notifications belongs to + val tuskyAccountId: Long, + // null when placeholder + val type: Notification.Type?, + val id: String, + val accountId: String?, + val statusId: String?, + val reportId: String?, + // relevant when it is a placeholder + val loading: Boolean = false +) + +@Entity( + primaryKeys = ["serverId", "tuskyAccountId"], + foreignKeys = ( + [ + ForeignKey( + entity = TimelineAccountEntity::class, + parentColumns = ["serverId", "tuskyAccountId"], + childColumns = ["targetAccountId", "tuskyAccountId"] + ) + ] + ), + indices = [ + Index("targetAccountId", "tuskyAccountId"), + ] +) +@TypeConverters(Converters::class) +data class NotificationReportEntity( + // id of the account logged into Tusky this report belongs to + val tuskyAccountId: Long, + val serverId: String, + val category: String, + val statusIds: List?, + val createdAt: Date, + val targetAccountId: String? +) diff --git a/app/src/main/java/com/keylesspalace/tusky/db/entity/TimelineAccountEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/entity/TimelineAccountEntity.kt new file mode 100644 index 000000000..12499dbe2 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/db/entity/TimelineAccountEntity.kt @@ -0,0 +1,37 @@ +/* Copyright 2024 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.db.entity + +import androidx.room.Entity +import androidx.room.TypeConverters +import com.keylesspalace.tusky.db.Converters +import com.keylesspalace.tusky.entity.Emoji + +@Entity( + primaryKeys = ["serverId", "tuskyAccountId"] +) +@TypeConverters(Converters::class) +data class TimelineAccountEntity( + val serverId: String, + val tuskyAccountId: Long, + val localUsername: String, + val username: String, + val displayName: String, + val url: String, + val avatar: String, + val emojis: List, + val bot: Boolean +) diff --git a/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/entity/TimelineStatusEntity.kt similarity index 50% rename from app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt rename to app/src/main/java/com/keylesspalace/tusky/db/entity/TimelineStatusEntity.kt index ba32f6380..2b377d687 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/entity/TimelineStatusEntity.kt @@ -13,40 +13,38 @@ * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ -package com.keylesspalace.tusky.db +package com.keylesspalace.tusky.db.entity -import androidx.room.Embedded import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Index import androidx.room.TypeConverters +import com.keylesspalace.tusky.db.Converters +import com.keylesspalace.tusky.entity.Attachment +import com.keylesspalace.tusky.entity.Card +import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.FilterResult +import com.keylesspalace.tusky.entity.HashTag +import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Status /** - * We're trying to play smart here. Server sends us reblogs as two entities one embedded into - * another (reblogged status is a field inside of "reblog" status). But it's really inefficient from - * the DB perspective and doesn't matter much for the display/interaction purposes. - * What if when we store reblog we don't store almost empty "reblog status" but we store - * *reblogged* status and we embed "reblog status" into reblogged status. This reversed - * relationship takes much less space and is much faster to fetch (no N+1 type queries or JSON - * serialization). - * "Reblog status", if present, is marked by [reblogServerId], and [reblogAccountId] - * fields. + * Entity for caching status data. Used within home timelines and notifications. + * The information if a status is a reblog is not stored here but in [HomeTimelineEntity]. */ @Entity( - primaryKeys = ["serverId", "timelineUserId"], + primaryKeys = ["serverId", "tuskyAccountId"], foreignKeys = ( [ ForeignKey( entity = TimelineAccountEntity::class, - parentColumns = ["serverId", "timelineUserId"], - childColumns = ["authorServerId", "timelineUserId"] + parentColumns = ["serverId", "tuskyAccountId"], + childColumns = ["authorServerId", "tuskyAccountId"] ) ] ), // Avoiding rescanning status table when accounts table changes. Recommended by Room(c). - indices = [Index("authorServerId", "timelineUserId")] + indices = [Index("authorServerId", "tuskyAccountId")] ) @TypeConverters(Converters::class) data class TimelineStatusEntity( @@ -54,14 +52,14 @@ data class TimelineStatusEntity( val serverId: String, val url: String?, // our local id for the logged in user in case there are multiple accounts per instance - val timelineUserId: Long, - val authorServerId: String?, + val tuskyAccountId: Long, + val authorServerId: String, val inReplyToId: String?, val inReplyToAccountId: String?, - val content: String?, + val content: String, val createdAt: Long, val editedAt: Long?, - val emojis: String?, + val emojis: List, val reblogsCount: Int, val favouritesCount: Int, val repliesCount: Int, @@ -71,50 +69,19 @@ data class TimelineStatusEntity( val sensitive: Boolean, val spoilerText: String, val visibility: Status.Visibility, - val attachments: String?, - val mentions: String?, - val tags: String?, - val application: String?, + val attachments: List, + val mentions: List, + val tags: List, + val application: Status.Application?, // if it has a reblogged status, it's id is stored here - val reblogServerId: String?, - val reblogAccountId: String?, - val poll: String?, - val muted: Boolean?, + val poll: Poll?, + val muted: Boolean, /** Also used as the "loading" attribute when this TimelineStatusEntity is a placeholder */ val expanded: Boolean, val contentCollapsed: Boolean, val contentShowing: Boolean, val pinned: Boolean, - val card: String?, + val card: Card?, val language: String?, - val filtered: List? -) { - val isPlaceholder: Boolean - get() = this.authorServerId == null -} - -@Entity( - primaryKeys = ["serverId", "timelineUserId"] -) -data class TimelineAccountEntity( - val serverId: String, - val timelineUserId: Long, - val localUsername: String, - val username: String, - val displayName: String, - val url: String, - val avatar: String, - val emojis: String, - val bot: Boolean -) - -data class TimelineStatusWithAccount( - @Embedded - val status: TimelineStatusEntity, - // null when placeholder - @Embedded(prefix = "a_") - val account: TimelineAccountEntity? = null, - // null when no reblog - @Embedded(prefix = "rb_") - val reblogAccount: TimelineAccountEntity? = null + val filtered: List ) diff --git a/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt index 0f9277dda..e1b446f5d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt @@ -68,7 +68,8 @@ class AppModule { AppDatabase.MIGRATION_38_39, AppDatabase.MIGRATION_39_40, AppDatabase.MIGRATION_40_41, AppDatabase.MIGRATION_41_42, AppDatabase.MIGRATION_42_43, AppDatabase.MIGRATION_43_44, AppDatabase.MIGRATION_44_45, AppDatabase.MIGRATION_45_46, AppDatabase.MIGRATION_46_47, - AppDatabase.MIGRATION_47_48, AppDatabase.MIGRATION_52_53, AppDatabase.MIGRATION_54_56 + AppDatabase.MIGRATION_47_48, AppDatabase.MIGRATION_52_53, AppDatabase.MIGRATION_54_56, + AppDatabase.MIGRATION_58_60 ) .build() } diff --git a/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt index b1a8b17ad..cf2d05b4a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt @@ -21,6 +21,7 @@ import com.keylesspalace.tusky.components.account.media.AccountMediaFragment import com.keylesspalace.tusky.components.accountlist.AccountListFragment import com.keylesspalace.tusky.components.conversation.ConversationsFragment import com.keylesspalace.tusky.components.domainblocks.DomainBlocksFragment +import com.keylesspalace.tusky.components.notifications.NotificationsFragment import com.keylesspalace.tusky.components.preference.AccountPreferencesFragment import com.keylesspalace.tusky.components.preference.NotificationPreferencesFragment import com.keylesspalace.tusky.components.preference.PreferencesFragment @@ -35,7 +36,6 @@ import com.keylesspalace.tusky.components.timeline.TimelineFragment import com.keylesspalace.tusky.components.trending.TrendingTagsFragment import com.keylesspalace.tusky.components.viewthread.ViewThreadFragment import com.keylesspalace.tusky.components.viewthread.edits.ViewEditsFragment -import com.keylesspalace.tusky.fragment.NotificationsFragment import com.keylesspalace.tusky.fragment.ViewVideoFragment import dagger.Module import dagger.android.ContributesAndroidInjector diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt index d2c86a598..3fbcbe096 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt @@ -33,6 +33,7 @@ import com.keylesspalace.tusky.components.filters.EditFilterViewModel import com.keylesspalace.tusky.components.filters.FiltersViewModel import com.keylesspalace.tusky.components.followedtags.FollowedTagsViewModel import com.keylesspalace.tusky.components.login.LoginWebViewViewModel +import com.keylesspalace.tusky.components.notifications.NotificationsViewModel import com.keylesspalace.tusky.components.report.ReportViewModel import com.keylesspalace.tusky.components.scheduled.ScheduledStatusViewModel import com.keylesspalace.tusky.components.search.SearchViewModel @@ -192,5 +193,10 @@ abstract class ViewModelModule { @ViewModelKey(DomainBlocksViewModel::class) internal abstract fun instanceMuteViewModel(viewModel: DomainBlocksViewModel): ViewModel + @Binds + @IntoMap + @ViewModelKey(NotificationsViewModel::class) + internal abstract fun notificationsViewModel(viewModel: NotificationsViewModel): ViewModel + // Add more ViewModels here } diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt index 25b8b1f92..2fc8a258b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt @@ -75,7 +75,6 @@ data class Notification( REPORT("admin.report", R.string.notification_report_name); companion object { - @JvmStatic fun byString(s: String): Type { return entries.firstOrNull { it.presentation == s } ?: UNKNOWN } @@ -85,25 +84,9 @@ data class Notification( listOf(MENTION, REBLOG, FAVOURITE, FOLLOW, FOLLOW_REQUEST, POLL, STATUS, SIGN_UP, UPDATE, REPORT) } - override fun toString(): String { - return presentation - } + override fun toString() = presentation } - override fun hashCode(): Int { - return id.hashCode() - } - - override fun equals(other: Any?): Boolean { - if (other !is Notification) { - return false - } - return other.id == this.id - } - - /** Helper for Java */ - fun copyWithStatus(status: Status?): Notification = copy(status = status) - // for Pleroma compatibility that uses Mention type fun rewriteToStatusTypeIfNeeded(accountId: String): Notification { if (type == Type.MENTION && status != null) { 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 1176752d4..b79659786 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt @@ -47,7 +47,7 @@ data class Status( @Json(name = "media_attachments") val attachments: List, val mentions: List, // Use null to mark the absence of tags because of semantic differences in LinkHelper - val tags: List? = null, + val tags: List = emptyList(), val application: Application? = null, val pinned: Boolean = false, val muted: Boolean = false, @@ -66,13 +66,6 @@ data class Status( val actionableStatus: Status get() = reblog ?: this - /** Helpers for Java */ - fun copyWithFavourited(favourited: Boolean): Status = copy(favourited = favourited) - fun copyWithReblogged(reblogged: Boolean): Status = copy(reblogged = reblogged) - fun copyWithBookmarked(bookmarked: Boolean): Status = copy(bookmarked = bookmarked) - fun copyWithPoll(poll: Poll?): Status = copy(poll = poll) - fun copyWithPinned(pinned: Boolean): Status = copy(pinned = pinned) - @JsonClass(generateAdapter = false) enum class Visibility(val num: Int) { UNKNOWN(0), diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java deleted file mode 100644 index ac81fb832..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java +++ /dev/null @@ -1,1260 +0,0 @@ -/* Copyright 2017 Andrew Dawson - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . */ - -package com.keylesspalace.tusky.fragment; - -import static com.keylesspalace.tusky.util.StringUtils.isLessThan; - -import android.app.Activity; -import android.content.Context; -import android.content.DialogInterface; -import android.content.SharedPreferences; -import android.os.Bundle; -import android.util.Log; -import android.util.SparseBooleanArray; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ArrayAdapter; -import android.widget.ListView; -import android.widget.PopupWindow; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AlertDialog; -import androidx.arch.core.util.Function; -import androidx.coordinatorlayout.widget.CoordinatorLayout; -import androidx.core.util.Pair; -import androidx.core.view.MenuProvider; -import androidx.lifecycle.Lifecycle; -import androidx.preference.PreferenceManager; -import androidx.recyclerview.widget.AsyncDifferConfig; -import androidx.recyclerview.widget.AsyncListDiffer; -import androidx.recyclerview.widget.DiffUtil; -import androidx.recyclerview.widget.DividerItemDecoration; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.ListUpdateCallback; -import androidx.recyclerview.widget.RecyclerView; -import androidx.recyclerview.widget.SimpleItemAnimator; -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; - -import com.google.android.material.appbar.AppBarLayout; -import com.google.android.material.floatingactionbutton.FloatingActionButton; -import com.keylesspalace.tusky.R; -import com.keylesspalace.tusky.adapter.NotificationsAdapter; -import com.keylesspalace.tusky.adapter.StatusBaseViewHolder; -import com.keylesspalace.tusky.appstore.BlockEvent; -import com.keylesspalace.tusky.appstore.EventHub; -import com.keylesspalace.tusky.appstore.PreferenceChangedEvent; -import com.keylesspalace.tusky.appstore.StatusChangedEvent; -import com.keylesspalace.tusky.components.notifications.NotificationHelper; -import com.keylesspalace.tusky.databinding.FragmentTimelineNotificationsBinding; -import com.keylesspalace.tusky.db.AccountEntity; -import com.keylesspalace.tusky.db.AccountManager; -import com.keylesspalace.tusky.di.Injectable; -import com.keylesspalace.tusky.entity.Notification; -import com.keylesspalace.tusky.entity.Poll; -import com.keylesspalace.tusky.entity.Relationship; -import com.keylesspalace.tusky.entity.Status; -import com.keylesspalace.tusky.interfaces.AccountActionListener; -import com.keylesspalace.tusky.interfaces.ActionButtonActivity; -import com.keylesspalace.tusky.interfaces.ReselectableFragment; -import com.keylesspalace.tusky.interfaces.StatusActionListener; -import com.keylesspalace.tusky.settings.PrefKeys; -import com.keylesspalace.tusky.util.CardViewMode; -import com.keylesspalace.tusky.util.Either; -import com.keylesspalace.tusky.util.HttpHeaderLink; -import com.keylesspalace.tusky.util.LinkHelper; -import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate; -import com.keylesspalace.tusky.util.ListUtils; -import com.keylesspalace.tusky.util.NotificationTypeConverterKt; -import com.keylesspalace.tusky.util.PairedList; -import com.keylesspalace.tusky.util.RelativeTimeUpdater; -import com.keylesspalace.tusky.util.Single; -import com.keylesspalace.tusky.util.StatusDisplayOptions; -import com.keylesspalace.tusky.util.ViewDataUtils; -import com.keylesspalace.tusky.view.EndlessOnScrollListener; -import com.keylesspalace.tusky.viewdata.AttachmentViewData; -import com.keylesspalace.tusky.viewdata.NotificationViewData; -import com.keylesspalace.tusky.viewdata.StatusViewData; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashSet; -import java.util.Iterator; -import java.util.List; -import java.util.Locale; -import java.util.Objects; -import java.util.Set; - -import javax.inject.Inject; - -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 - SwipeRefreshLayout.OnRefreshListener, - StatusActionListener, - NotificationsAdapter.NotificationActionListener, - AccountActionListener, - Injectable, - MenuProvider, - ReselectableFragment { - private static final String TAG = "NotificationF"; // logging tag - - private static final int LOAD_AT_ONCE = 30; - private int maxPlaceholderId = 0; - - private final Set notificationFilter = new HashSet<>(); - - private final ArrayList jobs = new ArrayList<>(); - - private enum FetchEnd { - TOP, - BOTTOM, - MIDDLE - } - - /** - * Placeholder for the notificationsEnabled. Consider moving to the separate class to hide constructor - * and reuse in different places as needed. - */ - private static final class Placeholder { - final long id; - - public static Placeholder getInstance(long id) { - return new Placeholder(id); - } - - private Placeholder(long id) { - this.id = id; - } - } - - @Inject - AccountManager accountManager; - @Inject - EventHub eventHub; - - private FragmentTimelineNotificationsBinding binding; - - private LinearLayoutManager layoutManager; - private EndlessOnScrollListener scrollListener; - private NotificationsAdapter adapter; - private boolean hideFab; - private boolean topLoading; - private boolean bottomLoading; - private String bottomId; - private boolean alwaysShowSensitiveMedia; - private boolean alwaysOpenSpoiler; - private boolean showNotificationsFilter; - private boolean showingError; - - // Each element is either a Notification for loading data or a Placeholder - private final PairedList, NotificationViewData> notifications - = new PairedList<>(new Function<>() { - @Override - public NotificationViewData apply(Either input) { - if (input.isRight()) { - Notification notification = input.asRight() - .rewriteToStatusTypeIfNeeded(accountManager.getActiveAccount().getAccountId()); - - boolean sensitiveStatus = notification.getStatus() != null && notification.getStatus().getActionableStatus().getSensitive(); - - return ViewDataUtils.notificationToViewData( - notification, - alwaysShowSensitiveMedia || !sensitiveStatus, - alwaysOpenSpoiler, - true - ); - } else { - return new NotificationViewData.Placeholder(input.asLeft().id, false); - } - } - }); - - public static NotificationsFragment newInstance() { - NotificationsFragment fragment = new NotificationsFragment(); - Bundle arguments = new Bundle(); - fragment.setArguments(arguments); - return fragment; - } - - @Nullable - @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, - @Nullable Bundle savedInstanceState) { - requireActivity().addMenuProvider(this, getViewLifecycleOwner(), Lifecycle.State.RESUMED); - - binding = FragmentTimelineNotificationsBinding.inflate(inflater, container, false); - - @NonNull Context context = inflater.getContext(); // from inflater to silence warning - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(requireContext()); - - boolean showNotificationsFilterSetting = preferences.getBoolean("showNotificationsFilter", true); - // Clear notifications on filter visibility change to force refresh - if (showNotificationsFilterSetting != showNotificationsFilter) - notifications.clear(); - showNotificationsFilter = showNotificationsFilterSetting; - - // Setup the SwipeRefreshLayout. - binding.swipeRefreshLayout.setOnRefreshListener(this); - binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue); - - loadNotificationsFilter(); - - // Setup the RecyclerView. - binding.recyclerView.setHasFixedSize(true); - layoutManager = new LinearLayoutManager(context); - binding.recyclerView.setLayoutManager(layoutManager); - binding.recyclerView.setAccessibilityDelegateCompat( - new ListStatusAccessibilityDelegate(binding.recyclerView, this, (pos) -> { - NotificationViewData notification = notifications.getPairedItemOrNull(pos); - // We support replies only for now - if (notification instanceof NotificationViewData.Concrete) { - return ((NotificationViewData.Concrete) notification).getStatusViewData(); - } else { - return null; - } - })); - - binding.recyclerView.addItemDecoration(new DividerItemDecoration(context, DividerItemDecoration.VERTICAL)); - - StatusDisplayOptions statusDisplayOptions = new StatusDisplayOptions( - preferences.getBoolean("animateGifAvatars", false), - accountManager.getActiveAccount().getMediaPreviewEnabled(), - preferences.getBoolean("absoluteTimeView", false), - preferences.getBoolean("showBotOverlay", true), - preferences.getBoolean("useBlurhash", true), - CardViewMode.NONE, - preferences.getBoolean("confirmReblogs", true), - preferences.getBoolean("confirmFavourites", false), - preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), - preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), - preferences.getBoolean(PrefKeys.SHOW_STATS_INLINE, false), - accountManager.getActiveAccount().getAlwaysShowSensitiveMedia(), - accountManager.getActiveAccount().getAlwaysOpenSpoiler() - ); - - adapter = new NotificationsAdapter(accountManager.getActiveAccount().getAccountId(), - dataSource, statusDisplayOptions, this, this, this); - alwaysShowSensitiveMedia = accountManager.getActiveAccount().getAlwaysShowSensitiveMedia(); - alwaysOpenSpoiler = accountManager.getActiveAccount().getAlwaysOpenSpoiler(); - binding.recyclerView.setAdapter(adapter); - - topLoading = false; - bottomLoading = false; - bottomId = null; - - updateAdapter(); - - binding.buttonClear.setOnClickListener(v -> confirmClearNotifications()); - binding.buttonFilter.setOnClickListener(v -> showFilterMenu()); - - if (notifications.isEmpty()) { - binding.swipeRefreshLayout.setEnabled(false); - sendFetchNotificationsRequest(null, null, FetchEnd.BOTTOM, -1); - } else { - binding.progressBar.setVisibility(View.GONE); - } - - ((SimpleItemAnimator) binding.recyclerView.getItemAnimator()).setSupportsChangeAnimations(false); - - updateFilterVisibility(); - - return binding.getRoot(); - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - binding = null; - } - - @Override - public void onCreateMenu(@NonNull Menu menu, @NonNull MenuInflater menuInflater) { - menuInflater.inflate(R.menu.fragment_notifications, menu); - } - - @Override - public boolean onMenuItemSelected(@NonNull MenuItem menuItem) { - if (menuItem.getItemId() == R.id.action_refresh) { - binding.swipeRefreshLayout.setRefreshing(true); - onRefresh(); - return true; - } else if (menuItem.getItemId() == R.id.action_edit_notification_filter) { - showFilterMenu(); - return true; - } else if (menuItem.getItemId() == R.id.action_clear_notifications) { - confirmClearNotifications(); - return true; - } - - return false; - } - - private void updateFilterVisibility() { - CoordinatorLayout.LayoutParams params = - (CoordinatorLayout.LayoutParams) binding.swipeRefreshLayout.getLayoutParams(); - if (showNotificationsFilter && !showingError) { - binding.appBarOptions.setExpanded(true, false); - binding.appBarOptions.setVisibility(View.VISIBLE); - // Set content behaviour to hide filter on scroll - params.setBehavior(new AppBarLayout.ScrollingViewBehavior()); - } else { - binding.appBarOptions.setExpanded(false, false); - binding.appBarOptions.setVisibility(View.GONE); - // Clear behaviour to hide app bar - params.setBehavior(null); - } - } - - private void confirmClearNotifications() { - new AlertDialog.Builder(requireContext()) - .setMessage(R.string.notification_clear_text) - .setPositiveButton(android.R.string.ok, (DialogInterface dia, int which) -> clearNotifications()) - .setNegativeButton(android.R.string.cancel, null) - .show(); - } - - @Override - public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - Activity activity = getActivity(); - if (activity == null) throw new AssertionError("Activity is null"); - - // This is delayed until onActivityCreated solely because MainActivity.composeButton - // isn't guaranteed to be set until then. - // Use a modified scroll listener that both loads more notificationsEnabled as it - // goes, and hides the compose button on down-scroll. - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(activity); - hideFab = preferences.getBoolean("fabHide", false); - scrollListener = new EndlessOnScrollListener(layoutManager) { - @Override - public void onScrolled(@NonNull RecyclerView view, int dx, int dy) { - super.onScrolled(view, dx, dy); - - ActionButtonActivity activity = (ActionButtonActivity) getActivity(); - FloatingActionButton composeButton = activity.getActionButton(); - - if (composeButton != null) { - if (hideFab) { - if (dy > 0 && composeButton.isShown()) { - composeButton.hide(); // Hides the button if we're scrolling down - } else if (dy < 0 && !composeButton.isShown()) { - composeButton.show(); // Shows it if we are scrolling up - } - } else if (!composeButton.isShown()) { - composeButton.show(); - } - } - } - - @Override - public void onLoadMore(int totalItemsCount, @NonNull RecyclerView view) { - NotificationsFragment.this.onLoadMore(); - } - }; - - binding.recyclerView.addOnScrollListener(scrollListener); - - eventHub.subscribe( - getViewLifecycleOwner(), - event -> { - if (event instanceof StatusChangedEvent) { - Status updatedStatus = ((StatusChangedEvent) event).getStatus(); - updateStatus(updatedStatus.getActionableId(), s -> updatedStatus); - } else if (event instanceof BlockEvent) { - removeAllByAccountId(((BlockEvent) event).getAccountId()); - } else if (event instanceof PreferenceChangedEvent) { - onPreferenceChanged(((PreferenceChangedEvent) event).getPreferenceKey()); - } - } - ); - - RelativeTimeUpdater.updateRelativeTimePeriodically(this, this::updateAdapter); - } - - @Override - public void onRefresh() { - binding.statusView.setVisibility(View.GONE); - this.showingError = false; - Either first = CollectionsKt.firstOrNull(this.notifications); - String topId; - if (first != null && first.isRight()) { - topId = first.asRight().getId(); - } else { - topId = null; - } - 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()); - } - - @Override - public void onReblog(final boolean reblog, final int position) { - final Notification notification = notifications.get(position).asRight(); - final Status status = notification.getStatus(); - Objects.requireNonNull(status, "Reblog on notification without status"); - timelineCases.reblogOld(status.getId(), reblog) - .subscribe( - getViewLifecycleOwner(), - (newStatus) -> setReblogForStatus(status.getId(), reblog), - (t) -> Log.d(getClass().getSimpleName(), - "Failed to reblog status: " + status.getId(), t) - ); - } - - private void setReblogForStatus(String statusId, boolean reblog) { - updateStatus(statusId, (s) -> s.copyWithReblogged(reblog)); - } - - @Override - public void onFavourite(final boolean favourite, final int position) { - final Notification notification = notifications.get(position).asRight(); - final Status status = notification.getStatus(); - - timelineCases.favouriteOld(status.getId(), favourite) - .subscribe( - getViewLifecycleOwner(), - (newStatus) -> setFavouriteForStatus(status.getId(), favourite), - (t) -> Log.d(getClass().getSimpleName(), - "Failed to favourite status: " + status.getId(), t) - ); - } - - private void setFavouriteForStatus(String statusId, boolean favourite) { - updateStatus(statusId, (s) -> s.copyWithFavourited(favourite)); - } - - @Override - public void onBookmark(final boolean bookmark, final int position) { - final Notification notification = notifications.get(position).asRight(); - final Status status = notification.getStatus(); - - timelineCases.bookmarkOld(status.getActionableId(), bookmark) - .subscribe( - getViewLifecycleOwner(), - (newStatus) -> setBookmarkForStatus(status.getId(), bookmark), - (t) -> Log.d(getClass().getSimpleName(), - "Failed to bookmark status: " + status.getId(), t) - ); - } - - private void setBookmarkForStatus(String statusId, boolean bookmark) { - updateStatus(statusId, (s) -> s.copyWithBookmarked(bookmark)); - } - - public void onVoteInPoll(int position, @NonNull List choices) { - final Notification notification = notifications.get(position).asRight(); - final Status status = notification.getStatus().getActionableStatus(); - timelineCases.voteInPollOld(status.getId(), status.getPoll().getId(), choices) - .subscribe( - getViewLifecycleOwner(), - (newPoll) -> setVoteForPoll(status, newPoll), - (t) -> Log.d(TAG, "Failed to vote in poll: " + status.getId(), t) - ); - } - - @Override - public void clearWarningAction(int position) { - - } - - private void setVoteForPoll(Status status, Poll poll) { - updateStatus(status.getId(), (s) -> s.copyWithPoll(poll)); - } - - @Override - public void onMore(@NonNull View view, int position) { - Notification notification = notifications.get(position).asRight(); - super.more(notification.getStatus(), view, position, null); - } - - @Override - public void onViewMedia(int position, int attachmentIndex, @Nullable View view) { - Notification notification = notifications.get(position).asRightOrNull(); - if (notification == null || notification.getStatus() == null) return; - Status status = notification.getStatus(); - super.viewMedia(attachmentIndex, AttachmentViewData.list(status, accountManager.getActiveAccount().getAlwaysShowSensitiveMedia()), view); - } - - @Override - public void onViewThread(int position) { - Notification notification = notifications.get(position).asRight(); - Status status = notification.getStatus(); - if (status == null) return; - super.viewThread(status.getActionableId(), status.getActionableStatus().getUrl()); - } - - @Override - public void onOpenReblog(int position) { - Notification notification = notifications.get(position).asRight(); - onViewAccount(notification.getAccount().getId()); - } - - @Override - public void onExpandedChange(boolean expanded, int position) { - updateViewDataAt(position, (vd) -> vd.copyWithExpanded(expanded)); - } - - @Override - public void onContentHiddenChange(boolean isShowing, int position) { - updateViewDataAt(position, (vd) -> vd.copyWithShowingContent(isShowing)); - } - - @Override - public void onLoadMore(int position) { - // Check bounds before accessing list, - if (notifications.size() >= position && position > 0) { - Notification previous = notifications.get(position - 1).asRightOrNull(); - Notification next = notifications.get(position + 1).asRightOrNull(); - if (previous == null || next == null) { - Log.e(TAG, "Failed to load more, invalid placeholder position: " + position); - return; - } - sendFetchNotificationsRequest(previous.getId(), next.getId(), FetchEnd.MIDDLE, position); - Placeholder placeholder = notifications.get(position).asLeft(); - NotificationViewData notificationViewData = - new NotificationViewData.Placeholder(placeholder.id, true); - notifications.setPairedItem(position, notificationViewData); - updateAdapter(); - } else { - Log.d(TAG, "error loading more"); - } - } - - @Override - public void onContentCollapsedChange(boolean isCollapsed, int position) { - updateViewDataAt(position, (vd) -> vd.copyWithCollapsed(isCollapsed)); - } - - @Override - public void onUntranslate(int position) { - // not needed - } - - private void updateStatus(String statusId, Function mapper) { - int index = CollectionsKt.indexOfFirst(this.notifications, (s) -> s.isRight() && - s.asRight().getStatus() != null && - s.asRight().getStatus().getId().equals(statusId)); - if (index == -1) return; - - // We have quite some graph here: - // - // Notification --------> Status - // ^ - // | - // StatusViewData - // ^ - // | - // NotificationViewData -----+ - // - // So if we have "new" status we need to update all references to be sure that data is - // up-to-date: - // 1. update status - // 2. update notification - // 3. update statusViewData - // 4. update notificationViewData - - Status oldStatus = notifications.get(index).asRight().getStatus(); - NotificationViewData.Concrete oldViewData = - (NotificationViewData.Concrete) this.notifications.getPairedItem(index); - Status newStatus = mapper.apply(oldStatus); - Notification newNotification = this.notifications.get(index).asRight() - .copyWithStatus(newStatus); - StatusViewData.Concrete newStatusViewData = - Objects.requireNonNull(oldViewData.getStatusViewData()).copyWithStatus(newStatus); - NotificationViewData.Concrete newViewData = oldViewData.copyWithStatus(newStatusViewData); - - notifications.set(index, new Either.Right<>(newNotification)); - notifications.setPairedItem(index, newViewData); - - updateAdapter(); - } - - private void updateViewDataAt(int position, - Function mapper) { - if (position < 0 || position >= notifications.size()) { - String message = String.format( - Locale.getDefault(), - "Tried to access out of bounds status position: %d of %d", - position, - notifications.size() - 1 - ); - Log.e(TAG, message); - return; - } - NotificationViewData someViewData = this.notifications.getPairedItem(position); - if (!(someViewData instanceof NotificationViewData.Concrete)) { - return; - } - NotificationViewData.Concrete oldViewData = (NotificationViewData.Concrete) someViewData; - StatusViewData.Concrete oldStatusViewData = oldViewData.getStatusViewData(); - if (oldStatusViewData == null) return; - - NotificationViewData.Concrete newViewData = - oldViewData.copyWithStatus(mapper.apply(oldStatusViewData)); - notifications.setPairedItem(position, newViewData); - - updateAdapter(); - } - - @Override - public void onNotificationContentCollapsedChange(boolean isCollapsed, int position) { - onContentCollapsedChange(isCollapsed, position); - } - - private void clearNotifications() { - // Cancel all ongoing requests - binding.swipeRefreshLayout.setRefreshing(false); - resetNotificationsLoad(); - - // Show friend elephant - binding.statusView.setVisibility(View.VISIBLE); - binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null); - updateFilterVisibility(); - - // Update adapter - updateAdapter(); - - // Execute clear notifications request - timelineCases.clearNotificationsOld() - .subscribe( - getViewLifecycleOwner(), - response -> { - // Nothing to do - }, - throwable -> { - // Reload notifications on failure - fullyRefreshWithProgressBar(true); - }); - } - - private void resetNotificationsLoad() { - for (Job job : jobs) { - job.cancel(null); - } - jobs.clear(); - bottomLoading = false; - topLoading = false; - - // Disable load more - bottomId = null; - - // Clear exists notifications - notifications.clear(); - } - - - private void showFilterMenu() { - List notificationsList = Notification.Type.Companion.getVisibleTypes(); - List list = new ArrayList<>(); - for (Notification.Type type : notificationsList) { - list.add(getNotificationText(type)); - } - - ArrayAdapter adapter = new ArrayAdapter<>(getContext(), android.R.layout.simple_list_item_multiple_choice, list); - PopupWindow window = new PopupWindow(getContext()); - View view = LayoutInflater.from(getContext()).inflate(R.layout.notifications_filter, (ViewGroup) getView(), false); - final ListView listView = view.findViewById(R.id.listView); - view.findViewById(R.id.buttonApply) - .setOnClickListener(v -> { - SparseBooleanArray checkedItems = listView.getCheckedItemPositions(); - Set excludes = new HashSet<>(); - for (int i = 0; i < notificationsList.size(); i++) { - if (!checkedItems.get(i, false)) - excludes.add(notificationsList.get(i)); - } - window.dismiss(); - applyFilterChanges(excludes); - - }); - - listView.setAdapter(adapter); - listView.setChoiceMode(ListView.CHOICE_MODE_MULTIPLE); - for (int i = 0; i < notificationsList.size(); i++) { - if (!notificationFilter.contains(notificationsList.get(i))) - listView.setItemChecked(i, true); - } - window.setContentView(view); - window.setFocusable(true); - window.setWidth(ViewGroup.LayoutParams.WRAP_CONTENT); - window.setHeight(ViewGroup.LayoutParams.WRAP_CONTENT); - window.showAsDropDown(binding.buttonFilter); - - } - - private String getNotificationText(Notification.Type type) { - switch (type) { - case MENTION: - return getString(R.string.notification_mention_name); - case FAVOURITE: - return getString(R.string.notification_favourite_name); - case REBLOG: - return getString(R.string.notification_boost_name); - case FOLLOW: - return getString(R.string.notification_follow_name); - case FOLLOW_REQUEST: - return getString(R.string.notification_follow_request_name); - case POLL: - return getString(R.string.notification_poll_name); - case STATUS: - return getString(R.string.notification_subscription_name); - case SIGN_UP: - return getString(R.string.notification_sign_up_name); - case UPDATE: - return getString(R.string.notification_update_name); - case REPORT: - return getString(R.string.notification_report_name); - default: - return "Unknown"; - } - } - - private void applyFilterChanges(Set newSet) { - List notifications = Notification.Type.Companion.getVisibleTypes(); - boolean isChanged = false; - for (Notification.Type type : notifications) { - if (notificationFilter.contains(type) && !newSet.contains(type)) { - notificationFilter.remove(type); - isChanged = true; - } else if (!notificationFilter.contains(type) && newSet.contains(type)) { - notificationFilter.add(type); - isChanged = true; - } - } - if (isChanged) { - saveNotificationsFilter(); - fullyRefreshWithProgressBar(true); - } - - } - - private void loadNotificationsFilter() { - AccountEntity account = accountManager.getActiveAccount(); - if (account != null) { - notificationFilter.clear(); - notificationFilter.addAll(NotificationTypeConverterKt.deserialize( - account.getNotificationsFilter())); - } - } - - private void saveNotificationsFilter() { - AccountEntity account = accountManager.getActiveAccount(); - if (account != null) { - account.setNotificationsFilter(NotificationTypeConverterKt.serialize(notificationFilter)); - accountManager.saveAccount(account); - } - } - - @Override - public void onViewTag(@NonNull String tag) { - super.viewTag(tag); - } - - @Override - public void onViewAccount(@NonNull String id) { - super.viewAccount(id); - } - - @Override - public void onMute(boolean mute, String id, int position, boolean notifications) { - // No muting from notifications yet - } - - @Override - public void onBlock(boolean block, String id, int position) { - // No blocking from notifications yet - } - - @Override - public void onRespondToFollowRequest(boolean accept, String id, int position) { - final Single request = accept ? - timelineCases.acceptFollowRequestOld(id) : - timelineCases.rejectFollowRequestOld(id); - request.subscribe( - getViewLifecycleOwner(), - (relationship) -> fullyRefreshWithProgressBar(true), - (error) -> Log.e(TAG, String.format("Failed to %s account id %s", accept ? "accept" : "reject", id)) - ); - } - - @Override - public void onViewStatusForNotificationId(String notificationId) { - for (Either either : notifications) { - Notification notification = either.asRightOrNull(); - if (notification != null && notification.getId().equals(notificationId)) { - Status status = notification.getStatus(); - if (status != null) { - super.viewThread(status.getActionableId(), status.getActionableStatus().getUrl()); - return; - } - } - } - Log.w(TAG, "Didn't find a notification for ID: " + notificationId); - } - - @Override - public void onViewReport(String reportId) { - LinkHelper.openLink(requireContext(), String.format("https://%s/admin/reports/%s", accountManager.getActiveAccount().getDomain(), reportId)); - } - - private void onPreferenceChanged(String key) { - switch (key) { - case "fabHide": { - hideFab = PreferenceManager.getDefaultSharedPreferences(requireContext()).getBoolean("fabHide", false); - break; - } - case "mediaPreviewEnabled": { - boolean enabled = accountManager.getActiveAccount().getMediaPreviewEnabled(); - if (enabled != adapter.isMediaPreviewEnabled()) { - adapter.setMediaPreviewEnabled(enabled); - fullyRefresh(); - } - break; - } - case "showNotificationsFilter": { - if (isAdded()) { - showNotificationsFilter = PreferenceManager.getDefaultSharedPreferences(requireContext()).getBoolean("showNotificationsFilter", true); - updateFilterVisibility(); - fullyRefreshWithProgressBar(true); - } - break; - } - } - } - - @Override - public void removeItem(int position) { - notifications.remove(position); - updateAdapter(); - } - - private void removeAllByAccountId(String accountId) { - // Using iterator to safely remove items while iterating - Iterator> iterator = notifications.iterator(); - while (iterator.hasNext()) { - Either notification = iterator.next(); - Notification maybeNotification = notification.asRightOrNull(); - if (maybeNotification != null && maybeNotification.getAccount().getId().equals(accountId)) { - iterator.remove(); - } - } - updateAdapter(); - } - - private void onLoadMore() { - if (bottomId == null) { - // Already loaded everything - return; - } - - // Check for out-of-bounds when loading - // This is required to allow full-timeline reloads of collapsible statuses when the settings - // change. - if (notifications.size() > 0) { - Either last = notifications.get(notifications.size() - 1); - if (last.isRight()) { - final Placeholder placeholder = newPlaceholder(); - notifications.add(new Either.Left<>(placeholder)); - NotificationViewData viewData = - new NotificationViewData.Placeholder(placeholder.id, true); - notifications.setPairedItem(notifications.size() - 1, viewData); - updateAdapter(); - } - } - - sendFetchNotificationsRequest(bottomId, null, FetchEnd.BOTTOM, -1); - } - - private Placeholder newPlaceholder() { - Placeholder placeholder = Placeholder.getInstance(maxPlaceholderId); - maxPlaceholderId--; - return placeholder; - } - - private void jumpToTop() { - if (isAdded()) { - //binding.appBarOptions.setExpanded(true, false); - layoutManager.scrollToPosition(0); - scrollListener.reset(); - } - } - - private void sendFetchNotificationsRequest(String fromId, String uptoId, - final FetchEnd fetchEnd, final int pos) { - // If there is a fetch already ongoing, record however many fetches are requested and - // fulfill them after it's complete. - if (fetchEnd == FetchEnd.TOP && topLoading) { - return; - } - if (fetchEnd == FetchEnd.BOTTOM && bottomLoading) { - return; - } - if (fetchEnd == FetchEnd.TOP) { - topLoading = true; - } - if (fetchEnd == FetchEnd.BOTTOM) { - bottomLoading = true; - } - - Job notificationCall = timelineCases.notificationsOld(fromId, uptoId, LOAD_AT_ONCE, showNotificationsFilter ? notificationFilter : null) - .subscribe( - getViewLifecycleOwner(), - response -> { - if (response.isSuccessful()) { - String linkHeader = response.headers().get("Link"); - onFetchNotificationsSuccess(response.body(), linkHeader, fetchEnd, pos); - } else { - onFetchNotificationsFailure(new Exception(response.message()), fetchEnd, pos); - } - }, - throwable -> onFetchNotificationsFailure(throwable, fetchEnd, pos) - ); - jobs.add(notificationCall); - } - - private void onFetchNotificationsSuccess(List notifications, String linkHeader, - FetchEnd fetchEnd, int pos) { - List links = HttpHeaderLink.Companion.parse(linkHeader); - HttpHeaderLink next = HttpHeaderLink.Companion.findByRelationType(links, "next"); - String fromId = null; - if (next != null) { - fromId = next.getUri().getQueryParameter("max_id"); - } - - switch (fetchEnd) { - case TOP: { - update(notifications, this.notifications.isEmpty() ? fromId : null); - break; - } - case MIDDLE: { - replacePlaceholderWithNotifications(notifications, pos); - break; - } - case BOTTOM: { - - if (!this.notifications.isEmpty() - && !this.notifications.get(this.notifications.size() - 1).isRight()) { - this.notifications.remove(this.notifications.size() - 1); - updateAdapter(); - } - - if (adapter.getItemCount() > 1) { - addItems(notifications, fromId); - } else { - update(notifications, fromId); - } - - break; - } - } - - saveNewestNotificationId(notifications); - - if (fetchEnd == FetchEnd.TOP) { - topLoading = false; - } - if (fetchEnd == FetchEnd.BOTTOM) { - bottomLoading = false; - } - - if (notifications.size() == 0 && adapter.getItemCount() == 0) { - binding.statusView.setVisibility(View.VISIBLE); - binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null); - } - - updateFilterVisibility(); - binding.swipeRefreshLayout.setEnabled(true); - binding.swipeRefreshLayout.setRefreshing(false); - binding.progressBar.setVisibility(View.GONE); - } - - private void onFetchNotificationsFailure(Throwable throwable, FetchEnd fetchEnd, int position) { - binding.swipeRefreshLayout.setRefreshing(false); - if (fetchEnd == FetchEnd.MIDDLE && !notifications.get(position).isRight()) { - Placeholder placeholder = notifications.get(position).asLeft(); - NotificationViewData placeholderVD = - new NotificationViewData.Placeholder(placeholder.id, false); - notifications.setPairedItem(position, placeholderVD); - updateAdapter(); - } else if (this.notifications.isEmpty()) { - binding.statusView.setVisibility(View.VISIBLE); - binding.swipeRefreshLayout.setEnabled(false); - this.showingError = true; - if (throwable instanceof IOException) { - binding.statusView.setup(R.drawable.errorphant_offline, R.string.error_network, __ -> { - binding.progressBar.setVisibility(View.VISIBLE); - this.onRefresh(); - return Unit.INSTANCE; - }); - } else { - binding.statusView.setup(R.drawable.errorphant_error, R.string.error_generic, __ -> { - binding.progressBar.setVisibility(View.VISIBLE); - this.onRefresh(); - return Unit.INSTANCE; - }); - } - updateFilterVisibility(); - } - Log.e(TAG, "Fetch failure: " + throwable.getMessage()); - - if (fetchEnd == FetchEnd.TOP) { - topLoading = false; - } - if (fetchEnd == FetchEnd.BOTTOM) { - bottomLoading = false; - } - - binding.progressBar.setVisibility(View.GONE); - } - - private void saveNewestNotificationId(List notifications) { - - AccountEntity account = accountManager.getActiveAccount(); - if (account != null) { - String lastNotificationId = account.getLastNotificationId(); - - for (Notification noti : notifications) { - if (isLessThan(lastNotificationId, noti.getId())) { - lastNotificationId = noti.getId(); - } - } - - if (!account.getLastNotificationId().equals(lastNotificationId)) { - Log.d(TAG, "saving newest noti id: " + lastNotificationId); - account.setLastNotificationId(lastNotificationId); - accountManager.saveAccount(account); - } - } - } - - private void update(@Nullable List newNotifications, @Nullable String fromId) { - if (ListUtils.isEmpty(newNotifications)) { - updateAdapter(); - return; - } - if (fromId != null) { - bottomId = fromId; - } - List> liftedNew = - liftNotificationList(newNotifications); - if (notifications.isEmpty()) { - notifications.addAll(liftedNew); - } else { - int index = notifications.indexOf(liftedNew.get(newNotifications.size() - 1)); - if (index > 0) { - notifications.subList(0, index).clear(); - } - - int newIndex = liftedNew.indexOf(notifications.get(0)); - if (newIndex == -1) { - if (index == -1 && liftedNew.size() >= LOAD_AT_ONCE) { - liftedNew.add(new Either.Left<>(newPlaceholder())); - } - notifications.addAll(0, liftedNew); - } else { - notifications.addAll(0, liftedNew.subList(0, newIndex)); - } - } - updateAdapter(); - } - - private void addItems(List newNotifications, @Nullable String fromId) { - bottomId = fromId; - if (ListUtils.isEmpty(newNotifications)) { - return; - } - int end = notifications.size(); - List> liftedNew = liftNotificationList(newNotifications); - Either last = notifications.get(end - 1); - if (last != null && !liftedNew.contains(last)) { - notifications.addAll(liftedNew); - updateAdapter(); - } - } - - private void replacePlaceholderWithNotifications(List newNotifications, int pos) { - // Remove placeholder - notifications.remove(pos); - - if (ListUtils.isEmpty(newNotifications)) { - updateAdapter(); - return; - } - - List> liftedNew = liftNotificationList(newNotifications); - - // If we fetched less posts than in the limit, it means that the hole is not filled - // If we fetched at least as much it means that there are more posts to load and we should - // insert new placeholder - if (newNotifications.size() >= LOAD_AT_ONCE) { - liftedNew.add(new Either.Left<>(newPlaceholder())); - } - - notifications.addAll(pos, liftedNew); - updateAdapter(); - } - - private final Function1> notificationLifter = - Either.Right::new; - - private List> liftNotificationList(List list) { - return CollectionsKt.map(list, notificationLifter); - } - - private void fullyRefreshWithProgressBar(boolean isShow) { - resetNotificationsLoad(); - if (isShow) { - binding.progressBar.setVisibility(View.VISIBLE); - binding.statusView.setVisibility(View.GONE); - } - updateAdapter(); - sendFetchNotificationsRequest(null, null, FetchEnd.TOP, -1); - } - - private void fullyRefresh() { - fullyRefreshWithProgressBar(false); - } - - @Nullable - private Pair findReplyPosition(@NonNull String statusId) { - for (int i = 0; i < notifications.size(); i++) { - Notification notification = notifications.get(i).asRightOrNull(); - if (notification != null - && notification.getStatus() != null - && notification.getType() == Notification.Type.MENTION - && (statusId.equals(notification.getStatus().getId()) - || (notification.getStatus().getReblog() != null - && statusId.equals(notification.getStatus().getReblog().getId())))) { - return new Pair<>(i, notification); - } - } - return null; - } - - private void updateAdapter() { - differ.submitList(notifications.getPairedCopy()); - } - - private final ListUpdateCallback listUpdateCallback = new ListUpdateCallback() { - @Override - public void onInserted(int position, int count) { - if (isAdded()) { - adapter.notifyItemRangeInserted(position, count); - Context context = getContext(); - // scroll up when new items at the top are loaded while being at the start - // https://github.com/tuskyapp/Tusky/pull/1905#issuecomment-677819724 - if (position == 0 && context != null && adapter.getItemCount() != count) { - binding.recyclerView.scrollBy(0, Utils.dpToPx(context, -30)); - } - } - } - - @Override - public void onRemoved(int position, int count) { - adapter.notifyItemRangeRemoved(position, count); - } - - @Override - public void onMoved(int fromPosition, int toPosition) { - adapter.notifyItemMoved(fromPosition, toPosition); - } - - @Override - public void onChanged(int position, int count, Object payload) { - adapter.notifyItemRangeChanged(position, count, payload); - } - }; - - private final AsyncListDiffer - differ = new AsyncListDiffer<>(listUpdateCallback, - new AsyncDifferConfig.Builder<>(diffCallback).build()); - - private final NotificationsAdapter.AdapterDataSource dataSource = - new NotificationsAdapter.AdapterDataSource<>() { - @Override - public int getItemCount() { - return differ.getCurrentList().size(); - } - - @Override - public NotificationViewData getItemAt(int pos) { - return differ.getCurrentList().get(pos); - } - }; - - private static final DiffUtil.ItemCallback diffCallback - = new DiffUtil.ItemCallback<>() { - - @Override - public boolean areItemsTheSame(NotificationViewData oldItem, NotificationViewData newItem) { - return oldItem.getViewDataId() == newItem.getViewDataId(); - } - - @Override - public boolean areContentsTheSame(@NonNull NotificationViewData oldItem, @NonNull NotificationViewData newItem) { - return false; - } - - @Nullable - @Override - public Object getChangePayload(@NonNull NotificationViewData oldItem, @NonNull NotificationViewData newItem) { - if (oldItem.deepEquals(newItem)) { - // If items are equal - update timestamp only - return Collections.singletonList(StatusBaseViewHolder.Key.KEY_CREATED); - } else - // If items are different - update a whole view holder - return null; - } - }; - - @Override - public void onResume() { - super.onResume(); - - NotificationHelper.clearNotificationsForAccount(requireContext(), accountManager.getActiveAccount()); - - String rawAccountNotificationFilter = accountManager.getActiveAccount().getNotificationsFilter(); - Set accountNotificationFilter = NotificationTypeConverterKt.deserialize(rawAccountNotificationFilter); - if (!notificationFilter.equals(accountNotificationFilter)) { - loadNotificationsFilter(); - fullyRefreshWithProgressBar(true); - } - } - - @Override - public void onReselect() { - jumpToTop(); - } -} 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 110a99e95..3662c4a11 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.kt @@ -48,8 +48,8 @@ import com.keylesspalace.tusky.components.compose.ComposeActivity.Companion.star 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.db.entity.AccountEntity import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Status diff --git a/app/src/main/java/com/keylesspalace/tusky/interfaces/AccountSelectionListener.kt b/app/src/main/java/com/keylesspalace/tusky/interfaces/AccountSelectionListener.kt index b86c55c76..272a16aa4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/interfaces/AccountSelectionListener.kt +++ b/app/src/main/java/com/keylesspalace/tusky/interfaces/AccountSelectionListener.kt @@ -15,7 +15,7 @@ package com.keylesspalace.tusky.interfaces -import com.keylesspalace.tusky.db.AccountEntity +import com.keylesspalace.tusky.db.entity.AccountEntity interface AccountSelectionListener { fun onAccountSelected(account: AccountEntity) 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 291f548dd..9ad49f5df 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -137,15 +137,18 @@ interface MastodonApi { ): Response> @GET("api/v1/notifications") + @Throws(Exception::class) suspend fun notifications( /** Return results older than this ID */ - @Query("max_id") maxId: String?, + @Query("max_id") maxId: String? = null, /** Return results newer than this ID */ - @Query("since_id") sinceId: String?, + @Query("since_id") sinceId: String? = null, + /** Return results immediately newer than this ID */ + @Query("min_id") minId: String? = null, /** Maximum number of results to return. Defaults to 15, max is 30 */ - @Query("limit") limit: Int?, + @Query("limit") limit: Int? = null, /** Types to excludes from the results */ - @Query("exclude_types[]") excludes: Set? + @Query("exclude_types[]") excludes: Set? = null ): Response> /** Fetch a single notification */ diff --git a/app/src/main/java/com/keylesspalace/tusky/receiver/NotificationBlockStateBroadcastReceiver.kt b/app/src/main/java/com/keylesspalace/tusky/receiver/NotificationBlockStateBroadcastReceiver.kt index a2656d0f1..c481a9aa8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/receiver/NotificationBlockStateBroadcastReceiver.kt +++ b/app/src/main/java/com/keylesspalace/tusky/receiver/NotificationBlockStateBroadcastReceiver.kt @@ -20,9 +20,9 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.os.Build -import com.keylesspalace.tusky.components.notifications.canEnablePushNotifications -import com.keylesspalace.tusky.components.notifications.isUnifiedPushNotificationEnabledForAccount -import com.keylesspalace.tusky.components.notifications.updateUnifiedPushSubscription +import com.keylesspalace.tusky.components.systemnotifications.canEnablePushNotifications +import com.keylesspalace.tusky.components.systemnotifications.isUnifiedPushNotificationEnabledForAccount +import com.keylesspalace.tusky.components.systemnotifications.updateUnifiedPushSubscription import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.di.ApplicationScope import com.keylesspalace.tusky.network.MastodonApi diff --git a/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt b/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt index 9777867e4..098400199 100644 --- a/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt +++ b/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt @@ -24,7 +24,7 @@ import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.app.RemoteInput import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.components.notifications.NotificationHelper +import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.service.SendStatusService diff --git a/app/src/main/java/com/keylesspalace/tusky/receiver/UnifiedPushBroadcastReceiver.kt b/app/src/main/java/com/keylesspalace/tusky/receiver/UnifiedPushBroadcastReceiver.kt index 40fe48438..0e2a011d5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/receiver/UnifiedPushBroadcastReceiver.kt +++ b/app/src/main/java/com/keylesspalace/tusky/receiver/UnifiedPushBroadcastReceiver.kt @@ -20,8 +20,8 @@ import android.content.Intent import android.util.Log import androidx.work.OneTimeWorkRequest import androidx.work.WorkManager -import com.keylesspalace.tusky.components.notifications.registerUnifiedPushEndpoint -import com.keylesspalace.tusky.components.notifications.unregisterUnifiedPushEndpoint +import com.keylesspalace.tusky.components.systemnotifications.registerUnifiedPushEndpoint +import com.keylesspalace.tusky.components.systemnotifications.unregisterUnifiedPushEndpoint import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.di.ApplicationScope import com.keylesspalace.tusky.network.MastodonApi diff --git a/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt b/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt index 84f5c5981..5f463e6bf 100644 --- a/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt +++ b/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt @@ -42,7 +42,7 @@ import com.keylesspalace.tusky.appstore.StatusScheduledEvent import com.keylesspalace.tusky.components.compose.MediaUploader import com.keylesspalace.tusky.components.compose.UploadEvent import com.keylesspalace.tusky.components.drafts.DraftHelper -import com.keylesspalace.tusky.components.notifications.NotificationHelper +import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.entity.Attachment diff --git a/app/src/main/java/com/keylesspalace/tusky/settings/AccountPreferenceDataStore.kt b/app/src/main/java/com/keylesspalace/tusky/settings/AccountPreferenceDataStore.kt index 479b43310..d62731f01 100644 --- a/app/src/main/java/com/keylesspalace/tusky/settings/AccountPreferenceDataStore.kt +++ b/app/src/main/java/com/keylesspalace/tusky/settings/AccountPreferenceDataStore.kt @@ -3,8 +3,8 @@ package com.keylesspalace.tusky.settings import androidx.preference.PreferenceDataStore import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.PreferenceChangedEvent -import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.db.entity.AccountEntity import com.keylesspalace.tusky.di.ApplicationScope import javax.inject.Inject import kotlinx.coroutines.CoroutineScope diff --git a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt index 6005c9600..0760d1147 100644 --- a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt +++ b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt @@ -62,13 +62,13 @@ object PrefKeys { const val READING_ORDER = "readingOrder" const val MAIN_NAV_POSITION = "mainNavPosition" const val HIDE_TOP_TOOLBAR = "hideTopToolbar" + const val SHOW_NOTIFICATIONS_FILTER = "showNotificationsFilter" const val ABSOLUTE_TIME_VIEW = "absoluteTimeView" const val SHOW_BOT_OVERLAY = "showBotOverlay" const val ANIMATE_GIF_AVATARS = "animateGifAvatars" const val USE_BLURHASH = "useBlurhash" const val SHOW_SELF_USERNAME = "showSelfUsername" const val SHOW_CARDS_IN_TIMELINES = "showCardsInTimelines" - const val SHOW_NOTIFICATIONS_FILTER = "showNotificationsFilter" const val CONFIRM_REBLOGS = "confirmReblogs" const val CONFIRM_FAVOURITES = "confirmFavourites" const val ENABLE_SWIPE_FOR_TABS = "enableSwipeForTabs" @@ -111,9 +111,4 @@ object PrefKeys { /** UI text scaling factor, stored as float, 100 = 100% = no scaling */ const val UI_TEXT_SCALE_RATIO = "uiTextScaleRatio" - - /** Keys that are no longer used (e.g., the preference has been removed */ - object Deprecated { - const val SHOW_NOTIFICATIONS_FILTER = "showNotificationsFilter" - } } diff --git a/app/src/main/java/com/keylesspalace/tusky/usecase/DeveloperToolsUseCase.kt b/app/src/main/java/com/keylesspalace/tusky/usecase/DeveloperToolsUseCase.kt index 8724718a8..f2d0d3645 100644 --- a/app/src/main/java/com/keylesspalace/tusky/usecase/DeveloperToolsUseCase.kt +++ b/app/src/main/java/com/keylesspalace/tusky/usecase/DeveloperToolsUseCase.kt @@ -3,7 +3,7 @@ package com.keylesspalace.tusky.usecase import android.util.Log import androidx.room.withTransaction import com.keylesspalace.tusky.db.AppDatabase -import com.keylesspalace.tusky.db.TimelineDao +import com.keylesspalace.tusky.db.dao.TimelineDao import javax.inject.Inject /** @@ -25,18 +25,18 @@ class DeveloperToolsUseCase @Inject constructor( */ suspend fun createLoadMoreGap(accountId: Long) { db.withTransaction { - val ids = timelineDao.getMostRecentNStatusIds(accountId, 10) + val ids = timelineDao.getMostRecentNHomeTimelineIds(accountId, 10) val maxId = ids[2] val minId = ids[8] val placeHolderId = ids[9] Log.d( - "TAG", + TAG, "createLoadMoreGap: creating gap between $minId .. $maxId (new placeholder: $placeHolderId" ) timelineDao.deleteRange(accountId, minId, maxId) - timelineDao.convertStatustoPlaceholder(placeHolderId) + timelineDao.convertHomeTimelineItemToPlaceholder(placeHolderId) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/usecase/LogoutUsecase.kt b/app/src/main/java/com/keylesspalace/tusky/usecase/LogoutUsecase.kt index 1bcab4179..11f8937b5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/usecase/LogoutUsecase.kt +++ b/app/src/main/java/com/keylesspalace/tusky/usecase/LogoutUsecase.kt @@ -2,10 +2,10 @@ package com.keylesspalace.tusky.usecase import android.content.Context import com.keylesspalace.tusky.components.drafts.DraftHelper -import com.keylesspalace.tusky.components.notifications.NotificationHelper -import com.keylesspalace.tusky.components.notifications.disableUnifiedPushNotificationsForAccount +import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper +import com.keylesspalace.tusky.components.systemnotifications.disableUnifiedPushNotificationsForAccount import com.keylesspalace.tusky.db.AccountManager -import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.db.DatabaseCleaner import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.ShareShortcutHelper import javax.inject.Inject @@ -13,7 +13,7 @@ import javax.inject.Inject class LogoutUsecase @Inject constructor( private val context: Context, private val api: MastodonApi, - private val db: AppDatabase, + private val databaseCleaner: DatabaseCleaner, private val accountManager: AccountManager, private val draftHelper: DraftHelper, private val shareShortcutHelper: ShareShortcutHelper @@ -53,8 +53,7 @@ class LogoutUsecase @Inject constructor( val otherAccountAvailable = accountManager.logActiveAccountOut() != null // clear the database - this could trigger network calls so do it last when all tokens are gone - db.timelineDao().removeAll(activeAccount.id) - db.conversationDao().deleteForAccount(activeAccount.id) + databaseCleaner.cleanupEverything(activeAccount.id) draftHelper.deleteAllDraftsAndAttachmentsForAccount(activeAccount.id) // remove shortcut associated with the account 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 24afc0637..cb12229b2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/usecase/TimelineCases.kt +++ b/app/src/main/java/com/keylesspalace/tusky/usecase/TimelineCases.kt @@ -20,7 +20,6 @@ import at.connyduck.calladapter.networkresult.NetworkResult import at.connyduck.calladapter.networkresult.fold import at.connyduck.calladapter.networkresult.onFailure import at.connyduck.calladapter.networkresult.onSuccess -import at.connyduck.calladapter.networkresult.runCatching import com.keylesspalace.tusky.appstore.BlockEvent import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.MuteConversationEvent @@ -29,17 +28,13 @@ import com.keylesspalace.tusky.appstore.PollVoteEvent import com.keylesspalace.tusky.appstore.StatusChangedEvent import com.keylesspalace.tusky.appstore.StatusDeletedEvent import com.keylesspalace.tusky.entity.DeletedStatus -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 retrofit2.Response /** * Created by charlag on 3/24/18. @@ -66,10 +61,6 @@ class TimelineCases @Inject constructor( } } - fun reblogOld(statusId: String, reblog: Boolean): Single { - return Single { reblog(statusId, reblog) } - } - suspend fun favourite(statusId: String, favourite: Boolean): NetworkResult { return if (favourite) { mastodonApi.favouriteStatus(statusId) @@ -80,10 +71,6 @@ class TimelineCases @Inject constructor( } } - fun favouriteOld(statusId: String, favourite: Boolean): Single { - return Single { favourite(statusId, favourite) } - } - suspend fun bookmark(statusId: String, bookmark: Boolean): NetworkResult { return if (bookmark) { mastodonApi.bookmarkStatus(statusId) @@ -94,10 +81,6 @@ class TimelineCases @Inject constructor( } } - fun bookmarkOld(statusId: String, bookmark: Boolean): Single { - return Single { bookmark(statusId, bookmark) } - } - suspend fun muteConversation(statusId: String, mute: Boolean): NetworkResult { return if (mute) { mastodonApi.muteConversation(statusId) @@ -160,31 +143,6 @@ class TimelineCases @Inject constructor( } } - fun voteInPollOld(statusId: String, pollId: String, choices: List): Single { - return Single { voteInPoll(statusId, pollId, choices) } - } - - fun acceptFollowRequestOld(accountId: String): Single { - return Single { mastodonApi.authorizeFollowRequest(accountId) } - } - - fun rejectFollowRequestOld(accountId: String): Single { - return Single { mastodonApi.rejectFollowRequest(accountId) } - } - - fun notificationsOld( - maxId: String?, - sinceId: String?, - limit: Int?, - excludes: Set? - ): Single>> { - return Single { runCatching { mastodonApi.notifications(maxId, sinceId, limit, excludes) } } - } - - fun clearNotificationsOld(): Single { - return Single { mastodonApi.clearNotifications() } - } - suspend fun translate( statusId: String ): NetworkResult { diff --git a/app/src/main/java/com/keylesspalace/tusky/util/FlowExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/FlowExtensions.kt deleted file mode 100644 index 54eaa8a23..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/util/FlowExtensions.kt +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright 2023 Tusky Contributors - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . - */ - -package com.keylesspalace.tusky.util - -import kotlin.time.Duration -import kotlin.time.TimeMark -import kotlin.time.TimeSource -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow - -/** - * Returns a flow that mirrors the original flow, but filters out values that occur within - * [timeout] of the previously emitted value. The first value is always emitted. - * - * Example: - * - * ```kotlin - * flow { - * emit(1) - * delay(90.milliseconds) - * emit(2) - * delay(90.milliseconds) - * emit(3) - * delay(1010.milliseconds) - * emit(4) - * delay(1010.milliseconds) - * emit(5) - * }.throttleFirst(1000.milliseconds) - * ``` - * - * produces the following emissions. - * - * ```text - * 1, 4, 5 - * ``` - * - * @see kotlinx.coroutines.flow.debounce(Duration) - * @param timeout Emissions within this duration of the last emission are filtered - * @param timeSource Used to measure elapsed time. Normally only overridden in tests - */ -fun Flow.throttleFirst(timeout: Duration, timeSource: TimeSource = TimeSource.Monotonic) = - flow { - var marker: TimeMark? = null - collect { - if (marker == null || marker!!.elapsedNow() >= timeout) { - emit(it) - marker = timeSource.markNow() - } - } - } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ListUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/ListUtils.kt index e7f03fca4..fa6d4e92e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ListUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ListUtils.kt @@ -20,13 +20,6 @@ package com.keylesspalace.tusky.util import java.util.ArrayList import java.util.LinkedHashSet -/** - * @return true if list is null or else return list.isEmpty() - */ -fun isEmpty(list: List<*>?): Boolean { - return list == null || list.isEmpty() -} - /** * @return a new ArrayList containing the elements without duplicates in the same order */ 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 105f99ced..010321855 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/LocaleUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/LocaleUtils.kt @@ -18,7 +18,7 @@ package com.keylesspalace.tusky.util import android.util.Log import androidx.appcompat.app.AppCompatDelegate import androidx.core.os.LocaleListCompat -import com.keylesspalace.tusky.db.AccountEntity +import com.keylesspalace.tusky.db.entity.AccountEntity import java.util.Locale private const val TAG: String = "LocaleUtils" diff --git a/app/src/main/java/com/keylesspalace/tusky/util/PairedList.kt b/app/src/main/java/com/keylesspalace/tusky/util/PairedList.kt deleted file mode 100644 index bdcf23925..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/util/PairedList.kt +++ /dev/null @@ -1,74 +0,0 @@ -package com.keylesspalace.tusky.util - -import androidx.arch.core.util.Function - -/** - * This list implementation can help to keep two lists in sync - like real models and view models. - * - * Every operation on the main list triggers update of the supplementary list (but not vice versa). - * - * This makes sure that the main list is always the source of truth. - * - * Main list is projected to the supplementary list by the passed mapper function. - * - * Paired list is newer actually exposed and clients are provided with `getPairedCopy()`, - * `getPairedItem()` and `setPairedItem()`. This prevents modifications of the - * supplementary list size so lists are always have the same length. - * - * This implementation will not try to recover from exceptional cases so lists may be out of sync - * after the exception. - * - * It is most useful with immutable data because we cannot track changes inside stored objects. - * - * @param T type of elements in the main list - * @param V type of elements in supplementary list - * @param mapper Function, which will be used to translate items from the main list to the - * supplementary one. - * @constructor - */ -class PairedList(private val mapper: Function) : AbstractMutableList() { - private val main: MutableList = ArrayList() - private val synced: MutableList = ArrayList() - - val pairedCopy: List - get() = ArrayList(synced) - - fun getPairedItem(index: Int): V { - return synced[index] - } - - fun getPairedItemOrNull(index: Int): V? { - return synced.getOrNull(index) - } - - fun setPairedItem(index: Int, element: V) { - synced[index] = element - } - - override fun get(index: Int): T { - return main[index] - } - - override fun set(index: Int, element: T): T { - synced[index] = mapper.apply(element) - return main.set(index, element) - } - - override fun add(element: T): Boolean { - synced.add(mapper.apply(element)) - return main.add(element) - } - - override fun add(index: Int, element: T) { - synced.add(index, mapper.apply(element)) - main.add(index, element) - } - - override fun removeAt(index: Int): T { - synced.removeAt(index) - return main.removeAt(index) - } - - override val size: Int - get() = main.size -} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ShareShortcutHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/ShareShortcutHelper.kt index d35e3af5a..03c5d5848 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ShareShortcutHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ShareShortcutHelper.kt @@ -28,8 +28,8 @@ import androidx.core.graphics.drawable.IconCompat import com.bumptech.glide.Glide import com.keylesspalace.tusky.MainActivity import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.db.entity.AccountEntity import com.keylesspalace.tusky.di.ApplicationScope import javax.inject.Inject import kotlinx.coroutines.CoroutineScope diff --git a/app/src/main/java/com/keylesspalace/tusky/util/Single.kt b/app/src/main/java/com/keylesspalace/tusky/util/Single.kt deleted file mode 100644 index d77bdb5a7..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/util/Single.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.keylesspalace.tusky.util - -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.lifecycleScope -import at.connyduck.calladapter.networkresult.NetworkResult -import at.connyduck.calladapter.networkresult.fold -import java.util.function.Consumer -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch - -/** - * Simple reimplementation of RxJava's Single using a Kotlin coroutine, - * intended to be consumed by legacy Java code only. - */ -class Single(private val producer: suspend CoroutineScope.() -> NetworkResult) { - fun subscribe( - owner: LifecycleOwner, - onSuccess: Consumer, - onError: Consumer - ): Job { - return owner.lifecycleScope.launch { - producer().fold( - onSuccess = { onSuccess.accept(it) }, - onFailure = { onError.accept(it) } - ) - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt b/app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt index e55e1706d..3980ac712 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt @@ -18,7 +18,7 @@ package com.keylesspalace.tusky.util import android.content.SharedPreferences -import com.keylesspalace.tusky.db.AccountEntity +import com.keylesspalace.tusky.db.entity.AccountEntity import com.keylesspalace.tusky.settings.PrefKeys data class StatusDisplayOptions( 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 0975b00f1..7c0f8adf2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt @@ -36,10 +36,8 @@ package com.keylesspalace.tusky.util import androidx.paging.CombinedLoadStates import androidx.paging.LoadState -import com.keylesspalace.tusky.entity.Notification 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 @@ -61,21 +59,6 @@ fun Status.toViewData( ) } -@JvmName("notificationToViewData") -fun Notification.toViewData( - isShowingContent: Boolean, - isExpanded: Boolean, - isCollapsed: Boolean -): NotificationViewData.Concrete { - return NotificationViewData.Concrete( - this.type, - this.id, - this.account, - this.status?.toViewData(isShowingContent, isExpanded, isCollapsed), - this.report - ) -} - fun List.toViewData(): List { val maxTrendingValue = flatMap { tag -> tag.history } .mapNotNull { it.uses.toLongOrNull() } diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java b/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java deleted file mode 100644 index c70e2fc71..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java +++ /dev/null @@ -1,138 +0,0 @@ -/* Copyright 2017 Andrew Dawson - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . */ - -package com.keylesspalace.tusky.viewdata; - -import androidx.annotation.Nullable; - -import com.keylesspalace.tusky.entity.Notification; -import com.keylesspalace.tusky.entity.Report; -import com.keylesspalace.tusky.entity.TimelineAccount; - -import java.util.Objects; - -/** - * Created by charlag on 12/07/2017. - *

- * Class to represent data required to display either a notification or a placeholder. - * It is either a {@link Placeholder} or a {@link Concrete}. - * It is modelled this way because close relationship between placeholder and concrete notification - * is fine in this case. Placeholder case is not modelled as a type of notification because - * invariants would be violated and because it would model domain incorrectly. It is preferable to - * {@link com.keylesspalace.tusky.util.Either} because class hierarchy is cheaper, faster and - * more native. - */ -public abstract class NotificationViewData { - private NotificationViewData() { - } - - public abstract long getViewDataId(); - - public abstract boolean deepEquals(NotificationViewData other); - - public static final class Concrete extends NotificationViewData { - private final Notification.Type type; - private final String id; - private final TimelineAccount account; - @Nullable - private final StatusViewData.Concrete statusViewData; - @Nullable - private final Report report; - - public Concrete(Notification.Type type, String id, TimelineAccount account, - @Nullable StatusViewData.Concrete statusViewData, @Nullable Report report) { - this.type = type; - this.id = id; - this.account = account; - this.statusViewData = statusViewData; - this.report = report; - } - - public Notification.Type getType() { - return type; - } - - public String getId() { - return id; - } - - public TimelineAccount getAccount() { - return account; - } - - @Nullable - public StatusViewData.Concrete getStatusViewData() { - return statusViewData; - } - - @Nullable - public Report getReport() { - return report; - } - - @Override - public long getViewDataId() { - return id.hashCode(); - } - - @Override - public boolean deepEquals(NotificationViewData o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - Concrete concrete = (Concrete) o; - return type == concrete.type && - Objects.equals(id, concrete.id) && - account.getId().equals(concrete.account.getId()) && - (Objects.equals(statusViewData, concrete.statusViewData)) && - (Objects.equals(report, concrete.report)); - } - - @Override - public int hashCode() { - - return Objects.hash(type, id, account, statusViewData); - } - - public Concrete copyWithStatus(@Nullable StatusViewData.Concrete statusViewData) { - return new Concrete(type, id, account, statusViewData, report); - } - } - - public static final class Placeholder extends NotificationViewData { - private final long id; - private final boolean isLoading; - - public Placeholder(long id, boolean isLoading) { - this.id = id; - this.isLoading = isLoading; - } - - public boolean isLoading() { - return isLoading; - } - - @Override - public long getViewDataId() { - return id; - } - - @Override - public boolean deepEquals(NotificationViewData other) { - if (!(other instanceof Placeholder)) return false; - Placeholder that = (Placeholder) other; - return isLoading == that.isLoading && id == that.id; - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.kt b/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.kt new file mode 100644 index 000000000..54e58c063 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.kt @@ -0,0 +1,48 @@ +/* Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ +package com.keylesspalace.tusky.viewdata + +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.entity.Report +import com.keylesspalace.tusky.entity.TimelineAccount + +sealed class NotificationViewData { + + abstract val id: String + + abstract fun asStatusOrNull(): StatusViewData.Concrete? + abstract fun asPlaceholderOrNull(): Placeholder? + + data class Concrete( + override val id: String, + val type: Notification.Type, + val account: TimelineAccount, + val statusViewData: StatusViewData.Concrete?, + val report: Report? + ) : NotificationViewData() { + override fun asStatusOrNull() = statusViewData + + override fun asPlaceholderOrNull() = null + } + + data class Placeholder( + override val id: String, + val isLoading: Boolean + ) : NotificationViewData() { + override fun asStatusOrNull() = null + + override fun asPlaceholderOrNull() = this + } +} 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 6f8d5d698..7647773eb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt @@ -103,21 +103,6 @@ sealed class StatusViewData { val rebloggingStatus: Status? get() = if (status.reblog != null) status else null - /** Helper for Java */ - fun copyWithStatus(status: Status): Concrete { - return copy(status = status) - } - - /** Helper for Java */ - fun copyWithExpanded(isExpanded: Boolean): Concrete { - return copy(isExpanded = isExpanded) - } - - /** Helper for Java */ - fun copyWithShowingContent(isShowingContent: Boolean): Concrete { - return copy(isShowingContent = isShowingContent) - } - /** Helper for Java */ fun copyWithCollapsed(isCollapsed: Boolean): Concrete { return copy(isCollapsed = isCollapsed) diff --git a/app/src/main/java/com/keylesspalace/tusky/worker/NotificationWorker.kt b/app/src/main/java/com/keylesspalace/tusky/worker/NotificationWorker.kt index a7362630c..6dbe92c9e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/worker/NotificationWorker.kt +++ b/app/src/main/java/com/keylesspalace/tusky/worker/NotificationWorker.kt @@ -23,9 +23,9 @@ import androidx.work.CoroutineWorker import androidx.work.ForegroundInfo import androidx.work.WorkerParameters import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.components.notifications.NotificationFetcher -import com.keylesspalace.tusky.components.notifications.NotificationHelper -import com.keylesspalace.tusky.components.notifications.NotificationHelper.NOTIFICATION_ID_FETCH_NOTIFICATION +import com.keylesspalace.tusky.components.systemnotifications.NotificationFetcher +import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper +import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper.NOTIFICATION_ID_FETCH_NOTIFICATION import javax.inject.Inject /** Fetch and show new notifications. */ diff --git a/app/src/main/java/com/keylesspalace/tusky/worker/PruneCacheWorker.kt b/app/src/main/java/com/keylesspalace/tusky/worker/PruneCacheWorker.kt index c69a035a6..3234a7c84 100644 --- a/app/src/main/java/com/keylesspalace/tusky/worker/PruneCacheWorker.kt +++ b/app/src/main/java/com/keylesspalace/tusky/worker/PruneCacheWorker.kt @@ -25,17 +25,17 @@ import androidx.work.ForegroundInfo import androidx.work.ListenableWorker import androidx.work.WorkerParameters import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.components.notifications.NotificationHelper -import com.keylesspalace.tusky.components.notifications.NotificationHelper.NOTIFICATION_ID_PRUNE_CACHE +import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper +import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper.NOTIFICATION_ID_PRUNE_CACHE import com.keylesspalace.tusky.db.AccountManager -import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.db.DatabaseCleaner import javax.inject.Inject /** Prune the database cache of old statuses. */ class PruneCacheWorker( appContext: Context, workerParams: WorkerParameters, - private val appDatabase: AppDatabase, + private val databaseCleaner: DatabaseCleaner, private val accountManager: AccountManager ) : CoroutineWorker(appContext, workerParams) { val notification: Notification = NotificationHelper.createWorkerNotification( @@ -46,7 +46,7 @@ class PruneCacheWorker( override suspend fun doWork(): Result { for (account in accountManager.accounts) { Log.d(TAG, "Pruning database using account ID: ${account.id}") - appDatabase.timelineDao().cleanup(account.id, MAX_STATUSES_IN_CACHE) + databaseCleaner.cleanupOldData(account.id, MAX_HOMETIMELINE_ITEMS_IN_CACHE, MAX_NOTIFICATIONS_IN_CACHE) } return Result.success() } @@ -58,16 +58,17 @@ class PruneCacheWorker( companion object { private const val TAG = "PruneCacheWorker" - private const val MAX_STATUSES_IN_CACHE = 1000 + private const val MAX_HOMETIMELINE_ITEMS_IN_CACHE = 1000 + private const val MAX_NOTIFICATIONS_IN_CACHE = 1000 const val PERIODIC_WORK_TAG = "PruneCacheWorker_periodic" } class Factory @Inject constructor( - private val appDatabase: AppDatabase, + private val databaseCleaner: DatabaseCleaner, private val accountManager: AccountManager ) : ChildWorkerFactory { override fun createWorker(appContext: Context, params: WorkerParameters): ListenableWorker { - return PruneCacheWorker(appContext, params, appDatabase, accountManager) + return PruneCacheWorker(appContext, params, databaseCleaner, accountManager) } } } diff --git a/app/src/main/res/layout/item_report_notification.xml b/app/src/main/res/layout/item_report_notification.xml index 313cf5d27..fde968d48 100644 --- a/app/src/main/res/layout/item_report_notification.xml +++ b/app/src/main/res/layout/item_report_notification.xml @@ -1,20 +1,19 @@ + android:paddingStart="14dp" + android:paddingEnd="14dp" + android:paddingBottom="14dp"> @@ -44,40 +47,38 @@ android:layout_width="24dp" android:layout_height="24dp" android:contentDescription="@string/action_view_profile" - app:layout_constraintRight_toRightOf="@id/notification_reportee_avatar" app:layout_constraintBottom_toBottomOf="@id/notification_reportee_avatar" - /> + app:layout_constraintEnd_toEndOf="@id/notification_reportee_avatar" + tools:src="@drawable/avatar_default" /> + app:layout_constraintStart_toEndOf="@id/notification_reporter_avatar" + app:layout_constraintTop_toBottomOf="@id/notification_top_text" + tools:text="30 minutes ago · 2 posts attached" /> diff --git a/app/src/main/res/layout/item_status_placeholder.xml b/app/src/main/res/layout/item_status_placeholder.xml index 660c99eaa..4159e6bab 100644 --- a/app/src/main/res/layout/item_status_placeholder.xml +++ b/app/src/main/res/layout/item_status_placeholder.xml @@ -1,51 +1,24 @@ - - - + android:background="@color/dividerColorOther"> - + android:textSize="?attr/status_text_large" + android:textStyle="bold" /> + + + diff --git a/app/src/main/res/layout/item_unknown_notification.xml b/app/src/main/res/layout/item_unknown_notification.xml new file mode 100644 index 000000000..09afceb3f --- /dev/null +++ b/app/src/main/res/layout/item_unknown_notification.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 54bc3d352..b648bc00e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -686,7 +686,7 @@ Enable swipe gesture to switch between tabs Show post statistics in timeline - + Show Notifications filter Poll Duration @@ -718,7 +718,6 @@ Mastodon has a minimum scheduling interval of 5 minutes. Show username in toolbars Show link previews in timelines - Show Notifications filter Show confirmation before boosting Show confirmation before favoriting Hide the title of the top toolbar @@ -770,6 +769,7 @@ Rule violation Spam + Legal Other Unfollow #%1$s? @@ -847,8 +847,11 @@ Delete filter \'%1$s\'?" Delete Do you want to save your profile changes? + No one Members of the list Any followed user Show replies to + + Unknown notification type diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 5115cb15d..610b50af8 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -133,12 +133,6 @@ 0 - -