diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/43.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/43.json new file mode 100644 index 000000000..eeceb2031 --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/43.json @@ -0,0 +1,959 @@ +{ + "formatVersion": 1, + "database": { + "version": 43, + "identityHash": "bf68abe55bb58765da7f9d6f7ef618e2", + "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, `scheduledAt` TEXT, `language` 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": "scheduledAt", + "columnName": "scheduledAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `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, `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, `activeNotifications` TEXT NOT NULL, `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)", + "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": "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": "activeNotifications", + "columnName": "activeNotifications", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabPreferences", + "columnName": "tabPreferences", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsFilter", + "columnName": "notificationsFilter", + "affinity": "TEXT", + "notNull": true + }, + { + "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 + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "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, 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 + } + ], + "primaryKey": { + "columnNames": [ + "instance" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `repliesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT, `mentions` TEXT, `tags` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `card` TEXT, `language` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogsCount", + "columnName": "reblogsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favouritesCount", + "columnName": "favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "repliesCount", + "columnName": "repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reblogged", + "columnName": "reblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarked", + "columnName": "bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favourited", + "columnName": "favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogServerId", + "columnName": "reblogServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "muted", + "columnName": "muted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "expanded", + "columnName": "expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentCollapsed", + "columnName": "contentCollapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentShowing", + "columnName": "contentShowing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pinned", + "columnName": "pinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "card", + "columnName": "card", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "timelineUserId" + ], + "referencedColumns": [ + "serverId", + "timelineUserId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUsername", + "columnName": "localUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `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_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.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": { + "columnNames": [ + "id", + "accountId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'bf68abe55bb58765da7f9d6f7ef618e2')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/44.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/44.json new file mode 100644 index 000000000..e04b07549 --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/44.json @@ -0,0 +1,965 @@ +{ + "formatVersion": 1, + "database": { + "version": 44, + "identityHash": "7b5271980102f35e55438f46777e3d46", + "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, `scheduledAt` TEXT, `language` 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": "scheduledAt", + "columnName": "scheduledAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `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, `activeNotifications` TEXT NOT NULL, `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)", + "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": "activeNotifications", + "columnName": "activeNotifications", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabPreferences", + "columnName": "tabPreferences", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsFilter", + "columnName": "notificationsFilter", + "affinity": "TEXT", + "notNull": true + }, + { + "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 + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "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, 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 + } + ], + "primaryKey": { + "columnNames": [ + "instance" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `repliesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT, `mentions` TEXT, `tags` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `card` TEXT, `language` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogsCount", + "columnName": "reblogsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favouritesCount", + "columnName": "favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "repliesCount", + "columnName": "repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reblogged", + "columnName": "reblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarked", + "columnName": "bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favourited", + "columnName": "favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogServerId", + "columnName": "reblogServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "muted", + "columnName": "muted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "expanded", + "columnName": "expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentCollapsed", + "columnName": "contentCollapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentShowing", + "columnName": "contentShowing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pinned", + "columnName": "pinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "card", + "columnName": "card", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "timelineUserId" + ], + "referencedColumns": [ + "serverId", + "timelineUserId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUsername", + "columnName": "localUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `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_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.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": { + "columnNames": [ + "id", + "accountId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7b5271980102f35e55438f46777e3d46')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/45.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/45.json new file mode 100644 index 000000000..32d523da0 --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/45.json @@ -0,0 +1,983 @@ +{ + "formatVersion": 1, + "database": { + "version": 45, + "identityHash": "edb371b819690636d843eebffa55792a", + "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, `scheduledAt` TEXT, `language` 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": "scheduledAt", + "columnName": "scheduledAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `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, `activeNotifications` TEXT NOT NULL, `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)", + "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": "activeNotifications", + "columnName": "activeNotifications", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabPreferences", + "columnName": "tabPreferences", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsFilter", + "columnName": "notificationsFilter", + "affinity": "TEXT", + "notNull": true + }, + { + "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 + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "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, 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 + } + ], + "primaryKey": { + "columnNames": [ + "instance" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `editedAt` INTEGER, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `repliesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT, `mentions` TEXT, `tags` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `card` TEXT, `language` TEXT, `quote` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "editedAt", + "columnName": "editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogsCount", + "columnName": "reblogsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favouritesCount", + "columnName": "favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "repliesCount", + "columnName": "repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reblogged", + "columnName": "reblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarked", + "columnName": "bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favourited", + "columnName": "favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogServerId", + "columnName": "reblogServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "muted", + "columnName": "muted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "expanded", + "columnName": "expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentCollapsed", + "columnName": "contentCollapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentShowing", + "columnName": "contentShowing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pinned", + "columnName": "pinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "card", + "columnName": "card", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "quote", + "columnName": "quote", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "timelineUserId" + ], + "referencedColumns": [ + "serverId", + "timelineUserId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUsername", + "columnName": "localUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `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": { + "columnNames": [ + "id", + "accountId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'edb371b819690636d843eebffa55792a')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a36513400..000f4a5b4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,8 +6,8 @@ - - + + @@ -19,7 +19,8 @@ android:label="@string/app_name" android:supportsRtl="true" android:theme="@style/TuskyTheme" - android:usesCleartextTraffic="false"> + android:usesCleartextTraffic="false" + android:localeConfig="@xml/locales_config"> + diff --git a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java index eaf2db8b9..cb262806a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java @@ -16,7 +16,6 @@ package com.keylesspalace.tusky; import android.app.ActivityManager; -import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.content.pm.PackageManager; @@ -92,11 +91,6 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab requesters = new HashMap<>(); } - @Override - protected void attachBaseContext(Context base) { - super.attachBaseContext(TuskyApplication.getLocaleManager().setLocale(base)); - } - protected boolean requiresLogin() { return true; } diff --git a/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt b/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt index 628d91ab4..6f07a99f2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt @@ -29,10 +29,9 @@ import com.google.android.material.bottomsheet.BottomSheetBehavior import com.keylesspalace.tusky.components.account.AccountActivity import com.keylesspalace.tusky.components.viewthread.ViewThreadActivity import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.looksLikeMastodonUrl import com.keylesspalace.tusky.util.openLink import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import java.net.URI -import java.net.URISyntaxException import javax.inject.Inject /** this is the base class for all activities that open links @@ -180,47 +179,6 @@ abstract class BottomSheetActivity : BaseActivity() { } } -// https://mastodon.foo.bar/@User -// https://mastodon.foo.bar/@User/43456787654678 -// https://pleroma.foo.bar/users/User -// https://pleroma.foo.bar/users/9qTHT2ANWUdXzENqC0 -// https://pleroma.foo.bar/notice/9sBHWIlwwGZi5QGlHc -// https://pleroma.foo.bar/objects/d4643c42-3ae0-4b73-b8b0-c725f5819207 -// https://friendica.foo.bar/profile/user -// https://friendica.foo.bar/display/d4643c42-3ae0-4b73-b8b0-c725f5819207 -// https://misskey.foo.bar/notes/83w6r388br (always lowercase) -// https://pixelfed.social/p/connyduck/391263492998670833 -// https://pixelfed.social/connyduck -// https://mastodon.foo.bar/users/User/statuses/000000000000000000 -fun looksLikeMastodonUrl(urlString: String): Boolean { - val uri: URI - try { - uri = URI(urlString) - } catch (e: URISyntaxException) { - return false - } - - if (uri.query != null || - uri.fragment != null || - uri.path == null - ) { - return false - } - - val path = uri.path - return path.matches("^/@[^/]+$".toRegex()) || - path.matches("^/@[^/]+/\\d+$".toRegex()) || - path.matches("^/users/\\w+$".toRegex()) || - path.matches("^/notice/[a-zA-Z0-9]+$".toRegex()) || - path.matches("^/objects/[-a-f0-9]+$".toRegex()) || - path.matches("^/notes/[a-z0-9]+$".toRegex()) || - path.matches("^/display/[-a-f0-9]+$".toRegex()) || - path.matches("^/profile/\\w+$".toRegex()) || - path.matches("^/p/\\w+/\\d+$".toRegex()) || - path.matches("^/\\w+$".toRegex()) || - path.matches("^/users/[^/]+/statuses/\\d+$".toRegex()) -} - enum class PostLookupFallbackBehavior { OPEN_IN_BROWSER, DISPLAY_ERROR, diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt index 6c281a3c1..2fe21d231 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt @@ -434,9 +434,11 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje private fun setupDrawer(savedInstanceState: Bundle?, addSearchButton: Boolean) { - binding.mainToolbar.setNavigationOnClickListener { - binding.mainDrawerLayout.open() - } + val drawerOpenClickListener = View.OnClickListener { binding.mainDrawerLayout.open() } + + binding.mainToolbar.setNavigationOnClickListener(drawerOpenClickListener) + binding.topNavAvatar.setOnClickListener(drawerOpenClickListener) + binding.bottomNavAvatar.setOnClickListener(drawerOpenClickListener) header = AccountHeaderView(this).apply { headerBackgroundScaleType = ImageView.ScaleType.CENTER_CROP @@ -648,7 +650,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje val actionBarSize = ThemeUtils.getDimension(this, R.attr.actionBarSize) val fabMargin = resources.getDimensionPixelSize(R.dimen.fabMargin) (binding.composeButton.layoutParams as CoordinatorLayout.LayoutParams).bottomMargin = actionBarSize + fabMargin - binding.tabLayout.hide() + binding.topNav.hide() binding.bottomTabLayout } else { binding.bottomNav.hide() @@ -784,7 +786,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje val pageMargin = resources.getDimensionPixelSize(R.dimen.tab_page_margin) binding.viewPager.setPageTransformer(MarginPageTransformer(pageMargin)) - val enableSwipeForTabs = preferences.getBoolean("enableSwipeForTabs", true) + val enableSwipeForTabs = preferences.getBoolean(PrefKeys.ENABLE_SWIPE_FOR_TABS, true) binding.viewPager.isUserInputEnabled = enableSwipeForTabs onTabSelectedListener?.let { @@ -922,71 +924,117 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje } private fun loadDrawerAvatar(avatarUrl: String, showPlaceholder: Boolean) { - val navIconSize = resources.getDimensionPixelSize(R.dimen.avatar_toolbar_nav_icon_size) + val hideTopToolbar = preferences.getBoolean(PrefKeys.HIDE_TOP_TOOLBAR, false) val animateAvatars = preferences.getBoolean("animateGifAvatars", false) - if (animateAvatars) { - glide.asDrawable() - .load(avatarUrl) - .transform( - RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp)) - ) - .apply { - if (showPlaceholder) { - placeholder(R.drawable.avatar_default) - } - } - .into(object : CustomTarget(navIconSize, navIconSize) { + if (hideTopToolbar) { + val navOnBottom = preferences.getString("mainNavPosition", "top") == "bottom" - override fun onLoadStarted(placeholder: Drawable?) { - if (placeholder != null) { - binding.mainToolbar.navigationIcon = FixedSizeDrawable(placeholder, navIconSize, navIconSize) - } - } + val avatarView = if (navOnBottom) { + binding.bottomNavAvatar.show() + binding.bottomNavAvatar + } else { + binding.topNavAvatar.show() + binding.topNavAvatar + } - override fun onResourceReady(resource: Drawable, transition: Transition?) { - if (resource is Animatable) { - resource.start() - } - binding.mainToolbar.navigationIcon = FixedSizeDrawable(resource, navIconSize, navIconSize) - } - - override fun onLoadCleared(placeholder: Drawable?) { - if (placeholder != null) { - binding.mainToolbar.navigationIcon = FixedSizeDrawable(placeholder, navIconSize, navIconSize) - } - } - }) + if (animateAvatars) { + Glide.with(this) + .load(avatarUrl) + .placeholder(R.drawable.avatar_default) + .into(avatarView) + } else { + Glide.with(this) + .asBitmap() + .load(avatarUrl) + .placeholder(R.drawable.avatar_default) + .into(avatarView) + } } else { - glide.asBitmap() - .load(avatarUrl) - .transform( - RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp)) - ) - .apply { - if (showPlaceholder) { - placeholder(R.drawable.avatar_default) - } - } - .into(object : CustomTarget(navIconSize, navIconSize) { - override fun onLoadStarted(placeholder: Drawable?) { - if (placeholder != null) { - binding.mainToolbar.navigationIcon = FixedSizeDrawable(placeholder, navIconSize, navIconSize) + binding.bottomNavAvatar.hide() + binding.topNavAvatar.hide() + + val navIconSize = resources.getDimensionPixelSize(R.dimen.avatar_toolbar_nav_icon_size) + + if (animateAvatars) { + glide.asDrawable() + .load(avatarUrl) + .transform( + RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp)) + ) + .apply { + if (showPlaceholder) { + placeholder(R.drawable.avatar_default) } } + .into(object : CustomTarget(navIconSize, navIconSize) { - override fun onResourceReady(resource: Bitmap, transition: Transition?) { - binding.mainToolbar.navigationIcon = FixedSizeDrawable(BitmapDrawable(resources, resource), navIconSize, navIconSize) - } + override fun onLoadStarted(placeholder: Drawable?) { + if (placeholder != null) { + binding.mainToolbar.navigationIcon = + FixedSizeDrawable(placeholder, navIconSize, navIconSize) + } + } - override fun onLoadCleared(placeholder: Drawable?) { - if (placeholder != null) { - binding.mainToolbar.navigationIcon = FixedSizeDrawable(placeholder, navIconSize, navIconSize) + override fun onResourceReady( + resource: Drawable, + transition: Transition? + ) { + if (resource is Animatable) { + resource.start() + } + binding.mainToolbar.navigationIcon = + FixedSizeDrawable(resource, navIconSize, navIconSize) + } + + override fun onLoadCleared(placeholder: Drawable?) { + if (placeholder != null) { + binding.mainToolbar.navigationIcon = + FixedSizeDrawable(placeholder, navIconSize, navIconSize) + } + } + }) + } else { + glide.asBitmap() + .load(avatarUrl) + .transform( + RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp)) + ) + .apply { + if (showPlaceholder) { + placeholder(R.drawable.avatar_default) } } - }) + .into(object : CustomTarget(navIconSize, navIconSize) { + + override fun onLoadStarted(placeholder: Drawable?) { + if (placeholder != null) { + binding.mainToolbar.navigationIcon = + FixedSizeDrawable(placeholder, navIconSize, navIconSize) + } + } + + override fun onResourceReady( + resource: Bitmap, + transition: Transition? + ) { + binding.mainToolbar.navigationIcon = FixedSizeDrawable( + BitmapDrawable(resources, resource), + navIconSize, + navIconSize + ) + } + + override fun onLoadCleared(placeholder: Drawable?) { + if (placeholder != null) { + binding.mainToolbar.navigationIcon = + FixedSizeDrawable(placeholder, navIconSize, navIconSize) + } + } + }) + } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt b/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt index 8c3d826ef..0ff2f3997 100644 --- a/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt @@ -26,13 +26,16 @@ import androidx.fragment.app.commit import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import at.connyduck.calladapter.networkresult.fold -import autodispose2.androidx.lifecycle.autoDispose +import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider +import autodispose2.autoDispose import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.components.timeline.TimelineFragment import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel.Kind import com.keylesspalace.tusky.databinding.ActivityStatuslistBinding import com.keylesspalace.tusky.di.ViewModelFactory +import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.util.viewBinding import dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector @@ -52,13 +55,20 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { private val quickTootViewModel: QuickTootViewModel by viewModels { viewModelFactory } + private val binding: ActivityStatuslistBinding by viewBinding(ActivityStatuslistBinding::inflate) private lateinit var kind: Kind private var hashtag: String? = null private var followTagItem: MenuItem? = null private var unfollowTagItem: MenuItem? = null + private var muteTagItem: MenuItem? = null + private var unmuteTagItem: MenuItem? = null + + /** The filter muting hashtag, null if unknown or hashtag is not filtered */ + private var mutedFilter: Filter? = null override fun onCreate(savedInstanceState: Bundle?) { + Log.d("StatusListActivity", "onCreate") super.onCreate(savedInstanceState) setContentView(binding.root) @@ -96,7 +106,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { eventHub.events .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(this, Lifecycle.Event.ON_DESTROY) + .autoDispose(AndroidLifecycleScopeProvider.from(this, Lifecycle.Event.ON_DESTROY)) .subscribe(binding.viewQuickToot::handleEvent) binding.floatingBtn.setOnClickListener(binding.viewQuickToot::onFABClicked) } @@ -110,10 +120,15 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { menuInflater.inflate(R.menu.view_hashtag_toolbar, menu) followTagItem = menu.findItem(R.id.action_follow_hashtag) unfollowTagItem = menu.findItem(R.id.action_unfollow_hashtag) + muteTagItem = menu.findItem(R.id.action_mute_hashtag) + unmuteTagItem = menu.findItem(R.id.action_unmute_hashtag) followTagItem?.isVisible = tagEntity.following == false unfollowTagItem?.isVisible = tagEntity.following == true followTagItem?.setOnMenuItemClickListener { followTag() } unfollowTagItem?.setOnMenuItemClickListener { unfollowTag() } + muteTagItem?.setOnMenuItemClickListener { muteTag() } + unmuteTagItem?.setOnMenuItemClickListener { unmuteTag() } + updateMuteTagMenuItems() }, { Log.w(TAG, "Failed to query tag #$tag", it) @@ -165,6 +180,85 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { return true } + /** + * Determine if the current hashtag is muted, and update the UI state accordingly. + */ + private fun updateMuteTagMenuItems() { + val tag = hashtag ?: return + + muteTagItem?.isVisible = true + muteTagItem?.isEnabled = false + unmuteTagItem?.isVisible = false + + mastodonApi.getFilters().observeOn(AndroidSchedulers.mainThread()) + .autoDispose(AndroidLifecycleScopeProvider.from(this, Lifecycle.Event.ON_DESTROY)) + .subscribe { filters -> + for (filter in filters) { + if ((tag == filter.phrase) and filter.context.contains(Filter.HOME)) { + Log.d(TAG, "Tag $hashtag is filtered") + muteTagItem?.isVisible = false + unmuteTagItem?.isVisible = true + mutedFilter = filter + return@subscribe + } + } + + Log.d(TAG, "Tag $hashtag is not filtered") + mutedFilter = null + muteTagItem?.isEnabled = true + muteTagItem?.isVisible = true + muteTagItem?.isVisible = true + } + } + + private fun muteTag(): Boolean { + val tag = hashtag ?: return true + + lifecycleScope.launch { + mastodonApi.createFilter( + tag, + listOf(Filter.HOME), + irreversible = false, + wholeWord = true, + expiresInSeconds = null + ).fold( + { filter -> + mutedFilter = filter + muteTagItem?.isVisible = false + unmuteTagItem?.isVisible = true + eventHub.dispatch(PreferenceChangedEvent(filter.context[0])) + }, + { + Snackbar.make(binding.root, getString(R.string.error_muting_hashtag_format, tag), Snackbar.LENGTH_SHORT).show() + Log.e(TAG, "Failed to mute #$tag", it) + } + ) + } + + return true + } + + private fun unmuteTag(): Boolean { + val filter = mutedFilter ?: return true + + lifecycleScope.launch { + mastodonApi.deleteFilter(filter.id).fold( + { + muteTagItem?.isVisible = true + unmuteTagItem?.isVisible = false + eventHub.dispatch(PreferenceChangedEvent(filter.context[0])) + mutedFilter = null + }, + { + Snackbar.make(binding.root, getString(R.string.error_unmuting_hashtag_format, filter.phrase), Snackbar.LENGTH_SHORT).show() + Log.e(TAG, "Failed to unmute #${filter.phrase}", it) + } + ) + } + + return true + } + override fun androidInjector() = dispatchingAndroidInjector companion object { diff --git a/app/src/main/java/com/keylesspalace/tusky/TabData.kt b/app/src/main/java/com/keylesspalace/tusky/TabData.kt index cd789f017..7ea063e1e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TabData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TabData.kt @@ -108,6 +108,6 @@ fun defaultTabs(): List { createTabDataFromId(HOME), createTabDataFromId(NOTIFICATIONS), createTabDataFromId(LOCAL), - createTabDataFromId(FEDERATED) + createTabDataFromId(DIRECT) ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt index ded947a84..5401b5931 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt @@ -16,8 +16,6 @@ package com.keylesspalace.tusky import android.app.Application -import android.content.Context -import android.content.res.Configuration import android.util.Log import androidx.preference.PreferenceManager import androidx.work.WorkManager @@ -44,6 +42,9 @@ class TuskyApplication : Application(), HasAndroidInjector { @Inject lateinit var notificationWorkerFactory: NotificationWorkerFactory + @Inject + lateinit var localeManager: LocaleManager + override fun onCreate() { // Uncomment me to get StrictMode violation logs // if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { @@ -74,6 +75,8 @@ class TuskyApplication : Application(), HasAndroidInjector { val theme = preferences.getString("appTheme", ThemeUtils.APP_THEME_DEFAULT) ThemeUtils.setAppNightMode(theme) + localeManager.setLocale() + RxJavaPlugins.setErrorHandler { Log.w("RxJava", "undeliverable exception", it) } @@ -86,20 +89,5 @@ class TuskyApplication : Application(), HasAndroidInjector { ) } - override fun attachBaseContext(base: Context) { - localeManager = LocaleManager(base) - super.attachBaseContext(localeManager.setLocale(base)) - } - - override fun onConfigurationChanged(newConfig: Configuration) { - super.onConfigurationChanged(newConfig) - localeManager.setLocale(this) - } - override fun androidInjector() = androidInjector - - companion object { - @JvmStatic - lateinit var localeManager: LocaleManager - } } diff --git a/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt b/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt index 2a96cb7fe..8c7dff594 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt @@ -27,6 +27,7 @@ import android.content.pm.PackageManager import android.graphics.Bitmap import android.graphics.Color import android.net.Uri +import android.os.Build import android.os.Bundle import android.os.Environment import android.transition.Transition @@ -51,6 +52,7 @@ import com.keylesspalace.tusky.components.viewthread.ViewThreadActivity import com.keylesspalace.tusky.databinding.ActivityViewMediaBinding import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.fragment.ViewImageFragment +import com.keylesspalace.tusky.fragment.ViewVideoFragment import com.keylesspalace.tusky.pager.ImagePagerAdapter import com.keylesspalace.tusky.pager.SingleImagePagerAdapter import com.keylesspalace.tusky.util.getTemporaryMediaFilename @@ -67,7 +69,7 @@ import java.util.Locale typealias ToolbarVisibilityListener = (isVisible: Boolean) -> Unit -class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener { +class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener, ViewVideoFragment.VideoActionsListener { private val binding by viewBinding(ActivityViewMediaBinding::inflate) @@ -212,12 +214,20 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener } private fun requestDownloadMedia() { - requestPermissions(arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE)) { _, grantResults -> - if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { - downloadMedia() - } else { - showErrorDialog(binding.toolbar, R.string.error_media_download_permission, R.string.action_retry) { requestDownloadMedia() } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + requestPermissions(arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE)) { _, grantResults -> + if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + downloadMedia() + } else { + showErrorDialog( + binding.toolbar, + R.string.error_media_download_permission, + R.string.action_retry + ) { requestDownloadMedia() } + } } + } else { + downloadMedia() } } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/EmojiAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/EmojiAdapter.kt index dc9ec70dc..d3412e5b1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/EmojiAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/EmojiAdapter.kt @@ -17,6 +17,7 @@ package com.keylesspalace.tusky.adapter import android.view.LayoutInflater import android.view.ViewGroup +import androidx.appcompat.widget.TooltipCompat import androidx.recyclerview.widget.RecyclerView import com.bumptech.glide.Glide import com.keylesspalace.tusky.databinding.ItemEmojiButtonBinding @@ -52,6 +53,7 @@ class EmojiAdapter( } emojiImageView.contentDescription = emoji.shortcode + TooltipCompat.setTooltipText(emojiImageView, emoji.shortcode) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/LocaleAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/LocaleAdapter.kt index fdde2f21b..ef5edd1d4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/LocaleAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/LocaleAdapter.kt @@ -22,6 +22,8 @@ import android.view.ViewGroup import android.widget.ArrayAdapter import android.widget.TextView import com.keylesspalace.tusky.util.ThemeUtils +import com.keylesspalace.tusky.util.getTuskyDisplayName +import com.keylesspalace.tusky.util.modernLanguageCode import java.util.Locale class LocaleAdapter(context: Context, resource: Int, locales: List) : ArrayAdapter(context, resource, locales) { @@ -29,15 +31,14 @@ class LocaleAdapter(context: Context, resource: Int, locales: List) : Ar return (super.getView(position, convertView, parent) as TextView).apply { setTextColor(ThemeUtils.getColor(context, android.R.attr.textColorTertiary)) typeface = Typeface.DEFAULT_BOLD - text = super.getItem(position)?.language?.uppercase() + text = super.getItem(position)?.modernLanguageCode?.uppercase() } } override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View { return (super.getDropDownView(position, convertView, parent) as TextView).apply { setTextColor(ThemeUtils.getColor(context, android.R.attr.textColorTertiary)) - val locale = super.getItem(position) - text = "${locale?.displayLanguage} (${locale?.getDisplayLanguage(locale)})" + text = super.getItem(position)?.getTuskyDisplayName(context) } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java index b75ac7bf7..47ef253c3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java @@ -43,6 +43,7 @@ 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.databinding.ViewQuoteInlineBinding; import com.keylesspalace.tusky.entity.Emoji; import com.keylesspalace.tusky.entity.Notification; @@ -84,7 +85,8 @@ public class NotificationsAdapter extends RecyclerView.Adapter { 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_UNKNOWN = 5; + 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]; @@ -141,6 +143,10 @@ public class NotificationsAdapter extends RecyclerView.Adapter { .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()); @@ -256,6 +262,13 @@ public class NotificationsAdapter extends RecyclerView.Adapter { } 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: } } @@ -309,6 +322,9 @@ public class NotificationsAdapter extends RecyclerView.Adapter { case FOLLOW_REQUEST: { return VIEW_TYPE_FOLLOW_REQUEST; } + case REPORT: { + return VIEW_TYPE_REPORT; + } default: { return VIEW_TYPE_UNKNOWN; } @@ -327,6 +343,8 @@ public class NotificationsAdapter extends RecyclerView.Adapter { void onViewStatusForNotificationId(String notificationId); + void onViewReport(String reportId); + void onExpandedChange(boolean expanded, int position); /** diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/ReportNotificationViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/ReportNotificationViewHolder.kt new file mode 100644 index 000000000..0155f4a44 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/ReportNotificationViewHolder.kt @@ -0,0 +1,90 @@ +/* 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 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.util.TimestampUtils +import com.keylesspalace.tusky.util.emojify +import com.keylesspalace.tusky.util.loadAvatar +import com.keylesspalace.tusky.util.unicodeWrap +import java.util.Date + +class ReportNotificationViewHolder( + private val binding: ItemReportNotificationBinding, +) : RecyclerView.ViewHolder(binding.root) { + + fun setupWithReport(reporter: TimelineAccount, report: Report, animateAvatar: Boolean, animateEmojis: Boolean) { + 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) + + 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, TimestampUtils.getRelativeTimeSpanString(itemView.context, report.createdAt.time, Date().time), report.status_ids?.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, + ) + loadAvatar( + reporter.avatar, + binding.notificationReporterAvatar, + itemView.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_24dp), + animateAvatar, + ) + } + + fun setupActionListener(listener: NotificationActionListener, reporteeId: String, reporterId: String, reportId: String) { + binding.notificationReporteeAvatar.setOnClickListener { + val position = bindingAdapterPosition + if (position != RecyclerView.NO_POSITION) { + listener.onViewAccount(reporteeId) + } + } + binding.notificationReporterAvatar.setOnClickListener { + val position = bindingAdapterPosition + if (position != RecyclerView.NO_POSITION) { + listener.onViewAccount(reporterId) + } + } + + itemView.setOnClickListener { listener.onViewReport(reportId) } + } + + 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) + "other" -> context.getString(R.string.report_category_other) + else -> rawCategory + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java index 9d77b7bae..f69e9fc9c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java @@ -1,5 +1,7 @@ package com.keylesspalace.tusky.adapter; +import static com.keylesspalace.tusky.viewdata.PollViewDataKt.buildDescription; + import android.content.Context; import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.ColorDrawable; @@ -23,6 +25,7 @@ import androidx.appcompat.app.AlertDialog; import androidx.constraintlayout.widget.ConstraintLayout; import androidx.core.content.ContextCompat; import androidx.core.text.HtmlCompat; +import androidx.core.view.ViewKt; import androidx.recyclerview.widget.DefaultItemAnimator; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; @@ -54,6 +57,7 @@ import com.keylesspalace.tusky.util.StatusDisplayOptions; import com.keylesspalace.tusky.util.ThemeUtils; import com.keylesspalace.tusky.util.TimestampUtils; import com.keylesspalace.tusky.view.MediaPreviewImageView; +import com.keylesspalace.tusky.view.MediaPreviewLayout; import com.keylesspalace.tusky.viewdata.PollOptionViewData; import com.keylesspalace.tusky.viewdata.PollViewData; import com.keylesspalace.tusky.viewdata.PollViewDataKt; @@ -67,14 +71,13 @@ import at.connyduck.sparkbutton.SparkButton; import at.connyduck.sparkbutton.helpers.Utils; import kotlin.collections.CollectionsKt; -import static com.keylesspalace.tusky.viewdata.PollViewDataKt.buildDescription; - import net.accelf.yuito.QuoteInlineHelper; public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { public static class Key { public static final String KEY_CREATED = "created"; } + private TextView displayName; private TextView username; private ImageButton replyButton; @@ -85,8 +88,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { private SparkButton bookmarkButton; private ImageButton moreButton; private ConstraintLayout mediaContainer; - protected MediaPreviewImageView[] mediaPreviews; - private ImageView[] mediaOverlays; + protected MediaPreviewLayout mediaPreview; private TextView sensitiveMediaWarning; private View sensitiveMediaShow; protected TextView[] mediaLabels; @@ -139,19 +141,8 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { mediaContainer = itemView.findViewById(R.id.status_media_preview_container); mediaContainer.setClipToOutline(true); + mediaPreview = itemView.findViewById(R.id.status_media_preview); - mediaPreviews = new MediaPreviewImageView[]{ - itemView.findViewById(R.id.status_media_preview_0), - itemView.findViewById(R.id.status_media_preview_1), - itemView.findViewById(R.id.status_media_preview_2), - itemView.findViewById(R.id.status_media_preview_3) - }; - mediaOverlays = new ImageView[]{ - itemView.findViewById(R.id.status_media_overlay_0), - itemView.findViewById(R.id.status_media_overlay_1), - itemView.findViewById(R.id.status_media_overlay_2), - itemView.findViewById(R.id.status_media_overlay_3) - }; sensitiveMediaWarning = itemView.findViewById(R.id.status_sensitive_media_warning); sensitiveMediaShow = itemView.findViewById(R.id.status_sensitive_media_button); mediaLabels = new TextView[]{ @@ -190,8 +181,6 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { mediaPreviewUnloaded = new ColorDrawable(ThemeUtils.getColor(itemView.getContext(), R.attr.colorBackgroundAccent)); } - protected abstract int getMediaPreviewHeight(Context context); - protected void setDisplayName(String name, List customEmojis, StatusDisplayOptions statusDisplayOptions) { CharSequence emojifiedName = CustomEmojiHelper.emojify( name, customEmojis, displayName, statusDisplayOptions.animateEmojis() @@ -327,19 +316,25 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } - protected void setCreatedAt(Date createdAt, StatusDisplayOptions statusDisplayOptions) { + protected void setCreatedAt(Date createdAt, Date editedAt, StatusDisplayOptions statusDisplayOptions) { + String timestampText; if (statusDisplayOptions.useAbsoluteTime()) { - timestampInfo.setText(absoluteTimeFormatter.format(createdAt, true)); + timestampText = absoluteTimeFormatter.format(createdAt, true); } else { if (createdAt == null) { - timestampInfo.setText("?m"); + timestampText = "?m"; } else { long then = createdAt.getTime(); long now = System.currentTimeMillis(); String readout = TimestampUtils.getRelativeTimeSpanString(timestampInfo.getContext(), then, now); - timestampInfo.setText(readout); + timestampText = readout; } } + + if (editedAt != null) { + timestampText = timestampInfo.getContext().getString(R.string.post_timestamp_with_edited_indicator, timestampText); + } + timestampInfo.setText(timestampText); } private CharSequence getCreatedAtDescription(Date createdAt, @@ -501,64 +496,51 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { Drawable placeholder = blurhash != null ? decodeBlurHash(blurhash) : mediaPreviewUnloaded; - if (TextUtils.isEmpty(previewUrl)) { - imageView.removeFocalPoint(); - - Glide.with(imageView) - .load(placeholder) - .centerInside() - .into(imageView); - - } else { - Focus focus = meta != null ? meta.getFocus() : null; - - if (focus != null) { // If there is a focal point for this attachment: - imageView.setFocalPoint(focus); - - Glide.with(imageView) - .load(previewUrl) - .placeholder(placeholder) - .centerInside() - .addListener(imageView) - .into(imageView); - } else { + ViewKt.doOnLayout(imageView, view -> { + if (TextUtils.isEmpty(previewUrl)) { imageView.removeFocalPoint(); Glide.with(imageView) - .load(previewUrl) - .placeholder(placeholder) + .load(placeholder) .centerInside() .into(imageView); + + } else { + Focus focus = meta != null ? meta.getFocus() : null; + + if (focus != null) { // If there is a focal point for this attachment: + imageView.setFocalPoint(focus); + + Glide.with(imageView) + .load(previewUrl) + .placeholder(placeholder) + .centerInside() + .addListener(imageView) + .into(imageView); + } else { + imageView.removeFocalPoint(); + + Glide.with(imageView) + .load(previewUrl) + .placeholder(placeholder) + .centerInside() + .into(imageView); + } } - } + return null; + }); } protected void setMediaPreviews(final List attachments, boolean sensitive, final StatusActionListener listener, boolean showingContent, boolean useBlurhash) { - Context context = itemView.getContext(); - final int n = Math.min(attachments.size(), Status.MAX_MEDIA_ATTACHMENTS); + mediaPreview.setAspectRatios(AttachmentHelper.aspectRatios(attachments)); - final int mediaPreviewHeight = getMediaPreviewHeight(context); - - if (n <= 2) { - mediaPreviews[0].getLayoutParams().height = mediaPreviewHeight * 2; - mediaPreviews[1].getLayoutParams().height = mediaPreviewHeight * 2; - } else { - mediaPreviews[0].getLayoutParams().height = mediaPreviewHeight; - mediaPreviews[1].getLayoutParams().height = mediaPreviewHeight; - mediaPreviews[2].getLayoutParams().height = mediaPreviewHeight; - mediaPreviews[3].getLayoutParams().height = mediaPreviewHeight; - } - - for (int i = 0; i < n; i++) { + mediaPreview.forEachIndexed((i, imageView) -> { Attachment attachment = attachments.get(i); String previewUrl = attachment.getPreviewUrl(); String description = attachment.getDescription(); - MediaPreviewImageView imageView = mediaPreviews[i]; - - imageView.setVisibility(View.VISIBLE); if (TextUtils.isEmpty(description)) { imageView.setContentDescription(imageView.getContext() @@ -576,42 +558,38 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { final Attachment.Type type = attachment.getType(); if (showingContent && (type == Attachment.Type.VIDEO || type == Attachment.Type.GIFV)) { - mediaOverlays[i].setVisibility(View.VISIBLE); + imageView.setForeground(ContextCompat.getDrawable(itemView.getContext(), R.drawable.play_indicator_overlay)); } else { - mediaOverlays[i].setVisibility(View.GONE); + imageView.setForeground(null); } setAttachmentClickListener(imageView, listener, i, attachment, true); - } - if (sensitive) { - sensitiveMediaWarning.setText(R.string.post_sensitive_media_title); - } else { - sensitiveMediaWarning.setText(R.string.post_media_hidden_title); - } - - sensitiveMediaWarning.setVisibility(showingContent ? View.GONE : View.VISIBLE); - sensitiveMediaShow.setVisibility(showingContent ? View.VISIBLE : View.GONE); - sensitiveMediaShow.setOnClickListener(v -> { - if (getBindingAdapterPosition() != RecyclerView.NO_POSITION) { - listener.onContentHiddenChange(false, getBindingAdapterPosition()); + if (sensitive) { + sensitiveMediaWarning.setText(R.string.post_sensitive_media_title); + } else { + sensitiveMediaWarning.setText(R.string.post_media_hidden_title); } - v.setVisibility(View.GONE); - sensitiveMediaWarning.setVisibility(View.VISIBLE); - }); - sensitiveMediaWarning.setOnClickListener(v -> { - if (getBindingAdapterPosition() != RecyclerView.NO_POSITION) { - listener.onContentHiddenChange(true, getBindingAdapterPosition()); - } - v.setVisibility(View.GONE); - sensitiveMediaShow.setVisibility(View.VISIBLE); - }); + sensitiveMediaWarning.setVisibility(showingContent ? View.GONE : View.VISIBLE); + sensitiveMediaShow.setVisibility(showingContent ? View.VISIBLE : View.GONE); + sensitiveMediaShow.setOnClickListener(v -> { + if (getBindingAdapterPosition() != RecyclerView.NO_POSITION) { + listener.onContentHiddenChange(false, getBindingAdapterPosition()); + } + v.setVisibility(View.GONE); + sensitiveMediaWarning.setVisibility(View.VISIBLE); + }); + sensitiveMediaWarning.setOnClickListener(v -> { + if (getBindingAdapterPosition() != RecyclerView.NO_POSITION) { + listener.onContentHiddenChange(true, getBindingAdapterPosition()); + } + v.setVisibility(View.GONE); + sensitiveMediaShow.setVisibility(View.VISIBLE); + }); - // Hide any of the placeholder previews beyond the ones set. - for (int i = n; i < Status.MAX_MEDIA_ATTACHMENTS; i++) { - mediaPreviews[i].setVisibility(View.GONE); - } + return null; + }); } @DrawableRes @@ -835,7 +813,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { Status actionable = status.getActionable(); setDisplayName(actionable.getAccount().getName(), actionable.getAccount().getEmojis(), statusDisplayOptions); setUsername(status.getUsername()); - setCreatedAt(actionable.getCreatedAt(), statusDisplayOptions); + setCreatedAt(actionable.getCreatedAt(), actionable.getEditedAt(), statusDisplayOptions); setStatusVisibility(actionable.getVisibility()); setIsReply(actionable.getInReplyToId() != null); setReplyCount(actionable.getRepliesCount()); @@ -860,10 +838,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } else { setMediaLabel(attachments, sensitive, listener, status.isShowingContent()); // Hide all unused views. - mediaPreviews[0].setVisibility(View.GONE); - mediaPreviews[1].setVisibility(View.GONE); - mediaPreviews[2].setVisibility(View.GONE); - mediaPreviews[3].setVisibility(View.GONE); + mediaPreview.setVisibility(View.GONE); hideSensitiveMediaWarning(); } @@ -893,7 +868,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { if (payloads instanceof List) for (Object item : (List) payloads) { if (Key.KEY_CREATED.equals(item)) { - setCreatedAt(status.getActionable().getCreatedAt(), statusDisplayOptions); + setCreatedAt(status.getActionable().getCreatedAt(), status.getActionable().getEditedAt(), statusDisplayOptions); } } @@ -919,6 +894,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { getContentWarningDescription(context, status), (TextUtils.isEmpty(status.getSpoilerText()) || !actionable.getSensitive() || status.isExpanded() ? status.getContent() : ""), getCreatedAtDescription(actionable.getCreatedAt(), statusDisplayOptions), + actionable.getEditedAt() != null ? context.getString(R.string.description_post_edited) : "", getReblogDescription(context, status), status.getUsername(), actionable.getReblogged() ? context.getString(R.string.description_post_reblogged) : "", diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java index 398ec2ccf..35b2d7ac5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java @@ -1,6 +1,7 @@ package com.keylesspalace.tusky.adapter; import android.content.Context; +import android.text.TextUtils; import android.text.method.LinkMovementMethod; import android.view.View; import android.widget.TextView; @@ -18,7 +19,9 @@ import com.keylesspalace.tusky.util.StatusDisplayOptions; import com.keylesspalace.tusky.viewdata.StatusViewData; import java.text.DateFormat; +import java.util.ArrayList; import java.util.Date; +import java.util.List; public class StatusDetailedViewHolder extends StatusBaseViewHolder { private final TextView reblogs; @@ -33,18 +36,17 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder { } @Override - protected int getMediaPreviewHeight(Context context) { - return context.getResources().getDimensionPixelSize(R.dimen.status_detail_media_preview_height); - } - - @Override - protected void setCreatedAt(Date createdAt, StatusDisplayOptions statusDisplayOptions) { - if (createdAt == null) { - timestampInfo.setText(""); - } else { - DateFormat dateFormat = DateFormat.getDateTimeInstance(DateFormat.DEFAULT, DateFormat.SHORT); - timestampInfo.setText(dateFormat.format(createdAt)); + protected void setCreatedAt(Date createdAt, Date editedAt, StatusDisplayOptions statusDisplayOptions) { + DateFormat dateFormat = DateFormat.getDateTimeInstance(DateFormat.DEFAULT, DateFormat.SHORT); + Context context = timestampInfo.getContext(); + List list = new ArrayList<>(); + if (createdAt != null) { + list.add(dateFormat.format(createdAt)); } + if (editedAt != null) { + list.add(context.getString(R.string.post_edited, dateFormat.format(editedAt))); + } + timestampInfo.setText(TextUtils.join(context.getString(R.string.timestamp_joiner), list)); } private void setReblogAndFavCount(int reblogCount, int favCount, StatusActionListener listener) { diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java index 93c475643..76d129170 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java @@ -53,11 +53,6 @@ public class StatusViewHolder extends StatusBaseViewHolder { contentCollapseButton = itemView.findViewById(R.id.button_toggle_content); } - @Override - protected int getMediaPreviewHeight(Context context) { - return context.getResources().getDimensionPixelSize(R.dimen.status_media_preview_height); - } - @Override public void setupWithStatus(@NonNull StatusViewData.Concrete status, @NonNull final StatusActionListener listener, 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 30ebf436e..254218fc8 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 @@ -53,6 +53,7 @@ import com.keylesspalace.tusky.EditProfileActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.StatusListActivity import com.keylesspalace.tusky.ViewMediaActivity +import com.keylesspalace.tusky.components.account.list.ListsForAccountFragment import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.components.report.ReportActivity import com.keylesspalace.tusky.databinding.ActivityAccountBinding @@ -103,6 +104,8 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI private lateinit var accountFieldAdapter: AccountFieldAdapter + private val preferences by lazy { PreferenceManager.getDefaultSharedPreferences(this) } + private var followState: FollowState = FollowState.NOT_FOLLOWING private var blocking: Boolean = false private var muting: Boolean = false @@ -247,6 +250,9 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI val pageMargin = resources.getDimensionPixelSize(R.dimen.tab_page_margin) binding.accountFragmentViewPager.setPageTransformer(MarginPageTransformer(pageMargin)) + val enableSwipeForTabs = preferences.getBoolean(PrefKeys.ENABLE_SWIPE_FOR_TABS, true) + binding.accountFragmentViewPager.isUserInputEnabled = enableSwipeForTabs + binding.accountTabLayout.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener { override fun onTabReselected(tab: TabLayout.Tab?) { tab?.position?.let { position -> @@ -737,6 +743,10 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI menu.removeItem(R.id.action_report) } + if (!viewModel.isSelf && followState != FollowState.FOLLOWING) { + menu.removeItem(R.id.action_add_or_remove_from_list) + } + return super.onCreateOptionsMenu(menu) } @@ -849,6 +859,10 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI toggleMute() return true } + R.id.action_add_or_remove_from_list -> { + ListsForAccountFragment.newInstance(viewModel.accountId).show(supportFragmentManager, null) + return true + } R.id.action_mute_domain -> { toggleBlockDomain(domain) return true diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/list/ListsForAccountFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/list/ListsForAccountFragment.kt new file mode 100644 index 000000000..874d4b9f2 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/list/ListsForAccountFragment.kt @@ -0,0 +1,210 @@ +/* Copyright 2022 kyori19 + * + * 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.account.list + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.ListAdapter +import com.google.android.material.snackbar.Snackbar +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.databinding.FragmentListsForAccountBinding +import com.keylesspalace.tusky.databinding.ItemAddOrRemoveFromListBinding +import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.di.ViewModelFactory +import com.keylesspalace.tusky.util.BindingHolder +import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.util.viewBinding +import com.keylesspalace.tusky.util.visible +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import java.io.IOException +import javax.inject.Inject + +class ListsForAccountFragment : DialogFragment(), Injectable { + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + private val viewModel: ListsForAccountViewModel by viewModels { viewModelFactory } + private val binding by viewBinding(FragmentListsForAccountBinding::bind) + + private val adapter = Adapter() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setStyle(STYLE_NORMAL, R.style.TuskyDialogFragmentStyle) + + viewModel.setup(requireArguments().getString(ARG_ACCOUNT_ID)!!) + } + + override fun onStart() { + super.onStart() + dialog?.apply { + window?.setLayout( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.MATCH_PARENT, + ) + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return inflater.inflate(R.layout.fragment_lists_for_account, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + binding.listsView.layoutManager = LinearLayoutManager(view.context) + binding.listsView.adapter = adapter + + viewLifecycleOwner.lifecycleScope.launch { + viewModel.states.collectLatest { states -> + binding.progressBar.hide() + if (states.isEmpty()) { + binding.messageView.show() + binding.messageView.setup(R.drawable.elephant_friend_empty, R.string.no_lists) { + load() + } + } else { + binding.listsView.show() + adapter.submitList(states) + } + } + } + + viewLifecycleOwner.lifecycleScope.launch { + viewModel.loadError.collectLatest { error -> + binding.progressBar.hide() + binding.listsView.hide() + binding.messageView.apply { + show() + + if (error is IOException) { + setup(R.drawable.elephant_offline, R.string.error_network) { + load() + } + } else { + setup(R.drawable.elephant_error, R.string.error_generic) { + load() + } + } + } + } + } + + viewLifecycleOwner.lifecycleScope.launch { + viewModel.actionError.collectLatest { error -> + when (error.type) { + ActionError.Type.ADD -> { + Snackbar.make(binding.root, R.string.failed_to_add_to_list, Snackbar.LENGTH_LONG) + .setAction(R.string.action_retry) { + viewModel.addAccountToList(error.listId) + } + .show() + } + ActionError.Type.REMOVE -> { + Snackbar.make(binding.root, R.string.failed_to_remove_from_list, Snackbar.LENGTH_LONG) + .setAction(R.string.action_retry) { + viewModel.removeAccountFromList(error.listId) + } + .show() + } + } + } + } + + binding.doneButton.setOnClickListener { + dismiss() + } + + load() + } + + private fun load() { + binding.progressBar.show() + binding.listsView.hide() + binding.messageView.hide() + viewModel.load() + } + + private object Differ : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: AccountListState, + newItem: AccountListState + ): Boolean { + return oldItem.list.id == newItem.list.id + } + + override fun areContentsTheSame( + oldItem: AccountListState, + newItem: AccountListState + ): Boolean { + return oldItem == newItem + } + } + + inner class Adapter : + ListAdapter>(Differ) { + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): BindingHolder { + val binding = + ItemAddOrRemoveFromListBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return BindingHolder(binding) + } + + override fun onBindViewHolder(holder: BindingHolder, position: Int) { + val item = getItem(position) + holder.binding.listNameView.text = item.list.title + holder.binding.addButton.apply { + visible(!item.includesAccount) + setOnClickListener { + viewModel.addAccountToList(item.list.id) + } + } + holder.binding.removeButton.apply { + visible(item.includesAccount) + setOnClickListener { + viewModel.removeAccountFromList(item.list.id) + } + } + } + } + + companion object { + private const val ARG_ACCOUNT_ID = "accountId" + + fun newInstance(accountId: String): ListsForAccountFragment { + val args = Bundle().apply { + putString(ARG_ACCOUNT_ID, accountId) + } + return ListsForAccountFragment().apply { arguments = args } + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/list/ListsForAccountViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/list/ListsForAccountViewModel.kt new file mode 100644 index 000000000..b571390e5 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/list/ListsForAccountViewModel.kt @@ -0,0 +1,137 @@ +/* Copyright 2022 kyori19 + * + * 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.account.list + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import at.connyduck.calladapter.networkresult.getOrThrow +import at.connyduck.calladapter.networkresult.onFailure +import at.connyduck.calladapter.networkresult.onSuccess +import at.connyduck.calladapter.networkresult.runCatching +import com.keylesspalace.tusky.entity.MastoList +import com.keylesspalace.tusky.network.MastodonApi +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class AccountListState( + val list: MastoList, + val includesAccount: Boolean, +) + +data class ActionError( + val error: Throwable, + val type: Type, + val listId: String, +) : Throwable(error) { + enum class Type { + ADD, + REMOVE, + } +} + +@OptIn(ExperimentalCoroutinesApi::class) +class ListsForAccountViewModel @Inject constructor( + private val mastodonApi: MastodonApi, +) : ViewModel() { + + private lateinit var accountId: String + + private val _states = MutableSharedFlow>(1) + val states: SharedFlow> = _states + + private val _loadError = MutableSharedFlow(1) + val loadError: SharedFlow = _loadError + + private val _actionError = MutableSharedFlow(1) + val actionError: SharedFlow = _actionError + + fun setup(accountId: String) { + this.accountId = accountId + } + + fun load() { + _loadError.resetReplayCache() + viewModelScope.launch { + runCatching { + val (all, includes) = listOf( + async { mastodonApi.getLists() }, + async { mastodonApi.getListsIncludesAccount(accountId) }, + ).awaitAll() + + _states.emit( + all.getOrThrow().map { list -> + AccountListState( + list = list, + includesAccount = includes.getOrThrow().any { it.id == list.id }, + ) + } + ) + } + .onFailure { + _loadError.emit(it) + } + } + } + + fun addAccountToList(listId: String) { + _actionError.resetReplayCache() + viewModelScope.launch { + mastodonApi.addAccountToList(listId, listOf(accountId)) + .onSuccess { + _states.emit( + _states.first().map { state -> + if (state.list.id == listId) { + state.copy(includesAccount = true) + } else { + state + } + } + ) + } + .onFailure { + _actionError.emit(ActionError(it, ActionError.Type.ADD, listId)) + } + } + } + + fun removeAccountFromList(listId: String) { + _actionError.resetReplayCache() + viewModelScope.launch { + mastodonApi.deleteAccountFromList(listId, listOf(accountId)) + .onSuccess { + _states.emit( + _states.first().map { state -> + if (state.list.id == listId) { + state.copy(includesAccount = false) + } else { + state + } + } + ) + } + .onFailure { + _actionError.emit(ActionError(it, ActionError.Type.REMOVE, listId)) + } + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaFragment.kt index 69d651d51..457cda7b9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaFragment.kt @@ -16,7 +16,6 @@ package com.keylesspalace.tusky.components.account.media import android.os.Bundle -import android.util.Log import android.view.View import androidx.core.app.ActivityOptionsCompat import androidx.core.view.ViewCompat @@ -39,7 +38,6 @@ 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.util.visible import com.keylesspalace.tusky.viewdata.AttachmentViewData import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @@ -107,21 +105,30 @@ class AccountMediaFragment : } adapter.addLoadStateListener { loadState -> - binding.progressBar.visible(loadState.refresh == LoadState.Loading && adapter.itemCount == 0) + binding.statusView.hide() + binding.progressBar.hide() - if (loadState.refresh is LoadState.Error) { - binding.recyclerView.hide() - binding.statusView.show() - val errorState = loadState.refresh as LoadState.Error - if (errorState.error is IOException) { - binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) { adapter.retry() } - } else { - binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic) { adapter.retry() } + 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, null) + } + } + is LoadState.Error -> { + binding.statusView.show() + + if ((loadState.refresh as LoadState.Error).error is IOException) { + binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network, null) + } else { + binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic, null) + } + } + is LoadState.Loading -> { + binding.progressBar.show() + } } - Log.w(TAG, "error loading account media", errorState.error) - } else { - binding.recyclerView.show() - binding.statusView.hide() } } } 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 fe57bdb69..154519ba8 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 @@ -92,10 +92,13 @@ import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.util.PickMediaFiles import com.keylesspalace.tusky.util.ThemeUtils import com.keylesspalace.tusky.util.afterTextChanged +import com.keylesspalace.tusky.util.getInitialLanguage +import com.keylesspalace.tusky.util.getLocaleList import com.keylesspalace.tusky.util.getMediaSize import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.highlightSpans import com.keylesspalace.tusky.util.loadAvatar +import com.keylesspalace.tusky.util.modernLanguageCode import com.keylesspalace.tusky.util.onTextChanged import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.viewBinding @@ -269,7 +272,7 @@ class ComposeActivity : binding.composeScheduleView.setDateTime(composeOptions?.scheduledAt) } - setupLanguageSpinner(getInitialLanguage(composeOptions?.language)) + setupLanguageSpinner(getInitialLanguage(composeOptions?.language, accountManager.activeAccount)) setupComposeField(preferences, viewModel.startingText) setupDefaultTagViews(preferences) setupContentWarningField(composeOptions?.contentWarning) @@ -604,37 +607,19 @@ class ComposeActivity : ) } - private fun setupLanguageSpinner(initialLanguage: String?) { - val locales = Locale.getAvailableLocales() - .filter { it.country.isNullOrEmpty() && it.script.isNullOrEmpty() && it.variant.isNullOrEmpty() } // Only "base" languages, "en" but not "en_DK" - var currentLocaleIndex = locales.indexOfFirst { it.language == initialLanguage } - if (currentLocaleIndex < 0) { - Log.e(TAG, "Error looking up language tag '$initialLanguage', falling back to english") - currentLocaleIndex = locales.indexOfFirst { it.language == "en" } - } - - val context = this + private fun setupLanguageSpinner(initialLanguage: String) { binding.composePostLanguageButton.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) { - viewModel.postLanguage = (parent.adapter.getItem(position) as Locale).language + viewModel.postLanguage = (parent.adapter.getItem(position) as Locale).modernLanguageCode } override fun onNothingSelected(parent: AdapterView<*>) { - parent.setSelection(locales.indexOfFirst { it.language == getInitialLanguage() }) + parent.setSelection(0) } } binding.composePostLanguageButton.apply { - adapter = LocaleAdapter(context, android.R.layout.simple_spinner_dropdown_item, locales) - setSelection(currentLocaleIndex) - } - } - - private fun getInitialLanguage(language: String? = null): String { - return if (language.isNullOrEmpty()) { - // Setting the application ui preference sets the default locale - Locale.getDefault().language - } else { - language + adapter = LocaleAdapter(context, android.R.layout.simple_spinner_dropdown_item, getLocaleList(initialLanguage)) + setSelection(0) } } @@ -874,7 +859,7 @@ class ComposeActivity : // Wait until bottom sheet is not collapsed and show next screen after if (newState == BottomSheetBehavior.STATE_COLLAPSED) { addMediaBehavior.removeBottomSheetCallback(this) - if (ContextCompat.checkSelfPermission(this@ComposeActivity, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && ContextCompat.checkSelfPermission(this@ComposeActivity, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { ActivityCompat.requestPermissions( this@ComposeActivity, arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), 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 949fe6bf1..190de74c2 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 @@ -80,6 +80,7 @@ data class ConversationStatusEntity( val account: ConversationAccountEntity, val content: String, val createdAt: Date, + val editedAt: Date?, val emojis: List, val favouritesCount: Int, val repliesCount: Int, @@ -109,6 +110,7 @@ data class ConversationStatusEntity( content = content, reblog = null, createdAt = createdAt, + editedAt = editedAt, emojis = emojis, reblogsCount = 0, favouritesCount = favouritesCount, @@ -147,7 +149,11 @@ fun TimelineAccount.toEntity() = emojis = emojis ?: emptyList() ) -fun Status.toEntity() = +fun Status.toEntity( + expanded: Boolean, + contentShowing: Boolean, + contentCollapsed: Boolean +) = ConversationStatusEntity( id = id, url = url, @@ -156,6 +162,7 @@ fun Status.toEntity() = account = account.toEntity(), content = content, createdAt = createdAt, + editedAt = editedAt, emojis = emojis, favouritesCount = favouritesCount, repliesCount = repliesCount, @@ -166,20 +173,30 @@ fun Status.toEntity() = attachments = attachments, mentions = mentions, tags = tags, - showingHiddenContent = false, - expanded = false, - collapsed = true, + showingHiddenContent = contentShowing, + expanded = expanded, + collapsed = contentCollapsed, muted = muted ?: false, poll = poll, language = language, ) -fun Conversation.toEntity(accountId: Long, order: Int) = +fun Conversation.toEntity( + accountId: Long, + order: Int, + expanded: Boolean, + contentShowing: Boolean, + contentCollapsed: Boolean +) = ConversationEntity( accountId = accountId, id = id, order = order, accounts = accounts.map { it.toEntity() }, unread = unread, - lastStatus = lastStatus!!.toEntity() + lastStatus = lastStatus!!.toEntity( + expanded = expanded, + contentShowing = contentShowing, + contentCollapsed = contentCollapsed + ) ) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewData.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewData.kt index f9082e8a3..04e69d4c4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewData.kt @@ -71,6 +71,7 @@ fun StatusViewData.Concrete.toConversationStatusEntity( account = status.account.toEntity(), content = status.content, createdAt = status.createdAt, + editedAt = status.editedAt, emojis = status.emojis, favouritesCount = status.favouritesCount, repliesCount = status.repliesCount, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java index 64801b6d5..22da6de27 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java @@ -68,11 +68,6 @@ public class ConversationViewHolder extends StatusBaseViewHolder { this.listener = listener; } - @Override - protected int getMediaPreviewHeight(Context context) { - return context.getResources().getDimensionPixelSize(R.dimen.status_media_preview_height); - } - void setupWithConversation( @NonNull ConversationViewData conversation, @Nullable Object payloads @@ -88,7 +83,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder { setDisplayName(account.getDisplayName(), account.getEmojis(), statusDisplayOptions); setUsername(account.getUsername()); - setCreatedAt(status.getCreatedAt(), statusDisplayOptions); + setCreatedAt(status.getCreatedAt(), status.getEditedAt(), statusDisplayOptions); setIsReply(status.getInReplyToId() != null); setFavourited(status.getFavourited()); setBookmarked(status.getBookmarked()); @@ -108,10 +103,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder { } else { setMediaLabel(attachments, sensitive, listener, statusViewData.isShowingContent()); // Hide all unused views. - mediaPreviews[0].setVisibility(View.GONE); - mediaPreviews[1].setVisibility(View.GONE); - mediaPreviews[2].setVisibility(View.GONE); - mediaPreviews[3].setVisibility(View.GONE); + mediaPreview.setVisibility(View.GONE); hideSensitiveMediaWarning(); } @@ -129,7 +121,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder { if (payloads instanceof List) { for (Object item : (List) payloads) { if (Key.KEY_CREATED.equals(item)) { - setCreatedAt(status.getCreatedAt(), statusDisplayOptions); + setCreatedAt(status.getCreatedAt(), status.getEditedAt(), statusDisplayOptions); } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRemoteMediator.kt index 02a44f951..921d694b2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRemoteMediator.kt @@ -5,6 +5,7 @@ import androidx.paging.LoadType import androidx.paging.PagingState import androidx.paging.RemoteMediator import androidx.room.withTransaction +import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.HttpHeaderLink @@ -12,15 +13,17 @@ import retrofit2.HttpException @OptIn(ExperimentalPagingApi::class) class ConversationsRemoteMediator( - private val accountId: Long, private val api: MastodonApi, - private val db: AppDatabase + private val db: AppDatabase, + accountManager: AccountManager, ) : RemoteMediator() { private var nextKey: String? = null private var order: Int = 0 + private val activeAccount = accountManager.activeAccount!! + override suspend fun load( loadType: LoadType, state: PagingState @@ -46,7 +49,7 @@ class ConversationsRemoteMediator( db.withTransaction { if (loadType == LoadType.REFRESH) { - db.conversationDao().deleteForAccount(accountId) + db.conversationDao().deleteForAccount(activeAccount.id) } val linkHeader = conversationsResponse.headers()["Link"] @@ -56,8 +59,19 @@ class ConversationsRemoteMediator( db.conversationDao().insert( conversations .filterNot { it.lastStatus == null } - .map { - it.toEntity(accountId, order++) + .map { conversation -> + + val expanded = activeAccount.alwaysOpenSpoiler + val contentShowing = activeAccount.alwaysShowSensitiveMedia || !conversation.lastStatus!!.sensitive + val contentCollapsed = true + + conversation.toEntity( + accountId = activeAccount.id, + order = order++, + expanded = expanded, + contentShowing = contentShowing, + contentCollapsed = contentCollapsed + ) } ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt index 735aa26c1..4e140b761 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt @@ -27,6 +27,7 @@ import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.usecase.TimelineCases +import com.keylesspalace.tusky.util.EmptyPagingSource import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.rx3.await @@ -42,8 +43,15 @@ class ConversationsViewModel @Inject constructor( @OptIn(ExperimentalPagingApi::class) val conversationFlow = Pager( config = PagingConfig(pageSize = 30), - remoteMediator = ConversationsRemoteMediator(accountManager.activeAccount!!.id, api, database), - pagingSourceFactory = { database.conversationDao().conversationsForAccount(accountManager.activeAccount!!.id) } + remoteMediator = ConversationsRemoteMediator(api, database, accountManager), + pagingSourceFactory = { + val activeAccount = accountManager.activeAccount + if (activeAccount == null) { + EmptyPagingSource() + } else { + database.conversationDao().conversationsForAccount(activeAccount.id) + } + } ) .flow .map { pagingData -> diff --git a/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsActivity.kt new file mode 100644 index 000000000..0b8e7a5c7 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsActivity.kt @@ -0,0 +1,148 @@ +package com.keylesspalace.tusky.components.followedtags + +import android.os.Bundle +import android.util.Log +import androidx.activity.viewModels +import androidx.lifecycle.lifecycleScope +import androidx.paging.LoadState +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.SimpleItemAnimator +import at.connyduck.calladapter.networkresult.fold +import com.google.android.material.snackbar.Snackbar +import com.keylesspalace.tusky.BaseActivity +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.databinding.ActivityFollowedTagsBinding +import com.keylesspalace.tusky.di.ViewModelFactory +import com.keylesspalace.tusky.interfaces.HashtagActionListener +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.util.viewBinding +import com.keylesspalace.tusky.util.visible +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import java.io.IOException +import javax.inject.Inject + +class FollowedTagsActivity : BaseActivity(), HashtagActionListener { + @Inject + lateinit var api: MastodonApi + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + private val binding by viewBinding(ActivityFollowedTagsBinding::inflate) + private val viewModel: FollowedTagsViewModel by viewModels { viewModelFactory } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContentView(binding.root) + setSupportActionBar(binding.includedToolbar.toolbar) + supportActionBar?.run { + setTitle(R.string.title_followed_hashtags) + // Back button + setDisplayHomeAsUpEnabled(true) + setDisplayShowHomeEnabled(true) + } + + setupAdapter().let { adapter -> + setupRecyclerView(adapter) + + lifecycleScope.launch { + viewModel.pager.collectLatest { pagingData -> + adapter.submitData(pagingData) + } + } + } + } + + private fun setupRecyclerView(adapter: FollowedTagsAdapter) { + binding.followedTagsView.adapter = adapter + binding.followedTagsView.setHasFixedSize(true) + binding.followedTagsView.layoutManager = LinearLayoutManager(this) + binding.followedTagsView.addItemDecoration(DividerItemDecoration(this, DividerItemDecoration.VERTICAL)) + (binding.followedTagsView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false + } + + private fun setupAdapter(): FollowedTagsAdapter { + return FollowedTagsAdapter(this, viewModel).apply { + addLoadStateListener { loadState -> + binding.followedTagsProgressBar.visible(loadState.refresh == LoadState.Loading && itemCount == 0) + + if (loadState.refresh is LoadState.Error) { + binding.followedTagsView.hide() + binding.followedTagsMessageView.show() + val errorState = loadState.refresh as LoadState.Error + if (errorState.error is IOException) { + binding.followedTagsMessageView.setup(R.drawable.elephant_offline, R.string.error_network) { retry() } + } else { + binding.followedTagsMessageView.setup(R.drawable.elephant_error, R.string.error_generic) { retry() } + } + Log.w(TAG, "error loading followed hashtags", errorState.error) + } else { + binding.followedTagsView.show() + binding.followedTagsMessageView.hide() + } + } + } + } + + private fun follow(tagName: String, position: Int) { + lifecycleScope.launch { + api.followTag(tagName).fold( + { + viewModel.tags.add(position, it) + viewModel.currentSource?.invalidate() + }, + { + Snackbar.make( + this@FollowedTagsActivity, + binding.followedTagsView, + getString(R.string.error_following_hashtag_format, tagName), + Snackbar.LENGTH_SHORT + ) + .show() + } + ) + } + } + + override fun unfollow(tagName: String, position: Int) { + lifecycleScope.launch { + api.unfollowTag(tagName).fold( + { + viewModel.tags.removeAt(position) + Snackbar.make( + this@FollowedTagsActivity, + binding.followedTagsView, + getString(R.string.confirmation_hashtag_unfollowed, tagName), + Snackbar.LENGTH_LONG + ) + .setAction(R.string.action_undo) { + follow(tagName, position) + } + .show() + viewModel.currentSource?.invalidate() + }, + { + Snackbar.make( + this@FollowedTagsActivity, + binding.followedTagsView, + getString( + R.string.error_unfollowing_hashtag_format, + tagName + ), + Snackbar.LENGTH_SHORT + ) + .show() + } + ) + } + } + + companion object { + const val TAG = "FollowedTagsActivity" + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsAdapter.kt new file mode 100644 index 000000000..365900886 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsAdapter.kt @@ -0,0 +1,38 @@ +package com.keylesspalace.tusky.components.followedtags + +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.ImageButton +import android.widget.TextView +import androidx.paging.PagingDataAdapter +import androidx.recyclerview.widget.DiffUtil +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.databinding.ItemFollowedHashtagBinding +import com.keylesspalace.tusky.interfaces.HashtagActionListener +import com.keylesspalace.tusky.util.BindingHolder + +class FollowedTagsAdapter( + private val actionListener: HashtagActionListener, + private val viewModel: FollowedTagsViewModel, +) : PagingDataAdapter>(STRING_COMPARATOR) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder = + BindingHolder(ItemFollowedHashtagBinding.inflate(LayoutInflater.from(parent.context), parent, false)) + + override fun onBindViewHolder(holder: BindingHolder, position: Int) { + viewModel.tags[position].let { tag -> + holder.itemView.findViewById(R.id.followed_tag).text = tag.name + holder.itemView.findViewById(R.id.followed_tag_unfollow).setOnClickListener { + actionListener.unfollow(tag.name, position) + } + } + } + + override fun getItemCount(): Int = viewModel.tags.size + + companion object { + val STRING_COMPARATOR = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: String, newItem: String): Boolean = oldItem == newItem + override fun areContentsTheSame(oldItem: String, newItem: String): Boolean = oldItem == newItem + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsPagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsPagingSource.kt new file mode 100644 index 000000000..da5479c9b --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsPagingSource.kt @@ -0,0 +1,16 @@ +package com.keylesspalace.tusky.components.followedtags + +import androidx.paging.PagingSource +import androidx.paging.PagingState + +class FollowedTagsPagingSource(private val viewModel: FollowedTagsViewModel) : PagingSource() { + override fun getRefreshKey(state: PagingState): String? = null + + override suspend fun load(params: LoadParams): LoadResult { + return if (params is LoadParams.Refresh) { + LoadResult.Page(viewModel.tags.map { it.name }, null, viewModel.nextKey) + } else { + LoadResult.Page(emptyList(), null, null) + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsRemoteMediator.kt new file mode 100644 index 000000000..649ca583e --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsRemoteMediator.kt @@ -0,0 +1,57 @@ +package com.keylesspalace.tusky.components.followedtags + +import androidx.paging.ExperimentalPagingApi +import androidx.paging.LoadType +import androidx.paging.PagingState +import androidx.paging.RemoteMediator +import com.keylesspalace.tusky.entity.HashTag +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.HttpHeaderLink +import retrofit2.HttpException +import retrofit2.Response + +@OptIn(ExperimentalPagingApi::class) +class FollowedTagsRemoteMediator( + private val api: MastodonApi, + private val viewModel: FollowedTagsViewModel, +) : RemoteMediator() { + override suspend fun load( + loadType: LoadType, + state: PagingState + ): MediatorResult { + return try { + val response = request(loadType) + ?: return MediatorResult.Success(endOfPaginationReached = true) + + return applyResponse(response) + } catch (e: Exception) { + MediatorResult.Error(e) + } + } + + private suspend fun request(loadType: LoadType): Response>? { + return when (loadType) { + LoadType.PREPEND -> null + LoadType.APPEND -> api.followedTags(maxId = viewModel.nextKey) + LoadType.REFRESH -> { + viewModel.nextKey = null + viewModel.tags.clear() + api.followedTags() + } + } + } + + private fun applyResponse(response: Response>): MediatorResult { + val tags = response.body() + if (!response.isSuccessful || tags == null) { + return MediatorResult.Error(HttpException(response)) + } + + val links = HttpHeaderLink.parse(response.headers()["Link"]) + viewModel.nextKey = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter("max_id") + viewModel.tags.addAll(tags) + viewModel.currentSource?.invalidate() + + return MediatorResult.Success(endOfPaginationReached = viewModel.nextKey == null) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsViewModel.kt new file mode 100644 index 000000000..efe5661a9 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsViewModel.kt @@ -0,0 +1,33 @@ +package com.keylesspalace.tusky.components.followedtags + +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 com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.entity.HashTag +import com.keylesspalace.tusky.network.MastodonApi +import javax.inject.Inject + +class FollowedTagsViewModel @Inject constructor ( + api: MastodonApi +) : ViewModel(), Injectable { + val tags: MutableList = mutableListOf() + var nextKey: String? = null + var currentSource: FollowedTagsPagingSource? = null + + @OptIn(ExperimentalPagingApi::class) + val pager = Pager( + config = PagingConfig(pageSize = 100), + remoteMediator = FollowedTagsRemoteMediator(api, this), + pagingSourceFactory = { + FollowedTagsPagingSource( + viewModel = this + ).also { source -> + currentSource = source + } + }, + ).flow.cachedIn(viewModelScope) +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java index beac22aad..af70cce2e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java @@ -120,6 +120,7 @@ public class NotificationHelper { public static final String CHANNEL_SUBSCRIPTIONS = "CHANNEL_SUBSCRIPTIONS"; public static final String CHANNEL_SIGN_UP = "CHANNEL_SIGN_UP"; public static final String CHANNEL_UPDATES = "CHANNEL_UPDATES"; + public static final String CHANNEL_REPORT = "CHANNEL_REPORT"; /** * WorkManager Tag @@ -401,6 +402,7 @@ public class NotificationHelper { CHANNEL_SUBSCRIPTIONS + account.getIdentifier(), CHANNEL_SIGN_UP + account.getIdentifier(), CHANNEL_UPDATES + account.getIdentifier(), + CHANNEL_REPORT + account.getIdentifier(), }; int[] channelNames = { R.string.notification_mention_name, @@ -412,6 +414,7 @@ public class NotificationHelper { R.string.notification_subscription_name, R.string.notification_sign_up_name, R.string.notification_update_name, + R.string.notification_report_name, }; int[] channelDescriptions = { R.string.notification_mention_descriptions, @@ -423,6 +426,7 @@ public class NotificationHelper { R.string.notification_subscription_description, R.string.notification_sign_up_description, R.string.notification_update_description, + R.string.notification_report_description, }; List channels = new ArrayList<>(6); @@ -469,7 +473,7 @@ public class NotificationHelper { if (notificationManager.areNotificationsEnabled()) { for (NotificationChannel channel : notificationManager.getNotificationChannels()) { - if (channel.getImportance() > NotificationManager.IMPORTANCE_NONE) { + if (channel != null && channel.getImportance() > NotificationManager.IMPORTANCE_NONE) { Log.d(TAG, "NotificationsEnabled"); return true; } @@ -542,7 +546,7 @@ public class NotificationHelper { return false; } NotificationChannel channel = notificationManager.getNotificationChannel(channelId); - return channel.getImportance() > NotificationManager.IMPORTANCE_NONE; + return channel != null && channel.getImportance() > NotificationManager.IMPORTANCE_NONE; } switch (type) { @@ -564,6 +568,8 @@ public class NotificationHelper { return account.getNotificationsSignUps(); case UPDATE: return account.getNotificationsUpdates(); + case REPORT: + return account.getNotificationsReports(); default: return false; } @@ -593,6 +599,10 @@ public class NotificationHelper { return CHANNEL_POLL + account.getIdentifier(); case SIGN_UP: return CHANNEL_SIGN_UP + account.getIdentifier(); + case UPDATE: + return CHANNEL_UPDATES + account.getIdentifier(); + case REPORT: + return CHANNEL_REPORT + account.getIdentifier(); default: return null; } @@ -678,6 +688,8 @@ public class NotificationHelper { return String.format(context.getString(R.string.notification_sign_up_format), accountName); case UPDATE: return String.format(context.getString(R.string.notification_update_format), accountName); + case REPORT: + return context.getString(R.string.notification_report_format, account.getDomain()); } return null; } @@ -715,6 +727,12 @@ public class NotificationHelper { } return builder.toString(); } + case REPORT: + return context.getString( + R.string.notification_header_report_format, + StringUtils.unicodeWrap(notification.getAccount().getName()), + StringUtils.unicodeWrap(notification.getReport().getTargetAccount().getName()) + ); } return null; } 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 ff4380d32..d60210ba5 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 @@ -30,6 +30,7 @@ import com.keylesspalace.tusky.R import com.keylesspalace.tusky.TabPreferenceActivity import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.PreferenceChangedEvent +import com.keylesspalace.tusky.components.followedtags.FollowedTagsActivity import com.keylesspalace.tusky.components.instancemute.InstanceListActivity import com.keylesspalace.tusky.components.login.LoginActivity import com.keylesspalace.tusky.components.notifications.currentAccountNeedsMigration @@ -47,6 +48,10 @@ import com.keylesspalace.tusky.settings.preference import com.keylesspalace.tusky.settings.preferenceCategory import com.keylesspalace.tusky.settings.switchPreference import com.keylesspalace.tusky.util.ThemeUtils +import com.keylesspalace.tusky.util.getInitialLanguage +import com.keylesspalace.tusky.util.getLocaleList +import com.keylesspalace.tusky.util.getTuskyDisplayName +import com.keylesspalace.tusky.util.makeIcon import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.utils.colorInt @@ -66,6 +71,8 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { @Inject lateinit var eventHub: EventHub + private val iconSize by lazy { resources.getDimensionPixelSize(R.dimen.preference_icon_size) } + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { val context = requireContext() makePreferenceScreen { @@ -95,6 +102,20 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { } } + preference { + setTitle(R.string.title_followed_hashtags) + setIcon(R.drawable.ic_hashtag) + setOnPreferenceClickListener { + val intent = Intent(context, FollowedTagsActivity::class.java) + activity?.startActivity(intent) + activity?.overridePendingTransition( + R.anim.slide_from_right, + R.anim.slide_to_left + ) + true + } + } + preference { setTitle(R.string.action_view_mutes) setIcon(R.drawable.ic_mute_24dp) @@ -173,6 +194,29 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { } } + listPreference { + val locales = getLocaleList(getInitialLanguage(null, accountManager.activeAccount)) + setTitle(R.string.pref_default_post_language) + // Explicitly add "System default" to the start of the list + entries = ( + listOf(context.getString(R.string.system_default)) + locales.map { + it.getTuskyDisplayName(context) + } + ).toTypedArray() + entryValues = (listOf("") + locales.map { it.language }).toTypedArray() + key = PrefKeys.DEFAULT_POST_LANGUAGE + icon = makeIcon(requireContext(), GoogleMaterial.Icon.gmd_translate, iconSize) + value = accountManager.activeAccount?.defaultPostLanguage ?: "" + isPersistent = false // This will be entirely server-driven + setSummaryProvider { entry } + + setOnPreferenceChangeListener { _, newValue -> + syncWithServer(language = (newValue as String)) + eventHub.dispatch(PreferenceChangedEvent(key)) + true + } + } + switchPreference { setTitle(R.string.pref_default_media_sensitivity) setIcon(R.drawable.ic_eye_24dp) @@ -301,8 +345,8 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { } } - private fun syncWithServer(visibility: String? = null, sensitive: Boolean? = null) { - mastodonApi.accountUpdateSource(visibility, sensitive) + private fun syncWithServer(visibility: String? = null, sensitive: Boolean? = null, language: String? = null) { + mastodonApi.accountUpdateSource(visibility, sensitive, language) .enqueue(object : Callback { override fun onResponse(call: Call, response: Response) { val account = response.body() @@ -312,6 +356,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { it.defaultPostPrivacy = account.source?.privacy ?: Status.Visibility.PUBLIC it.defaultMediaSensitivity = account.source?.sensitive ?: false + it.defaultPostLanguage = language ?: "" accountManager.saveAccount(it) } } else { 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 6fdc1e8ab..b6eb43894 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 @@ -144,6 +144,17 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat(), Injectable { true } } + + switchPreference { + setTitle(R.string.pref_title_notification_filter_reports) + key = PrefKeys.NOTIFICATION_FILTER_REPORTS + isIconSpaceReserved = false + isChecked = activeAccount.notificationsReports + setOnPreferenceChangeListener { _, newValue -> + updateAccount { it.notificationsReports = newValue as Boolean } + true + } + } } preferenceCategory(R.string.pref_title_notification_alerts) { category -> diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesActivity.kt index 1054c5682..ca6f8f1dd 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesActivity.kt @@ -72,30 +72,17 @@ class PreferencesActivity : setDisplayShowHomeEnabled(true) } - val fragmentTag = "preference_fragment_$EXTRA_PREFERENCE_TYPE" + val preferenceType = intent.getIntExtra(EXTRA_PREFERENCE_TYPE, 0) + + val fragmentTag = "preference_fragment_$preferenceType" val fragment: Fragment = supportFragmentManager.findFragmentByTag(fragmentTag) - ?: when (intent.getIntExtra(EXTRA_PREFERENCE_TYPE, 0)) { - GENERAL_PREFERENCES -> { - setTitle(R.string.action_view_preferences) - PreferencesFragment.newInstance() - } - ACCOUNT_PREFERENCES -> { - setTitle(R.string.action_view_account_preferences) - AccountPreferencesFragment.newInstance() - } - NOTIFICATION_PREFERENCES -> { - setTitle(R.string.pref_title_edit_notification_settings) - NotificationPreferencesFragment.newInstance() - } - TAB_FILTER_PREFERENCES -> { - setTitle(R.string.pref_title_post_tabs) - TabFilterPreferencesFragment.newInstance() - } - PROXY_PREFERENCES -> { - setTitle(R.string.pref_title_http_proxy_settings) - ProxyPreferencesFragment.newInstance() - } + ?: when (preferenceType) { + GENERAL_PREFERENCES -> PreferencesFragment.newInstance() + ACCOUNT_PREFERENCES -> AccountPreferencesFragment.newInstance() + NOTIFICATION_PREFERENCES -> NotificationPreferencesFragment.newInstance() + TAB_FILTER_PREFERENCES -> TabFilterPreferencesFragment.newInstance() + PROXY_PREFERENCES -> ProxyPreferencesFragment.newInstance() else -> throw IllegalArgumentException("preferenceType not known") } @@ -103,6 +90,14 @@ class PreferencesActivity : replace(R.id.fragment_container, fragment, fragmentTag) } + when (preferenceType) { + GENERAL_PREFERENCES -> setTitle(R.string.action_view_preferences) + ACCOUNT_PREFERENCES -> setTitle(R.string.action_view_account_preferences) + NOTIFICATION_PREFERENCES -> setTitle(R.string.pref_title_edit_notification_settings) + TAB_FILTER_PREFERENCES -> setTitle(R.string.pref_title_post_tabs) + PROXY_PREFERENCES -> setTitle(R.string.pref_title_http_proxy_settings) + } + onBackPressedDispatcher.addCallback(this, restartActivitiesOnBackPressedCallback) restartActivitiesOnBackPressedCallback.isEnabled = savedInstanceState?.getBoolean(EXTRA_RESTART_ON_BACK, false) ?: false } @@ -141,10 +136,6 @@ class PreferencesActivity : "enableSwipeForTabs", "mainNavPosition", PrefKeys.HIDE_TOP_TOOLBAR, "viewPagerOffScreenLimit" -> { restartActivitiesOnBackPressedCallback.isEnabled = true } - "language" -> { - restartActivitiesOnBackPressedCallback.isEnabled = true - this.restartCurrentActivity() - } } eventHub.dispatch(PreferenceChangedEvent(key)) 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 bfe4c1f64..7fadb4979 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 @@ -33,14 +33,13 @@ import com.keylesspalace.tusky.settings.makePreferenceScreen import com.keylesspalace.tusky.settings.preference import com.keylesspalace.tusky.settings.preferenceCategory import com.keylesspalace.tusky.settings.switchPreference -import com.keylesspalace.tusky.util.ThemeUtils +import com.keylesspalace.tusky.util.LocaleManager import com.keylesspalace.tusky.util.deserialize import com.keylesspalace.tusky.util.getNonNullString +import com.keylesspalace.tusky.util.makeIcon import com.keylesspalace.tusky.util.serialize import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial -import com.mikepenz.iconics.utils.colorInt -import com.mikepenz.iconics.utils.sizePx import de.c1710.filemojicompat_ui.views.picker.preference.EmojiPickerPreference import javax.inject.Inject @@ -49,6 +48,9 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable { @Inject lateinit var accountManager: AccountManager + @Inject + lateinit var localeManager: LocaleManager + private val iconSize by lazy { resources.getDimensionPixelSize(R.dimen.preference_icon_size) } private var httpProxyPref: Preference? = null @@ -77,10 +79,11 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable { setDefaultValue("default") setEntries(R.array.language_entries) setEntryValues(R.array.language_values) - key = PrefKeys.LANGUAGE + key = PrefKeys.LANGUAGE + "_" // deliberately not the actual key, the real handling happens in LocaleManager setSummaryProvider { entry } setTitle(R.string.pref_title_language) icon = makeIcon(GoogleMaterial.Icon.gmd_translate) + preferenceDataStore = localeManager } listPreference { @@ -350,11 +353,7 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable { } private fun makeIcon(icon: GoogleMaterial.Icon): IconicsDrawable { - val context = requireContext() - return IconicsDrawable(context, icon).apply { - sizePx = iconSize - colorInt = ThemeUtils.getColor(context, R.attr.iconColor) - } + return makeIcon(requireContext(), icon, iconSize) } override fun onResume() { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchActivity.kt index 3d60fcdf8..d5071a554 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchActivity.kt @@ -22,12 +22,14 @@ import android.os.Bundle import android.view.Menu import androidx.activity.viewModels import androidx.appcompat.widget.SearchView +import androidx.preference.PreferenceManager import com.google.android.material.tabs.TabLayoutMediator import com.keylesspalace.tusky.BottomSheetActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.components.search.adapter.SearchPagerAdapter import com.keylesspalace.tusky.databinding.ActivitySearchBinding import com.keylesspalace.tusky.di.ViewModelFactory +import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.util.viewBinding import dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector @@ -44,6 +46,8 @@ class SearchActivity : BottomSheetActivity(), HasAndroidInjector { private val binding by viewBinding(ActivitySearchBinding::inflate) + private val preferences by lazy { PreferenceManager.getDefaultSharedPreferences(this) } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(binding.root) @@ -61,6 +65,9 @@ class SearchActivity : BottomSheetActivity(), HasAndroidInjector { binding.pages.adapter = SearchPagerAdapter(this) binding.pages.offscreenPageLimit = 4 + val enableSwipeForTabs = preferences.getBoolean(PrefKeys.ENABLE_SWIPE_FOR_TABS, true) + binding.pages.isUserInputEnabled = enableSwipeForTabs + TabLayoutMediator(binding.tabs, binding.pages) { tab, position -> tab.text = getPageTitle(position) 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 d2d73878f..e958f0944 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 @@ -23,6 +23,7 @@ import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.net.Uri +import android.os.Build import android.os.Environment import android.util.Log import android.view.View @@ -438,13 +439,21 @@ class SearchStatusesFragment : SearchFragment(), Status } private fun requestDownloadAllMedia(status: Status) { - val permissions = arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE) - (activity as BaseActivity).requestPermissions(permissions) { _, grantResults -> - if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { - downloadAllMedia(status) - } else { - Toast.makeText(context, R.string.error_media_download_permission, Toast.LENGTH_SHORT).show() + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + val permissions = arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE) + (activity as BaseActivity).requestPermissions(permissions) { _, grantResults -> + if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + downloadAllMedia(status) + } else { + Toast.makeText( + context, + R.string.error_media_download_permission, + Toast.LENGTH_SHORT + ).show() + } } + } else { + downloadAllMedia(status) } } 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 01b111184..4ecb4d3d0 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 @@ -77,6 +77,7 @@ fun Placeholder.toEntity(timelineUserId: Long): TimelineStatusEntity { inReplyToAccountId = null, content = null, createdAt = 0L, + editedAt = 0L, emojis = null, reblogsCount = 0, favouritesCount = 0, @@ -121,6 +122,7 @@ fun Status.toEntity( inReplyToAccountId = actionableStatus.inReplyToAccountId, content = actionableStatus.content, createdAt = actionableStatus.createdAt.time, + editedAt = actionableStatus.editedAt?.time, emojis = actionableStatus.emojis.let(gson::toJson), reblogsCount = actionableStatus.reblogsCount, favouritesCount = actionableStatus.favouritesCount, @@ -173,6 +175,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData { reblog = null, content = status.content.orEmpty(), createdAt = Date(status.createdAt), + editedAt = status.editedAt?.let { Date(it) }, emojis = emojis, reblogsCount = status.reblogsCount, favouritesCount = status.favouritesCount, @@ -205,6 +208,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData { reblog = reblog, content = "", createdAt = Date(status.createdAt), // lie but whatever? + editedAt = null, emojis = listOf(), reblogsCount = 0, favouritesCount = 0, @@ -236,6 +240,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData { reblog = null, content = status.content.orEmpty(), createdAt = Date(status.createdAt), + editedAt = status.editedAt?.let { Date(it) }, emojis = emojis, reblogsCount = status.reblogsCount, favouritesCount = status.favouritesCount, 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 187e2ae28..20c5ea5cc 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 @@ -44,6 +44,7 @@ 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 kotlinx.coroutines.Dispatchers import kotlinx.coroutines.asExecutor @@ -89,7 +90,7 @@ class CachedTimelineViewModel @Inject constructor( pagingSourceFactory = { val activeAccount = accountManager.activeAccount if (activeAccount == null) { - EmptyTimelinePagingSource() + EmptyPagingSource() } else { db.timelineDao().getStatuses(activeAccount.id) }.also { newPagingSource -> diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/EmptyTimelinePagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/EmptyTimelinePagingSource.kt deleted file mode 100644 index 5fd13dfb0..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/EmptyTimelinePagingSource.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.keylesspalace.tusky.components.timeline.viewmodel - -import androidx.paging.PagingSource -import androidx.paging.PagingState -import com.keylesspalace.tusky.db.TimelineStatusWithAccount - -class EmptyTimelinePagingSource : PagingSource() { - override fun getRefreshKey(state: PagingState): Int? = null - - override suspend fun load(params: LoadParams): LoadResult = LoadResult.Page(emptyList(), null, null) -} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt index f50ed2912..76cf930f4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt @@ -171,6 +171,12 @@ class ViewThreadFragment : SFragment(), OnRefreshListener, StatusActionListener, } } is ThreadUiState.Success -> { + if (uiState.statuses.none { viewData -> viewData.isDetailed }) { + // no detailed statuses available, e.g. because author is blocked + activity?.finish() + return@collect + } + adapter.submitList(uiState.statuses) { if (viewModel.isInitialLoad) { viewModel.isInitialLoad = false 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 c109494cd..f4d7e14d5 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 @@ -262,7 +262,7 @@ class ViewThreadViewModel @Inject constructor( updateSuccess { uiState -> uiState.copy( statuses = uiState.statuses.filter { viewData -> - viewData.status.account.id == accountId + viewData.status.account.id != accountId } ) } @@ -366,11 +366,12 @@ class ViewThreadViewModel @Inject constructor( } private fun Status.toViewData(detailed: Boolean = false): StatusViewData.Concrete { + val oldStatus = (_uiState.value as? ThreadUiState.Success)?.statuses?.find { it.id == this.id } return toViewData( - isShowingContent = alwaysShowSensitiveMedia || !actionableStatus.sensitive, - isExpanded = alwaysOpenSpoiler, - isCollapsed = !detailed, - isDetailed = detailed + isShowingContent = oldStatus?.isShowingContent ?: (alwaysShowSensitiveMedia || !actionableStatus.sensitive), + isExpanded = oldStatus?.isExpanded ?: alwaysOpenSpoiler, + isCollapsed = oldStatus?.isCollapsed ?: !detailed, + isDetailed = oldStatus?.isDetailed ?: detailed ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt index 5ffc9021d..f20660083 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt @@ -54,11 +54,13 @@ data class AccountEntity( var notificationsSubscriptions: Boolean = true, var notificationsSignUps: Boolean = true, var notificationsUpdates: Boolean = true, + var notificationsReports: Boolean = true, var notificationSound: Boolean = true, var notificationVibration: Boolean = true, var notificationLight: Boolean = true, var defaultPostPrivacy: Status.Visibility = Status.Visibility.PUBLIC, var defaultMediaSensitivity: Boolean = false, + var defaultPostLanguage: String = "", var alwaysShowSensitiveMedia: Boolean = false, var alwaysOpenSpoiler: Boolean = false, var mediaPreviewEnabled: Boolean = 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 951cb9776..c4956b814 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt @@ -154,6 +154,7 @@ class AccountManager @Inject constructor(db: AppDatabase) { it.displayName = account.name it.profilePictureUrl = account.avatar it.defaultPostPrivacy = account.source?.privacy ?: Status.Visibility.PUBLIC + it.defaultPostLanguage = account.source?.language ?: "" it.defaultMediaSensitivity = account.source?.sensitive ?: false it.emojis = account.emojis ?: emptyList() 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 be169e49c..843e20b21 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java +++ b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java @@ -31,7 +31,7 @@ import java.io.File; */ @Database(entities = { DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class, TimelineAccountEntity.class, ConversationEntity.class - }, version = 42) + }, version = 45) public abstract class AppDatabase extends RoomDatabase { public abstract AccountDao accountDao(); @@ -611,4 +611,26 @@ public abstract class AppDatabase extends RoomDatabase { database.execSQL("ALTER TABLE `ConversationEntity` ADD COLUMN `s_language` TEXT"); } }; + + public static final Migration MIGRATION_42_43 = new Migration(42, 43) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `defaultPostLanguage` TEXT NOT NULL DEFAULT ''"); + } + }; + + public static final Migration MIGRATION_43_44 = new Migration(43, 44) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `notificationsReports` INTEGER NOT NULL DEFAULT 1"); + } + }; + + public static final Migration MIGRATION_44_45 = new Migration(44, 45) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `editedAt` INTEGER"); + database.execSQL("ALTER TABLE `ConversationEntity` ADD COLUMN `s_editedAt` INTEGER"); + } + }; } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt index 8a4ef18b0..9882c70d6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt @@ -33,7 +33,7 @@ abstract class TimelineDao { @Query( """ SELECT s.serverId, s.url, s.timelineUserId, -s.authorServerId, s.inReplyToId, s.inReplyToAccountId, s.createdAt, +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, diff --git a/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt index 6d4d8ede7..0d3ed1c26 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt @@ -58,6 +58,7 @@ data class TimelineStatusEntity( val inReplyToAccountId: String?, val content: String?, val createdAt: Long, + val editedAt: Long?, val emojis: String?, val reblogsCount: Int, val favouritesCount: Int, diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt index 76f76ba7f..18c03797a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt @@ -31,6 +31,7 @@ import com.keylesspalace.tusky.components.account.AccountActivity import com.keylesspalace.tusky.components.announcements.AnnouncementsActivity import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.components.drafts.DraftsActivity +import com.keylesspalace.tusky.components.followedtags.FollowedTagsActivity import com.keylesspalace.tusky.components.instancemute.InstanceListActivity import com.keylesspalace.tusky.components.login.LoginActivity import com.keylesspalace.tusky.components.login.LoginWebViewActivity @@ -104,6 +105,9 @@ abstract class ActivitiesModule { @ContributesAndroidInjector abstract fun contributesFiltersActivity(): FiltersActivity + @ContributesAndroidInjector + abstract fun contributesFollowedTagsActivity(): FollowedTagsActivity + @ContributesAndroidInjector(modules = [FragmentBuildersModule::class]) abstract fun contributesReportActivity(): ReportActivity 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 9028dc1c7..a8e34c55e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt @@ -72,7 +72,8 @@ class AppModule { AppDatabase.MIGRATION_32_33, AppDatabase.MIGRATION_33_34, AppDatabase.MIGRATION_34_35, AppDatabase.MIGRATION_35_36, AppDatabase.MIGRATION_36_37, AppDatabase.MIGRATION_37_38, AppDatabase.MIGRATION_38_39, AppDatabase.MIGRATION_39_40, AppDatabase.MIGRATION_40_41, - AppDatabase.MIGRATION_41_42, + AppDatabase.MIGRATION_41_42, AppDatabase.MIGRATION_42_43, AppDatabase.MIGRATION_43_44, + AppDatabase.MIGRATION_44_45, ) .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 ff508a52b..bea0ba789 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt @@ -16,6 +16,7 @@ package com.keylesspalace.tusky.di import com.keylesspalace.tusky.AccountsInListFragment +import com.keylesspalace.tusky.components.account.list.ListsForAccountFragment import com.keylesspalace.tusky.components.account.media.AccountMediaFragment import com.keylesspalace.tusky.components.conversation.ConversationsFragment import com.keylesspalace.tusky.components.instancemute.fragment.InstanceListFragment @@ -93,6 +94,9 @@ abstract class FragmentBuildersModule { @ContributesAndroidInjector abstract fun preferencesFragment(): PreferencesFragment + @ContributesAndroidInjector + abstract fun listsForAccountFragment(): ListsForAccountFragment + @ContributesAndroidInjector abstract fun searchNotestockFragment(): SearchNotestockFragment } 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 9e865c6f4..8e23d2d40 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt @@ -5,11 +5,13 @@ package com.keylesspalace.tusky.di import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import com.keylesspalace.tusky.components.account.AccountViewModel +import com.keylesspalace.tusky.components.account.list.ListsForAccountViewModel import com.keylesspalace.tusky.components.account.media.AccountMediaViewModel import com.keylesspalace.tusky.components.announcements.AnnouncementsViewModel import com.keylesspalace.tusky.components.compose.ComposeViewModel import com.keylesspalace.tusky.components.conversation.ConversationsViewModel import com.keylesspalace.tusky.components.drafts.DraftsViewModel +import com.keylesspalace.tusky.components.followedtags.FollowedTagsViewModel import com.keylesspalace.tusky.components.login.LoginWebViewViewModel import com.keylesspalace.tusky.components.report.ReportViewModel import com.keylesspalace.tusky.components.scheduled.ScheduledStatusViewModel @@ -127,6 +129,16 @@ abstract class ViewModelModule { @ViewModelKey(LoginWebViewViewModel::class) internal abstract fun loginWebViewViewModel(viewModel: LoginWebViewViewModel): ViewModel + @Binds + @IntoMap + @ViewModelKey(FollowedTagsViewModel::class) + internal abstract fun followedTagsViewModel(viewModel: FollowedTagsViewModel): ViewModel + + @Binds + @IntoMap + @ViewModelKey(ListsForAccountViewModel::class) + internal abstract fun listsForAccountViewModel(viewModel: ListsForAccountViewModel): ViewModel + @Binds @IntoMap @ViewModelKey(QuickTootViewModel::class) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt index 16ec4507f..49bcda6bd 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt @@ -59,7 +59,8 @@ data class AccountSource( val privacy: Status.Visibility?, val sensitive: Boolean?, val note: String?, - val fields: List? + val fields: List?, + val language: String?, ) data class Field( diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Attachment.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Attachment.kt index 27fdc8be6..c1368325e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Attachment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Attachment.kt @@ -68,7 +68,9 @@ data class Attachment( @Parcelize data class MetaData( val focus: Focus?, - val duration: Float? + val duration: Float?, + val original: Size?, + val small: Size?, ) : Parcelable /** @@ -82,4 +84,14 @@ data class Attachment( val x: Float, val y: Float ) : Parcelable + + /** + * The size of an image, used to specify the width/height. + */ + @Parcelize + data class Size( + val width: Int, + val height: Int, + val aspect: Double + ) : Parcelable } 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 f6e381502..b058c4c10 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt @@ -25,7 +25,8 @@ data class Notification( val type: Type, val id: String, val account: TimelineAccount, - val status: Status? + val status: Status?, + val report: Report?, ) { @JsonAdapter(NotificationTypeAdapter::class) @@ -40,6 +41,7 @@ data class Notification( STATUS("status"), SIGN_UP("admin.sign_up"), UPDATE("update"), + REPORT("admin.report"), ; companion object { @@ -52,7 +54,7 @@ data class Notification( } return UNKNOWN } - val asList = listOf(MENTION, REBLOG, FAVOURITE, FOLLOW, FOLLOW_REQUEST, POLL, STATUS, SIGN_UP, UPDATE) + val asList = listOf(MENTION, REBLOG, FAVOURITE, FOLLOW, FOLLOW_REQUEST, POLL, STATUS, SIGN_UP, UPDATE, REPORT) } override fun toString(): String { diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Report.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Report.kt new file mode 100644 index 000000000..0330c102c --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Report.kt @@ -0,0 +1,12 @@ +package com.keylesspalace.tusky.entity + +import com.google.gson.annotations.SerializedName +import java.util.Date + +data class Report( + val id: String, + val category: String, + val status_ids: List?, + @SerializedName("created_at") val createdAt: Date, + @SerializedName("target_account") val targetAccount: TimelineAccount, +) 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 5d6f5ff07..9b4ef6704 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt @@ -30,6 +30,7 @@ data class Status( val reblog: Status?, val content: String, @SerializedName("created_at", alternate = ["published"]) val createdAt: Date, + @SerializedName("edited_at") val editedAt: Date?, val emojis: List, @SerializedName("reblogs_count") val reblogsCount: Int, @SerializedName("favourites_count") val favouritesCount: Int, diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java index 87df99aa0..a7c145c31 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java @@ -31,10 +31,8 @@ import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.ArrayAdapter; -import android.widget.Button; import android.widget.ListView; import android.widget.PopupWindow; -import android.widget.ProgressBar; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -67,6 +65,7 @@ import com.keylesspalace.tusky.appstore.PinEvent; import com.keylesspalace.tusky.appstore.PreferenceChangedEvent; import com.keylesspalace.tusky.appstore.QuickReplyEvent; import com.keylesspalace.tusky.appstore.ReblogEvent; +import com.keylesspalace.tusky.databinding.FragmentTimelineNotificationsBinding; import com.keylesspalace.tusky.db.AccountEntity; import com.keylesspalace.tusky.db.AccountManager; import com.keylesspalace.tusky.di.Injectable; @@ -82,13 +81,13 @@ 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.StatusDisplayOptions; import com.keylesspalace.tusky.util.ViewDataUtils; -import com.keylesspalace.tusky.view.BackgroundMessageView; import com.keylesspalace.tusky.view.EndlessOnScrollListener; import com.keylesspalace.tusky.viewdata.AttachmentViewData; import com.keylesspalace.tusky.viewdata.NotificationViewData; @@ -160,16 +159,11 @@ public class NotificationsFragment extends SFragment implements @Inject EventHub eventHub; - private SwipeRefreshLayout swipeRefreshLayout; - private RecyclerView recyclerView; - private ProgressBar progressBar; - private BackgroundMessageView statusView; - private AppBarLayout appBarOptions; + private FragmentTimelineNotificationsBinding binding; private LinearLayoutManager layoutManager; private EndlessOnScrollListener scrollListener; private NotificationsAdapter adapter; - private Button buttonFilter; private boolean hideFab; private boolean topLoading; private boolean bottomLoading; @@ -213,35 +207,29 @@ public class NotificationsFragment extends SFragment implements @Override public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - View rootView = inflater.inflate(R.layout.fragment_timeline_notifications, container, false); + binding = FragmentTimelineNotificationsBinding.inflate(inflater, container, false); @NonNull Context context = inflater.getContext(); // from inflater to silence warning - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getActivity()); + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(requireContext()); boolean showNotificationsFilterSetting = preferences.getBoolean("showNotificationsFilter", true); - //Clear notifications on filter visibility change to force refresh + // Clear notifications on filter visibility change to force refresh if (showNotificationsFilterSetting != showNotificationsFilter) notifications.clear(); showNotificationsFilter = showNotificationsFilterSetting; // Setup the SwipeRefreshLayout. - swipeRefreshLayout = rootView.findViewById(R.id.swipeRefreshLayout); - recyclerView = rootView.findViewById(R.id.recyclerView); - progressBar = rootView.findViewById(R.id.progressBar); - statusView = rootView.findViewById(R.id.statusView); - appBarOptions = rootView.findViewById(R.id.appBarOptions); - - swipeRefreshLayout.setOnRefreshListener(this); - swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue); + binding.swipeRefreshLayout.setOnRefreshListener(this); + binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue); loadNotificationsFilter(); // Setup the RecyclerView. - recyclerView.setHasFixedSize(true); + binding.recyclerView.setHasFixedSize(true); layoutManager = new LinearLayoutManager(context); - recyclerView.setLayoutManager(layoutManager); - recyclerView.setAccessibilityDelegateCompat( - new ListStatusAccessibilityDelegate(recyclerView, this, (pos) -> { + 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) { @@ -251,7 +239,7 @@ public class NotificationsFragment extends SFragment implements } })); - recyclerView.addItemDecoration(new DividerItemDecoration(context, DividerItemDecoration.VERTICAL)); + binding.recyclerView.addItemDecoration(new DividerItemDecoration(context, DividerItemDecoration.VERTICAL)); StatusDisplayOptions statusDisplayOptions = new StatusDisplayOptions( preferences.getBoolean("animateGifAvatars", false), @@ -271,7 +259,7 @@ public class NotificationsFragment extends SFragment implements dataSource, statusDisplayOptions, this, this, this); alwaysShowSensitiveMedia = accountManager.getActiveAccount().getAlwaysShowSensitiveMedia(); alwaysOpenSpoiler = accountManager.getActiveAccount().getAlwaysOpenSpoiler(); - recyclerView.setAdapter(adapter); + binding.recyclerView.setAdapter(adapter); topLoading = false; bottomLoading = false; @@ -279,43 +267,47 @@ public class NotificationsFragment extends SFragment implements updateAdapter(); - Button buttonClear = rootView.findViewById(R.id.buttonClear); - buttonClear.setOnClickListener(v -> confirmClearNotifications()); - buttonFilter = rootView.findViewById(R.id.buttonFilter); - buttonFilter.setOnClickListener(v -> showFilterMenu()); + binding.buttonClear.setOnClickListener(v -> confirmClearNotifications()); + binding.buttonFilter.setOnClickListener(v -> showFilterMenu()); if (notifications.isEmpty()) { - swipeRefreshLayout.setEnabled(false); + binding.swipeRefreshLayout.setEnabled(false); sendFetchNotificationsRequest(null, null, FetchEnd.BOTTOM, -1); } else { - progressBar.setVisibility(View.GONE); + binding.progressBar.setVisibility(View.GONE); } - ((SimpleItemAnimator) recyclerView.getItemAnimator()).setSupportsChangeAnimations(false); + ((SimpleItemAnimator) binding.recyclerView.getItemAnimator()).setSupportsChangeAnimations(false); updateFilterVisibility(); - return rootView; + return binding.getRoot(); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + binding = null; } private void updateFilterVisibility() { CoordinatorLayout.LayoutParams params = - (CoordinatorLayout.LayoutParams) swipeRefreshLayout.getLayoutParams(); + (CoordinatorLayout.LayoutParams) binding.swipeRefreshLayout.getLayoutParams(); if (showNotificationsFilter && !showingError) { - appBarOptions.setExpanded(true, false); - appBarOptions.setVisibility(View.VISIBLE); - //Set content behaviour to hide filter on scroll + binding.appBarOptions.setExpanded(true, false); + binding.appBarOptions.setVisibility(View.VISIBLE); + // Set content behaviour to hide filter on scroll params.setBehavior(new AppBarLayout.ScrollingViewBehavior()); } else { - appBarOptions.setExpanded(false, false); - appBarOptions.setVisibility(View.GONE); - //Clear behaviour to hide app bar + 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(getContext()) + 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) @@ -328,10 +320,10 @@ public class NotificationsFragment extends SFragment implements 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. */ + // 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) { @@ -345,9 +337,9 @@ public class NotificationsFragment extends SFragment implements if (composeButton != null) { if (hideFab) { if (dy > 0 && composeButton.isShown()) { - composeButton.hide(); // hides the button if we're scrolling down + 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 + composeButton.show(); // Shows it if we are scrolling up } } else if (!composeButton.isShown()) { composeButton.show(); @@ -361,7 +353,7 @@ public class NotificationsFragment extends SFragment implements } }; - recyclerView.addOnScrollListener(scrollListener); + binding.recyclerView.addOnScrollListener(scrollListener); eventHub.getEvents() .observeOn(AndroidSchedulers.mainThread()) @@ -385,7 +377,7 @@ public class NotificationsFragment extends SFragment implements @Override public void onRefresh() { - this.statusView.setVisibility(View.GONE); + binding.statusView.setVisibility(View.GONE); this.showingError = false; Either first = CollectionsKt.firstOrNull(this.notifications); String topId; @@ -526,7 +518,7 @@ public class NotificationsFragment extends SFragment implements @Override public void onLoadMore(int position) { - //check bounds before accessing list, + // 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(); @@ -548,7 +540,6 @@ public class NotificationsFragment extends SFragment implements @Override public void onContentCollapsedChange(boolean isCollapsed, int position) { updateViewDataAt(position, (vd) -> vd.copyWithCollapsed(isCollapsed)); - ; } private void updateStatus(String statusId, Function mapper) { @@ -623,28 +614,28 @@ public class NotificationsFragment extends SFragment implements } private void clearNotifications() { - //Cancel all ongoing requests - swipeRefreshLayout.setRefreshing(false); + // Cancel all ongoing requests + binding.swipeRefreshLayout.setRefreshing(false); resetNotificationsLoad(); - //Show friend elephant - this.statusView.setVisibility(View.VISIBLE); - this.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null); + // Show friend elephant + binding.statusView.setVisibility(View.VISIBLE); + binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null); updateFilterVisibility(); - //Update adapter + // Update adapter updateAdapter(); - //Execute clear notifications request + // Execute clear notifications request mastodonApi.clearNotifications() .observeOn(AndroidSchedulers.mainThread()) .to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) .subscribe( response -> { - // nothing to do + // Nothing to do }, throwable -> { - //Reload notifications on failure + // Reload notifications on failure fullyRefreshWithProgressBar(true); }); } @@ -654,10 +645,10 @@ public class NotificationsFragment extends SFragment implements bottomLoading = false; topLoading = false; - //Disable load more + // Disable load more bottomId = null; - //Clear exists notifications + // Clear exists notifications notifications.clear(); } @@ -696,7 +687,7 @@ public class NotificationsFragment extends SFragment implements window.setFocusable(true); window.setWidth(ViewGroup.LayoutParams.WRAP_CONTENT); window.setHeight(ViewGroup.LayoutParams.WRAP_CONTENT); - window.showAsDropDown(buttonFilter); + window.showAsDropDown(binding.buttonFilter); } @@ -720,6 +711,8 @@ public class NotificationsFragment extends SFragment implements 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"; } @@ -762,12 +755,12 @@ public class NotificationsFragment extends SFragment implements } @Override - public void onViewTag(String tag) { + public void onViewTag(@NonNull String tag) { super.viewTag(tag); } @Override - public void onViewAccount(String id) { + public void onViewAccount(@NonNull String id) { super.viewAccount(id); } @@ -809,10 +802,15 @@ public class NotificationsFragment extends SFragment implements 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(getContext()).getBoolean("fabHide", false); + hideFab = PreferenceManager.getDefaultSharedPreferences(requireContext()).getBoolean("fabHide", false); break; } case "mediaPreviewEnabled": { @@ -825,7 +823,7 @@ public class NotificationsFragment extends SFragment implements } case "showNotificationsFilter": { if (isAdded()) { - showNotificationsFilter = PreferenceManager.getDefaultSharedPreferences(getContext()).getBoolean("showNotificationsFilter", true); + showNotificationsFilter = PreferenceManager.getDefaultSharedPreferences(requireContext()).getBoolean("showNotificationsFilter", true); updateFilterVisibility(); fullyRefreshWithProgressBar(true); } @@ -841,7 +839,7 @@ public class NotificationsFragment extends SFragment implements } private void removeAllByAccountId(String accountId) { - // using iterator to safely remove items while iterating + // Using iterator to safely remove items while iterating Iterator> iterator = notifications.iterator(); while (iterator.hasNext()) { Either notification = iterator.next(); @@ -855,7 +853,7 @@ public class NotificationsFragment extends SFragment implements private void onLoadMore() { if (bottomId == null) { - // already loaded everything + // Already loaded everything return; } @@ -885,7 +883,7 @@ public class NotificationsFragment extends SFragment implements private void jumpToTop() { if (isAdded()) { - appBarOptions.setExpanded(true, false); + binding.appBarOptions.setExpanded(true, false); layoutManager.scrollToPosition(0); scrollListener.reset(); } @@ -893,8 +891,8 @@ public class NotificationsFragment extends SFragment implements 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 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; } @@ -970,18 +968,18 @@ public class NotificationsFragment extends SFragment implements } if (notifications.size() == 0 && adapter.getItemCount() == 0) { - this.statusView.setVisibility(View.VISIBLE); - this.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null); + binding.statusView.setVisibility(View.VISIBLE); + binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null); } updateFilterVisibility(); - swipeRefreshLayout.setEnabled(true); - swipeRefreshLayout.setRefreshing(false); - progressBar.setVisibility(View.GONE); + binding.swipeRefreshLayout.setEnabled(true); + binding.swipeRefreshLayout.setRefreshing(false); + binding.progressBar.setVisibility(View.GONE); } private void onFetchNotificationsFailure(Throwable throwable, FetchEnd fetchEnd, int position) { - swipeRefreshLayout.setRefreshing(false); + binding.swipeRefreshLayout.setRefreshing(false); if (fetchEnd == FetchEnd.MIDDLE && !notifications.get(position).isRight()) { Placeholder placeholder = notifications.get(position).asLeft(); NotificationViewData placeholderVD = @@ -989,18 +987,18 @@ public class NotificationsFragment extends SFragment implements notifications.setPairedItem(position, placeholderVD); updateAdapter(); } else if (this.notifications.isEmpty()) { - this.statusView.setVisibility(View.VISIBLE); - swipeRefreshLayout.setEnabled(false); + binding.statusView.setVisibility(View.VISIBLE); + binding.swipeRefreshLayout.setEnabled(false); this.showingError = true; if (throwable instanceof IOException) { - this.statusView.setup(R.drawable.elephant_offline, R.string.error_network, __ -> { - this.progressBar.setVisibility(View.VISIBLE); + binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network, __ -> { + binding.progressBar.setVisibility(View.VISIBLE); this.onRefresh(); return Unit.INSTANCE; }); } else { - this.statusView.setup(R.drawable.elephant_error, R.string.error_generic, __ -> { - this.progressBar.setVisibility(View.VISIBLE); + binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic, __ -> { + binding.progressBar.setVisibility(View.VISIBLE); this.onRefresh(); return Unit.INSTANCE; }); @@ -1016,7 +1014,7 @@ public class NotificationsFragment extends SFragment implements bottomLoading = false; } - progressBar.setVisibility(View.GONE); + binding.progressBar.setVisibility(View.GONE); } private void saveNewestNotificationId(List notifications) { @@ -1053,8 +1051,8 @@ public class NotificationsFragment extends SFragment implements notifications.addAll(liftedNew); } else { int index = notifications.indexOf(liftedNew.get(newNotifications.size() - 1)); - for (int i = 0; i < index; i++) { - notifications.remove(0); + if (index > 0) { + notifications.subList(0, index).clear(); } int newIndex = liftedNew.indexOf(notifications.get(0)); @@ -1078,7 +1076,7 @@ public class NotificationsFragment extends SFragment implements int end = notifications.size(); List> liftedNew = liftNotificationList(newNotifications); Either last = notifications.get(end - 1); - if (last != null && liftedNew.indexOf(last) == -1) { + if (last != null && !liftedNew.contains(last)) { notifications.addAll(liftedNew); updateAdapter(); } @@ -1116,8 +1114,8 @@ public class NotificationsFragment extends SFragment implements private void fullyRefreshWithProgressBar(boolean isShow) { resetNotificationsLoad(); if (isShow) { - progressBar.setVisibility(View.VISIBLE); - statusView.setVisibility(View.GONE); + binding.progressBar.setVisibility(View.VISIBLE); + binding.statusView.setVisibility(View.GONE); } updateAdapter(); sendFetchNotificationsRequest(null, null, FetchEnd.TOP, -1); @@ -1156,7 +1154,7 @@ public class NotificationsFragment extends SFragment implements // 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) { - recyclerView.scrollBy(0, Utils.dpToPx(context, -30)); + binding.recyclerView.scrollBy(0, Utils.dpToPx(context, -30)); } } } @@ -1211,7 +1209,7 @@ public class NotificationsFragment extends SFragment implements @Override public Object getChangePayload(@NonNull NotificationViewData oldItem, @NonNull NotificationViewData newItem) { if (oldItem.deepEquals(newItem)) { - //If items are equal - update timestamp only + // If items are equal - update timestamp only return Collections.singletonList(StatusBaseViewHolder.Key.KEY_CREATED); } else // If items are different - update a whole view holder @@ -1237,7 +1235,7 @@ public class NotificationsFragment extends SFragment implements * Auto dispose observable on pause */ private void startUpdateTimestamp() { - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getActivity()); + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(requireContext()); boolean useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false); if (!useAbsoluteTime) { Observable.interval(0, 1, TimeUnit.MINUTES) diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java index 59f40f890..b70ffceea 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java @@ -25,6 +25,7 @@ import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; import android.net.Uri; +import android.os.Build; import android.os.Environment; import android.util.Log; import android.view.Menu; @@ -501,13 +502,17 @@ public abstract class SFragment extends Fragment implements Injectable { } private void requestDownloadAllMedia(Status status) { - String[] permissions = new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}; - ((BaseActivity) getActivity()).requestPermissions(permissions, (permissions1, grantResults) -> { - if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { - downloadAllMedia(status); - } else { - Toast.makeText(getContext(), R.string.error_media_download_permission, Toast.LENGTH_SHORT).show(); - } - }); + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + String[] permissions = new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}; + ((BaseActivity) getActivity()).requestPermissions(permissions, (permissions1, grantResults) -> { + if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + downloadAllMedia(status); + } else { + Toast.makeText(getContext(), R.string.error_media_download_permission, Toast.LENGTH_SHORT).show(); + } + }); + } else { + downloadAllMedia(status); + } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt index 214741a8e..28991c262 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt @@ -18,27 +18,36 @@ package com.keylesspalace.tusky.fragment import android.animation.Animator import android.animation.AnimatorListenerAdapter import android.annotation.SuppressLint +import android.content.Context import android.os.Bundle import android.os.Handler import android.os.Looper import android.text.method.ScrollingMovementMethod +import android.view.GestureDetector import android.view.KeyEvent import android.view.LayoutInflater +import android.view.MotionEvent import android.view.View import android.view.ViewGroup import android.widget.MediaController +import androidx.core.view.GestureDetectorCompat import com.keylesspalace.tusky.ViewMediaActivity import com.keylesspalace.tusky.databinding.FragmentViewVideoBinding import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.visible import com.keylesspalace.tusky.view.ExposedPlayPauseVideoView +import kotlin.math.abs class ViewVideoFragment : ViewMediaFragment() { + interface VideoActionsListener { + fun onDismiss() + } private var _binding: FragmentViewVideoBinding? = null private val binding get() = _binding!! + private lateinit var videoActionsListener: VideoActionsListener private lateinit var toolbar: View private val handler = Handler(Looper.getMainLooper()) private val hideToolbar = Runnable { @@ -52,6 +61,11 @@ class ViewVideoFragment : ViewMediaFragment() { private lateinit var mediaController: MediaController private var isAudio = false + override fun onAttach(context: Context) { + super.onAttach(context) + videoActionsListener = context as VideoActionsListener + } + override fun setUserVisibleHint(isVisibleToUser: Boolean) { // Start/pause/resume video playback as fragment is shown/hidden super.setUserVisibleHint(isVisibleToUser) @@ -168,6 +182,7 @@ class ViewVideoFragment : ViewMediaFragment() { return binding.root } + @SuppressLint("ClickableViewAccessibility") override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val attachment = arguments?.getParcelable(ARG_ATTACHMENT) @@ -177,6 +192,54 @@ class ViewVideoFragment : ViewMediaFragment() { } val url = attachment.url isAudio = attachment.type == Attachment.Type.AUDIO + + val gestureDetector = GestureDetectorCompat( + requireContext(), + object : GestureDetector.SimpleOnGestureListener() { + override fun onDown(event: MotionEvent): Boolean { + return true + } + + override fun onFling( + e1: MotionEvent, + e2: MotionEvent, + velocityX: Float, + velocityY: Float + ): Boolean { + if (abs(velocityY) > abs(velocityX)) { + videoActionsListener.onDismiss() + return true + } + return false + } + } + ) + + var lastY = 0f + binding.root.setOnTouchListener { _, event -> + if (event.action == MotionEvent.ACTION_DOWN) { + lastY = event.rawY + } else if (event.pointerCount == 1 && event.action == MotionEvent.ACTION_MOVE) { + val diff = event.rawY - lastY + if (binding.videoView.translationY != 0f || abs(diff) > 40) { + binding.videoView.translationY += diff + val scale = (-abs(binding.videoView.translationY) / 720 + 1).coerceAtLeast(0.5f) + binding.videoView.scaleY = scale + binding.videoView.scaleX = scale + lastY = event.rawY + return@setOnTouchListener true + } + } else if (event.action == MotionEvent.ACTION_UP || event.action == MotionEvent.ACTION_CANCEL) { + if (abs(binding.videoView.translationY) > 180) { + videoActionsListener.onDismiss() + } else { + binding.videoView.animate().translationY(0f).scaleX(1f).scaleY(1f).start() + } + } + + gestureDetector.onTouchEvent(event) + } + finalizeViewSetup(url, attachment.previewUrl, attachment.description) } diff --git a/app/src/main/java/com/keylesspalace/tusky/interfaces/HashtagActionListener.kt b/app/src/main/java/com/keylesspalace/tusky/interfaces/HashtagActionListener.kt new file mode 100644 index 000000000..a223f268d --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/interfaces/HashtagActionListener.kt @@ -0,0 +1,5 @@ +package com.keylesspalace.tusky.interfaces + +interface HashtagActionListener { + fun unfollow(tagName: String, position: Int) +} 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 f92729941..42652a122 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -267,7 +267,8 @@ interface MastodonApi { @PATCH("api/v1/accounts/update_credentials") fun accountUpdateSource( @Field("source[privacy]") privacy: String?, - @Field("source[sensitive]") sensitive: Boolean? + @Field("source[sensitive]") sensitive: Boolean?, + @Field("source[language]") language: String?, ): Call @Multipart @@ -481,6 +482,11 @@ interface MastodonApi { @GET("/api/v1/lists") suspend fun getLists(): NetworkResult> + @GET("/api/v1/accounts/{id}/lists") + suspend fun getListsIncludesAccount( + @Path("id") accountId: String + ): NetworkResult> + @FormUrlEncoded @POST("api/v1/lists") suspend fun createList( @@ -668,6 +674,14 @@ interface MastodonApi { @GET("api/v1/tags/{name}") suspend fun tag(@Path("name") name: String): NetworkResult + @GET("api/v1/followed_tags") + suspend fun followedTags( + @Query("min_id") minId: String? = null, + @Query("since_id") sinceId: String? = null, + @Query("max_id") maxId: String? = null, + @Query("limit") limit: Int? = null, + ): Response> + @POST("api/v1/tags/{name}/follow") suspend fun followTag(@Path("name") name: String): NetworkResult 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 f5c92f507..e0ab6b33f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt +++ b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt @@ -55,6 +55,7 @@ object PrefKeys { const val STACK_TRACE = "stackTrace" const val DEFAULT_POST_PRIVACY = "defaultPostPrivacy" + const val DEFAULT_POST_LANGUAGE = "defaultPostLanguage" const val DEFAULT_MEDIA_SENSITIVITY = "defaultMediaSensitivity" const val MEDIA_PREVIEW_ENABLED = "mediaPreviewEnabled" const val ALWAYS_SHOW_SENSITIVE_MEDIA = "alwaysShowSensitiveMedia" @@ -72,6 +73,7 @@ object PrefKeys { const val NOTIFICATION_FILTER_SUBSCRIPTIONS = "notificationFilterSubscriptions" const val NOTIFICATION_FILTER_SIGN_UPS = "notificationFilterSignUps" const val NOTIFICATION_FILTER_UPDATES = "notificationFilterUpdates" + const val NOTIFICATION_FILTER_REPORTS = "notificationFilterReports" const val TAB_FILTER_HOME_REPLIES = "tabFilterHomeReplies_v2" // This was changed once to reset an unintentionally set default. const val TAB_FILTER_HOME_BOOSTS = "tabFilterHomeBoosts" diff --git a/app/src/main/java/com/keylesspalace/tusky/util/AttachmentHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/AttachmentHelper.kt index 6307e7211..f49b901a0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/AttachmentHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/AttachmentHelper.kt @@ -1,4 +1,5 @@ @file:JvmName("AttachmentHelper") + package com.keylesspalace.tusky.util import android.content.Context @@ -24,3 +25,12 @@ private fun formatDuration(durationInSeconds: Double): String { val hours = durationInSeconds.toInt() / 3600 return "%d:%02d:%02d".format(hours, minutes, seconds) } + +fun List.aspectRatios(): List { + return map { attachment -> + // clamp ratio between 2:1 & 1:2, defaulting to 16:9 + val size = (attachment.meta?.small ?: attachment.meta?.original) ?: return@map 1.7778 + val aspect = if (size.aspect > 0) size.aspect else size.width.toDouble() / size.height + aspect.coerceIn(0.5, 2.0) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/EmptyPagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/util/EmptyPagingSource.kt new file mode 100644 index 000000000..41a12aa3d --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/EmptyPagingSource.kt @@ -0,0 +1,10 @@ +package com.keylesspalace.tusky.util + +import androidx.paging.PagingSource +import androidx.paging.PagingState + +class EmptyPagingSource : PagingSource() { + override fun getRefreshKey(state: PagingState): Int? = null + + override suspend fun load(params: LoadParams): LoadResult = LoadResult.Page(emptyList(), null, null) +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/IconUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/IconUtils.kt new file mode 100644 index 000000000..59b0b15d7 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/IconUtils.kt @@ -0,0 +1,31 @@ +/* Copyright 2022 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 android.content.Context +import androidx.annotation.Px +import com.keylesspalace.tusky.R +import com.mikepenz.iconics.IconicsDrawable +import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial +import com.mikepenz.iconics.utils.colorInt +import com.mikepenz.iconics.utils.sizePx + +fun makeIcon(context: Context, icon: GoogleMaterial.Icon, @Px iconSize: Int): IconicsDrawable { + return IconicsDrawable(context, icon).apply { + sizePx = iconSize + colorInt = ThemeUtils.getColor(context, R.attr.iconColor) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt index 1a7de9050..c385766e7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt @@ -37,6 +37,8 @@ import com.keylesspalace.tusky.R import com.keylesspalace.tusky.entity.HashTag import com.keylesspalace.tusky.entity.Status.Mention import com.keylesspalace.tusky.interfaces.LinkListener +import java.net.URI +import java.net.URISyntaxException fun getDomain(urlString: String?): String { val host = urlString?.toUri()?.host @@ -72,22 +74,16 @@ fun markupHiddenUrls(context: Context, content: CharSequence): SpannableStringBu val spannableContent = SpannableStringBuilder(content) val originalSpans = spannableContent.getSpans(0, content.length, URLSpan::class.java) val obscuredLinkSpans = originalSpans.filter { - val text = spannableContent.subSequence(spannableContent.getSpanStart(it), spannableContent.getSpanEnd(it)) - val firstCharacter = text[0] + val start = spannableContent.getSpanStart(it) + val firstCharacter = content[start] return@filter if (firstCharacter == '#' || firstCharacter == '@') { false } else { - val lastPart = text.toString().split(' ').lastOrNull() ?: "" - var textDomain = getDomain(lastPart) + val text = spannableContent.subSequence(start, spannableContent.getSpanEnd(it)).toString() + .split(' ').lastOrNull() ?: "" + var textDomain = getDomain(text) if (textDomain.isBlank()) { - // Allow "some.domain" or "www.some.domain" without a domain notifier - textDomain = lastPart - if (textDomain.startsWith("www.")) { - textDomain = textDomain.substring(4) - } - if (textDomain.endsWith("/")) { - textDomain = textDomain.substring(0, textDomain.length - 1) - } + textDomain = getDomain("https://$text") } getDomain(it.url) != textDomain } @@ -276,4 +272,49 @@ private fun openLinkInCustomTab(uri: Uri, context: Context) { } } +// https://mastodon.foo.bar/@User +// https://mastodon.foo.bar/@User/43456787654678 +// https://pleroma.foo.bar/users/User +// https://pleroma.foo.bar/users/9qTHT2ANWUdXzENqC0 +// https://pleroma.foo.bar/notice/9sBHWIlwwGZi5QGlHc +// https://pleroma.foo.bar/objects/d4643c42-3ae0-4b73-b8b0-c725f5819207 +// https://friendica.foo.bar/profile/user +// https://friendica.foo.bar/display/d4643c42-3ae0-4b73-b8b0-c725f5819207 +// https://misskey.foo.bar/notes/83w6r388br (always lowercase) +// https://pixelfed.social/p/connyduck/391263492998670833 +// https://pixelfed.social/connyduck +// https://gts.foo.bar/@goblin/statuses/01GH9XANCJ0TA8Y95VE9H3Y0Q2 +// https://gts.foo.bar/@goblin +// https://foo.microblog.pub/o/5b64045effd24f48a27d7059f6cb38f5 +fun looksLikeMastodonUrl(urlString: String): Boolean { + val uri: URI + try { + uri = URI(urlString) + } catch (e: URISyntaxException) { + return false + } + + if (uri.query != null || + uri.fragment != null || + uri.path == null + ) { + return false + } + + return uri.path.let { + it.matches("^/@[^/]+$".toRegex()) || + it.matches("^/@[^/]+/\\d+$".toRegex()) || + it.matches("^/users/\\w+$".toRegex()) || + it.matches("^/notice/[a-zA-Z0-9]+$".toRegex()) || + it.matches("^/objects/[-a-f0-9]+$".toRegex()) || + it.matches("^/notes/[a-z0-9]+$".toRegex()) || + it.matches("^/display/[-a-f0-9]+$".toRegex()) || + it.matches("^/profile/\\w+$".toRegex()) || + it.matches("^/p/\\w+/\\d+$".toRegex()) || + it.matches("^/\\w+$".toRegex()) || + it.matches("^/@[^/]+/statuses/[a-zA-Z0-9]+$".toRegex()) || + it.matches("^/o/[a-f0-9]+$".toRegex()) + } +} + private const val TAG = "LinkHelper" diff --git a/app/src/main/java/com/keylesspalace/tusky/util/LocaleExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/LocaleExtensions.kt new file mode 100644 index 000000000..caab21927 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/LocaleExtensions.kt @@ -0,0 +1,36 @@ +/* Copyright 2022 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 android.content.Context +import com.keylesspalace.tusky.R +import java.util.Locale + +// When a language code has changed, `language` *explicitly* returns the obsolete version, +// but `toLanguageTag()` uses the current version +// https://developer.android.com/reference/java/util/Locale#getLanguage() +val Locale.modernLanguageCode: String + get() { + return this.toLanguageTag().split('-', limit = 2)[0] + } + +fun Locale.getTuskyDisplayName(context: Context): String { + return context.getString( + R.string.language_display_name_format, + this?.displayLanguage, + this?.getDisplayLanguage(this) + ) +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/LocaleManager.kt b/app/src/main/java/com/keylesspalace/tusky/util/LocaleManager.kt index 45f3ab371..6795317b2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/LocaleManager.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/LocaleManager.kt @@ -17,25 +17,89 @@ package com.keylesspalace.tusky.util import android.content.Context import android.content.SharedPreferences -import android.content.res.Configuration +import android.os.Build +import androidx.appcompat.app.AppCompatDelegate +import androidx.core.os.LocaleListCompat +import androidx.preference.PreferenceDataStore import androidx.preference.PreferenceManager -import java.util.Locale +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.settings.PrefKeys +import javax.inject.Inject +import javax.inject.Singleton -class LocaleManager(context: Context) { +@Singleton +class LocaleManager @Inject constructor( + val context: Context +) : PreferenceDataStore() { private var prefs: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) - fun setLocale(context: Context): Context { - val language = prefs.getNonNullString("language", "default") - if (language == "default") { - return context - } - val locale = Locale.forLanguageTag(language) - Locale.setDefault(locale) + fun setLocale() { + val language = prefs.getNonNullString(PrefKeys.LANGUAGE, DEFAULT) - val res = context.resources - val config = Configuration(res.configuration) - config.setLocale(locale) - return context.createConfigurationContext(config) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (language != HANDLED_BY_SYSTEM) { + // app is being opened on Android 13+ for the first time + // hand over the old setting to the system and save a dummy value in Shared Preferences + applyLanguageToApp(language) + + prefs.edit() + .putString(PrefKeys.LANGUAGE, HANDLED_BY_SYSTEM) + .apply() + } + } else { + // on Android < 13 we have to apply the language at every app start + applyLanguageToApp(language) + } + } + + override fun putString(key: String?, value: String?) { + + // if we are on Android < 13 we have to save the selected language so we can apply it at appstart + // on Android 13+ the system handles it for us + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + prefs.edit() + .putString(PrefKeys.LANGUAGE, value) + .apply() + } + applyLanguageToApp(value) + } + + override fun getString(key: String?, defValue: String?): String? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val selectedLanguage = AppCompatDelegate.getApplicationLocales() + + if (selectedLanguage.isEmpty) { + DEFAULT + } else { + // Android lets users select all variants of languages we support in the system settings, + // so we need to find the closest match + // it should not happen that we find no match, but returning null is fine (picker will show default) + + val availableLanguages = context.resources.getStringArray(R.array.language_values) + + return availableLanguages.find { it == selectedLanguage[0]!!.toLanguageTag() } + ?: availableLanguages.find { language -> + language.startsWith(selectedLanguage[0]!!.language) + } + } + } else { + prefs.getNonNullString(PrefKeys.LANGUAGE, DEFAULT) + } + } + + private fun applyLanguageToApp(language: String?) { + val localeList = if (language == DEFAULT) { + LocaleListCompat.getEmptyLocaleList() + } else { + LocaleListCompat.forLanguageTags(language) + } + + AppCompatDelegate.setApplicationLocales(localeList) + } + + companion object { + private const val DEFAULT = "default" + private const val HANDLED_BY_SYSTEM = "handled_by_system" } } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/LocaleUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/LocaleUtils.kt new file mode 100644 index 000000000..316e14d0a --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/LocaleUtils.kt @@ -0,0 +1,89 @@ +/* Copyright 2022 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 android.util.Log +import androidx.appcompat.app.AppCompatDelegate +import androidx.core.os.LocaleListCompat +import com.keylesspalace.tusky.db.AccountEntity +import java.util.Locale + +private const val TAG: String = "LocaleUtils" + +private fun mergeLocaleListCompat(list: MutableList, localeListCompat: LocaleListCompat) { + for (index in 0 until localeListCompat.size()) { + val locale = localeListCompat[index] + if (locale != null && list.none { locale.language == it.language }) { + list.add(locale) + } + } +} + +// Ensure that the locale whose code matches the given language is first in the list +private fun ensureLanguageIsFirst(locales: MutableList, language: String) { + var currentLocaleIndex = locales.indexOfFirst { it.language == language } + if (currentLocaleIndex < 0) { + // Recheck against modern language codes + // This should only happen when replying or when the per-account post language is set + // to a modern code + currentLocaleIndex = locales.indexOfFirst { it.modernLanguageCode == language } + + if (currentLocaleIndex < 0) { + // This can happen when: + // - Your per-account posting language is set to one android doesn't know (e.g. toki pona) + // - Replying to a post in a language android doesn't know + locales.add(0, Locale(language)) + Log.w(TAG, "Attempting to use unknown language tag '$language'") + return + } + } + + if (currentLocaleIndex > 0) { + // Move preselected locale to the top + locales.add(0, locales.removeAt(currentLocaleIndex)) + } +} + +fun getInitialLanguage(language: String? = null, activeAccount: AccountEntity? = null): String { + return if (language.isNullOrEmpty()) { + // Account-specific language set on the server + if (activeAccount?.defaultPostLanguage?.isNotEmpty() == true) { + activeAccount.defaultPostLanguage + } else { + // Setting the application ui preference sets the default locale + AppCompatDelegate.getApplicationLocales()[0]?.language + ?: Locale.getDefault().language + } + } else { + language + } +} + +fun getLocaleList(initialLanguage: String): List { + val locales = mutableListOf() + mergeLocaleListCompat(locales, AppCompatDelegate.getApplicationLocales()) // configured app languages first + mergeLocaleListCompat(locales, LocaleListCompat.getDefault()) // then configured system languages + locales.addAll( // finally, other languages + // Only "base" languages, "en" but not "en_DK" + Locale.getAvailableLocales().filter { + it.country.isNullOrEmpty() && + it.script.isNullOrEmpty() && + it.variant.isNullOrEmpty() + }.sortedBy { it.displayName } + ) + ensureLanguageIsFirst(locales, initialLanguage) + return locales +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/TimestampUtils.java b/app/src/main/java/com/keylesspalace/tusky/util/TimestampUtils.java index c94b42297..5b911fb13 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/TimestampUtils.java +++ b/app/src/main/java/com/keylesspalace/tusky/util/TimestampUtils.java @@ -34,7 +34,10 @@ public class TimestampUtils { public static String getRelativeTimeSpanString(Context context, long then, long now) { long span = now - then; boolean future = false; - if (span < 0) { + if (Math.abs(span) < SECOND_IN_MILLIS) { + return context.getString(R.string.status_created_at_now); + } + else if (span < 0) { future = true; span = -span; } 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 3facc3a9a..bc40cdd6e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt @@ -47,6 +47,7 @@ fun Notification.toViewData( this.type, this.id, this.account, - this.status?.toViewData(isShowingContent, isExpanded, isCollapsed) + this.status?.toViewData(isShowingContent, isExpanded, isCollapsed), + this.report, ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/view/MediaPreviewLayout.kt b/app/src/main/java/com/keylesspalace/tusky/view/MediaPreviewLayout.kt new file mode 100644 index 000000000..802141f7d --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/view/MediaPreviewLayout.kt @@ -0,0 +1,210 @@ +package com.keylesspalace.tusky.view + +import android.content.Context +import android.graphics.Canvas +import android.util.AttributeSet +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import com.keylesspalace.tusky.R +import kotlin.math.roundToInt + +/** + * Lays out a set of [MediaPreviewImageView]s keeping their aspect ratios into account. + */ +class MediaPreviewLayout(context: Context, attrs: AttributeSet? = null) : + ViewGroup(context, attrs) { + + private val spacing = context.resources.getDimensionPixelOffset(R.dimen.preview_image_spacing) + + /** + * An ordered list of aspect ratios used for layout. An image view for each aspect ratio passed + * will be attached. Supports up to 4, additional ones will be ignored. + */ + var aspectRatios: List = emptyList() + set(value) { + field = value + attachImageViews() + } + + private val imageViewCache = Array(4) { MediaPreviewImageView(context) } + + private var measuredOrientation = LinearLayout.VERTICAL + + private fun attachImageViews() { + removeAllViews() + for (i in 0 until aspectRatios.size.coerceAtMost(imageViewCache.size)) { + addView(imageViewCache[i]) + } + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val width = MeasureSpec.getSize(widthMeasureSpec) + val halfWidth = width / 2 - spacing / 2 + var totalHeight = 0 + + when (childCount) { + 1 -> { + val aspect = aspectRatios[0] + totalHeight += getChildAt(0).measureToAspect(width, aspect) + } + 2 -> { + val aspect1 = aspectRatios[0] + val aspect2 = aspectRatios[1] + + if ((aspect1 + aspect2) / 2 > 1.2) { + // stack vertically + measuredOrientation = LinearLayout.VERTICAL + totalHeight += getChildAt(0).measureToAspect(width, aspect1.coerceAtLeast(1.8)) + totalHeight += spacing + totalHeight += getChildAt(1).measureToAspect(width, aspect2.coerceAtLeast(1.8)) + } else { + // stack horizontally + measuredOrientation = LinearLayout.HORIZONTAL + val height = rowHeight(halfWidth, aspect1, aspect2) + totalHeight += height + getChildAt(0).measureExactly(halfWidth, height) + getChildAt(1).measureExactly(halfWidth, height) + } + } + 3 -> { + val aspect1 = aspectRatios[0] + val aspect2 = aspectRatios[1] + val aspect3 = aspectRatios[2] + if (aspect1 >= 1) { + // | 1 | + // ------------- + // | 2 | 3 | + measuredOrientation = LinearLayout.VERTICAL + totalHeight += getChildAt(0).measureToAspect(width, aspect1.coerceAtLeast(1.8)) + totalHeight += spacing + val bottomHeight = rowHeight(halfWidth, aspect2, aspect3) + totalHeight += bottomHeight + getChildAt(1).measureExactly(halfWidth, bottomHeight) + getChildAt(2).measureExactly(halfWidth, bottomHeight) + } else { + // | | 2 | + // | 1 |-----| + // | | 3 | + measuredOrientation = LinearLayout.HORIZONTAL + val colHeight = getChildAt(0).measureToAspect(halfWidth, aspect1) + totalHeight += colHeight + val halfHeight = colHeight / 2 - spacing / 2 + getChildAt(1).measureExactly(halfWidth, halfHeight) + getChildAt(2).measureExactly(halfWidth, halfHeight) + } + } + 4 -> { + val aspect1 = aspectRatios[0] + val aspect2 = aspectRatios[1] + val aspect3 = aspectRatios[2] + val aspect4 = aspectRatios[3] + val topHeight = rowHeight(halfWidth, aspect1, aspect2) + totalHeight += topHeight + getChildAt(0).measureExactly(halfWidth, topHeight) + getChildAt(1).measureExactly(halfWidth, topHeight) + totalHeight += spacing + val bottomHeight = rowHeight(halfWidth, aspect3, aspect4) + totalHeight += bottomHeight + getChildAt(2).measureExactly(halfWidth, bottomHeight) + getChildAt(3).measureExactly(halfWidth, bottomHeight) + } + } + + super.onMeasure( + widthMeasureSpec, + MeasureSpec.makeMeasureSpec(totalHeight, MeasureSpec.EXACTLY) + ) + } + + override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { + val width = r - l + val height = b - t + val halfWidth = width / 2 - spacing / 2 + when (childCount) { + 1 -> { + getChildAt(0).layout(0, 0, width, height) + } + 2 -> { + if (measuredOrientation == LinearLayout.VERTICAL) { + val y = imageViewCache[0].measuredHeight + getChildAt(0).layout(0, 0, width, y) + getChildAt(1).layout( + 0, + y + spacing, + width, + y + spacing + getChildAt(1).measuredHeight + ) + } else { + getChildAt(0).layout(0, 0, halfWidth, height) + getChildAt(1).layout(halfWidth + spacing, 0, width, height) + } + } + 3 -> { + if (measuredOrientation == LinearLayout.VERTICAL) { + val y = getChildAt(0).measuredHeight + getChildAt(0).layout(0, 0, width, y) + getChildAt(1).layout(0, y + spacing, halfWidth, height) + getChildAt(2).layout(halfWidth + spacing, y + spacing, width, height) + } else { + val colHeight = getChildAt(0).measuredHeight + getChildAt(0).layout(0, 0, halfWidth, colHeight) + val halfHeight = colHeight / 2 - spacing / 2 + getChildAt(1).layout(halfWidth + spacing, 0, width, halfHeight) + getChildAt(2).layout( + halfWidth + spacing, + halfHeight + spacing, + width, + colHeight + ) + } + } + 4 -> { + val topHeight = (getChildAt(0).measuredHeight + getChildAt(1).measuredHeight) / 2 + getChildAt(0).layout(0, 0, halfWidth, topHeight) + getChildAt(1).layout(halfWidth + spacing, 0, width, topHeight) + val bottomHeight = + (imageViewCache[2].measuredHeight + imageViewCache[3].measuredHeight) / 2 + getChildAt(2).layout( + 0, + topHeight + spacing, + halfWidth, + topHeight + spacing + bottomHeight + ) + getChildAt(3).layout( + halfWidth + spacing, + topHeight + spacing, + width, + topHeight + spacing + bottomHeight + ) + } + } + } + + inline fun forEachIndexed(action: (Int, MediaPreviewImageView) -> Unit) { + for (index in 0 until childCount) { + action(index, getChildAt(index) as MediaPreviewImageView) + } + } + + override fun onDraw(canvas: Canvas?) { + super.onDraw(canvas) + } +} + +private fun rowHeight(halfWidth: Int, aspect1: Double, aspect2: Double): Int { + return ((halfWidth / aspect1 + halfWidth / aspect2) / 2).roundToInt() +} + +private fun View.measureToAspect(width: Int, aspect: Double): Int { + val height = (width / aspect).roundToInt() + measureExactly(width, height) + return height +} + +private fun View.measureExactly(width: Int, height: Int) { + measure( + View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY) + ) +} diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java b/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java index 2a25bb4cf..c70e2fc71 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java @@ -17,8 +17,8 @@ package com.keylesspalace.tusky.viewdata; import androidx.annotation.Nullable; -import com.keylesspalace.tusky.entity.Account; import com.keylesspalace.tusky.entity.Notification; +import com.keylesspalace.tusky.entity.Report; import com.keylesspalace.tusky.entity.TimelineAccount; import java.util.Objects; @@ -48,13 +48,16 @@ public abstract class NotificationViewData { 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 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() { @@ -74,6 +77,11 @@ public abstract class NotificationViewData { return statusViewData; } + @Nullable + public Report getReport() { + return report; + } + @Override public long getViewDataId() { return id.hashCode(); @@ -87,7 +95,8 @@ public abstract class NotificationViewData { return type == concrete.type && Objects.equals(id, concrete.id) && account.getId().equals(concrete.account.getId()) && - (Objects.equals(statusViewData, concrete.statusViewData)); + (Objects.equals(statusViewData, concrete.statusViewData)) && + (Objects.equals(report, concrete.report)); } @Override @@ -97,7 +106,7 @@ public abstract class NotificationViewData { } public Concrete copyWithStatus(@Nullable StatusViewData.Concrete statusViewData) { - return new Concrete(type, id, account, statusViewData); + return new Concrete(type, id, account, statusViewData, report); } } diff --git a/app/src/main/res/drawable/ic_flag_24dp.xml b/app/src/main/res/drawable/ic_flag_24dp.xml new file mode 100644 index 000000000..14529c0a9 --- /dev/null +++ b/app/src/main/res/drawable/ic_flag_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/play_indicator_overlay.xml b/app/src/main/res/drawable/play_indicator_overlay.xml new file mode 100644 index 000000000..66ffc2c9b --- /dev/null +++ b/app/src/main/res/drawable/play_indicator_overlay.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/layout-sw640dp/fragment_view_thread.xml b/app/src/main/res/layout-sw640dp/fragment_view_thread.xml index ec023062a..0c661b0b4 100644 --- a/app/src/main/res/layout-sw640dp/fragment_view_thread.xml +++ b/app/src/main/res/layout-sw640dp/fragment_view_thread.xml @@ -14,10 +14,10 @@ android:id="@+id/toolbar" android:layout_width="match_parent" android:layout_height="?attr/actionBarSize" - app:title="@string/title_view_thread" - app:navigationIcon="?attr/homeAsUpIndicator" + app:menu="@menu/view_thread_toolbar" app:navigationContentDescription="@string/abc_action_bar_up_description" - app:menu="@menu/view_thread_toolbar" /> + app:navigationIcon="?attr/homeAsUpIndicator" + app:title="@string/title_view_thread" /> @@ -25,8 +25,8 @@ android:id="@+id/swipeRefreshLayout" android:layout_width="640dp" android:layout_height="match_parent" - app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior" - android:layout_gravity="center_horizontal"> + android:layout_gravity="center_horizontal|top" + app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior"> + android:layout_gravity="center" + android:visibility="gone" /> diff --git a/app/src/main/res/layout/activity_followed_tags.xml b/app/src/main/res/layout/activity_followed_tags.xml new file mode 100644 index 000000000..f26027571 --- /dev/null +++ b/app/src/main/res/layout/activity_followed_tags.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 4e51b8b28..c0f583d64 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -28,20 +28,38 @@ - + android:orientation="horizontal"> + + + + + @@ -61,13 +79,30 @@ app:contentInsetStart="0dp" app:fabAlignmentMode="end"> - + android:layout_height="wrap_content" + android:orientation="horizontal"> + + + + + + @@ -87,8 +122,8 @@ android:id="@+id/progressBar" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:visibility="gone" - android:layout_gravity="center" /> + android:layout_gravity="center" + android:visibility="gone" /> diff --git a/app/src/main/res/layout/activity_tab_preference.xml b/app/src/main/res/layout/activity_tab_preference.xml index 06b1d1c13..f1ebf9d8f 100644 --- a/app/src/main/res/layout/activity_tab_preference.xml +++ b/app/src/main/res/layout/activity_tab_preference.xml @@ -29,6 +29,7 @@ android:layout_height="wrap_content" android:layout_gravity="bottom|end" android:layout_margin="16dp" + android:contentDescription="@string/action_add_tab" android:src="@drawable/ic_plus_24dp" /> + + + + + + + + + + + + +