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
32 changed files with 1612 additions and 1022 deletions
|
@ -97,6 +97,9 @@ ext.materialdrawerVersion = '8.4.1'
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
|
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.core:core-ktx:1.5.0"
|
||||||
implementation "androidx.appcompat:appcompat:1.3.0"
|
implementation "androidx.appcompat:appcompat:1.3.0"
|
||||||
implementation "androidx.fragment:fragment-ktx:1.3.4"
|
implementation "androidx.fragment:fragment-ktx:1.3.4"
|
||||||
|
@ -114,13 +117,11 @@ dependencies {
|
||||||
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion"
|
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion"
|
||||||
implementation "androidx.lifecycle:lifecycle-reactivestreams-ktx:$lifecycleVersion"
|
implementation "androidx.lifecycle:lifecycle-reactivestreams-ktx:$lifecycleVersion"
|
||||||
implementation "androidx.constraintlayout:constraintlayout:2.0.4"
|
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.viewpager2:viewpager2:1.0.0"
|
||||||
implementation "androidx.work:work-runtime:2.5.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 "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"
|
kapt "androidx.room:room-compiler:$roomVersion"
|
||||||
|
|
||||||
implementation "com.google.android.material:material:1.3.0"
|
implementation "com.google.android.material:material:1.3.0"
|
||||||
|
|
753
app/schemas/com.keylesspalace.tusky.db.AppDatabase/27.json
Normal file
753
app/schemas/com.keylesspalace.tusky.db.AppDatabase/27.json
Normal file
|
@ -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
|
package com.keylesspalace.tusky.adapter
|
||||||
|
|
||||||
|
import androidx.paging.LoadState
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import android.view.ViewGroup
|
|
||||||
import com.keylesspalace.tusky.databinding.ItemNetworkStateBinding
|
import com.keylesspalace.tusky.databinding.ItemNetworkStateBinding
|
||||||
import com.keylesspalace.tusky.util.NetworkState
|
|
||||||
import com.keylesspalace.tusky.util.Status
|
|
||||||
import com.keylesspalace.tusky.util.visible
|
import com.keylesspalace.tusky.util.visible
|
||||||
|
|
||||||
class NetworkStateViewHolder(private val binding: ItemNetworkStateBinding,
|
class NetworkStateViewHolder(private val binding: ItemNetworkStateBinding,
|
||||||
private val retryCallback: () -> Unit)
|
private val retryCallback: () -> Unit)
|
||||||
: RecyclerView.ViewHolder(binding.root) {
|
: RecyclerView.ViewHolder(binding.root) {
|
||||||
|
|
||||||
fun setUpWithNetworkState(state: NetworkState?, fullScreen: Boolean) {
|
fun setUpWithNetworkState(state: LoadState) {
|
||||||
binding.progressBar.visible(state?.status == Status.RUNNING)
|
binding.progressBar.visible(state == LoadState.Loading)
|
||||||
binding.retryButton.visible(state?.status == Status.FAILED)
|
binding.retryButton.visible(state is LoadState.Error)
|
||||||
binding.errorMsg.visible(state?.msg != null)
|
val msg = if (state is LoadState.Error) {
|
||||||
binding.errorMsg.text = state?.msg
|
state.error.message
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
binding.errorMsg.visible(msg != null)
|
||||||
|
binding.errorMsg.text = msg
|
||||||
binding.retryButton.setOnClickListener {
|
binding.retryButton.setOnClickListener {
|
||||||
retryCallback()
|
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.LayoutInflater
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.paging.AsyncPagedListDiffer
|
import androidx.paging.PagingDataAdapter
|
||||||
import androidx.paging.PagedList
|
|
||||||
import androidx.recyclerview.widget.AsyncDifferConfig
|
|
||||||
import androidx.recyclerview.widget.DiffUtil
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
import androidx.recyclerview.widget.ListUpdateCallback
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import com.keylesspalace.tusky.R
|
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.interfaces.StatusActionListener
|
||||||
import com.keylesspalace.tusky.util.NetworkState
|
|
||||||
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
||||||
|
|
||||||
class ConversationAdapter(
|
class ConversationAdapter(
|
||||||
private val statusDisplayOptions: StatusDisplayOptions,
|
private val statusDisplayOptions: StatusDisplayOptions,
|
||||||
private val listener: StatusActionListener,
|
private val listener: StatusActionListener
|
||||||
private val topLoadedCallback: () -> Unit,
|
) : PagingDataAdapter<ConversationEntity, ConversationViewHolder>(CONVERSATION_COMPARATOR) {
|
||||||
private val retryCallback: () -> Unit
|
|
||||||
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
|
||||||
|
|
||||||
private var networkState: NetworkState? = null
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ConversationViewHolder {
|
||||||
|
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_conversation, parent, false)
|
||||||
private val differ: AsyncPagedListDiffer<ConversationEntity> = AsyncPagedListDiffer(object : ListUpdateCallback {
|
return ConversationViewHolder(view, statusDisplayOptions, listener)
|
||||||
override fun onInserted(position: Int, count: Int) {
|
|
||||||
notifyItemRangeInserted(position, count)
|
|
||||||
if (position == 0) {
|
|
||||||
topLoadedCallback()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onRemoved(position: Int, count: Int) {
|
override fun onBindViewHolder(holder: ConversationViewHolder, position: Int) {
|
||||||
notifyItemRangeRemoved(position, count)
|
holder.setupWithConversation(getItem(position))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onMoved(fromPosition: Int, toPosition: Int) {
|
fun item(position: Int): ConversationEntity? {
|
||||||
notifyItemMoved(fromPosition, toPosition)
|
return getItem(position)
|
||||||
}
|
|
||||||
|
|
||||||
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): 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: 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
val CONVERSATION_COMPARATOR = object : DiffUtil.ItemCallback<ConversationEntity>() {
|
val CONVERSATION_COMPARATOR = object : DiffUtil.ItemCallback<ConversationEntity>() {
|
||||||
override fun areContentsTheSame(oldItem: ConversationEntity, newItem: ConversationEntity): Boolean =
|
override fun areItemsTheSame(oldItem: ConversationEntity, newItem: ConversationEntity): Boolean {
|
||||||
oldItem == newItem
|
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.
|
* This file is a part of Tusky.
|
||||||
*
|
*
|
||||||
|
@ -21,9 +21,14 @@ import androidx.room.Embedded
|
||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
import androidx.room.TypeConverters
|
import androidx.room.TypeConverters
|
||||||
import com.keylesspalace.tusky.db.Converters
|
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 com.keylesspalace.tusky.util.shouldTrimStatus
|
||||||
import java.util.*
|
import java.util.Date
|
||||||
|
|
||||||
@Entity(primaryKeys = ["id","accountId"])
|
@Entity(primaryKeys = ["id","accountId"])
|
||||||
@TypeConverters(Converters::class)
|
@TypeConverters(Converters::class)
|
||||||
|
@ -78,8 +83,8 @@ data class ConversationStatusEntity(
|
||||||
val expanded: Boolean,
|
val expanded: Boolean,
|
||||||
val collapsible: Boolean,
|
val collapsible: Boolean,
|
||||||
val collapsed: Boolean,
|
val collapsed: Boolean,
|
||||||
|
val muted: Boolean,
|
||||||
val poll: Poll?
|
val poll: Poll?
|
||||||
|
|
||||||
) {
|
) {
|
||||||
/** its necessary to override this because Spanned.equals does not work as expected */
|
/** its necessary to override this because Spanned.equals does not work as expected */
|
||||||
override fun equals(other: Any?): Boolean {
|
override fun equals(other: Any?): Boolean {
|
||||||
|
@ -106,6 +111,7 @@ data class ConversationStatusEntity(
|
||||||
if (expanded != other.expanded) return false
|
if (expanded != other.expanded) return false
|
||||||
if (collapsible != other.collapsible) return false
|
if (collapsible != other.collapsible) return false
|
||||||
if (collapsed != other.collapsed) return false
|
if (collapsed != other.collapsed) return false
|
||||||
|
if (muted != other.muted) return false
|
||||||
if (poll != other.poll) return false
|
if (poll != other.poll) return false
|
||||||
|
|
||||||
return true
|
return true
|
||||||
|
@ -130,6 +136,7 @@ data class ConversationStatusEntity(
|
||||||
result = 31 * result + expanded.hashCode()
|
result = 31 * result + expanded.hashCode()
|
||||||
result = 31 * result + collapsible.hashCode()
|
result = 31 * result + collapsible.hashCode()
|
||||||
result = 31 * result + collapsed.hashCode()
|
result = 31 * result + collapsed.hashCode()
|
||||||
|
result = 31 * result + muted.hashCode()
|
||||||
result = 31 * result + poll.hashCode()
|
result = 31 * result + poll.hashCode()
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
@ -157,7 +164,7 @@ data class ConversationStatusEntity(
|
||||||
mentions = mentions,
|
mentions = mentions,
|
||||||
application = null,
|
application = null,
|
||||||
pinned = false,
|
pinned = false,
|
||||||
muted = false,
|
muted = muted,
|
||||||
poll = poll,
|
poll = poll,
|
||||||
card = null)
|
card = null)
|
||||||
}
|
}
|
||||||
|
@ -165,31 +172,43 @@ data class ConversationStatusEntity(
|
||||||
|
|
||||||
fun Account.toEntity() =
|
fun Account.toEntity() =
|
||||||
ConversationAccountEntity(
|
ConversationAccountEntity(
|
||||||
id,
|
id = id,
|
||||||
username,
|
username = username,
|
||||||
name,
|
displayName = name,
|
||||||
avatar,
|
avatar = avatar,
|
||||||
emojis ?: emptyList()
|
emojis = emojis ?: emptyList()
|
||||||
)
|
)
|
||||||
|
|
||||||
fun Status.toEntity() =
|
fun Status.toEntity() =
|
||||||
ConversationStatusEntity(
|
ConversationStatusEntity(
|
||||||
id, url, inReplyToId, inReplyToAccountId, account.toEntity(), content,
|
id = id,
|
||||||
createdAt, emojis, favouritesCount, favourited, bookmarked, sensitive,
|
url = url,
|
||||||
spoilerText, attachments, mentions,
|
inReplyToId = inReplyToId,
|
||||||
false,
|
inReplyToAccountId = inReplyToAccountId,
|
||||||
false,
|
account = account.toEntity(),
|
||||||
shouldTrimStatus(content),
|
content = content,
|
||||||
true,
|
createdAt = createdAt,
|
||||||
poll
|
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) =
|
fun Conversation.toEntity(accountId: Long) =
|
||||||
ConversationEntity(
|
ConversationEntity(
|
||||||
accountId,
|
accountId = accountId,
|
||||||
id,
|
id = id,
|
||||||
accounts.map { it.toEntity() },
|
accounts = accounts.map { it.toEntity() },
|
||||||
unread,
|
unread = unread,
|
||||||
lastStatus!!.toEntity()
|
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.
|
* This file is a part of Tusky.
|
||||||
*
|
*
|
||||||
|
@ -20,7 +20,12 @@ import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.appcompat.widget.PopupMenu
|
||||||
import androidx.fragment.app.viewModels
|
import androidx.fragment.app.viewModels
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.paging.ExperimentalPagingApi
|
||||||
|
import androidx.paging.LoadState
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import androidx.recyclerview.widget.DividerItemDecoration
|
import androidx.recyclerview.widget.DividerItemDecoration
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
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.ReselectableFragment
|
||||||
import com.keylesspalace.tusky.interfaces.StatusActionListener
|
import com.keylesspalace.tusky.interfaces.StatusActionListener
|
||||||
import com.keylesspalace.tusky.settings.PrefKeys
|
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.CardViewMode
|
||||||
import com.keylesspalace.tusky.util.NetworkState
|
|
||||||
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
||||||
import com.keylesspalace.tusky.util.hide
|
import com.keylesspalace.tusky.util.hide
|
||||||
import com.keylesspalace.tusky.util.viewBinding
|
import com.keylesspalace.tusky.util.viewBinding
|
||||||
|
@ -53,13 +61,17 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
|
||||||
private val binding by viewBinding(FragmentTimelineBinding::bind)
|
private val binding by viewBinding(FragmentTimelineBinding::bind)
|
||||||
|
|
||||||
private lateinit var adapter: ConversationAdapter
|
private lateinit var adapter: ConversationAdapter
|
||||||
|
private lateinit var loadStateAdapter: ConversationLoadStateAdapter
|
||||||
|
|
||||||
private var layoutManager: LinearLayoutManager? = null
|
private var layoutManager: LinearLayoutManager? = null
|
||||||
|
|
||||||
|
private var initialRefreshDone: Boolean = false
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||||
return inflater.inflate(R.layout.fragment_timeline, container, false)
|
return inflater.inflate(R.layout.fragment_timeline, container, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ExperimentalPagingApi
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
val preferences = PreferenceManager.getDefaultSharedPreferences(view.context)
|
val preferences = PreferenceManager.getDefaultSharedPreferences(view.context)
|
||||||
|
|
||||||
|
@ -75,12 +87,13 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
|
||||||
animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, 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))
|
binding.recyclerView.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL))
|
||||||
layoutManager = LinearLayoutManager(view.context)
|
layoutManager = LinearLayoutManager(view.context)
|
||||||
binding.recyclerView.layoutManager = layoutManager
|
binding.recyclerView.layoutManager = layoutManager
|
||||||
binding.recyclerView.adapter = adapter
|
binding.recyclerView.adapter = adapter.withLoadStateFooter(loadStateAdapter)
|
||||||
(binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
|
(binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
|
||||||
|
|
||||||
binding.progressBar.hide()
|
binding.progressBar.hide()
|
||||||
|
@ -88,59 +101,101 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
|
||||||
|
|
||||||
initSwipeToRefresh()
|
initSwipeToRefresh()
|
||||||
|
|
||||||
viewModel.conversations.observe(viewLifecycleOwner) {
|
lifecycleScope.launch {
|
||||||
adapter.submitList(it)
|
viewModel.conversationFlow.collectLatest { pagingData ->
|
||||||
|
adapter.submitData(pagingData)
|
||||||
}
|
}
|
||||||
viewModel.networkState.observe(viewLifecycleOwner) {
|
|
||||||
adapter.setNetworkState(it)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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() {
|
private fun initSwipeToRefresh() {
|
||||||
viewModel.refreshState.observe(viewLifecycleOwner) {
|
|
||||||
binding.swipeRefreshLayout.isRefreshing = it == NetworkState.LOADING
|
|
||||||
}
|
|
||||||
binding.swipeRefreshLayout.setOnRefreshListener {
|
binding.swipeRefreshLayout.setOnRefreshListener {
|
||||||
viewModel.refresh()
|
adapter.refresh()
|
||||||
}
|
}
|
||||||
binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
|
binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onTopLoaded() {
|
|
||||||
binding.recyclerView.scrollToPosition(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onReblog(reblog: Boolean, position: Int) {
|
override fun onReblog(reblog: Boolean, position: Int) {
|
||||||
// its impossible to reblog private messages
|
// its impossible to reblog private messages
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onFavourite(favourite: Boolean, position: Int) {
|
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) {
|
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) {
|
override fun onMore(view: View, position: Int) {
|
||||||
viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let {
|
adapter.item(position)?.let { conversation ->
|
||||||
more(it.toStatus(), view, position)
|
|
||||||
|
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?) {
|
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
|
||||||
viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let {
|
adapter.item(position)?.let { conversation ->
|
||||||
viewMedia(attachmentIndex, AttachmentViewData.list(it.toStatus()), view)
|
viewMedia(attachmentIndex, AttachmentViewData.list(conversation.lastStatus.toStatus()), view)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewThread(position: Int) {
|
override fun onViewThread(position: Int) {
|
||||||
viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let {
|
adapter.item(position)?.let { conversation ->
|
||||||
val status = it.toStatus()
|
viewThread(conversation.lastStatus.id, conversation.lastStatus.url)
|
||||||
viewThread(status.actionableId, status.actionableStatus.url)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -149,11 +204,15 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onExpandedChange(expanded: Boolean, position: Int) {
|
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) {
|
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) {
|
override fun onLoadMore(position: Int) {
|
||||||
|
@ -161,7 +220,9 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) {
|
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) {
|
override fun onViewAccount(id: String) {
|
||||||
|
@ -176,15 +237,25 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun removeItem(position: Int) {
|
override fun removeItem(position: Int) {
|
||||||
viewModel.remove(position)
|
// not needed
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onReply(position: Int) {
|
override fun onReply(position: Int) {
|
||||||
viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let {
|
adapter.item(position)?.let { conversation ->
|
||||||
reply(it.toStatus())
|
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() {
|
private fun jumpToTop() {
|
||||||
if (isAdded) {
|
if (isAdded) {
|
||||||
layoutManager?.scrollToPosition(0)
|
layoutManager?.scrollToPosition(0)
|
||||||
|
@ -197,7 +268,9 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onVoteInPoll(position: Int, choices: MutableList<Int>) {
|
override fun onVoteInPoll(position: Int, choices: MutableList<Int>) {
|
||||||
viewModel.voteInPoll(position, choices)
|
adapter.item(position)?.let { conversation ->
|
||||||
|
viewModel.voteInPoll(choices, conversation)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
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
|
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.db.AppDatabase
|
||||||
import com.keylesspalace.tusky.entity.Conversation
|
|
||||||
import com.keylesspalace.tusky.network.MastodonApi
|
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.core.Single
|
||||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
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.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
class ConversationsRepository @Inject constructor(val mastodonApi: MastodonApi, val db: AppDatabase) {
|
class ConversationsRepository @Inject constructor(
|
||||||
|
val mastodonApi: MastodonApi,
|
||||||
private val ioExecutor = Executors.newSingleThreadExecutor()
|
val db: AppDatabase
|
||||||
|
) {
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun deleteCacheForAccount(accountId: Long) {
|
fun deleteCacheForAccount(accountId: Long) {
|
||||||
Single.fromCallable {
|
Single.fromCallable {
|
||||||
|
@ -102,10 +35,4 @@ class ConversationsRepository @Inject constructor(val mastodonApi: MastodonApi,
|
||||||
.subscribe()
|
.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
|
package com.keylesspalace.tusky.components.conversation
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.lifecycle.LiveData
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.paging.ExperimentalPagingApi
|
||||||
import androidx.lifecycle.Transformations
|
import androidx.paging.Pager
|
||||||
import androidx.paging.PagedList
|
import androidx.paging.PagingConfig
|
||||||
|
import androidx.paging.cachedIn
|
||||||
import com.keylesspalace.tusky.db.AccountManager
|
import com.keylesspalace.tusky.db.AccountManager
|
||||||
import com.keylesspalace.tusky.db.AppDatabase
|
import com.keylesspalace.tusky.db.AppDatabase
|
||||||
|
import com.keylesspalace.tusky.network.MastodonApi
|
||||||
import com.keylesspalace.tusky.network.TimelineCases
|
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 com.keylesspalace.tusky.util.RxAwareViewModel
|
||||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.rx3.await
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class ConversationsViewModel @Inject constructor(
|
class ConversationsViewModel @Inject constructor(
|
||||||
private val repository: ConversationsRepository,
|
|
||||||
private val timelineCases: TimelineCases,
|
private val timelineCases: TimelineCases,
|
||||||
private val database: AppDatabase,
|
private val database: AppDatabase,
|
||||||
private val accountManager: AccountManager
|
private val accountManager: AccountManager,
|
||||||
|
private val api: MastodonApi
|
||||||
) : RxAwareViewModel() {
|
) : 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>> =
|
fun favourite(favourite: Boolean, conversation: ConversationEntity) {
|
||||||
Transformations.switchMap(repoResult) { it.pagedList }
|
viewModelScope.launch {
|
||||||
val networkState: LiveData<NetworkState> =
|
try {
|
||||||
Transformations.switchMap(repoResult) { it.networkState }
|
timelineCases.favourite(conversation.lastStatus.id, favourite).await()
|
||||||
val refreshState: LiveData<NetworkState> =
|
|
||||||
Transformations.switchMap(repoResult) { it.refreshState }
|
|
||||||
|
|
||||||
fun load() {
|
|
||||||
val accountId = accountManager.activeAccount?.id ?: return
|
|
||||||
if (repoResult.value == null) {
|
|
||||||
repository.refresh(accountId, false)
|
|
||||||
}
|
|
||||||
repoResult.value = repository.conversations(accountId)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun refresh() {
|
|
||||||
repoResult.value?.refresh?.invoke()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun retry() {
|
|
||||||
repoResult.value?.retry?.invoke()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun favourite(favourite: Boolean, position: Int) {
|
|
||||||
conversations.value?.getOrNull(position)?.let { conversation ->
|
|
||||||
timelineCases.favourite(conversation.lastStatus.id, favourite)
|
|
||||||
.flatMap {
|
|
||||||
val newConversation = conversation.copy(
|
val newConversation = conversation.copy(
|
||||||
lastStatus = conversation.lastStatus.copy(favourited = favourite)
|
lastStatus = conversation.lastStatus.copy(favourited = favourite)
|
||||||
)
|
)
|
||||||
|
|
||||||
database.conversationDao().insert(newConversation)
|
database.conversationDao().insert(newConversation)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "failed to favourite status", e)
|
||||||
}
|
}
|
||||||
.subscribeOn(Schedulers.io())
|
|
||||||
.doOnError { t ->
|
|
||||||
Log.w(
|
|
||||||
"ConversationViewModel",
|
|
||||||
"Failed to favourite conversation",
|
|
||||||
t
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
.onErrorReturnItem(0)
|
|
||||||
.subscribe()
|
|
||||||
.autoDispose()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
fun bookmark(bookmark: Boolean, conversation: ConversationEntity) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
timelineCases.bookmark(conversation.lastStatus.id, bookmark).await()
|
||||||
|
|
||||||
fun bookmark(bookmark: Boolean, position: Int) {
|
|
||||||
conversations.value?.getOrNull(position)?.let { conversation ->
|
|
||||||
timelineCases.bookmark(conversation.lastStatus.id, bookmark)
|
|
||||||
.flatMap {
|
|
||||||
val newConversation = conversation.copy(
|
val newConversation = conversation.copy(
|
||||||
lastStatus = conversation.lastStatus.copy(bookmarked = bookmark)
|
lastStatus = conversation.lastStatus.copy(bookmarked = bookmark)
|
||||||
)
|
)
|
||||||
|
|
||||||
database.conversationDao().insert(newConversation)
|
database.conversationDao().insert(newConversation)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "failed to bookmark status", e)
|
||||||
}
|
}
|
||||||
.subscribeOn(Schedulers.io())
|
|
||||||
.doOnError { t ->
|
|
||||||
Log.w(
|
|
||||||
"ConversationViewModel",
|
|
||||||
"Failed to bookmark conversation",
|
|
||||||
t
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
.onErrorReturnItem(0)
|
|
||||||
.subscribe()
|
|
||||||
.autoDispose()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
fun voteInPoll(choices: List<Int>, conversation: ConversationEntity) {
|
||||||
|
viewModelScope.launch {
|
||||||
fun voteInPoll(position: Int, choices: MutableList<Int>) {
|
try {
|
||||||
conversations.value?.getOrNull(position)?.let { conversation ->
|
val poll = timelineCases.voteInPoll(conversation.lastStatus.id, conversation.lastStatus.poll?.id!!, choices).await()
|
||||||
val poll = conversation.lastStatus.poll ?: return
|
|
||||||
timelineCases.voteInPoll(conversation.lastStatus.id, poll.id, choices)
|
|
||||||
.flatMap { newPoll ->
|
|
||||||
val newConversation = conversation.copy(
|
val newConversation = conversation.copy(
|
||||||
lastStatus = conversation.lastStatus.copy(poll = newPoll)
|
lastStatus = conversation.lastStatus.copy(poll = poll)
|
||||||
)
|
)
|
||||||
|
|
||||||
database.conversationDao().insert(newConversation)
|
database.conversationDao().insert(newConversation)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "failed to vote in poll", e)
|
||||||
}
|
}
|
||||||
.subscribeOn(Schedulers.io())
|
|
||||||
.doOnError { t ->
|
|
||||||
Log.w(
|
|
||||||
"ConversationViewModel",
|
|
||||||
"Failed to favourite conversation",
|
|
||||||
t
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
.onErrorReturnItem(0)
|
|
||||||
.subscribe()
|
|
||||||
.autoDispose()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
fun expandHiddenStatus(expanded: Boolean, conversation: ConversationEntity) {
|
||||||
|
viewModelScope.launch {
|
||||||
fun expandHiddenStatus(expanded: Boolean, position: Int) {
|
|
||||||
conversations.value?.getOrNull(position)?.let { conversation ->
|
|
||||||
val newConversation = conversation.copy(
|
val newConversation = conversation.copy(
|
||||||
lastStatus = conversation.lastStatus.copy(expanded = expanded)
|
lastStatus = conversation.lastStatus.copy(expanded = expanded)
|
||||||
)
|
)
|
||||||
|
@ -131,8 +102,8 @@ class ConversationsViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun collapseLongStatus(collapsed: Boolean, position: Int) {
|
fun collapseLongStatus(collapsed: Boolean, conversation: ConversationEntity) {
|
||||||
conversations.value?.getOrNull(position)?.let { conversation ->
|
viewModelScope.launch {
|
||||||
val newConversation = conversation.copy(
|
val newConversation = conversation.copy(
|
||||||
lastStatus = conversation.lastStatus.copy(collapsed = collapsed)
|
lastStatus = conversation.lastStatus.copy(collapsed = collapsed)
|
||||||
)
|
)
|
||||||
|
@ -140,8 +111,8 @@ class ConversationsViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showContent(showing: Boolean, position: Int) {
|
fun showContent(showing: Boolean, conversation: ConversationEntity) {
|
||||||
conversations.value?.getOrNull(position)?.let { conversation ->
|
viewModelScope.launch {
|
||||||
val newConversation = conversation.copy(
|
val newConversation = conversation.copy(
|
||||||
lastStatus = conversation.lastStatus.copy(showingHiddenContent = showing)
|
lastStatus = conversation.lastStatus.copy(showingHiddenContent = showing)
|
||||||
)
|
)
|
||||||
|
@ -149,16 +120,42 @@ class ConversationsViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun remove(position: Int) {
|
fun remove(conversation: ConversationEntity) {
|
||||||
conversations.value?.getOrNull(position)?.let {
|
viewModelScope.launch {
|
||||||
refresh()
|
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) {
|
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)
|
database.conversationDao().insert(conversation)
|
||||||
.subscribeOn(Schedulers.io())
|
|
||||||
.subscribe()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
package com.keylesspalace.tusky.components.search
|
||||||
|
|
||||||
enum class SearchType(val apiParameter: String) {
|
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
|
package com.keylesspalace.tusky.components.search
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.lifecycle.LiveData
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.paging.Pager
|
||||||
import androidx.paging.PagedList
|
import androidx.paging.PagingConfig
|
||||||
import com.keylesspalace.tusky.components.search.adapter.SearchRepository
|
import androidx.paging.cachedIn
|
||||||
|
import com.keylesspalace.tusky.components.search.adapter.SearchPagingSourceFactory
|
||||||
import com.keylesspalace.tusky.db.AccountEntity
|
import com.keylesspalace.tusky.db.AccountEntity
|
||||||
import com.keylesspalace.tusky.db.AccountManager
|
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.entity.Status
|
||||||
import com.keylesspalace.tusky.network.MastodonApi
|
import com.keylesspalace.tusky.network.MastodonApi
|
||||||
import com.keylesspalace.tusky.network.TimelineCases
|
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 com.keylesspalace.tusky.viewdata.StatusViewData
|
||||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||||
import io.reactivex.rxjava3.core.Single
|
import io.reactivex.rxjava3.core.Single
|
||||||
|
@ -35,82 +53,62 @@ class SearchViewModel @Inject constructor(
|
||||||
val alwaysShowSensitiveMedia = activeAccount?.alwaysShowSensitiveMedia ?: false
|
val alwaysShowSensitiveMedia = activeAccount?.alwaysShowSensitiveMedia ?: false
|
||||||
val alwaysOpenSpoiler = activeAccount?.alwaysOpenSpoiler ?: false
|
val alwaysOpenSpoiler = activeAccount?.alwaysOpenSpoiler ?: false
|
||||||
|
|
||||||
private val statusesRepository =
|
private val loadedStatuses: MutableList<Pair<Status, StatusViewData.Concrete>> = mutableListOf()
|
||||||
SearchRepository<Pair<Status, StatusViewData.Concrete>>(mastodonApi)
|
|
||||||
private val accountsRepository = SearchRepository<Account>(mastodonApi)
|
|
||||||
private val hashtagsRepository = SearchRepository<HashTag>(mastodonApi)
|
|
||||||
|
|
||||||
private val repoResultStatus = MutableLiveData<Listing<Pair<Status, StatusViewData.Concrete>>>()
|
private val statusesPagingSourceFactory = SearchPagingSourceFactory(mastodonApi, SearchType.Status, loadedStatuses) {
|
||||||
val statuses: LiveData<PagedList<Pair<Status, StatusViewData.Concrete>>> =
|
it.statuses.map { status -> Pair(status, status.toViewData(alwaysShowSensitiveMedia, alwaysOpenSpoiler)) }
|
||||||
repoResultStatus.switchMap { it.pagedList }
|
|
||||||
val networkStateStatus: LiveData<NetworkState> = repoResultStatus.switchMap { it.networkState }
|
|
||||||
val networkStateStatusRefresh: LiveData<NetworkState> =
|
|
||||||
repoResultStatus.switchMap { it.refreshState }
|
|
||||||
|
|
||||||
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 }
|
|
||||||
|
|
||||||
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 }
|
|
||||||
|
|
||||||
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 {
|
.apply {
|
||||||
loadedStatuses.addAll(this)
|
loadedStatuses.addAll(this)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
repoResultAccount.value =
|
private val accountsPagingSourceFactory = SearchPagingSourceFactory(mastodonApi, SearchType.Account) {
|
||||||
accountsRepository.getSearchData(SearchType.Account, query, disposables) {
|
it.accounts
|
||||||
it?.accounts.orEmpty()
|
|
||||||
}
|
}
|
||||||
val hashtagQuery = if (query.startsWith("#")) query else "#$query"
|
private val hashtagsPagingSourceFactory = SearchPagingSourceFactory(mastodonApi, SearchType.Hashtag) {
|
||||||
repoResultHashTag.value =
|
it.hashtags
|
||||||
hashtagsRepository.getSearchData(SearchType.Hashtag, hashtagQuery, disposables) {
|
|
||||||
it?.hashtags.orEmpty()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val statusesFlow = Pager(
|
||||||
|
config = PagingConfig(pageSize = DEFAULT_LOAD_SIZE, initialLoadSize = DEFAULT_LOAD_SIZE),
|
||||||
|
pagingSourceFactory = statusesPagingSourceFactory
|
||||||
|
).flow
|
||||||
|
.cachedIn(viewModelScope)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
fun search(query: String) {
|
||||||
|
loadedStatuses.clear()
|
||||||
|
statusesPagingSourceFactory.newSearch(query)
|
||||||
|
accountsPagingSourceFactory.newSearch(query)
|
||||||
|
hashtagsPagingSourceFactory.newSearch(query)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeItem(status: Pair<Status, StatusViewData.Concrete>) {
|
fun removeItem(status: Pair<Status, StatusViewData.Concrete>) {
|
||||||
timelineCases.delete(status.first.id)
|
timelineCases.delete(status.first.id)
|
||||||
.subscribe({
|
.subscribe({
|
||||||
if (loadedStatuses.remove(status))
|
if (loadedStatuses.remove(status))
|
||||||
repoResultStatus.value?.refresh?.invoke()
|
statusesPagingSourceFactory.invalidate()
|
||||||
}, { err ->
|
}, {
|
||||||
Log.d(TAG, "Failed to delete status", err)
|
err -> Log.d(TAG, "Failed to delete status", err)
|
||||||
})
|
})
|
||||||
.autoDispose()
|
.autoDispose()
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun expandedChange(status: Pair<Status, StatusViewData.Concrete>, expanded: Boolean) {
|
fun expandedChange(status: Pair<Status, StatusViewData.Concrete>, expanded: Boolean) {
|
||||||
val idx = loadedStatuses.indexOf(status)
|
val idx = loadedStatuses.indexOf(status)
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
val newPair = Pair(status.first, status.second.copy(isExpanded = expanded))
|
loadedStatuses[idx] = Pair(status.first, status.second.copy(isExpanded = expanded))
|
||||||
loadedStatuses[idx] = newPair
|
statusesPagingSourceFactory.invalidate()
|
||||||
repoResultStatus.value?.refresh?.invoke()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -119,36 +117,30 @@ class SearchViewModel @Inject constructor(
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
.subscribe(
|
.subscribe(
|
||||||
{ setRebloggedForStatus(status, reblog) },
|
{ 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()
|
.autoDispose()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setRebloggedForStatus(
|
private fun setRebloggedForStatus(status: Pair<Status, StatusViewData.Concrete>, reblog: Boolean) {
|
||||||
status: Pair<Status, StatusViewData.Concrete>,
|
|
||||||
reblog: Boolean
|
|
||||||
) {
|
|
||||||
status.first.reblogged = reblog
|
status.first.reblogged = reblog
|
||||||
status.first.reblog?.reblogged = reblog
|
status.first.reblog?.reblogged = reblog
|
||||||
|
statusesPagingSourceFactory.invalidate()
|
||||||
repoResultStatus.value?.refresh?.invoke()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun contentHiddenChange(status: Pair<Status, StatusViewData.Concrete>, isShowing: Boolean) {
|
fun contentHiddenChange(status: Pair<Status, StatusViewData.Concrete>, isShowing: Boolean) {
|
||||||
val idx = loadedStatuses.indexOf(status)
|
val idx = loadedStatuses.indexOf(status)
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
val newPair = Pair(status.first, status.second.copy(isShowingContent = isShowing))
|
loadedStatuses[idx] = Pair(status.first, status.second.copy(isShowingContent = isShowing))
|
||||||
loadedStatuses[idx] = newPair
|
statusesPagingSourceFactory.invalidate()
|
||||||
repoResultStatus.value?.refresh?.invoke()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun collapsedChange(status: Pair<Status, StatusViewData.Concrete>, collapsed: Boolean) {
|
fun collapsedChange(status: Pair<Status, StatusViewData.Concrete>, collapsed: Boolean) {
|
||||||
val idx = loadedStatuses.indexOf(status)
|
val idx = loadedStatuses.indexOf(status)
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
val newPair = Pair(status.first, status.second.copy(isCollapsed = collapsed))
|
loadedStatuses[idx] = Pair(status.first, status.second.copy(isCollapsed = collapsed))
|
||||||
loadedStatuses[idx] = newPair
|
statusesPagingSourceFactory.invalidate()
|
||||||
repoResultStatus.value?.refresh?.invoke()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -159,12 +151,7 @@ class SearchViewModel @Inject constructor(
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
.subscribe(
|
.subscribe(
|
||||||
{ newPoll -> updateStatus(status, newPoll) },
|
{ newPoll -> updateStatus(status, newPoll) },
|
||||||
{ t ->
|
{ t -> Log.d(TAG, "Failed to vote in poll: ${status.first.id}", t) }
|
||||||
Log.d(
|
|
||||||
TAG,
|
|
||||||
"Failed to vote in poll: ${status.first.id}", t
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
.autoDispose()
|
.autoDispose()
|
||||||
}
|
}
|
||||||
|
@ -175,13 +162,13 @@ class SearchViewModel @Inject constructor(
|
||||||
val newStatus = status.first.copy(poll = newPoll)
|
val newStatus = status.first.copy(poll = newPoll)
|
||||||
val newViewData = status.second.copy(status = newStatus)
|
val newViewData = status.second.copy(status = newStatus)
|
||||||
loadedStatuses[idx] = Pair(newStatus, newViewData)
|
loadedStatuses[idx] = Pair(newStatus, newViewData)
|
||||||
repoResultStatus.value?.refresh?.invoke()
|
statusesPagingSourceFactory.invalidate()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun favorite(status: Pair<Status, StatusViewData.Concrete>, isFavorited: Boolean) {
|
fun favorite(status: Pair<Status, StatusViewData.Concrete>, isFavorited: Boolean) {
|
||||||
status.first.favourited = isFavorited
|
status.first.favourited = isFavorited
|
||||||
repoResultStatus.value?.refresh?.invoke()
|
statusesPagingSourceFactory.invalidate()
|
||||||
timelineCases.favourite(status.first.id, isFavorited)
|
timelineCases.favourite(status.first.id, isFavorited)
|
||||||
.onErrorReturnItem(status.first)
|
.onErrorReturnItem(status.first)
|
||||||
.subscribe()
|
.subscribe()
|
||||||
|
@ -190,7 +177,7 @@ class SearchViewModel @Inject constructor(
|
||||||
|
|
||||||
fun bookmark(status: Pair<Status, StatusViewData.Concrete>, isBookmarked: Boolean) {
|
fun bookmark(status: Pair<Status, StatusViewData.Concrete>, isBookmarked: Boolean) {
|
||||||
status.first.bookmarked = isBookmarked
|
status.first.bookmarked = isBookmarked
|
||||||
repoResultStatus.value?.refresh?.invoke()
|
statusesPagingSourceFactory.invalidate()
|
||||||
timelineCases.bookmark(status.first.id, isBookmarked)
|
timelineCases.bookmark(status.first.id, isBookmarked)
|
||||||
.onErrorReturnItem(status.first)
|
.onErrorReturnItem(status.first)
|
||||||
.subscribe()
|
.subscribe()
|
||||||
|
@ -217,10 +204,6 @@ class SearchViewModel @Inject constructor(
|
||||||
return timelineCases.delete(id)
|
return timelineCases.delete(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun retryAllSearches() {
|
|
||||||
search(currentQuery)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun muteConversation(status: Pair<Status, StatusViewData.Concrete>, mute: Boolean) {
|
fun muteConversation(status: Pair<Status, StatusViewData.Concrete>, mute: Boolean) {
|
||||||
val idx = loadedStatuses.indexOf(status)
|
val idx = loadedStatuses.indexOf(status)
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
|
@ -230,7 +213,7 @@ class SearchViewModel @Inject constructor(
|
||||||
status.second.copy(status = newStatus)
|
status.second.copy(status = newStatus)
|
||||||
)
|
)
|
||||||
loadedStatuses[idx] = newPair
|
loadedStatuses[idx] = newPair
|
||||||
repoResultStatus.value?.refresh?.invoke()
|
statusesPagingSourceFactory.invalidate()
|
||||||
}
|
}
|
||||||
timelineCases.muteConversation(status.first.id, mute)
|
timelineCases.muteConversation(status.first.id, mute)
|
||||||
.onErrorReturnItem(status.first)
|
.onErrorReturnItem(status.first)
|
||||||
|
@ -240,5 +223,6 @@ class SearchViewModel @Inject constructor(
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "SearchViewModel"
|
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.
|
* 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.LayoutInflater
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.paging.PagedListAdapter
|
import androidx.paging.PagingDataAdapter
|
||||||
import androidx.recyclerview.widget.DiffUtil
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import com.keylesspalace.tusky.R
|
import com.keylesspalace.tusky.R
|
||||||
import com.keylesspalace.tusky.adapter.AccountViewHolder
|
import com.keylesspalace.tusky.adapter.AccountViewHolder
|
||||||
import com.keylesspalace.tusky.entity.Account
|
import com.keylesspalace.tusky.entity.Account
|
||||||
import com.keylesspalace.tusky.interfaces.LinkListener
|
import com.keylesspalace.tusky.interfaces.LinkListener
|
||||||
|
|
||||||
class SearchAccountsAdapter(private val linkListener: LinkListener, private val animateAvatars: Boolean, private val animateEmojis: Boolean)
|
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)
|
val view = LayoutInflater.from(parent.context)
|
||||||
.inflate(R.layout.item_account, parent, false)
|
.inflate(R.layout.item_account, parent, false)
|
||||||
return AccountViewHolder(view)
|
return AccountViewHolder(view)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
override fun onBindViewHolder(holder: AccountViewHolder, position: Int) {
|
||||||
getItem(position)?.let { item ->
|
getItem(position)?.let { item ->
|
||||||
(holder as AccountViewHolder).apply {
|
holder.apply {
|
||||||
setupWithAccount(item, animateAvatars, animateEmojis)
|
setupWithAccount(item, animateAvatars, animateEmojis)
|
||||||
setupLinkListener(linkListener)
|
setupLinkListener(linkListener)
|
||||||
}
|
}
|
||||||
|
@ -52,7 +51,5 @@ class SearchAccountsAdapter(private val linkListener: LinkListener, private val
|
||||||
override fun areItemsTheSame(oldItem: Account, newItem: Account): Boolean =
|
override fun areItemsTheSame(oldItem: Account, newItem: Account): Boolean =
|
||||||
oldItem.id == newItem.id
|
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.
|
* 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.LayoutInflater
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.paging.PagedListAdapter
|
import androidx.paging.PagingDataAdapter
|
||||||
import androidx.recyclerview.widget.DiffUtil
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
import com.keylesspalace.tusky.databinding.ItemHashtagBinding
|
import com.keylesspalace.tusky.databinding.ItemHashtagBinding
|
||||||
import com.keylesspalace.tusky.entity.HashTag
|
import com.keylesspalace.tusky.entity.HashTag
|
||||||
|
@ -25,7 +25,7 @@ import com.keylesspalace.tusky.interfaces.LinkListener
|
||||||
import com.keylesspalace.tusky.util.BindingHolder
|
import com.keylesspalace.tusky.util.BindingHolder
|
||||||
|
|
||||||
class SearchHashtagsAdapter(private val linkListener: LinkListener)
|
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> {
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemHashtagBinding> {
|
||||||
val binding = ItemHashtagBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
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 =
|
override fun areItemsTheSame(oldItem: HashTag, newItem: HashTag): Boolean =
|
||||||
oldItem.name == newItem.name
|
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.
|
* This file is a part of Tusky.
|
||||||
*
|
*
|
||||||
|
@ -15,30 +15,39 @@
|
||||||
|
|
||||||
package com.keylesspalace.tusky.components.search.adapter
|
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.components.search.SearchType
|
||||||
import com.keylesspalace.tusky.entity.SearchResult
|
import com.keylesspalace.tusky.entity.SearchResult
|
||||||
import com.keylesspalace.tusky.network.MastodonApi
|
import com.keylesspalace.tusky.network.MastodonApi
|
||||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
|
||||||
import java.util.concurrent.Executor
|
|
||||||
|
|
||||||
class SearchDataSourceFactory<T>(
|
class SearchPagingSourceFactory<T : Any>(
|
||||||
private val mastodonApi: MastodonApi,
|
private val mastodonApi: MastodonApi,
|
||||||
private val searchType: SearchType,
|
private val searchType: SearchType,
|
||||||
private val searchRequest: String,
|
private val initialItems: List<T>? = null,
|
||||||
private val disposables: CompositeDisposable,
|
private val parser: (SearchResult) -> List<T>
|
||||||
private val retryExecutor: Executor,
|
) : () -> SearchPagingSource<T> {
|
||||||
private val cacheData: List<T>? = null,
|
|
||||||
private val parser: (SearchResult?) -> List<T>) : DataSource.Factory<Int, 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> {
|
override fun invoke(): SearchPagingSource<T> {
|
||||||
val source = SearchDataSource(mastodonApi, searchType, searchRequest, disposables, retryExecutor, cacheData, parser, this)
|
return SearchPagingSource(
|
||||||
sourceLiveData.postValue(source)
|
mastodonApi = mastodonApi,
|
||||||
return source
|
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.
|
* 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.LayoutInflater
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.paging.PagedListAdapter
|
import androidx.paging.PagingDataAdapter
|
||||||
import androidx.recyclerview.widget.DiffUtil
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import com.keylesspalace.tusky.R
|
import com.keylesspalace.tusky.R
|
||||||
import com.keylesspalace.tusky.adapter.StatusViewHolder
|
import com.keylesspalace.tusky.adapter.StatusViewHolder
|
||||||
import com.keylesspalace.tusky.entity.Status
|
import com.keylesspalace.tusky.entity.Status
|
||||||
|
@ -30,34 +29,32 @@ import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||||
class SearchStatusesAdapter(
|
class SearchStatusesAdapter(
|
||||||
private val statusDisplayOptions: StatusDisplayOptions,
|
private val statusDisplayOptions: StatusDisplayOptions,
|
||||||
private val statusListener: StatusActionListener
|
private val statusListener: StatusActionListener
|
||||||
) : PagedListAdapter<Pair<Status, StatusViewData.Concrete>, RecyclerView.ViewHolder>(STATUS_COMPARATOR) {
|
) : 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)
|
val view = LayoutInflater.from(parent.context)
|
||||||
.inflate(R.layout.item_status, parent, false)
|
.inflate(R.layout.item_status, parent, false)
|
||||||
return StatusViewHolder(view)
|
return StatusViewHolder(view)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
override fun onBindViewHolder(holder: StatusViewHolder, position: Int) {
|
||||||
getItem(position)?.let { item ->
|
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>? {
|
fun item(position: Int): Pair<Status, StatusViewData.Concrete>? {
|
||||||
return super.getItem(position)
|
return getItem(position)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
val STATUS_COMPARATOR = object : DiffUtil.ItemCallback<Pair<Status, StatusViewData.Concrete>>() {
|
val STATUS_COMPARATOR = object : DiffUtil.ItemCallback<Pair<Status, StatusViewData.Concrete>>() {
|
||||||
override fun areContentsTheSame(oldItem: Pair<Status, StatusViewData.Concrete>, newItem: Pair<Status, StatusViewData.Concrete>): Boolean =
|
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 =
|
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.
|
* This file is a part of Tusky.
|
||||||
*
|
*
|
||||||
|
@ -15,17 +15,16 @@
|
||||||
|
|
||||||
package com.keylesspalace.tusky.components.search.fragments
|
package com.keylesspalace.tusky.components.search.fragments
|
||||||
|
|
||||||
import androidx.lifecycle.LiveData
|
import androidx.paging.PagingData
|
||||||
import androidx.paging.PagedList
|
import androidx.paging.PagingDataAdapter
|
||||||
import androidx.paging.PagedListAdapter
|
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import com.keylesspalace.tusky.components.search.adapter.SearchAccountsAdapter
|
import com.keylesspalace.tusky.components.search.adapter.SearchAccountsAdapter
|
||||||
import com.keylesspalace.tusky.entity.Account
|
import com.keylesspalace.tusky.entity.Account
|
||||||
import com.keylesspalace.tusky.settings.PrefKeys
|
import com.keylesspalace.tusky.settings.PrefKeys
|
||||||
import com.keylesspalace.tusky.util.NetworkState
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
class SearchAccountsFragment : SearchFragment<Account>() {
|
class SearchAccountsFragment : SearchFragment<Account>() {
|
||||||
override fun createAdapter(): PagedListAdapter<Account, *> {
|
override fun createAdapter(): PagingDataAdapter<Account, *> {
|
||||||
val preferences = PreferenceManager.getDefaultSharedPreferences(binding.searchRecyclerView.context)
|
val preferences = PreferenceManager.getDefaultSharedPreferences(binding.searchRecyclerView.context)
|
||||||
|
|
||||||
return SearchAccountsAdapter(
|
return SearchAccountsAdapter(
|
||||||
|
@ -35,12 +34,8 @@ class SearchAccountsFragment : SearchFragment<Account>() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override val networkStateRefresh: LiveData<NetworkState>
|
override val data: Flow<PagingData<Account>>
|
||||||
get() = viewModel.networkStateAccountRefresh
|
get() = viewModel.accountsFlow
|
||||||
override val networkState: LiveData<NetworkState>
|
|
||||||
get() = viewModel.networkStateAccount
|
|
||||||
override val data: LiveData<PagedList<Account>>
|
|
||||||
get() = viewModel.accounts
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun newInstance() = SearchAccountsFragment()
|
fun newInstance() = SearchAccountsFragment()
|
||||||
|
|
|
@ -4,9 +4,10 @@ import android.os.Bundle
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.activityViewModels
|
import androidx.fragment.app.activityViewModels
|
||||||
import androidx.lifecycle.LiveData
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.paging.PagedList
|
import androidx.paging.LoadState
|
||||||
import androidx.paging.PagedListAdapter
|
import androidx.paging.PagingData
|
||||||
|
import androidx.paging.PagingDataAdapter
|
||||||
import androidx.recyclerview.widget.DividerItemDecoration
|
import androidx.recyclerview.widget.DividerItemDecoration
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.recyclerview.widget.SimpleItemAnimator
|
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.Injectable
|
||||||
import com.keylesspalace.tusky.di.ViewModelFactory
|
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||||
import com.keylesspalace.tusky.interfaces.LinkListener
|
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
|
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 {
|
LinkListener, Injectable, SwipeRefreshLayout.OnRefreshListener {
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
|
@ -36,12 +41,12 @@ abstract class SearchFragment<T> : Fragment(R.layout.fragment_search),
|
||||||
|
|
||||||
private var snackbarErrorRetry: Snackbar? = null
|
private var snackbarErrorRetry: Snackbar? = null
|
||||||
|
|
||||||
abstract fun createAdapter(): PagedListAdapter<T, *>
|
abstract fun createAdapter(): PagingDataAdapter<T, *>
|
||||||
|
|
||||||
abstract val networkStateRefresh: LiveData<NetworkState>
|
abstract val data: Flow<PagingData<T>>
|
||||||
abstract val networkState: LiveData<NetworkState>
|
protected lateinit var adapter: PagingDataAdapter<T, *>
|
||||||
abstract val data: LiveData<PagedList<T>>
|
|
||||||
protected lateinit var adapter: PagedListAdapter<T, *>
|
private var currentQuery: String = ""
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
initAdapter()
|
initAdapter()
|
||||||
|
@ -55,33 +60,33 @@ abstract class SearchFragment<T> : Fragment(R.layout.fragment_search),
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun subscribeObservables() {
|
private fun subscribeObservables() {
|
||||||
data.observe(viewLifecycleOwner) {
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
adapter.submitList(it)
|
data.collectLatest { pagingData ->
|
||||||
|
adapter.submitData(pagingData)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
networkStateRefresh.observe(viewLifecycleOwner) {
|
adapter.addLoadStateListener { loadState ->
|
||||||
|
|
||||||
binding.searchProgressBar.visible(it == NetworkState.LOADING)
|
if (loadState.refresh is LoadState.Error) {
|
||||||
|
|
||||||
if (it.status == Status.FAILED) {
|
|
||||||
showError()
|
showError()
|
||||||
}
|
}
|
||||||
checkNoData()
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
networkState.observe(viewLifecycleOwner) {
|
binding.progressBarBottom.visible(loadState.append == LoadState.Loading)
|
||||||
|
|
||||||
binding.progressBarBottom.visible(it == NetworkState.LOADING)
|
binding.searchNoResultsText.visible(loadState.refresh is LoadState.NotLoading && adapter.itemCount == 0 && viewModel.currentQuery.isNotEmpty())
|
||||||
|
|
||||||
if (it.status == Status.FAILED) {
|
|
||||||
showError()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private fun checkNoData() {
|
|
||||||
showNoData(adapter.itemCount == 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initAdapter() {
|
private fun initAdapter() {
|
||||||
binding.searchRecyclerView.addItemDecoration(DividerItemDecoration(binding.searchRecyclerView.context, DividerItemDecoration.VERTICAL))
|
binding.searchRecyclerView.addItemDecoration(DividerItemDecoration(binding.searchRecyclerView.context, DividerItemDecoration.VERTICAL))
|
||||||
|
@ -92,20 +97,12 @@ abstract class SearchFragment<T> : Fragment(R.layout.fragment_search),
|
||||||
(binding.searchRecyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
|
(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() {
|
private fun showError() {
|
||||||
if (snackbarErrorRetry?.isShown != true) {
|
if (snackbarErrorRetry?.isShown != true) {
|
||||||
snackbarErrorRetry = Snackbar.make(binding.root, R.string.failed_search, Snackbar.LENGTH_INDEFINITE)
|
snackbarErrorRetry = Snackbar.make(binding.root, R.string.failed_search, Snackbar.LENGTH_INDEFINITE)
|
||||||
snackbarErrorRetry?.setAction(R.string.action_retry) {
|
snackbarErrorRetry?.setAction(R.string.action_retry) {
|
||||||
snackbarErrorRetry = null
|
snackbarErrorRetry = null
|
||||||
viewModel.retryAllSearches()
|
adapter.retry()
|
||||||
}
|
}
|
||||||
snackbarErrorRetry?.show()
|
snackbarErrorRetry?.show()
|
||||||
}
|
}
|
||||||
|
@ -123,11 +120,6 @@ abstract class SearchFragment<T> : Fragment(R.layout.fragment_search),
|
||||||
get() = (activity as? BottomSheetActivity)
|
get() = (activity as? BottomSheetActivity)
|
||||||
|
|
||||||
override fun onRefresh() {
|
override fun onRefresh() {
|
||||||
|
adapter.refresh()
|
||||||
// Dismissed here because the RecyclerView bottomProgressBar is shown as soon as the retry begins.
|
|
||||||
binding.swipeRefreshLayout.post {
|
|
||||||
binding.swipeRefreshLayout.isRefreshing = false
|
|
||||||
}
|
|
||||||
viewModel.retryAllSearches()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
/* Copyright 2019 Joel Pyska
|
/* Copyright 2021 Tusky Contributors
|
||||||
*
|
*
|
||||||
* This file is a part of Tusky.
|
* This file is a part of Tusky.
|
||||||
*
|
*
|
||||||
|
@ -15,22 +15,18 @@
|
||||||
|
|
||||||
package com.keylesspalace.tusky.components.search.fragments
|
package com.keylesspalace.tusky.components.search.fragments
|
||||||
|
|
||||||
import androidx.lifecycle.LiveData
|
import androidx.paging.PagingData
|
||||||
import androidx.paging.PagedList
|
import androidx.paging.PagingDataAdapter
|
||||||
import androidx.paging.PagedListAdapter
|
|
||||||
import com.keylesspalace.tusky.components.search.adapter.SearchHashtagsAdapter
|
import com.keylesspalace.tusky.components.search.adapter.SearchHashtagsAdapter
|
||||||
import com.keylesspalace.tusky.entity.HashTag
|
import com.keylesspalace.tusky.entity.HashTag
|
||||||
import com.keylesspalace.tusky.util.NetworkState
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
class SearchHashtagsFragment : SearchFragment<HashTag>() {
|
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 {
|
companion object {
|
||||||
fun newInstance() = SearchHashtagsFragment()
|
fun newInstance() = SearchHashtagsFragment()
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
/* Copyright 2019 Joel Pyska
|
/* Copyright 2021 Tusky Contributors
|
||||||
*
|
*
|
||||||
* This file is a part of Tusky.
|
* This file is a part of Tusky.
|
||||||
*
|
*
|
||||||
|
@ -32,9 +32,8 @@ import androidx.appcompat.widget.PopupMenu
|
||||||
import androidx.core.app.ActivityOptionsCompat
|
import androidx.core.app.ActivityOptionsCompat
|
||||||
import androidx.core.view.ViewCompat
|
import androidx.core.view.ViewCompat
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LiveData
|
import androidx.paging.PagingData
|
||||||
import androidx.paging.PagedList
|
import androidx.paging.PagingDataAdapter
|
||||||
import androidx.paging.PagedListAdapter
|
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import androidx.recyclerview.widget.DividerItemDecoration
|
import androidx.recyclerview.widget.DividerItemDecoration
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
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.settings.PrefKeys
|
||||||
import com.keylesspalace.tusky.util.CardViewMode
|
import com.keylesspalace.tusky.util.CardViewMode
|
||||||
import com.keylesspalace.tusky.util.LinkHelper
|
import com.keylesspalace.tusky.util.LinkHelper
|
||||||
import com.keylesspalace.tusky.util.NetworkState
|
|
||||||
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
||||||
import com.keylesspalace.tusky.view.showMuteAccountDialog
|
import com.keylesspalace.tusky.view.showMuteAccountDialog
|
||||||
import com.keylesspalace.tusky.viewdata.AttachmentViewData
|
import com.keylesspalace.tusky.viewdata.AttachmentViewData
|
||||||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concrete>>(), StatusActionListener {
|
class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concrete>>(), StatusActionListener {
|
||||||
|
|
||||||
override val networkStateRefresh: LiveData<NetworkState>
|
override val data: Flow<PagingData<Pair<Status, StatusViewData.Concrete>>>
|
||||||
get() = viewModel.networkStateStatusRefresh
|
get() = viewModel.statusesFlow
|
||||||
override val networkState: LiveData<NetworkState>
|
|
||||||
get() = viewModel.networkStateStatus
|
|
||||||
override val data: LiveData<PagedList<Pair<Status, StatusViewData.Concrete>>>
|
|
||||||
get() = viewModel.statuses
|
|
||||||
|
|
||||||
private val searchAdapter
|
private val searchAdapter
|
||||||
get() = super.adapter as SearchStatusesAdapter
|
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 preferences = PreferenceManager.getDefaultSharedPreferences(binding.searchRecyclerView.context)
|
||||||
val statusDisplayOptions = StatusDisplayOptions(
|
val statusDisplayOptions = StatusDisplayOptions(
|
||||||
animateAvatars = preferences.getBoolean("animateGifAvatars", false),
|
animateAvatars = preferences.getBoolean("animateGifAvatars", false),
|
||||||
|
@ -96,37 +91,37 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onContentHiddenChange(isShowing: Boolean, position: Int) {
|
override fun onContentHiddenChange(isShowing: Boolean, position: Int) {
|
||||||
searchAdapter.getItem(position)?.let {
|
searchAdapter.item(position)?.let {
|
||||||
viewModel.contentHiddenChange(it, isShowing)
|
viewModel.contentHiddenChange(it, isShowing)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onReply(position: Int) {
|
override fun onReply(position: Int) {
|
||||||
searchAdapter.getItem(position)?.first?.let { status ->
|
searchAdapter.item(position)?.first?.let { status ->
|
||||||
reply(status)
|
reply(status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onFavourite(favourite: Boolean, position: Int) {
|
override fun onFavourite(favourite: Boolean, position: Int) {
|
||||||
searchAdapter.getItem(position)?.let { status ->
|
searchAdapter.item(position)?.let { status ->
|
||||||
viewModel.favorite(status, favourite)
|
viewModel.favorite(status, favourite)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBookmark(bookmark: Boolean, position: Int) {
|
override fun onBookmark(bookmark: Boolean, position: Int) {
|
||||||
searchAdapter.getItem(position)?.let { status ->
|
searchAdapter.item(position)?.let { status ->
|
||||||
viewModel.bookmark(status, bookmark)
|
viewModel.bookmark(status, bookmark)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onMore(view: View, position: Int) {
|
override fun onMore(view: View, position: Int) {
|
||||||
searchAdapter.getItem(position)?.first?.let {
|
searchAdapter.item(position)?.first?.let {
|
||||||
more(it, view, position)
|
more(it, view, position)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
|
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) {
|
when (actionable.attachments[attachmentIndex].type) {
|
||||||
Attachment.Type.GIFV, Attachment.Type.VIDEO, Attachment.Type.IMAGE, Attachment.Type.AUDIO -> {
|
Attachment.Type.GIFV, Attachment.Type.VIDEO, Attachment.Type.IMAGE, Attachment.Type.AUDIO -> {
|
||||||
val attachments = AttachmentViewData.list(actionable)
|
val attachments = AttachmentViewData.list(actionable)
|
||||||
|
@ -146,26 +141,24 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
|
||||||
LinkHelper.openLink(actionable.attachments[attachmentIndex].url, context)
|
LinkHelper.openLink(actionable.attachments[attachmentIndex].url, context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewThread(position: Int) {
|
override fun onViewThread(position: Int) {
|
||||||
searchAdapter.getItem(position)?.first?.let { status ->
|
searchAdapter.item(position)?.first?.let { status ->
|
||||||
val actionableStatus = status.actionableStatus
|
val actionableStatus = status.actionableStatus
|
||||||
bottomSheetActivity?.viewThread(actionableStatus.id, actionableStatus.url)
|
bottomSheetActivity?.viewThread(actionableStatus.id, actionableStatus.url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onOpenReblog(position: Int) {
|
override fun onOpenReblog(position: Int) {
|
||||||
searchAdapter.getItem(position)?.first?.let { status ->
|
searchAdapter.item(position)?.first?.let { status ->
|
||||||
bottomSheetActivity?.viewAccount(status.account.id)
|
bottomSheetActivity?.viewAccount(status.account.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onExpandedChange(expanded: Boolean, position: Int) {
|
override fun onExpandedChange(expanded: Boolean, position: Int) {
|
||||||
searchAdapter.getItem(position)?.let {
|
searchAdapter.item(position)?.let {
|
||||||
viewModel.expandedChange(it, expanded)
|
viewModel.expandedChange(it, expanded)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -175,25 +168,25 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) {
|
override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) {
|
||||||
searchAdapter.getItem(position)?.let {
|
searchAdapter.item(position)?.let {
|
||||||
viewModel.collapsedChange(it, isCollapsed)
|
viewModel.collapsedChange(it, isCollapsed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onVoteInPoll(position: Int, choices: MutableList<Int>) {
|
override fun onVoteInPoll(position: Int, choices: MutableList<Int>) {
|
||||||
searchAdapter.getItem(position)?.let {
|
searchAdapter.item(position)?.let {
|
||||||
viewModel.voteInPoll(it, choices)
|
viewModel.voteInPoll(it, choices)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun removeItem(position: Int) {
|
private fun removeItem(position: Int) {
|
||||||
searchAdapter.getItem(position)?.let {
|
searchAdapter.item(position)?.let {
|
||||||
viewModel.removeItem(it)
|
viewModel.removeItem(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onReblog(reblog: Boolean, position: Int) {
|
override fun onReblog(reblog: Boolean, position: Int) {
|
||||||
searchAdapter.getItem(position)?.let { status ->
|
searchAdapter.item(position)?.let { status ->
|
||||||
viewModel.reblog(status, reblog)
|
viewModel.reblog(status, reblog)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -323,7 +316,7 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
|
||||||
return@setOnMenuItemClickListener true
|
return@setOnMenuItemClickListener true
|
||||||
}
|
}
|
||||||
R.id.status_mute_conversation -> {
|
R.id.status_mute_conversation -> {
|
||||||
searchAdapter.getItem(position)?.let { foundStatus ->
|
searchAdapter.item(position)?.let { foundStatus ->
|
||||||
viewModel.muteConversation(foundStatus, status.muted != true)
|
viewModel.muteConversation(foundStatus, status.muted != true)
|
||||||
}
|
}
|
||||||
return@setOnMenuItemClickListener true
|
return@setOnMenuItemClickListener true
|
||||||
|
|
|
@ -32,7 +32,7 @@ import java.io.File;
|
||||||
|
|
||||||
@Database(entities = { DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class,
|
@Database(entities = { DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class,
|
||||||
TimelineAccountEntity.class, ConversationEntity.class
|
TimelineAccountEntity.class, ConversationEntity.class
|
||||||
}, version = 26)
|
}, version = 27)
|
||||||
public abstract class AppDatabase extends RoomDatabase {
|
public abstract class AppDatabase extends RoomDatabase {
|
||||||
|
|
||||||
public abstract AccountDao accountDao();
|
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
|
package com.keylesspalace.tusky.db
|
||||||
|
|
||||||
import androidx.paging.DataSource
|
import androidx.paging.PagingSource
|
||||||
import androidx.room.*
|
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 com.keylesspalace.tusky.components.conversation.ConversationEntity
|
||||||
import io.reactivex.rxjava3.core.Single
|
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
interface ConversationsDao {
|
interface ConversationsDao {
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
fun insert(conversations: List<ConversationEntity>)
|
suspend fun insert(conversations: List<ConversationEntity>)
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
fun insert(conversation: ConversationEntity): Single<Long>
|
suspend fun insert(conversation: ConversationEntity): Long
|
||||||
|
|
||||||
@Delete
|
@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")
|
@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")
|
@Query("DELETE FROM ConversationEntity WHERE accountId = :accountId")
|
||||||
fun deleteForAccount(accountId: Long)
|
fun deleteForAccount(accountId: Long)
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -83,6 +83,7 @@ class AppModule {
|
||||||
AppDatabase.MIGRATION_16_17, AppDatabase.MIGRATION_17_18, AppDatabase.MIGRATION_18_19,
|
AppDatabase.MIGRATION_16_17, AppDatabase.MIGRATION_17_18, AppDatabase.MIGRATION_18_19,
|
||||||
AppDatabase.MIGRATION_19_20, AppDatabase.MIGRATION_20_21, AppDatabase.MIGRATION_21_22,
|
AppDatabase.MIGRATION_19_20, AppDatabase.MIGRATION_20_21, AppDatabase.MIGRATION_21_22,
|
||||||
AppDatabase.MIGRATION_22_23, AppDatabase.MIGRATION_23_24, AppDatabase.MIGRATION_24_25,
|
AppDatabase.MIGRATION_22_23, AppDatabase.MIGRATION_23_24, AppDatabase.MIGRATION_24_25,
|
||||||
|
AppDatabase.MIGRATION_26_27,
|
||||||
AppDatabase.Migration25_26(appContext.getExternalFilesDir("Tusky"))
|
AppDatabase.Migration25_26(appContext.getExternalFilesDir("Tusky"))
|
||||||
)
|
)
|
||||||
.build()
|
.build()
|
||||||
|
|
|
@ -15,7 +15,27 @@
|
||||||
|
|
||||||
package com.keylesspalace.tusky.network
|
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.Completable
|
||||||
import io.reactivex.rxjava3.core.Single
|
import io.reactivex.rxjava3.core.Single
|
||||||
import okhttp3.MultipartBody
|
import okhttp3.MultipartBody
|
||||||
|
@ -23,8 +43,20 @@ import okhttp3.RequestBody
|
||||||
import okhttp3.ResponseBody
|
import okhttp3.ResponseBody
|
||||||
import retrofit2.Call
|
import retrofit2.Call
|
||||||
import retrofit2.Response
|
import retrofit2.Response
|
||||||
import retrofit2.http.*
|
import retrofit2.http.Body
|
||||||
|
import retrofit2.http.DELETE
|
||||||
import retrofit2.http.Field
|
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/
|
* for documentation of the Mastodon REST API see https://docs.joinmastodon.org/api/
|
||||||
|
@ -466,10 +498,15 @@ interface MastodonApi {
|
||||||
): Completable
|
): Completable
|
||||||
|
|
||||||
@GET("/api/v1/conversations")
|
@GET("/api/v1/conversations")
|
||||||
fun getConversations(
|
suspend fun getConversations(
|
||||||
@Query("max_id") maxId: String? = null,
|
@Query("max_id") maxId: String? = null,
|
||||||
@Query("limit") limit: Int
|
@Query("limit") limit: Int
|
||||||
): Call<List<Conversation>>
|
): List<Conversation>
|
||||||
|
|
||||||
|
@DELETE("/api/v1/conversations/{id}")
|
||||||
|
suspend fun deleteConversation(
|
||||||
|
@Path("id") conversationId: String
|
||||||
|
)
|
||||||
|
|
||||||
@FormUrlEncoded
|
@FormUrlEncoded
|
||||||
@POST("api/v1/filters")
|
@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 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
|
// the LiveData of paged lists for the UI to observe
|
||||||
val pagedList: LiveData<PagedList<T>>,
|
val pagedList: LiveData<PagedList<T>>,
|
||||||
// represents the network request status for load data before first to show to the user
|
// 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)
|
|
13
app/src/main/res/menu/conversation_more.xml
Normal file
13
app/src/main/res/menu/conversation_more.xml
Normal file
|
@ -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_report">Report</string>
|
||||||
<string name="action_edit">Edit</string>
|
<string name="action_edit">Edit</string>
|
||||||
<string name="action_delete">Delete</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_delete_and_redraft">Delete and re-draft</string>
|
||||||
<string name="action_send">TOOT</string>
|
<string name="action_send">TOOT</string>
|
||||||
<string name="action_send_public">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_unfollow_warning">Unfollow this account?</string>
|
||||||
<string name="dialog_delete_toot_warning">Delete this toot?</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_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">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="mute_domain_warning_dialog_ok">Hide entire domain</string>
|
||||||
<string name="dialog_block_warning">Block @%s?</string>
|
<string name="dialog_block_warning">Block @%s?</string>
|
||||||
|
|
Loading…
Reference in a new issue