migrate to paging 3 (#2182)
* migrate conversations and search to paging 3 * delete SearchRepository * remove unneeded executor from search * fix bugs in conversations * update license headers * fix conversations refreshing * fix search refresh indicators * show fullscreen loading while conversations are empty * search bugfixes * error handling * error handling * remove mastodon bug workaround * update ConversationsFragment * fix conversations more menu and deleting conversations * delete unused class * catch exceptions in ConversationsViewModel * fix bug where items are not diffed correctly / cleanup code * fix search progressbar display conditions
This commit is contained in:
parent
31da851f28
commit
6d4f5ad027
|
@ -97,6 +97,9 @@ ext.materialdrawerVersion = '8.4.1'
|
|||
dependencies {
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
|
||||
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.0'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-rx3:1.5.0'
|
||||
|
||||
implementation "androidx.core:core-ktx:1.5.0"
|
||||
implementation "androidx.appcompat:appcompat:1.3.0"
|
||||
implementation "androidx.fragment:fragment-ktx:1.3.4"
|
||||
|
@ -114,13 +117,11 @@ dependencies {
|
|||
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion"
|
||||
implementation "androidx.lifecycle:lifecycle-reactivestreams-ktx:$lifecycleVersion"
|
||||
implementation "androidx.constraintlayout:constraintlayout:2.0.4"
|
||||
implementation "androidx.paging:paging-runtime-ktx:2.1.2"
|
||||
implementation "androidx.paging:paging-runtime-ktx:3.0.0"
|
||||
implementation "androidx.viewpager2:viewpager2:1.0.0"
|
||||
implementation "androidx.work:work-runtime:2.5.0"
|
||||
implementation "androidx.room:room-runtime:$roomVersion"
|
||||
implementation "androidx.room:room-ktx:$roomVersion"
|
||||
implementation "androidx.room:room-rxjava3:$roomVersion"
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.0'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-rx3:1.5.0'
|
||||
kapt "androidx.room:room-compiler:$roomVersion"
|
||||
|
||||
implementation "com.google.android.material:material:1.3.0"
|
||||
|
|
|
@ -0,0 +1,753 @@
|
|||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 27,
|
||||
"identityHash": "be914d4eb3f406b6970fef53a925afa1",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "DraftEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountId` INTEGER NOT NULL, `inReplyToId` TEXT, `content` TEXT, `contentWarning` TEXT, `sensitive` INTEGER NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `poll` TEXT, `failedToSend` INTEGER NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "inReplyToId",
|
||||
"columnName": "inReplyToId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "content",
|
||||
"columnName": "content",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "contentWarning",
|
||||
"columnName": "contentWarning",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "sensitive",
|
||||
"columnName": "sensitive",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "visibility",
|
||||
"columnName": "visibility",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachments",
|
||||
"columnName": "attachments",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "poll",
|
||||
"columnName": "poll",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "failedToSend",
|
||||
"columnName": "failedToSend",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "AccountEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsSubscriptions` INTEGER NOT NULL, `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)",
|
||||
"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": "notificationsSubscriptions",
|
||||
"columnName": "notificationsSubscriptions",
|
||||
"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
|
||||
}
|
||||
],
|
||||
"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, `muted` INTEGER, 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
|
||||
},
|
||||
{
|
||||
"fieldPath": "muted",
|
||||
"columnName": "muted",
|
||||
"affinity": "INTEGER",
|
||||
"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_muted` 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.muted",
|
||||
"columnName": "s_muted",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.poll",
|
||||
"columnName": "s_poll",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id",
|
||||
"accountId"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'be914d4eb3f406b6970fef53a925afa1')"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -15,30 +15,28 @@
|
|||
|
||||
package com.keylesspalace.tusky.adapter
|
||||
|
||||
import androidx.paging.LoadState
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import android.view.ViewGroup
|
||||
import com.keylesspalace.tusky.databinding.ItemNetworkStateBinding
|
||||
import com.keylesspalace.tusky.util.NetworkState
|
||||
import com.keylesspalace.tusky.util.Status
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
|
||||
class NetworkStateViewHolder(private val binding: ItemNetworkStateBinding,
|
||||
private val retryCallback: () -> Unit)
|
||||
: RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
fun setUpWithNetworkState(state: NetworkState?, fullScreen: Boolean) {
|
||||
binding.progressBar.visible(state?.status == Status.RUNNING)
|
||||
binding.retryButton.visible(state?.status == Status.FAILED)
|
||||
binding.errorMsg.visible(state?.msg != null)
|
||||
binding.errorMsg.text = state?.msg
|
||||
fun setUpWithNetworkState(state: LoadState) {
|
||||
binding.progressBar.visible(state == LoadState.Loading)
|
||||
binding.retryButton.visible(state is LoadState.Error)
|
||||
val msg = if (state is LoadState.Error) {
|
||||
state.error.message
|
||||
} else {
|
||||
null
|
||||
}
|
||||
binding.errorMsg.visible(msg != null)
|
||||
binding.errorMsg.text = msg
|
||||
binding.retryButton.setOnClickListener {
|
||||
retryCallback()
|
||||
}
|
||||
if(fullScreen) {
|
||||
binding.root.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT
|
||||
} else {
|
||||
binding.root.layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -17,114 +17,39 @@ package com.keylesspalace.tusky.components.conversation
|
|||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.paging.AsyncPagedListDiffer
|
||||
import androidx.paging.PagedList
|
||||
import androidx.recyclerview.widget.AsyncDifferConfig
|
||||
import androidx.paging.PagingDataAdapter
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.ListUpdateCallback
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.adapter.NetworkStateViewHolder
|
||||
import com.keylesspalace.tusky.databinding.ItemNetworkStateBinding
|
||||
import com.keylesspalace.tusky.interfaces.StatusActionListener
|
||||
import com.keylesspalace.tusky.util.NetworkState
|
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
||||
|
||||
class ConversationAdapter(
|
||||
private val statusDisplayOptions: StatusDisplayOptions,
|
||||
private val listener: StatusActionListener,
|
||||
private val topLoadedCallback: () -> Unit,
|
||||
private val retryCallback: () -> Unit
|
||||
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
private val statusDisplayOptions: StatusDisplayOptions,
|
||||
private val listener: StatusActionListener
|
||||
) : PagingDataAdapter<ConversationEntity, ConversationViewHolder>(CONVERSATION_COMPARATOR) {
|
||||
|
||||
private var networkState: NetworkState? = null
|
||||
|
||||
private val differ: AsyncPagedListDiffer<ConversationEntity> = AsyncPagedListDiffer(object : ListUpdateCallback {
|
||||
override fun onInserted(position: Int, count: Int) {
|
||||
notifyItemRangeInserted(position, count)
|
||||
if (position == 0) {
|
||||
topLoadedCallback()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRemoved(position: Int, count: Int) {
|
||||
notifyItemRangeRemoved(position, count)
|
||||
}
|
||||
|
||||
override fun onMoved(fromPosition: Int, toPosition: Int) {
|
||||
notifyItemMoved(fromPosition, toPosition)
|
||||
}
|
||||
|
||||
override fun onChanged(position: Int, count: Int, payload: Any?) {
|
||||
notifyItemRangeChanged(position, count, payload)
|
||||
}
|
||||
}, AsyncDifferConfig.Builder(CONVERSATION_COMPARATOR).build())
|
||||
|
||||
fun submitList(list: PagedList<ConversationEntity>) {
|
||||
differ.submitList(list)
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ConversationViewHolder {
|
||||
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_conversation, parent, false)
|
||||
return ConversationViewHolder(view, statusDisplayOptions, listener)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
return when (viewType) {
|
||||
R.layout.item_network_state -> {
|
||||
val binding = ItemNetworkStateBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
NetworkStateViewHolder(binding, retryCallback)
|
||||
}
|
||||
R.layout.item_conversation -> {
|
||||
val view = LayoutInflater.from(parent.context).inflate(viewType, parent, false)
|
||||
ConversationViewHolder(view, statusDisplayOptions, listener)
|
||||
}
|
||||
else -> throw IllegalArgumentException("unknown view type $viewType")
|
||||
}
|
||||
override fun onBindViewHolder(holder: ConversationViewHolder, position: Int) {
|
||||
holder.setupWithConversation(getItem(position))
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
when (getItemViewType(position)) {
|
||||
R.layout.item_network_state -> (holder as NetworkStateViewHolder).setUpWithNetworkState(networkState, differ.itemCount == 0)
|
||||
R.layout.item_conversation -> (holder as ConversationViewHolder).setupWithConversation(differ.getItem(position))
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasExtraRow() = networkState != null && networkState != NetworkState.LOADED
|
||||
|
||||
override fun getItemViewType(position: Int): Int {
|
||||
return if (hasExtraRow() && position == itemCount - 1) {
|
||||
R.layout.item_network_state
|
||||
} else {
|
||||
R.layout.item_conversation
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
return differ.itemCount + if (hasExtraRow()) 1 else 0
|
||||
}
|
||||
|
||||
fun setNetworkState(newNetworkState: NetworkState?) {
|
||||
val previousState = this.networkState
|
||||
val hadExtraRow = hasExtraRow()
|
||||
this.networkState = newNetworkState
|
||||
val hasExtraRow = hasExtraRow()
|
||||
if (hadExtraRow != hasExtraRow) {
|
||||
if (hadExtraRow) {
|
||||
notifyItemRemoved(differ.itemCount)
|
||||
} else {
|
||||
notifyItemInserted(differ.itemCount)
|
||||
}
|
||||
} else if (hasExtraRow && previousState != newNetworkState) {
|
||||
notifyItemChanged(itemCount - 1)
|
||||
}
|
||||
fun item(position: Int): ConversationEntity? {
|
||||
return getItem(position)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
val CONVERSATION_COMPARATOR = object : DiffUtil.ItemCallback<ConversationEntity>() {
|
||||
override fun areContentsTheSame(oldItem: ConversationEntity, newItem: ConversationEntity): Boolean =
|
||||
oldItem == newItem
|
||||
override fun areItemsTheSame(oldItem: ConversationEntity, newItem: ConversationEntity): Boolean {
|
||||
return oldItem.id == newItem.id
|
||||
}
|
||||
|
||||
override fun areItemsTheSame(oldItem: ConversationEntity, newItem: ConversationEntity): Boolean =
|
||||
oldItem.id == newItem.id
|
||||
override fun areContentsTheSame(oldItem: ConversationEntity, newItem: ConversationEntity): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
/* Copyright 2019 Conny Duck
|
||||
/* Copyright 2021 Tusky Contributors
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
|
@ -21,65 +21,70 @@ import androidx.room.Embedded
|
|||
import androidx.room.Entity
|
||||
import androidx.room.TypeConverters
|
||||
import com.keylesspalace.tusky.db.Converters
|
||||
import com.keylesspalace.tusky.entity.*
|
||||
import com.keylesspalace.tusky.entity.Account
|
||||
import com.keylesspalace.tusky.entity.Attachment
|
||||
import com.keylesspalace.tusky.entity.Conversation
|
||||
import com.keylesspalace.tusky.entity.Emoji
|
||||
import com.keylesspalace.tusky.entity.Poll
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.util.shouldTrimStatus
|
||||
import java.util.*
|
||||
import java.util.Date
|
||||
|
||||
@Entity(primaryKeys = ["id","accountId"])
|
||||
@TypeConverters(Converters::class)
|
||||
data class ConversationEntity(
|
||||
val accountId: Long,
|
||||
val id: String,
|
||||
val accounts: List<ConversationAccountEntity>,
|
||||
val unread: Boolean,
|
||||
@Embedded(prefix = "s_") val lastStatus: ConversationStatusEntity
|
||||
val accountId: Long,
|
||||
val id: String,
|
||||
val accounts: List<ConversationAccountEntity>,
|
||||
val unread: Boolean,
|
||||
@Embedded(prefix = "s_") val lastStatus: ConversationStatusEntity
|
||||
)
|
||||
|
||||
data class ConversationAccountEntity(
|
||||
val id: String,
|
||||
val username: String,
|
||||
val displayName: String,
|
||||
val avatar: String,
|
||||
val emojis: List<Emoji>
|
||||
val id: String,
|
||||
val username: String,
|
||||
val displayName: String,
|
||||
val avatar: String,
|
||||
val emojis: List<Emoji>
|
||||
) {
|
||||
fun toAccount(): Account {
|
||||
return Account(
|
||||
id = id,
|
||||
username = username,
|
||||
displayName = displayName,
|
||||
avatar = avatar,
|
||||
emojis = emojis,
|
||||
url = "",
|
||||
localUsername = "",
|
||||
note = SpannedString(""),
|
||||
header = ""
|
||||
id = id,
|
||||
username = username,
|
||||
displayName = displayName,
|
||||
avatar = avatar,
|
||||
emojis = emojis,
|
||||
url = "",
|
||||
localUsername = "",
|
||||
note = SpannedString(""),
|
||||
header = ""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@TypeConverters(Converters::class)
|
||||
data class ConversationStatusEntity(
|
||||
val id: String,
|
||||
val url: String?,
|
||||
val inReplyToId: String?,
|
||||
val inReplyToAccountId: String?,
|
||||
val account: ConversationAccountEntity,
|
||||
val content: Spanned,
|
||||
val createdAt: Date,
|
||||
val emojis: List<Emoji>,
|
||||
val favouritesCount: Int,
|
||||
val favourited: Boolean,
|
||||
val bookmarked: Boolean,
|
||||
val sensitive: Boolean,
|
||||
val spoilerText: String,
|
||||
val attachments: ArrayList<Attachment>,
|
||||
val mentions: List<Status.Mention>,
|
||||
val showingHiddenContent: Boolean,
|
||||
val expanded: Boolean,
|
||||
val collapsible: Boolean,
|
||||
val collapsed: Boolean,
|
||||
val poll: Poll?
|
||||
|
||||
val id: String,
|
||||
val url: String?,
|
||||
val inReplyToId: String?,
|
||||
val inReplyToAccountId: String?,
|
||||
val account: ConversationAccountEntity,
|
||||
val content: Spanned,
|
||||
val createdAt: Date,
|
||||
val emojis: List<Emoji>,
|
||||
val favouritesCount: Int,
|
||||
val favourited: Boolean,
|
||||
val bookmarked: Boolean,
|
||||
val sensitive: Boolean,
|
||||
val spoilerText: String,
|
||||
val attachments: ArrayList<Attachment>,
|
||||
val mentions: List<Status.Mention>,
|
||||
val showingHiddenContent: Boolean,
|
||||
val expanded: Boolean,
|
||||
val collapsible: Boolean,
|
||||
val collapsed: Boolean,
|
||||
val muted: Boolean,
|
||||
val poll: Poll?
|
||||
) {
|
||||
/** its necessary to override this because Spanned.equals does not work as expected */
|
||||
override fun equals(other: Any?): Boolean {
|
||||
|
@ -106,6 +111,7 @@ data class ConversationStatusEntity(
|
|||
if (expanded != other.expanded) return false
|
||||
if (collapsible != other.collapsible) return false
|
||||
if (collapsed != other.collapsed) return false
|
||||
if (muted != other.muted) return false
|
||||
if (poll != other.poll) return false
|
||||
|
||||
return true
|
||||
|
@ -130,66 +136,79 @@ data class ConversationStatusEntity(
|
|||
result = 31 * result + expanded.hashCode()
|
||||
result = 31 * result + collapsible.hashCode()
|
||||
result = 31 * result + collapsed.hashCode()
|
||||
result = 31 * result + muted.hashCode()
|
||||
result = 31 * result + poll.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
fun toStatus(): Status {
|
||||
return Status(
|
||||
id = id,
|
||||
url = url,
|
||||
account = account.toAccount(),
|
||||
inReplyToId = inReplyToId,
|
||||
inReplyToAccountId = inReplyToAccountId,
|
||||
content = content,
|
||||
reblog = null,
|
||||
createdAt = createdAt,
|
||||
emojis = emojis,
|
||||
reblogsCount = 0,
|
||||
favouritesCount = favouritesCount,
|
||||
reblogged = false,
|
||||
favourited = favourited,
|
||||
bookmarked = bookmarked,
|
||||
sensitive= sensitive,
|
||||
spoilerText = spoilerText,
|
||||
visibility = Status.Visibility.DIRECT,
|
||||
attachments = attachments,
|
||||
mentions = mentions,
|
||||
application = null,
|
||||
pinned = false,
|
||||
muted = false,
|
||||
poll = poll,
|
||||
card = null)
|
||||
id = id,
|
||||
url = url,
|
||||
account = account.toAccount(),
|
||||
inReplyToId = inReplyToId,
|
||||
inReplyToAccountId = inReplyToAccountId,
|
||||
content = content,
|
||||
reblog = null,
|
||||
createdAt = createdAt,
|
||||
emojis = emojis,
|
||||
reblogsCount = 0,
|
||||
favouritesCount = favouritesCount,
|
||||
reblogged = false,
|
||||
favourited = favourited,
|
||||
bookmarked = bookmarked,
|
||||
sensitive= sensitive,
|
||||
spoilerText = spoilerText,
|
||||
visibility = Status.Visibility.DIRECT,
|
||||
attachments = attachments,
|
||||
mentions = mentions,
|
||||
application = null,
|
||||
pinned = false,
|
||||
muted = muted,
|
||||
poll = poll,
|
||||
card = null)
|
||||
}
|
||||
}
|
||||
|
||||
fun Account.toEntity() =
|
||||
ConversationAccountEntity(
|
||||
id,
|
||||
username,
|
||||
name,
|
||||
avatar,
|
||||
emojis ?: emptyList()
|
||||
)
|
||||
ConversationAccountEntity(
|
||||
id = id,
|
||||
username = username,
|
||||
displayName = name,
|
||||
avatar = avatar,
|
||||
emojis = emojis ?: emptyList()
|
||||
)
|
||||
|
||||
fun Status.toEntity() =
|
||||
ConversationStatusEntity(
|
||||
id, url, inReplyToId, inReplyToAccountId, account.toEntity(), content,
|
||||
createdAt, emojis, favouritesCount, favourited, bookmarked, sensitive,
|
||||
spoilerText, attachments, mentions,
|
||||
false,
|
||||
false,
|
||||
shouldTrimStatus(content),
|
||||
true,
|
||||
poll
|
||||
)
|
||||
|
||||
ConversationStatusEntity(
|
||||
id = id,
|
||||
url = url,
|
||||
inReplyToId = inReplyToId,
|
||||
inReplyToAccountId = inReplyToAccountId,
|
||||
account = account.toEntity(),
|
||||
content = content,
|
||||
createdAt = createdAt,
|
||||
emojis = emojis,
|
||||
favouritesCount = favouritesCount,
|
||||
favourited = favourited,
|
||||
bookmarked = bookmarked,
|
||||
sensitive = sensitive,
|
||||
spoilerText = spoilerText,
|
||||
attachments = attachments,
|
||||
mentions = mentions,
|
||||
showingHiddenContent = false,
|
||||
expanded = false,
|
||||
collapsible = shouldTrimStatus(content),
|
||||
collapsed = true,
|
||||
muted = muted ?: false,
|
||||
poll = poll
|
||||
)
|
||||
|
||||
fun Conversation.toEntity(accountId: Long) =
|
||||
ConversationEntity(
|
||||
accountId,
|
||||
id,
|
||||
accounts.map { it.toEntity() },
|
||||
unread,
|
||||
lastStatus!!.toEntity()
|
||||
)
|
||||
ConversationEntity(
|
||||
accountId = accountId,
|
||||
id = id,
|
||||
accounts = accounts.map { it.toEntity() },
|
||||
unread = unread,
|
||||
lastStatus = lastStatus!!.toEntity()
|
||||
)
|
||||
|
|
|
@ -0,0 +1,41 @@
|
|||
/* Copyright 2021 Tusky Contributors
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.components.conversation
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.paging.LoadState
|
||||
import androidx.paging.LoadStateAdapter
|
||||
import com.keylesspalace.tusky.adapter.NetworkStateViewHolder
|
||||
import com.keylesspalace.tusky.databinding.ItemNetworkStateBinding
|
||||
|
||||
class ConversationLoadStateAdapter(
|
||||
private val retryCallback: () -> Unit
|
||||
) : LoadStateAdapter<NetworkStateViewHolder>() {
|
||||
|
||||
override fun onBindViewHolder(holder: NetworkStateViewHolder, loadState: LoadState) {
|
||||
holder.setUpWithNetworkState(loadState)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
loadState: LoadState
|
||||
): NetworkStateViewHolder {
|
||||
val binding = ItemNetworkStateBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
return NetworkStateViewHolder(binding, retryCallback)
|
||||
}
|
||||
|
||||
}
|
|
@ -1,98 +0,0 @@
|
|||
/*
|
||||
* Copyright (C) 2017 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.keylesspalace.tusky.components.conversation
|
||||
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.paging.PagedList
|
||||
import com.keylesspalace.tusky.entity.Conversation
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.PagingRequestHelper
|
||||
import com.keylesspalace.tusky.util.createStatusLiveData
|
||||
import retrofit2.Call
|
||||
import retrofit2.Callback
|
||||
import retrofit2.Response
|
||||
import java.util.concurrent.Executor
|
||||
|
||||
/**
|
||||
* This boundary callback gets notified when user reaches to the edges of the list such that the
|
||||
* database cannot provide any more data.
|
||||
* <p>
|
||||
* The boundary callback might be called multiple times for the same direction so it does its own
|
||||
* rate limiting using the PagingRequestHelper class.
|
||||
*/
|
||||
class ConversationsBoundaryCallback(
|
||||
private val accountId: Long,
|
||||
private val mastodonApi: MastodonApi,
|
||||
private val handleResponse: (Long, List<Conversation>?) -> Unit,
|
||||
private val ioExecutor: Executor,
|
||||
private val networkPageSize: Int)
|
||||
: PagedList.BoundaryCallback<ConversationEntity>() {
|
||||
|
||||
val helper = PagingRequestHelper(ioExecutor)
|
||||
val networkState = helper.createStatusLiveData()
|
||||
|
||||
/**
|
||||
* Database returned 0 items. We should query the backend for more items.
|
||||
*/
|
||||
@MainThread
|
||||
override fun onZeroItemsLoaded() {
|
||||
helper.runIfNotRunning(PagingRequestHelper.RequestType.INITIAL) {
|
||||
mastodonApi.getConversations(null, networkPageSize)
|
||||
.enqueue(createWebserviceCallback(it))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* User reached to the end of the list.
|
||||
*/
|
||||
@MainThread
|
||||
override fun onItemAtEndLoaded(itemAtEnd: ConversationEntity) {
|
||||
helper.runIfNotRunning(PagingRequestHelper.RequestType.AFTER) {
|
||||
mastodonApi.getConversations(itemAtEnd.lastStatus.id, networkPageSize)
|
||||
.enqueue(createWebserviceCallback(it))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* every time it gets new items, boundary callback simply inserts them into the database and
|
||||
* paging library takes care of refreshing the list if necessary.
|
||||
*/
|
||||
private fun insertItemsIntoDb(
|
||||
response: Response<List<Conversation>>,
|
||||
it: PagingRequestHelper.Request.Callback) {
|
||||
ioExecutor.execute {
|
||||
handleResponse(accountId, response.body())
|
||||
it.recordSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onItemAtFrontLoaded(itemAtFront: ConversationEntity) {
|
||||
// ignored, since we only ever append to what's in the DB
|
||||
}
|
||||
|
||||
private fun createWebserviceCallback(it: PagingRequestHelper.Request.Callback): Callback<List<Conversation>> {
|
||||
return object : Callback<List<Conversation>> {
|
||||
override fun onFailure(call: Call<List<Conversation>>, t: Throwable) {
|
||||
it.recordFailure(t)
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call<List<Conversation>>, response: Response<List<Conversation>>) {
|
||||
insertItemsIntoDb(response, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
/* Copyright 2019 Conny Duck
|
||||
/* Copyright 2021 Tusky Contributors
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
|
@ -20,7 +20,12 @@ import android.os.Bundle
|
|||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.paging.ExperimentalPagingApi
|
||||
import androidx.paging.LoadState
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
|
@ -35,8 +40,11 @@ import com.keylesspalace.tusky.fragment.SFragment
|
|||
import com.keylesspalace.tusky.interfaces.ReselectableFragment
|
||||
import com.keylesspalace.tusky.interfaces.StatusActionListener
|
||||
import com.keylesspalace.tusky.settings.PrefKeys
|
||||
import com.keylesspalace.tusky.util.*
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.IOException
|
||||
import com.keylesspalace.tusky.util.CardViewMode
|
||||
import com.keylesspalace.tusky.util.NetworkState
|
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
|
@ -53,34 +61,39 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
|
|||
private val binding by viewBinding(FragmentTimelineBinding::bind)
|
||||
|
||||
private lateinit var adapter: ConversationAdapter
|
||||
private lateinit var loadStateAdapter: ConversationLoadStateAdapter
|
||||
|
||||
private var layoutManager: LinearLayoutManager? = null
|
||||
|
||||
private var initialRefreshDone: Boolean = false
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
return inflater.inflate(R.layout.fragment_timeline, container, false)
|
||||
}
|
||||
|
||||
@ExperimentalPagingApi
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val preferences = PreferenceManager.getDefaultSharedPreferences(view.context)
|
||||
|
||||
val statusDisplayOptions = StatusDisplayOptions(
|
||||
animateAvatars = preferences.getBoolean("animateGifAvatars", false),
|
||||
mediaPreviewEnabled = accountManager.activeAccount?.mediaPreviewEnabled ?: true,
|
||||
useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false),
|
||||
showBotOverlay = preferences.getBoolean("showBotOverlay", true),
|
||||
useBlurhash = preferences.getBoolean("useBlurhash", true),
|
||||
cardViewMode = CardViewMode.NONE,
|
||||
confirmReblogs = preferences.getBoolean("confirmReblogs", true),
|
||||
hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false),
|
||||
animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
|
||||
animateAvatars = preferences.getBoolean("animateGifAvatars", false),
|
||||
mediaPreviewEnabled = accountManager.activeAccount?.mediaPreviewEnabled ?: true,
|
||||
useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false),
|
||||
showBotOverlay = preferences.getBoolean("showBotOverlay", true),
|
||||
useBlurhash = preferences.getBoolean("useBlurhash", true),
|
||||
cardViewMode = CardViewMode.NONE,
|
||||
confirmReblogs = preferences.getBoolean("confirmReblogs", true),
|
||||
hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false),
|
||||
animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
|
||||
)
|
||||
|
||||
adapter = ConversationAdapter(statusDisplayOptions, this, ::onTopLoaded, viewModel::retry)
|
||||
adapter = ConversationAdapter(statusDisplayOptions, this)
|
||||
loadStateAdapter = ConversationLoadStateAdapter(adapter::retry)
|
||||
|
||||
binding.recyclerView.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL))
|
||||
layoutManager = LinearLayoutManager(view.context)
|
||||
binding.recyclerView.layoutManager = layoutManager
|
||||
binding.recyclerView.adapter = adapter
|
||||
binding.recyclerView.adapter = adapter.withLoadStateFooter(loadStateAdapter)
|
||||
(binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
|
||||
|
||||
binding.progressBar.hide()
|
||||
|
@ -88,59 +101,101 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
|
|||
|
||||
initSwipeToRefresh()
|
||||
|
||||
viewModel.conversations.observe(viewLifecycleOwner) {
|
||||
adapter.submitList(it)
|
||||
}
|
||||
viewModel.networkState.observe(viewLifecycleOwner) {
|
||||
adapter.setNetworkState(it)
|
||||
lifecycleScope.launch {
|
||||
viewModel.conversationFlow.collectLatest { pagingData ->
|
||||
adapter.submitData(pagingData)
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.load()
|
||||
adapter.addLoadStateListener { loadStates ->
|
||||
|
||||
loadStates.refresh.let { refreshState ->
|
||||
if (refreshState is LoadState.Error) {
|
||||
binding.statusView.show()
|
||||
if (refreshState.error is IOException) {
|
||||
binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) {
|
||||
adapter.refresh()
|
||||
}
|
||||
} else {
|
||||
binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic) {
|
||||
adapter.refresh()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
binding.statusView.hide()
|
||||
}
|
||||
|
||||
binding.progressBar.visible(refreshState == LoadState.Loading && adapter.itemCount == 0)
|
||||
|
||||
if (refreshState is LoadState.NotLoading && !initialRefreshDone) {
|
||||
// jump to top after the initial refresh finished
|
||||
binding.recyclerView.scrollToPosition(0)
|
||||
initialRefreshDone = true
|
||||
}
|
||||
|
||||
if (refreshState != LoadState.Loading) {
|
||||
binding.swipeRefreshLayout.isRefreshing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun initSwipeToRefresh() {
|
||||
viewModel.refreshState.observe(viewLifecycleOwner) {
|
||||
binding.swipeRefreshLayout.isRefreshing = it == NetworkState.LOADING
|
||||
}
|
||||
binding.swipeRefreshLayout.setOnRefreshListener {
|
||||
viewModel.refresh()
|
||||
adapter.refresh()
|
||||
}
|
||||
binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
|
||||
}
|
||||
|
||||
private fun onTopLoaded() {
|
||||
binding.recyclerView.scrollToPosition(0)
|
||||
}
|
||||
|
||||
override fun onReblog(reblog: Boolean, position: Int) {
|
||||
// its impossible to reblog private messages
|
||||
}
|
||||
|
||||
override fun onFavourite(favourite: Boolean, position: Int) {
|
||||
viewModel.favourite(favourite, position)
|
||||
adapter.item(position)?.let { conversation ->
|
||||
viewModel.favourite(favourite, conversation)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBookmark(favourite: Boolean, position: Int) {
|
||||
viewModel.bookmark(favourite, position)
|
||||
adapter.item(position)?.let { conversation ->
|
||||
viewModel.bookmark(favourite, conversation)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMore(view: View, position: Int) {
|
||||
viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let {
|
||||
more(it.toStatus(), view, position)
|
||||
adapter.item(position)?.let { conversation ->
|
||||
|
||||
val popup = PopupMenu(requireContext(), view)
|
||||
popup.inflate(R.menu.conversation_more)
|
||||
|
||||
if (conversation.lastStatus.muted) {
|
||||
popup.menu.removeItem(R.id.status_mute_conversation)
|
||||
} else {
|
||||
popup.menu.removeItem(R.id.status_unmute_conversation)
|
||||
}
|
||||
|
||||
popup.setOnMenuItemClickListener { item ->
|
||||
when (item.itemId) {
|
||||
R.id.status_mute_conversation -> viewModel.muteConversation(conversation)
|
||||
R.id.status_unmute_conversation -> viewModel.muteConversation(conversation)
|
||||
R.id.conversation_delete -> deleteConversation(conversation)
|
||||
}
|
||||
true
|
||||
}
|
||||
popup.show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
|
||||
viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let {
|
||||
viewMedia(attachmentIndex, AttachmentViewData.list(it.toStatus()), view)
|
||||
adapter.item(position)?.let { conversation ->
|
||||
viewMedia(attachmentIndex, AttachmentViewData.list(conversation.lastStatus.toStatus()), view)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewThread(position: Int) {
|
||||
viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let {
|
||||
val status = it.toStatus()
|
||||
viewThread(status.actionableId, status.actionableStatus.url)
|
||||
adapter.item(position)?.let { conversation ->
|
||||
viewThread(conversation.lastStatus.id, conversation.lastStatus.url)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -149,11 +204,15 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
|
|||
}
|
||||
|
||||
override fun onExpandedChange(expanded: Boolean, position: Int) {
|
||||
viewModel.expandHiddenStatus(expanded, position)
|
||||
adapter.item(position)?.let { conversation ->
|
||||
viewModel.expandHiddenStatus(expanded, conversation)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onContentHiddenChange(isShowing: Boolean, position: Int) {
|
||||
viewModel.showContent(isShowing, position)
|
||||
adapter.item(position)?.let { conversation ->
|
||||
viewModel.showContent(isShowing, conversation)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLoadMore(position: Int) {
|
||||
|
@ -161,7 +220,9 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
|
|||
}
|
||||
|
||||
override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) {
|
||||
viewModel.collapseLongStatus(isCollapsed, position)
|
||||
adapter.item(position)?.let { conversation ->
|
||||
viewModel.collapseLongStatus(isCollapsed, conversation)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewAccount(id: String) {
|
||||
|
@ -176,15 +237,25 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
|
|||
}
|
||||
|
||||
override fun removeItem(position: Int) {
|
||||
viewModel.remove(position)
|
||||
// not needed
|
||||
}
|
||||
|
||||
override fun onReply(position: Int) {
|
||||
viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let {
|
||||
reply(it.toStatus())
|
||||
adapter.item(position)?.let { conversation ->
|
||||
reply(conversation.lastStatus.toStatus())
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteConversation(conversation: ConversationEntity) {
|
||||
AlertDialog.Builder(requireContext())
|
||||
.setMessage(R.string.dialog_delete_conversation_warning)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
viewModel.remove(conversation)
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun jumpToTop() {
|
||||
if (isAdded) {
|
||||
layoutManager?.scrollToPosition(0)
|
||||
|
@ -197,7 +268,9 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
|
|||
}
|
||||
|
||||
override fun onVoteInPoll(position: Int, choices: MutableList<Int>) {
|
||||
viewModel.voteInPoll(position, choices)
|
||||
adapter.item(position)?.let { conversation ->
|
||||
viewModel.voteInPoll(choices, conversation)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
|
|
@ -0,0 +1,51 @@
|
|||
package com.keylesspalace.tusky.components.conversation
|
||||
|
||||
import androidx.paging.ExperimentalPagingApi
|
||||
import androidx.paging.LoadType
|
||||
import androidx.paging.PagingState
|
||||
import androidx.paging.RemoteMediator
|
||||
import com.keylesspalace.tusky.db.AppDatabase
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
|
||||
@ExperimentalPagingApi
|
||||
class ConversationsRemoteMediator(
|
||||
private val accountId: Long,
|
||||
private val api: MastodonApi,
|
||||
private val db: AppDatabase
|
||||
) : RemoteMediator<Int, ConversationEntity>() {
|
||||
|
||||
override suspend fun load(
|
||||
loadType: LoadType,
|
||||
state: PagingState<Int, ConversationEntity>
|
||||
): MediatorResult {
|
||||
|
||||
try {
|
||||
val conversationsResult = when (loadType) {
|
||||
LoadType.REFRESH -> {
|
||||
api.getConversations(limit = state.config.initialLoadSize)
|
||||
}
|
||||
LoadType.PREPEND -> {
|
||||
return MediatorResult.Success(endOfPaginationReached = true)
|
||||
}
|
||||
LoadType.APPEND -> {
|
||||
val maxId = state.pages.findLast { it.data.isNotEmpty() }?.data?.lastOrNull()?.lastStatus?.id
|
||||
api.getConversations(maxId = maxId, limit = state.config.pageSize)
|
||||
}
|
||||
}
|
||||
|
||||
if (loadType == LoadType.REFRESH) {
|
||||
db.conversationDao().deleteForAccount(accountId)
|
||||
}
|
||||
db.conversationDao().insert(
|
||||
conversationsResult
|
||||
.filterNot { it.lastStatus == null }
|
||||
.map { it.toEntity(accountId) }
|
||||
)
|
||||
return MediatorResult.Success(endOfPaginationReached = conversationsResult.isEmpty())
|
||||
} catch (e: Exception) {
|
||||
return MediatorResult.Error(e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun initialize() = InitializeAction.LAUNCH_INITIAL_REFRESH
|
||||
}
|
|
@ -1,99 +1,32 @@
|
|||
/* Copyright 2021 Tusky Contributors
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.components.conversation
|
||||
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.Transformations
|
||||
import androidx.paging.Config
|
||||
import androidx.paging.toLiveData
|
||||
import com.keylesspalace.tusky.db.AppDatabase
|
||||
import com.keylesspalace.tusky.entity.Conversation
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.Listing
|
||||
import com.keylesspalace.tusky.util.NetworkState
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import retrofit2.Call
|
||||
import retrofit2.Callback
|
||||
import retrofit2.Response
|
||||
import java.util.concurrent.Executors
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class ConversationsRepository @Inject constructor(val mastodonApi: MastodonApi, val db: AppDatabase) {
|
||||
|
||||
private val ioExecutor = Executors.newSingleThreadExecutor()
|
||||
|
||||
companion object {
|
||||
private const val DEFAULT_PAGE_SIZE = 20
|
||||
}
|
||||
|
||||
@MainThread
|
||||
fun refresh(accountId: Long, showLoadingIndicator: Boolean): LiveData<NetworkState> {
|
||||
val networkState = MutableLiveData<NetworkState>()
|
||||
if(showLoadingIndicator) {
|
||||
networkState.value = NetworkState.LOADING
|
||||
}
|
||||
|
||||
mastodonApi.getConversations(limit = DEFAULT_PAGE_SIZE).enqueue(
|
||||
object : Callback<List<Conversation>> {
|
||||
override fun onFailure(call: Call<List<Conversation>>, t: Throwable) {
|
||||
// retrofit calls this on main thread so safe to call set value
|
||||
networkState.value = NetworkState.error(t.message)
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call<List<Conversation>>, response: Response<List<Conversation>>) {
|
||||
ioExecutor.execute {
|
||||
db.runInTransaction {
|
||||
db.conversationDao().deleteForAccount(accountId)
|
||||
insertResultIntoDb(accountId, response.body())
|
||||
}
|
||||
// since we are in bg thread now, post the result.
|
||||
networkState.postValue(NetworkState.LOADED)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
return networkState
|
||||
}
|
||||
|
||||
@MainThread
|
||||
fun conversations(accountId: Long): Listing<ConversationEntity> {
|
||||
// create a boundary callback which will observe when the user reaches to the edges of
|
||||
// the list and update the database with extra data.
|
||||
val boundaryCallback = ConversationsBoundaryCallback(
|
||||
accountId = accountId,
|
||||
mastodonApi = mastodonApi,
|
||||
handleResponse = this::insertResultIntoDb,
|
||||
ioExecutor = ioExecutor,
|
||||
networkPageSize = DEFAULT_PAGE_SIZE)
|
||||
// we are using a mutable live data to trigger refresh requests which eventually calls
|
||||
// refresh method and gets a new live data. Each refresh request by the user becomes a newly
|
||||
// dispatched data in refreshTrigger
|
||||
val refreshTrigger = MutableLiveData<Unit?>()
|
||||
val refreshState = Transformations.switchMap(refreshTrigger) {
|
||||
refresh(accountId, true)
|
||||
}
|
||||
|
||||
// We use toLiveData Kotlin extension function here, you could also use LivePagedListBuilder
|
||||
val livePagedList = db.conversationDao().conversationsForAccount(accountId).toLiveData(
|
||||
config = Config(pageSize = DEFAULT_PAGE_SIZE, prefetchDistance = DEFAULT_PAGE_SIZE / 2, enablePlaceholders = false),
|
||||
boundaryCallback = boundaryCallback
|
||||
)
|
||||
|
||||
return Listing(
|
||||
pagedList = livePagedList,
|
||||
networkState = boundaryCallback.networkState,
|
||||
retry = {
|
||||
boundaryCallback.helper.retryAllFailed()
|
||||
},
|
||||
refresh = {
|
||||
refreshTrigger.value = null
|
||||
},
|
||||
refreshState = refreshState
|
||||
)
|
||||
}
|
||||
class ConversationsRepository @Inject constructor(
|
||||
val mastodonApi: MastodonApi,
|
||||
val db: AppDatabase
|
||||
) {
|
||||
|
||||
fun deleteCacheForAccount(accountId: Long) {
|
||||
Single.fromCallable {
|
||||
|
@ -102,10 +35,4 @@ class ConversationsRepository @Inject constructor(val mastodonApi: MastodonApi,
|
|||
.subscribe()
|
||||
}
|
||||
|
||||
private fun insertResultIntoDb(accountId: Long, result: List<Conversation>?) {
|
||||
result?.filter { it.lastStatus != null }
|
||||
?.map{ it.toEntity(accountId) }
|
||||
?.let { db.conversationDao().insert(it) }
|
||||
|
||||
}
|
||||
}
|
|
@ -1,129 +1,100 @@
|
|||
/* Copyright 2021 Tusky Contributors
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.components.conversation
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.Transformations
|
||||
import androidx.paging.PagedList
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.ExperimentalPagingApi
|
||||
import androidx.paging.Pager
|
||||
import androidx.paging.PagingConfig
|
||||
import androidx.paging.cachedIn
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.db.AppDatabase
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.network.TimelineCases
|
||||
import com.keylesspalace.tusky.util.Listing
|
||||
import com.keylesspalace.tusky.util.NetworkState
|
||||
import com.keylesspalace.tusky.util.RxAwareViewModel
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.rx3.await
|
||||
import javax.inject.Inject
|
||||
|
||||
class ConversationsViewModel @Inject constructor(
|
||||
private val repository: ConversationsRepository,
|
||||
private val timelineCases: TimelineCases,
|
||||
private val database: AppDatabase,
|
||||
private val accountManager: AccountManager
|
||||
private val accountManager: AccountManager,
|
||||
private val api: MastodonApi
|
||||
) : RxAwareViewModel() {
|
||||
|
||||
private val repoResult = MutableLiveData<Listing<ConversationEntity>>()
|
||||
@ExperimentalPagingApi
|
||||
val conversationFlow = Pager(
|
||||
config = PagingConfig(pageSize = 10, enablePlaceholders = false, initialLoadSize = 20),
|
||||
remoteMediator = ConversationsRemoteMediator(accountManager.activeAccount!!.id, api, database),
|
||||
pagingSourceFactory = { database.conversationDao().conversationsForAccount(accountManager.activeAccount!!.id) }
|
||||
)
|
||||
.flow
|
||||
.cachedIn(viewModelScope)
|
||||
|
||||
val conversations: LiveData<PagedList<ConversationEntity>> =
|
||||
Transformations.switchMap(repoResult) { it.pagedList }
|
||||
val networkState: LiveData<NetworkState> =
|
||||
Transformations.switchMap(repoResult) { it.networkState }
|
||||
val refreshState: LiveData<NetworkState> =
|
||||
Transformations.switchMap(repoResult) { it.refreshState }
|
||||
fun favourite(favourite: Boolean, conversation: ConversationEntity) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
timelineCases.favourite(conversation.lastStatus.id, favourite).await()
|
||||
|
||||
fun load() {
|
||||
val accountId = accountManager.activeAccount?.id ?: return
|
||||
if (repoResult.value == null) {
|
||||
repository.refresh(accountId, false)
|
||||
val newConversation = conversation.copy(
|
||||
lastStatus = conversation.lastStatus.copy(favourited = favourite)
|
||||
)
|
||||
|
||||
database.conversationDao().insert(newConversation)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "failed to favourite status", e)
|
||||
}
|
||||
}
|
||||
repoResult.value = repository.conversations(accountId)
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
repoResult.value?.refresh?.invoke()
|
||||
}
|
||||
fun bookmark(bookmark: Boolean, conversation: ConversationEntity) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
timelineCases.bookmark(conversation.lastStatus.id, bookmark).await()
|
||||
|
||||
fun retry() {
|
||||
repoResult.value?.retry?.invoke()
|
||||
}
|
||||
val newConversation = conversation.copy(
|
||||
lastStatus = conversation.lastStatus.copy(bookmarked = bookmark)
|
||||
)
|
||||
|
||||
fun favourite(favourite: Boolean, position: Int) {
|
||||
conversations.value?.getOrNull(position)?.let { conversation ->
|
||||
timelineCases.favourite(conversation.lastStatus.id, favourite)
|
||||
.flatMap {
|
||||
val newConversation = conversation.copy(
|
||||
lastStatus = conversation.lastStatus.copy(favourited = favourite)
|
||||
)
|
||||
|
||||
database.conversationDao().insert(newConversation)
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.doOnError { t ->
|
||||
Log.w(
|
||||
"ConversationViewModel",
|
||||
"Failed to favourite conversation",
|
||||
t
|
||||
)
|
||||
}
|
||||
.onErrorReturnItem(0)
|
||||
.subscribe()
|
||||
.autoDispose()
|
||||
database.conversationDao().insert(newConversation)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "failed to bookmark status", e)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun bookmark(bookmark: Boolean, position: Int) {
|
||||
conversations.value?.getOrNull(position)?.let { conversation ->
|
||||
timelineCases.bookmark(conversation.lastStatus.id, bookmark)
|
||||
.flatMap {
|
||||
val newConversation = conversation.copy(
|
||||
lastStatus = conversation.lastStatus.copy(bookmarked = bookmark)
|
||||
)
|
||||
fun voteInPoll(choices: List<Int>, conversation: ConversationEntity) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val poll = timelineCases.voteInPoll(conversation.lastStatus.id, conversation.lastStatus.poll?.id!!, choices).await()
|
||||
val newConversation = conversation.copy(
|
||||
lastStatus = conversation.lastStatus.copy(poll = poll)
|
||||
)
|
||||
|
||||
database.conversationDao().insert(newConversation)
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.doOnError { t ->
|
||||
Log.w(
|
||||
"ConversationViewModel",
|
||||
"Failed to bookmark conversation",
|
||||
t
|
||||
)
|
||||
}
|
||||
.onErrorReturnItem(0)
|
||||
.subscribe()
|
||||
.autoDispose()
|
||||
database.conversationDao().insert(newConversation)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "failed to vote in poll", e)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun voteInPoll(position: Int, choices: MutableList<Int>) {
|
||||
conversations.value?.getOrNull(position)?.let { conversation ->
|
||||
val poll = conversation.lastStatus.poll ?: return
|
||||
timelineCases.voteInPoll(conversation.lastStatus.id, poll.id, choices)
|
||||
.flatMap { newPoll ->
|
||||
val newConversation = conversation.copy(
|
||||
lastStatus = conversation.lastStatus.copy(poll = newPoll)
|
||||
)
|
||||
|
||||
database.conversationDao().insert(newConversation)
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.doOnError { t ->
|
||||
Log.w(
|
||||
"ConversationViewModel",
|
||||
"Failed to favourite conversation",
|
||||
t
|
||||
)
|
||||
}
|
||||
.onErrorReturnItem(0)
|
||||
.subscribe()
|
||||
.autoDispose()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun expandHiddenStatus(expanded: Boolean, position: Int) {
|
||||
conversations.value?.getOrNull(position)?.let { conversation ->
|
||||
fun expandHiddenStatus(expanded: Boolean, conversation: ConversationEntity) {
|
||||
viewModelScope.launch {
|
||||
val newConversation = conversation.copy(
|
||||
lastStatus = conversation.lastStatus.copy(expanded = expanded)
|
||||
)
|
||||
|
@ -131,8 +102,8 @@ class ConversationsViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
fun collapseLongStatus(collapsed: Boolean, position: Int) {
|
||||
conversations.value?.getOrNull(position)?.let { conversation ->
|
||||
fun collapseLongStatus(collapsed: Boolean, conversation: ConversationEntity) {
|
||||
viewModelScope.launch {
|
||||
val newConversation = conversation.copy(
|
||||
lastStatus = conversation.lastStatus.copy(collapsed = collapsed)
|
||||
)
|
||||
|
@ -140,8 +111,8 @@ class ConversationsViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
fun showContent(showing: Boolean, position: Int) {
|
||||
conversations.value?.getOrNull(position)?.let { conversation ->
|
||||
fun showContent(showing: Boolean, conversation: ConversationEntity) {
|
||||
viewModelScope.launch {
|
||||
val newConversation = conversation.copy(
|
||||
lastStatus = conversation.lastStatus.copy(showingHiddenContent = showing)
|
||||
)
|
||||
|
@ -149,16 +120,42 @@ class ConversationsViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
fun remove(position: Int) {
|
||||
conversations.value?.getOrNull(position)?.let {
|
||||
refresh()
|
||||
fun remove(conversation: ConversationEntity) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
api.deleteConversation(conversationId = conversation.id)
|
||||
|
||||
database.conversationDao().delete(conversation)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "failed to delete conversation", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveConversationToDb(conversation: ConversationEntity) {
|
||||
database.conversationDao().insert(conversation)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.subscribe()
|
||||
fun muteConversation(conversation: ConversationEntity) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val newStatus = timelineCases.muteConversation(
|
||||
conversation.lastStatus.id,
|
||||
!conversation.lastStatus.muted
|
||||
).await()
|
||||
|
||||
val newConversation = conversation.copy(
|
||||
lastStatus = newStatus.toEntity()
|
||||
)
|
||||
|
||||
database.conversationDao().insert(newConversation)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "failed to mute conversation", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveConversationToDb(conversation: ConversationEntity) {
|
||||
database.conversationDao().insert(conversation)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ConversationsViewModel"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,3 +1,18 @@
|
|||
/* Copyright 2021 Tusky Contributors
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.components.search
|
||||
|
||||
enum class SearchType(val apiParameter: String) {
|
||||
|
|
|
@ -1,17 +1,35 @@
|
|||
/* Copyright 2021 Tusky Contributors
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.components.search
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.paging.PagedList
|
||||
import com.keylesspalace.tusky.components.search.adapter.SearchRepository
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.Pager
|
||||
import androidx.paging.PagingConfig
|
||||
import androidx.paging.cachedIn
|
||||
import com.keylesspalace.tusky.components.search.adapter.SearchPagingSourceFactory
|
||||
import com.keylesspalace.tusky.db.AccountEntity
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.entity.*
|
||||
import com.keylesspalace.tusky.entity.DeletedStatus
|
||||
import com.keylesspalace.tusky.entity.Poll
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.network.TimelineCases
|
||||
import com.keylesspalace.tusky.util.*
|
||||
import com.keylesspalace.tusky.util.RxAwareViewModel
|
||||
import com.keylesspalace.tusky.util.toViewData
|
||||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
|
@ -35,82 +53,62 @@ class SearchViewModel @Inject constructor(
|
|||
val alwaysShowSensitiveMedia = activeAccount?.alwaysShowSensitiveMedia ?: false
|
||||
val alwaysOpenSpoiler = activeAccount?.alwaysOpenSpoiler ?: false
|
||||
|
||||
private val statusesRepository =
|
||||
SearchRepository<Pair<Status, StatusViewData.Concrete>>(mastodonApi)
|
||||
private val accountsRepository = SearchRepository<Account>(mastodonApi)
|
||||
private val hashtagsRepository = SearchRepository<HashTag>(mastodonApi)
|
||||
private val loadedStatuses: MutableList<Pair<Status, StatusViewData.Concrete>> = mutableListOf()
|
||||
|
||||
private val repoResultStatus = MutableLiveData<Listing<Pair<Status, StatusViewData.Concrete>>>()
|
||||
val statuses: LiveData<PagedList<Pair<Status, StatusViewData.Concrete>>> =
|
||||
repoResultStatus.switchMap { it.pagedList }
|
||||
val networkStateStatus: LiveData<NetworkState> = repoResultStatus.switchMap { it.networkState }
|
||||
val networkStateStatusRefresh: LiveData<NetworkState> =
|
||||
repoResultStatus.switchMap { it.refreshState }
|
||||
private val statusesPagingSourceFactory = SearchPagingSourceFactory(mastodonApi, SearchType.Status, loadedStatuses) {
|
||||
it.statuses.map { status -> Pair(status, status.toViewData(alwaysShowSensitiveMedia, alwaysOpenSpoiler)) }
|
||||
.apply {
|
||||
loadedStatuses.addAll(this)
|
||||
}
|
||||
}
|
||||
private val accountsPagingSourceFactory = SearchPagingSourceFactory(mastodonApi, SearchType.Account) {
|
||||
it.accounts
|
||||
}
|
||||
private val hashtagsPagingSourceFactory = SearchPagingSourceFactory(mastodonApi, SearchType.Hashtag) {
|
||||
it.hashtags
|
||||
}
|
||||
|
||||
private val repoResultAccount = MutableLiveData<Listing<Account>>()
|
||||
val accounts: LiveData<PagedList<Account>> = repoResultAccount.switchMap { it.pagedList }
|
||||
val networkStateAccount: LiveData<NetworkState> =
|
||||
repoResultAccount.switchMap { it.networkState }
|
||||
val networkStateAccountRefresh: LiveData<NetworkState> =
|
||||
repoResultAccount.switchMap { it.refreshState }
|
||||
val statusesFlow = Pager(
|
||||
config = PagingConfig(pageSize = DEFAULT_LOAD_SIZE, initialLoadSize = DEFAULT_LOAD_SIZE),
|
||||
pagingSourceFactory = statusesPagingSourceFactory
|
||||
).flow
|
||||
.cachedIn(viewModelScope)
|
||||
|
||||
private val repoResultHashTag = MutableLiveData<Listing<HashTag>>()
|
||||
val hashtags: LiveData<PagedList<HashTag>> = repoResultHashTag.switchMap { it.pagedList }
|
||||
val networkStateHashTag: LiveData<NetworkState> =
|
||||
repoResultHashTag.switchMap { it.networkState }
|
||||
val networkStateHashTagRefresh: LiveData<NetworkState> =
|
||||
repoResultHashTag.switchMap { it.refreshState }
|
||||
val accountsFlow = Pager(
|
||||
config = PagingConfig(pageSize = DEFAULT_LOAD_SIZE, initialLoadSize = DEFAULT_LOAD_SIZE),
|
||||
pagingSourceFactory = accountsPagingSourceFactory
|
||||
).flow
|
||||
.cachedIn(viewModelScope)
|
||||
|
||||
val hashtagsFlow = Pager(
|
||||
config = PagingConfig(pageSize = DEFAULT_LOAD_SIZE, initialLoadSize = DEFAULT_LOAD_SIZE),
|
||||
pagingSourceFactory = hashtagsPagingSourceFactory
|
||||
).flow
|
||||
.cachedIn(viewModelScope)
|
||||
|
||||
private val loadedStatuses = ArrayList<Pair<Status, StatusViewData.Concrete>>()
|
||||
fun search(query: String) {
|
||||
loadedStatuses.clear()
|
||||
repoResultStatus.value = statusesRepository.getSearchData(
|
||||
SearchType.Status,
|
||||
query,
|
||||
disposables,
|
||||
initialItems = loadedStatuses
|
||||
) {
|
||||
it?.statuses?.map { status ->
|
||||
Pair(
|
||||
status,
|
||||
status.toViewData(alwaysShowSensitiveMedia, alwaysOpenSpoiler)
|
||||
)
|
||||
}
|
||||
.orEmpty()
|
||||
.apply {
|
||||
loadedStatuses.addAll(this)
|
||||
}
|
||||
}
|
||||
repoResultAccount.value =
|
||||
accountsRepository.getSearchData(SearchType.Account, query, disposables) {
|
||||
it?.accounts.orEmpty()
|
||||
}
|
||||
val hashtagQuery = if (query.startsWith("#")) query else "#$query"
|
||||
repoResultHashTag.value =
|
||||
hashtagsRepository.getSearchData(SearchType.Hashtag, hashtagQuery, disposables) {
|
||||
it?.hashtags.orEmpty()
|
||||
}
|
||||
|
||||
statusesPagingSourceFactory.newSearch(query)
|
||||
accountsPagingSourceFactory.newSearch(query)
|
||||
hashtagsPagingSourceFactory.newSearch(query)
|
||||
}
|
||||
|
||||
fun removeItem(status: Pair<Status, StatusViewData.Concrete>) {
|
||||
timelineCases.delete(status.first.id)
|
||||
.subscribe({
|
||||
if (loadedStatuses.remove(status))
|
||||
repoResultStatus.value?.refresh?.invoke()
|
||||
}, { err ->
|
||||
Log.d(TAG, "Failed to delete status", err)
|
||||
})
|
||||
.autoDispose()
|
||||
|
||||
.subscribe({
|
||||
if (loadedStatuses.remove(status))
|
||||
statusesPagingSourceFactory.invalidate()
|
||||
}, {
|
||||
err -> Log.d(TAG, "Failed to delete status", err)
|
||||
})
|
||||
.autoDispose()
|
||||
}
|
||||
|
||||
fun expandedChange(status: Pair<Status, StatusViewData.Concrete>, expanded: Boolean) {
|
||||
val idx = loadedStatuses.indexOf(status)
|
||||
if (idx >= 0) {
|
||||
val newPair = Pair(status.first, status.second.copy(isExpanded = expanded))
|
||||
loadedStatuses[idx] = newPair
|
||||
repoResultStatus.value?.refresh?.invoke()
|
||||
loadedStatuses[idx] = Pair(status.first, status.second.copy(isExpanded = expanded))
|
||||
statusesPagingSourceFactory.invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -119,36 +117,30 @@ class SearchViewModel @Inject constructor(
|
|||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{ setRebloggedForStatus(status, reblog) },
|
||||
{ err -> Log.d(TAG, "Failed to reblog status ${status.first.id}", err) }
|
||||
{ t -> Log.d(TAG, "Failed to reblog status ${status.first.id}", t) }
|
||||
)
|
||||
.autoDispose()
|
||||
}
|
||||
|
||||
private fun setRebloggedForStatus(
|
||||
status: Pair<Status, StatusViewData.Concrete>,
|
||||
reblog: Boolean
|
||||
) {
|
||||
private fun setRebloggedForStatus(status: Pair<Status, StatusViewData.Concrete>, reblog: Boolean) {
|
||||
status.first.reblogged = reblog
|
||||
status.first.reblog?.reblogged = reblog
|
||||
|
||||
repoResultStatus.value?.refresh?.invoke()
|
||||
statusesPagingSourceFactory.invalidate()
|
||||
}
|
||||
|
||||
fun contentHiddenChange(status: Pair<Status, StatusViewData.Concrete>, isShowing: Boolean) {
|
||||
val idx = loadedStatuses.indexOf(status)
|
||||
if (idx >= 0) {
|
||||
val newPair = Pair(status.first, status.second.copy(isShowingContent = isShowing))
|
||||
loadedStatuses[idx] = newPair
|
||||
repoResultStatus.value?.refresh?.invoke()
|
||||
loadedStatuses[idx] = Pair(status.first, status.second.copy(isShowingContent = isShowing))
|
||||
statusesPagingSourceFactory.invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
fun collapsedChange(status: Pair<Status, StatusViewData.Concrete>, collapsed: Boolean) {
|
||||
val idx = loadedStatuses.indexOf(status)
|
||||
if (idx >= 0) {
|
||||
val newPair = Pair(status.first, status.second.copy(isCollapsed = collapsed))
|
||||
loadedStatuses[idx] = newPair
|
||||
repoResultStatus.value?.refresh?.invoke()
|
||||
loadedStatuses[idx] = Pair(status.first, status.second.copy(isCollapsed = collapsed))
|
||||
statusesPagingSourceFactory.invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -159,12 +151,7 @@ class SearchViewModel @Inject constructor(
|
|||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{ newPoll -> updateStatus(status, newPoll) },
|
||||
{ t ->
|
||||
Log.d(
|
||||
TAG,
|
||||
"Failed to vote in poll: ${status.first.id}", t
|
||||
)
|
||||
}
|
||||
{ t -> Log.d(TAG, "Failed to vote in poll: ${status.first.id}", t) }
|
||||
)
|
||||
.autoDispose()
|
||||
}
|
||||
|
@ -175,13 +162,13 @@ class SearchViewModel @Inject constructor(
|
|||
val newStatus = status.first.copy(poll = newPoll)
|
||||
val newViewData = status.second.copy(status = newStatus)
|
||||
loadedStatuses[idx] = Pair(newStatus, newViewData)
|
||||
repoResultStatus.value?.refresh?.invoke()
|
||||
statusesPagingSourceFactory.invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
fun favorite(status: Pair<Status, StatusViewData.Concrete>, isFavorited: Boolean) {
|
||||
status.first.favourited = isFavorited
|
||||
repoResultStatus.value?.refresh?.invoke()
|
||||
statusesPagingSourceFactory.invalidate()
|
||||
timelineCases.favourite(status.first.id, isFavorited)
|
||||
.onErrorReturnItem(status.first)
|
||||
.subscribe()
|
||||
|
@ -190,7 +177,7 @@ class SearchViewModel @Inject constructor(
|
|||
|
||||
fun bookmark(status: Pair<Status, StatusViewData.Concrete>, isBookmarked: Boolean) {
|
||||
status.first.bookmarked = isBookmarked
|
||||
repoResultStatus.value?.refresh?.invoke()
|
||||
statusesPagingSourceFactory.invalidate()
|
||||
timelineCases.bookmark(status.first.id, isBookmarked)
|
||||
.onErrorReturnItem(status.first)
|
||||
.subscribe()
|
||||
|
@ -217,10 +204,6 @@ class SearchViewModel @Inject constructor(
|
|||
return timelineCases.delete(id)
|
||||
}
|
||||
|
||||
fun retryAllSearches() {
|
||||
search(currentQuery)
|
||||
}
|
||||
|
||||
fun muteConversation(status: Pair<Status, StatusViewData.Concrete>, mute: Boolean) {
|
||||
val idx = loadedStatuses.indexOf(status)
|
||||
if (idx >= 0) {
|
||||
|
@ -230,7 +213,7 @@ class SearchViewModel @Inject constructor(
|
|||
status.second.copy(status = newStatus)
|
||||
)
|
||||
loadedStatuses[idx] = newPair
|
||||
repoResultStatus.value?.refresh?.invoke()
|
||||
statusesPagingSourceFactory.invalidate()
|
||||
}
|
||||
timelineCases.muteConversation(status.first.id, mute)
|
||||
.onErrorReturnItem(status.first)
|
||||
|
@ -240,5 +223,6 @@ class SearchViewModel @Inject constructor(
|
|||
|
||||
companion object {
|
||||
private const val TAG = "SearchViewModel"
|
||||
private const val DEFAULT_LOAD_SIZE = 20
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
/* Copyright 2019 Joel Pyska
|
||||
/* Copyright 2021 Tusky Contributors
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
|
@ -17,26 +17,25 @@ package com.keylesspalace.tusky.components.search.adapter
|
|||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.paging.PagedListAdapter
|
||||
import androidx.paging.PagingDataAdapter
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.adapter.AccountViewHolder
|
||||
import com.keylesspalace.tusky.entity.Account
|
||||
import com.keylesspalace.tusky.interfaces.LinkListener
|
||||
|
||||
class SearchAccountsAdapter(private val linkListener: LinkListener, private val animateAvatars: Boolean, private val animateEmojis: Boolean)
|
||||
: PagedListAdapter<Account, RecyclerView.ViewHolder>(ACCOUNT_COMPARATOR) {
|
||||
: PagingDataAdapter<Account, AccountViewHolder>(ACCOUNT_COMPARATOR) {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AccountViewHolder {
|
||||
val view = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.item_account, parent, false)
|
||||
return AccountViewHolder(view)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
override fun onBindViewHolder(holder: AccountViewHolder, position: Int) {
|
||||
getItem(position)?.let { item ->
|
||||
(holder as AccountViewHolder).apply {
|
||||
holder.apply {
|
||||
setupWithAccount(item, animateAvatars, animateEmojis)
|
||||
setupLinkListener(linkListener)
|
||||
}
|
||||
|
@ -52,7 +51,5 @@ class SearchAccountsAdapter(private val linkListener: LinkListener, private val
|
|||
override fun areItemsTheSame(oldItem: Account, newItem: Account): Boolean =
|
||||
oldItem.id == newItem.id
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,126 +0,0 @@
|
|||
/* Copyright 2019 Joel Pyska
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.components.search.adapter
|
||||
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.paging.PositionalDataSource
|
||||
import com.keylesspalace.tusky.components.search.SearchType
|
||||
import com.keylesspalace.tusky.entity.SearchResult
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.NetworkState
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.addTo
|
||||
import java.util.concurrent.Executor
|
||||
|
||||
class SearchDataSource<T>(
|
||||
private val mastodonApi: MastodonApi,
|
||||
private val searchType: SearchType,
|
||||
private val searchRequest: String,
|
||||
private val disposables: CompositeDisposable,
|
||||
private val retryExecutor: Executor,
|
||||
private val initialItems: List<T>? = null,
|
||||
private val parser: (SearchResult?) -> List<T>,
|
||||
private val source: SearchDataSourceFactory<T>) : PositionalDataSource<T>() {
|
||||
|
||||
val networkState = MutableLiveData<NetworkState>()
|
||||
|
||||
private var retry: (() -> Any)? = null
|
||||
|
||||
val initialLoad = MutableLiveData<NetworkState>()
|
||||
|
||||
fun retry() {
|
||||
retry?.let {
|
||||
retryExecutor.execute {
|
||||
it.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun loadInitial(params: LoadInitialParams, callback: LoadInitialCallback<T>) {
|
||||
if (!initialItems.isNullOrEmpty()) {
|
||||
callback.onResult(initialItems.toList(), 0)
|
||||
} else {
|
||||
networkState.postValue(NetworkState.LOADED)
|
||||
retry = null
|
||||
initialLoad.postValue(NetworkState.LOADING)
|
||||
mastodonApi.searchObservable(
|
||||
query = searchRequest,
|
||||
type = searchType.apiParameter,
|
||||
resolve = true,
|
||||
limit = params.requestedLoadSize,
|
||||
offset = 0,
|
||||
following = false)
|
||||
.subscribe(
|
||||
{ data ->
|
||||
val res = parser(data)
|
||||
callback.onResult(res, params.requestedStartPosition)
|
||||
initialLoad.postValue(NetworkState.LOADED)
|
||||
|
||||
},
|
||||
{ error ->
|
||||
retry = {
|
||||
loadInitial(params, callback)
|
||||
}
|
||||
initialLoad.postValue(NetworkState.error(error.message))
|
||||
}
|
||||
).addTo(disposables)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun loadRange(params: LoadRangeParams, callback: LoadRangeCallback<T>) {
|
||||
networkState.postValue(NetworkState.LOADING)
|
||||
retry = null
|
||||
if (source.exhausted) {
|
||||
return callback.onResult(emptyList())
|
||||
}
|
||||
mastodonApi.searchObservable(
|
||||
query = searchRequest,
|
||||
type = searchType.apiParameter,
|
||||
resolve = true,
|
||||
limit = params.loadSize,
|
||||
offset = params.startPosition,
|
||||
following = false)
|
||||
.subscribe(
|
||||
{ data ->
|
||||
// Working around Mastodon bug where exact match is returned no matter
|
||||
// which offset is requested (so if we search for a full username, it's
|
||||
// infinite)
|
||||
// see https://github.com/tootsuite/mastodon/issues/11365
|
||||
// see https://github.com/tootsuite/mastodon/issues/13083
|
||||
val res = if ((data.accounts.size == 1 && data.accounts[0].username.equals(searchRequest, ignoreCase = true))
|
||||
|| (data.statuses.size == 1 && data.statuses[0].url.equals(searchRequest))) {
|
||||
listOf()
|
||||
} else {
|
||||
parser(data)
|
||||
}
|
||||
if (res.isEmpty()) {
|
||||
source.exhausted = true
|
||||
}
|
||||
callback.onResult(res)
|
||||
networkState.postValue(NetworkState.LOADED)
|
||||
},
|
||||
{ error ->
|
||||
retry = {
|
||||
loadRange(params, callback)
|
||||
}
|
||||
networkState.postValue(NetworkState.error(error.message))
|
||||
}
|
||||
).addTo(disposables)
|
||||
|
||||
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
/* Copyright 2019 Joel Pyska
|
||||
/* Copyright 2021 Tusky Contributors
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
|
@ -17,7 +17,7 @@ package com.keylesspalace.tusky.components.search.adapter
|
|||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.paging.PagedListAdapter
|
||||
import androidx.paging.PagingDataAdapter
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import com.keylesspalace.tusky.databinding.ItemHashtagBinding
|
||||
import com.keylesspalace.tusky.entity.HashTag
|
||||
|
@ -25,7 +25,7 @@ import com.keylesspalace.tusky.interfaces.LinkListener
|
|||
import com.keylesspalace.tusky.util.BindingHolder
|
||||
|
||||
class SearchHashtagsAdapter(private val linkListener: LinkListener)
|
||||
: PagedListAdapter<HashTag, BindingHolder<ItemHashtagBinding>>(HASHTAG_COMPARATOR) {
|
||||
: PagingDataAdapter<HashTag, BindingHolder<ItemHashtagBinding>>(HASHTAG_COMPARATOR) {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemHashtagBinding> {
|
||||
val binding = ItemHashtagBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
|
@ -48,7 +48,5 @@ class SearchHashtagsAdapter(private val linkListener: LinkListener)
|
|||
override fun areItemsTheSame(oldItem: HashTag, newItem: HashTag): Boolean =
|
||||
oldItem.name == newItem.name
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,83 @@
|
|||
/* Copyright 2021 Tusky Contributors
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.components.search.adapter
|
||||
|
||||
import androidx.paging.PagingSource
|
||||
import androidx.paging.PagingState
|
||||
import com.keylesspalace.tusky.components.search.SearchType
|
||||
import com.keylesspalace.tusky.entity.SearchResult
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import kotlinx.coroutines.rx3.await
|
||||
|
||||
class SearchPagingSource<T: Any>(
|
||||
private val mastodonApi: MastodonApi,
|
||||
private val searchType: SearchType,
|
||||
private val searchRequest: String,
|
||||
private val initialItems: List<T>?,
|
||||
private val parser: (SearchResult) -> List<T>) : PagingSource<Int, T>() {
|
||||
|
||||
override fun getRefreshKey(state: PagingState<Int, T>): Int? {
|
||||
return null
|
||||
}
|
||||
|
||||
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, T> {
|
||||
if (searchRequest.isEmpty()) {
|
||||
return LoadResult.Page(
|
||||
data = emptyList(),
|
||||
prevKey = null,
|
||||
nextKey = null
|
||||
)
|
||||
}
|
||||
|
||||
if (params.key == null && !initialItems.isNullOrEmpty()) {
|
||||
return LoadResult.Page(
|
||||
data = initialItems.toList(),
|
||||
prevKey = null,
|
||||
nextKey = initialItems.size
|
||||
)
|
||||
}
|
||||
|
||||
val currentKey = params.key ?: 0
|
||||
|
||||
try {
|
||||
|
||||
val data = mastodonApi.searchObservable(
|
||||
query = searchRequest,
|
||||
type = searchType.apiParameter,
|
||||
resolve = true,
|
||||
limit = params.loadSize,
|
||||
offset = currentKey,
|
||||
following = false
|
||||
).await()
|
||||
|
||||
val res = parser(data)
|
||||
|
||||
val nextKey = if (res.isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
currentKey + res.size
|
||||
}
|
||||
|
||||
return LoadResult.Page(
|
||||
data = res,
|
||||
prevKey = null,
|
||||
nextKey = nextKey
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
return LoadResult.Error(e)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
/* Copyright 2019 Joel Pyska
|
||||
/* Copyright 2021 Tusky Contributors
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
|
@ -15,30 +15,39 @@
|
|||
|
||||
package com.keylesspalace.tusky.components.search.adapter
|
||||
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.paging.DataSource
|
||||
import com.keylesspalace.tusky.components.search.SearchType
|
||||
import com.keylesspalace.tusky.entity.SearchResult
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import java.util.concurrent.Executor
|
||||
|
||||
class SearchDataSourceFactory<T>(
|
||||
private val mastodonApi: MastodonApi,
|
||||
private val searchType: SearchType,
|
||||
private val searchRequest: String,
|
||||
private val disposables: CompositeDisposable,
|
||||
private val retryExecutor: Executor,
|
||||
private val cacheData: List<T>? = null,
|
||||
private val parser: (SearchResult?) -> List<T>) : DataSource.Factory<Int, T>() {
|
||||
class SearchPagingSourceFactory<T : Any>(
|
||||
private val mastodonApi: MastodonApi,
|
||||
private val searchType: SearchType,
|
||||
private val initialItems: List<T>? = null,
|
||||
private val parser: (SearchResult) -> List<T>
|
||||
) : () -> SearchPagingSource<T> {
|
||||
|
||||
val sourceLiveData = MutableLiveData<SearchDataSource<T>>()
|
||||
private var searchRequest: String = ""
|
||||
|
||||
var exhausted = false
|
||||
private var currentSource: SearchPagingSource<T>? = null
|
||||
|
||||
override fun create(): DataSource<Int, T> {
|
||||
val source = SearchDataSource(mastodonApi, searchType, searchRequest, disposables, retryExecutor, cacheData, parser, this)
|
||||
sourceLiveData.postValue(source)
|
||||
return source
|
||||
override fun invoke(): SearchPagingSource<T> {
|
||||
return SearchPagingSource(
|
||||
mastodonApi = mastodonApi,
|
||||
searchType = searchType,
|
||||
searchRequest = searchRequest,
|
||||
initialItems = initialItems,
|
||||
parser = parser
|
||||
).also { source ->
|
||||
currentSource = source
|
||||
}
|
||||
}
|
||||
|
||||
fun newSearch(newSearchRequest: String) {
|
||||
this.searchRequest = newSearchRequest
|
||||
currentSource?.invalidate()
|
||||
}
|
||||
|
||||
fun invalidate() {
|
||||
currentSource?.invalidate()
|
||||
}
|
||||
}
|
|
@ -1,56 +0,0 @@
|
|||
/* Copyright 2019 Joel Pyska
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.components.search.adapter
|
||||
|
||||
import androidx.lifecycle.Transformations
|
||||
import androidx.paging.Config
|
||||
import androidx.paging.toLiveData
|
||||
import com.keylesspalace.tusky.components.search.SearchType
|
||||
import com.keylesspalace.tusky.entity.SearchResult
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.Listing
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
class SearchRepository<T>(private val mastodonApi: MastodonApi) {
|
||||
|
||||
private val executor = Executors.newSingleThreadExecutor()
|
||||
|
||||
fun getSearchData(searchType: SearchType, searchRequest: String, disposables: CompositeDisposable, pageSize: Int = 20,
|
||||
initialItems: List<T>? = null, parser: (SearchResult?) -> List<T>): Listing<T> {
|
||||
val sourceFactory = SearchDataSourceFactory(mastodonApi, searchType, searchRequest, disposables, executor, initialItems, parser)
|
||||
val livePagedList = sourceFactory.toLiveData(
|
||||
config = Config(pageSize = pageSize, enablePlaceholders = false, initialLoadSizeHint = pageSize * 2),
|
||||
fetchExecutor = executor
|
||||
)
|
||||
return Listing(
|
||||
pagedList = livePagedList,
|
||||
networkState = Transformations.switchMap(sourceFactory.sourceLiveData) {
|
||||
it.networkState
|
||||
},
|
||||
retry = {
|
||||
sourceFactory.sourceLiveData.value?.retry()
|
||||
},
|
||||
refresh = {
|
||||
sourceFactory.sourceLiveData.value?.invalidate()
|
||||
},
|
||||
refreshState = Transformations.switchMap(sourceFactory.sourceLiveData) {
|
||||
it.initialLoad
|
||||
}
|
||||
|
||||
)
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
/* Copyright 2019 Joel Pyska
|
||||
/* Copyright 2021 Tusky Contributors
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
|
@ -17,9 +17,8 @@ package com.keylesspalace.tusky.components.search.adapter
|
|||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.paging.PagedListAdapter
|
||||
import androidx.paging.PagingDataAdapter
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.adapter.StatusViewHolder
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
|
@ -28,36 +27,34 @@ import com.keylesspalace.tusky.util.StatusDisplayOptions
|
|||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||
|
||||
class SearchStatusesAdapter(
|
||||
private val statusDisplayOptions: StatusDisplayOptions,
|
||||
private val statusListener: StatusActionListener
|
||||
) : PagedListAdapter<Pair<Status, StatusViewData.Concrete>, RecyclerView.ViewHolder>(STATUS_COMPARATOR) {
|
||||
private val statusDisplayOptions: StatusDisplayOptions,
|
||||
private val statusListener: StatusActionListener
|
||||
) : PagingDataAdapter<Pair<Status, StatusViewData.Concrete>, StatusViewHolder>(STATUS_COMPARATOR) {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StatusViewHolder {
|
||||
val view = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.item_status, parent, false)
|
||||
.inflate(R.layout.item_status, parent, false)
|
||||
return StatusViewHolder(view)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
override fun onBindViewHolder(holder: StatusViewHolder, position: Int) {
|
||||
getItem(position)?.let { item ->
|
||||
(holder as StatusViewHolder).setupWithStatus(item.second, statusListener, statusDisplayOptions)
|
||||
holder.setupWithStatus(item.second, statusListener, statusDisplayOptions)
|
||||
}
|
||||
}
|
||||
|
||||
public override fun getItem(position: Int): Pair<Status, StatusViewData.Concrete>? {
|
||||
return super.getItem(position)
|
||||
fun item(position: Int): Pair<Status, StatusViewData.Concrete>? {
|
||||
return getItem(position)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
val STATUS_COMPARATOR = object : DiffUtil.ItemCallback<Pair<Status, StatusViewData.Concrete>>() {
|
||||
override fun areContentsTheSame(oldItem: Pair<Status, StatusViewData.Concrete>, newItem: Pair<Status, StatusViewData.Concrete>): Boolean =
|
||||
oldItem.second == newItem.second
|
||||
oldItem == newItem
|
||||
|
||||
override fun areItemsTheSame(oldItem: Pair<Status, StatusViewData.Concrete>, newItem: Pair<Status, StatusViewData.Concrete>): Boolean =
|
||||
oldItem.second.id == newItem.second.id
|
||||
oldItem.second.id == newItem.second.id
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
/* Copyright 2019 Joel Pyska
|
||||
/* Copyright 2021 Tusky Contributors
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
|
@ -15,17 +15,16 @@
|
|||
|
||||
package com.keylesspalace.tusky.components.search.fragments
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.paging.PagedList
|
||||
import androidx.paging.PagedListAdapter
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.PagingDataAdapter
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.keylesspalace.tusky.components.search.adapter.SearchAccountsAdapter
|
||||
import com.keylesspalace.tusky.entity.Account
|
||||
import com.keylesspalace.tusky.settings.PrefKeys
|
||||
import com.keylesspalace.tusky.util.NetworkState
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
class SearchAccountsFragment : SearchFragment<Account>() {
|
||||
override fun createAdapter(): PagedListAdapter<Account, *> {
|
||||
override fun createAdapter(): PagingDataAdapter<Account, *> {
|
||||
val preferences = PreferenceManager.getDefaultSharedPreferences(binding.searchRecyclerView.context)
|
||||
|
||||
return SearchAccountsAdapter(
|
||||
|
@ -35,12 +34,8 @@ class SearchAccountsFragment : SearchFragment<Account>() {
|
|||
)
|
||||
}
|
||||
|
||||
override val networkStateRefresh: LiveData<NetworkState>
|
||||
get() = viewModel.networkStateAccountRefresh
|
||||
override val networkState: LiveData<NetworkState>
|
||||
get() = viewModel.networkStateAccount
|
||||
override val data: LiveData<PagedList<Account>>
|
||||
get() = viewModel.accounts
|
||||
override val data: Flow<PagingData<Account>>
|
||||
get() = viewModel.accountsFlow
|
||||
|
||||
companion object {
|
||||
fun newInstance() = SearchAccountsFragment()
|
||||
|
|
|
@ -4,9 +4,10 @@ import android.os.Bundle
|
|||
import android.view.View
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.paging.PagedList
|
||||
import androidx.paging.PagedListAdapter
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.paging.LoadState
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.PagingDataAdapter
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.SimpleItemAnimator
|
||||
|
@ -21,10 +22,14 @@ import com.keylesspalace.tusky.databinding.FragmentSearchBinding
|
|||
import com.keylesspalace.tusky.di.Injectable
|
||||
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||
import com.keylesspalace.tusky.interfaces.LinkListener
|
||||
import com.keylesspalace.tusky.util.*
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
abstract class SearchFragment<T> : Fragment(R.layout.fragment_search),
|
||||
abstract class SearchFragment<T: Any> : Fragment(R.layout.fragment_search),
|
||||
LinkListener, Injectable, SwipeRefreshLayout.OnRefreshListener {
|
||||
|
||||
@Inject
|
||||
|
@ -36,12 +41,12 @@ abstract class SearchFragment<T> : Fragment(R.layout.fragment_search),
|
|||
|
||||
private var snackbarErrorRetry: Snackbar? = null
|
||||
|
||||
abstract fun createAdapter(): PagedListAdapter<T, *>
|
||||
abstract fun createAdapter(): PagingDataAdapter<T, *>
|
||||
|
||||
abstract val networkStateRefresh: LiveData<NetworkState>
|
||||
abstract val networkState: LiveData<NetworkState>
|
||||
abstract val data: LiveData<PagedList<T>>
|
||||
protected lateinit var adapter: PagedListAdapter<T, *>
|
||||
abstract val data: Flow<PagingData<T>>
|
||||
protected lateinit var adapter: PagingDataAdapter<T, *>
|
||||
|
||||
private var currentQuery: String = ""
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
initAdapter()
|
||||
|
@ -55,32 +60,32 @@ abstract class SearchFragment<T> : Fragment(R.layout.fragment_search),
|
|||
}
|
||||
|
||||
private fun subscribeObservables() {
|
||||
data.observe(viewLifecycleOwner) {
|
||||
adapter.submitList(it)
|
||||
}
|
||||
|
||||
networkStateRefresh.observe(viewLifecycleOwner) {
|
||||
|
||||
binding.searchProgressBar.visible(it == NetworkState.LOADING)
|
||||
|
||||
if (it.status == Status.FAILED) {
|
||||
showError()
|
||||
}
|
||||
checkNoData()
|
||||
}
|
||||
|
||||
networkState.observe(viewLifecycleOwner) {
|
||||
|
||||
binding.progressBarBottom.visible(it == NetworkState.LOADING)
|
||||
|
||||
if (it.status == Status.FAILED) {
|
||||
showError()
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
data.collectLatest { pagingData ->
|
||||
adapter.submitData(pagingData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkNoData() {
|
||||
showNoData(adapter.itemCount == 0)
|
||||
adapter.addLoadStateListener { loadState ->
|
||||
|
||||
if (loadState.refresh is LoadState.Error) {
|
||||
showError()
|
||||
}
|
||||
|
||||
val isNewSearch = currentQuery != viewModel.currentQuery
|
||||
|
||||
binding.searchProgressBar.visible(loadState.refresh == LoadState.Loading && isNewSearch && !binding.swipeRefreshLayout.isRefreshing)
|
||||
binding.searchRecyclerView.visible(loadState.refresh is LoadState.NotLoading || !isNewSearch || binding.swipeRefreshLayout.isRefreshing)
|
||||
|
||||
if (loadState.refresh != LoadState.Loading) {
|
||||
binding.swipeRefreshLayout.isRefreshing = false
|
||||
currentQuery = viewModel.currentQuery
|
||||
}
|
||||
|
||||
binding.progressBarBottom.visible(loadState.append == LoadState.Loading)
|
||||
|
||||
binding.searchNoResultsText.visible(loadState.refresh is LoadState.NotLoading && adapter.itemCount == 0 && viewModel.currentQuery.isNotEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
private fun initAdapter() {
|
||||
|
@ -92,20 +97,12 @@ abstract class SearchFragment<T> : Fragment(R.layout.fragment_search),
|
|||
(binding.searchRecyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
|
||||
}
|
||||
|
||||
private fun showNoData(isEmpty: Boolean) {
|
||||
if (isEmpty && networkStateRefresh.value == NetworkState.LOADED) {
|
||||
binding.searchNoResultsText.show()
|
||||
} else {
|
||||
binding.searchNoResultsText.hide()
|
||||
}
|
||||
}
|
||||
|
||||
private fun showError() {
|
||||
if (snackbarErrorRetry?.isShown != true) {
|
||||
snackbarErrorRetry = Snackbar.make(binding.root, R.string.failed_search, Snackbar.LENGTH_INDEFINITE)
|
||||
snackbarErrorRetry?.setAction(R.string.action_retry) {
|
||||
snackbarErrorRetry = null
|
||||
viewModel.retryAllSearches()
|
||||
adapter.retry()
|
||||
}
|
||||
snackbarErrorRetry?.show()
|
||||
}
|
||||
|
@ -123,11 +120,6 @@ abstract class SearchFragment<T> : Fragment(R.layout.fragment_search),
|
|||
get() = (activity as? BottomSheetActivity)
|
||||
|
||||
override fun onRefresh() {
|
||||
|
||||
// Dismissed here because the RecyclerView bottomProgressBar is shown as soon as the retry begins.
|
||||
binding.swipeRefreshLayout.post {
|
||||
binding.swipeRefreshLayout.isRefreshing = false
|
||||
}
|
||||
viewModel.retryAllSearches()
|
||||
adapter.refresh()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
/* Copyright 2019 Joel Pyska
|
||||
/* Copyright 2021 Tusky Contributors
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
|
@ -15,22 +15,18 @@
|
|||
|
||||
package com.keylesspalace.tusky.components.search.fragments
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.paging.PagedList
|
||||
import androidx.paging.PagedListAdapter
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.PagingDataAdapter
|
||||
import com.keylesspalace.tusky.components.search.adapter.SearchHashtagsAdapter
|
||||
import com.keylesspalace.tusky.entity.HashTag
|
||||
import com.keylesspalace.tusky.util.NetworkState
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
class SearchHashtagsFragment : SearchFragment<HashTag>() {
|
||||
override val networkStateRefresh: LiveData<NetworkState>
|
||||
get() = viewModel.networkStateHashTagRefresh
|
||||
override val networkState: LiveData<NetworkState>
|
||||
get() = viewModel.networkStateHashTag
|
||||
override val data: LiveData<PagedList<HashTag>>
|
||||
get() = viewModel.hashtags
|
||||
|
||||
override fun createAdapter(): PagedListAdapter<HashTag, *> = SearchHashtagsAdapter(this)
|
||||
override val data: Flow<PagingData<HashTag>>
|
||||
get() = viewModel.hashtagsFlow
|
||||
|
||||
override fun createAdapter(): PagingDataAdapter<HashTag, *> = SearchHashtagsAdapter(this)
|
||||
|
||||
companion object {
|
||||
fun newInstance() = SearchHashtagsFragment()
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
/* Copyright 2019 Joel Pyska
|
||||
/* Copyright 2021 Tusky Contributors
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
|
@ -32,9 +32,8 @@ import androidx.appcompat.widget.PopupMenu
|
|||
import androidx.core.app.ActivityOptionsCompat
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.paging.PagedList
|
||||
import androidx.paging.PagedListAdapter
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.PagingDataAdapter
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
|
@ -57,26 +56,22 @@ import com.keylesspalace.tusky.interfaces.StatusActionListener
|
|||
import com.keylesspalace.tusky.settings.PrefKeys
|
||||
import com.keylesspalace.tusky.util.CardViewMode
|
||||
import com.keylesspalace.tusky.util.LinkHelper
|
||||
import com.keylesspalace.tusky.util.NetworkState
|
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
||||
import com.keylesspalace.tusky.view.showMuteAccountDialog
|
||||
import com.keylesspalace.tusky.viewdata.AttachmentViewData
|
||||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concrete>>(), StatusActionListener {
|
||||
|
||||
override val networkStateRefresh: LiveData<NetworkState>
|
||||
get() = viewModel.networkStateStatusRefresh
|
||||
override val networkState: LiveData<NetworkState>
|
||||
get() = viewModel.networkStateStatus
|
||||
override val data: LiveData<PagedList<Pair<Status, StatusViewData.Concrete>>>
|
||||
get() = viewModel.statuses
|
||||
override val data: Flow<PagingData<Pair<Status, StatusViewData.Concrete>>>
|
||||
get() = viewModel.statusesFlow
|
||||
|
||||
private val searchAdapter
|
||||
get() = super.adapter as SearchStatusesAdapter
|
||||
|
||||
override fun createAdapter(): PagedListAdapter<Pair<Status, StatusViewData.Concrete>, *> {
|
||||
override fun createAdapter(): PagingDataAdapter<Pair<Status, StatusViewData.Concrete>, *> {
|
||||
val preferences = PreferenceManager.getDefaultSharedPreferences(binding.searchRecyclerView.context)
|
||||
val statusDisplayOptions = StatusDisplayOptions(
|
||||
animateAvatars = preferences.getBoolean("animateGifAvatars", false),
|
||||
|
@ -96,37 +91,37 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
|
|||
}
|
||||
|
||||
override fun onContentHiddenChange(isShowing: Boolean, position: Int) {
|
||||
searchAdapter.getItem(position)?.let {
|
||||
searchAdapter.item(position)?.let {
|
||||
viewModel.contentHiddenChange(it, isShowing)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onReply(position: Int) {
|
||||
searchAdapter.getItem(position)?.first?.let { status ->
|
||||
searchAdapter.item(position)?.first?.let { status ->
|
||||
reply(status)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFavourite(favourite: Boolean, position: Int) {
|
||||
searchAdapter.getItem(position)?.let { status ->
|
||||
searchAdapter.item(position)?.let { status ->
|
||||
viewModel.favorite(status, favourite)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBookmark(bookmark: Boolean, position: Int) {
|
||||
searchAdapter.getItem(position)?.let { status ->
|
||||
searchAdapter.item(position)?.let { status ->
|
||||
viewModel.bookmark(status, bookmark)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMore(view: View, position: Int) {
|
||||
searchAdapter.getItem(position)?.first?.let {
|
||||
searchAdapter.item(position)?.first?.let {
|
||||
more(it, view, position)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
|
||||
searchAdapter.getItem(position)?.first?.actionableStatus?.let { actionable ->
|
||||
searchAdapter.item(position)?.first?.actionableStatus?.let { actionable ->
|
||||
when (actionable.attachments[attachmentIndex].type) {
|
||||
Attachment.Type.GIFV, Attachment.Type.VIDEO, Attachment.Type.IMAGE, Attachment.Type.AUDIO -> {
|
||||
val attachments = AttachmentViewData.list(actionable)
|
||||
|
@ -146,26 +141,24 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
|
|||
LinkHelper.openLink(actionable.attachments[attachmentIndex].url, context)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun onViewThread(position: Int) {
|
||||
searchAdapter.getItem(position)?.first?.let { status ->
|
||||
searchAdapter.item(position)?.first?.let { status ->
|
||||
val actionableStatus = status.actionableStatus
|
||||
bottomSheetActivity?.viewThread(actionableStatus.id, actionableStatus.url)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onOpenReblog(position: Int) {
|
||||
searchAdapter.getItem(position)?.first?.let { status ->
|
||||
searchAdapter.item(position)?.first?.let { status ->
|
||||
bottomSheetActivity?.viewAccount(status.account.id)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onExpandedChange(expanded: Boolean, position: Int) {
|
||||
searchAdapter.getItem(position)?.let {
|
||||
searchAdapter.item(position)?.let {
|
||||
viewModel.expandedChange(it, expanded)
|
||||
}
|
||||
}
|
||||
|
@ -175,25 +168,25 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
|
|||
}
|
||||
|
||||
override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) {
|
||||
searchAdapter.getItem(position)?.let {
|
||||
searchAdapter.item(position)?.let {
|
||||
viewModel.collapsedChange(it, isCollapsed)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onVoteInPoll(position: Int, choices: MutableList<Int>) {
|
||||
searchAdapter.getItem(position)?.let {
|
||||
searchAdapter.item(position)?.let {
|
||||
viewModel.voteInPoll(it, choices)
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeItem(position: Int) {
|
||||
searchAdapter.getItem(position)?.let {
|
||||
searchAdapter.item(position)?.let {
|
||||
viewModel.removeItem(it)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onReblog(reblog: Boolean, position: Int) {
|
||||
searchAdapter.getItem(position)?.let { status ->
|
||||
searchAdapter.item(position)?.let { status ->
|
||||
viewModel.reblog(status, reblog)
|
||||
}
|
||||
}
|
||||
|
@ -323,7 +316,7 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
|
|||
return@setOnMenuItemClickListener true
|
||||
}
|
||||
R.id.status_mute_conversation -> {
|
||||
searchAdapter.getItem(position)?.let { foundStatus ->
|
||||
searchAdapter.item(position)?.let { foundStatus ->
|
||||
viewModel.muteConversation(foundStatus, status.muted != true)
|
||||
}
|
||||
return@setOnMenuItemClickListener true
|
||||
|
|
|
@ -32,7 +32,7 @@ import java.io.File;
|
|||
|
||||
@Database(entities = { DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class,
|
||||
TimelineAccountEntity.class, ConversationEntity.class
|
||||
}, version = 26)
|
||||
}, version = 27)
|
||||
public abstract class AppDatabase extends RoomDatabase {
|
||||
|
||||
public abstract AccountDao accountDao();
|
||||
|
@ -393,4 +393,11 @@ public abstract class AppDatabase extends RoomDatabase {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static final Migration MIGRATION_26_27 = new Migration(26, 27) {
|
||||
@Override
|
||||
public void migrate(@NonNull SupportSQLiteDatabase database) {
|
||||
database.execSQL("ALTER TABLE `ConversationEntity` ADD COLUMN `s_muted` INTEGER NOT NULL DEFAULT 0");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -15,27 +15,29 @@
|
|||
|
||||
package com.keylesspalace.tusky.db
|
||||
|
||||
import androidx.paging.DataSource
|
||||
import androidx.room.*
|
||||
import androidx.paging.PagingSource
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Delete
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import com.keylesspalace.tusky.components.conversation.ConversationEntity
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
|
||||
@Dao
|
||||
interface ConversationsDao {
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun insert(conversations: List<ConversationEntity>)
|
||||
suspend fun insert(conversations: List<ConversationEntity>)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun insert(conversation: ConversationEntity): Single<Long>
|
||||
suspend fun insert(conversation: ConversationEntity): Long
|
||||
|
||||
@Delete
|
||||
fun delete(conversation: ConversationEntity): Single<Int>
|
||||
suspend fun delete(conversation: ConversationEntity): Int
|
||||
|
||||
@Query("SELECT * FROM ConversationEntity WHERE accountId = :accountId ORDER BY s_createdAt DESC")
|
||||
fun conversationsForAccount(accountId: Long) : DataSource.Factory<Int, ConversationEntity>
|
||||
fun conversationsForAccount(accountId: Long) : PagingSource<Int, ConversationEntity>
|
||||
|
||||
@Query("DELETE FROM ConversationEntity WHERE accountId = :accountId")
|
||||
fun deleteForAccount(accountId: Long)
|
||||
|
||||
|
||||
}
|
||||
|
|
|
@ -83,6 +83,7 @@ class AppModule {
|
|||
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_24_25,
|
||||
AppDatabase.MIGRATION_26_27,
|
||||
AppDatabase.Migration25_26(appContext.getExternalFilesDir("Tusky"))
|
||||
)
|
||||
.build()
|
||||
|
|
|
@ -15,7 +15,27 @@
|
|||
|
||||
package com.keylesspalace.tusky.network
|
||||
|
||||
import com.keylesspalace.tusky.entity.*
|
||||
import com.keylesspalace.tusky.entity.AccessToken
|
||||
import com.keylesspalace.tusky.entity.Account
|
||||
import com.keylesspalace.tusky.entity.Announcement
|
||||
import com.keylesspalace.tusky.entity.AppCredentials
|
||||
import com.keylesspalace.tusky.entity.Attachment
|
||||
import com.keylesspalace.tusky.entity.Conversation
|
||||
import com.keylesspalace.tusky.entity.DeletedStatus
|
||||
import com.keylesspalace.tusky.entity.Emoji
|
||||
import com.keylesspalace.tusky.entity.Filter
|
||||
import com.keylesspalace.tusky.entity.IdentityProof
|
||||
import com.keylesspalace.tusky.entity.Instance
|
||||
import com.keylesspalace.tusky.entity.Marker
|
||||
import com.keylesspalace.tusky.entity.MastoList
|
||||
import com.keylesspalace.tusky.entity.NewStatus
|
||||
import com.keylesspalace.tusky.entity.Notification
|
||||
import com.keylesspalace.tusky.entity.Poll
|
||||
import com.keylesspalace.tusky.entity.Relationship
|
||||
import com.keylesspalace.tusky.entity.ScheduledStatus
|
||||
import com.keylesspalace.tusky.entity.SearchResult
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.entity.StatusContext
|
||||
import io.reactivex.rxjava3.core.Completable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import okhttp3.MultipartBody
|
||||
|
@ -23,8 +43,20 @@ import okhttp3.RequestBody
|
|||
import okhttp3.ResponseBody
|
||||
import retrofit2.Call
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.*
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.Field
|
||||
import retrofit2.http.FormUrlEncoded
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.HTTP
|
||||
import retrofit2.http.Header
|
||||
import retrofit2.http.Multipart
|
||||
import retrofit2.http.PATCH
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.PUT
|
||||
import retrofit2.http.Part
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
|
||||
/**
|
||||
* for documentation of the Mastodon REST API see https://docs.joinmastodon.org/api/
|
||||
|
@ -466,10 +498,15 @@ interface MastodonApi {
|
|||
): Completable
|
||||
|
||||
@GET("/api/v1/conversations")
|
||||
fun getConversations(
|
||||
suspend fun getConversations(
|
||||
@Query("max_id") maxId: String? = null,
|
||||
@Query("limit") limit: Int
|
||||
): Call<List<Conversation>>
|
||||
): List<Conversation>
|
||||
|
||||
@DELETE("/api/v1/conversations/{id}")
|
||||
suspend fun deleteConversation(
|
||||
@Path("id") conversationId: String
|
||||
)
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST("api/v1/filters")
|
||||
|
|
|
@ -22,7 +22,7 @@ import androidx.paging.PagedList
|
|||
/**
|
||||
* Data class that is necessary for a UI to show a listing and interact w/ the rest of the system
|
||||
*/
|
||||
data class BiListing<T>(
|
||||
data class BiListing<T: Any>(
|
||||
// the LiveData of paged lists for the UI to observe
|
||||
val pagedList: LiveData<PagedList<T>>,
|
||||
// represents the network request status for load data before first to show to the user
|
||||
|
|
|
@ -1,36 +0,0 @@
|
|||
/*
|
||||
* Copyright (C) 2017 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.keylesspalace.tusky.util
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.paging.PagedList
|
||||
|
||||
/**
|
||||
* Data class that is necessary for a UI to show a listing and interact w/ the rest of the system
|
||||
*/
|
||||
data class Listing<T>(
|
||||
// the LiveData of paged lists for the UI to observe
|
||||
val pagedList: LiveData<PagedList<T>>,
|
||||
// represents the network request status to show to the user
|
||||
val networkState: LiveData<NetworkState>,
|
||||
// represents the refresh status to show to the user. Separate from networkState, this
|
||||
// value is importantly only when refresh is requested.
|
||||
val refreshState: LiveData<NetworkState>,
|
||||
// refreshes the whole data and fetches it from scratch.
|
||||
val refresh: () -> Unit,
|
||||
// retries any failed requests.
|
||||
val retry: () -> Unit)
|
|
@ -0,0 +1,13 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item
|
||||
android:id="@+id/status_mute_conversation"
|
||||
android:title="@string/action_mute_conversation" />
|
||||
<item
|
||||
android:id="@+id/status_unmute_conversation"
|
||||
android:title="@string/action_unmute_conversation" />
|
||||
<item
|
||||
android:id="@+id/conversation_delete"
|
||||
android:title="@string/action_delete_conversation" />
|
||||
|
||||
</menu>
|
|
@ -88,6 +88,7 @@
|
|||
<string name="action_report">Report</string>
|
||||
<string name="action_edit">Edit</string>
|
||||
<string name="action_delete">Delete</string>
|
||||
<string name="action_delete_conversation">Delete conversation</string>
|
||||
<string name="action_delete_and_redraft">Delete and re-draft</string>
|
||||
<string name="action_send">TOOT</string>
|
||||
<string name="action_send_public">TOOT!</string>
|
||||
|
@ -200,6 +201,7 @@
|
|||
<string name="dialog_unfollow_warning">Unfollow this account?</string>
|
||||
<string name="dialog_delete_toot_warning">Delete this toot?</string>
|
||||
<string name="dialog_redraft_toot_warning">Delete and re-draft this toot?</string>
|
||||
<string name="dialog_delete_conversation_warning">Delete this conversation?</string>
|
||||
<string name="mute_domain_warning">Are you sure you want to block all of %s? You will not see content from that domain in any public timelines or in your notifications. Your followers from that domain will be removed.</string>
|
||||
<string name="mute_domain_warning_dialog_ok">Hide entire domain</string>
|
||||
<string name="dialog_block_warning">Block @%s?</string>
|
||||
|
|
Loading…
Reference in New Issue