parent
1f487faf93
commit
9f57699a3d
@ -0,0 +1,873 @@ |
||||
{ |
||||
"formatVersion": 1, |
||||
"database": { |
||||
"version": 25, |
||||
"identityHash": "322074fec4881114e2d85da67166e31f", |
||||
"entities": [ |
||||
{ |
||||
"tableName": "TootEntity", |
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `text` TEXT, `urls` TEXT, `descriptions` TEXT, `contentWarning` TEXT, `inReplyToId` TEXT, `inReplyToText` TEXT, `inReplyToUsername` TEXT, `visibility` INTEGER, `poll` TEXT, `formattingSyntax` TEXT NOT NULL, `markdownMode` INTEGER)", |
||||
"fields": [ |
||||
{ |
||||
"fieldPath": "uid", |
||||
"columnName": "uid", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "text", |
||||
"columnName": "text", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "urls", |
||||
"columnName": "urls", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "descriptions", |
||||
"columnName": "descriptions", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "contentWarning", |
||||
"columnName": "contentWarning", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "inReplyToId", |
||||
"columnName": "inReplyToId", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "inReplyToText", |
||||
"columnName": "inReplyToText", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "inReplyToUsername", |
||||
"columnName": "inReplyToUsername", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "visibility", |
||||
"columnName": "visibility", |
||||
"affinity": "INTEGER", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "poll", |
||||
"columnName": "poll", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "formattingSyntax", |
||||
"columnName": "formattingSyntax", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "markdownMode", |
||||
"columnName": "markdownMode", |
||||
"affinity": "INTEGER", |
||||
"notNull": false |
||||
} |
||||
], |
||||
"primaryKey": { |
||||
"columnNames": [ |
||||
"uid" |
||||
], |
||||
"autoGenerate": true |
||||
}, |
||||
"indices": [], |
||||
"foreignKeys": [] |
||||
}, |
||||
{ |
||||
"tableName": "AccountEntity", |
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsEmojiReactions` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL, `defaultFormattingSyntax` TEXT NOT NULL)", |
||||
"fields": [ |
||||
{ |
||||
"fieldPath": "id", |
||||
"columnName": "id", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "domain", |
||||
"columnName": "domain", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "accessToken", |
||||
"columnName": "accessToken", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "isActive", |
||||
"columnName": "isActive", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "accountId", |
||||
"columnName": "accountId", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "username", |
||||
"columnName": "username", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "displayName", |
||||
"columnName": "displayName", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "profilePictureUrl", |
||||
"columnName": "profilePictureUrl", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "notificationsEnabled", |
||||
"columnName": "notificationsEnabled", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "notificationsMentioned", |
||||
"columnName": "notificationsMentioned", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "notificationsFollowed", |
||||
"columnName": "notificationsFollowed", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "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": "notificationsEmojiReactions", |
||||
"columnName": "notificationsEmojiReactions", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "notificationSound", |
||||
"columnName": "notificationSound", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "notificationVibration", |
||||
"columnName": "notificationVibration", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "notificationLight", |
||||
"columnName": "notificationLight", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "defaultPostPrivacy", |
||||
"columnName": "defaultPostPrivacy", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "defaultMediaSensitivity", |
||||
"columnName": "defaultMediaSensitivity", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "alwaysShowSensitiveMedia", |
||||
"columnName": "alwaysShowSensitiveMedia", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "alwaysOpenSpoiler", |
||||
"columnName": "alwaysOpenSpoiler", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "mediaPreviewEnabled", |
||||
"columnName": "mediaPreviewEnabled", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "lastNotificationId", |
||||
"columnName": "lastNotificationId", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "activeNotifications", |
||||
"columnName": "activeNotifications", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "emojis", |
||||
"columnName": "emojis", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "tabPreferences", |
||||
"columnName": "tabPreferences", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "notificationsFilter", |
||||
"columnName": "notificationsFilter", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "defaultFormattingSyntax", |
||||
"columnName": "defaultFormattingSyntax", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
} |
||||
], |
||||
"primaryKey": { |
||||
"columnNames": [ |
||||
"id" |
||||
], |
||||
"autoGenerate": true |
||||
}, |
||||
"indices": [ |
||||
{ |
||||
"name": "index_AccountEntity_domain_accountId", |
||||
"unique": true, |
||||
"columnNames": [ |
||||
"domain", |
||||
"accountId" |
||||
], |
||||
"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, `version` TEXT, 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": "version", |
||||
"columnName": "version", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
} |
||||
], |
||||
"primaryKey": { |
||||
"columnNames": [ |
||||
"instance" |
||||
], |
||||
"autoGenerate": false |
||||
}, |
||||
"indices": [], |
||||
"foreignKeys": [] |
||||
}, |
||||
{ |
||||
"tableName": "TimelineStatusEntity", |
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT, `visibility` INTEGER, `attachments` TEXT, `mentions` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", |
||||
"fields": [ |
||||
{ |
||||
"fieldPath": "serverId", |
||||
"columnName": "serverId", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "url", |
||||
"columnName": "url", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "timelineUserId", |
||||
"columnName": "timelineUserId", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "authorServerId", |
||||
"columnName": "authorServerId", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "inReplyToId", |
||||
"columnName": "inReplyToId", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "inReplyToAccountId", |
||||
"columnName": "inReplyToAccountId", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "content", |
||||
"columnName": "content", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "createdAt", |
||||
"columnName": "createdAt", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "emojis", |
||||
"columnName": "emojis", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "reblogsCount", |
||||
"columnName": "reblogsCount", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "favouritesCount", |
||||
"columnName": "favouritesCount", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "reblogged", |
||||
"columnName": "reblogged", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "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": false |
||||
}, |
||||
{ |
||||
"fieldPath": "visibility", |
||||
"columnName": "visibility", |
||||
"affinity": "INTEGER", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "attachments", |
||||
"columnName": "attachments", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "mentions", |
||||
"columnName": "mentions", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "application", |
||||
"columnName": "application", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "reblogServerId", |
||||
"columnName": "reblogServerId", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "reblogAccountId", |
||||
"columnName": "reblogAccountId", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "poll", |
||||
"columnName": "poll", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
} |
||||
], |
||||
"primaryKey": { |
||||
"columnNames": [ |
||||
"serverId", |
||||
"timelineUserId" |
||||
], |
||||
"autoGenerate": false |
||||
}, |
||||
"indices": [ |
||||
{ |
||||
"name": "index_TimelineStatusEntity_authorServerId_timelineUserId", |
||||
"unique": false, |
||||
"columnNames": [ |
||||
"authorServerId", |
||||
"timelineUserId" |
||||
], |
||||
"createSql": "CREATE INDEX 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, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_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_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsible` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))", |
||||
"fields": [ |
||||
{ |
||||
"fieldPath": "accountId", |
||||
"columnName": "accountId", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "id", |
||||
"columnName": "id", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "accounts", |
||||
"columnName": "accounts", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "unread", |
||||
"columnName": "unread", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "lastStatus.id", |
||||
"columnName": "s_id", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "lastStatus.url", |
||||
"columnName": "s_url", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "lastStatus.inReplyToId", |
||||
"columnName": "s_inReplyToId", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "lastStatus.inReplyToAccountId", |
||||
"columnName": "s_inReplyToAccountId", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "lastStatus.account", |
||||
"columnName": "s_account", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "lastStatus.content", |
||||
"columnName": "s_content", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "lastStatus.createdAt", |
||||
"columnName": "s_createdAt", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "lastStatus.emojis", |
||||
"columnName": "s_emojis", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "lastStatus.favouritesCount", |
||||
"columnName": "s_favouritesCount", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "lastStatus.favourited", |
||||
"columnName": "s_favourited", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "lastStatus.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.showingHiddenContent", |
||||
"columnName": "s_showingHiddenContent", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "lastStatus.expanded", |
||||
"columnName": "s_expanded", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "lastStatus.collapsible", |
||||
"columnName": "s_collapsible", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "lastStatus.collapsed", |
||||
"columnName": "s_collapsed", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "lastStatus.poll", |
||||
"columnName": "s_poll", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
} |
||||
], |
||||
"primaryKey": { |
||||
"columnNames": [ |
||||
"id", |
||||
"accountId" |
||||
], |
||||
"autoGenerate": false |
||||
}, |
||||
"indices": [], |
||||
"foreignKeys": [] |
||||
}, |
||||
{ |
||||
"tableName": "ChatEntity", |
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`localId` INTEGER NOT NULL, `chatId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `unread` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `lastMessageId` TEXT, PRIMARY KEY(`localId`, `chatId`))", |
||||
"fields": [ |
||||
{ |
||||
"fieldPath": "localId", |
||||
"columnName": "localId", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "chatId", |
||||
"columnName": "chatId", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "accountId", |
||||
"columnName": "accountId", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "unread", |
||||
"columnName": "unread", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "updatedAt", |
||||
"columnName": "updatedAt", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "lastMessageId", |
||||
"columnName": "lastMessageId", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
} |
||||
], |
||||
"primaryKey": { |
||||
"columnNames": [ |
||||
"localId", |
||||
"chatId" |
||||
], |
||||
"autoGenerate": false |
||||
}, |
||||
"indices": [], |
||||
"foreignKeys": [] |
||||
}, |
||||
{ |
||||
"tableName": "ChatMessageEntity", |
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`localId` INTEGER NOT NULL, `messageId` TEXT NOT NULL, `content` TEXT NOT NULL, `chatId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `attachment` TEXT, `emojis` TEXT NOT NULL, PRIMARY KEY(`localId`, `messageId`))", |
||||
"fields": [ |
||||
{ |
||||
"fieldPath": "localId", |
||||
"columnName": "localId", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "messageId", |
||||
"columnName": "messageId", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "content", |
||||
"columnName": "content", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "chatId", |
||||
"columnName": "chatId", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "accountId", |
||||
"columnName": "accountId", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "createdAt", |
||||
"columnName": "createdAt", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "attachment", |
||||
"columnName": "attachment", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "emojis", |
||||
"columnName": "emojis", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
} |
||||
], |
||||
"primaryKey": { |
||||
"columnNames": [ |
||||
"localId", |
||||
"messageId" |
||||
], |
||||
"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, '322074fec4881114e2d85da67166e31f')" |
||||
] |
||||
} |
||||
} |
@ -0,0 +1,175 @@ |
||||
package com.keylesspalace.tusky.adapter |
||||
|
||||
import android.opengl.Visibility |
||||
import android.text.TextUtils |
||||
import android.text.format.DateUtils |
||||
import android.view.LayoutInflater |
||||
import android.view.View |
||||
import android.view.ViewGroup |
||||
import android.widget.ImageView |
||||
import android.widget.TextView |
||||
import androidx.recyclerview.widget.RecyclerView |
||||
import at.connyduck.sparkbutton.helpers.Utils |
||||
import com.bumptech.glide.Glide |
||||
import com.keylesspalace.tusky.R |
||||
import com.keylesspalace.tusky.interfaces.AccountActionListener |
||||
import com.keylesspalace.tusky.interfaces.ChatActionListener |
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions |
||||
import com.keylesspalace.tusky.util.TimestampUtils |
||||
import com.keylesspalace.tusky.util.emojify |
||||
import com.keylesspalace.tusky.util.loadAvatar |
||||
import com.keylesspalace.tusky.viewdata.ChatViewData |
||||
import java.text.SimpleDateFormat |
||||
import java.util.* |
||||
|
||||
class ChatsViewHolder(view: View) : RecyclerView.ViewHolder(view) { |
||||
object Key { |
||||
const val KEY_CREATED = "created" |
||||
} |
||||
|
||||
private val avatar: ImageView = view.findViewById(R.id.status_avatar) |
||||
private val avatarInset: ImageView = view.findViewById(R.id.status_avatar_inset) |
||||
private val displayName: TextView = view.findViewById(R.id.status_display_name) |
||||
private val userName: TextView = view.findViewById(R.id.status_username) |
||||
private val timestamp: TextView = view.findViewById(R.id.status_timestamp_info) |
||||
private val content: TextView = view.findViewById(R.id.status_content) |
||||
private val unread: TextView = view.findViewById(R.id.chat_unread) |
||||
|
||||
private val shortSdf = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) |
||||
private val longSdf = SimpleDateFormat("MM/dd HH:mm:ss", Locale.getDefault()) |
||||
|
||||
fun setupWithChat(chat: ChatViewData.Concrete, listener: ChatActionListener, statusDisplayOptions: StatusDisplayOptions, payload: Any?) { |
||||
if(payload == null) { |
||||
displayName.text = chat.account.displayName?.emojify(chat.account.emojis, displayName, true) |
||||
?: "" |
||||
userName.text = userName.context.getString(R.string.status_username_format, chat.account.localUsername) |
||||
setUpdatedAt(chat.updatedAt, statusDisplayOptions) |
||||
setAvatar(chat.account.avatar, chat.account.bot, statusDisplayOptions) |
||||
if(chat.unread <= 0) { |
||||
unread.visibility = View.GONE |
||||
} else if(chat.unread > 99) { |
||||
unread.text = ":)" |
||||
} else { |
||||
unread.text = chat.unread.toString() |
||||
} |
||||
avatar.setOnClickListener { listener.onViewAccount(chat.account.id) } |
||||
val onLongClickListener = View.OnLongClickListener { |
||||
listener.onMore(chat.id, it) |
||||
true |
||||
} |
||||
|
||||
content.setOnLongClickListener(onLongClickListener) |
||||
itemView.setOnLongClickListener(onLongClickListener) |
||||
content.setOnClickListener { } |
||||
itemView.setOnClickListener { } |
||||
|
||||
chat.lastMessage?.let { |
||||
content.text = it.content.emojify(it.emojis, content, true) |
||||
} |
||||
} else { |
||||
if(payload is List<*>) { |
||||
for (item in payload as List<*>) { |
||||
if (Key.KEY_CREATED == item) { |
||||
setUpdatedAt(chat.updatedAt, statusDisplayOptions) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
private fun setAvatar(url: String, |
||||
isBot: Boolean, |
||||
statusDisplayOptions: StatusDisplayOptions) { |
||||
avatar.setPaddingRelative(0, 0, 0, 0) |
||||
if (statusDisplayOptions.showBotOverlay && isBot) { |
||||
avatarInset.visibility = View.VISIBLE |
||||
avatarInset.setBackgroundColor(0x50ffffff) |
||||
Glide.with(avatarInset) |
||||
.load(R.drawable.ic_bot_24dp) |
||||
.into(avatarInset) |
||||
} else { |
||||
avatarInset.visibility = View.GONE |
||||
} |
||||
val avatarRadius = itemView.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp); |
||||
loadAvatar(url, avatar, avatarRadius, |
||||
statusDisplayOptions.animateAvatars) |
||||
} |
||||
|
||||
|
||||
private fun getAbsoluteTime(createdAt: Date?): String? { |
||||
if (createdAt == null) { |
||||
return "??:??:??" |
||||
} |
||||
return if (DateUtils.isToday(createdAt.time)) { |
||||
shortSdf.format(createdAt) |
||||
} else { |
||||
longSdf.format(createdAt) |
||||
} |
||||
} |
||||
|
||||
private fun setUpdatedAt(updatedAt: Date, statusDisplayOptions: StatusDisplayOptions) { |
||||
if (statusDisplayOptions.useAbsoluteTime) { |
||||
timestamp.text = getAbsoluteTime(updatedAt) |
||||
} else { |
||||
val then = updatedAt.time |
||||
val now = System.currentTimeMillis() |
||||
val readout = TimestampUtils.getRelativeTimeSpanString(timestamp.context, then, now) |
||||
timestamp.text = readout |
||||
} |
||||
} |
||||
|
||||
} |
||||
|
||||
class ChatsAdapter(private val dataSource: TimelineAdapter.AdapterDataSource<ChatViewData>, |
||||
val statusDisplayOptions: StatusDisplayOptions, |
||||
private val chatActionListener: ChatActionListener) : RecyclerView.Adapter<RecyclerView.ViewHolder>() { |
||||
|
||||
private val VIEW_TYPE_CHAT = 0 |
||||
private val VIEW_TYPE_PLACEHOLDER = 1 |
||||
|
||||
override fun getItemCount(): Int { |
||||
return dataSource.itemCount |
||||
} |
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { |
||||
bindViewHolder(holder, position, null) |
||||
} |
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int, payload: MutableList<Any>) { |
||||
bindViewHolder(holder, position, payload) |
||||
} |
||||
|
||||
private fun bindViewHolder(holder: RecyclerView.ViewHolder, position: Int, payloads: MutableList<Any>?) { |
||||
val chat: ChatViewData = dataSource.getItemAt(position) |
||||
if(holder is PlaceholderViewHolder) { |
||||
holder.setup(chatActionListener, (chat as ChatViewData.Placeholder).isLoading) |
||||
} else if(holder is ChatsViewHolder) { |
||||
holder.setupWithChat(chat as ChatViewData.Concrete, chatActionListener, statusDisplayOptions, |
||||
if (payloads != null && payloads.isNotEmpty()) payloads[0] else null) |
||||
} |
||||
} |
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { |
||||
if(viewType == VIEW_TYPE_CHAT ) { |
||||
val view = LayoutInflater.from(parent.context) |
||||
.inflate(R.layout.item_chat, parent, false) |
||||
return ChatsViewHolder(view) |
||||
} |
||||
// else VIEW_TYPE_PLACEHOLDER |
||||
|
||||
val view = LayoutInflater.from(parent.context) |
||||
.inflate(R.layout.item_status_placeholder, parent, false) |
||||
return PlaceholderViewHolder(view) |
||||
} |
||||
|
||||
override fun getItemViewType(position: Int): Int { |
||||
if(dataSource.getItemAt(position) is ChatViewData.Concrete) |
||||
return VIEW_TYPE_CHAT |
||||
|
||||
return VIEW_TYPE_PLACEHOLDER |
||||
} |
||||
|
||||
override fun getItemId(position: Int): Long { |
||||
return dataSource.getItemAt(position).getViewDataId() |
||||
} |
||||
} |
@ -0,0 +1,21 @@ |
||||
package com.keylesspalace.tusky.db |
||||
|
||||
import androidx.room.* |
||||
|
||||
@Entity( |
||||
primaryKeys = ["localId", "chatId"] |
||||
) |
||||
data class ChatEntity ( |
||||
val localId: Long, /* our user account id */ |
||||
val chatId: String, |
||||
val accountId: String, |
||||
val unread: Long, |
||||
val updatedAt: Long, |
||||
val lastMessageId: String? |
||||
) |
||||
|
||||
data class ChatEntityWithAccount ( |
||||
@Embedded val chat: ChatEntity, |
||||
@Embedded(prefix = "a_") val account: TimelineAccountEntity?, |
||||
@Embedded(prefix = "msg_") val lastMessage: ChatMessageEntity? = null |
||||
) |
@ -0,0 +1,21 @@ |
||||
package com.keylesspalace.tusky.db |
||||
|
||||
import androidx.room.Entity |
||||
|
||||
/* |
||||
* ChatMessage model |
||||
*/ |
||||
|
||||
@Entity( |
||||
primaryKeys = ["localId", "messageId"] |
||||
) |
||||
data class ChatMessageEntity( |
||||
val localId: Long, |
||||
val messageId: String, |
||||
val content: String, |
||||
val chatId: String, |
||||
val accountId: String, |
||||
val createdAt: Long, |
||||
val attachment: String?, |
||||
val emojis: String |
||||
) |
@ -0,0 +1,85 @@ |
||||
package com.keylesspalace.tusky.db |
||||
|
||||
import androidx.room.* |
||||
import androidx.room.OnConflictStrategy.IGNORE |
||||
import androidx.room.OnConflictStrategy.REPLACE |
||||
import io.reactivex.Single |
||||
|
||||
@Dao |
||||
abstract class ChatsDao { |
||||
|
||||
// TODO: must be ordering by date but it leads to issues |
||||
@Query("""SELECT c.chatId, c.localId, c.accountId, c.lastMessageId, c.unread, c.updatedAt, |
||||
a.serverId as 'a_serverId', a.timelineUserId as 'a_timelineUserId', |
||||
a.localUsername as 'a_localUsername', a.username as 'a_username', |
||||
a.displayName as 'a_displayName', a.url as 'a_url', a.avatar as 'a_avatar', |
||||
a.emojis as 'a_emojis', a.bot as 'a_bot', |
||||
msg.accountId as 'msg_accountId', msg.localId as 'msg_localId', |
||||
msg.chatId as 'msg_chatId', msg.attachment as 'msg_attachment', |
||||
msg.content as 'msg_content', msg.createdAt as 'msg_createdAt', msg.emojis as 'msg_emojis', |
||||
msg.messageId as 'msg_messageId' |
||||
FROM ChatEntity c |
||||
LEFT JOIN TimelineAccountEntity a ON (a.timelineUserId == :localId AND a.serverId = c.accountId) |
||||
LEFT JOIN ChatMessageEntity msg ON (msg.localId == :localId AND msg.chatId == c.chatId) |
||||
WHERE c.localId = :localId |
||||
AND (CASE WHEN :maxId IS NOT NULL THEN |
||||
(LENGTH(c.chatId) < LENGTH(:maxId) OR LENGTH(c.chatId) == LENGTH(:maxId) AND c.chatId < :maxId) |
||||
ELSE 1 END) |
||||
AND (CASE WHEN :sinceId IS NOT NULL THEN |
||||
(LENGTH(c.chatId) > LENGTH(:sinceId) OR LENGTH(c.chatId) == LENGTH(:sinceId) AND c.chatId > :sinceId) |
||||
ELSE 1 END) |
||||
ORDER BY LENGTH(c.chatId) DESC, c.chatId DESC |
||||
LIMIT :limit |
||||
""") |
||||
abstract fun getChatsForAccount(localId: Long, maxId: String?, sinceId: String?, limit: Int) : Single<List<ChatEntityWithAccount>> |
||||
|
||||
@Insert(onConflict = REPLACE) |
||||
abstract fun insertChat(chatEntity: ChatEntity) : Long |
||||
|
||||
@Insert(onConflict = IGNORE) |
||||
abstract fun insertChatIfNotThere(chatEntity: ChatEntity): Long |
||||
|
||||
@Insert(onConflict = REPLACE) |
||||
abstract fun insertAccount(accountEntity: TimelineAccountEntity) : Long |
||||
|
||||
@Insert(onConflict = REPLACE) |
||||
abstract fun insertChatMessage(chatMessageEntity: ChatMessageEntity) : Long |
||||
|
||||
@Transaction |
||||
open fun insertInTransaction(chatEntity: ChatEntity, lastMessage: ChatMessageEntity?, accountEntity: TimelineAccountEntity) { |
||||
insertAccount(accountEntity) |
||||
lastMessage?.let(this::insertChatMessage) |
||||
insertChat(chatEntity) |
||||
} |
||||
|
||||
@Transaction |
||||
open fun setLastMessage(accountId: Long, chatId: String, lastMessageEntity: ChatMessageEntity) { |
||||
insertChatMessage(lastMessageEntity) |
||||
setLastMessageId(accountId, chatId, lastMessageEntity.messageId) |
||||
} |
||||
|
||||
@Query("""UPDATE ChatEntity SET lastMessageId = :messageId WHERE localId = :localId AND chatId = :chatId""") |
||||
abstract fun setLastMessageId(localId: Long, chatId: String, messageId: String) |
||||
|
||||
@Query("""DELETE FROM ChatEntity WHERE accountId = "" |
||||
AND localId = :account AND |
||||
(LENGTH(chatId) < LENGTH(:maxId) OR LENGTH(chatId) == LENGTH(:maxId) AND chatId < :maxId) |
||||
AND |
||||
(LENGTH(chatId) > LENGTH(:sinceId) OR LENGTH(chatId) == LENGTH(:sinceId) AND chatId > :sinceId) |
||||
""") |
||||
abstract fun removeAllPlaceholdersBetween(account: Long, maxId: String, sinceId: String) |
||||
|
||||
@Query("""DELETE FROM ChatEntity WHERE localId = :accountId AND |
||||
(LENGTH(chatId) < LENGTH(:maxId) OR LENGTH(chatId) == LENGTH(:maxId) AND chatId < :maxId) |
||||
AND |
||||
(LENGTH(chatId) > LENGTH(:minId) OR LENGTH(chatId) == LENGTH(:minId) AND chatId > :minId) |
||||
""") |
||||
abstract fun deleteRange(accountId: Long, minId: String, maxId: String) |
||||
|
||||
|
||||
@Query("""DELETE FROM ChatEntity WHERE localId = :localId AND accountId = :accountId""") |
||||
abstract fun deleteChatByAccount(localId: Long, accountId: String) |
||||
|
||||
@Query("""DELETE FROM ChatEntity WHERE localId = :localId AND chatId = :chatId""") |
||||
abstract fun deleteChat(localId: Long, chatId: String) |
||||
} |
@ -0,0 +1,722 @@ |
||||
package com.keylesspalace.tusky.fragment |
||||
|
||||
import android.content.Context |
||||
import android.os.Bundle |
||||
import android.util.Log |
||||
import android.view.LayoutInflater |
||||
import android.view.View |
||||
import android.view.ViewGroup |
||||
import androidx.appcompat.widget.PopupMenu |
||||
import androidx.arch.core.util.Function |
||||
import androidx.lifecycle.Lifecycle |
||||
import androidx.preference.PreferenceManager |
||||
import androidx.recyclerview.widget.* |
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener |
||||
import com.keylesspalace.tusky.BottomSheetActivity |
||||
import com.keylesspalace.tusky.PostLookupFallbackBehavior |
||||
import com.keylesspalace.tusky.R |
||||
import com.keylesspalace.tusky.adapter.ChatsAdapter |
||||
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder |
||||
import com.keylesspalace.tusky.adapter.TimelineAdapter |
||||
import com.keylesspalace.tusky.appstore.* |
||||
import com.keylesspalace.tusky.db.AccountManager |
||||
import com.keylesspalace.tusky.di.Injectable |
||||
import com.keylesspalace.tusky.entity.Chat |
||||
import com.keylesspalace.tusky.interfaces.ActionButtonActivity |
||||
import com.keylesspalace.tusky.interfaces.ChatActionListener |
||||
import com.keylesspalace.tusky.interfaces.RefreshableFragment |
||||
import com.keylesspalace.tusky.interfaces.ReselectableFragment |
||||
import com.keylesspalace.tusky.network.MastodonApi |
||||
import com.keylesspalace.tusky.network.TimelineCases |
||||
import com.keylesspalace.tusky.repository.ChatRepository |
||||
import com.keylesspalace.tusky.repository.ChatStatus |
||||
import com.keylesspalace.tusky.repository.Placeholder |
||||
import com.keylesspalace.tusky.repository.TimelineRequestMode |
||||
import com.keylesspalace.tusky.util.* |
||||
import com.keylesspalace.tusky.util.Either.Left |
||||
import com.keylesspalace.tusky.view.EndlessOnScrollListener |
||||
import com.keylesspalace.tusky.viewdata.ChatViewData |
||||
import com.uber.autodispose.AutoDispose |
||||
import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider |
||||
import com.uber.autodispose.android.lifecycle.autoDispose |
||||
import io.reactivex.Observable |
||||
import io.reactivex.android.schedulers.AndroidSchedulers |
||||
import kotlinx.android.synthetic.main.fragment_timeline.* |
||||
import java.io.IOException |
||||
import java.util.concurrent.TimeUnit |
||||
import javax.inject.Inject |
||||
|
||||
class ChatsFragment : BaseFragment(), Injectable, RefreshableFragment, ReselectableFragment, ChatActionListener, OnRefreshListener { |
||||
private val TAG = "ChatsF" // logging tag |
||||
private val LOAD_AT_ONCE = 30 |
||||
|
||||
@Inject |
||||
lateinit var eventHub: EventHub |
||||
@Inject |
||||
lateinit var api: MastodonApi |
||||
@Inject |
||||
lateinit var accountManager: AccountManager |
||||
@Inject |
||||
lateinit var chatRepo: ChatRepository |
||||
@Inject |
||||
lateinit var timelineCases: TimelineCases |
||||
|
||||
lateinit var adapter: ChatsAdapter |
||||
|
||||
lateinit var layoutManager: LinearLayoutManager |
||||
|
||||
private lateinit var scrollListener: EndlessOnScrollListener |
||||
|
||||
private lateinit var bottomSheetActivity: BottomSheetActivity |
||||
private var hideFab = false |
||||
private var bottomLoading = false |
||||
|
||||
private var eventRegistered = false |
||||
private var isSwipeToRefreshEnabled = true |
||||
private var isNeedRefresh = false |
||||
private var didLoadEverythingBottom = false |
||||
private var initialUpdateFailed = false |
||||
|
||||
private enum class FetchEnd { |
||||
TOP, BOTTOM, MIDDLE |
||||
} |
||||
|
||||
private val chats = PairedList<ChatStatus, ChatViewData?>(Function<ChatStatus, ChatViewData?> {input -> |
||||
input.asRightOrNull()?.let(ViewDataUtils::chatToViewData) ?: |
||||
ChatViewData.Placeholder(input.asLeft().id, false) |
||||
}) |
||||
|
||||
private val listUpdateCallback = object : ListUpdateCallback { |
||||
override fun onInserted(position: Int, count: Int) { |
||||
if (isAdded) { |
||||
Log.d(TAG, "onInserted"); |
||||
adapter.notifyItemRangeInserted(position, count) |
||||
if (position == 0 && context != null) { |
||||
recyclerView.scrollToPosition(0) |
||||
} |
||||
} |
||||
} |
||||
|
||||
override fun onRemoved(position: Int, count: Int) { |
||||
Log.d(TAG, "onRemoved"); |
||||
adapter.notifyItemRangeRemoved(position, count) |
||||
} |
||||
|
||||
override fun onMoved(fromPosition: Int, toPosition: Int) { |
||||
Log.d(TAG, "onMoved"); |
||||
adapter.notifyItemMoved(fromPosition, toPosition) |
||||
} |
||||
|
||||
override fun onChanged(position: Int, count: Int, payload: Any?) { |
||||
Log.d(TAG, "onChanged"); |
||||
adapter.notifyItemRangeChanged(position, count, payload) |
||||
} |
||||
} |
||||
|
||||
private val diffCallback: DiffUtil.ItemCallback<ChatViewData> = object : DiffUtil.ItemCallback<ChatViewData>() { |
||||
override fun areItemsTheSame(oldItem: ChatViewData, newItem: ChatViewData): Boolean { |
||||
return oldItem.getViewDataId() == newItem.getViewDataId() |
||||
} |
||||
|
||||
override fun areContentsTheSame(oldItem: ChatViewData, newItem: ChatViewData): Boolean { |
||||
return false // Items are different always. It allows to refresh timestamp on every view holder update |
||||
} |
||||
|
||||
override fun getChangePayload(oldItem: ChatViewData, newItem: ChatViewData): Any? { |
||||
return if (oldItem.deepEquals(newItem)) { |
||||
//If items are equal - update timestamp only |
||||
listOf(StatusBaseViewHolder.Key.KEY_CREATED) |
||||
} else // If items are different - update a whole view holder |
||||
null |
||||
} |
||||
} |
||||
|
||||
private val differ = AsyncListDiffer(listUpdateCallback, |
||||
AsyncDifferConfig.Builder(diffCallback).build()) |
||||
|
||||
private val dataSource: TimelineAdapter.AdapterDataSource<ChatViewData> = object : TimelineAdapter.AdapterDataSource<ChatViewData> { |
||||
override fun getItemCount(): Int { |
||||
return differ.currentList.size |
||||
} |
||||
|
||||
override fun getItemAt(pos: Int): ChatViewData { |
||||
return differ.currentList[pos] |
||||
} |
||||
} |
||||
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) { |
||||
super.onCreate(savedInstanceState) |
||||
|
||||
val preferences = PreferenceManager.getDefaultSharedPreferences(activity) |
||||
|
||||
val statusDisplayOptions = StatusDisplayOptions( |
||||
preferences.getBoolean("animateGifAvatars", false), |
||||
accountManager.activeAccount!!.mediaPreviewEnabled, |
||||
preferences.getBoolean("absoluteTimeView", false), |
||||
preferences.getBoolean("showBotOverlay", true), |
||||
false, CardViewMode.NONE,false |
||||
) |
||||
adapter = ChatsAdapter(dataSource, statusDisplayOptions, this) |
||||
} |
||||
|
||||
override fun onAttach(context: Context) { |
||||
super.onAttach(context) |
||||
bottomSheetActivity = if (context is BottomSheetActivity) { |
||||
context |
||||
} else { |
||||
throw IllegalStateException("Fragment must be attached to a BottomSheetActivity!") |
||||
} |
||||
} |
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { |
||||
return inflater.inflate(R.layout.fragment_timeline, container, false) |
||||
} |
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { |
||||
super.onViewCreated(view, savedInstanceState) |
||||
|
||||
swipeRefreshLayout.isEnabled = isSwipeToRefreshEnabled |
||||
swipeRefreshLayout.setOnRefreshListener(this) |
||||
swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue) |
||||
|
||||
// TODO: a11y |
||||
recyclerView.setHasFixedSize(true) |
||||
layoutManager = LinearLayoutManager(view.context) |
||||
recyclerView.layoutManager = layoutManager |
||||
recyclerView.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL)) |
||||
recyclerView.adapter = adapter |
||||
|
||||
if (chats.isEmpty()) { |
||||
progressBar.visibility = View.VISIBLE |
||||
bottomLoading = true |
||||
sendInitialRequest() |
||||
} else { |
||||
progressBar.visibility = View.GONE |
||||
if (isNeedRefresh) onRefresh() |
||||
} |
||||
} |
||||
|
||||
private fun sendInitialRequest() { |
||||
// debug |
||||
// sendFetchChatsRequest(null, null, null, FetchEnd.BOTTOM, -1) |
||||
tryCache() |
||||
} |
||||
|
||||
private fun clearPlaceholdersForResponse(chats: MutableList<Either<Placeholder, Chat>>) { |
||||
chats.removeAll { it.isLeft() } |
||||
} |
||||
|
||||
private fun tryCache() { |
||||
// Request timeline from disk to make it quick, then replace it with timeline from |
||||
// the server to update it |
||||
chatRepo.getChats(null, null, null, LOAD_AT_ONCE, TimelineRequestMode.DISK) |
||||
.observeOn(AndroidSchedulers.mainThread()) |
||||
.autoDispose(this, Lifecycle.Event.ON_DESTROY) |
||||
.subscribe { chats -> |
||||
if (chats.size > 1) { |
||||
val mutableChats = chats.toMutableList() |
||||
clearPlaceholdersForResponse(mutableChats) |
||||
this.chats.clear() |
||||
this.chats.addAll(mutableChats) |
||||
updateAdapter() |
||||
progressBar.visibility = View.GONE |
||||
// Request statuses including current top to refresh all of them |
||||
} |
||||
updateCurrent() |
||||
loadAbove() |
||||
} |
||||
} |
||||
|
||||
private fun updateCurrent() { |
||||
if (chats.isEmpty()) { |
||||
return |
||||
} |
||||
|
||||
val topId = chats.first { it.isRight() }.asRight().id |
||||
chatRepo.getChats(topId, null, null, LOAD_AT_ONCE, TimelineRequestMode.NETWORK) |
||||
.observeOn(AndroidSchedulers.mainThread()) |
||||
.autoDispose(this, Lifecycle.Event.ON_DESTROY) |
||||
.subscribe({ chats -> |
||||
initialUpdateFailed = false |
||||
// When cached timeline is too old, we would replace it with nothing |
||||
if (chats.isNotEmpty()) { |
||||
// clear old cached statuses |
||||
if(this.chats.isNotEmpty()) { |
||||
this.chats.removeAll { |
||||
if(it.isRight()) { |
||||
val chat = it.asRight() |
||||
chat.id.length < topId.length || chat.id < topId |
||||
} else { |
||||
val placeholder = it.asLeft() |
||||
placeholder.id.length < topId.length || placeholder.id < topId |
||||
} |
||||
} |
||||
} |
||||
this.chats.addAll(chats) |
||||
updateAdapter() |
||||
} |
||||
bottomLoading = false |
||||
}, |
||||
{ |
||||
initialUpdateFailed = true |
||||
// Indicate that we are not loading anymore |
||||
progressBar.visibility = View.GONE |
||||
swipeRefreshLayout.isRefreshing = false |
||||
}) |
||||
} |
||||
|
||||
private fun showNothing() { |
||||
statusView.visibility = View.VISIBLE |
||||
statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null) |
||||
} |
||||
|
||||
private fun removeAllByAccountId(accountId: String) { |
||||
chats.removeAll { |
||||
val chat = it.asRightOrNull() |
||||
chat != null && chat.account.id == accountId |
||||
} |
||||
updateAdapter() |
||||
} |
||||
|
||||
private fun removeAllByInstance(instance: String) { |
||||
chats.removeAll { |
||||
val chat = it.asRightOrNull() |
||||
chat != null && LinkHelper.getDomain(chat.account.url) == instance |
||||
} |
||||
updateAdapter() |
||||
} |
||||
|
||||
private fun deleteChatById(id: String) { |
||||
val iterator = chats.iterator() |
||||
while(iterator.hasNext()) { |
||||
val chat = iterator.next().asRightOrNull() |
||||
if(chat != null && chat.id == id) { |
||||
iterator.remove() |
||||
updateAdapter() |
||||
break |
||||
} |
||||
} |
||||
|
||||
if(chats.isEmpty()) { |
||||
showNothing() |
||||
} |
||||
} |
||||
|
||||
override fun onActivityCreated(savedInstanceState: Bundle?) { |
||||
super.onActivityCreated(savedInstanceState) |
||||
|
||||
/* 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 statuses as it goes, and hides |
||||
* the follow button on down-scroll. */ |
||||
val preferences = PreferenceManager.getDefaultSharedPreferences(context) |
||||
hideFab = preferences.getBoolean("fabHide", false) |
||||
scrollListener = object : EndlessOnScrollListener(layoutManager) { |
||||
override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) { |
||||
super.onScrolled(view, dx, dy) |
||||
val activity = activity as ActionButtonActivity? |
||||
val composeButton = activity!!.actionButton |
||||
if (composeButton != null) { |
||||
if (hideFab) { |
||||
if (dy > 0 && composeButton.isShown) { |
||||
composeButton.hide() // hides the button if we're scrolling down |
||||
} else if (dy < 0 && !composeButton.isShown) { |
||||
composeButton.show() // shows it if we are scrolling up |
||||
} |
||||
} else if (!composeButton.isShown) { |
||||
composeButton.show() |
||||
} |
||||
} |
||||
} |
||||
|
||||
override fun onLoadMore(totalItemsCount: Int, view: RecyclerView) { |
||||
this@ChatsFragment.onLoadMore() |
||||
} |
||||
} |
||||
recyclerView.addOnScrollListener(scrollListener) |
||||
if (!eventRegistered) { |
||||
eventHub.events |
||||
.observeOn(AndroidSchedulers.mainThread()) |
||||
.autoDispose(this, Lifecycle.Event.ON_DESTROY) |
||||
.subscribe { event: Event? -> |
||||
when(event) { |
||||
is BlockEvent -> removeAllByAccountId(event.accountId) |
||||
is MuteEvent -> removeAllByAccountId(event.accountId) |
||||
is DomainMuteEvent -> removeAllByInstance(event.instance) |
||||
is StatusDeletedEvent -> deleteChatById(event.statusId) |
||||
is PreferenceChangedEvent -> onPreferenceChanged(event.preferenceKey) |
||||
} |
||||
} |
||||
eventRegistered = true |
||||
} |
||||
} |
||||
|
||||
private fun onPreferenceChanged(key: String) { |
||||
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) |
||||
when (key) { |
||||
"fabHide" -> { |
||||
hideFab = sharedPreferences.getBoolean("fabHide", false) |
||||
} |
||||
} |
||||
} |
||||
|
||||
override fun onRefresh() { |
||||
if (isSwipeToRefreshEnabled) |
||||
swipeRefreshLayout.isEnabled = true |
||||
|
||||
statusView.visibility = View.GONE |
||||
isNeedRefresh = false |
||||
|
||||
if (this.initialUpdateFailed) { |
||||
updateCurrent() |
||||
} |
||||
loadAbove() |
||||
} |
||||
|
||||
private fun loadAbove() { |
||||
var firstOrNull: String? = null |
||||
var secondOrNull: String? = null |
||||
for (i in chats.indices) { |
||||
val chat = chats[i] |
||||
if (chat.isRight()) { |
||||
firstOrNull = chat.asRight().id |
||||
if (i + 1 < chats.size && chats[i + 1].isRight()) { |
||||
secondOrNull = chats[i + 1].asRight().id |
||||
} |
||||
break |
||||
} |
||||
} |
||||
if (firstOrNull != null) { |
||||
sendFetchChatsRequest(null, firstOrNull, secondOrNull, FetchEnd.TOP, -1) |
||||
} else { |
||||
sendFetchChatsRequest(null, null, null, FetchEnd.BOTTOM, -1) |
||||
} |
||||
} |
||||
|
||||
private fun onLoadMore() { |
||||
if (didLoadEverythingBottom || bottomLoading) { |
||||
return |
||||
} |
||||
if (chats.isEmpty()) { |
||||
sendInitialRequest() |
||||
return |
||||
} |
||||
bottomLoading = true |
||||
val last = chats.last() |
||||
val placeholder: Placeholder |
||||
if (last.isRight()) { |
||||
val placeholderId = last.asRight().id.dec() |
||||
placeholder = Placeholder(placeholderId) |
||||
chats.add(Left(placeholder)) |
||||
} else { |
||||
placeholder = last.asLeft() |
||||
} |
||||
chats.setPairedItem(chats.size - 1, |
||||
ChatViewData.Placeholder(placeholder.id, true)) |
||||
updateAdapter() |
||||
val bottomId = chats.findLast { it.isRight() }?.let { it.asRight().id } |
||||
sendFetchChatsRequest(bottomId, null, null, FetchEnd.BOTTOM, -1) |
||||
} |
||||
|
||||
|
||||
private fun sendFetchChatsRequest(maxId: String?, sinceId: String?, |
||||
sinceIdMinusOne: String?, |
||||
fetchEnd: FetchEnd, pos: Int) { |
||||
if (isAdded |
||||
&& (fetchEnd == FetchEnd.TOP || fetchEnd == FetchEnd.BOTTOM && maxId == null && progressBar.visibility != View.VISIBLE) |
||||
&& !isSwipeToRefreshEnabled) |
||||
topProgressBar.show() |
||||
// allow getting old statuses/fallbacks for network only for for bottom loading |
||||
val mode = if (fetchEnd == FetchEnd.BOTTOM) { |
||||
TimelineRequestMode.ANY |
||||
} else { |
||||
TimelineRequestMode.NETWORK |
||||
} |
||||
chatRepo.getChats(maxId, sinceId, sinceIdMinusOne, LOAD_AT_ONCE, mode) |
||||
.observeOn(AndroidSchedulers.mainThread()) |
||||
.autoDispose(this, Lifecycle.Event.ON_DESTROY) |
||||
.subscribe( { result -> onFetchTimelineSuccess(result.toMutableList(), fetchEnd, pos) }, |
||||
{ onFetchTimelineFailure(Exception(it), fetchEnd, pos) }) |
||||
} |
||||
|
||||
private fun updateChats(newChats: MutableList<ChatStatus>, fullFetch: Boolean) { |
||||
if (newChats.isEmpty()) { |
||||
updateAdapter() |
||||
return |
||||
} |
||||
if (chats.isEmpty()) { |
||||
chats.addAll(newChats) |
||||
} else { |
||||
val lastOfNew = newChats[newChats.size - 1] |
||||
val index = chats.indexOf(lastOfNew) |
||||
if (index >= 0) { |
||||
chats.subList(0, index).clear() |
||||
} |
||||
val newIndex = newChats.indexOf(chats[0]) |
||||
if (newIndex == -1) { |
||||
if (index == -1 && fullFetch) { |
||||
newChats.findLast { it.isRight() }?.let { |
||||
val placeholderId = it.asRight().id.inc() |
||||
newChats.add(Left(Placeholder(placeholderId))) |
||||
} |
||||
} |
||||
chats.addAll(0, newChats) |
||||
} else { |
||||
chats.addAll(0, newChats.subList(0, newIndex)) |
||||
} |
||||
} |
||||
// Remove all consecutive placeholders |
||||
removeConsecutivePlaceholders() |
||||
updateAdapter() |
||||
} |
||||
|
||||
private fun removeConsecutivePlaceholders() { |
||||
for (i in 0 until chats.size - 1) { |
||||
if (chats[i].isLeft() && chats[i + 1].isLeft()) { |
||||
chats.removeAt(i) |
||||
} |
||||
} |
||||
} |
||||
|
||||
private fun replacePlaceholderWithChats(newChats: MutableList<ChatStatus>, |
||||
fullFetch: Boolean, pos: Int) { |
||||
val placeholder = chats[pos] |
||||
if (placeholder.isLeft()) { |
||||
chats.removeAt(pos) |
||||
} |
||||
if (newChats.isEmpty()) { |
||||
updateAdapter() |
||||
return |
||||
} |
||||
if (fullFetch) { |
||||
newChats.add(placeholder) |
||||
} |
||||
chats.addAll(pos, newChats) |
||||
removeConsecutivePlaceholders() |
||||
updateAdapter() |
||||
} |
||||
|
||||
private fun addItems(newChats: List<ChatStatus>) { |
||||
if (newChats.isEmpty()) { |
||||
return |
||||
} |
||||
val last = chats.findLast { it.isRight() } |
||||
|
||||
// I was about to replace findStatus with indexOf but it is incorrect to compare value |
||||
// types by ID anyway and we should change equals() for Status, I think, so this makes sense |
||||
if (last != null && !newChats.contains(last)) { |
||||
chats.addAll(newChats) |
||||
removeConsecutivePlaceholders() |
||||
updateAdapter() |
||||
} |
||||
} |
||||
|
||||
private fun onFetchTimelineSuccess(chats: MutableList<ChatStatus>, |
||||
fetchEnd: FetchEnd, pos: Int) { |
||||
|
||||
// We filled the hole (or reached the end) if the server returned less statuses than we |
||||
// we asked for. |
||||
val fullFetch = chats.size >= LOAD_AT_ONCE |
||||
|
||||
when (fetchEnd) { |
||||
FetchEnd.TOP -> { |
||||
updateChats(chats, fullFetch) |
||||
} |
||||
FetchEnd.MIDDLE -> { |
||||
replacePlaceholderWithChats(chats, fullFetch, pos) |
||||
} |
||||
FetchEnd.BOTTOM -> { |
||||
if (this.chats.isNotEmpty() && !this.chats.last().isRight()) { |
||||
this.chats.removeAt(this.chats.size - 1) |
||||
updateAdapter() |
||||
} |
||||
|
||||
if (chats.isNotEmpty() && !chats.last().isRight()) { |
||||
// Removing placeholder if it's the last one from the cache |
||||
chats.removeAt(chats.size - 1) |
||||
} |
||||
|
||||
val oldSize = this.chats.size |
||||
if (this.chats.size > 1) { |
||||
addItems(chats) |
||||
} else { |
||||
updateChats(chats, fullFetch) |
||||
} |
||||
|
||||
if (this.chats.size == oldSize) { |
||||
// This may be a brittle check but seems like it works |
||||
// Can we check it using headers somehow? Do all server support them? |
||||
didLoadEverythingBottom = true |
||||
} |
||||
} |
||||
} |
||||
if (isAdded) { |
||||
topProgressBar.hide() |
||||
updateBottomLoadingState(fetchEnd) |
||||
progressBar.visibility = View.GONE |
||||
swipeRefreshLayout.isRefreshing = false |
||||
swipeRefreshLayout.isEnabled = true |
||||
if (this.chats.size == 0) { |
||||
showNothing() |
||||
} else { |
||||
this.statusView.visibility = View.GONE |
||||
} |
||||
} |
||||
} |
||||
|
||||
private fun onFetchTimelineFailure(exception: Exception, fetchEnd: FetchEnd, position: Int) { |
||||
if (isAdded) { |
||||
swipeRefreshLayout.isRefreshing = false |
||||
topProgressBar.hide() |
||||
if (fetchEnd == FetchEnd.MIDDLE && !chats[position].isRight()) { |
||||
var placeholder = chats[position].asLeftOrNull() |
||||
val newViewData: ChatViewData |
||||
if (placeholder == null) { |
||||
val chat = chats[position - 1].asRight() |
||||
val newId = chat.id.dec() |
||||
placeholder = Placeholder(newId) |
||||
} |
||||
newViewData = ChatViewData.Placeholder(placeholder.id, false) |
||||
chats.setPairedItem(position, newViewData) |
||||
updateAdapter() |
||||
} else if (chats.isEmpty()) { |
||||
swipeRefreshLayout.isEnabled = false |
||||
statusView.visibility = View.VISIBLE |
||||
if (exception is IOException) { |
||||
statusView.setup(R.drawable.elephant_offline, R.string.error_network) { |
||||
progressBar.visibility = View.VISIBLE |
||||
onRefresh() |
||||
} |
||||
} else { |
||||
statusView.setup(R.drawable.elephant_error, R.string.error_generic) { |
||||
progressBar.visibility = View.VISIBLE |
||||
onRefresh() |
||||
} |
||||
} |
||||
} |
||||
Log.e(TAG, "Fetch Failure: " + exception.message) |
||||
updateBottomLoadingState(fetchEnd) |
||||
progressBar.visibility = View.GONE |
||||
} |
||||
} |
||||
|
||||
private fun updateBottomLoadingState(fetchEnd: FetchEnd) { |
||||
if (fetchEnd == FetchEnd.BOTTOM) { |
||||
bottomLoading = false |
||||
} |
||||
} |
||||
|
||||
override fun onLoadMore(position: Int) { |
||||
//check bounds before accessing list, |
||||
if (chats.size >= position && position > 0) { |
||||
val fromChat = chats[position - 1].asRightOrNull() |
||||
val toChat = chats[position + 1].asRightOrNull() |
||||
if (fromChat == null || toChat == null) { |
||||
Log.e(TAG, "Failed to load more at $position, wrong placeholder position") |
||||
return |
||||
} |
||||
|
||||
val maxMinusOne = if (chats.size > position + 1 && chats[position + 2].isRight()) chats[position + 1].asRight().id else null |
||||
sendFetchChatsRequest(fromChat.id, toChat.id, maxMinusOne, |
||||
FetchEnd.MIDDLE, position) |
||||
|
||||
val (id) = chats[position].asLeft() |
||||
val newViewData = ChatViewData.Placeholder(id, true) |
||||
chats.setPairedItem(position, newViewData) |
||||
updateAdapter() |
||||
} else { |
||||
Log.e(TAG, "error loading more") |
||||
} |
||||
} |
||||
|
||||
override fun onViewAccount(id: String?) { |
||||
id?.let(bottomSheetActivity::viewAccount) |
||||
} |
||||
|
||||
override fun onViewUrl(url: String?) { |
||||
url?.let { bottomSheetActivity.viewUrl(it, PostLookupFallbackBehavior.OPEN_IN_BROWSER) } |
||||
} |
||||
|
||||
// never called |
||||
override fun onViewTag(tag: String?) {} |
||||
|
||||
private fun updateAdapter() { |
||||
Log.d(TAG, "updateAdapter") |
||||
differ.submitList(chats.pairedCopy) |
||||
} |
||||
|
||||
private fun jumpToTop() { |
||||
if (isAdded) { |
||||
layoutManager.scrollToPosition(0) |
||||
recyclerView.stopScroll() |
||||
scrollListener.reset() |
||||
} |
||||
} |
||||
|
||||
override fun onReselect() { |
||||
jumpToTop() |
||||
} |
||||
|
||||
override fun onResume() { |
||||
super.onResume() |
||||
startUpdateTimestamp() |
||||
} |
||||
|
||||
override fun refreshContent() { |
||||
if (isAdded) onRefresh() else isNeedRefresh = true |
||||
} |
||||
|
||||
/** |
||||
* Start to update adapter every minute to refresh timestamp |
||||
* If setting absoluteTimeView is false |
||||
* Auto dispose observable on pause |
||||
*/ |
||||
private fun startUpdateTimestamp() { |
||||
val preferences = PreferenceManager.getDefaultSharedPreferences(activity) |
||||
val useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false) |
||||
if (!useAbsoluteTime) { |
||||
Observable.interval(1, TimeUnit.MINUTES) |
||||
.observeOn(AndroidSchedulers.mainThread()) |
||||
.autoDispose(this, Lifecycle.Event.ON_PAUSE) |
||||
.subscribe { updateAdapter() } |
||||
} |
||||
} |
||||
|
||||
private fun findChatPosition(id: String) : Int { |
||||
return chats.indexOfFirst { it.isRight() && it.asRight().id == id } |
||||
} |
||||
|
||||
private fun markAsRead(chat: Chat) { |
||||
val pos = findChatPosition(chat.id) |
||||
val chatViewData = ViewDataUtils.chatToViewData(chat) |
||||
|
||||
chats.setPairedItem(pos, chatViewData) |
||||
updateAdapter() |
||||
} |
||||
|
||||
override fun onMore(id: String, v: View) { |
||||
val popup = PopupMenu(requireContext(), v) |
||||
popup.inflate(R.menu.chat_more) |
||||
val pos = findChatPosition(id) |
||||
val chat = chats[pos].asRight() |
||||
// val menu = popup.menu |
||||
popup.setOnMenuItemClickListener { |
||||
when(it.itemId) { |
||||
R.id.chat_mark_as_read -> { |
||||
api.markChatAsRead(chat.id, chat.lastMessage?.id ?: null) |
||||
.observeOn(AndroidSchedulers.mainThread()) |
||||
.autoDispose(this, Lifecycle.Event.ON_DESTROY) |
||||
.subscribe({ chat -> markAsRead(chat) |
||||
}, { err -> Log.e(TAG, "Failed to mark chat as read", err) }) |
||||
|
||||
true |
||||
} |
||||
else -> { |
||||
false // ???? |
||||
} |
||||
} |
||||
} |
||||
popup.show() |
||||
} |
||||
} |
@ -0,0 +1,10 @@ |
||||
package com.keylesspalace.tusky.interfaces |
||||
|
||||
import android.view.View |
||||
import com.keylesspalace.tusky.entity.Chat |
||||
|
||||
interface ChatActionListener: LinkListener { |
||||
fun onLoadMore(position: Int) |
||||
|
||||
fun onMore(chatId: String, v: View) |
||||
} |
@ -0,0 +1,237 @@ |
||||
package com.keylesspalace.tusky.repository |
||||
|
||||
import android.text.Spanned |
||||
import android.text.SpannedString |
||||
import androidx.core.text.parseAsHtml |
||||
import androidx.core.text.toHtml |
||||
import com.google.gson.Gson |
||||
import com.google.gson.reflect.TypeToken |
||||
import com.keylesspalace.tusky.db.* |
||||
import com.keylesspalace.tusky.entity.* |
||||
import com.keylesspalace.tusky.network.MastodonApi |
||||
import com.keylesspalace.tusky.repository.TimelineRequestMode.DISK |
||||
import com.keylesspalace.tusky.repository.TimelineRequestMode.NETWORK |
||||
import com.keylesspalace.tusky.util.Either |
||||
import com.keylesspalace.tusky.util.dec |
||||
import com.keylesspalace.tusky.util.inc |
||||
import com.keylesspalace.tusky.util.trimTrailingWhitespace |
||||
import io.reactivex.Single |
||||
import io.reactivex.schedulers.Schedulers |
||||
import java.io.IOException |
||||
import java.util.* |
||||
import java.util.concurrent.TimeUnit |
||||
import kotlin.collections.ArrayList |
||||
|
||||
typealias ChatStatus = Either<Placeholder, Chat> |
||||
|
||||
interface ChatRepository { |
||||
fun getChats(maxId: String?, sinceId: String?, sincedIdMinusOne: String?, limit: Int, |
||||
requestMode: TimelineRequestMode): Single<out List<ChatStatus>> |
||||
} |
||||
|
||||
class ChatRepositoryImpl( |
||||
private val chatsDao: ChatsDao, |
||||
private val mastodonApi: MastodonApi, |
||||
private val accountManager: AccountManager, |
||||
private val gson: Gson |
||||
) : ChatRepository { |
||||
|
||||
override fun getChats(maxId: String?, sinceId: String?, sincedIdMinusOne: String?, |
||||
limit: Int, requestMode: TimelineRequestMode |
||||
): Single<out List<ChatStatus>> { |
||||
val acc = accountManager.activeAccount ?: throw IllegalStateException() |
||||
val accountId = acc.id |
||||
|
||||
return if (requestMode == DISK) { |
||||
this.getChatsFromDb(accountId, maxId, sinceId, limit) |
||||
} else { |
||||
getChatsFromNetwork(maxId, sinceId, sincedIdMinusOne, limit, accountId, requestMode) |
||||
} |
||||
} |
||||
|
||||
private fun getChatsFromNetwork(maxId: String?, sinceId: String?, |
||||
sinceIdMinusOne: String?, limit: Int, |
||||
accountId: Long, requestMode: TimelineRequestMode |
||||
): Single<out List<ChatStatus>> { |
||||
return mastodonApi.getChats(maxId, null, sinceIdMinusOne, 0, limit + 1) |
||||
.map { chats -> |
||||
this.saveChatsToDb(accountId, chats, maxId, sinceId) |
||||
} |
||||
.flatMap { chats -> |
||||
this.addFromDbIfNeeded(accountId, chats, maxId, sinceId, limit, requestMode) |
||||
} |
||||
.onErrorResumeNext { error -> |
||||
if (error is IOException && requestMode != NETWORK) { |
||||
this.getChatsFromDb(accountId, maxId, sinceId, limit) |
||||
} else { |
||||
Single.error(error) |
||||
} |
||||
} |
||||
} |
||||
|
||||
private fun addFromDbIfNeeded(accountId: Long, chats: List<ChatStatus>, |
||||
maxId: String?, sinceId: String?, limit: Int, |
||||
requestMode: TimelineRequestMode |
||||
): Single<List<ChatStatus>> { |
||||
return if (requestMode != NETWORK && chats.size < 2) { |
||||
val newMaxID = if (chats.isEmpty()) { |
||||
maxId |
||||
} else { |
||||
chats.last { it.isRight() }.asRight().id |
||||
} |
||||
this.getChatsFromDb(accountId, newMaxID, sinceId, limit) |
||||
.map { fromDb -> |
||||
// If it's just placeholders and less than limit (so we exhausted both |
||||
// db and server at this point) |
||||
if (fromDb.size < limit && fromDb.all { !it.isRight() }) { |
||||
chats |
||||
} else { |
||||
chats + fromDb |
||||
} |
||||
} |
||||
} else { |
||||
Single.just(chats) |
||||
} |
||||
} |
||||
|
||||
private fun getChatsFromDb(accountId: Long, maxId: String?, sinceId: String?, |
||||
limit: Int): Single<out List<ChatStatus>> { |
||||
return chatsDao.getChatsForAccount(accountId, maxId, sinceId, limit) |
||||
.subscribeOn(Schedulers.io()) |
||||
.map { chats -> |
||||
chats.map { it.toChat(gson) } |
||||
} |
||||
} |
||||
|
||||
private fun saveChatsToDb(accountId: Long, chats: List<Chat>, |
||||
maxId: String?, sinceId: String? |
||||
): List<ChatStatus> { |
||||
var placeholderToInsert: Placeholder? = null |
||||
|
||||
// Look for overlap |
||||
val resultChats = if (chats.isNotEmpty() && sinceId != null) { |
||||
val indexOfSince = chats.indexOfLast { it.id == sinceId } |
||||
if (indexOfSince == -1) { |
||||
// We didn't find the status which must be there. Add a placeholder |
||||
placeholderToInsert = Placeholder(sinceId.inc()) |
||||
chats.mapTo(mutableListOf(), Chat::lift) |
||||
.apply { |
||||
add(Either.Left(placeholderToInsert)) |
||||
} |
||||
} else { |
||||
// There was an overlap. Remove all overlapped statuses. No need for a placeholder. |
||||
chats.mapTo(mutableListOf(), Chat::lift) |
||||
.apply { |
||||
subList(indexOfSince, size).clear() |
||||
} |
||||
} |
||||
} else { |
||||
// Just a normal case. |
||||
chats.map(Chat::lift) |
||||
} |
||||
|
||||
Single.fromCallable { |
||||
|
||||
if(chats.isNotEmpty()) { |
||||
chatsDao.deleteRange(accountId, chats.last().id, chats.first().id) |
||||
} |
||||
|
||||
for (chat in chats) { |
||||
val pair = chat.toEntity(accountId, gson) |
||||
|
||||
chatsDao.insertInTransaction( |
||||
pair.first, |
||||
pair.second, |
||||
chat.account.toEntity(accountId, gson) |
||||
) |
||||
} |
||||
|
||||
placeholderToInsert?.let { |
||||
chatsDao.insertChatIfNotThere(it.toChatEntity(accountId)) |
||||
} |
||||
|
||||
// If we're loading in the bottom insert placeholder after every load |
||||
// (for requests on next launches) but not return it. |
||||
if (sinceId == null && chats.isNotEmpty()) { |
||||
chatsDao.insertChatIfNotThere( |
||||
Placeholder(chats.last().id.dec()).toChatEntity(accountId)) |
||||
} |
||||
|
||||
// There may be placeholders which we thought could be from our TL but they are not |
||||
if (chats.size > 2) { |
||||
chatsDao.removeAllPlaceholdersBetween(accountId, chats.first().id, |
||||
chats.last().id) |
||||
} else if (placeholderToInsert == null && maxId != null && sinceId != null) { |
||||
chatsDao.removeAllPlaceholdersBetween(accountId, maxId, sinceId) |
||||
} |
||||
} |
||||
.subscribeOn(Schedulers.io()) |
||||
.subscribe() |
||||
|
||||
return resultChats |
||||
} |
||||
} |
||||
|
||||
private val emojisListTypeToken = object : TypeToken<List<Emoji>>() {} |
||||
|
||||
fun Placeholder.toChatEntity(timelineUserId: Long): ChatEntity { |
||||
return ChatEntity( |
||||
localId = timelineUserId, |
||||
chatId = this.id, |
||||
accountId = "", |
||||
unread = 0L, |
||||
updatedAt = 0L, |
||||
lastMessageId = null |
||||
) |
||||
} |
||||
|
||||
fun ChatMessage.toEntity(timelineUserId: Long, gson: Gson) : ChatMessageEntity { |
||||
return ChatMessageEntity( |
||||
localId = timelineUserId, |
||||
messageId = this.id, |
||||
content = this.content.toHtml(), |
||||
chatId = this.chatId, |
||||
accountId = this.accountId, |
||||
createdAt = this.createdAt.time, |
||||
attachment = this.attachment?.let { gson.toJson(it, Attachment::class.java) }, |
||||
emojis = gson.toJson(this.emojis) |
||||
) |
||||
} |
||||
|
||||
fun Chat.toEntity(timelineUserId: Long, gson: Gson): Pair<ChatEntity, ChatMessageEntity?> { |
||||
return Pair(ChatEntity( |
||||
localId = timelineUserId, |
||||
chatId = this.id, |
||||
accountId = this.account.id, |
||||
unread = this.unread, |
||||
updatedAt = this.updatedAt.time, |
||||
lastMessageId = this.lastMessage?.let { it.id } |
||||
), this.lastMessage?.toEntity(timelineUserId, gson)) |
||||
} |
||||
|
||||
fun ChatMessageEntity.toChatMessage(gson: Gson) : ChatMessage { |
||||
return ChatMessage( |
||||
id = this.messageId, |
||||
content = this.content.parseAsHtml().trimTrailingWhitespace(), |
||||
chatId = this.chatId, |
||||
accountId = this.accountId, |
||||
createdAt = Date(this.createdAt), |
||||
attachment = this.attachment?.let { gson.fromJson(it, Attachment::class.java) }, |
||||
emojis = gson.fromJson(this.emojis, object : TypeToken<List<Emoji>>() {}.type ) |
||||
) |
||||
} |
||||
|
||||
fun ChatEntityWithAccount.toChat(gson: Gson) : ChatStatus { |
||||
if(account == null || chat.accountId.isEmpty() || chat.updatedAt == 0L) |
||||
return Either.Left(Placeholder(chat.chatId)) |
||||
|
||||
return Chat( |
||||
account = this.account?.toAccount(gson) ?: Account("", "", "", "", SpannedString(""), "", "", "" ), |
||||
id = this.chat.chatId, |
||||
unread = this.chat.unread, |
||||
updatedAt = Date(this.chat.updatedAt), |
||||
lastMessage = this.lastMessage?.toChatMessage(gson) |
||||
).lift() |
||||
} |
||||
|
||||
fun Chat.lift(): Either<Placeholder, Chat> = Either.Right(this) |
@ -0,0 +1,9 @@ |
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" |
||||
android:width="24dp" |
||||
android:height="24dp" |
||||
android:viewportWidth="24" |
||||
android:viewportHeight="24"> |
||||
<path |
||||
android:fillColor="#FF000000" |
||||
android:pathData="M21,6h-2v9L6,15v2c0,0.55 0.45,1 1,1h11l4,4L22,7c0,-0.55 -0.45,-1 -1,-1zM17,12L17,3c0,-0.55 -0.45,-1 -1,-1L3,2c-0.55,0 -1,0.45 -1,1v14l4,-4h10c0.55,0 1,-0.45 1,-1z"/> |
||||
</vector> |
@ -0,0 +1,7 @@ |
||||
<?xml version="1.0" encoding="utf-8"?> |
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval"> |
||||
<solid android:color="@color/red" /> |
||||
<size |
||||
android:height="?attr/status_text_medium" |
||||
android:width="?attr/status_text_medium" /> |
||||
</shape> |
@ -0,0 +1,7 @@ |
||||
<?xml version="1.0" encoding="utf-8"?> |
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android"> |
||||
<item |
||||
android:id="@+id/chat_mark_as_read" |
||||
android:title="@string/action_mark_as_read" |
||||
/> |
||||
</menu> |
Loading…
Reference in new issue