chats: finished chat listing
This commit is contained in:
parent
8ddd0f06b4
commit
e169c89aea
|
@ -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')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,5 +1,7 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
|
<string name="chats">Чаты</string>
|
||||||
|
<string name="action_mark_as_read">Пометить как прочитанное</string>
|
||||||
<string name="action_reply_to">Ответ на</string>
|
<string name="action_reply_to">Ответ на</string>
|
||||||
<!--<string name="action_mute_conversation">Заглушить разговор</string>
|
<!--<string name="action_mute_conversation">Заглушить разговор</string>
|
||||||
<string name="action_unmute_conversation">Отменить глушение разговора</string>-->
|
<string name="action_unmute_conversation">Отменить глушение разговора</string>-->
|
||||||
|
|
|
@ -1,5 +1,8 @@
|
||||||
<resources>
|
<resources>
|
||||||
<!-- HUSKY SPECIFIC STRINGS -->
|
<!-- HUSKY SPECIFIC STRINGS -->
|
||||||
|
<string name="chats">Chats</string>
|
||||||
|
<string name="action_mark_as_read">Mark as read</string>
|
||||||
|
|
||||||
<string name="action_reply_to">Reply to</string>
|
<string name="action_reply_to">Reply to</string>
|
||||||
<string name="action_emoji_react">React</string>
|
<string name="action_emoji_react">React</string>
|
||||||
<string name="action_emoji_unreact">Remove reaction</string>
|
<string name="action_emoji_unreact">Remove reaction</string>
|
||||||
|
|
|
@ -20,6 +20,7 @@ import androidx.annotation.DrawableRes
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import com.keylesspalace.tusky.components.conversation.ConversationsFragment
|
import com.keylesspalace.tusky.components.conversation.ConversationsFragment
|
||||||
|
import com.keylesspalace.tusky.fragment.ChatsFragment
|
||||||
import com.keylesspalace.tusky.fragment.NotificationsFragment
|
import com.keylesspalace.tusky.fragment.NotificationsFragment
|
||||||
import com.keylesspalace.tusky.fragment.TimelineFragment
|
import com.keylesspalace.tusky.fragment.TimelineFragment
|
||||||
|
|
||||||
|
@ -32,6 +33,7 @@ const val FEDERATED = "Federated"
|
||||||
const val DIRECT = "Direct"
|
const val DIRECT = "Direct"
|
||||||
const val HASHTAG = "Hashtag"
|
const val HASHTAG = "Hashtag"
|
||||||
const val LIST = "List"
|
const val LIST = "List"
|
||||||
|
const val CHATS = "Chats"
|
||||||
|
|
||||||
data class TabData(val id: String,
|
data class TabData(val id: String,
|
||||||
@StringRes val text: Int,
|
@StringRes val text: Int,
|
||||||
|
@ -89,6 +91,12 @@ fun createTabDataFromId(id: String, arguments: List<String> = emptyList()): TabD
|
||||||
arguments,
|
arguments,
|
||||||
{ arguments.getOrNull(1).orEmpty() }
|
{ arguments.getOrNull(1).orEmpty() }
|
||||||
)
|
)
|
||||||
|
CHATS -> TabData(
|
||||||
|
CHATS,
|
||||||
|
R.string.chats,
|
||||||
|
R.drawable.ic_forum_24px,
|
||||||
|
{ ChatsFragment() }
|
||||||
|
)
|
||||||
else -> throw IllegalArgumentException("unknown tab type")
|
else -> throw IllegalArgumentException("unknown tab type")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -98,6 +106,7 @@ fun defaultTabs(): List<TabData> {
|
||||||
createTabDataFromId(HOME),
|
createTabDataFromId(HOME),
|
||||||
createTabDataFromId(NOTIFICATIONS),
|
createTabDataFromId(NOTIFICATIONS),
|
||||||
createTabDataFromId(LOCAL),
|
createTabDataFromId(LOCAL),
|
||||||
createTabDataFromId(FEDERATED)
|
createTabDataFromId(FEDERATED),
|
||||||
|
createTabDataFromId(CHATS)
|
||||||
)
|
)
|
||||||
}
|
}
|
|
@ -286,6 +286,10 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
|
||||||
if (!currentTabs.contains(directMessagesTab)) {
|
if (!currentTabs.contains(directMessagesTab)) {
|
||||||
addableTabs.add(directMessagesTab)
|
addableTabs.add(directMessagesTab)
|
||||||
}
|
}
|
||||||
|
val chatTab = createTabDataFromId(CHATS)
|
||||||
|
if (!currentTabs.contains(chatTab)) {
|
||||||
|
addableTabs.add(chatTab)
|
||||||
|
}
|
||||||
|
|
||||||
addableTabs.add(createTabDataFromId(HASHTAG))
|
addableTabs.add(createTabDataFromId(HASHTAG))
|
||||||
addableTabs.add(createTabDataFromId(LIST))
|
addableTabs.add(createTabDataFromId(LIST))
|
||||||
|
@ -343,7 +347,7 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val MIN_TAB_COUNT = 2
|
private const val MIN_TAB_COUNT = 2
|
||||||
private const val MAX_TAB_COUNT = 5
|
private const val MAX_TAB_COUNT = 9
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -21,6 +21,7 @@ import android.widget.Button;
|
||||||
import android.widget.ProgressBar;
|
import android.widget.ProgressBar;
|
||||||
|
|
||||||
import com.keylesspalace.tusky.R;
|
import com.keylesspalace.tusky.R;
|
||||||
|
import com.keylesspalace.tusky.interfaces.ChatActionListener;
|
||||||
import com.keylesspalace.tusky.interfaces.StatusActionListener;
|
import com.keylesspalace.tusky.interfaces.StatusActionListener;
|
||||||
|
|
||||||
public final class PlaceholderViewHolder extends RecyclerView.ViewHolder {
|
public final class PlaceholderViewHolder extends RecyclerView.ViewHolder {
|
||||||
|
@ -34,16 +35,26 @@ public final class PlaceholderViewHolder extends RecyclerView.ViewHolder {
|
||||||
progressBar = itemView.findViewById(R.id.progressBar);
|
progressBar = itemView.findViewById(R.id.progressBar);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setup(final StatusActionListener listener, boolean progress) {
|
private void setup(boolean progress) {
|
||||||
loadMoreButton.setVisibility(progress ? View.GONE : View.VISIBLE);
|
loadMoreButton.setVisibility(progress ? View.GONE : View.VISIBLE);
|
||||||
progressBar.setVisibility(progress ? View.VISIBLE : View.GONE);
|
progressBar.setVisibility(progress ? View.VISIBLE : View.GONE);
|
||||||
|
|
||||||
loadMoreButton.setEnabled(true);
|
loadMoreButton.setEnabled(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setup(final StatusActionListener listener, boolean progress) {
|
||||||
|
setup(progress);
|
||||||
loadMoreButton.setOnClickListener(v -> {
|
loadMoreButton.setOnClickListener(v -> {
|
||||||
loadMoreButton.setEnabled(false);
|
loadMoreButton.setEnabled(false);
|
||||||
listener.onLoadMore(getAdapterPosition());
|
listener.onLoadMore(getAdapterPosition());
|
||||||
});
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void setup(final ChatActionListener listener, boolean progress) {
|
||||||
|
setup(progress);
|
||||||
|
loadMoreButton.setOnClickListener( v -> {
|
||||||
|
loadMoreButton.setEnabled(false);
|
||||||
|
listener.onLoadMore(getAdapterPosition());
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -29,7 +29,7 @@ import androidx.annotation.NonNull;
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@Database(entities = {TootEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class,
|
@Database(entities = {TootEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class,
|
||||||
TimelineAccountEntity.class, ConversationEntity.class}, version = 24)
|
TimelineAccountEntity.class, ConversationEntity.class, ChatEntity.class, ChatMessageEntity.class}, version = 25)
|
||||||
public abstract class AppDatabase extends RoomDatabase {
|
public abstract class AppDatabase extends RoomDatabase {
|
||||||
|
|
||||||
public abstract TootDao tootDao();
|
public abstract TootDao tootDao();
|
||||||
|
@ -37,6 +37,7 @@ public abstract class AppDatabase extends RoomDatabase {
|
||||||
public abstract InstanceDao instanceDao();
|
public abstract InstanceDao instanceDao();
|
||||||
public abstract ConversationsDao conversationDao();
|
public abstract ConversationsDao conversationDao();
|
||||||
public abstract TimelineDao timelineDao();
|
public abstract TimelineDao timelineDao();
|
||||||
|
public abstract ChatsDao chatsDao();
|
||||||
|
|
||||||
public static final Migration MIGRATION_2_3 = new Migration(2, 3) {
|
public static final Migration MIGRATION_2_3 = new Migration(2, 3) {
|
||||||
@Override
|
@Override
|
||||||
|
@ -349,4 +350,26 @@ public abstract class AppDatabase extends RoomDatabase {
|
||||||
database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `notificationsFollowRequested` INTEGER NOT NULL DEFAULT 0");
|
database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `notificationsFollowRequested` INTEGER NOT NULL DEFAULT 0");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
public static final Migration MIGRATION_24_25 = new Migration(24, 25) {
|
||||||
|
@Override
|
||||||
|
public void migrate(@NonNull SupportSQLiteDatabase database) {
|
||||||
|
database.execSQL("CREATE TABLE `ChatEntity` (`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`))");
|
||||||
|
database.execSQL("CREATE TABLE `ChatMessageEntity` (`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`))");
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
|
@ -49,7 +49,6 @@ ORDER BY LENGTH(s.serverId) DESC, s.serverId DESC
|
||||||
LIMIT :limit""")
|
LIMIT :limit""")
|
||||||
abstract fun getStatusesForAccount(account: Long, maxId: String?, sinceId: String?, limit: Int): Single<List<TimelineStatusWithAccount>>
|
abstract fun getStatusesForAccount(account: Long, maxId: String?, sinceId: String?, limit: Int): Single<List<TimelineStatusWithAccount>>
|
||||||
|
|
||||||
|
|
||||||
@Transaction
|
@Transaction
|
||||||
open fun insertInTransaction(status: TimelineStatusEntity, account: TimelineAccountEntity,
|
open fun insertInTransaction(status: TimelineStatusEntity, account: TimelineAccountEntity,
|
||||||
reblogAccount: TimelineAccountEntity?) {
|
reblogAccount: TimelineAccountEntity?) {
|
||||||
|
|
|
@ -78,7 +78,7 @@ class AppModule {
|
||||||
AppDatabase.MIGRATION_13_14, AppDatabase.MIGRATION_14_15, AppDatabase.MIGRATION_15_16,
|
AppDatabase.MIGRATION_13_14, AppDatabase.MIGRATION_14_15, AppDatabase.MIGRATION_15_16,
|
||||||
AppDatabase.MIGRATION_16_17, AppDatabase.MIGRATION_17_18, AppDatabase.MIGRATION_18_19,
|
AppDatabase.MIGRATION_16_17, AppDatabase.MIGRATION_17_18, AppDatabase.MIGRATION_18_19,
|
||||||
AppDatabase.MIGRATION_19_20, AppDatabase.MIGRATION_20_21, AppDatabase.MIGRATION_21_22,
|
AppDatabase.MIGRATION_19_20, AppDatabase.MIGRATION_20_21, AppDatabase.MIGRATION_21_22,
|
||||||
AppDatabase.MIGRATION_22_23, AppDatabase.MIGRATION_23_24)
|
AppDatabase.MIGRATION_22_23, AppDatabase.MIGRATION_23_24, AppDatabase.MIGRATION_24_25)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -49,6 +49,9 @@ abstract class FragmentBuildersModule {
|
||||||
@ContributesAndroidInjector
|
@ContributesAndroidInjector
|
||||||
abstract fun timelineFragment(): TimelineFragment
|
abstract fun timelineFragment(): TimelineFragment
|
||||||
|
|
||||||
|
@ContributesAndroidInjector
|
||||||
|
abstract fun chatsFragment(): ChatsFragment
|
||||||
|
|
||||||
@ContributesAndroidInjector
|
@ContributesAndroidInjector
|
||||||
abstract fun notificationsFragment(): NotificationsFragment
|
abstract fun notificationsFragment(): NotificationsFragment
|
||||||
|
|
||||||
|
|
|
@ -4,6 +4,8 @@ import com.google.gson.Gson
|
||||||
import com.keylesspalace.tusky.db.AccountManager
|
import com.keylesspalace.tusky.db.AccountManager
|
||||||
import com.keylesspalace.tusky.db.AppDatabase
|
import com.keylesspalace.tusky.db.AppDatabase
|
||||||
import com.keylesspalace.tusky.network.MastodonApi
|
import com.keylesspalace.tusky.network.MastodonApi
|
||||||
|
import com.keylesspalace.tusky.repository.ChatRepository
|
||||||
|
import com.keylesspalace.tusky.repository.ChatRepositoryImpl
|
||||||
import com.keylesspalace.tusky.repository.TimelineRepository
|
import com.keylesspalace.tusky.repository.TimelineRepository
|
||||||
import com.keylesspalace.tusky.repository.TimelineRepositoryImpl
|
import com.keylesspalace.tusky.repository.TimelineRepositoryImpl
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
|
@ -20,4 +22,14 @@ class RepositoryModule {
|
||||||
): TimelineRepository {
|
): TimelineRepository {
|
||||||
return TimelineRepositoryImpl(db.timelineDao(), mastodonApi, accountManager, gson)
|
return TimelineRepositoryImpl(db.timelineDao(), mastodonApi, accountManager, gson)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun providesChatRepository(
|
||||||
|
db: AppDatabase,
|
||||||
|
mastodonApi: MastodonApi,
|
||||||
|
accountManager: AccountManager,
|
||||||
|
gson: Gson
|
||||||
|
): ChatRepository {
|
||||||
|
return ChatRepositoryImpl(db.chatsDao(), mastodonApi, accountManager, gson)
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -15,12 +15,13 @@
|
||||||
|
|
||||||
package com.keylesspalace.tusky.entity
|
package com.keylesspalace.tusky.entity
|
||||||
|
|
||||||
|
import android.text.Spanned
|
||||||
import com.google.gson.annotations.SerializedName
|
import com.google.gson.annotations.SerializedName
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
data class ChatMessage(
|
data class ChatMessage(
|
||||||
val id: String,
|
val id: String,
|
||||||
val content: String,
|
val content: Spanned,
|
||||||
@SerializedName("chat_id") val chatId: String,
|
@SerializedName("chat_id") val chatId: String,
|
||||||
@SerializedName("account_id") val accountId: String,
|
@SerializedName("account_id") val accountId: String,
|
||||||
@SerializedName("created_at") val createdAt: Date,
|
@SerializedName("created_at") val createdAt: Date,
|
||||||
|
@ -31,7 +32,7 @@ data class ChatMessage(
|
||||||
data class Chat(
|
data class Chat(
|
||||||
val account: Account,
|
val account: Account,
|
||||||
val id: String,
|
val id: String,
|
||||||
val unread: Int,
|
val unread: Long,
|
||||||
@SerializedName("last_message") val lastMessage: ChatMessage?,
|
@SerializedName("last_message") val lastMessage: ChatMessage?,
|
||||||
@SerializedName("updated_at") val updatedAt: Date
|
@SerializedName("updated_at") val updatedAt: Date
|
||||||
)
|
)
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
|
@ -660,10 +660,11 @@ interface MastodonApi {
|
||||||
@Body chatMessage: NewChatMessage
|
@Body chatMessage: NewChatMessage
|
||||||
): Single<ChatMessage>
|
): Single<ChatMessage>
|
||||||
|
|
||||||
|
@FormUrlEncoded
|
||||||
@POST("api/v1/pleroma/chats/{id}/read")
|
@POST("api/v1/pleroma/chats/{id}/read")
|
||||||
fun markChatAsRead(
|
fun markChatAsRead(
|
||||||
@Path("id") chatId: String,
|
@Path("id") chatId: String,
|
||||||
@Field("last_read_id") lastReadId: String
|
@Field("last_read_id") lastReadId: String? = null
|
||||||
): Single<Chat>
|
): Single<Chat>
|
||||||
|
|
||||||
@POST("api/v1/pleroma/chats/by-account-id/{id}")
|
@POST("api/v1/pleroma/chats/by-account-id/{id}")
|
||||||
|
|
|
@ -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)
|
|
@ -15,12 +15,17 @@
|
||||||
|
|
||||||
package com.keylesspalace.tusky.util;
|
package com.keylesspalace.tusky.util;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
import com.keylesspalace.tusky.entity.Notification;
|
import com.keylesspalace.tusky.entity.Notification;
|
||||||
import com.keylesspalace.tusky.entity.Status;
|
import com.keylesspalace.tusky.entity.Status;
|
||||||
|
import com.keylesspalace.tusky.entity.Chat;
|
||||||
|
import com.keylesspalace.tusky.entity.ChatMessage;
|
||||||
import com.keylesspalace.tusky.viewdata.NotificationViewData;
|
import com.keylesspalace.tusky.viewdata.NotificationViewData;
|
||||||
import com.keylesspalace.tusky.viewdata.StatusViewData;
|
import com.keylesspalace.tusky.viewdata.StatusViewData;
|
||||||
|
import com.keylesspalace.tusky.viewdata.ChatViewData;
|
||||||
|
import com.keylesspalace.tusky.viewdata.ChatMessageViewData;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Created by charlag on 12/07/2017.
|
* Created by charlag on 12/07/2017.
|
||||||
|
@ -88,4 +93,31 @@ public final class ViewDataUtils {
|
||||||
notification.getEmoji()
|
notification.getEmoji()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static ChatMessageViewData.Concrete chatMessageToViewData(@Nullable ChatMessage msg) {
|
||||||
|
if(msg == null) return null;
|
||||||
|
|
||||||
|
return new ChatMessageViewData.Concrete(
|
||||||
|
msg.getId(),
|
||||||
|
msg.getContent(),
|
||||||
|
msg.getChatId(),
|
||||||
|
msg.getAccountId(),
|
||||||
|
msg.getCreatedAt(),
|
||||||
|
msg.getAttachment(),
|
||||||
|
msg.getEmojis()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
public static ChatViewData.Concrete chatToViewData(Chat chat) {
|
||||||
|
return new ChatViewData.Concrete(
|
||||||
|
chat.getAccount(),
|
||||||
|
chat.getId(),
|
||||||
|
chat.getUnread(),
|
||||||
|
chatMessageToViewData(
|
||||||
|
chat.getLastMessage()
|
||||||
|
),
|
||||||
|
chat.getUpdatedAt()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,28 +1,30 @@
|
||||||
package com.keylesspalace.tusky.viewdata
|
package com.keylesspalace.tusky.viewdata
|
||||||
|
|
||||||
|
import android.text.Spanned
|
||||||
import com.keylesspalace.tusky.entity.*
|
import com.keylesspalace.tusky.entity.*
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
|
|
||||||
abstract class ChatViewData {
|
abstract class ChatViewData {
|
||||||
abstract fun getViewDataId() : Int
|
abstract fun getViewDataId() : Long
|
||||||
abstract fun deepEquals(val o: ChatViewData) : Boolean
|
abstract fun deepEquals(o: ChatViewData) : Boolean
|
||||||
|
|
||||||
class Concrete(val account : Account,
|
class Concrete(val account : Account,
|
||||||
val id: String,
|
val id: String,
|
||||||
val unread: Int,
|
val unread: Long,
|
||||||
val lastMessage: ChatMessageViewData.Concrete?,
|
val lastMessage: ChatMessageViewData.Concrete?,
|
||||||
val updatedAt: Date ) : ChatViewData() {
|
val updatedAt: Date ) : ChatViewData() {
|
||||||
override fun getViewDataId(): Int {
|
override fun getViewDataId(): Long {
|
||||||
return id.hashCode()
|
return id.hashCode().toLong()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun deepEquals(o: ChatViewData): Boolean {
|
override fun deepEquals(o: ChatViewData): Boolean {
|
||||||
if (o !is Concrete) return false
|
if (o !is Concrete) return false
|
||||||
return o.account == account && o.id == id &&
|
return Objects.equals(o.account, account)
|
||||||
o.unread == unread &&
|
&& Objects.equals(o.id, id)
|
||||||
(lastMessage != null && o.lastMessage?.deepEquals(lastMessage) ?: false) &&
|
&& o.unread == unread
|
||||||
o.updatedAt == updatedAt
|
&& (lastMessage == o.lastMessage || (lastMessage != null && o.lastMessage != null && o.lastMessage.deepEquals(lastMessage)))
|
||||||
|
&& Objects.equals(o.updatedAt, updatedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun hashCode(): Int {
|
override fun hashCode(): Int {
|
||||||
|
@ -30,12 +32,12 @@ abstract class ChatViewData {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class Placeholder(val id: Int, val isLoading: Boolean) : ChatViewData() {
|
class Placeholder(val id: String, val isLoading: Boolean) : ChatViewData() {
|
||||||
override fun getViewDataId(): Int {
|
override fun getViewDataId(): Long {
|
||||||
return id
|
return id.hashCode().toLong()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun deepEquals(val o: ChatViewData): Boolean {
|
override fun deepEquals(o: ChatViewData): Boolean {
|
||||||
if( o !is Placeholder ) return false
|
if( o !is Placeholder ) return false
|
||||||
return o.isLoading == isLoading && o.id == id
|
return o.isLoading == isLoading && o.id == id
|
||||||
}
|
}
|
||||||
|
@ -43,27 +45,31 @@ abstract class ChatViewData {
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract class ChatMessageViewData {
|
abstract class ChatMessageViewData {
|
||||||
abstract fun getViewDataId() : Int
|
abstract fun getViewDataId() : Long
|
||||||
abstract fun deepEquals(val o: ChatMessageViewData) : Boolean
|
abstract fun deepEquals(o: ChatMessageViewData) : Boolean
|
||||||
|
|
||||||
class Concrete(val id: String,
|
class Concrete(val id: String,
|
||||||
val content: String,
|
val content: Spanned,
|
||||||
val chatId: String,
|
val chatId: String,
|
||||||
val accountId: String,
|
val accountId: String,
|
||||||
val createdAt: Date,
|
val createdAt: Date,
|
||||||
val attachment: Attachment?,
|
val attachment: Attachment?,
|
||||||
val emojis: List<Emoji>) : ChatMessageViewData()
|
val emojis: List<Emoji>) : ChatMessageViewData()
|
||||||
{
|
{
|
||||||
override fun getViewDataId(): Int {
|
override fun getViewDataId(): Long {
|
||||||
return id.hashCode()
|
return id.hashCode().toLong()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun deepEquals(o: ChatMessageViewData): Boolean {
|
override fun deepEquals(o: ChatMessageViewData): Boolean {
|
||||||
if( o !is Concrete ) return false
|
if( o !is Concrete ) return false
|
||||||
|
|
||||||
return o.id == id && o.content == content && o.chatId == chatId &&
|
return Objects.equals(o.id, id)
|
||||||
o.accountId == accountId && o.createdAt == createdAt &&
|
&& Objects.equals(o.content, content)
|
||||||
o.attachment == attachment && o.emojis == emojis
|
&& Objects.equals(o.chatId, chatId)
|
||||||
|
&& Objects.equals(o.accountId, accountId)
|
||||||
|
&& Objects.equals(o.createdAt, createdAt)
|
||||||
|
&& Objects.equals(o.attachment, attachment)
|
||||||
|
&& Objects.equals(o.emojis, emojis)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun hashCode() : Int {
|
override fun hashCode() : Int {
|
||||||
|
@ -71,12 +77,12 @@ abstract class ChatMessageViewData {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class Placeholder(val id: Int, private val isLoading: Boolean) : ChatMessageViewData() {
|
class Placeholder(val id: String, private val isLoading: Boolean) : ChatMessageViewData() {
|
||||||
override fun getViewDataId(): Int {
|
override fun getViewDataId(): Long {
|
||||||
return id
|
return id.hashCode().toLong()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun deepEquals(val o: ChatMessageViewData): Boolean {
|
override fun deepEquals(o: ChatMessageViewData): Boolean {
|
||||||
if( o !is Placeholder) return false
|
if( o !is Placeholder) return false
|
||||||
return o.isLoading == isLoading && o.id == id
|
return o.isLoading == isLoading && o.id == id
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
|
@ -12,7 +12,9 @@
|
||||||
android:focusable="true"
|
android:focusable="true"
|
||||||
android:paddingLeft="14dp"
|
android:paddingLeft="14dp"
|
||||||
android:paddingRight="14dp"
|
android:paddingRight="14dp"
|
||||||
android:paddingBottom="8dp">
|
android:paddingBottom="8dp"
|
||||||
|
android:clickable="true"
|
||||||
|
android:longClickable="true">
|
||||||
|
|
||||||
<ImageView
|
<ImageView
|
||||||
android:id="@+id/status_avatar"
|
android:id="@+id/status_avatar"
|
||||||
|
@ -98,9 +100,25 @@
|
||||||
android:textSize="?attr/status_text_medium"
|
android:textSize="?attr/status_text_medium"
|
||||||
android:maxLines="1"
|
android:maxLines="1"
|
||||||
android:ellipsize="end"
|
android:ellipsize="end"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toStartOf="@id/chat_unread"
|
||||||
app:layout_constraintStart_toStartOf="@id/status_display_name"
|
app:layout_constraintStart_toStartOf="@id/status_display_name"
|
||||||
app:layout_constraintTop_toBottomOf="@id/status_display_name"
|
app:layout_constraintTop_toBottomOf="@id/status_display_name"
|
||||||
tools:text="Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." />
|
tools:text="Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/chat_unread"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="@drawable/unread_shape"
|
||||||
|
android:textColor="@color/white"
|
||||||
|
android:textSize="?attr/status_text_small"
|
||||||
|
android:ellipsize="none"
|
||||||
|
android:gravity="center"
|
||||||
|
app:layout_constraintDimensionRatio="1:1"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/status_display_name"
|
||||||
|
app:layout_constraintBaseline_toBaselineOf="@id/status_content"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
tools:text="1"
|
||||||
|
android:singleLine="true" />
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
@ -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>
|
|
@ -7,6 +7,7 @@
|
||||||
<color name="tusky_green">#19a341</color>
|
<color name="tusky_green">#19a341</color>
|
||||||
<color name="tusky_green_light">#25d069</color>
|
<color name="tusky_green_light">#25d069</color>
|
||||||
|
|
||||||
|
<color name="red">#f00</color>
|
||||||
<color name="white">#fff</color>
|
<color name="white">#fff</color>
|
||||||
<color name="black">#000</color>
|
<color name="black">#000</color>
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue