chats: finished chat listing

main
Alibek Omarov 4 years ago
parent 1f487faf93
commit 9f57699a3d
  1. 873
      app/schemas/com.keylesspalace.tusky.db.AppDatabase/25.json
  2. 2
      app/src/husky/res/values-ru/strings.xml
  3. 3
      app/src/husky/res/values/strings.xml
  4. 11
      app/src/main/java/com/keylesspalace/tusky/TabData.kt
  5. 6
      app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt
  6. 175
      app/src/main/java/com/keylesspalace/tusky/adapter/ChatsAdapter.kt
  7. 15
      app/src/main/java/com/keylesspalace/tusky/adapter/PlaceholderViewHolder.java
  8. 25
      app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java
  9. 21
      app/src/main/java/com/keylesspalace/tusky/db/ChatEntity.kt
  10. 21
      app/src/main/java/com/keylesspalace/tusky/db/ChatMessageEntity.kt
  11. 85
      app/src/main/java/com/keylesspalace/tusky/db/ChatsDao.kt
  12. 1
      app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt
  13. 2
      app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt
  14. 3
      app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt
  15. 12
      app/src/main/java/com/keylesspalace/tusky/di/RepositoryModule.kt
  16. 17
      app/src/main/java/com/keylesspalace/tusky/entity/Chat.kt
  17. 722
      app/src/main/java/com/keylesspalace/tusky/fragment/ChatsFragment.kt
  18. 10
      app/src/main/java/com/keylesspalace/tusky/interfaces/ChatActionListener.kt
  19. 3
      app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt
  20. 237
      app/src/main/java/com/keylesspalace/tusky/repository/ChatRepository.kt
  21. 32
      app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.java
  22. 66
      app/src/main/java/com/keylesspalace/tusky/viewdata/ChatViewData.kt
  23. 9
      app/src/main/res/drawable/ic_forum_24px.xml
  24. 7
      app/src/main/res/drawable/unread_shape.xml
  25. 22
      app/src/main/res/layout/item_chat.xml
  26. 7
      app/src/main/res/menu/chat_more.xml
  27. 1
      app/src/main/res/values/colors.xml

@ -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"?>
<resources>
<string name="chats">Чаты</string>
<string name="action_mark_as_read">Пометить как прочитанное</string>
<string name="action_reply_to">Ответ на</string>
<!--<string name="action_mute_conversation">Заглушить разговор</string>
<string name="action_unmute_conversation">Отменить глушение разговора</string>-->

@ -1,5 +1,8 @@
<resources>
<!-- 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_emoji_react">React</string>
<string name="action_emoji_unreact">Remove reaction</string>

@ -20,6 +20,7 @@ import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.fragment.app.Fragment
import com.keylesspalace.tusky.components.conversation.ConversationsFragment
import com.keylesspalace.tusky.fragment.ChatsFragment
import com.keylesspalace.tusky.fragment.NotificationsFragment
import com.keylesspalace.tusky.fragment.TimelineFragment
@ -32,6 +33,7 @@ const val FEDERATED = "Federated"
const val DIRECT = "Direct"
const val HASHTAG = "Hashtag"
const val LIST = "List"
const val CHATS = "Chats"
data class TabData(val id: String,
@StringRes val text: Int,
@ -89,6 +91,12 @@ fun createTabDataFromId(id: String, arguments: List<String> = emptyList()): TabD
arguments,
{ arguments.getOrNull(1).orEmpty() }
)
CHATS -> TabData(
CHATS,
R.string.chats,
R.drawable.ic_forum_24px,
{ ChatsFragment() }
)
else -> throw IllegalArgumentException("unknown tab type")
}
}
@ -98,6 +106,7 @@ fun defaultTabs(): List<TabData> {
createTabDataFromId(HOME),
createTabDataFromId(NOTIFICATIONS),
createTabDataFromId(LOCAL),
createTabDataFromId(FEDERATED)
createTabDataFromId(FEDERATED),
createTabDataFromId(CHATS)
)
}

@ -286,6 +286,10 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
if (!currentTabs.contains(directMessagesTab)) {
addableTabs.add(directMessagesTab)
}
val chatTab = createTabDataFromId(CHATS)
if (!currentTabs.contains(chatTab)) {
addableTabs.add(chatTab)
}
addableTabs.add(createTabDataFromId(HASHTAG))
addableTabs.add(createTabDataFromId(LIST))
@ -343,7 +347,7 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
companion object {
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 com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.interfaces.ChatActionListener;
import com.keylesspalace.tusky.interfaces.StatusActionListener;
public final class PlaceholderViewHolder extends RecyclerView.ViewHolder {
@ -34,16 +35,26 @@ public final class PlaceholderViewHolder extends RecyclerView.ViewHolder {
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);
progressBar.setVisibility(progress ? View.VISIBLE : View.GONE);
loadMoreButton.setEnabled(true);
}
public void setup(final StatusActionListener listener, boolean progress) {
setup(progress);
loadMoreButton.setOnClickListener(v -> {
loadMoreButton.setEnabled(false);
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,
TimelineAccountEntity.class, ConversationEntity.class}, version = 24)
TimelineAccountEntity.class, ConversationEntity.class, ChatEntity.class, ChatMessageEntity.class}, version = 25)
public abstract class AppDatabase extends RoomDatabase {
public abstract TootDao tootDao();
@ -37,6 +37,7 @@ public abstract class AppDatabase extends RoomDatabase {
public abstract InstanceDao instanceDao();
public abstract ConversationsDao conversationDao();
public abstract TimelineDao timelineDao();
public abstract ChatsDao chatsDao();
public static final Migration MIGRATION_2_3 = new Migration(2, 3) {
@Override
@ -349,4 +350,26 @@ public abstract class AppDatabase extends RoomDatabase {
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""")
abstract fun getStatusesForAccount(account: Long, maxId: String?, sinceId: String?, limit: Int): Single<List<TimelineStatusWithAccount>>
@Transaction
open fun insertInTransaction(status: TimelineStatusEntity, account: TimelineAccountEntity,
reblogAccount: TimelineAccountEntity?) {

@ -78,7 +78,7 @@ class AppModule {
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_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()
}
}

@ -49,6 +49,9 @@ abstract class FragmentBuildersModule {
@ContributesAndroidInjector
abstract fun timelineFragment(): TimelineFragment
@ContributesAndroidInjector
abstract fun chatsFragment(): ChatsFragment
@ContributesAndroidInjector
abstract fun notificationsFragment(): NotificationsFragment

@ -4,6 +4,8 @@ import com.google.gson.Gson
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
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.TimelineRepositoryImpl
import dagger.Module
@ -20,4 +22,14 @@ class RepositoryModule {
): TimelineRepository {
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,23 +15,24 @@
package com.keylesspalace.tusky.entity
import android.text.Spanned
import com.google.gson.annotations.SerializedName
import java.util.*
data class ChatMessage(
val id: String,
val content: String,
@SerializedName("chat_id") val chatId: String,
@SerializedName("account_id") val accountId: String,
@SerializedName("created_at") val createdAt: Date,
val attachment: Attachment?,
val emojis: List<Emoji>
val id: String,
val content: Spanned,
@SerializedName("chat_id") val chatId: String,
@SerializedName("account_id") val accountId: String,
@SerializedName("created_at") val createdAt: Date,
val attachment: Attachment?,
val emojis: List<Emoji>
)
data class Chat(
val account: Account,
val id: String,
val unread: Int,
val unread: Long,
@SerializedName("last_message") val lastMessage: ChatMessage?,
@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
): Single<ChatMessage>
@FormUrlEncoded
@POST("api/v1/pleroma/chats/{id}/read")
fun markChatAsRead(
@Path("id") chatId: String,
@Field("last_read_id") lastReadId: String
@Field("last_read_id") lastReadId: String? = null
): Single<Chat>
@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;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.keylesspalace.tusky.entity.Notification;
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.StatusViewData;
import com.keylesspalace.tusky.viewdata.ChatViewData;
import com.keylesspalace.tusky.viewdata.ChatMessageViewData;
/**
* Created by charlag on 12/07/2017.
@ -88,4 +93,31 @@ public final class ViewDataUtils {
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
import android.text.Spanned
import com.keylesspalace.tusky.entity.*
import java.util.*
abstract class ChatViewData {
abstract fun getViewDataId() : Int
abstract fun deepEquals(val o: ChatViewData) : Boolean
abstract fun getViewDataId() : Long
abstract fun deepEquals(o: ChatViewData) : Boolean
class Concrete(val account : Account,
val id: String,
val unread: Int,
val unread: Long,
val lastMessage: ChatMessageViewData.Concrete?,
val updatedAt: Date ) : ChatViewData() {
override fun getViewDataId(): Int {
return id.hashCode()
override fun getViewDataId(): Long {
return id.hashCode().toLong()
}
override fun deepEquals(o: ChatViewData): Boolean {
if (o !is Concrete) return false
return o.account == account && o.id == id &&
o.unread == unread &&
(lastMessage != null && o.lastMessage?.deepEquals(lastMessage) ?: false) &&
o.updatedAt == updatedAt
return Objects.equals(o.account, account)
&& Objects.equals(o.id, id)
&& o.unread == unread
&& (lastMessage == o.lastMessage || (lastMessage != null && o.lastMessage != null && o.lastMessage.deepEquals(lastMessage)))
&& Objects.equals(o.updatedAt, updatedAt)
}
override fun hashCode(): Int {
@ -30,12 +32,12 @@ abstract class ChatViewData {
}
}
class Placeholder(val id: Int, val isLoading: Boolean) : ChatViewData() {
override fun getViewDataId(): Int {
return id
class Placeholder(val id: String, val isLoading: Boolean) : ChatViewData() {
override fun getViewDataId(): Long {
return id.hashCode().toLong()
}
override fun deepEquals(val o: ChatViewData): Boolean {
override fun deepEquals(o: ChatViewData): Boolean {
if( o !is Placeholder ) return false
return o.isLoading == isLoading && o.id == id
}
@ -43,27 +45,31 @@ abstract class ChatViewData {
}
abstract class ChatMessageViewData {
abstract fun getViewDataId() : Int
abstract fun deepEquals(val o: ChatMessageViewData) : Boolean
abstract fun getViewDataId() : Long
abstract fun deepEquals(o: ChatMessageViewData) : Boolean
class Concrete(val id: String,
val content: String,
val chatId: String,
val accountId: String,
val createdAt: Date,
val attachment: Attachment?,
val emojis: List<Emoji>) : ChatMessageViewData()
val content: Spanned,
val chatId: String,
val accountId: String,
val createdAt: Date,
val attachment: Attachment?,
val emojis: List<Emoji>) : ChatMessageViewData()
{
override fun getViewDataId(): Int {
return id.hashCode()
override fun getViewDataId(): Long {
return id.hashCode().toLong()
}
override fun deepEquals(o: ChatMessageViewData): Boolean {
if( o !is Concrete ) return false
return o.id == id && o.content == content && o.chatId == chatId &&
o.accountId == accountId && o.createdAt == createdAt &&
o.attachment == attachment && o.emojis == emojis
return Objects.equals(o.id, id)
&& Objects.equals(o.content, content)
&& 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 {
@ -71,12 +77,12 @@ abstract class ChatMessageViewData {
}
}
class Placeholder(val id: Int, private val isLoading: Boolean) : ChatMessageViewData() {
override fun getViewDataId(): Int {
return id
class Placeholder(val id: String, private val isLoading: Boolean) : ChatMessageViewData() {
override fun getViewDataId(): Long {
return id.hashCode().toLong()
}
override fun deepEquals(val o: ChatMessageViewData): Boolean {
override fun deepEquals(o: ChatMessageViewData): Boolean {
if( o !is Placeholder) return false
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:paddingLeft="14dp"
android:paddingRight="14dp"
android:paddingBottom="8dp">
android:paddingBottom="8dp"
android:clickable="true"
android:longClickable="true">
<ImageView
android:id="@+id/status_avatar"
@ -98,9 +100,25 @@
android:textSize="?attr/status_text_medium"
android:maxLines="1"
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_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." />
<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>

@ -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_light">#25d069</color>
<color name="red">#f00</color>
<color name="white">#fff</color>
<color name="black">#000</color>

Loading…
Cancel
Save