diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0e4f81ab2..428f50eab 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,17 +11,23 @@ All English text that will be visible to users should be put in ```app/src/main/res/values/strings.xml```. Any text that is missing in a translation will fall back to the version in this file. Be aware that anything added to this file will need to be translated, so be very concise with wording and try to add as few things as possible. Look for existing strings to use first. If there is untranslatable text that you don't want to keep as a string constant in a Java class, you can use the string resource file ```app/src/main/res/values/donottranslate.xml```. ### Translation -Translations are done through https://weblate.tusky.app/projects/tusky/tusky/ . -To add a new language, clic on the 'Start a new translation' button on at the bottom of the page. +Translations are done through our [Weblate](https://weblate.tusky.app/projects/tusky/tusky/). +To add a new language, click on the 'Start a new translation' button on at the bottom of the page. ### Kotlin -This project is in the process of migrating to Kotlin, we prefer new code to be written in Kotlin. We try to follow the [Kotlin Style Guide](https://android.github.io/kotlin-guides/style.html) and make use of the [Kotlin Android Extensions](https://kotlinlang.org/docs/tutorials/android-plugin.html). +This project is in the process of migrating to Kotlin, all new code must be written in Kotlin. +We try to follow the [Kotlin Style Guide](https://developer.android.com/kotlin/style-guide) and make format the code according to the default [ktlint codestyle](https://github.com/pinterest/ktlint). +You can check the codestyle by running `./gradlew ktlintCheck`. ### Java -Existing code in Java should follow the [Android Style Guide](https://source.android.com/source/code-style), which is what Android uses for their own source code. ```@Nullable``` and ```@NotNull``` annotations are really helpful for Kotlin interoperability. +Existing code in Java should follow the [Android Style Guide](https://source.android.com/source/code-style), which is what Android uses for their own source code. ```@Nullable``` and ```@NotNull``` annotations are really helpful for Kotlin interoperability. Please don't submit new features written in Kotlin. + +### Viewbinding +We use [Viewbinding](https://developer.android.com/topic/libraries/view-binding) to reference views. No contribution using another mechanism will be accepted. +There are useful extensions in `src/main/java/com/keylesspalace/tusky/util/ViewExtensions.kt` that make working with viewbinding easier. ### Visuals -There are three themes in the app, so any visual changes should be checked with each of them to ensure they look appropriate no matter which theme is selected. Usually, you can use existing color attributes like ```?attr/colorPrimary``` and ```?attr/textColorSecondary```. For icons and drawables, use a white drawable and tint it at runtime using ```ThemeUtils``` and specify an attribute that references different colours depending on the theme. +There are three themes in the app, so any visual changes should be checked with each of them to ensure they look appropriate no matter which theme is selected. Usually, you can use existing color attributes like ```?attr/colorPrimary``` and ```?attr/textColorSecondary```. ### Saving Any time you get a good chunk of work done it's good to make a commit. You can either uses Android Studio's built-in UI for doing this or running the commands: diff --git a/app/build.gradle b/app/build.gradle index bf87b27c1..f5daa5e72 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -15,7 +15,7 @@ def getGitSha = { } android { - compileSdkVersion 29 + compileSdkVersion 30 defaultConfig { applicationId 'net.accelf.yuito' minSdkVersion 21 @@ -34,7 +34,6 @@ android { kapt { arguments { arg("room.schemaLocation", "$projectDir/schemas") - arg("room.incremental", "true") } } } @@ -67,10 +66,6 @@ android { lintOptions { disable 'MissingTranslation' } - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } buildFeatures { viewBinding true } @@ -97,19 +92,13 @@ android { } } -project.tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all { - kotlinOptions { - jvmTarget = "1.8" - } -} - -ext.lifecycleVersion = "2.2.0" +ext.lifecycleVersion = "2.3.1" ext.roomVersion = '2.3.0' ext.retrofitVersion = '2.9.0' -ext.okhttpVersion = '4.9.0' -ext.glideVersion = '4.11.0' -ext.daggerVersion = '2.30.1' -ext.materialdrawerVersion = '8.2.0' +ext.okhttpVersion = '4.9.1' +ext.glideVersion = '4.12.0' +ext.daggerVersion = '2.37' +ext.materialdrawerVersion = '8.4.1' repositories { maven { @@ -121,16 +110,19 @@ repositories { dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" - implementation "androidx.core:core-ktx:1.3.2" - implementation "androidx.appcompat:appcompat:1.2.0" - implementation "androidx.fragment:fragment-ktx:1.2.5" + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.0' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-rx3:1.5.0' + + implementation "androidx.core:core-ktx:1.5.0" + implementation "androidx.appcompat:appcompat:1.3.0" + implementation "androidx.fragment:fragment-ktx:1.3.4" implementation "androidx.browser:browser:1.3.0" implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0" - implementation "androidx.recyclerview:recyclerview:1.2.0" + implementation "androidx.recyclerview:recyclerview:1.2.1" implementation "androidx.exifinterface:exifinterface:1.3.2" implementation "androidx.cardview:cardview:1.0.0" implementation "androidx.preference:preference-ktx:1.1.1" - implementation "androidx.sharetarget:sharetarget:1.0.0" + implementation "androidx.sharetarget:sharetarget:1.1.0" implementation "androidx.emoji:emoji:1.1.0" implementation "androidx.emoji:emoji-appcompat:1.1.0" implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion" @@ -138,33 +130,33 @@ dependencies { implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion" implementation "androidx.lifecycle:lifecycle-reactivestreams-ktx:$lifecycleVersion" implementation "androidx.constraintlayout:constraintlayout:2.0.4" - implementation "androidx.paging:paging-runtime-ktx:2.1.2" + implementation "androidx.paging:paging-runtime-ktx:3.0.0" implementation "androidx.viewpager2:viewpager2:1.0.0" - implementation "androidx.work:work-runtime:2.4.0" - implementation "androidx.room:room-runtime:$roomVersion" - implementation "androidx.room:room-rxjava2:$roomVersion" + implementation "androidx.work:work-runtime:2.5.0" + implementation "androidx.room:room-ktx:$roomVersion" + implementation "androidx.room:room-rxjava3:$roomVersion" kapt "androidx.room:room-compiler:$roomVersion" implementation "com.google.android.material:material:1.3.0" implementation "com.squareup.retrofit2:retrofit:$retrofitVersion" implementation "com.squareup.retrofit2:converter-gson:$retrofitVersion" - implementation "com.squareup.retrofit2:adapter-rxjava2:$retrofitVersion" + implementation "com.squareup.retrofit2:adapter-rxjava3:$retrofitVersion" implementation "com.squareup.okhttp3:okhttp:$okhttpVersion" implementation "com.squareup.okhttp3:logging-interceptor:$okhttpVersion" - implementation "org.conscrypt:conscrypt-android:2.5.1" + implementation "org.conscrypt:conscrypt-android:2.5.2" implementation "com.github.bumptech.glide:glide:$glideVersion" implementation "com.github.bumptech.glide:okhttp3-integration:$glideVersion" - implementation "io.reactivex.rxjava2:rxjava:2.2.20" - implementation "io.reactivex.rxjava2:rxandroid:2.1.1" - implementation "io.reactivex.rxjava2:rxkotlin:2.4.0" + implementation "io.reactivex.rxjava3:rxjava:3.0.12" + implementation "io.reactivex.rxjava3:rxandroid:3.0.0" + implementation "io.reactivex.rxjava3:rxkotlin:3.0.1" - implementation "com.uber.autodispose:autodispose-android-archcomponents:1.4.0" - implementation "com.uber.autodispose:autodispose:1.4.0" + implementation "com.uber.autodispose2:autodispose-androidx-lifecycle:2.0.0" + implementation "com.uber.autodispose2:autodispose:2.0.0" implementation "com.google.dagger:dagger:$daggerVersion" kapt "com.google.dagger:dagger-compiler:$daggerVersion" @@ -180,9 +172,9 @@ dependencies { implementation "com.mikepenz:materialdrawer-iconics:$materialdrawerVersion" implementation 'com.mikepenz:google-material-typeface:3.0.1.4.original-kotlin@aar' - implementation "com.theartofdev.edmodo:android-image-cropper:2.8.0" + implementation "com.github.CanHub:Android-Image-Cropper:3.1.0" - implementation "de.c1710:filemojicompat:1.0.17" + implementation "de.c1710:filemojicompat:1.0.18" testImplementation "androidx.test.ext:junit:1.1.2" testImplementation "org.robolectric:robolectric:4.4" @@ -192,6 +184,7 @@ dependencies { androidTestImplementation "androidx.test.espresso:espresso-core:3.3.0" androidTestImplementation "androidx.room:room-testing:$roomVersion" androidTestImplementation "androidx.test.ext:junit:1.1.2" + testImplementation "androidx.arch.core:core-testing:2.1.0" implementation 'net.accelf:easter:1.0.2' implementation 'org.jsoup:jsoup:1.13.1' diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index a05994a10..af6372d4e 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -65,7 +65,14 @@ # remove some kotlin overhead -assumenosideeffects class kotlin.jvm.internal.Intrinsics { + static void checkNotNull(java.lang.Object); + static void checkNotNull(java.lang.Object, java.lang.String); static void checkParameterIsNotNull(java.lang.Object, java.lang.String); + static void checkParameterIsNotNull(java.lang.Object, java.lang.String); + static void checkNotNullParameter(java.lang.Object, java.lang.String); static void checkExpressionValueIsNotNull(java.lang.Object, java.lang.String); + static void checkNotNullExpressionValue(java.lang.Object, java.lang.String); + static void checkReturnedValueIsNotNull(java.lang.Object, java.lang.String); + static void checkReturnedValueIsNotNull(java.lang.Object, java.lang.String, java.lang.String); static void throwUninitializedPropertyAccessException(java.lang.String); } diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/26.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/26.json new file mode 100644 index 000000000..bd82fd4fd --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/26.json @@ -0,0 +1,747 @@ +{ + "formatVersion": 1, + "database": { + "version": 26, + "identityHash": "14fb3d5743b7a89e8e62463e05f086ab", + "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_poll` TEXT, PRIMARY KEY(`id`, `accountId`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accounts", + "columnName": "accounts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.id", + "columnName": "s_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.url", + "columnName": "s_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToId", + "columnName": "s_inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToAccountId", + "columnName": "s_inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.account", + "columnName": "s_account", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.content", + "columnName": "s_content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.createdAt", + "columnName": "s_createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.emojis", + "columnName": "s_emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.favouritesCount", + "columnName": "s_favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.favourited", + "columnName": "s_favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.bookmarked", + "columnName": "s_bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.sensitive", + "columnName": "s_sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.spoilerText", + "columnName": "s_spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.attachments", + "columnName": "s_attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.mentions", + "columnName": "s_mentions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.showingHiddenContent", + "columnName": "s_showingHiddenContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.expanded", + "columnName": "s_expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsible", + "columnName": "s_collapsible", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsed", + "columnName": "s_collapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.poll", + "columnName": "s_poll", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id", + "accountId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "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, '14fb3d5743b7a89e8e62463e05f086ab')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/27.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/27.json new file mode 100644 index 000000000..c83963093 --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/27.json @@ -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')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/keylesspalace/tusky/MigrationsTest.kt b/app/src/androidTest/java/com/keylesspalace/tusky/MigrationsTest.kt index 9c65aebf3..69641cc41 100644 --- a/app/src/androidTest/java/com/keylesspalace/tusky/MigrationsTest.kt +++ b/app/src/androidTest/java/com/keylesspalace/tusky/MigrationsTest.kt @@ -2,8 +2,8 @@ package com.keylesspalace.tusky import androidx.room.testing.MigrationTestHelper import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory -import androidx.test.platform.app.InstrumentationRegistry import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry import com.keylesspalace.tusky.db.AppDatabase import org.junit.Assert.assertEquals import org.junit.Rule @@ -18,9 +18,9 @@ class MigrationsTest { @JvmField @Rule var helper: MigrationTestHelper = MigrationTestHelper( - InstrumentationRegistry.getInstrumentation(), - AppDatabase::class.java.canonicalName, - FrameworkSQLiteOpenHelperFactory() + InstrumentationRegistry.getInstrumentation(), + AppDatabase::class.java.canonicalName, + FrameworkSQLiteOpenHelperFactory() ) @Test @@ -33,12 +33,15 @@ class MigrationsTest { val active = true val accountId = "accountId" val username = "username" - val values = arrayOf(id, domain, token, active, accountId, username, "Display Name", - "https://picture.url", true, true, true, true, true, true, true, - true, "1000", "[]", "[{\"shortcode\": \"emoji\", \"url\": \"yes\"}]", 0, false, - false, true) + val values = arrayOf( + id, domain, token, active, accountId, username, "Display Name", + "https://picture.url", true, true, true, true, true, true, true, + true, "1000", "[]", "[{\"shortcode\": \"emoji\", \"url\": \"yes\"}]", 0, false, + false, true + ) - db.execSQL("INSERT OR REPLACE INTO `AccountEntity`(`id`,`domain`,`accessToken`,`isActive`," + + db.execSQL( + "INSERT OR REPLACE INTO `AccountEntity`(`id`,`domain`,`accessToken`,`isActive`," + "`accountId`,`username`,`displayName`,`profilePictureUrl`,`notificationsEnabled`," + "`notificationsMentioned`,`notificationsFollowed`,`notificationsReblogged`," + "`notificationsFavorited`,`notificationSound`,`notificationVibration`," + @@ -46,7 +49,8 @@ class MigrationsTest { "`defaultPostPrivacy`,`defaultMediaSensitivity`,`alwaysShowSensitiveMedia`," + "`mediaPreviewEnabled`) " + "VALUES (nullif(?, 0),?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", - values) + values + ) db.close() @@ -61,4 +65,4 @@ class MigrationsTest { assertEquals(accountId, cursor.getString(4)) assertEquals(username, cursor.getString(5)) } -} \ No newline at end of file +} diff --git a/app/src/androidTest/java/com/keylesspalace/tusky/TimelineDAOTest.kt b/app/src/androidTest/java/com/keylesspalace/tusky/TimelineDAOTest.kt index da55b08b7..c4959b3ab 100644 --- a/app/src/androidTest/java/com/keylesspalace/tusky/TimelineDAOTest.kt +++ b/app/src/androidTest/java/com/keylesspalace/tusky/TimelineDAOTest.kt @@ -3,9 +3,13 @@ package com.keylesspalace.tusky import androidx.room.Room import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry -import com.keylesspalace.tusky.db.* +import com.keylesspalace.tusky.components.timeline.TimelineRepository +import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.db.TimelineAccountEntity +import com.keylesspalace.tusky.db.TimelineDao +import com.keylesspalace.tusky.db.TimelineStatusEntity +import com.keylesspalace.tusky.db.TimelineStatusWithAccount import com.keylesspalace.tusky.entity.Status -import com.keylesspalace.tusky.repository.TimelineRepository import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNull @@ -41,9 +45,11 @@ class TimelineDAOTest { timelineDao.insertInTransaction(status, author, reblogger) } - val resultsFromDb = timelineDao.getStatusesForAccount(setOne.first.timelineUserId, - maxId = "21", sinceId = ignoredOne.first.serverId, limit = 10) - .blockingGet() + val resultsFromDb = timelineDao.getStatusesForAccount( + setOne.first.timelineUserId, + maxId = "21", sinceId = ignoredOne.first.serverId, limit = 10 + ) + .blockingGet() assertEquals(2, resultsFromDb.size) for ((set, fromDb) in listOf(setTwo, setOne).zip(resultsFromDb)) { @@ -64,14 +70,13 @@ class TimelineDAOTest { timelineDao.insertStatusIfNotThere(placeholder) val fromDb = timelineDao.getStatusesForAccount(status.timelineUserId, null, null, 10) - .blockingGet() + .blockingGet() val result = fromDb.first() assertEquals(1, fromDb.size) assertEquals(author, result.account) assertEquals(status, result.status) assertNull(result.reblogAccount) - } @Test @@ -79,22 +84,22 @@ class TimelineDAOTest { val now = System.currentTimeMillis() val oldDate = now - TimelineRepository.CLEANUP_INTERVAL - 20_000 val oldThisAccount = makeStatus( - statusId = 5, - createdAt = oldDate + statusId = 5, + createdAt = oldDate ) val oldAnotherAccount = makeStatus( - statusId = 10, - createdAt = oldDate, - accountId = 2 + statusId = 10, + createdAt = oldDate, + accountId = 2 ) val recentThisAccount = makeStatus( - statusId = 30, - createdAt = System.currentTimeMillis() + statusId = 30, + createdAt = System.currentTimeMillis() ) val recentAnotherAccount = makeStatus( - statusId = 60, - createdAt = System.currentTimeMillis(), - accountId = 2 + statusId = 60, + createdAt = System.currentTimeMillis(), + accountId = 2 ) for ((status, author, reblogAuthor) in listOf(oldThisAccount, oldAnotherAccount, recentThisAccount, recentAnotherAccount)) { @@ -104,15 +109,15 @@ class TimelineDAOTest { timelineDao.cleanup(now - TimelineRepository.CLEANUP_INTERVAL) assertEquals( - listOf(recentThisAccount), - timelineDao.getStatusesForAccount(1, null, null, 100).blockingGet() - .map { it.toTriple() } + listOf(recentThisAccount), + timelineDao.getStatusesForAccount(1, null, null, 100).blockingGet() + .map { it.toTriple() } ) assertEquals( - listOf(recentAnotherAccount), - timelineDao.getStatusesForAccount(2, null, null, 100).blockingGet() - .map { it.toTriple() } + listOf(recentAnotherAccount), + timelineDao.getStatusesForAccount(2, null, null, 100).blockingGet() + .map { it.toTriple() } ) } @@ -120,9 +125,9 @@ class TimelineDAOTest { fun overwriteDeletedStatus() { val oldStatuses = listOf( - makeStatus(statusId = 3), - makeStatus(statusId = 2), - makeStatus(statusId = 1) + makeStatus(statusId = 3), + makeStatus(statusId = 2), + makeStatus(statusId = 1) ) timelineDao.deleteRange(1, oldStatuses.last().first.serverId, oldStatuses.first().first.serverId) @@ -133,8 +138,8 @@ class TimelineDAOTest { // status 2 gets deleted, newly loaded status contain only 1 + 3 val newStatuses = listOf( - makeStatus(statusId = 3), - makeStatus(statusId = 1) + makeStatus(statusId = 3), + makeStatus(statusId = 1) ) timelineDao.deleteRange(1, newStatuses.last().first.serverId, newStatuses.first().first.serverId) @@ -143,107 +148,106 @@ class TimelineDAOTest { timelineDao.insertInTransaction(status, author, reblogAuthor) } - //make sure status 2 is no longer in db + // make sure status 2 is no longer in db assertEquals( - newStatuses, - timelineDao.getStatusesForAccount(1, null, null, 100).blockingGet() - .map { it.toTriple() } + newStatuses, + timelineDao.getStatusesForAccount(1, null, null, 100).blockingGet() + .map { it.toTriple() } ) } private fun makeStatus( - accountId: Long = 1, - statusId: Long = 10, - reblog: Boolean = false, - createdAt: Long = statusId, - authorServerId: String = "20" + accountId: Long = 1, + statusId: Long = 10, + reblog: Boolean = false, + createdAt: Long = statusId, + authorServerId: String = "20" ): Triple { val author = TimelineAccountEntity( - authorServerId, - accountId, - "localUsername", - "username", - "displayName", - "blah", - "avatar", - "[\"tusky\": \"http://tusky.cool/emoji.jpg\"]", - false + authorServerId, + accountId, + "localUsername", + "username", + "displayName", + "blah", + "avatar", + "[\"tusky\": \"http://tusky.cool/emoji.jpg\"]", + false ) val reblogAuthor = if (reblog) { TimelineAccountEntity( - "R$authorServerId", - accountId, - "RlocalUsername", - "Rusername", - "RdisplayName", - "Rblah", - "Ravatar", - "[]", - false + "R$authorServerId", + accountId, + "RlocalUsername", + "Rusername", + "RdisplayName", + "Rblah", + "Ravatar", + "[]", + false ) } else null - val even = accountId % 2 == 0L val status = TimelineStatusEntity( - serverId = statusId.toString(), - url = "url$statusId", - timelineUserId = accountId, - authorServerId = authorServerId, - inReplyToId = "inReplyToId$statusId", - inReplyToAccountId = "inReplyToAccountId$statusId", - content = "Content!$statusId", - createdAt = createdAt, - emojis = "emojis$statusId", - reblogsCount = 1 * statusId.toInt(), - favouritesCount = 2 * statusId.toInt(), - reblogged = even, - favourited = !even, - bookmarked = false, - sensitive = even, - spoilerText = "spoier$statusId", - visibility = Status.Visibility.PRIVATE, - attachments = "attachments$accountId", - mentions = "mentions$accountId", - application = "application$accountId", - reblogServerId = if (reblog) (statusId * 100).toString() else null, - reblogAccountId = reblogAuthor?.serverId, - poll = null, - muted = false + serverId = statusId.toString(), + url = "url$statusId", + timelineUserId = accountId, + authorServerId = authorServerId, + inReplyToId = "inReplyToId$statusId", + inReplyToAccountId = "inReplyToAccountId$statusId", + content = "Content!$statusId", + createdAt = createdAt, + emojis = "emojis$statusId", + reblogsCount = 1 * statusId.toInt(), + favouritesCount = 2 * statusId.toInt(), + reblogged = even, + favourited = !even, + bookmarked = false, + sensitive = even, + spoilerText = "spoier$statusId", + visibility = Status.Visibility.PRIVATE, + attachments = "attachments$accountId", + mentions = "mentions$accountId", + application = "application$accountId", + reblogServerId = if (reblog) (statusId * 100).toString() else null, + reblogAccountId = reblogAuthor?.serverId, + poll = null, + muted = false ) return Triple(status, author, reblogAuthor) } private fun createPlaceholder(serverId: String, timelineUserId: Long): TimelineStatusEntity { return TimelineStatusEntity( - serverId = serverId, - url = null, - timelineUserId = timelineUserId, - authorServerId = null, - inReplyToId = null, - inReplyToAccountId = null, - content = null, - createdAt = 0L, - emojis = null, - reblogsCount = 0, - favouritesCount = 0, - reblogged = false, - favourited = false, - bookmarked = false, - sensitive = false, - spoilerText = null, - visibility = null, - attachments = null, - mentions = null, - application = null, - reblogServerId = null, - reblogAccountId = null, - poll = null, - muted = false + serverId = serverId, + url = null, + timelineUserId = timelineUserId, + authorServerId = null, + inReplyToId = null, + inReplyToAccountId = null, + content = null, + createdAt = 0L, + emojis = null, + reblogsCount = 0, + favouritesCount = 0, + reblogged = false, + favourited = false, + bookmarked = false, + sensitive = false, + spoilerText = null, + visibility = null, + attachments = null, + mentions = null, + application = null, + reblogServerId = null, + reblogAccountId = null, + poll = null, + muted = false ) } private fun TimelineStatusWithAccount.toTriple() = Triple(status, account, reblogAccount) -} \ No newline at end of file +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6eba0ce31..2ba8c9f25 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -35,9 +35,6 @@ android:resource="@xml/share_shortcuts" /> - @@ -124,7 +121,7 @@ val type = when (v.id) { R.id.accountFollowers -> AccountListActivity.Type.FOLLOWERS @@ -236,19 +247,16 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI override fun onTabUnselected(tab: TabLayout.Tab?) {} override fun onTabSelected(tab: TabLayout.Tab?) {} - }) } private fun setupToolbar() { // set toolbar top margin according to system window insets - binding.accountCoordinatorLayout.setOnApplyWindowInsetsListener { _, insets -> - val top = insets.systemWindowInsetTop - - val toolbarParams = binding.accountToolbar.layoutParams as CollapsingToolbarLayout.LayoutParams + ViewCompat.setOnApplyWindowInsetsListener(binding.accountCoordinatorLayout) { _, insets -> + val top = insets.getInsets(systemBars()).top + val toolbarParams = binding.accountToolbar.layoutParams as ViewGroup.MarginLayoutParams toolbarParams.topMargin = top - - insets.consumeSystemWindowInsets() + WindowInsetsCompat.CONSUMED } // Setup the toolbar. @@ -271,8 +279,8 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI fillColor = ColorStateList.valueOf(toolbarColor) elevation = appBarElevation shapeAppearanceModel = ShapeAppearanceModel.builder() - .setAllCornerSizes(resources.getDimension(R.dimen.account_avatar_background_radius)) - .build() + .setAllCornerSizes(resources.getDimension(R.dimen.account_avatar_background_radius)) + .build() } binding.accountAvatarImageView.background = avatarBackground @@ -319,12 +327,10 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI binding.swipeToRefreshLayout.isEnabled = verticalOffset == 0 } }) - } private fun makeNotificationBarTransparent() { - val decorView = window.decorView - decorView.systemUiVisibility = decorView.systemUiVisibility or View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + WindowCompat.setDecorFitsSystemWindows(window, false) window.statusBarColor = statusBarColorTransparent } @@ -337,8 +343,8 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI is Success -> onAccountChanged(it.data) is Error -> { Snackbar.make(binding.accountCoordinatorLayout, R.string.error_generic, Snackbar.LENGTH_LONG) - .setAction(R.string.action_retry) { viewModel.refresh() } - .show() + .setAction(R.string.action_retry) { viewModel.refresh() } + .show() } } } @@ -350,15 +356,17 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI if (it is Error) { Snackbar.make(binding.accountCoordinatorLayout, R.string.error_generic, Snackbar.LENGTH_LONG) - .setAction(R.string.action_retry) { viewModel.refresh() } - .show() + .setAction(R.string.action_retry) { viewModel.refresh() } + .show() } - } - viewModel.accountFieldData.observe(this, { - accountFieldAdapter.fields = it - accountFieldAdapter.notifyDataSetChanged() - }) + viewModel.accountFieldData.observe( + this, + { + accountFieldAdapter.fields = it + accountFieldAdapter.notifyDataSetChanged() + } + ) viewModel.noteSaved.observe(this) { binding.saveNoteInfo.visible(it, View.INVISIBLE) } @@ -372,9 +380,12 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI viewModel.refresh() adapter.refreshContent() } - viewModel.isRefreshing.observe(this, { isRefreshing -> - binding.swipeToRefreshLayout.isRefreshing = isRefreshing == true - }) + viewModel.isRefreshing.observe( + this, + { isRefreshing -> + binding.swipeToRefreshLayout.isRefreshing = isRefreshing == true + } + ) binding.swipeToRefreshLayout.setColorSchemeResources(R.color.tusky_blue) } @@ -415,18 +426,17 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI loadedAccount?.let { account -> loadAvatar( - account.avatar, - binding.accountAvatarImageView, - resources.getDimensionPixelSize(R.dimen.avatar_radius_94dp), - animateAvatar + account.avatar, + binding.accountAvatarImageView, + resources.getDimensionPixelSize(R.dimen.avatar_radius_94dp), + animateAvatar ) Glide.with(this) - .asBitmap() - .load(account.header) - .centerCrop() - .into(binding.accountHeaderImageView) - + .asBitmap() + .load(account.header) + .centerCrop() + .into(binding.accountHeaderImageView) binding.accountAvatarImageView.setOnClickListener { avatarView -> val intent = ViewMediaActivity.newSingleImageIntent(avatarView.context, account.avatar) @@ -484,7 +494,6 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI binding.accountMovedText.setCompoundDrawablesRelativeWithIntrinsicBounds(movedIcon, null, null, null) } - } /** @@ -560,8 +569,9 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI // because subscribing is Pleroma extension, enable it __only__ when we have non-null subscribing field // it's also now supported in Mastodon 3.3.0rc but called notifying and use different API call - if (!viewModel.isSelf && followState == FollowState.FOLLOWING - && (relation.subscribing != null || relation.notifying != null)) { + if (!viewModel.isSelf && followState == FollowState.FOLLOWING && + (relation.subscribing != null || relation.notifying != null) + ) { binding.accountSubscribeButton.show() binding.accountSubscribeButton.setOnClickListener { viewModel.changeSubscribingState() @@ -695,11 +705,9 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI } else { getString(R.string.action_show_reblogs) } - } else { menu.removeItem(R.id.action_show_reblogs) } - } else { // It shouldn't be possible to block, mute or report yourself. menu.removeItem(R.id.action_block) @@ -714,18 +722,18 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI private fun showFollowRequestPendingDialog() { AlertDialog.Builder(this) - .setMessage(R.string.dialog_message_cancel_follow_request) - .setPositiveButton(android.R.string.ok) { _, _ -> viewModel.changeFollowState() } - .setNegativeButton(android.R.string.cancel, null) - .show() + .setMessage(R.string.dialog_message_cancel_follow_request) + .setPositiveButton(android.R.string.ok) { _, _ -> viewModel.changeFollowState() } + .setNegativeButton(android.R.string.cancel, null) + .show() } private fun showUnfollowWarningDialog() { AlertDialog.Builder(this) - .setMessage(R.string.dialog_unfollow_warning) - .setPositiveButton(android.R.string.ok) { _, _ -> viewModel.changeFollowState() } - .setNegativeButton(android.R.string.cancel, null) - .show() + .setMessage(R.string.dialog_unfollow_warning) + .setPositiveButton(android.R.string.ok) { _, _ -> viewModel.changeFollowState() } + .setNegativeButton(android.R.string.cancel, null) + .show() } private fun toggleBlockDomain(instance: String) { @@ -733,20 +741,20 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI viewModel.unblockDomain(instance) } else { AlertDialog.Builder(this) - .setMessage(getString(R.string.mute_domain_warning, instance)) - .setPositiveButton(getString(R.string.mute_domain_warning_dialog_ok)) { _, _ -> viewModel.blockDomain(instance) } - .setNegativeButton(android.R.string.cancel, null) - .show() + .setMessage(getString(R.string.mute_domain_warning, instance)) + .setPositiveButton(getString(R.string.mute_domain_warning_dialog_ok)) { _, _ -> viewModel.blockDomain(instance) } + .setNegativeButton(android.R.string.cancel, null) + .show() } } private fun toggleBlock() { if (viewModel.relationshipData.value?.data?.blocking != true) { AlertDialog.Builder(this) - .setMessage(getString(R.string.dialog_block_warning, loadedAccount?.username)) - .setPositiveButton(android.R.string.ok) { _, _ -> viewModel.changeBlockState() } - .setNegativeButton(android.R.string.cancel, null) - .show() + .setMessage(getString(R.string.dialog_block_warning, loadedAccount?.username)) + .setPositiveButton(android.R.string.ok) { _, _ -> viewModel.changeBlockState() } + .setNegativeButton(android.R.string.cancel, null) + .show() } else { viewModel.changeBlockState() } @@ -756,8 +764,8 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI if (viewModel.relationshipData.value?.data?.muting != true) { loadedAccount?.let { showMuteAccountDialog( - this, - it.username + this, + it.username ) { notifications, duration -> viewModel.muteAccount(notifications, duration) } @@ -769,8 +777,10 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI private fun mention() { loadedAccount?.let { - val intent = ComposeActivity.startIntent(this, - ComposeActivity.ComposeOptions(mentionedUsernames = setOf(it.username))) + val intent = ComposeActivity.startIntent( + this, + ComposeActivity.ComposeOptions(mentionedUsernames = setOf(it.username)) + ) startActivity(intent) } } @@ -846,5 +856,4 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI return intent } } - } diff --git a/app/src/main/java/com/keylesspalace/tusky/AccountListActivity.kt b/app/src/main/java/com/keylesspalace/tusky/AccountListActivity.kt index 7f00150f4..ca23f7912 100644 --- a/app/src/main/java/com/keylesspalace/tusky/AccountListActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/AccountListActivity.kt @@ -64,9 +64,9 @@ class AccountListActivity : BaseActivity(), HasAndroidInjector { } supportFragmentManager - .beginTransaction() - .replace(R.id.fragment_container, AccountListFragment.newInstance(type, id, accountLocked)) - .commit() + .beginTransaction() + .replace(R.id.fragment_container, AccountListFragment.newInstance(type, id, accountLocked)) + .commit() } override fun androidInjector() = dispatchingAndroidInjector diff --git a/app/src/main/java/com/keylesspalace/tusky/AccountsInListFragment.kt b/app/src/main/java/com/keylesspalace/tusky/AccountsInListFragment.kt index df381aa17..02fa07382 100644 --- a/app/src/main/java/com/keylesspalace/tusky/AccountsInListFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/AccountsInListFragment.kt @@ -28,18 +28,24 @@ import androidx.preference.PreferenceManager import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.ListAdapter +import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from +import autodispose2.autoDispose import com.keylesspalace.tusky.databinding.FragmentAccountsInListBinding import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.settings.PrefKeys -import com.keylesspalace.tusky.util.* +import com.keylesspalace.tusky.util.BindingHolder +import com.keylesspalace.tusky.util.Either +import com.keylesspalace.tusky.util.emojify +import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.loadAvatar +import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel import com.keylesspalace.tusky.viewmodel.State -import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from -import com.uber.autodispose.autoDispose -import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import java.io.IOException import javax.inject.Inject @@ -93,19 +99,19 @@ class AccountsInListFragment : DialogFragment(), Injectable { binding.accountsSearchRecycler.adapter = searchAdapter viewModel.state - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(from(this)) - .subscribe { state -> - adapter.submitList(state.accounts.asRightOrNull() ?: listOf()) + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(from(this)) + .subscribe { state -> + adapter.submitList(state.accounts.asRightOrNull() ?: listOf()) - when (state.accounts) { - is Either.Right -> binding.messageView.hide() - is Either.Left -> handleError(state.accounts.value) - } - - setupSearchView(state) + when (state.accounts) { + is Either.Right -> binding.messageView.hide() + is Either.Left -> handleError(state.accounts.value) } + setupSearchView(state) + } + binding.searchView.isSubmitButtonEnabled = true binding.searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String?): Boolean { @@ -146,11 +152,15 @@ class AccountsInListFragment : DialogFragment(), Injectable { viewModel.load(listId) } if (error is IOException) { - binding.messageView.setup(R.drawable.elephant_offline, - R.string.error_network, retryAction) + binding.messageView.setup( + R.drawable.elephant_offline, + R.string.error_network, retryAction + ) } else { - binding.messageView.setup(R.drawable.elephant_error, - R.string.error_generic, retryAction) + binding.messageView.setup( + R.drawable.elephant_error, + R.string.error_generic, retryAction + ) } } @@ -184,7 +194,7 @@ class AccountsInListFragment : DialogFragment(), Injectable { onRemoveFromList(getItem(holder.bindingAdapterPosition).id) } binding.rejectButton.contentDescription = - binding.root.context.getString(R.string.action_remove_from_list) + binding.root.context.getString(R.string.action_remove_from_list) return holder } @@ -203,8 +213,8 @@ class AccountsInListFragment : DialogFragment(), Injectable { } override fun areContentsTheSame(oldItem: AccountInfo, newItem: AccountInfo): Boolean { - return oldItem.second == newItem.second - && oldItem.first.deepEquals(newItem.first) + return oldItem.second == newItem.second && + oldItem.first.deepEquals(newItem.first) } } @@ -260,4 +270,4 @@ class AccountsInListFragment : DialogFragment(), Injectable { return AccountsInListFragment().apply { arguments = args } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java index 92994f165..e348f0364 100644 --- a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java @@ -199,6 +199,7 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab @Override public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); if (requesters.containsKey(requestCode)) { PermissionRequester requester = requesters.remove(requestCode); requester.onRequestPermissionsResult(permissions, grantResults); diff --git a/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt b/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt index 57d815652..dcc167608 100644 --- a/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt @@ -22,12 +22,12 @@ import android.widget.LinearLayout import android.widget.Toast import androidx.annotation.VisibleForTesting import androidx.lifecycle.Lifecycle +import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider +import autodispose2.autoDispose import com.google.android.material.bottomsheet.BottomSheetBehavior import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.LinkHelper -import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider -import com.uber.autodispose.autoDispose -import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import java.net.URI import java.net.URISyntaxException import javax.inject.Inject @@ -62,7 +62,6 @@ abstract class BottomSheetActivity : BaseActivity() { override fun onSlide(bottomSheet: View, slideOffset: Float) {} }) - } open fun viewUrl(url: String, lookupFallbackBehavior: PostLookupFallbackBehavior = PostLookupFallbackBehavior.OPEN_IN_BROWSER, text: String = "") { @@ -77,11 +76,12 @@ abstract class BottomSheetActivity : BaseActivity() { } mastodonApi.searchObservable( - query = url, - resolve = true + query = url, + resolve = true ).observeOn(AndroidSchedulers.mainThread()) - .autoDispose(AndroidLifecycleScopeProvider.from(this, Lifecycle.Event.ON_DESTROY)) - .subscribe({ (accounts, statuses) -> + .autoDispose(AndroidLifecycleScopeProvider.from(this, Lifecycle.Event.ON_DESTROY)) + .subscribe( + { (accounts, statuses) -> if (getCancelSearchRequested(url)) { return@subscribe } @@ -97,12 +97,14 @@ abstract class BottomSheetActivity : BaseActivity() { } performUrlFallbackAction(url, lookupFallbackBehavior) - }, { + }, + { if (!getCancelSearchRequested(url)) { onEndSearch(url) performUrlFallbackAction(url, lookupFallbackBehavior) } - }) + } + ) onBeginSearch(url) } @@ -194,21 +196,22 @@ fun looksLikeMastodonUrl(urlString: String): Boolean { } if (uri.query != null || - uri.fragment != null || - uri.path == null) { + uri.fragment != null || + uri.path == null + ) { return false } val path = uri.path return path.matches("^/@[^/]+$".toRegex()) || - path.matches("^/@[^/]+/\\d+$".toRegex()) || - path.matches("^/users/\\w+$".toRegex()) || - path.matches("^/notice/[a-zA-Z0-9]+$".toRegex()) || - path.matches("^/objects/[-a-f0-9]+$".toRegex()) || - path.matches("^/notes/[a-z0-9]+$".toRegex()) || - path.matches("^/display/[-a-f0-9]+$".toRegex()) || - path.matches("^/profile/\\w+$".toRegex()) || - path.matches("^/users/[^/]+/statuses/\\d+$".toRegex()) + path.matches("^/@[^/]+/\\d+$".toRegex()) || + path.matches("^/users/\\w+$".toRegex()) || + path.matches("^/notice/[a-zA-Z0-9]+$".toRegex()) || + path.matches("^/objects/[-a-f0-9]+$".toRegex()) || + path.matches("^/notes/[a-z0-9]+$".toRegex()) || + path.matches("^/display/[-a-f0-9]+$".toRegex()) || + path.matches("^/profile/\\w+$".toRegex()) || + path.matches("^/users/[^/]+/statuses/\\d+$".toRegex()) } enum class PostLookupFallbackBehavior { diff --git a/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt b/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt index 4c6f85acd..1f2826e19 100644 --- a/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt @@ -36,18 +36,24 @@ import androidx.recyclerview.widget.LinearLayoutManager import com.bumptech.glide.Glide import com.bumptech.glide.load.resource.bitmap.FitCenter import com.bumptech.glide.load.resource.bitmap.RoundedCorners +import com.canhub.cropper.CropImage import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.adapter.AccountFieldEditAdapter import com.keylesspalace.tusky.databinding.ActivityEditProfileBinding import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.ViewModelFactory -import com.keylesspalace.tusky.util.* +import com.keylesspalace.tusky.util.Error +import com.keylesspalace.tusky.util.Loading +import com.keylesspalace.tusky.util.Resource +import com.keylesspalace.tusky.util.Success +import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.viewmodel.EditProfileViewModel import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.sizeDp -import com.theartofdev.edmodo.cropper.CropImage import javax.inject.Inject class EditProfileActivity : BaseActivity(), Injectable { @@ -110,11 +116,11 @@ class EditProfileActivity : BaseActivity(), Injectable { binding.addFieldButton.setOnClickListener { accountFieldEditAdapter.addField() - if(accountFieldEditAdapter.itemCount >= MAX_ACCOUNT_FIELDS) { + if (accountFieldEditAdapter.itemCount >= MAX_ACCOUNT_FIELDS) { it.isVisible = false } - binding.scrollView.post{ + binding.scrollView.post { binding.scrollView.smoothScrollTo(0, it.bottom) } } @@ -134,23 +140,22 @@ class EditProfileActivity : BaseActivity(), Injectable { accountFieldEditAdapter.setFields(me.source?.fields ?: emptyList()) binding.addFieldButton.isEnabled = me.source?.fields?.size ?: 0 < MAX_ACCOUNT_FIELDS - if(viewModel.avatarData.value == null) { + if (viewModel.avatarData.value == null) { Glide.with(this) - .load(me.avatar) - .placeholder(R.drawable.avatar_default) - .transform( - FitCenter(), - RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_80dp)) - ) - .into(binding.avatarPreview) + .load(me.avatar) + .placeholder(R.drawable.avatar_default) + .transform( + FitCenter(), + RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_80dp)) + ) + .into(binding.avatarPreview) } - if(viewModel.headerData.value == null) { + if (viewModel.headerData.value == null) { Glide.with(this) - .load(me.header) - .into(binding.headerPreview) + .load(me.header) + .into(binding.headerPreview) } - } } is Error -> { @@ -159,7 +164,6 @@ class EditProfileActivity : BaseActivity(), Injectable { viewModel.obtainProfile() } snackbar.show() - } } } @@ -179,20 +183,22 @@ class EditProfileActivity : BaseActivity(), Injectable { observeImage(viewModel.avatarData, binding.avatarPreview, binding.avatarProgressBar, true) observeImage(viewModel.headerData, binding.headerPreview, binding.headerProgressBar, false) - viewModel.saveData.observe(this, { - when(it) { - is Success -> { - finish() - } - is Loading -> { - binding.saveProgressBar.visibility = View.VISIBLE - } - is Error -> { - onSaveFailure(it.errorMessage) + viewModel.saveData.observe( + this, + { + when (it) { + is Success -> { + finish() + } + is Loading -> { + binding.saveProgressBar.visibility = View.VISIBLE + } + is Error -> { + onSaveFailure(it.errorMessage) + } } } - }) - + ) } override fun onSaveInstanceState(outState: Bundle) { @@ -202,50 +208,56 @@ class EditProfileActivity : BaseActivity(), Injectable { override fun onStop() { super.onStop() - if(!isFinishing) { - viewModel.updateProfile(binding.displayNameEditText.text.toString(), - binding.noteEditText.text.toString(), - binding.lockedCheckBox.isChecked, - accountFieldEditAdapter.getFieldData()) + if (!isFinishing) { + viewModel.updateProfile( + binding.displayNameEditText.text.toString(), + binding.noteEditText.text.toString(), + binding.lockedCheckBox.isChecked, + accountFieldEditAdapter.getFieldData() + ) } } - private fun observeImage(liveData: LiveData>, - imageView: ImageView, - progressBar: View, - roundedCorners: Boolean) { - liveData.observe(this, { + private fun observeImage( + liveData: LiveData>, + imageView: ImageView, + progressBar: View, + roundedCorners: Boolean + ) { + liveData.observe( + this, + { - when (it) { - is Success -> { - val glide = Glide.with(imageView) + when (it) { + is Success -> { + val glide = Glide.with(imageView) .load(it.data) - if (roundedCorners) { - glide.transform( - FitCenter(), - RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_80dp)) - ) - } + if (roundedCorners) { + glide.transform( + FitCenter(), + RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_80dp)) + ) + } - glide.into(imageView) + glide.into(imageView) - imageView.show() - progressBar.hide() - } - is Loading -> { - progressBar.show() - } - is Error -> { - progressBar.hide() - if(!it.consumed) { - onResizeFailure() - it.consumed = true + imageView.show() + progressBar.hide() + } + is Loading -> { + progressBar.show() + } + is Error -> { + progressBar.hide() + if (!it.consumed) { + onResizeFailure() + it.consumed = true + } } - } } - }) + ) } private fun onMediaPick(pickType: PickType) { @@ -261,8 +273,11 @@ class EditProfileActivity : BaseActivity(), Injectable { } } - override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, - grantResults: IntArray) { + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray + ) { when (requestCode) { PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE -> { if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { @@ -307,14 +322,16 @@ class EditProfileActivity : BaseActivity(), Injectable { private fun save() { if (currentlyPicking != PickType.NOTHING) { - return + return } - viewModel.save(binding.displayNameEditText.text.toString(), - binding.noteEditText.text.toString(), - binding.lockedCheckBox.isChecked, - accountFieldEditAdapter.getFieldData(), - this) + viewModel.save( + binding.displayNameEditText.text.toString(), + binding.noteEditText.text.toString(), + binding.lockedCheckBox.isChecked, + accountFieldEditAdapter.getFieldData(), + this + ) } private fun onSaveFailure(msg: String?) { @@ -352,10 +369,10 @@ class EditProfileActivity : BaseActivity(), Injectable { AVATAR_PICK_RESULT -> { if (resultCode == Activity.RESULT_OK && data != null) { CropImage.activity(data.data) - .setInitialCropWindowPaddingRatio(0f) - .setOutputCompressFormat(Bitmap.CompressFormat.PNG) - .setAspectRatio(AVATAR_SIZE, AVATAR_SIZE) - .start(this) + .setInitialCropWindowPaddingRatio(0f) + .setOutputCompressFormat(Bitmap.CompressFormat.PNG) + .setAspectRatio(AVATAR_SIZE, AVATAR_SIZE) + .start(this) } else { endMediaPicking() } @@ -363,10 +380,10 @@ class EditProfileActivity : BaseActivity(), Injectable { HEADER_PICK_RESULT -> { if (resultCode == Activity.RESULT_OK && data != null) { CropImage.activity(data.data) - .setInitialCropWindowPaddingRatio(0f) - .setOutputCompressFormat(Bitmap.CompressFormat.PNG) - .setAspectRatio(HEADER_WIDTH, HEADER_HEIGHT) - .start(this) + .setInitialCropWindowPaddingRatio(0f) + .setOutputCompressFormat(Bitmap.CompressFormat.PNG) + .setAspectRatio(HEADER_WIDTH, HEADER_HEIGHT) + .start(this) } else { endMediaPicking() } @@ -374,7 +391,7 @@ class EditProfileActivity : BaseActivity(), Injectable { CropImage.CROP_IMAGE_ACTIVITY_REQUEST_CODE -> { val result = CropImage.getActivityResult(data) when (resultCode) { - Activity.RESULT_OK -> beginResize(result.uri) + Activity.RESULT_OK -> beginResize(result?.uriContent) CropImage.CROP_IMAGE_ACTIVITY_RESULT_ERROR_CODE -> onResizeFailure() else -> endMediaPicking() } @@ -382,7 +399,12 @@ class EditProfileActivity : BaseActivity(), Injectable { } } - private fun beginResize(uri: Uri) { + private fun beginResize(uri: Uri?) { + if (uri == null) { + currentlyPicking = PickType.NOTHING + return + } + beginMediaPicking() when (currentlyPicking) { @@ -398,12 +420,10 @@ class EditProfileActivity : BaseActivity(), Injectable { } currentlyPicking = PickType.NOTHING - } private fun onResizeFailure() { Snackbar.make(binding.avatarButton, R.string.error_media_upload_sending, Snackbar.LENGTH_LONG).show() endMediaPicking() } - } diff --git a/app/src/main/java/com/keylesspalace/tusky/FiltersActivity.kt b/app/src/main/java/com/keylesspalace/tusky/FiltersActivity.kt index 7e91db07d..d6de5d8ec 100644 --- a/app/src/main/java/com/keylesspalace/tusky/FiltersActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/FiltersActivity.kt @@ -5,6 +5,7 @@ import android.widget.AdapterView import android.widget.ArrayAdapter import android.widget.Toast import androidx.appcompat.app.AlertDialog +import androidx.lifecycle.lifecycleScope import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.databinding.ActivityFiltersBinding @@ -14,6 +15,8 @@ import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.viewBinding +import kotlinx.coroutines.launch +import kotlinx.coroutines.rx3.await import okhttp3.ResponseBody import retrofit2.Call import retrofit2.Callback @@ -21,7 +24,7 @@ import retrofit2.Response import java.io.IOException import javax.inject.Inject -class FiltersActivity: BaseActivity() { +class FiltersActivity : BaseActivity() { @Inject lateinit var api: MastodonApi @@ -30,7 +33,7 @@ class FiltersActivity: BaseActivity() { private val binding by viewBinding(ActivityFiltersBinding::inflate) - private lateinit var context : String + private lateinit var context: String private lateinit var filters: MutableList override fun onCreate(savedInstanceState: Bundle?) { @@ -54,7 +57,7 @@ class FiltersActivity: BaseActivity() { private fun updateFilter(filter: Filter, itemIndex: Int) { api.updateFilter(filter.id, filter.phrase, filter.context, filter.irreversible, filter.wholeWord, filter.expiresAt) - .enqueue(object: Callback{ + .enqueue(object : Callback { override fun onFailure(call: Call, t: Throwable) { Toast.makeText(this@FiltersActivity, "Error updating filter '${filter.phrase}'", Toast.LENGTH_SHORT).show() } @@ -76,7 +79,7 @@ class FiltersActivity: BaseActivity() { val filter = filters[itemIndex] if (filter.context.size == 1) { // This is the only context for this filter; delete it - api.deleteFilter(filters[itemIndex].id).enqueue(object: Callback { + api.deleteFilter(filters[itemIndex].id).enqueue(object : Callback { override fun onFailure(call: Call, t: Throwable) { Toast.makeText(this@FiltersActivity, "Error updating filter '${filters[itemIndex].phrase}'", Toast.LENGTH_SHORT).show() } @@ -90,17 +93,19 @@ class FiltersActivity: BaseActivity() { } else { // Keep the filter, but remove it from this context val oldFilter = filters[itemIndex] - val newFilter = Filter(oldFilter.id, oldFilter.phrase, oldFilter.context.filter { c -> c != context }, - oldFilter.expiresAt, oldFilter.irreversible, oldFilter.wholeWord) + val newFilter = Filter( + oldFilter.id, oldFilter.phrase, oldFilter.context.filter { c -> c != context }, + oldFilter.expiresAt, oldFilter.irreversible, oldFilter.wholeWord + ) updateFilter(newFilter, itemIndex) } } private fun createFilter(phrase: String, wholeWord: Boolean) { - api.createFilter(phrase, listOf(context), false, wholeWord, "").enqueue(object: Callback { + api.createFilter(phrase, listOf(context), false, wholeWord, "").enqueue(object : Callback { override fun onResponse(call: Call, response: Response) { val filterResponse = response.body() - if(response.isSuccessful && filterResponse != null) { + if (response.isSuccessful && filterResponse != null) { filters.add(filterResponse) refreshFilterDisplay() eventHub.dispatch(PreferenceChangedEvent(context)) @@ -119,13 +124,13 @@ class FiltersActivity: BaseActivity() { val binding = DialogFilterBinding.inflate(layoutInflater) binding.phraseWholeWord.isChecked = true AlertDialog.Builder(this@FiltersActivity) - .setTitle(R.string.filter_addition_dialog_title) - .setView(binding.root) - .setPositiveButton(android.R.string.ok){ _, _ -> - createFilter(binding.phraseEditText.text.toString(), binding.phraseWholeWord.isChecked) - } - .setNeutralButton(android.R.string.cancel, null) - .show() + .setTitle(R.string.filter_addition_dialog_title) + .setView(binding.root) + .setPositiveButton(android.R.string.ok) { _, _ -> + createFilter(binding.phraseEditText.text.toString(), binding.phraseWholeWord.isChecked) + } + .setNeutralButton(android.R.string.cancel, null) + .show() } private fun setupEditDialogForItem(itemIndex: Int) { @@ -135,19 +140,21 @@ class FiltersActivity: BaseActivity() { binding.phraseWholeWord.isChecked = filter.wholeWord AlertDialog.Builder(this@FiltersActivity) - .setTitle(R.string.filter_edit_dialog_title) - .setView(binding.root) - .setPositiveButton(R.string.filter_dialog_update_button) { _, _ -> - val oldFilter = filters[itemIndex] - val newFilter = Filter(oldFilter.id, binding.phraseEditText.text.toString(), oldFilter.context, - oldFilter.expiresAt, oldFilter.irreversible, binding.phraseWholeWord.isChecked) - updateFilter(newFilter, itemIndex) - } - .setNegativeButton(R.string.filter_dialog_remove_button) { _, _ -> - deleteFilter(itemIndex) - } - .setNeutralButton(android.R.string.cancel, null) - .show() + .setTitle(R.string.filter_edit_dialog_title) + .setView(binding.root) + .setPositiveButton(R.string.filter_dialog_update_button) { _, _ -> + val oldFilter = filters[itemIndex] + val newFilter = Filter( + oldFilter.id, binding.phraseEditText.text.toString(), oldFilter.context, + oldFilter.expiresAt, oldFilter.irreversible, binding.phraseWholeWord.isChecked + ) + updateFilter(newFilter, itemIndex) + } + .setNegativeButton(R.string.filter_dialog_remove_button) { _, _ -> + deleteFilter(itemIndex) + } + .setNeutralButton(android.R.string.cancel, null) + .show() } private fun refreshFilterDisplay() { @@ -162,41 +169,37 @@ class FiltersActivity: BaseActivity() { binding.addFilterButton.hide() binding.filterProgressBar.show() - api.getFilters().enqueue(object : Callback> { - override fun onResponse(call: Call>, response: Response>) { - val filterResponse = response.body() - if(response.isSuccessful && filterResponse != null) { - - filters = filterResponse.filter { filter -> filter.context.contains(context) }.toMutableList() - refreshFilterDisplay() - - binding.filtersView.show() - binding.addFilterButton.show() - binding.filterProgressBar.hide() - } else { - binding.filterProgressBar.hide() - binding.filterMessageView.show() - binding.filterMessageView.setup(R.drawable.elephant_error, - R.string.error_generic) { loadFilters() } - } - } - - override fun onFailure(call: Call>, t: Throwable) { + lifecycleScope.launch { + val newFilters = try { + api.getFilters().await() + } catch (t: Exception) { binding.filterProgressBar.hide() binding.filterMessageView.show() if (t is IOException) { - binding.filterMessageView.setup(R.drawable.elephant_offline, - R.string.error_network) { loadFilters() } + binding.filterMessageView.setup( + R.drawable.elephant_offline, + R.string.error_network + ) { loadFilters() } } else { - binding.filterMessageView.setup(R.drawable.elephant_error, - R.string.error_generic) { loadFilters() } + binding.filterMessageView.setup( + R.drawable.elephant_error, + R.string.error_generic + ) { loadFilters() } } + return@launch } - }) + + filters = newFilters.filter { it.context.contains(context) }.toMutableList() + refreshFilterDisplay() + + binding.filtersView.show() + binding.addFilterButton.show() + binding.filterProgressBar.hide() + } } companion object { const val FILTERS_CONTEXT = "filters_context" const val FILTERS_TITLE = "filters_title" } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/LicenseActivity.kt b/app/src/main/java/com/keylesspalace/tusky/LicenseActivity.kt index d51593128..a9131417c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/LicenseActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/LicenseActivity.kt @@ -16,9 +16,9 @@ package com.keylesspalace.tusky import android.os.Bundle -import androidx.annotation.RawRes import android.util.Log import android.widget.TextView +import androidx.annotation.RawRes import com.keylesspalace.tusky.databinding.ActivityLicenseBinding import com.keylesspalace.tusky.util.IOUtils import java.io.BufferedReader @@ -42,7 +42,6 @@ class LicenseActivity : BaseActivity() { loadFileIntoTextView(R.raw.apache, binding.licenseApacheTextView) loadFileIntoTextView(R.raw.mit, binding.licenseMitTextView) - } private fun loadFileIntoTextView(@RawRes fileId: Int, textView: TextView) { diff --git a/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt b/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt index be995e9ee..e816a0a55 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt @@ -23,32 +23,48 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.* +import android.widget.EditText +import android.widget.FrameLayout +import android.widget.ImageButton +import android.widget.PopupMenu +import android.widget.TextView import androidx.activity.viewModels import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog -import androidx.recyclerview.widget.* +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView import at.connyduck.sparkbutton.helpers.Utils +import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from +import autodispose2.autoDispose import com.google.android.material.snackbar.Snackbar +import com.keylesspalace.tusky.components.timeline.TimelineViewModel import com.keylesspalace.tusky.databinding.ActivityListsBinding import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.entity.MastoList -import com.keylesspalace.tusky.fragment.TimelineFragment -import com.keylesspalace.tusky.util.* +import com.keylesspalace.tusky.util.ThemeUtils +import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.onTextChanged +import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.util.viewBinding +import com.keylesspalace.tusky.util.visible import com.keylesspalace.tusky.viewmodel.ListsViewModel -import com.keylesspalace.tusky.viewmodel.ListsViewModel.Event.* -import com.keylesspalace.tusky.viewmodel.ListsViewModel.LoadingState.* +import com.keylesspalace.tusky.viewmodel.ListsViewModel.Event +import com.keylesspalace.tusky.viewmodel.ListsViewModel.LoadingState.ERROR_NETWORK +import com.keylesspalace.tusky.viewmodel.ListsViewModel.LoadingState.ERROR_OTHER +import com.keylesspalace.tusky.viewmodel.ListsViewModel.LoadingState.INITIAL +import com.keylesspalace.tusky.viewmodel.ListsViewModel.LoadingState.LOADED +import com.keylesspalace.tusky.viewmodel.ListsViewModel.LoadingState.LOADING import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.sizeDp -import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from -import com.uber.autodispose.autoDispose import dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector -import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import javax.inject.Inject /** @@ -84,12 +100,13 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { binding.listsRecycler.adapter = adapter binding.listsRecycler.layoutManager = LinearLayoutManager(this) binding.listsRecycler.addItemDecoration( - DividerItemDecoration(this, DividerItemDecoration.VERTICAL)) + DividerItemDecoration(this, DividerItemDecoration.VERTICAL) + ) viewModel.state - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(from(this)) - .subscribe(this::update) + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(from(this)) + .subscribe(this::update) viewModel.retryLoading() binding.addListButton.setOnClickListener { @@ -97,15 +114,15 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { } viewModel.events.observeOn(AndroidSchedulers.mainThread()) - .autoDispose(from(this)) - .subscribe { event -> - @Suppress("WHEN_ENUM_CAN_BE_NULL_IN_JAVA") - when (event) { - CREATE_ERROR -> showMessage(R.string.error_create_list) - RENAME_ERROR -> showMessage(R.string.error_rename_list) - DELETE_ERROR -> showMessage(R.string.error_delete_list) - } + .autoDispose(from(this)) + .subscribe { event -> + @Suppress("WHEN_ENUM_CAN_BE_NULL_IN_JAVA") + when (event) { + Event.CREATE_ERROR -> showMessage(R.string.error_create_list) + Event.RENAME_ERROR -> showMessage(R.string.error_rename_list) + Event.DELETE_ERROR -> showMessage(R.string.error_delete_list) } + } } private fun showlistNameDialog(list: MastoList?) { @@ -115,17 +132,18 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { layout.addView(editText) val margin = Utils.dpToPx(this, 8) (editText.layoutParams as ViewGroup.MarginLayoutParams) - .setMargins(margin, margin, margin, 0) + .setMargins(margin, margin, margin, 0) val dialog = AlertDialog.Builder(this) - .setView(layout) - .setPositiveButton( - if (list == null) R.string.action_create_list - else R.string.action_rename_list) { _, _ -> - onPickedDialogName(editText.text, list?.id) - } - .setNegativeButton(android.R.string.cancel, null) - .show() + .setView(layout) + .setPositiveButton( + if (list == null) R.string.action_create_list + else R.string.action_rename_list + ) { _, _ -> + onPickedDialogName(editText.text, list?.id) + } + .setNegativeButton(android.R.string.cancel, null) + .show() val positiveButton = dialog.getButton(Dialog.BUTTON_POSITIVE) editText.onTextChanged { s, _, _, _ -> @@ -137,15 +155,14 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { private fun showListDeleteDialog(list: MastoList) { AlertDialog.Builder(this) - .setMessage(getString(R.string.dialog_delete_list_warning, list.title)) - .setPositiveButton(R.string.action_delete){ _, _ -> - viewModel.deleteList(list.id) - } - .setNegativeButton(android.R.string.cancel, null) - .show() + .setMessage(getString(R.string.dialog_delete_list_warning, list.title)) + .setPositiveButton(R.string.action_delete) { _, _ -> + viewModel.deleteList(list.id) + } + .setNegativeButton(android.R.string.cancel, null) + .show() } - private fun update(state: ListsViewModel.State) { adapter.submitList(state.lists) binding.progressBar.visible(state.loadingState == LOADING) @@ -166,8 +183,10 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { LOADED -> if (state.lists.isEmpty()) { binding.messageView.show() - binding.messageView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, - null) + binding.messageView.setup( + R.drawable.elephant_friend_empty, R.string.message_empty, + null + ) } else { binding.messageView.hide() } @@ -176,13 +195,14 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { private fun showMessage(@StringRes messageId: Int) { Snackbar.make( - binding.listsRecycler, messageId, Snackbar.LENGTH_SHORT + binding.listsRecycler, messageId, Snackbar.LENGTH_SHORT ).show() } private fun onListSelected(listId: String) { startActivityWithSlideInAnimation( - ModalTimelineActivity.newIntent(this, TimelineFragment.Kind.LIST, listId)) + ModalTimelineActivity.newIntent(this, TimelineViewModel.Kind.LIST, listId) + ) } private fun openListSettings(list: MastoList) { @@ -219,27 +239,28 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { } } - private inner class ListsAdapter - : ListAdapter(ListsDiffer) { + private inner class ListsAdapter : + ListAdapter(ListsDiffer) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ListViewHolder { return LayoutInflater.from(parent.context).inflate(R.layout.item_list, parent, false) - .let(this::ListViewHolder) - .apply { - val context = nameTextView.context - val iconColor = ThemeUtils.getColor(context, android.R.attr.textColorTertiary) - val icon = IconicsDrawable(context, GoogleMaterial.Icon.gmd_list).apply { sizeDp = 20; colorInt = iconColor } + .let(this::ListViewHolder) + .apply { + val context = nameTextView.context + val iconColor = ThemeUtils.getColor(context, android.R.attr.textColorTertiary) + val icon = IconicsDrawable(context, GoogleMaterial.Icon.gmd_list).apply { sizeDp = 20; colorInt = iconColor } - nameTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(icon, null, null, null) - } + nameTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(icon, null, null, null) + } } override fun onBindViewHolder(holder: ListViewHolder, position: Int) { holder.nameTextView.text = getItem(position).title } - private inner class ListViewHolder(view: View) : RecyclerView.ViewHolder(view), - View.OnClickListener { + private inner class ListViewHolder(view: View) : + RecyclerView.ViewHolder(view), + View.OnClickListener { val nameTextView: TextView = view.findViewById(R.id.list_name_textview) val moreButton: ImageButton = view.findViewById(R.id.editListButton) @@ -271,4 +292,4 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { companion object { fun newIntent(context: Context) = Intent(context, ListsActivity::class.java) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/LoginActivity.kt b/app/src/main/java/com/keylesspalace/tusky/LoginActivity.kt index 9f310c19a..0d5df274c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/LoginActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/LoginActivity.kt @@ -34,7 +34,9 @@ import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.entity.AccessToken import com.keylesspalace.tusky.entity.AppCredentials import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.util.* +import com.keylesspalace.tusky.util.ThemeUtils +import com.keylesspalace.tusky.util.getNonNullString +import com.keylesspalace.tusky.util.viewBinding import okhttp3.HttpUrl import retrofit2.Call import retrofit2.Callback @@ -62,28 +64,29 @@ class LoginActivity : BaseActivity(), Injectable { setContentView(binding.root) - if(savedInstanceState == null && BuildConfig.CUSTOM_INSTANCE.isNotBlank() && !isAdditionalLogin()) { + if (savedInstanceState == null && BuildConfig.CUSTOM_INSTANCE.isNotBlank() && !isAdditionalLogin()) { binding.domainEditText.setText(BuildConfig.CUSTOM_INSTANCE) binding.domainEditText.setSelection(BuildConfig.CUSTOM_INSTANCE.length) } - if(BuildConfig.CUSTOM_LOGO_URL.isNotBlank()) { + if (BuildConfig.CUSTOM_LOGO_URL.isNotBlank()) { Glide.with(binding.loginLogo) - .load(BuildConfig.CUSTOM_LOGO_URL) - .placeholder(null) - .into(binding.loginLogo) + .load(BuildConfig.CUSTOM_LOGO_URL) + .placeholder(null) + .into(binding.loginLogo) } preferences = getSharedPreferences( - getString(R.string.preferences_file_key), Context.MODE_PRIVATE) + getString(R.string.preferences_file_key), Context.MODE_PRIVATE + ) binding.loginButton.setOnClickListener { onButtonClick() } binding.whatsAnInstanceTextView.setOnClickListener { val dialog = AlertDialog.Builder(this) - .setMessage(R.string.dialog_whats_an_instance) - .setPositiveButton(R.string.action_close, null) - .show() + .setMessage(R.string.dialog_whats_an_instance) + .setPositiveButton(R.string.action_close, null) + .show() val textView = dialog.findViewById(android.R.id.message) textView?.movementMethod = LinkMovementMethod.getInstance() } @@ -95,7 +98,6 @@ class LoginActivity : BaseActivity(), Injectable { } else { binding.toolbar.visibility = View.GONE } - } override fun requiresLogin(): Boolean { @@ -104,7 +106,7 @@ class LoginActivity : BaseActivity(), Injectable { override fun finish() { super.finish() - if(isAdditionalLogin()) { + if (isAdditionalLogin()) { overridePendingTransition(R.anim.slide_from_left, R.anim.slide_to_right) } } @@ -129,8 +131,10 @@ class LoginActivity : BaseActivity(), Injectable { } val callback = object : Callback { - override fun onResponse(call: Call, - response: Response) { + override fun onResponse( + call: Call, + response: Response + ) { if (!response.isSuccessful) { binding.loginButton.isEnabled = true binding.domainTextInputLayout.error = getString(R.string.error_failed_app_registration) @@ -143,10 +147,10 @@ class LoginActivity : BaseActivity(), Injectable { val clientSecret = credentials.clientSecret preferences.edit() - .putString("domain", domain) - .putString("clientId", clientId) - .putString("clientSecret", clientSecret) - .apply() + .putString("domain", domain) + .putString("clientId", clientId) + .putString("clientSecret", clientSecret) + .apply() redirectUserToAuthorizeAndLogin(domain, clientId) } @@ -160,11 +164,12 @@ class LoginActivity : BaseActivity(), Injectable { } mastodonApi - .authenticateApp(domain, getString(R.string.app_name), oauthRedirectUri, - OAUTH_SCOPES, getString(R.string.tusky_website)) - .enqueue(callback) + .authenticateApp( + domain, getString(R.string.app_name), oauthRedirectUri, + OAUTH_SCOPES, getString(R.string.tusky_website) + ) + .enqueue(callback) setLoading(true) - } private fun redirectUserToAuthorizeAndLogin(domain: String, clientId: String) { @@ -172,10 +177,10 @@ class LoginActivity : BaseActivity(), Injectable { * login there, and the server will redirect back to the app with its response. */ val endpoint = MastodonApi.ENDPOINT_AUTHORIZE val parameters = mapOf( - "client_id" to clientId, - "redirect_uri" to oauthRedirectUri, - "response_type" to "code", - "scope" to OAUTH_SCOPES + "client_id" to clientId, + "redirect_uri" to oauthRedirectUri, + "response_type" to "code", + "scope" to OAUTH_SCOPES ) val url = "https://" + domain + endpoint + "?" + toQueryString(parameters) val uri = Uri.parse(url) @@ -219,31 +224,27 @@ class LoginActivity : BaseActivity(), Injectable { } else { setLoading(false) binding.domainTextInputLayout.error = getString(R.string.error_retrieving_oauth_token) - Log.e(TAG, String.format("%s %s", - getString(R.string.error_retrieving_oauth_token), - response.message())) + Log.e(TAG, "%s %s".format(getString(R.string.error_retrieving_oauth_token), response.message())) } } override fun onFailure(call: Call, t: Throwable) { setLoading(false) binding.domainTextInputLayout.error = getString(R.string.error_retrieving_oauth_token) - Log.e(TAG, String.format("%s %s", - getString(R.string.error_retrieving_oauth_token), - t.message)) + Log.e(TAG, "%s %s".format(getString(R.string.error_retrieving_oauth_token), t.message)) } } - mastodonApi.fetchOAuthToken(domain, clientId, clientSecret, redirectUri, code, - "authorization_code").enqueue(callback) + mastodonApi.fetchOAuthToken( + domain, clientId, clientSecret, redirectUri, code, + "authorization_code" + ).enqueue(callback) } else if (error != null) { /* Authorization failed. Put the error response where the user can read it and they * can try again. */ setLoading(false) binding.domainTextInputLayout.error = getString(R.string.error_authorization_denied) - Log.e(TAG, String.format("%s %s", - getString(R.string.error_authorization_denied), - error)) + Log.e(TAG, "%s %s".format(getString(R.string.error_authorization_denied), error)) } else { // This case means a junk response was received somehow. setLoading(false) @@ -335,14 +336,14 @@ class LoginActivity : BaseActivity(), Injectable { val navigationbarDividerColor = ThemeUtils.getColor(context, R.attr.dividerColor) val colorSchemeParams = CustomTabColorSchemeParams.Builder() - .setToolbarColor(toolbarColor) - .setNavigationBarColor(navigationbarColor) - .setNavigationBarDividerColor(navigationbarDividerColor) - .build() + .setToolbarColor(toolbarColor) + .setNavigationBarColor(navigationbarColor) + .setNavigationBarDividerColor(navigationbarDividerColor) + .build() val customTabsIntent = CustomTabsIntent.Builder() - .setDefaultColorSchemeParams(colorSchemeParams) - .build() + .setDefaultColorSchemeParams(colorSchemeParams) + .build() try { customTabsIntent.launchUrl(context, uri) diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt index 0c1b40b4e..9e8fd8e0c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt @@ -40,20 +40,20 @@ import androidx.appcompat.view.menu.MenuBuilder import androidx.appcompat.widget.PopupMenu import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.content.ContextCompat -import androidx.core.content.edit import androidx.core.content.pm.ShortcutManagerCompat import androidx.emoji.text.EmojiCompat import androidx.emoji.text.EmojiCompat.InitCallback import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope import androidx.preference.PreferenceManager import androidx.viewpager2.widget.MarginPageTransformer +import autodispose2.androidx.lifecycle.autoDispose import com.bumptech.glide.Glide import com.bumptech.glide.RequestManager import com.bumptech.glide.load.resource.bitmap.RoundedCorners import com.bumptech.glide.request.target.CustomTarget import com.bumptech.glide.request.target.FixedSizeDrawable import com.bumptech.glide.request.transition.Transition -import com.google.android.material.floatingactionbutton.FloatingActionButton import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayout.OnTabSelectedListener import com.google.android.material.tabs.TabLayoutMediator @@ -68,20 +68,25 @@ import com.keylesspalace.tusky.components.notifications.NotificationHelper import com.keylesspalace.tusky.components.preference.PreferencesActivity import com.keylesspalace.tusky.components.scheduled.ScheduledTootActivity import com.keylesspalace.tusky.components.search.SearchActivity +import com.keylesspalace.tusky.components.timeline.TimelineFragment import com.keylesspalace.tusky.databinding.ActivityMainBinding import com.keylesspalace.tusky.db.AccountEntity -import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.fragment.NotificationsFragment -import com.keylesspalace.tusky.fragment.SFragment -import com.keylesspalace.tusky.fragment.TimelineFragment import com.keylesspalace.tusky.interfaces.AccountSelectionListener import com.keylesspalace.tusky.interfaces.ActionButtonActivity import com.keylesspalace.tusky.interfaces.ReselectableFragment import com.keylesspalace.tusky.pager.MainPagerAdapter import com.keylesspalace.tusky.settings.PrefKeys -import com.keylesspalace.tusky.util.* +import com.keylesspalace.tusky.util.ThemeUtils +import com.keylesspalace.tusky.util.deleteStaleCachedMedia +import com.keylesspalace.tusky.util.emojify +import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.removeShortcut +import com.keylesspalace.tusky.util.updateShortcut +import com.keylesspalace.tusky.util.viewBinding +import com.keylesspalace.tusky.util.visible import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.utils.colorInt @@ -90,18 +95,27 @@ import com.mikepenz.materialdrawer.holder.BadgeStyle import com.mikepenz.materialdrawer.holder.ColorHolder import com.mikepenz.materialdrawer.holder.StringHolder import com.mikepenz.materialdrawer.iconics.iconicsIcon -import com.mikepenz.materialdrawer.model.* -import com.mikepenz.materialdrawer.model.interfaces.* +import com.mikepenz.materialdrawer.model.AbstractDrawerItem +import com.mikepenz.materialdrawer.model.DividerDrawerItem +import com.mikepenz.materialdrawer.model.PrimaryDrawerItem +import com.mikepenz.materialdrawer.model.ProfileDrawerItem +import com.mikepenz.materialdrawer.model.ProfileSettingDrawerItem +import com.mikepenz.materialdrawer.model.SecondaryDrawerItem +import com.mikepenz.materialdrawer.model.interfaces.IProfile +import com.mikepenz.materialdrawer.model.interfaces.descriptionRes +import com.mikepenz.materialdrawer.model.interfaces.descriptionText +import com.mikepenz.materialdrawer.model.interfaces.iconRes +import com.mikepenz.materialdrawer.model.interfaces.iconUrl +import com.mikepenz.materialdrawer.model.interfaces.nameRes +import com.mikepenz.materialdrawer.model.interfaces.nameText import com.mikepenz.materialdrawer.util.* import com.mikepenz.materialdrawer.widget.AccountHeaderView -import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider -import com.uber.autodispose.android.lifecycle.autoDispose -import com.uber.autodispose.autoDispose import dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector -import io.reactivex.Single -import io.reactivex.android.schedulers.AndroidSchedulers -import io.reactivex.schedulers.Schedulers +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers +import kotlinx.coroutines.launch import net.accelf.yuito.FooterDrawerItem import net.accelf.yuito.QuickTootViewModel import javax.inject.Inject @@ -119,9 +133,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje @Inject lateinit var conversationRepository: ConversationsRepository - @Inject - lateinit var appDb: AppDatabase - @Inject lateinit var draftHelper: DraftHelper @@ -158,10 +169,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje super.onCreate(savedInstanceState) val activeAccount = accountManager.activeAccount - if (activeAccount == null) { - // will be redirected to LoginActivity by BaseActivity - return - } + ?: return // will be redirected to LoginActivity by BaseActivity + var showNotificationTab = false if (intent != null) { /** there are two possibilities the accountId can be passed to MainActivity: @@ -186,19 +195,22 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje forwardShare(intent) } else { // No account was provided, show the chooser - showAccountChooserDialog(getString(R.string.action_share_as), true, object : AccountSelectionListener { - override fun onAccountSelected(account: AccountEntity) { - val requestedId = account.id - if (requestedId == activeAccount.id) { - // The correct account is already active - forwardShare(intent) - } else { - // A different account was requested, restart the activity - intent.putExtra(NotificationHelper.ACCOUNT_ID, requestedId) - changeAccount(requestedId, intent) + showAccountChooserDialog( + getString(R.string.action_share_as), true, + object : AccountSelectionListener { + override fun onAccountSelected(account: AccountEntity) { + val requestedId = account.id + if (requestedId == activeAccount.id) { + // The correct account is already active + forwardShare(intent) + } else { + // A different account was requested, restart the activity + intent.putExtra(NotificationHelper.ACCOUNT_ID, requestedId) + changeAccount(requestedId, intent) + } } } - }) + ) } } else if (accountRequested && savedInstanceState == null) { // user clicked a notification, show notification tab @@ -258,25 +270,24 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje NotificationHelper.disablePullNotifications(this) } eventHub.events - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(this, Lifecycle.Event.ON_DESTROY) - .subscribe { event: Event? -> - when (event) { - is ProfileEditedEvent -> onFetchUserInfoSuccess(event.newProfileData) - is MainTabsChangedEvent -> setupTabs(false) - is AnnouncementReadEvent -> { - unreadAnnouncementsCount-- - updateAnnouncementsBadge() - } + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(this, Lifecycle.Event.ON_DESTROY) + .subscribe { event: Event? -> + when (event) { + is ProfileEditedEvent -> onFetchUserInfoSuccess(event.newProfileData) + is MainTabsChangedEvent -> setupTabs(false) + is AnnouncementReadEvent -> { + unreadAnnouncementsCount-- + updateAnnouncementsBadge() } - binding.viewQuickToot.handleEvent(event) } + binding.viewQuickToot.handleEvent(event) + } Schedulers.io().scheduleDirect { // Flush old media that was cached for sharing deleteStaleCachedMedia(applicationContext.getExternalFilesDir("Tusky")) } - draftWarning() } override fun onResume() { @@ -382,12 +393,15 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje headerBackgroundScaleType = ImageView.ScaleType.CENTER_CROP currentHiddenInList = true onAccountHeaderListener = { _: View?, profile: IProfile, current: Boolean -> handleProfileClick(profile, current) } - addProfile(ProfileSettingDrawerItem().apply { - identifier = DRAWER_ITEM_ADD_ACCOUNT - nameRes = R.string.add_account_name - descriptionRes = R.string.add_account_description - iconicsIcon = GoogleMaterial.Icon.gmd_add - }, 0) + addProfile( + ProfileSettingDrawerItem().apply { + identifier = DRAWER_ITEM_ADD_ACCOUNT + nameRes = R.string.add_account_name + descriptionRes = R.string.add_account_description + iconicsIcon = GoogleMaterial.Icon.gmd_add + }, + 0 + ) attachToSliderView(binding.mainDrawer) dividerBelowHeader = false closeDrawerOnProfileListClick = true @@ -401,13 +415,13 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje override fun set(imageView: ImageView, uri: Uri, placeholder: Drawable, tag: String?) { if (animateAvatars) { glide.load(uri) - .placeholder(placeholder) - .into(imageView) + .placeholder(placeholder) + .into(imageView) } else { glide.asBitmap() - .load(uri) - .placeholder(placeholder) - .into(imageView) + .load(uri) + .placeholder(placeholder) + .into(imageView) } } @@ -427,103 +441,103 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje binding.mainDrawer.apply { tintStatusBar = true addItems( - primaryDrawerItem { - nameRes = R.string.action_edit_profile - iconicsIcon = GoogleMaterial.Icon.gmd_person - onClick = { - val intent = Intent(context, EditProfileActivity::class.java) - startActivityWithSlideInAnimation(intent) - } - }, - primaryDrawerItem { - nameRes = R.string.action_view_favourites - isSelectable = false - iconicsIcon = GoogleMaterial.Icon.gmd_star - onClick = { - val intent = StatusListActivity.newFavouritesIntent(context) - startActivityWithSlideInAnimation(intent) - } - }, - primaryDrawerItem { - nameRes = R.string.action_view_bookmarks - iconicsIcon = GoogleMaterial.Icon.gmd_bookmark - onClick = { - val intent = StatusListActivity.newBookmarksIntent(context) - startActivityWithSlideInAnimation(intent) - } - }, - primaryDrawerItem { - nameRes = R.string.action_view_follow_requests - iconicsIcon = GoogleMaterial.Icon.gmd_person_add - onClick = { - val intent = AccountListActivity.newIntent(context, AccountListActivity.Type.FOLLOW_REQUESTS, accountLocked = accountLocked) - startActivityWithSlideInAnimation(intent) - } - }, - primaryDrawerItem { - nameRes = R.string.action_lists - iconicsIcon = GoogleMaterial.Icon.gmd_list - onClick = { - startActivityWithSlideInAnimation(ListsActivity.newIntent(context)) - } - }, - primaryDrawerItem { - nameRes = R.string.action_access_saved_toot - iconRes = R.drawable.ic_notebook - onClick = { - val intent = DraftsActivity.newIntent(context) - startActivityWithSlideInAnimation(intent) - } - }, - primaryDrawerItem { - nameRes = R.string.action_access_scheduled_toot - iconRes = R.drawable.ic_access_time - onClick = { - startActivityWithSlideInAnimation(ScheduledTootActivity.newIntent(context)) - } - }, - primaryDrawerItem { - identifier = DRAWER_ITEM_ANNOUNCEMENTS - nameRes = R.string.title_announcements - iconRes = R.drawable.ic_bullhorn_24dp - onClick = { - startActivityWithSlideInAnimation(AnnouncementsActivity.newIntent(context)) - } - badgeStyle = BadgeStyle().apply { - textColor = ColorHolder.fromColor(ThemeUtils.getColor(this@MainActivity, R.attr.colorOnPrimary)) - color = ColorHolder.fromColor(ThemeUtils.getColor(this@MainActivity, R.attr.colorPrimary)) - } - }, - DividerDrawerItem(), - secondaryDrawerItem { - nameRes = R.string.action_view_account_preferences - iconRes = R.drawable.ic_account_settings - onClick = { - val intent = PreferencesActivity.newIntent(context, PreferencesActivity.ACCOUNT_PREFERENCES) - startActivityWithSlideInAnimation(intent) - } - }, - secondaryDrawerItem { - nameRes = R.string.action_view_preferences - iconicsIcon = GoogleMaterial.Icon.gmd_settings - onClick = { - val intent = PreferencesActivity.newIntent(context, PreferencesActivity.GENERAL_PREFERENCES) - startActivityWithSlideInAnimation(intent) - } - }, - secondaryDrawerItem { - nameRes = R.string.about_title_activity - iconicsIcon = GoogleMaterial.Icon.gmd_info - onClick = { - val intent = Intent(context, AboutActivity::class.java) - startActivityWithSlideInAnimation(intent) - } - }, - secondaryDrawerItem { - nameRes = R.string.action_logout - iconRes = R.drawable.ic_logout - onClick = ::logout + primaryDrawerItem { + nameRes = R.string.action_edit_profile + iconicsIcon = GoogleMaterial.Icon.gmd_person + onClick = { + val intent = Intent(context, EditProfileActivity::class.java) + startActivityWithSlideInAnimation(intent) } + }, + primaryDrawerItem { + nameRes = R.string.action_view_favourites + isSelectable = false + iconicsIcon = GoogleMaterial.Icon.gmd_star + onClick = { + val intent = StatusListActivity.newFavouritesIntent(context) + startActivityWithSlideInAnimation(intent) + } + }, + primaryDrawerItem { + nameRes = R.string.action_view_bookmarks + iconicsIcon = GoogleMaterial.Icon.gmd_bookmark + onClick = { + val intent = StatusListActivity.newBookmarksIntent(context) + startActivityWithSlideInAnimation(intent) + } + }, + primaryDrawerItem { + nameRes = R.string.action_view_follow_requests + iconicsIcon = GoogleMaterial.Icon.gmd_person_add + onClick = { + val intent = AccountListActivity.newIntent(context, AccountListActivity.Type.FOLLOW_REQUESTS, accountLocked = accountLocked) + startActivityWithSlideInAnimation(intent) + } + }, + primaryDrawerItem { + nameRes = R.string.action_lists + iconicsIcon = GoogleMaterial.Icon.gmd_list + onClick = { + startActivityWithSlideInAnimation(ListsActivity.newIntent(context)) + } + }, + primaryDrawerItem { + nameRes = R.string.action_access_drafts + iconRes = R.drawable.ic_notebook + onClick = { + val intent = DraftsActivity.newIntent(context) + startActivityWithSlideInAnimation(intent) + } + }, + primaryDrawerItem { + nameRes = R.string.action_access_scheduled_toot + iconRes = R.drawable.ic_access_time + onClick = { + startActivityWithSlideInAnimation(ScheduledTootActivity.newIntent(context)) + } + }, + primaryDrawerItem { + identifier = DRAWER_ITEM_ANNOUNCEMENTS + nameRes = R.string.title_announcements + iconRes = R.drawable.ic_bullhorn_24dp + onClick = { + startActivityWithSlideInAnimation(AnnouncementsActivity.newIntent(context)) + } + badgeStyle = BadgeStyle().apply { + textColor = ColorHolder.fromColor(ThemeUtils.getColor(this@MainActivity, R.attr.colorOnPrimary)) + color = ColorHolder.fromColor(ThemeUtils.getColor(this@MainActivity, R.attr.colorPrimary)) + } + }, + DividerDrawerItem(), + secondaryDrawerItem { + nameRes = R.string.action_view_account_preferences + iconRes = R.drawable.ic_account_settings + onClick = { + val intent = PreferencesActivity.newIntent(context, PreferencesActivity.ACCOUNT_PREFERENCES) + startActivityWithSlideInAnimation(intent) + } + }, + secondaryDrawerItem { + nameRes = R.string.action_view_preferences + iconicsIcon = GoogleMaterial.Icon.gmd_settings + onClick = { + val intent = PreferencesActivity.newIntent(context, PreferencesActivity.GENERAL_PREFERENCES) + startActivityWithSlideInAnimation(intent) + } + }, + secondaryDrawerItem { + nameRes = R.string.about_title_activity + iconicsIcon = GoogleMaterial.Icon.gmd_info + onClick = { + val intent = Intent(context, AboutActivity::class.java) + startActivityWithSlideInAnimation(intent) + } + }, + secondaryDrawerItem { + nameRes = R.string.action_logout + iconRes = R.drawable.ic_logout + onClick = ::logout + } ) addStickyDrawerItems( FooterDrawerItem().apply { @@ -536,14 +550,16 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje ) if (addSearchButton) { - binding.mainDrawer.addItemsAtPosition(4, - primaryDrawerItem { - nameRes = R.string.action_search - iconicsIcon = GoogleMaterial.Icon.gmd_search - onClick = { - startActivityWithSlideInAnimation(SearchActivity.getIntent(context)) - } - }) + binding.mainDrawer.addItemsAtPosition( + 4, + primaryDrawerItem { + nameRes = R.string.action_search + iconicsIcon = GoogleMaterial.Icon.gmd_search + onClick = { + startActivityWithSlideInAnimation(SearchActivity.getIntent(context)) + } + } + ) } setSavedInstance(savedInstanceState) @@ -551,11 +567,11 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje if (BuildConfig.DEBUG) { binding.mainDrawer.addItems( - secondaryDrawerItem { - nameText = "debug" - isEnabled = false - textColor = ColorStateList.valueOf(Color.GREEN) - } + secondaryDrawerItem { + nameText = "debug" + isEnabled = false + textColor = ColorStateList.valueOf(Color.GREEN) + } ) } @@ -607,7 +623,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje val popups = ArrayList() for (i in tabs.indices) { val tab = activeTabLayout.newTab() - .setIcon(tabs[i].icon) + .setIcon(tabs[i].icon) if (tabs[i].id == LIST) { tab.contentDescription = tabs[i].arguments[1] } else { @@ -663,11 +679,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje (fragment as ReselectableFragment).onReselect() } } - R.id.tabReset -> { - if (fragment is ReselectableFragment) { - (fragment as ReselectableFragment).onReset() - } - } R.id.tabEditList -> { AccountsInListFragment.newInstance( tabs[i].arguments.getOrNull(0).orEmpty(), @@ -676,25 +687,25 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje } R.id.tabToggleStreaming -> { if (fragment is TimelineFragment) { - fragment.isStreamingEnabled = !fragment.isStreamingEnabled - item.isChecked = fragment.isStreamingEnabled + val current = fragment.toggleStreaming() + item.isChecked = current tintCheckIcon(item) - if (fragment.isStreamingEnabled) { + if (current) { streamingTabsCount++ } else { streamingTabsCount-- } keepScreenOn() - tabs[i] = tabs[i].copy(enableStreaming = fragment.isStreamingEnabled) + tabs[i] = tabs[i].copy(enableStreaming = current) accountManager.activeAccount?.let { Single.fromCallable { it.tabPreferences = tabs accountManager.saveAccount(it) } .subscribeOn(Schedulers.io()) - .autoDispose(AndroidLifecycleScopeProvider.from(this, Lifecycle.Event.ON_DESTROY)) + .autoDispose(this, Lifecycle.Event.ON_DESTROY) .subscribe() } } @@ -767,25 +778,24 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje private fun handleProfileClick(profile: IProfile, current: Boolean): Boolean { val activeAccount = accountManager.activeAccount - //open profile when active image was clicked + // open profile when active image was clicked if (current && activeAccount != null) { val intent = AccountActivity.getIntent(this, activeAccount.accountId) startActivityWithSlideInAnimation(intent) return false } - //open LoginActivity to add new account + // open LoginActivity to add new account if (profile.identifier == DRAWER_ITEM_ADD_ACCOUNT) { startActivityWithSlideInAnimation(LoginActivity.getIntent(this, true)) return false } - //change Account + // change Account changeAccount(profile.identifier, null) return false } private fun changeAccount(newSelectedId: Long, forward: Intent?) { cacheUpdater.stop() - SFragment.flushFilters() accountManager.setActiveAccount(newSelectedId) window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) val intent = Intent(this, MainActivity::class.java) @@ -803,49 +813,55 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje private fun logout() { accountManager.activeAccount?.let { activeAccount -> AlertDialog.Builder(this) - .setTitle(R.string.action_logout) - .setMessage(getString(R.string.action_logout_confirm, activeAccount.fullName)) - .setPositiveButton(android.R.string.yes) { _: DialogInterface?, _: Int -> - NotificationHelper.deleteNotificationChannelsForAccount(activeAccount, this) + .setTitle(R.string.action_logout) + .setMessage(getString(R.string.action_logout_confirm, activeAccount.fullName)) + .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> + lifecycleScope.launch { + NotificationHelper.deleteNotificationChannelsForAccount(activeAccount, this@MainActivity) cacheUpdater.clearForUser(activeAccount.id) conversationRepository.deleteCacheForAccount(activeAccount.id) draftHelper.deleteAllDraftsAndAttachmentsForAccount(activeAccount.id) - removeShortcut(this, activeAccount) + removeShortcut(this@MainActivity, activeAccount) val newAccount = accountManager.logActiveAccountOut() - if (!NotificationHelper.areNotificationsEnabled(this, accountManager)) { - NotificationHelper.disablePullNotifications(this) + if (!NotificationHelper.areNotificationsEnabled( + this@MainActivity, + accountManager + ) + ) { + NotificationHelper.disablePullNotifications(this@MainActivity) } val intent = if (newAccount == null) { - LoginActivity.getIntent(this, false) + LoginActivity.getIntent(this@MainActivity, false) } else { - Intent(this, MainActivity::class.java) + Intent(this@MainActivity, MainActivity::class.java) } startActivity(intent) finishWithoutSlideOutAnimation() } - .setNegativeButton(android.R.string.no, null) - .show() + } + .setNegativeButton(android.R.string.cancel, null) + .show() } } private fun fetchUserInfo() { mastodonApi.accountVerifyCredentials() - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(this, Lifecycle.Event.ON_DESTROY) - .subscribe( - { userInfo -> - onFetchUserInfoSuccess(userInfo) - }, - { throwable -> - Log.e(TAG, "Failed to fetch user info. " + throwable.message) - } - ) + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(this, Lifecycle.Event.ON_DESTROY) + .subscribe( + { userInfo -> + onFetchUserInfoSuccess(userInfo) + }, + { throwable -> + Log.e(TAG, "Failed to fetch user info. " + throwable.message) + } + ) } private fun onFetchUserInfoSuccess(me: Account) { glide.asBitmap() - .load(me.header) - .into(header.accountHeaderBackground) + .load(me.header) + .into(header.accountHeaderBackground) loadDrawerAvatar(me.avatar, false) @@ -864,7 +880,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje glide.asDrawable() .load(avatarUrl) .transform( - RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp)) + RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp)) ) .apply { if (showPlaceholder) { @@ -893,17 +909,17 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje private fun fetchAnnouncements() { mastodonApi.listAnnouncements(false) - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(this, Lifecycle.Event.ON_DESTROY) - .subscribe( - { announcements -> - unreadAnnouncementsCount = announcements.count { !it.read } - updateAnnouncementsBadge() - }, - { - Log.w(TAG, "Failed to fetch announcements.", it) - } - ) + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(this, Lifecycle.Event.ON_DESTROY) + .subscribe( + { announcements -> + unreadAnnouncementsCount = announcements.count { !it.read } + updateAnnouncementsBadge() + }, + { + Log.w(TAG, "Failed to fetch announcements.", it) + } + ) } private fun updateAnnouncementsBadge() { @@ -937,30 +953,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje header.setActiveProfile(accountManager.activeAccount!!.id) } - private fun draftWarning() { - val sharedPrefsKey = "show_draft_warning" - appDb.tootDao().savedTootCount() - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(this, Lifecycle.Event.ON_DESTROY) - .subscribe { draftCount -> - val showDraftWarning = preferences.getBoolean(sharedPrefsKey, true) - if (draftCount > 0 && showDraftWarning) { - AlertDialog.Builder(this) - .setMessage(R.string.new_drafts_warning) - .setNegativeButton("Don't show again") { _, _ -> - preferences.edit(commit = true) { - putBoolean(sharedPrefsKey, false) - } - } - .setPositiveButton(android.R.string.ok, null) - .show() - } - } - - } - - override fun getActionButton(): FloatingActionButton = binding.composeButton + override fun getActionButton() = binding.composeButton override fun androidInjector() = androidInjector @@ -974,20 +967,20 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje private inline fun primaryDrawerItem(block: PrimaryDrawerItem.() -> Unit): PrimaryDrawerItem { return PrimaryDrawerItem() - .apply { - isSelectable = false - isIconTinted = true - } - .apply(block) + .apply { + isSelectable = false + isIconTinted = true + } + .apply(block) } private inline fun secondaryDrawerItem(block: SecondaryDrawerItem.() -> Unit): SecondaryDrawerItem { return SecondaryDrawerItem() - .apply { - isSelectable = false - isIconTinted = true - } - .apply(block) + .apply { + isSelectable = false + isIconTinted = true + } + .apply(block) } private var AbstractDrawerItem<*, *>.onClick: () -> Unit diff --git a/app/src/main/java/com/keylesspalace/tusky/ModalTimelineActivity.kt b/app/src/main/java/com/keylesspalace/tusky/ModalTimelineActivity.kt index 64911ba38..902735bc0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ModalTimelineActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/ModalTimelineActivity.kt @@ -5,17 +5,17 @@ import android.content.Intent import android.os.Bundle import androidx.activity.viewModels import androidx.lifecycle.Lifecycle +import autodispose2.androidx.lifecycle.autoDispose import com.google.android.material.floatingactionbutton.FloatingActionButton import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.components.timeline.TimelineFragment +import com.keylesspalace.tusky.components.timeline.TimelineViewModel import com.keylesspalace.tusky.databinding.ActivityModalTimelineBinding import com.keylesspalace.tusky.di.ViewModelFactory -import com.keylesspalace.tusky.fragment.TimelineFragment import com.keylesspalace.tusky.interfaces.ActionButtonActivity -import com.uber.autodispose.AutoDispose.autoDisposable -import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from import dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector -import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import net.accelf.yuito.QuickTootViewModel import javax.inject.Inject @@ -43,20 +43,20 @@ class ModalTimelineActivity : BottomSheetActivity(), ActionButtonActivity, HasAn } if (supportFragmentManager.findFragmentById(R.id.contentFrame) == null) { - val kind = intent?.getSerializableExtra(ARG_KIND) as? TimelineFragment.Kind - ?: TimelineFragment.Kind.HOME + val kind = intent?.getSerializableExtra(ARG_KIND) as? TimelineViewModel.Kind + ?: TimelineViewModel.Kind.HOME val argument = intent?.getStringExtra(ARG_ARG) supportFragmentManager.beginTransaction() - .replace(R.id.contentFrame, TimelineFragment.newInstance(kind, argument)) - .commit() + .replace(R.id.contentFrame, TimelineFragment.newInstance(kind, argument)) + .commit() } binding.viewQuickToot.attachViewModel(quickTootViewModel, this) eventHub.events - .observeOn(AndroidSchedulers.mainThread()) - .`as`(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) - .subscribe(binding.viewQuickToot::handleEvent) + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(this, Lifecycle.Event.ON_DESTROY) + .subscribe(binding.viewQuickToot::handleEvent) binding.floatingBtn.setOnClickListener(binding.viewQuickToot::onFABClicked) } @@ -69,13 +69,15 @@ class ModalTimelineActivity : BottomSheetActivity(), ActionButtonActivity, HasAn private const val ARG_ARG = "arg" @JvmStatic - fun newIntent(context: Context, kind: TimelineFragment.Kind, - argument: String?): Intent { + fun newIntent( + context: Context, + kind: TimelineViewModel.Kind, + argument: String? + ): Intent { val intent = Intent(context, ModalTimelineActivity::class.java) intent.putExtra(ARG_KIND, kind) intent.putExtra(ARG_ARG, argument) return intent } - } } diff --git a/app/src/main/java/com/keylesspalace/tusky/SavedTootActivity.java b/app/src/main/java/com/keylesspalace/tusky/SavedTootActivity.java deleted file mode 100644 index 4ccd330a4..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/SavedTootActivity.java +++ /dev/null @@ -1,213 +0,0 @@ -/* Copyright 2017 Andrew Dawson - * - * 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 . */ - -package com.keylesspalace.tusky; - -import android.content.Intent; -import android.os.AsyncTask; -import android.os.Bundle; -import android.view.View; - -import androidx.annotation.Nullable; -import androidx.appcompat.app.ActionBar; -import androidx.appcompat.widget.Toolbar; -import androidx.lifecycle.Lifecycle; -import androidx.recyclerview.widget.DividerItemDecoration; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; - -import com.google.gson.Gson; -import com.google.gson.reflect.TypeToken; -import com.keylesspalace.tusky.adapter.SavedTootAdapter; -import com.keylesspalace.tusky.appstore.EventHub; -import com.keylesspalace.tusky.appstore.StatusComposedEvent; -import com.keylesspalace.tusky.components.compose.ComposeActivity; -import com.keylesspalace.tusky.db.AppDatabase; -import com.keylesspalace.tusky.db.TootDao; -import com.keylesspalace.tusky.db.TootEntity; -import com.keylesspalace.tusky.di.Injectable; -import com.keylesspalace.tusky.util.SaveTootHelper; -import com.keylesspalace.tusky.view.BackgroundMessageView; - -import java.lang.ref.WeakReference; -import java.lang.reflect.Type; -import java.util.ArrayList; -import java.util.List; - -import javax.inject.Inject; - -import io.reactivex.android.schedulers.AndroidSchedulers; - -import static com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeOptions; -import static com.uber.autodispose.AutoDispose.autoDisposable; -import static com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from; - -public final class SavedTootActivity extends BaseActivity implements SavedTootAdapter.SavedTootAction, - Injectable { - - // ui - private SavedTootAdapter adapter; - private BackgroundMessageView errorMessageView; - - private List toots = new ArrayList<>(); - @Nullable - private AsyncTask asyncTask; - - @Inject - EventHub eventHub; - @Inject - AppDatabase database; - @Inject - SaveTootHelper saveTootHelper; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - eventHub.getEvents() - .observeOn(AndroidSchedulers.mainThread()) - .ofType(StatusComposedEvent.class) - .as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) - .subscribe((__) -> this.fetchToots()); - - setContentView(R.layout.activity_saved_toot); - - Toolbar toolbar = findViewById(R.id.toolbar); - setSupportActionBar(toolbar); - ActionBar bar = getSupportActionBar(); - if (bar != null) { - bar.setTitle(getString(R.string.title_drafts)); - bar.setDisplayHomeAsUpEnabled(true); - bar.setDisplayShowHomeEnabled(true); - } - - RecyclerView recyclerView = findViewById(R.id.recyclerView); - errorMessageView = findViewById(R.id.errorMessageView); - recyclerView.setHasFixedSize(true); - LinearLayoutManager layoutManager = new LinearLayoutManager(this); - recyclerView.setLayoutManager(layoutManager); - DividerItemDecoration divider = new DividerItemDecoration( - this, layoutManager.getOrientation()); - recyclerView.addItemDecoration(divider); - adapter = new SavedTootAdapter(this); - recyclerView.setAdapter(adapter); - } - - @Override - protected void onResume() { - super.onResume(); - fetchToots(); - } - - @Override - protected void onPause() { - super.onPause(); - if (asyncTask != null) asyncTask.cancel(true); - } - - private void fetchToots() { - asyncTask = new FetchPojosTask(this, database.tootDao()) - .executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); - } - - private void setNoContent(int size) { - if (size == 0) { - errorMessageView.setup(R.drawable.elephant_friend_empty, R.string.no_saved_status, null); - errorMessageView.setVisibility(View.VISIBLE); - } else { - errorMessageView.setVisibility(View.GONE); - } - } - - @Override - public void delete(int position, TootEntity item) { - - saveTootHelper.deleteDraft(item); - - toots.remove(position); - // update adapter - if (adapter != null) { - adapter.removeItem(position); - setNoContent(toots.size()); - } - } - - @Override - public void click(int position, TootEntity item) { - Gson gson = new Gson(); - Type stringListType = new TypeToken>() {}.getType(); - List jsonUrls = gson.fromJson(item.getUrls(), stringListType); - List descriptions = gson.fromJson(item.getDescriptions(), stringListType); - - ComposeOptions composeOptions = new ComposeOptions( - /*scheduledTootUid*/null, - item.getUid(), - /*drafId*/null, - item.getText(), - jsonUrls, - descriptions, - /*mentionedUsernames*/null, - item.getInReplyToId(), - /*quoteId*/null, - /*quoteStatusAuthor*/null, - /*quoteStatusContent*/null, - /*replyVisibility*/null, - item.getVisibility(), - item.getContentWarning(), - item.getInReplyToUsername(), - item.getInReplyToText(), - /*mediaAttachments*/null, - /*draftAttachments*/null, - /*scheduledAt*/null, - /*sensitive*/null, - /*poll*/null, - /* modifiedInitialState */ true, - false - ); - Intent intent = ComposeActivity.startIntent(this, composeOptions); - startActivity(intent); - } - - static final class FetchPojosTask extends AsyncTask> { - - private final WeakReference activityRef; - private final TootDao tootDao; - - FetchPojosTask(SavedTootActivity activity, TootDao tootDao) { - this.activityRef = new WeakReference<>(activity); - this.tootDao = tootDao; - } - - @Override - protected List doInBackground(Void... voids) { - return tootDao.loadAll(); - } - - @Override - protected void onPostExecute(List pojos) { - super.onPostExecute(pojos); - SavedTootActivity activity = activityRef.get(); - if (activity == null) return; - - activity.toots.clear(); - activity.toots.addAll(pojos); - - // set ui - activity.setNoContent(pojos.size()); - activity.adapter.setItems(activity.toots); - activity.adapter.notifyDataSetChanged(); - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/SplashActivity.kt b/app/src/main/java/com/keylesspalace/tusky/SplashActivity.kt index 0f4f66830..31ae757bc 100644 --- a/app/src/main/java/com/keylesspalace/tusky/SplashActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/SplashActivity.kt @@ -18,10 +18,9 @@ package com.keylesspalace.tusky import android.content.Intent import android.os.Bundle import androidx.appcompat.app.AppCompatActivity +import com.keylesspalace.tusky.components.notifications.NotificationHelper import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.di.Injectable - -import com.keylesspalace.tusky.components.notifications.NotificationHelper import net.accelf.yuito.CustomUncaughtExceptionHandler import javax.inject.Inject @@ -50,5 +49,4 @@ class SplashActivity : AppCompatActivity(), Injectable { startActivity(intent) finish() } - } diff --git a/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt b/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt index 7143f3ce1..b6ec4eccc 100644 --- a/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt @@ -21,17 +21,15 @@ import android.os.Bundle import androidx.activity.viewModels import androidx.fragment.app.commit import androidx.lifecycle.Lifecycle +import autodispose2.androidx.lifecycle.autoDispose import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.components.timeline.TimelineFragment +import com.keylesspalace.tusky.components.timeline.TimelineViewModel.Kind import com.keylesspalace.tusky.databinding.ActivityStatuslistBinding import com.keylesspalace.tusky.di.ViewModelFactory - -import com.keylesspalace.tusky.fragment.TimelineFragment -import com.keylesspalace.tusky.fragment.TimelineFragment.Kind -import com.uber.autodispose.AutoDispose -import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider import dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector -import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import net.accelf.yuito.QuickTootViewModel import javax.inject.Inject @@ -56,7 +54,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { setSupportActionBar(binding.includedToolbar.toolbar) - val title = if(kind == Kind.FAVOURITES) { + val title = if (kind == Kind.FAVOURITES) { R.string.title_favourites } else { R.string.title_bookmarks @@ -77,7 +75,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { eventHub.events .observeOn(AndroidSchedulers.mainThread()) - .`as`(AutoDispose.autoDisposable(AndroidLifecycleScopeProvider.from(this, Lifecycle.Event.ON_DESTROY))) + .autoDispose(this, Lifecycle.Event.ON_DESTROY) .subscribe(binding.viewQuickToot::handleEvent) binding.floatingBtn.setOnClickListener(binding.viewQuickToot::onFABClicked) } @@ -90,15 +88,14 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { @JvmStatic fun newFavouritesIntent(context: Context) = - Intent(context, StatusListActivity::class.java).apply { - putExtra(EXTRA_KIND, Kind.FAVOURITES.name) - } + Intent(context, StatusListActivity::class.java).apply { + putExtra(EXTRA_KIND, Kind.FAVOURITES.name) + } @JvmStatic fun newBookmarksIntent(context: Context) = - Intent(context, StatusListActivity::class.java).apply { - putExtra(EXTRA_KIND, Kind.BOOKMARKS.name) - } + Intent(context, StatusListActivity::class.java).apply { + putExtra(EXTRA_KIND, Kind.BOOKMARKS.name) + } } - } diff --git a/app/src/main/java/com/keylesspalace/tusky/TabData.kt b/app/src/main/java/com/keylesspalace/tusky/TabData.kt index e8b412809..993bf5819 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TabData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TabData.kt @@ -20,8 +20,9 @@ import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.fragment.app.Fragment import com.keylesspalace.tusky.components.conversation.ConversationsFragment +import com.keylesspalace.tusky.components.timeline.TimelineFragment +import com.keylesspalace.tusky.components.timeline.TimelineViewModel import com.keylesspalace.tusky.fragment.NotificationsFragment -import com.keylesspalace.tusky.fragment.TimelineFragment /** this would be a good case for a sealed class, but that does not work nice with Room */ @@ -35,66 +36,68 @@ const val LIST = "List" const val STREAMING = "STR" -data class TabData(val id: String, - @StringRes val text: Int, - @DrawableRes val icon: Int, - val fragment: (List) -> Fragment, - val arguments: List = emptyList(), - val title: (Context) -> String = { context -> context.getString(text)}, - val enableStreaming: Boolean = false) +data class TabData( + val id: String, + @StringRes val text: Int, + @DrawableRes val icon: Int, + val fragment: (List) -> Fragment, + val arguments: List = emptyList(), + val title: (Context) -> String = { context -> context.getString(text) }, + val enableStreaming: Boolean = false, +) fun createTabDataFromId(id: String, arguments: List = emptyList()): TabData { val enableStreaming = id.endsWith(STREAMING) return when (if (enableStreaming) id.slice(IntRange(0, id.length - 4)) else id) { HOME -> TabData( - HOME, - R.string.title_home, - R.drawable.ic_home_24dp, - { TimelineFragment.newInstance(TimelineFragment.Kind.HOME, enableStreaming = enableStreaming) }, - enableStreaming = enableStreaming + HOME, + R.string.title_home, + R.drawable.ic_home_24dp, + { TimelineFragment.newInstance(TimelineViewModel.Kind.HOME, enableStreaming = enableStreaming) }, + enableStreaming = enableStreaming ) NOTIFICATIONS -> TabData( - NOTIFICATIONS, - R.string.title_notifications, - R.drawable.ic_notifications_24dp, - { NotificationsFragment.newInstance() } + NOTIFICATIONS, + R.string.title_notifications, + R.drawable.ic_notifications_24dp, + { NotificationsFragment.newInstance() } ) LOCAL -> TabData( - LOCAL, - R.string.title_public_local, - R.drawable.ic_local_24dp, - { TimelineFragment.newInstance(TimelineFragment.Kind.PUBLIC_LOCAL, enableStreaming = enableStreaming) }, - enableStreaming = enableStreaming + LOCAL, + R.string.title_public_local, + R.drawable.ic_local_24dp, + { TimelineFragment.newInstance(TimelineViewModel.Kind.PUBLIC_LOCAL, enableStreaming = enableStreaming) }, + enableStreaming = enableStreaming ) FEDERATED -> TabData( - FEDERATED, - R.string.title_public_federated, - R.drawable.ic_public_24dp, - { TimelineFragment.newInstance(TimelineFragment.Kind.PUBLIC_FEDERATED, enableStreaming = enableStreaming) }, - enableStreaming = enableStreaming + FEDERATED, + R.string.title_public_federated, + R.drawable.ic_public_24dp, + { TimelineFragment.newInstance(TimelineViewModel.Kind.PUBLIC_FEDERATED, enableStreaming = enableStreaming) }, + enableStreaming = enableStreaming ) DIRECT -> TabData( - DIRECT, - R.string.title_direct_messages, - R.drawable.ic_reblog_direct_24dp, - { ConversationsFragment.newInstance() } + DIRECT, + R.string.title_direct_messages, + R.drawable.ic_reblog_direct_24dp, + { ConversationsFragment.newInstance() } ) HASHTAG -> TabData( - HASHTAG, - R.string.hashtags, - R.drawable.ic_hashtag, - { args -> TimelineFragment.newHashtagInstance(args) }, - arguments, - { context -> arguments.joinToString(separator = " ") { context.getString(R.string.title_tag, it) }} + HASHTAG, + R.string.hashtags, + R.drawable.ic_hashtag, + { args -> TimelineFragment.newHashtagInstance(args) }, + arguments, + { context -> arguments.joinToString(separator = " ") { context.getString(R.string.title_tag, it) } } ) LIST -> TabData( - LIST, - R.string.list, - R.drawable.ic_list, - { args -> TimelineFragment.newInstance(TimelineFragment.Kind.LIST, args.getOrNull(0).orEmpty(), true, enableStreaming) }, - arguments, - { arguments.getOrNull(1).orEmpty() }, - enableStreaming + LIST, + R.string.list, + R.drawable.ic_list, + { args -> TimelineFragment.newInstance(TimelineViewModel.Kind.LIST, args.getOrNull(0).orEmpty(), true, enableStreaming) }, + arguments, + { arguments.getOrNull(1).orEmpty() }, + enableStreaming ) else -> throw IllegalArgumentException("unknown tab type") } @@ -102,9 +105,9 @@ fun createTabDataFromId(id: String, arguments: List = emptyList()): TabD fun defaultTabs(): List { return listOf( - createTabDataFromId(HOME), - createTabDataFromId(NOTIFICATIONS), - createTabDataFromId(LOCAL), - createTabDataFromId(FEDERATED) + createTabDataFromId(HOME), + createTabDataFromId(NOTIFICATIONS), + createTabDataFromId(LOCAL), + createTabDataFromId(FEDERATED) ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt b/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt index 5e0b2eb34..366d1aeb1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt @@ -31,6 +31,8 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.transition.TransitionManager import at.connyduck.sparkbutton.helpers.Utils +import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from +import autodispose2.autoDispose import com.google.android.material.transition.MaterialArcMotion import com.google.android.material.transition.MaterialContainerTransform import com.keylesspalace.tusky.adapter.ItemInteractionListener @@ -44,11 +46,9 @@ import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.onTextChanged import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible -import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from -import com.uber.autodispose.autoDispose -import io.reactivex.Single -import io.reactivex.android.schedulers.AndroidSchedulers -import io.reactivex.schedulers.Schedulers +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers import java.util.regex.Pattern import javax.inject.Inject @@ -221,26 +221,26 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene frameLayout.addView(editText) val dialog = AlertDialog.Builder(this) - .setTitle(R.string.add_hashtag_title) - .setView(frameLayout) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(R.string.action_save) { _, _ -> - val input = editText.text.toString().trim() - if (tab == null) { - val newTab = createTabDataFromId(HASHTAG, listOf(input)) - currentTabs.add(newTab) - currentTabsAdapter.notifyItemInserted(currentTabs.size - 1) - } else { - val newTab = tab.copy(arguments = tab.arguments + input) - currentTabs[tabPosition] = newTab + .setTitle(R.string.add_hashtag_title) + .setView(frameLayout) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.action_save) { _, _ -> + val input = editText.text.toString().trim() + if (tab == null) { + val newTab = createTabDataFromId(HASHTAG, listOf(input)) + currentTabs.add(newTab) + currentTabsAdapter.notifyItemInserted(currentTabs.size - 1) + } else { + val newTab = tab.copy(arguments = tab.arguments + input) + currentTabs[tabPosition] = newTab - currentTabsAdapter.notifyItemChanged(tabPosition) - } - - updateAvailableTabs() - saveTabs() + currentTabsAdapter.notifyItemChanged(tabPosition) } - .create() + + updateAvailableTabs() + saveTabs() + } + .create() editText.onTextChanged { s, _, _, _ -> dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = validateHashtag(s) @@ -254,28 +254,28 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene private fun showSelectListDialog() { val adapter = ListSelectionAdapter(this) mastodonApi.getLists() - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(from(this, Lifecycle.Event.ON_DESTROY)) - .subscribe ( - { lists -> - adapter.addAll(lists) - }, - { throwable -> - Log.e("TabPreferenceActivity", "failed to load lists", throwable) - } - ) + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(from(this, Lifecycle.Event.ON_DESTROY)) + .subscribe( + { lists -> + adapter.addAll(lists) + }, + { throwable -> + Log.e("TabPreferenceActivity", "failed to load lists", throwable) + } + ) AlertDialog.Builder(this) - .setTitle(R.string.select_list_title) - .setAdapter(adapter) { _, position -> - val list = adapter.getItem(position) - val newTab = createTabDataFromId(LIST, listOf(list!!.id, list.title)) - currentTabs.add(newTab) - currentTabsAdapter.notifyItemInserted(currentTabs.size - 1) - updateAvailableTabs() - saveTabs() - } - .show() + .setTitle(R.string.select_list_title) + .setAdapter(adapter) { _, position -> + val list = adapter.getItem(position) + val newTab = createTabDataFromId(LIST, listOf(list!!.id, list.title)) + currentTabs.add(newTab) + currentTabsAdapter.notifyItemInserted(currentTabs.size - 1) + updateAvailableTabs() + saveTabs() + } + .show() } private fun validateHashtag(input: CharSequence?): Boolean { @@ -330,10 +330,9 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene it.tabPreferences = currentTabs accountManager.saveAccount(it) } - .subscribeOn(Schedulers.io()) - .autoDispose(from(this, Lifecycle.Event.ON_DESTROY)) - .subscribe() - + .subscribeOn(Schedulers.io()) + .autoDispose(from(this, Lifecycle.Event.ON_DESTROY)) + .subscribe() } tabsChanged = true } @@ -357,5 +356,4 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene private const val MIN_TAB_COUNT = 2 private const val MAX_TAB_COUNT = 9 } - } diff --git a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt index 562f644e1..0339a7bcc 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt @@ -22,16 +22,16 @@ import android.util.Log import androidx.emoji.text.EmojiCompat import androidx.preference.PreferenceManager import androidx.work.WorkManager +import autodispose2.AutoDisposePlugins import com.keylesspalace.tusky.components.notifications.NotificationWorkerFactory import com.keylesspalace.tusky.di.AppInjector import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.util.EmojiCompatFont import com.keylesspalace.tusky.util.LocaleManager import com.keylesspalace.tusky.util.ThemeUtils -import com.uber.autodispose.AutoDisposePlugins import dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector -import io.reactivex.plugins.RxJavaPlugins +import io.reactivex.rxjava3.plugins.RxJavaPlugins import org.conscrypt.Conscrypt import java.security.Security import javax.inject.Inject @@ -68,8 +68,8 @@ class TuskyApplication : Application(), HasAndroidInjector { // init the custom emoji fonts val emojiSelection = preferences.getInt(PrefKeys.EMOJI, 0) val emojiConfig = EmojiCompatFont.byId(emojiSelection) - .getConfig(this) - .setReplaceAll(true) + .getConfig(this) + .setReplaceAll(true) EmojiCompat.init(emojiConfig) // init night mode @@ -81,10 +81,10 @@ class TuskyApplication : Application(), HasAndroidInjector { } WorkManager.initialize( - this, - androidx.work.Configuration.Builder() - .setWorkerFactory(notificationWorkerFactory) - .build() + this, + androidx.work.Configuration.Builder() + .setWorkerFactory(notificationWorkerFactory) + .build() ) } @@ -104,4 +104,4 @@ class TuskyApplication : Application(), HasAndroidInjector { @JvmStatic lateinit var localeManager: LocaleManager } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt b/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt index 86205b29f..2598b5e22 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt @@ -41,27 +41,27 @@ import androidx.fragment.app.FragmentActivity import androidx.lifecycle.Lifecycle import androidx.viewpager2.adapter.FragmentStateAdapter import androidx.viewpager2.widget.ViewPager2 +import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider +import autodispose2.autoDispose import com.bumptech.glide.Glide import com.bumptech.glide.request.FutureTarget import com.keylesspalace.tusky.BuildConfig.APPLICATION_ID import com.keylesspalace.tusky.databinding.ActivityViewMediaBinding import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.fragment.ViewImageFragment -import com.keylesspalace.tusky.pager.SingleImagePagerAdapter import com.keylesspalace.tusky.pager.ImagePagerAdapter +import com.keylesspalace.tusky.pager.SingleImagePagerAdapter import com.keylesspalace.tusky.util.getTemporaryMediaFilename import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.viewdata.AttachmentViewData -import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider -import com.uber.autodispose.autoDispose -import io.reactivex.Single -import io.reactivex.android.schedulers.AndroidSchedulers -import io.reactivex.schedulers.Schedulers +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers import java.io.File import java.io.FileNotFoundException import java.io.FileOutputStream import java.io.IOException -import java.util.* +import java.util.Locale typealias ToolbarVisibilityListener = (isVisible: Boolean) -> Unit @@ -102,17 +102,16 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener val realAttachs = attachments!!.map(AttachmentViewData::attachment) // Setup the view pager. ImagePagerAdapter(this, realAttachs, initialPosition) - } else { imageUrl = intent.getStringExtra(EXTRA_SINGLE_IMAGE_URL) - ?: throw IllegalArgumentException("attachment list or image url has to be set") + ?: throw IllegalArgumentException("attachment list or image url has to be set") SingleImagePagerAdapter(this, imageUrl!!) } binding.viewPager.adapter = adapter binding.viewPager.setCurrentItem(initialPosition, false) - binding.viewPager.registerOnPageChangeCallback(object: ViewPager2.OnPageChangeCallback() { + binding.viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { override fun onPageSelected(position: Int) { binding.toolbar.title = getPageTitle(position) } @@ -138,6 +137,7 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener } window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LOW_PROFILE + window.statusBarColor = Color.BLACK window.sharedElementEnterTransition.addListener(object : NoopTransitionListener { override fun onTransitionEnd(transition: Transition) { @@ -182,17 +182,17 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener } binding.toolbar.animate().alpha(alpha) - .setListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator) { - binding.toolbar.visibility = visibility - animation.removeListener(this) - } - }) - .start() + .setListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + binding.toolbar.visibility = visibility + animation.removeListener(this) + } + }) + .start() } private fun getPageTitle(position: Int): CharSequence { - if(attachments == null) { + if (attachments == null) { return "" } return String.format(Locale.getDefault(), "%d/%d", position + 1, attachments?.size) @@ -205,8 +205,10 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener val downloadManager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager val request = DownloadManager.Request(Uri.parse(url)) - request.setDestinationInExternalPublicDir(Environment.DIRECTORY_PICTURES, - getString(R.string.app_name) + "/" + filename) + request.setDestinationInExternalPublicDir( + Environment.DIRECTORY_PICTURES, + getString(R.string.app_name) + "/" + filename + ) downloadManager.enqueue(request) } @@ -260,7 +262,6 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_media_to))) } - private var isCreating: Boolean = false private fun shareImage(directory: File, url: String) { @@ -269,7 +270,7 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener invalidateOptionsMenu() val file = File(directory, getTemporaryMediaFilename("png")) val futureTask: FutureTarget = - Glide.with(applicationContext).asBitmap().load(Uri.parse(url)).submit() + Glide.with(applicationContext).asBitmap().load(Uri.parse(url)).submit() Single.fromCallable { val bitmap = futureTask.get() try { @@ -283,32 +284,30 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener Log.e(TAG, "Error writing temporary media.") } return@fromCallable false - } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .doOnDispose { - futureTask.cancel(true) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .doOnDispose { + futureTask.cancel(true) + } + .autoDispose(AndroidLifecycleScopeProvider.from(this, Lifecycle.Event.ON_DESTROY)) + .subscribe( + { result -> + Log.d(TAG, "Download image result: $result") + isCreating = false + invalidateOptionsMenu() + binding.progressBarShare.visibility = View.GONE + if (result) + shareFile(file, "image/png") + }, + { error -> + isCreating = false + invalidateOptionsMenu() + binding.progressBarShare.visibility = View.GONE + Log.e(TAG, "Failed to download image", error) } - .autoDispose(AndroidLifecycleScopeProvider.from(this, Lifecycle.Event.ON_DESTROY)) - .subscribe( - { result -> - Log.d(TAG, "Download image result: $result") - isCreating = false - invalidateOptionsMenu() - binding.progressBarShare.visibility = View.GONE - if (result) - shareFile(file, "image/png") - }, - { error -> - isCreating = false - invalidateOptionsMenu() - binding.progressBarShare.visibility = View.GONE - Log.e(TAG, "Failed to download image", error) - } - ) - + ) } private fun shareMediaFile(directory: File, url: String) { @@ -351,7 +350,7 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener } } -abstract class ViewMediaAdapter(activity: FragmentActivity): FragmentStateAdapter(activity) { +abstract class ViewMediaAdapter(activity: FragmentActivity) : FragmentStateAdapter(activity) { abstract fun onTransitionEnd(position: Int) } diff --git a/app/src/main/java/com/keylesspalace/tusky/ViewTagActivity.java b/app/src/main/java/com/keylesspalace/tusky/ViewTagActivity.java index 6f0e8ee17..0071924bc 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ViewTagActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/ViewTagActivity.java @@ -24,14 +24,8 @@ import androidx.appcompat.app.ActionBar; import androidx.appcompat.widget.Toolbar; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentTransaction; -import androidx.lifecycle.Lifecycle; -import com.keylesspalace.tusky.appstore.EventHub; -import com.keylesspalace.tusky.di.ViewModelFactory; -import com.keylesspalace.tusky.fragment.TimelineFragment; - -import net.accelf.yuito.QuickTootView; -import net.accelf.yuito.QuickTootViewModel; +import com.keylesspalace.tusky.components.timeline.TimelineFragment; import java.util.Collections; @@ -40,10 +34,6 @@ import javax.inject.Inject; import dagger.android.AndroidInjector; import dagger.android.DispatchingAndroidInjector; import dagger.android.HasAndroidInjector; -import io.reactivex.android.schedulers.AndroidSchedulers; - -import static com.uber.autodispose.AutoDispose.autoDisposable; -import static com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from; public class ViewTagActivity extends BottomSheetActivity implements HasAndroidInjector { @@ -51,10 +41,6 @@ public class ViewTagActivity extends BottomSheetActivity implements HasAndroidIn @Inject public DispatchingAndroidInjector dispatchingAndroidInjector; - @Inject - public EventHub eventHub; - @Inject - public ViewModelFactory viewModelFactory; public static Intent getIntent(Context context, String tag){ Intent intent = new Intent(context,ViewTagActivity.class); @@ -83,15 +69,6 @@ public class ViewTagActivity extends BottomSheetActivity implements HasAndroidIn Fragment fragment = TimelineFragment.newHashtagInstance(Collections.singletonList(hashtag)); fragmentTransaction.replace(R.id.fragment_container, fragment); fragmentTransaction.commit(); - - QuickTootViewModel quickTootViewModel = viewModelFactory.create(QuickTootViewModel.class); - QuickTootView quickTootView = findViewById(R.id.viewQuickToot); - quickTootView.attachViewModel(quickTootViewModel, this); - - eventHub.getEvents() - .observeOn(AndroidSchedulers.mainThread()) - .as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) - .subscribe(quickTootView::handleEvent); } @Override diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountAdapter.java deleted file mode 100644 index 24430dcec..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountAdapter.java +++ /dev/null @@ -1,116 +0,0 @@ -/* Copyright 2017 Andrew Dawson - * - * 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 . */ - -package com.keylesspalace.tusky.adapter; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.recyclerview.widget.RecyclerView; - -import com.keylesspalace.tusky.entity.Account; -import com.keylesspalace.tusky.interfaces.AccountActionListener; -import com.keylesspalace.tusky.util.ListUtils; - -import java.util.ArrayList; -import java.util.List; - -public abstract class AccountAdapter extends RecyclerView.Adapter { - static final int VIEW_TYPE_ACCOUNT = 0; - static final int VIEW_TYPE_FOOTER = 1; - - List accountList; - AccountActionListener accountActionListener; - private boolean bottomLoading; - protected final boolean animateEmojis; - protected final boolean animateAvatar; - - AccountAdapter(AccountActionListener accountActionListener, boolean animateAvatar, boolean animateEmojis) { - this.accountList = new ArrayList<>(); - this.accountActionListener = accountActionListener; - this.animateAvatar = animateAvatar; - this.animateEmojis = animateEmojis; - bottomLoading = false; - } - - @Override - public int getItemCount() { - return accountList.size() + (bottomLoading ? 1 : 0); - } - - @Override - public int getItemViewType(int position) { - if (position == accountList.size() && bottomLoading) { - return VIEW_TYPE_FOOTER; - } else { - return VIEW_TYPE_ACCOUNT; - } - } - - public void update(@NonNull List newAccounts) { - accountList = ListUtils.removeDuplicates(newAccounts); - notifyDataSetChanged(); - } - - public void addItems(@NonNull List newAccounts) { - int end = accountList.size(); - Account last = accountList.get(end - 1); - if (last != null && !findAccount(newAccounts, last.getId())) { - accountList.addAll(newAccounts); - notifyItemRangeInserted(end, newAccounts.size()); - } - } - - public void setBottomLoading(boolean loading) { - boolean wasLoading = bottomLoading; - if(wasLoading == loading) { - return; - } - bottomLoading = loading; - if(loading) { - notifyItemInserted(accountList.size()); - } else { - notifyItemRemoved(accountList.size()); - } - } - - private static boolean findAccount(@NonNull List accounts, String id) { - for (Account account : accounts) { - if (account.getId().equals(id)) { - return true; - } - } - return false; - } - - @Nullable - public Account removeItem(int position) { - if (position < 0 || position >= accountList.size()) { - return null; - } - Account account = accountList.remove(position); - notifyItemRemoved(position); - return account; - } - - public void addItem(@NonNull Account account, int position) { - if (position < 0 || position > accountList.size()) { - return; - } - accountList.add(position, account); - notifyItemInserted(position); - } - - -} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountAdapter.kt new file mode 100644 index 000000000..320f8126f --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountAdapter.kt @@ -0,0 +1,124 @@ +/* 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 . */ +package com.keylesspalace.tusky.adapter + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.entity.Account +import com.keylesspalace.tusky.interfaces.AccountActionListener +import com.keylesspalace.tusky.util.removeDuplicates + +/** Generic adapter with bottom loading indicator. */ +abstract class AccountAdapter internal constructor( + var accountActionListener: AccountActionListener, + protected val animateAvatar: Boolean, + protected val animateEmojis: Boolean +) : RecyclerView.Adapter() { + var accountList = mutableListOf() + private var bottomLoading: Boolean = false + + override fun getItemCount(): Int { + return accountList.size + if (bottomLoading) 1 else 0 + } + + abstract fun createAccountViewHolder(parent: ViewGroup): AVH + + abstract fun onBindAccountViewHolder(viewHolder: AVH, position: Int) + + final override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + if (getItemViewType(position) == VIEW_TYPE_ACCOUNT) { + @Suppress("UNCHECKED_CAST") + this.onBindAccountViewHolder(holder as AVH, position) + } + } + + final override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): RecyclerView.ViewHolder { + return when (viewType) { + VIEW_TYPE_ACCOUNT -> this.createAccountViewHolder(parent) + VIEW_TYPE_FOOTER -> this.createFooterViewHolder(parent) + else -> error("Unknown item type: $viewType") + } + } + + private fun createFooterViewHolder( + parent: ViewGroup, + ): RecyclerView.ViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.item_footer, parent, false) + return LoadingFooterViewHolder(view) + } + + override fun getItemViewType(position: Int): Int { + return if (position == accountList.size && bottomLoading) { + VIEW_TYPE_FOOTER + } else { + VIEW_TYPE_ACCOUNT + } + } + + fun update(newAccounts: List) { + accountList = removeDuplicates(newAccounts) + notifyDataSetChanged() + } + + fun addItems(newAccounts: List) { + val end = accountList.size + val last = accountList[end - 1] + if (newAccounts.none { it.id == last.id }) { + accountList.addAll(newAccounts) + notifyItemRangeInserted(end, newAccounts.size) + } + } + + fun setBottomLoading(loading: Boolean) { + val wasLoading = bottomLoading + if (wasLoading == loading) { + return + } + bottomLoading = loading + if (loading) { + notifyItemInserted(accountList.size) + } else { + notifyItemRemoved(accountList.size) + } + } + + fun removeItem(position: Int): Account? { + if (position < 0 || position >= accountList.size) { + return null + } + val account = accountList.removeAt(position) + notifyItemRemoved(position) + return account + } + + fun addItem(account: Account, position: Int) { + if (position < 0 || position > accountList.size) { + return + } + accountList.add(position, account) + notifyItemInserted(position) + } + + companion object { + const val VIEW_TYPE_ACCOUNT = 0 + const val VIEW_TYPE_FOOTER = 1 + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldAdapter.kt index b0121f177..d6dd668a5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldAdapter.kt @@ -25,14 +25,14 @@ import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.Field import com.keylesspalace.tusky.entity.IdentityProof import com.keylesspalace.tusky.interfaces.LinkListener +import com.keylesspalace.tusky.util.BindingHolder import com.keylesspalace.tusky.util.Either import com.keylesspalace.tusky.util.LinkHelper import com.keylesspalace.tusky.util.emojify -import com.keylesspalace.tusky.util.* class AccountFieldAdapter( - private val linkListener: LinkListener, - private val animateEmojis: Boolean + private val linkListener: LinkListener, + private val animateEmojis: Boolean ) : RecyclerView.Adapter>() { var emojis: List = emptyList() @@ -50,7 +50,7 @@ class AccountFieldAdapter( val nameTextView = holder.binding.accountFieldName val valueTextView = holder.binding.accountFieldValue - if(proofOrField.isLeft()) { + if (proofOrField.isLeft()) { val identityProof = proofOrField.asLeft() nameTextView.text = identityProof.provider @@ -58,7 +58,7 @@ class AccountFieldAdapter( valueTextView.movementMethod = LinkMovementMethod.getInstance() - valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_check_circle, 0) + valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_check_circle, 0) } else { val field = proofOrField.asRight() val emojifiedName = field.name.emojify(emojis, nameTextView, animateEmojis) @@ -67,12 +67,11 @@ class AccountFieldAdapter( val emojifiedValue = field.value.emojify(emojis, valueTextView, animateEmojis) LinkHelper.setClickableText(valueTextView, emojifiedValue, null, linkListener) - if(field.verifiedAt != null) { - valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_check_circle, 0) + if (field.verifiedAt != null) { + valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_check_circle, 0) } else { - valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0 ) + valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0) } } - } } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldEditAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldEditAdapter.kt index f7f4553a3..7ba5537b8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldEditAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldEditAdapter.kt @@ -34,7 +34,7 @@ class AccountFieldEditAdapter : RecyclerView.Adapter fieldData.add(MutableStringPair(field.name, field.value)) } - if(fieldData.isEmpty()) { + if (fieldData.isEmpty()) { fieldData.add(MutableStringPair("", "")) } @@ -63,7 +63,7 @@ class AccountFieldEditAdapter : RecyclerView.Adapter(context, R.layout.item_autocomplete_account) { @@ -48,9 +49,8 @@ class AccountSelectionAdapter(context: Context) : ArrayAdapter(co val animateAvatar = pm.getBoolean("animateGifAvatars", false) loadAvatar(account.profilePictureUrl, binding.avatar, avatarRadius, animateAvatar) - } return binding.root } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/BlocksAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/BlocksAdapter.java deleted file mode 100644 index 57cc90359..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/BlocksAdapter.java +++ /dev/null @@ -1,106 +0,0 @@ -/* Copyright 2017 Andrew Dawson - * - * 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 . */ - -package com.keylesspalace.tusky.adapter; - -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ImageButton; -import android.widget.ImageView; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.preference.PreferenceManager; -import androidx.recyclerview.widget.RecyclerView; - -import com.keylesspalace.tusky.R; -import com.keylesspalace.tusky.entity.Account; -import com.keylesspalace.tusky.interfaces.AccountActionListener; -import com.keylesspalace.tusky.util.CustomEmojiHelper; -import com.keylesspalace.tusky.util.ImageLoadingHelper; - -public class BlocksAdapter extends AccountAdapter { - - public BlocksAdapter(AccountActionListener accountActionListener, boolean animateAvatar, boolean animateEmojis) { - super(accountActionListener, animateAvatar, animateEmojis); - } - - @NonNull - @Override - public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - switch (viewType) { - default: - case VIEW_TYPE_ACCOUNT: { - View view = LayoutInflater.from(parent.getContext()) - .inflate(R.layout.item_blocked_user, parent, false); - return new BlockedUserViewHolder(view); - } - case VIEW_TYPE_FOOTER: { - View view = LayoutInflater.from(parent.getContext()) - .inflate(R.layout.item_footer, parent, false); - return new LoadingFooterViewHolder(view); - } - } - } - - @Override - public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) { - if (getItemViewType(position) == VIEW_TYPE_ACCOUNT) { - BlockedUserViewHolder holder = (BlockedUserViewHolder) viewHolder; - holder.setupWithAccount(accountList.get(position), animateAvatar, animateEmojis); - holder.setupActionListener(accountActionListener); - } - } - - static class BlockedUserViewHolder extends RecyclerView.ViewHolder { - private ImageView avatar; - private TextView username; - private TextView displayName; - private ImageButton unblock; - private String id; - - BlockedUserViewHolder(View itemView) { - super(itemView); - avatar = itemView.findViewById(R.id.blocked_user_avatar); - username = itemView.findViewById(R.id.blocked_user_username); - displayName = itemView.findViewById(R.id.blocked_user_display_name); - unblock = itemView.findViewById(R.id.blocked_user_unblock); - - } - - void setupWithAccount(Account account, boolean animateAvatar, boolean animateEmojis) { - id = account.getId(); - CharSequence emojifiedName = CustomEmojiHelper.emojify(account.getName(), account.getEmojis(), displayName, animateEmojis); - displayName.setText(emojifiedName); - String format = username.getContext().getString(R.string.status_username_format); - String formattedUsername = String.format(format, account.getUsername()); - username.setText(formattedUsername); - int avatarRadius = avatar.getContext().getResources() - .getDimensionPixelSize(R.dimen.avatar_radius_48dp); - ImageLoadingHelper.loadAvatar(account.getAvatar(), avatar, avatarRadius, animateAvatar); - } - - void setupActionListener(final AccountActionListener listener) { - unblock.setOnClickListener(v -> { - int position = getBindingAdapterPosition(); - if (position != RecyclerView.NO_POSITION) { - listener.onBlock(false, id, position); - } - }); - itemView.setOnClickListener(v -> listener.onViewAccount(id)); - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/BlocksAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/BlocksAdapter.kt new file mode 100644 index 000000000..33a236056 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/BlocksAdapter.kt @@ -0,0 +1,80 @@ +/* Copyright 2017 Andrew Dawson + * + * 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 . */ +package com.keylesspalace.tusky.adapter + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.entity.Account +import com.keylesspalace.tusky.interfaces.AccountActionListener +import com.keylesspalace.tusky.util.emojify +import com.keylesspalace.tusky.util.loadAvatar + +/** Displays a list of blocked accounts. */ +class BlocksAdapter( + accountActionListener: AccountActionListener, + animateAvatar: Boolean, + animateEmojis: Boolean +) : AccountAdapter( + accountActionListener, + animateAvatar, + animateEmojis +) { + override fun createAccountViewHolder(parent: ViewGroup): BlockedUserViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.item_blocked_user, parent, false) + return BlockedUserViewHolder(view) + } + + override fun onBindAccountViewHolder(viewHolder: BlockedUserViewHolder, position: Int) { + viewHolder.setupWithAccount(accountList[position], animateAvatar, animateEmojis) + viewHolder.setupActionListener(accountActionListener) + } + + class BlockedUserViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + private val avatar: ImageView = itemView.findViewById(R.id.blocked_user_avatar) + private val username: TextView = itemView.findViewById(R.id.blocked_user_username) + private val displayName: TextView = itemView.findViewById(R.id.blocked_user_display_name) + private val unblock: ImageButton = itemView.findViewById(R.id.blocked_user_unblock) + private var id: String? = null + + fun setupWithAccount(account: Account, animateAvatar: Boolean, animateEmojis: Boolean) { + id = account.id + val emojifiedName = account.name.emojify(account.emojis, displayName, animateEmojis) + displayName.text = emojifiedName + val format = username.context.getString(R.string.status_username_format) + val formattedUsername = String.format(format, account.username) + username.text = formattedUsername + val avatarRadius = avatar.context.resources + .getDimensionPixelSize(R.dimen.avatar_radius_48dp) + loadAvatar(account.avatar, avatar, avatarRadius, animateAvatar) + } + + fun setupActionListener(listener: AccountActionListener) { + unblock.setOnClickListener { + val position = bindingAdapterPosition + if (position != RecyclerView.NO_POSITION) { + listener.onBlock(false, id, position) + } + } + itemView.setOnClickListener { listener.onViewAccount(id) } + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/EmojiAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/EmojiAdapter.kt index 2640caaca..dc9ec70dc 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/EmojiAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/EmojiAdapter.kt @@ -22,15 +22,15 @@ import com.bumptech.glide.Glide import com.keylesspalace.tusky.databinding.ItemEmojiButtonBinding import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.util.BindingHolder -import java.util.* +import java.util.Locale class EmojiAdapter( - emojiList: List, - private val onEmojiSelectedListener: OnEmojiSelectedListener + emojiList: List, + private val onEmojiSelectedListener: OnEmojiSelectedListener ) : RecyclerView.Adapter>() { - private val emojiList : List = emojiList.filter { emoji -> emoji.visibleInPicker == null || emoji.visibleInPicker } - .sortedBy { it.shortcode.toLowerCase(Locale.ROOT) } + private val emojiList: List = emojiList.filter { emoji -> emoji.visibleInPicker == null || emoji.visibleInPicker } + .sortedBy { it.shortcode.lowercase(Locale.ROOT) } override fun getItemCount() = emojiList.size @@ -44,8 +44,8 @@ class EmojiAdapter( val emojiImageView = holder.binding.root Glide.with(emojiImageView) - .load(emoji.url) - .into(emojiImageView) + .load(emoji.url) + .into(emojiImageView) emojiImageView.setOnClickListener { onEmojiSelectedListener.onEmojiSelected(emoji.shortcode) diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/FollowAdapter.java deleted file mode 100644 index 98cb9e4df..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowAdapter.java +++ /dev/null @@ -1,61 +0,0 @@ -/* Copyright 2017 Andrew Dawson - * - * 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 . */ - -package com.keylesspalace.tusky.adapter; - -import androidx.annotation.NonNull; -import androidx.recyclerview.widget.RecyclerView; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import com.keylesspalace.tusky.R; -import com.keylesspalace.tusky.interfaces.AccountActionListener; - -/** Both for follows and following lists. */ -public class FollowAdapter extends AccountAdapter { - - public FollowAdapter(AccountActionListener accountActionListener, boolean animateAvatar, boolean animateEmojis) { - super(accountActionListener, animateAvatar, animateEmojis); - } - - @NonNull - @Override - public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - switch (viewType) { - default: - case VIEW_TYPE_ACCOUNT: { - View view = LayoutInflater.from(parent.getContext()) - .inflate(R.layout.item_account, parent, false); - return new AccountViewHolder(view); - } - case VIEW_TYPE_FOOTER: { - View view = LayoutInflater.from(parent.getContext()) - .inflate(R.layout.item_footer, parent, false); - return new LoadingFooterViewHolder(view); - } - } - } - - @Override - public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) { - if (getItemViewType(position) == VIEW_TYPE_ACCOUNT) { - AccountViewHolder holder = (AccountViewHolder) viewHolder; - holder.setupWithAccount(accountList.get(position), animateAvatar, animateEmojis); - holder.setupActionListener(accountActionListener); - } - } - -} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/FollowAdapter.kt new file mode 100644 index 000000000..672f1fcac --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/FollowAdapter.kt @@ -0,0 +1,38 @@ +/* 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 . */ +package com.keylesspalace.tusky.adapter + +import android.view.LayoutInflater +import android.view.ViewGroup +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.interfaces.AccountActionListener + +/** Displays either a follows or following list. */ +class FollowAdapter( + accountActionListener: AccountActionListener, + animateAvatar: Boolean, + animateEmojis: Boolean +) : AccountAdapter(accountActionListener, animateAvatar, animateEmojis) { + override fun createAccountViewHolder(parent: ViewGroup): AccountViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.item_account, parent, false) + return AccountViewHolder(view) + } + + override fun onBindAccountViewHolder(viewHolder: AccountViewHolder, position: Int) { + viewHolder.setupWithAccount(accountList[position], animateAvatar, animateEmojis) + viewHolder.setupActionListener(accountActionListener) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt index a7e927433..2be8b7621 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt @@ -24,11 +24,14 @@ import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.interfaces.AccountActionListener -import com.keylesspalace.tusky.util.* +import com.keylesspalace.tusky.util.emojify +import com.keylesspalace.tusky.util.loadAvatar +import com.keylesspalace.tusky.util.unicodeWrap +import com.keylesspalace.tusky.util.visible class FollowRequestViewHolder( - private val binding: ItemFollowRequestBinding, - private val showHeader: Boolean + private val binding: ItemFollowRequestBinding, + private val showHeader: Boolean ) : RecyclerView.ViewHolder(binding.root) { fun setupWithAccount(account: Account, animateAvatar: Boolean, animateEmojis: Boolean) { diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestsAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestsAdapter.java deleted file mode 100644 index ef14618e6..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestsAdapter.java +++ /dev/null @@ -1,60 +0,0 @@ -/* Copyright 2017 Andrew Dawson - * - * 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 . */ - -package com.keylesspalace.tusky.adapter; - -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.recyclerview.widget.RecyclerView; - -import com.keylesspalace.tusky.R; -import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding; -import com.keylesspalace.tusky.interfaces.AccountActionListener; - -public class FollowRequestsAdapter extends AccountAdapter { - - public FollowRequestsAdapter(AccountActionListener accountActionListener, boolean animateAvatar, boolean animateEmojis) { - super(accountActionListener, animateAvatar, animateEmojis); - } - - @NonNull - @Override - public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - switch (viewType) { - default: - case VIEW_TYPE_ACCOUNT: { - ItemFollowRequestBinding binding = ItemFollowRequestBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false); - return new FollowRequestViewHolder(binding, false); - } - case VIEW_TYPE_FOOTER: { - View view = LayoutInflater.from(parent.getContext()) - .inflate(R.layout.item_footer, parent, false); - return new LoadingFooterViewHolder(view); - } - } - } - - @Override - public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) { - if (getItemViewType(position) == VIEW_TYPE_ACCOUNT) { - FollowRequestViewHolder holder = (FollowRequestViewHolder) viewHolder; - holder.setupWithAccount(accountList.get(position), animateAvatar, animateEmojis); - holder.setupActionListener(accountActionListener, accountList.get(position).getId()); - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestsAdapter.kt new file mode 100644 index 000000000..9b0a5dd90 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestsAdapter.kt @@ -0,0 +1,39 @@ +/* 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 . */ +package com.keylesspalace.tusky.adapter + +import android.view.LayoutInflater +import android.view.ViewGroup +import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding +import com.keylesspalace.tusky.interfaces.AccountActionListener + +/** Displays a list of follow requests with accept/reject buttons. */ +class FollowRequestsAdapter( + accountActionListener: AccountActionListener, + animateAvatar: Boolean, + animateEmojis: Boolean +) : AccountAdapter(accountActionListener, animateAvatar, animateEmojis) { + override fun createAccountViewHolder(parent: ViewGroup): FollowRequestViewHolder { + val binding = ItemFollowRequestBinding.inflate( + LayoutInflater.from(parent.context), parent, false + ) + return FollowRequestViewHolder(binding, false) + } + + override fun onBindAccountViewHolder(viewHolder: FollowRequestViewHolder, position: Int) { + viewHolder.setupWithAccount(accountList[position], animateAvatar, animateEmojis) + viewHolder.setupActionListener(accountActionListener, accountList[position].id) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestsHeaderAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestsHeaderAdapter.kt index 60ab40086..2480086e4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestsHeaderAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestsHeaderAdapter.kt @@ -25,7 +25,7 @@ class FollowRequestsHeaderAdapter(private val instanceName: String, private val override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HeaderViewHolder { val view = LayoutInflater.from(parent.context) - .inflate(R.layout.item_follow_requests_header, parent, false) as TextView + .inflate(R.layout.item_follow_requests_header, parent, false) as TextView return HeaderViewHolder(view) } @@ -34,7 +34,6 @@ class FollowRequestsHeaderAdapter(private val instanceName: String, private val } override fun getItemCount() = if (accountLocked) 0 else 1 - } class HeaderViewHolder(var textView: TextView) : RecyclerView.ViewHolder(textView) diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/LoadingFooterViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/LoadingFooterViewHolder.kt index ebff5c5f1..6d5ddbd81 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/LoadingFooterViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/LoadingFooterViewHolder.kt @@ -15,7 +15,7 @@ package com.keylesspalace.tusky.adapter -import androidx.recyclerview.widget.RecyclerView import android.view.View +import androidx.recyclerview.widget.RecyclerView -class LoadingFooterViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) \ No newline at end of file +class LoadingFooterViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/MutesAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/MutesAdapter.java deleted file mode 100644 index f63af6ca6..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/MutesAdapter.java +++ /dev/null @@ -1,131 +0,0 @@ -package com.keylesspalace.tusky.adapter; - -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ImageButton; -import android.widget.ImageView; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.core.view.ViewCompat; -import androidx.recyclerview.widget.RecyclerView; - -import com.keylesspalace.tusky.R; -import com.keylesspalace.tusky.entity.Account; -import com.keylesspalace.tusky.interfaces.AccountActionListener; -import com.keylesspalace.tusky.util.CustomEmojiHelper; -import com.keylesspalace.tusky.util.ImageLoadingHelper; - -import java.util.HashMap; - -public class MutesAdapter extends AccountAdapter { - private HashMap mutingNotificationsMap; - - public MutesAdapter(AccountActionListener accountActionListener, boolean animateAvatar, boolean animateEmojis) { - super(accountActionListener, animateAvatar, animateEmojis); - mutingNotificationsMap = new HashMap(); - } - - @NonNull - @Override - public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - switch (viewType) { - default: - case VIEW_TYPE_ACCOUNT: { - View view = LayoutInflater.from(parent.getContext()) - .inflate(R.layout.item_muted_user, parent, false); - return new MutesAdapter.MutedUserViewHolder(view); - } - case VIEW_TYPE_FOOTER: { - View view = LayoutInflater.from(parent.getContext()) - .inflate(R.layout.item_footer, parent, false); - return new LoadingFooterViewHolder(view); - } - } - } - - @Override - public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) { - if (getItemViewType(position) == VIEW_TYPE_ACCOUNT) { - MutedUserViewHolder holder = (MutedUserViewHolder) viewHolder; - Account account = accountList.get(position); - holder.setupWithAccount(account, mutingNotificationsMap.get(account.getId()), animateAvatar, animateEmojis); - holder.setupActionListener(accountActionListener); - } - } - - public void updateMutingNotifications(String id, boolean mutingNotifications, int position) { - mutingNotificationsMap.put(id, mutingNotifications); - notifyItemChanged(position); - } - - public void updateMutingNotificationsMap(HashMap newMutingNotificationsMap) { - mutingNotificationsMap.putAll(newMutingNotificationsMap); - notifyDataSetChanged(); - } - - static class MutedUserViewHolder extends RecyclerView.ViewHolder { - private ImageView avatar; - private TextView username; - private TextView displayName; - private ImageButton unmute; - private ImageButton muteNotifications; - private String id; - private boolean notifications; - - MutedUserViewHolder(View itemView) { - super(itemView); - avatar = itemView.findViewById(R.id.muted_user_avatar); - username = itemView.findViewById(R.id.muted_user_username); - displayName = itemView.findViewById(R.id.muted_user_display_name); - unmute = itemView.findViewById(R.id.muted_user_unmute); - muteNotifications = itemView.findViewById(R.id.muted_user_mute_notifications); - } - - void setupWithAccount(Account account, Boolean mutingNotifications, boolean animateAvatar, boolean animateEmojis) { - id = account.getId(); - CharSequence emojifiedName = CustomEmojiHelper.emojify(account.getName(), account.getEmojis(), displayName, animateEmojis); - displayName.setText(emojifiedName); - String format = username.getContext().getString(R.string.status_username_format); - String formattedUsername = String.format(format, account.getUsername()); - username.setText(formattedUsername); - int avatarRadius = avatar.getContext().getResources() - .getDimensionPixelSize(R.dimen.avatar_radius_48dp); - ImageLoadingHelper.loadAvatar(account.getAvatar(), avatar, avatarRadius, animateAvatar); - - String unmuteString = unmute.getContext().getString(R.string.action_unmute_desc, formattedUsername); - unmute.setContentDescription(unmuteString); - ViewCompat.setTooltipText(unmute, unmuteString); - - if (mutingNotifications == null) { - muteNotifications.setEnabled(false); - notifications = true; - } else { - muteNotifications.setEnabled(true); - notifications = mutingNotifications; - } - - if (notifications) { - muteNotifications.setImageResource(R.drawable.ic_notifications_24dp); - String unmuteNotificationsString = muteNotifications.getContext() - .getString(R.string.action_unmute_notifications_desc, formattedUsername); - muteNotifications.setContentDescription(unmuteNotificationsString); - ViewCompat.setTooltipText(muteNotifications, unmuteNotificationsString); - } else { - muteNotifications.setImageResource(R.drawable.ic_notifications_off_24dp); - String muteNotificationsString = muteNotifications.getContext() - .getString(R.string.action_mute_notifications_desc, formattedUsername); - muteNotifications.setContentDescription(muteNotificationsString); - ViewCompat.setTooltipText(muteNotifications, muteNotificationsString); - } - } - - void setupActionListener(final AccountActionListener listener) { - unmute.setOnClickListener(v -> listener.onMute(false, id, getBindingAdapterPosition(), false)); - muteNotifications.setOnClickListener( - v -> listener.onMute(true, id, getBindingAdapterPosition(), !notifications)); - itemView.setOnClickListener(v -> listener.onViewAccount(id)); - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/MutesAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/MutesAdapter.kt new file mode 100644 index 000000000..9fca33e8f --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/MutesAdapter.kt @@ -0,0 +1,132 @@ +package com.keylesspalace.tusky.adapter + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.TextView +import androidx.core.view.ViewCompat +import androidx.recyclerview.widget.RecyclerView +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.entity.Account +import com.keylesspalace.tusky.interfaces.AccountActionListener +import com.keylesspalace.tusky.util.emojify +import com.keylesspalace.tusky.util.loadAvatar +import java.util.HashMap + +/** + * Displays a list of muted accounts with mute/unmute account and mute/unmute notifications + * buttons. + * */ +class MutesAdapter( + accountActionListener: AccountActionListener, + animateAvatar: Boolean, + animateEmojis: Boolean +) : AccountAdapter( + accountActionListener, + animateAvatar, + animateEmojis +) { + private val mutingNotificationsMap = HashMap() + + override fun createAccountViewHolder(parent: ViewGroup): MutedUserViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.item_muted_user, parent, false) + return MutedUserViewHolder(view) + } + + override fun onBindAccountViewHolder(viewHolder: MutedUserViewHolder, position: Int) { + val account = accountList[position] + viewHolder.setupWithAccount( + account, + mutingNotificationsMap[account.id], + animateAvatar, + animateEmojis + ) + viewHolder.setupActionListener(accountActionListener) + } + + fun updateMutingNotifications(id: String, mutingNotifications: Boolean, position: Int) { + mutingNotificationsMap[id] = mutingNotifications + notifyItemChanged(position) + } + + fun updateMutingNotificationsMap(newMutingNotificationsMap: HashMap?) { + mutingNotificationsMap.putAll(newMutingNotificationsMap!!) + notifyDataSetChanged() + } + + class MutedUserViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + private val avatar: ImageView = itemView.findViewById(R.id.muted_user_avatar) + private val username: TextView = itemView.findViewById(R.id.muted_user_username) + private val displayName: TextView = itemView.findViewById(R.id.muted_user_display_name) + private val unmute: ImageButton = itemView.findViewById(R.id.muted_user_unmute) + private val muteNotifications: ImageButton = + itemView.findViewById(R.id.muted_user_mute_notifications) + + private var id: String? = null + private var notifications = false + + fun setupWithAccount( + account: Account, + mutingNotifications: Boolean?, + animateAvatar: Boolean, + animateEmojis: Boolean + ) { + id = account.id + val emojifiedName = account.name.emojify(account.emojis, displayName, animateEmojis) + displayName.text = emojifiedName + val format = username.context.getString(R.string.status_username_format) + val formattedUsername = String.format(format, account.username) + username.text = formattedUsername + val avatarRadius = avatar.context.resources + .getDimensionPixelSize(R.dimen.avatar_radius_48dp) + loadAvatar(account.avatar, avatar, avatarRadius, animateAvatar) + val unmuteString = + unmute.context.getString(R.string.action_unmute_desc, formattedUsername) + unmute.contentDescription = unmuteString + ViewCompat.setTooltipText(unmute, unmuteString) + if (mutingNotifications == null) { + muteNotifications.isEnabled = false + notifications = true + } else { + muteNotifications.isEnabled = true + notifications = mutingNotifications + } + if (notifications) { + muteNotifications.setImageResource(R.drawable.ic_notifications_24dp) + val unmuteNotificationsString = muteNotifications.context + .getString(R.string.action_unmute_notifications_desc, formattedUsername) + muteNotifications.contentDescription = unmuteNotificationsString + ViewCompat.setTooltipText(muteNotifications, unmuteNotificationsString) + } else { + muteNotifications.setImageResource(R.drawable.ic_notifications_off_24dp) + val muteNotificationsString = muteNotifications.context + .getString(R.string.action_mute_notifications_desc, formattedUsername) + muteNotifications.contentDescription = muteNotificationsString + ViewCompat.setTooltipText(muteNotifications, muteNotificationsString) + } + } + + fun setupActionListener(listener: AccountActionListener) { + unmute.setOnClickListener { + listener.onMute( + false, + id, + bindingAdapterPosition, + false + ) + } + muteNotifications.setOnClickListener { + listener.onMute( + true, + id, + bindingAdapterPosition, + !notifications + ) + } + itemView.setOnClickListener { listener.onViewAccount(id) } + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/NetworkStateViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/NetworkStateViewHolder.kt index b45ca95f7..cf7559908 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/NetworkStateViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/NetworkStateViewHolder.kt @@ -15,30 +15,28 @@ package com.keylesspalace.tusky.adapter +import androidx.paging.LoadState import androidx.recyclerview.widget.RecyclerView -import android.view.ViewGroup import com.keylesspalace.tusky.databinding.ItemNetworkStateBinding -import com.keylesspalace.tusky.util.NetworkState -import com.keylesspalace.tusky.util.Status import com.keylesspalace.tusky.util.visible -class NetworkStateViewHolder(private val binding: ItemNetworkStateBinding, - private val retryCallback: () -> Unit) -: RecyclerView.ViewHolder(binding.root) { +class NetworkStateViewHolder( + private val binding: ItemNetworkStateBinding, + private val retryCallback: () -> Unit +) : RecyclerView.ViewHolder(binding.root) { - fun setUpWithNetworkState(state: NetworkState?, fullScreen: Boolean) { - binding.progressBar.visible(state?.status == Status.RUNNING) - binding.retryButton.visible(state?.status == Status.FAILED) - binding.errorMsg.visible(state?.msg != null) - binding.errorMsg.text = state?.msg + fun setUpWithNetworkState(state: LoadState) { + binding.progressBar.visible(state == LoadState.Loading) + binding.retryButton.visible(state is LoadState.Error) + val msg = if (state is LoadState.Error) { + state.error.message + } else { + null + } + binding.errorMsg.visible(msg != null) + binding.errorMsg.text = msg binding.retryButton.setOnClickListener { retryCallback() } - if(fullScreen) { - binding.root.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT - } else { - binding.root.layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT - } } - -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java index cd3922b22..c3d12f918 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java @@ -1,4 +1,4 @@ -/* Copyright 2017 Andrew Dawson +/* Copyright 2021 Tusky Contributors * * This file is a part of Tusky. * @@ -199,14 +199,15 @@ public class NotificationsAdapter extends RecyclerView.Adapter { } else { holder.showNotificationContent(true); - holder.setDisplayName(statusViewData.getUserFullName(), statusViewData.getAccountEmojis()); - holder.setUsername(statusViewData.getNickname()); - holder.setCreatedAt(statusViewData.getCreatedAt()); + Status status = statusViewData.getActionable(); + holder.setDisplayName(status.getAccount().getName(), status.getAccount().getEmojis()); + holder.setUsername(status.getAccount().getUsername()); + holder.setCreatedAt(status.getCreatedAt()); - if(concreteNotificaton.getType() == Notification.Type.STATUS) { - holder.setAvatar(statusViewData.getAvatar(), statusViewData.isBot()); + if (concreteNotificaton.getType() == Notification.Type.STATUS) { + holder.setAvatar(status.getAccount().getAvatar(), status.getAccount().getBot()); } else { - holder.setAvatars(statusViewData.getAvatar(), + holder.setAvatars(status.getAccount().getAvatar(), concreteNotificaton.getAccount().getAvatar()); } } @@ -219,7 +220,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter { if (payloadForHolder instanceof List) for (Object item : (List) payloadForHolder) { if (StatusBaseViewHolder.Key.KEY_CREATED.equals(item) && statusViewData != null) { - holder.setCreatedAt(statusViewData.getCreatedAt()); + holder.setCreatedAt(statusViewData.getStatus().getActionableStatus().getCreatedAt()); } } } @@ -539,7 +540,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter { message.setText(emojifiedText); if (statusViewData != null) { - boolean hasSpoiler = !TextUtils.isEmpty(statusViewData.getSpoilerText()); + boolean hasSpoiler = !TextUtils.isEmpty(statusViewData.getStatus().getSpoilerText()); contentWarningDescriptionTextView.setVisibility(hasSpoiler ? View.VISIBLE : View.GONE); contentWarningButton.setVisibility(hasSpoiler ? View.VISIBLE : View.GONE); if (statusViewData.isExpanded()) { @@ -594,7 +595,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter { notificationAvatar.setVisibility(View.VISIBLE); ImageLoadingHelper.loadAvatar(notificationAvatarUrl, notificationAvatar, - avatarRadius24dp, statusDisplayOptions.animateAvatars()); + avatarRadius24dp, statusDisplayOptions.animateAvatars()); } private void setQuoteContainer(Status status, final LinkListener listener, StatusDisplayOptions statusDisplayOptions) { @@ -627,7 +628,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter { private void setupContentAndSpoiler(final LinkListener listener) { boolean shouldShowContentIfSpoiler = statusViewData.isExpanded(); - boolean hasSpoiler = !TextUtils.isEmpty(statusViewData.getSpoilerText()); + boolean hasSpoiler = !TextUtils.isEmpty(statusViewData.getStatus(). getSpoilerText()); if (!shouldShowContentIfSpoiler && hasSpoiler) { statusContent.setVisibility(View.GONE); } else { @@ -635,7 +636,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter { } Spanned content = statusViewData.getContent(); - List emojis = statusViewData.getStatusEmojis(); + List emojis = statusViewData.getActionable().getEmojis(); if (statusViewData.isCollapsible() && (statusViewData.isExpanded() || !hasSpoiler)) { contentCollapseButton.setOnClickListener(view -> { @@ -661,17 +662,22 @@ public class NotificationsAdapter extends RecyclerView.Adapter { CharSequence emojifiedText = CustomEmojiHelper.emojify( content, emojis, statusContent, statusDisplayOptions.animateEmojis() ); - LinkHelper.setClickableText(statusContent, emojifiedText, statusViewData.getMentions(), listener); + LinkHelper.setClickableText(statusContent, emojifiedText, statusViewData.getActionable().getMentions(), listener); - CharSequence emojifiedContentWarning = CustomEmojiHelper.emojify( - statusViewData.getSpoilerText(), - statusViewData.getStatusEmojis(), - contentWarningDescriptionTextView, - statusDisplayOptions.animateEmojis() - ); + CharSequence emojifiedContentWarning; + if (statusViewData.getSpoilerText() != null) { + emojifiedContentWarning = CustomEmojiHelper.emojify( + statusViewData.getSpoilerText(), + statusViewData.getActionable().getEmojis(), + contentWarningDescriptionTextView, + statusDisplayOptions.animateEmojis() + ); + } else { + emojifiedContentWarning = ""; + } contentWarningDescriptionTextView.setText(emojifiedContentWarning); - setQuoteContainer(statusViewData.getQuote(), listener, statusDisplayOptions); + setQuoteContainer(statusViewData.getStatus().getQuote(), listener, statusDisplayOptions); } } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/PlaceholderViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/PlaceholderViewHolder.java deleted file mode 100644 index f8f1a0b53..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/PlaceholderViewHolder.java +++ /dev/null @@ -1,49 +0,0 @@ -/* Copyright 2017 Andrew Dawson - * - * 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 . */ - -package com.keylesspalace.tusky.adapter; - -import androidx.recyclerview.widget.RecyclerView; -import android.view.View; -import android.widget.Button; -import android.widget.ProgressBar; - -import com.keylesspalace.tusky.R; -import com.keylesspalace.tusky.interfaces.StatusActionListener; - -public final class PlaceholderViewHolder extends RecyclerView.ViewHolder { - - private Button loadMoreButton; - private ProgressBar progressBar; - - PlaceholderViewHolder(View itemView) { - super(itemView); - loadMoreButton = itemView.findViewById(R.id.button_load_more); - progressBar = itemView.findViewById(R.id.progressBar); - } - - public void setup(final StatusActionListener listener, boolean progress) { - loadMoreButton.setVisibility(progress ? View.GONE : View.VISIBLE); - progressBar.setVisibility(progress ? View.VISIBLE : View.GONE); - - loadMoreButton.setEnabled(true); - loadMoreButton.setOnClickListener(v -> { - loadMoreButton.setEnabled(false); - listener.onLoadMore(getBindingAdapterPosition()); - }); - - } - -} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/PlaceholderViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/PlaceholderViewHolder.kt new file mode 100644 index 000000000..e80e3746d --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/PlaceholderViewHolder.kt @@ -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 . */ +package com.keylesspalace.tusky.adapter + +import android.view.View +import android.widget.Button +import android.widget.ProgressBar +import androidx.recyclerview.widget.RecyclerView +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.interfaces.StatusActionListener + +/** + * Placeholder for different timelines. + * Either displays "load more" button or a progress indicator. + **/ +class PlaceholderViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + private val loadMoreButton: Button = itemView.findViewById(R.id.button_load_more) + private val progressBar: ProgressBar = itemView.findViewById(R.id.progressBar) + + fun setup(listener: StatusActionListener, progress: Boolean) { + loadMoreButton.visibility = if (progress) View.GONE else View.VISIBLE + progressBar.visibility = if (progress) View.VISIBLE else View.GONE + loadMoreButton.isEnabled = true + loadMoreButton.setOnClickListener { v: View? -> + loadMoreButton.isEnabled = false + listener.onLoadMore(bindingAdapterPosition) + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/PollAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/PollAdapter.kt index 1f57cc4e0..89b3915e6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/PollAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/PollAdapter.kt @@ -29,7 +29,7 @@ import com.keylesspalace.tusky.viewdata.PollOptionViewData import com.keylesspalace.tusky.viewdata.buildDescription import com.keylesspalace.tusky.viewdata.calculatePercent -class PollAdapter: RecyclerView.Adapter>() { +class PollAdapter : RecyclerView.Adapter>() { private var pollOptions: List = emptyList() private var voteCount: Int = 0 @@ -40,13 +40,14 @@ class PollAdapter: RecyclerView.Adapter>() { private var animateEmojis = false fun setup( - options: List, - voteCount: Int, - votersCount: Int?, - emojis: List, - mode: Int, - resultClickListener: View.OnClickListener?, - animateEmojis: Boolean) { + options: List, + voteCount: Int, + votersCount: Int?, + emojis: List, + mode: Int, + resultClickListener: View.OnClickListener?, + animateEmojis: Boolean + ) { this.pollOptions = options this.voteCount = voteCount this.votersCount = votersCount @@ -57,12 +58,11 @@ class PollAdapter: RecyclerView.Adapter>() { notifyDataSetChanged() } - fun getSelected() : List { + fun getSelected(): List { return pollOptions.filter { it.selected } - .map { pollOptions.indexOf(it) } + .map { pollOptions.indexOf(it) } } - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder { val binding = ItemPollBinding.inflate(LayoutInflater.from(parent.context), parent, false) return BindingHolder(binding) @@ -82,12 +82,12 @@ class PollAdapter: RecyclerView.Adapter>() { radioButton.visible(mode == SINGLE) checkBox.visible(mode == MULTIPLE) - when(mode) { + when (mode) { RESULT -> { val percent = calculatePercent(option.votesCount, votersCount, voteCount) val emojifiedPollOptionText = buildDescription(option.title, percent, resultTextView.context) - .emojify(emojis, resultTextView, animateEmojis) - resultTextView.text = EmojiCompat.get().process(emojifiedPollOptionText) + .emojify(emojis, resultTextView, animateEmojis) + resultTextView.text = EmojiCompat.get().process(emojifiedPollOptionText) val level = percent * 100 @@ -114,7 +114,6 @@ class PollAdapter: RecyclerView.Adapter>() { } } } - } companion object { diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/PreviewPollOptionsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/PreviewPollOptionsAdapter.kt index 4206f7cfe..6b59672d1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/PreviewPollOptionsAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/PreviewPollOptionsAdapter.kt @@ -23,7 +23,7 @@ import androidx.core.widget.TextViewCompat import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R -class PreviewPollOptionsAdapter: RecyclerView.Adapter() { +class PreviewPollOptionsAdapter : RecyclerView.Adapter() { private var options: List = emptyList() private var multiple: Boolean = false @@ -60,7 +60,6 @@ class PreviewPollOptionsAdapter: RecyclerView.Adapter() { textView.setOnClickListener(clickListener) } - } -class PreviewViewHolder(itemView: View): RecyclerView.ViewHolder(itemView) +class PreviewViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/SavedTootAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/SavedTootAdapter.java deleted file mode 100644 index af9c31d5d..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/SavedTootAdapter.java +++ /dev/null @@ -1,122 +0,0 @@ -/* Copyright 2017 Andrew Dawson - * - * 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 . */ - -package com.keylesspalace.tusky.adapter; - -import android.content.Context; -import androidx.annotation.Nullable; -import androidx.recyclerview.widget.RecyclerView; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ImageButton; -import android.widget.TextView; - -import com.keylesspalace.tusky.R; -import com.keylesspalace.tusky.db.TootEntity; - -import java.util.ArrayList; -import java.util.List; - -public class SavedTootAdapter extends RecyclerView.Adapter { - private List list; - private SavedTootAction handler; - - public SavedTootAdapter(Context context) { - super(); - list = new ArrayList<>(); - handler = (SavedTootAction) context; - } - - @Override - public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { - View view = LayoutInflater.from(parent.getContext()) - .inflate(R.layout.item_saved_toot, parent, false); - return new TootViewHolder(view); - } - - @Override - public void onBindViewHolder(RecyclerView.ViewHolder viewHolder, int position) { - TootViewHolder holder = (TootViewHolder) viewHolder; - holder.bind(getItem(position)); - } - - @Override - public int getItemCount() { - return list.size(); - } - - public void setItems(List newToot) { - list = new ArrayList<>(); - list.addAll(newToot); - } - - public void addItems(List newToot) { - int end = list.size(); - list.addAll(newToot); - notifyItemRangeInserted(end, newToot.size()); - } - - @Nullable - public TootEntity removeItem(int position) { - if (position < 0 || position >= list.size()) { - return null; - } - TootEntity toot = list.remove(position); - notifyItemRemoved(position); - return toot; - } - - private TootEntity getItem(int position) { - if (position >= 0 && position < list.size()) { - return list.get(position); - } - return null; - } - - // handler saved toot - public interface SavedTootAction { - void delete(int position, TootEntity item); - - void click(int position, TootEntity item); - } - - private class TootViewHolder extends RecyclerView.ViewHolder { - View view; - TextView content; - ImageButton suppr; - - TootViewHolder(View view) { - super(view); - this.view = view; - this.content = view.findViewById(R.id.content); - this.suppr = view.findViewById(R.id.suppr); - } - - void bind(final TootEntity item) { - suppr.setEnabled(true); - - if (item != null) { - content.setText(item.getText()); - - suppr.setOnClickListener(v -> { - v.setEnabled(false); - handler.delete(getBindingAdapterPosition(), item); - }); - view.setOnClickListener(v -> handler.click(getBindingAdapterPosition(), item)); - } - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java index 4a54e3847..444c9c79b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java @@ -210,7 +210,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { protected void setSpoilerAndContent(boolean expanded, @NonNull Spanned content, @Nullable String spoilerText, - @Nullable Status.Mention[] mentions, + @Nullable List mentions, @NonNull List emojis, @Nullable PollViewData poll, @NonNull StatusDisplayOptions statusDisplayOptions, @@ -252,7 +252,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { private void setTextVisible(boolean sensitive, boolean expanded, Spanned content, - Status.Mention[] mentions, + List mentions, List emojis, @Nullable PollViewData poll, StatusDisplayOptions statusDisplayOptions, @@ -814,23 +814,25 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { this.setupWithStatus(status, listener, statusDisplayOptions, null); } - protected void setupWithStatus(StatusViewData.Concrete status, - final StatusActionListener listener, - StatusDisplayOptions statusDisplayOptions, - @Nullable Object payloads) { + public void setupWithStatus(StatusViewData.Concrete status, + final StatusActionListener listener, + StatusDisplayOptions statusDisplayOptions, + @Nullable Object payloads) { if (payloads == null) { - setDisplayName(status.getUserFullName(), status.getAccountEmojis(), statusDisplayOptions); - setUsername(status.getNickname()); - setCreatedAt(status.getCreatedAt(), statusDisplayOptions); - setStatusVisibility(status.getVisibility()); - setIsReply(status.getInReplyToId() != null); - setAvatar(status.getAvatar(), status.getRebloggedAvatar(), status.isBot(), statusDisplayOptions); - setReblogged(status.isReblogged()); - setFavourited(status.isFavourited()); - setQuoteContainer(status.getQuote(), listener, statusDisplayOptions); - setBookmarked(status.isBookmarked()); - List attachments = status.getAttachments(); - boolean sensitive = status.isSensitive(); + Status actionable = status.getActionable(); + setDisplayName(actionable.getAccount().getName(), actionable.getAccount().getEmojis(), statusDisplayOptions); + setUsername(status.getUsername()); + setCreatedAt(actionable.getCreatedAt(), statusDisplayOptions); + setStatusVisibility(actionable.getVisibility()); + setIsReply(actionable.getInReplyToId() != null); + setAvatar(actionable.getAccount().getAvatar(), status.getRebloggedAvatar(), + actionable.getAccount().getBot(), statusDisplayOptions); + setReblogged(actionable.getReblogged()); + setFavourited(actionable.getFavourited()); + setQuoteContainer(actionable.getQuote(), listener, statusDisplayOptions); + setBookmarked(actionable.getBookmarked()); + List attachments = actionable.getAttachments(); + boolean sensitive = actionable.getSensitive(); if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) { setMediaPreviews(attachments, sensitive, listener, status.isShowingContent(), statusDisplayOptions.useBlurhash()); @@ -855,12 +857,15 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { setupCard(status, statusDisplayOptions.cardViewMode(), statusDisplayOptions); } - setupButtons(listener, status.getSenderId(), status.getContent().toString(), - status.isNotestock(), statusDisplayOptions); - setRebloggingEnabled(status.getRebloggingEnabled(), status.getVisibility()); - setQuoteEnabled(status.getRebloggingEnabled() && !status.isNotestock(), status.getVisibility()); + setupButtons(listener, actionable.getAccount().getId(), status.getContent().toString(), + actionable.isNotestock(), statusDisplayOptions); + setRebloggingEnabled(actionable.rebloggingAllowed(), actionable.getVisibility()); + setQuoteEnabled(actionable.rebloggingAllowed() && !actionable.isNotestock(), actionable.getVisibility()); - setSpoilerAndContent(status.isExpanded(), status.getContent(), status.getSpoilerText(), status.getMentions(), status.getStatusEmojis(), status.getPoll(), statusDisplayOptions, listener); + setSpoilerAndContent(status.isExpanded(), status.getContent(), status.getSpoilerText(), + actionable.getMentions(), actionable.getEmojis(), + PollViewDataKt.toViewData(actionable.getPoll()), statusDisplayOptions, + listener); setDescriptionForStatus(status, statusDisplayOptions); @@ -874,7 +879,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { if (payloads instanceof List) for (Object item : (List) payloads) { if (Key.KEY_CREATED.equals(item)) { - setCreatedAt(status.getCreatedAt(), statusDisplayOptions); + setCreatedAt(status.getActionable().getCreatedAt(), statusDisplayOptions); } } @@ -893,21 +898,22 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { private void setDescriptionForStatus(@NonNull StatusViewData.Concrete status, StatusDisplayOptions statusDisplayOptions) { Context context = itemView.getContext(); + Status actionable = status.getActionable(); String description = context.getString(R.string.description_status, - status.getUserFullName(), + actionable.getAccount().getName(), getContentWarningDescription(context, status), - (TextUtils.isEmpty(status.getSpoilerText()) || !status.isSensitive() || status.isExpanded() ? status.getContent() : ""), - getCreatedAtDescription(status.getCreatedAt(), statusDisplayOptions), + (TextUtils.isEmpty(status.getSpoilerText()) || !actionable.getSensitive() || status.isExpanded() ? status.getContent() : ""), + getCreatedAtDescription(actionable.getCreatedAt(), statusDisplayOptions), getReblogDescription(context, status), - status.getNickname(), - status.isReblogged() ? context.getString(R.string.description_status_reblogged) : "", - status.isFavourited() ? context.getString(R.string.description_status_favourited) : "", - status.isBookmarked() ? context.getString(R.string.description_status_bookmarked) : "", + status.getUsername(), + actionable.getReblogged() ? context.getString(R.string.description_status_reblogged) : "", + actionable.getFavourited() ? context.getString(R.string.description_status_favourited) : "", + actionable.getBookmarked() ? context.getString(R.string.description_status_bookmarked) : "", getMediaDescription(context, status), - getVisibilityDescription(context, status.getVisibility()), - getFavsText(context, status.getFavouritesCount()), - getReblogsText(context, status.getReblogsCount()), + getVisibilityDescription(context, actionable.getVisibility()), + getFavsText(context, actionable.getFavouritesCount()), + getReblogsText(context, actionable.getReblogsCount()), getPollDescription(status, context, statusDisplayOptions) ); itemView.setContentDescription(description); @@ -915,10 +921,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { private static CharSequence getReblogDescription(Context context, @NonNull StatusViewData.Concrete status) { - String rebloggedUsername = status.getRebloggedByUsername(); - if (rebloggedUsername != null) { + Status reblog = status.getRebloggingStatus(); + if (reblog != null) { return context - .getString(R.string.status_boosted_format, rebloggedUsername); + .getString(R.string.status_boosted_format, reblog.getAccount().getUsername()); } else { return ""; } @@ -926,11 +932,11 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { private static CharSequence getMediaDescription(Context context, @NonNull StatusViewData.Concrete status) { - if (status.getAttachments().isEmpty()) { + if (status.getActionable().getAttachments().isEmpty()) { return ""; } StringBuilder mediaDescriptions = CollectionsKt.fold( - status.getAttachments(), + status.getActionable().getAttachments(), new StringBuilder(), (builder, a) -> { if (a.getDescription() == null) { @@ -983,7 +989,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { private CharSequence getPollDescription(@NonNull StatusViewData.Concrete status, Context context, StatusDisplayOptions statusDisplayOptions) { - PollViewData poll = status.getPoll(); + PollViewData poll = PollViewDataKt.toViewData(status.getActionable().getPoll()); if (poll == null) { return ""; } else { @@ -1089,7 +1095,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { StatusDisplayOptions statusDisplayOptions, Context context) { String votesText; - if(poll.getVotersCount() == null) { + if (poll.getVotersCount() == null) { String voters = numberFormat.format(poll.getVotesCount()); votesText = context.getResources().getQuantityString(R.plurals.poll_info_votes, poll.getVotesCount(), voters); } else { @@ -1113,12 +1119,12 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } protected void setupCard(StatusViewData.Concrete status, CardViewMode cardViewMode, StatusDisplayOptions statusDisplayOptions) { + final Card card = status.getActionable().getCard(); if (cardViewMode != CardViewMode.NONE && - status.getAttachments().size() == 0 && - status.getCard() != null && - !TextUtils.isEmpty(status.getCard().getUrl()) && + status.getActionable().getAttachments().size() == 0 && + card != null && + !TextUtils.isEmpty(card.getUrl()) && (!status.isCollapsible() || !status.isCollapsed())) { - final Card card = status.getCard(); cardView.setVisibility(View.VISIBLE); cardTitle.setText(card.getTitle()); if (TextUtils.isEmpty(card.getDescription()) && TextUtils.isEmpty(card.getAuthorName())) { @@ -1137,7 +1143,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { // Statuses from other activitypub sources can be marked sensitive even if there's no media, // so let's blur the preview in that case // If media previews are disabled, show placeholder for cards as well - if (statusDisplayOptions.mediaPreviewEnabled() && !status.isSensitive() && !TextUtils.isEmpty(card.getImage())) { + if (statusDisplayOptions.mediaPreviewEnabled() && !status.getActionable().getSensitive() && !TextUtils.isEmpty(card.getImage())) { int topLeftRadius = 0; int topRightRadius = 0; diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java index 82c9f3c4a..d9e5f4146 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java @@ -105,7 +105,7 @@ class StatusDetailedViewHolder extends StatusBaseViewHolder { } @Override - protected void setupWithStatus(final StatusViewData.Concrete status, + public void setupWithStatus(final StatusViewData.Concrete status, final StatusActionListener listener, StatusDisplayOptions statusDisplayOptions, @Nullable Object payloads) { @@ -114,12 +114,13 @@ class StatusDetailedViewHolder extends StatusBaseViewHolder { if (payloads == null) { if (!statusDisplayOptions.hideStats()) { - setReblogAndFavCount(status.getReblogsCount(), status.getFavouritesCount(), listener); + setReblogAndFavCount(status.getActionable().getReblogsCount(), + status.getActionable().getFavouritesCount(), listener); } else { hideQuantitativeStats(); } - setApplication(status.getApplication()); + setApplication(status.getActionable().getApplication()); View.OnLongClickListener longClickListener = view -> { TextView textView = (TextView) view; diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java index 68d64a698..165691455 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java @@ -26,6 +26,8 @@ import androidx.annotation.Nullable; import androidx.recyclerview.widget.RecyclerView; import com.keylesspalace.tusky.R; +import com.keylesspalace.tusky.entity.Emoji; +import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.util.CustomEmojiHelper; import com.keylesspalace.tusky.util.SmartLengthInputFilter; @@ -33,6 +35,8 @@ import com.keylesspalace.tusky.util.StatusDisplayOptions; import com.keylesspalace.tusky.util.StringUtils; import com.keylesspalace.tusky.viewdata.StatusViewData; +import java.util.List; + import at.connyduck.sparkbutton.helpers.Utils; public class StatusViewHolder extends StatusBaseViewHolder { @@ -54,19 +58,21 @@ public class StatusViewHolder extends StatusBaseViewHolder { } @Override - protected void setupWithStatus(StatusViewData.Concrete status, - final StatusActionListener listener, - StatusDisplayOptions statusDisplayOptions, - @Nullable Object payloads) { + public void setupWithStatus(StatusViewData.Concrete status, + final StatusActionListener listener, + StatusDisplayOptions statusDisplayOptions, + @Nullable Object payloads) { if (payloads == null) { setupCollapsedState(status, listener); - String rebloggedByDisplayName = status.getRebloggedByUsername(); - if (rebloggedByDisplayName == null) { + Status reblogging = status.getRebloggingStatus(); + if (reblogging == null) { hideStatusInfo(); } else { - setRebloggedByDisplayName(rebloggedByDisplayName, status, statusDisplayOptions); + String rebloggedByDisplayName = reblogging.getAccount().getName(); + setRebloggedByDisplayName(rebloggedByDisplayName, + reblogging.getAccount().getEmojis(), statusDisplayOptions); statusInfo.setOnClickListener(v -> listener.onOpenReblog(getBindingAdapterPosition())); } @@ -76,13 +82,13 @@ public class StatusViewHolder extends StatusBaseViewHolder { } private void setRebloggedByDisplayName(final CharSequence name, - final StatusViewData.Concrete status, + final List accountEmoji, final StatusDisplayOptions statusDisplayOptions) { Context context = statusInfo.getContext(); CharSequence wrappedName = StringUtils.unicodeWrap(name); CharSequence boostedText = context.getString(R.string.status_boosted_format, wrappedName); CharSequence emojifiedText = CustomEmojiHelper.emojify( - boostedText, status.getRebloggedByAccountEmojis(), statusInfo, statusDisplayOptions.animateEmojis() + boostedText, accountEmoji, statusInfo, statusDisplayOptions.animateEmojis() ); statusInfo.setText(emojifiedText); statusInfo.setVisibility(View.VISIBLE); diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/TabAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/TabAdapter.kt index bec07f067..994630a14 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/TabAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/TabAdapter.kt @@ -43,10 +43,11 @@ interface ItemInteractionListener { fun onChipClicked(tab: TabData, tabPosition: Int, chipPosition: Int) } -class TabAdapter(private var data: List, - private val small: Boolean, - private val listener: ItemInteractionListener, - private var removeButtonEnabled: Boolean = false +class TabAdapter( + private var data: List, + private val small: Boolean, + private val listener: ItemInteractionListener, + private var removeButtonEnabled: Boolean = false ) : RecyclerView.Adapter>() { fun updateData(newData: List) { @@ -77,7 +78,6 @@ class TabAdapter(private var data: List, binding.textView.setOnClickListener { listener.onTabAdded(tab) } - } else { val binding = holder.binding as ItemTabPreferenceBinding @@ -102,9 +102,9 @@ class TabAdapter(private var data: List, } binding.removeButton.isEnabled = removeButtonEnabled ThemeUtils.setDrawableTint( - holder.itemView.context, - binding.removeButton.drawable, - (if (removeButtonEnabled) android.R.attr.textColorTertiary else R.attr.textColorDisabled) + holder.itemView.context, + binding.removeButton.drawable, + (if (removeButtonEnabled) android.R.attr.textColorTertiary else R.attr.textColorDisabled) ) if (tab.id == HASHTAG) { @@ -118,14 +118,14 @@ class TabAdapter(private var data: List, tab.arguments.forEachIndexed { i, arg -> val chip = binding.chipGroup.getChildAt(i).takeUnless { it.id == R.id.actionChip } as Chip? - ?: Chip(context).apply { - binding.chipGroup.addView(this, binding.chipGroup.size - 1) - chipIconTint = ColorStateList.valueOf(ThemeUtils.getColor(context, android.R.attr.textColorPrimary)) - } + ?: Chip(context).apply { + binding.chipGroup.addView(this, binding.chipGroup.size - 1) + chipIconTint = ColorStateList.valueOf(ThemeUtils.getColor(context, android.R.attr.textColorPrimary)) + } chip.text = arg - if(tab.arguments.size <= 1) { + if (tab.arguments.size <= 1) { chip.chipIcon = null chip.setOnClickListener(null) } else { @@ -136,14 +136,13 @@ class TabAdapter(private var data: List, } } - while(binding.chipGroup.size - 1 > tab.arguments.size) { + while (binding.chipGroup.size - 1 > tab.arguments.size) { binding.chipGroup.removeViewAt(tab.arguments.size) } binding.actionChip.setOnClickListener { listener.onActionChipClicked(tab, holder.bindingAdapterPosition) } - } else { binding.chipGroup.hide() } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/ThreadAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/ThreadAdapter.java deleted file mode 100644 index 0143cb435..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/ThreadAdapter.java +++ /dev/null @@ -1,164 +0,0 @@ -/* Copyright 2017 Andrew Dawson - * - * 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 . */ - -package com.keylesspalace.tusky.adapter; - -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.recyclerview.widget.RecyclerView; - -import com.keylesspalace.tusky.R; -import com.keylesspalace.tusky.interfaces.StatusActionListener; -import com.keylesspalace.tusky.util.StatusDisplayOptions; -import com.keylesspalace.tusky.viewdata.StatusViewData; - -import java.util.ArrayList; -import java.util.List; - -public class ThreadAdapter extends RecyclerView.Adapter { - private static final int VIEW_TYPE_STATUS = 0; - private static final int VIEW_TYPE_STATUS_DETAILED = 1; - - private List statuses; - private StatusDisplayOptions statusDisplayOptions; - private StatusActionListener statusActionListener; - private int detailedStatusPosition; - - public ThreadAdapter(StatusDisplayOptions statusDisplayOptions, StatusActionListener listener) { - this.statusDisplayOptions = statusDisplayOptions; - this.statusActionListener = listener; - this.statuses = new ArrayList<>(); - detailedStatusPosition = RecyclerView.NO_POSITION; - } - - @NonNull - @Override - public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - switch (viewType) { - default: - case VIEW_TYPE_STATUS: { - View view = LayoutInflater.from(parent.getContext()) - .inflate(R.layout.item_status, parent, false); - return new StatusViewHolder(view); - } - case VIEW_TYPE_STATUS_DETAILED: { - View view = LayoutInflater.from(parent.getContext()) - .inflate(R.layout.item_status_detailed, parent, false); - return new StatusDetailedViewHolder(view); - } - } - } - - @Override - public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) { - StatusViewData.Concrete status = statuses.get(position); - if (position == detailedStatusPosition) { - StatusDetailedViewHolder holder = (StatusDetailedViewHolder) viewHolder; - holder.setupWithStatus(status, statusActionListener, statusDisplayOptions); - } else { - StatusViewHolder holder = (StatusViewHolder) viewHolder; - holder.setupWithStatus(status, statusActionListener, statusDisplayOptions); - } - } - - @Override - public int getItemViewType(int position) { - if (position == detailedStatusPosition) { - return VIEW_TYPE_STATUS_DETAILED; - } else { - return VIEW_TYPE_STATUS; - } - } - - @Override - public int getItemCount() { - return statuses.size(); - } - - public void setStatuses(List statuses) { - this.statuses.clear(); - this.statuses.addAll(statuses); - notifyDataSetChanged(); - } - - public void addItem(int position, StatusViewData.Concrete statusViewData) { - statuses.add(position, statusViewData); - notifyItemInserted(position); - } - - public void clearItems() { - int oldSize = statuses.size(); - statuses.clear(); - detailedStatusPosition = RecyclerView.NO_POSITION; - notifyItemRangeRemoved(0, oldSize); - } - - public void addAll(int position, List statuses) { - this.statuses.addAll(position, statuses); - notifyItemRangeInserted(position, statuses.size()); - } - - public void addAll(List statuses) { - int end = statuses.size(); - this.statuses.addAll(statuses); - notifyItemRangeInserted(end, statuses.size()); - } - - public void removeItem(int position) { - statuses.remove(position); - notifyItemRemoved(position); - } - - public void clear() { - statuses.clear(); - detailedStatusPosition = RecyclerView.NO_POSITION; - notifyDataSetChanged(); - } - - public void setItem(int position, StatusViewData.Concrete status, boolean notifyAdapter) { - statuses.set(position, status); - if (notifyAdapter) { - notifyItemChanged(position); - } - } - - @Nullable - public StatusViewData.Concrete getItem(int position) { - if (position >= 0 && position < statuses.size()) { - return statuses.get(position); - } else { - return null; - } - } - - public void setDetailedStatusPosition(int position) { - if (position != detailedStatusPosition - && detailedStatusPosition != RecyclerView.NO_POSITION) { - int prior = detailedStatusPosition; - detailedStatusPosition = position; - notifyItemChanged(prior); - } else { - detailedStatusPosition = position; - } - } - - public int getDetailedStatusPosition() { - return detailedStatusPosition; - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/ThreadAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/ThreadAdapter.kt new file mode 100644 index 000000000..8abbbd5f6 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/ThreadAdapter.kt @@ -0,0 +1,129 @@ +/* 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 . */ +package com.keylesspalace.tusky.adapter + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.interfaces.StatusActionListener +import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.viewdata.StatusViewData + +class ThreadAdapter( + private val statusDisplayOptions: StatusDisplayOptions, + private val statusActionListener: StatusActionListener +) : RecyclerView.Adapter() { + private val statuses = mutableListOf() + var detailedStatusPosition: Int = RecyclerView.NO_POSITION + private set + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StatusBaseViewHolder { + return when (viewType) { + VIEW_TYPE_STATUS -> { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.item_status, parent, false) + StatusViewHolder(view) + } + VIEW_TYPE_STATUS_DETAILED -> { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.item_status_detailed, parent, false) + StatusDetailedViewHolder(view) + } + else -> error("Unknown item type: $viewType") + } + } + + override fun onBindViewHolder(viewHolder: StatusBaseViewHolder, position: Int) { + val status = statuses[position] + viewHolder.setupWithStatus(status, statusActionListener, statusDisplayOptions) + } + + override fun getItemViewType(position: Int): Int { + return if (position == detailedStatusPosition) { + VIEW_TYPE_STATUS_DETAILED + } else { + VIEW_TYPE_STATUS + } + } + + override fun getItemCount(): Int = statuses.size + + fun setStatuses(statuses: List?) { + this.statuses.clear() + this.statuses.addAll(statuses!!) + notifyDataSetChanged() + } + + fun addItem(position: Int, statusViewData: StatusViewData.Concrete) { + statuses.add(position, statusViewData) + notifyItemInserted(position) + } + + fun clearItems() { + val oldSize = statuses.size + statuses.clear() + detailedStatusPosition = RecyclerView.NO_POSITION + notifyItemRangeRemoved(0, oldSize) + } + + fun addAll(position: Int, statuses: List) { + this.statuses.addAll(position, statuses) + notifyItemRangeInserted(position, statuses.size) + } + + fun addAll(statuses: List) { + val end = statuses.size + this.statuses.addAll(statuses) + notifyItemRangeInserted(end, statuses.size) + } + + fun removeItem(position: Int) { + statuses.removeAt(position) + notifyItemRemoved(position) + } + + fun clear() { + statuses.clear() + detailedStatusPosition = RecyclerView.NO_POSITION + notifyDataSetChanged() + } + + fun setItem(position: Int, status: StatusViewData.Concrete, notifyAdapter: Boolean) { + statuses[position] = status + if (notifyAdapter) { + notifyItemChanged(position) + } + } + + fun getItem(position: Int): StatusViewData.Concrete? = statuses.getOrNull(position) + + fun setDetailedStatusPosition(position: Int) { + if (position != detailedStatusPosition && + detailedStatusPosition != RecyclerView.NO_POSITION + ) { + val prior = detailedStatusPosition + detailedStatusPosition = position + notifyItemChanged(prior) + } else { + detailedStatusPosition = position + } + } + + companion object { + private const val VIEW_TYPE_STATUS = 0 + private const val VIEW_TYPE_STATUS_DETAILED = 1 + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/appstore/CacheUpdater.kt b/app/src/main/java/com/keylesspalace/tusky/appstore/CacheUpdater.kt index 6404de5ce..7b5ecf77a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/appstore/CacheUpdater.kt +++ b/app/src/main/java/com/keylesspalace/tusky/appstore/CacheUpdater.kt @@ -3,16 +3,16 @@ package com.keylesspalace.tusky.appstore import com.google.gson.Gson import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase -import io.reactivex.Single -import io.reactivex.disposables.Disposable -import io.reactivex.schedulers.Schedulers +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.disposables.Disposable +import io.reactivex.rxjava3.schedulers.Schedulers import javax.inject.Inject class CacheUpdater @Inject constructor( - eventHub: EventHub, - accountManager: AccountManager, - private val appDatabase: AppDatabase, - gson: Gson + eventHub: EventHub, + accountManager: AccountManager, + private val appDatabase: AppDatabase, + gson: Gson ) { private val disposable: Disposable @@ -27,7 +27,7 @@ class CacheUpdater @Inject constructor( is ReblogEvent -> timelineDao.setReblogged(accountId, event.statusId, event.reblog) is BookmarkEvent -> - timelineDao.setBookmarked(accountId, event.statusId, event.bookmark ) + timelineDao.setBookmarked(accountId, event.statusId, event.bookmark) is UnfollowEvent -> timelineDao.removeAllByUser(accountId, event.accountId) is StatusDeletedEvent -> @@ -49,7 +49,7 @@ class CacheUpdater @Inject constructor( appDatabase.timelineDao().removeAllForAccount(accountId) appDatabase.timelineDao().removeAllUsersForAccount(accountId) } - .subscribeOn(Schedulers.io()) - .subscribe() + .subscribeOn(Schedulers.io()) + .subscribe() } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt b/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt index 42e2a208e..1572d276e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt +++ b/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt @@ -1,10 +1,10 @@ package com.keylesspalace.tusky.appstore import com.keylesspalace.tusky.TabData +import com.keylesspalace.tusky.components.timeline.TimelineViewModel import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Status -import com.keylesspalace.tusky.fragment.TimelineFragment data class FavoriteEvent(val statusId: String, val favourite: Boolean) : Dispatchable data class ReblogEvent(val statusId: String, val reblog: Boolean) : Dispatchable @@ -20,7 +20,8 @@ data class ProfileEditedEvent(val newProfileData: Account) : Dispatchable data class PreferenceChangedEvent(val preferenceKey: String) : Dispatchable data class MainTabsChangedEvent(val newTabs: List) : Dispatchable data class PollVoteEvent(val statusId: String, val poll: Poll) : Dispatchable -data class DomainMuteEvent(val instance: String): Dispatchable -data class AnnouncementReadEvent(val announcementId: String): Dispatchable +data class DomainMuteEvent(val instance: String) : Dispatchable +data class AnnouncementReadEvent(val announcementId: String) : Dispatchable +data class PinEvent(val statusId: String, val pinned: Boolean) : Dispatchable data class QuickReplyEvent(val status: Status) : Dispatchable -data class StreamUpdateEvent(val status: Status, val targetKind: TimelineFragment.Kind, val targetIdentifier: String?, val first: Boolean) : Dispatchable +data class StreamUpdateEvent(val status: Status, val targetKind: TimelineViewModel.Kind, val targetIdentifier: String?) : Dispatchable diff --git a/app/src/main/java/com/keylesspalace/tusky/appstore/EventsHub.kt b/app/src/main/java/com/keylesspalace/tusky/appstore/EventsHub.kt index ceaf51331..316974935 100644 --- a/app/src/main/java/com/keylesspalace/tusky/appstore/EventsHub.kt +++ b/app/src/main/java/com/keylesspalace/tusky/appstore/EventsHub.kt @@ -1,7 +1,7 @@ package com.keylesspalace.tusky.appstore -import io.reactivex.Observable -import io.reactivex.subjects.PublishSubject +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.subjects.PublishSubject interface Event interface Dispatchable : Event @@ -19,4 +19,4 @@ object EventHubImpl : EventHub { override fun dispatch(event: Dispatchable) { eventsSubject.onNext(event) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementAdapter.kt index 5014b52e2..2b05ee088 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementAdapter.kt @@ -31,17 +31,17 @@ import com.keylesspalace.tusky.util.BindingHolder import com.keylesspalace.tusky.util.LinkHelper import com.keylesspalace.tusky.util.emojify -interface AnnouncementActionListener: LinkListener { +interface AnnouncementActionListener : LinkListener { fun openReactionPicker(announcementId: String, target: View) fun addReaction(announcementId: String, name: String) fun removeReaction(announcementId: String, name: String) } class AnnouncementAdapter( - private var items: List = emptyList(), - private val listener: AnnouncementActionListener, - private val wellbeingEnabled: Boolean = false, - private val animateEmojis: Boolean = false + private var items: List = emptyList(), + private val listener: AnnouncementActionListener, + private val wellbeingEnabled: Boolean = false, + private val animateEmojis: Boolean = false ) : RecyclerView.Adapter>() { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder { @@ -67,12 +67,12 @@ class AnnouncementAdapter( } item.reactions.forEachIndexed { i, reaction -> - (chips.getChildAt(i)?.takeUnless { it.id == R.id.addReactionChip } as Chip? - ?: Chip(ContextThemeWrapper(chips.context, R.style.Widget_MaterialComponents_Chip_Choice)).apply { - isCheckable = true - checkedIcon = null - chips.addView(this, i) - }) + chips.getChildAt(i)?.takeUnless { it.id == R.id.addReactionChip } as Chip? + ?: Chip(ContextThemeWrapper(chips.context, R.style.Widget_MaterialComponents_Chip_Choice)).apply { + isCheckable = true + checkedIcon = null + chips.addView(this, i) + } .apply { val emojiText = if (reaction.url == null) { reaction.name @@ -80,16 +80,18 @@ class AnnouncementAdapter( context.getString(R.string.emoji_shortcode_format, reaction.name) } this.text = ("$emojiText ${reaction.count}") - .emojify( - listOf(Emoji( - reaction.name, - reaction.url ?: "", - reaction.staticUrl ?: "", - null - )), - this, - animateEmojis - ) + .emojify( + listOf( + Emoji( + reaction.name, + reaction.url ?: "", + reaction.staticUrl ?: "", + null + ) + ), + this, + animateEmojis + ) isChecked = reaction.me diff --git a/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsActivity.kt index 60def921c..88f0ad041 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsActivity.kt @@ -34,7 +34,12 @@ import com.keylesspalace.tusky.databinding.ActivityAnnouncementsBinding import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.settings.PrefKeys -import com.keylesspalace.tusky.util.* +import com.keylesspalace.tusky.util.Error +import com.keylesspalace.tusky.util.Loading +import com.keylesspalace.tusky.util.Success +import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.view.EmojiPicker import javax.inject.Inject @@ -52,13 +57,13 @@ class AnnouncementsActivity : BottomSheetActivity(), AnnouncementActionListener, private val picker by lazy { EmojiPicker(this) } private val pickerDialog by lazy { PopupWindow(this) - .apply { - contentView = picker - isFocusable = true - setOnDismissListener { - currentAnnouncementId = null - } + .apply { + contentView = picker + isFocusable = true + setOnDismissListener { + currentAnnouncementId = null } + } } private var currentAnnouncementId: String? = null diff --git a/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsViewModel.kt index 964cc739a..fef8f9326 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsViewModel.kt @@ -27,15 +27,20 @@ import com.keylesspalace.tusky.entity.Announcement import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.Instance import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.util.* -import io.reactivex.rxkotlin.Singles +import com.keylesspalace.tusky.util.Either +import com.keylesspalace.tusky.util.Error +import com.keylesspalace.tusky.util.Loading +import com.keylesspalace.tusky.util.Resource +import com.keylesspalace.tusky.util.RxAwareViewModel +import com.keylesspalace.tusky.util.Success +import io.reactivex.rxjava3.core.Single import javax.inject.Inject class AnnouncementsViewModel @Inject constructor( - accountManager: AccountManager, - private val appDatabase: AppDatabase, - private val mastodonApi: MastodonApi, - private val eventHub: EventHub + accountManager: AccountManager, + private val appDatabase: AppDatabase, + private val mastodonApi: MastodonApi, + private val eventHub: EventHub ) : RxAwareViewModel() { private val announcementsMutable = MutableLiveData>>() @@ -45,140 +50,153 @@ class AnnouncementsViewModel @Inject constructor( val emojis: LiveData> = emojisMutable init { - Singles.zip( - mastodonApi.getCustomEmojis(), - appDatabase.instanceDao().loadMetadataForInstance(accountManager.activeAccount?.domain!!) - .map> { Either.Left(it) } - .onErrorResumeNext( - mastodonApi.getInstance() - .map { Either.Right(it) } - ) - ) { emojis, either -> - either.asLeftOrNull()?.copy(emojiList = emojis) + Single.zip( + mastodonApi.getCustomEmojis(), + appDatabase.instanceDao().loadMetadataForInstance(accountManager.activeAccount?.domain!!) + .map> { Either.Left(it) } + .onErrorResumeNext { + mastodonApi.getInstance() + .map { Either.Right(it) } + }, + { emojis, either -> + either.asLeftOrNull()?.copy(emojiList = emojis) ?: InstanceEntity( - accountManager.activeAccount?.domain!!, - emojis, - either.asRight().maxTootChars, - either.asRight().pollLimits?.maxOptions, - either.asRight().pollLimits?.maxOptionChars, - either.asRight().version + accountManager.activeAccount?.domain!!, + emojis, + either.asRight().maxTootChars, + either.asRight().pollLimits?.maxOptions, + either.asRight().pollLimits?.maxOptionChars, + either.asRight().version ) - } - .doOnSuccess { - appDatabase.instanceDao().insertOrReplace(it) - } - .subscribe({ - emojisMutable.postValue(it.emojiList) - }, { + } + ) + .doOnSuccess { + appDatabase.instanceDao().insertOrReplace(it) + } + .subscribe( + { + emojisMutable.postValue(it.emojiList.orEmpty()) + }, + { Log.w(TAG, "Failed to get custom emojis.", it) - }) - .autoDispose() + } + ) + .autoDispose() } fun load() { announcementsMutable.postValue(Loading()) mastodonApi.listAnnouncements() - .subscribe({ + .subscribe( + { announcementsMutable.postValue(Success(it)) it.filter { announcement -> !announcement.read } - .forEach { announcement -> - mastodonApi.dismissAnnouncement(announcement.id) - .subscribe( - { - eventHub.dispatch(AnnouncementReadEvent(announcement.id)) - }, - { throwable -> - Log.d(TAG, "Failed to mark announcement as read.", throwable) - } - ) - .autoDispose() - } - }, { + .forEach { announcement -> + mastodonApi.dismissAnnouncement(announcement.id) + .subscribe( + { + eventHub.dispatch(AnnouncementReadEvent(announcement.id)) + }, + { throwable -> + Log.d(TAG, "Failed to mark announcement as read.", throwable) + } + ) + .autoDispose() + } + }, + { announcementsMutable.postValue(Error(cause = it)) - }) - .autoDispose() + } + ) + .autoDispose() } fun addReaction(announcementId: String, name: String) { mastodonApi.addAnnouncementReaction(announcementId, name) - .subscribe({ + .subscribe( + { announcementsMutable.postValue( - Success( - announcements.value!!.data!!.map { announcement -> - if (announcement.id == announcementId) { - announcement.copy( - reactions = if (announcement.reactions.find { reaction -> reaction.name == name } != null) { - announcement.reactions.map { reaction -> - if (reaction.name == name) { - reaction.copy( - count = reaction.count + 1, - me = true - ) - } else { - reaction - } - } - } else { - listOf( - *announcement.reactions.toTypedArray(), - emojis.value!!.find { emoji -> emoji.shortcode == name } - !!.run { - Announcement.Reaction( - name, - 1, - true, - url, - staticUrl - ) - } - ) - } - ) + Success( + announcements.value!!.data!!.map { announcement -> + if (announcement.id == announcementId) { + announcement.copy( + reactions = if (announcement.reactions.find { reaction -> reaction.name == name } != null) { + announcement.reactions.map { reaction -> + if (reaction.name == name) { + reaction.copy( + count = reaction.count + 1, + me = true + ) + } else { + reaction + } + } } else { - announcement + listOf( + *announcement.reactions.toTypedArray(), + emojis.value!!.find { emoji -> emoji.shortcode == name } + !!.run { + Announcement.Reaction( + name, + 1, + true, + url, + staticUrl + ) + } + ) } - } - ) + ) + } else { + announcement + } + } + ) ) - }, { + }, + { Log.w(TAG, "Failed to add reaction to the announcement.", it) - }) - .autoDispose() + } + ) + .autoDispose() } fun removeReaction(announcementId: String, name: String) { mastodonApi.removeAnnouncementReaction(announcementId, name) - .subscribe({ + .subscribe( + { announcementsMutable.postValue( - Success( - announcements.value!!.data!!.map { announcement -> - if (announcement.id == announcementId) { - announcement.copy( - reactions = announcement.reactions.mapNotNull { reaction -> - if (reaction.name == name) { - if (reaction.count > 1) { - reaction.copy( - count = reaction.count - 1, - me = false - ) - } else { - null - } - } else { - reaction - } - } - ) - } else { - announcement + Success( + announcements.value!!.data!!.map { announcement -> + if (announcement.id == announcementId) { + announcement.copy( + reactions = announcement.reactions.mapNotNull { reaction -> + if (reaction.name == name) { + if (reaction.count > 1) { + reaction.copy( + count = reaction.count - 1, + me = false + ) + } else { + null + } + } else { + reaction + } } - } - ) + ) + } else { + announcement + } + } + ) ) - }, { + }, + { Log.w(TAG, "Failed to remove reaction from the announcement.", it) - }) - .autoDispose() + } + ) + .autoDispose() } companion object { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt index 7e0702b0d..fe82b8f9f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt @@ -16,7 +16,6 @@ package com.keylesspalace.tusky.components.compose import android.Manifest -import android.app.Activity import android.app.ProgressDialog import android.content.Context import android.content.Intent @@ -25,18 +24,20 @@ import android.content.pm.PackageManager import android.graphics.PorterDuff import android.graphics.PorterDuffColorFilter import android.net.ConnectivityManager -import android.net.NetworkCapabilities import android.net.Uri import android.os.Build import android.os.Bundle import android.os.Parcelable -import android.provider.MediaStore import android.util.Log import android.view.KeyEvent import android.view.MenuItem import android.view.View import android.view.ViewGroup -import android.widget.* +import android.widget.ImageButton +import android.widget.LinearLayout +import android.widget.PopupMenu +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.annotation.ColorInt import androidx.annotation.StringRes @@ -84,18 +85,19 @@ import com.mikepenz.iconics.utils.sizeDp import kotlinx.parcelize.Parcelize import java.io.File import java.io.IOException -import java.util.Locale +import java.util.* import javax.inject.Inject import kotlin.math.max import kotlin.math.min -class ComposeActivity : BaseActivity(), - ComposeOptionsListener, - ComposeAutoCompleteAdapter.AutocompletionProvider, - OnEmojiSelectedListener, - Injectable, - InputConnectionCompat.OnCommitContentListener, - ComposeScheduleView.OnTimeSetListener { +class ComposeActivity : + BaseActivity(), + ComposeOptionsListener, + ComposeAutoCompleteAdapter.AutocompletionProvider, + OnEmojiSelectedListener, + Injectable, + InputConnectionCompat.OnCommitContentListener, + ComposeScheduleView.OnTimeSetListener { @Inject lateinit var viewModelFactory: ViewModelFactory @@ -121,6 +123,21 @@ class ComposeActivity : BaseActivity(), private val maxUploadMediaNumber = 4 private var mediaCount = 0 + private val takePicture = registerForActivityResult(ActivityResultContracts.TakePicture()) { success -> + if (success) { + pickMedia(photoUploadUri!!) + } + } + private val pickMediaFile = registerForActivityResult(PickMediaFiles()) { uris -> + if (mediaCount + uris.size > maxUploadMediaNumber) { + Toast.makeText(this, resources.getQuantityString(R.plurals.error_upload_max_media_reached, maxUploadMediaNumber, maxUploadMediaNumber), Toast.LENGTH_SHORT).show() + } else { + uris.forEach { uri -> + pickMedia(uri) + } + } + } + public override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -137,16 +154,16 @@ class ComposeActivity : BaseActivity(), setupAvatar(preferences, activeAccount) val mediaAdapter = MediaPreviewAdapter( - this, - onAddCaption = { item -> - makeCaptionDialog(item.description, item.uri) { newDescription -> - viewModel.updateDescription(item.localId, newDescription) - } - }, - onRemove = this::removeMediaFromQueue + this, + onAddCaption = { item -> + makeCaptionDialog(item.description, item.uri) { newDescription -> + viewModel.updateDescription(item.localId, newDescription) + } + }, + onRemove = this::removeMediaFromQueue ) binding.composeMediaPreviewBar.layoutManager = - LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false) + LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false) binding.composeMediaPreviewBar.adapter = mediaAdapter binding.composeMediaPreviewBar.itemAnimator = null @@ -168,11 +185,7 @@ class ComposeActivity : BaseActivity(), binding.composeEditField.setText(tootText) } - if (loadInstanceData(preferences, composeOptions?.tootRightNow == true)) { - viewModel.loadInstanceDataFromNetwork() - } else { - viewModel.loadInstanceDataFromCache() - } + viewModel.loadInstanceDataFromNetwork(loadInstanceData(preferences, composeOptions?.tootRightNow == true)) if (!composeOptions?.scheduledAt.isNullOrEmpty()) { binding.composeScheduleView.setDateTime(composeOptions?.scheduledAt) @@ -314,11 +327,11 @@ class ComposeActivity : BaseActivity(), binding.composeEditField.setOnKeyListener { _, keyCode, event -> this.onKeyDown(keyCode, event) } binding.composeEditField.setAdapter( - ComposeAutoCompleteAdapter( - this, - preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), - preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) - ) + ComposeAutoCompleteAdapter( + this, + preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), + preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) + ) ) binding.composeEditField.setTokenizer(ComposeTokenizer()) @@ -333,8 +346,9 @@ class ComposeActivity : BaseActivity(), } // work around Android platform bug -> https://issuetracker.google.com/issues/67102093 - if (Build.VERSION.SDK_INT == Build.VERSION_CODES.O - || Build.VERSION.SDK_INT == Build.VERSION_CODES.O_MR1) { + if (Build.VERSION.SDK_INT == Build.VERSION_CODES.O || + Build.VERSION.SDK_INT == Build.VERSION_CODES.O_MR1 + ) { binding.composeEditField.setLayerType(View.LAYER_TYPE_SOFTWARE, null) } } @@ -375,9 +389,9 @@ class ComposeActivity : BaseActivity(), updateScheduleButton() } combineOptionalLiveData(viewModel.media, viewModel.poll) { media, poll -> - val active = poll == null - && media!!.size != 4 - && (media.isEmpty() || media.first().type == QueuedMedia.Type.IMAGE) + val active = poll == null && + media!!.size != 4 && + (media.isEmpty() || media.first().type == QueuedMedia.Type.IMAGE) enableButton(binding.composeAddMediaButton, active, active) enablePollButton(media.isNullOrEmpty()) }.subscribe() @@ -457,7 +471,6 @@ class ComposeActivity : BaseActivity(), setDisplayShowHomeEnabled(true) setHomeAsUpIndicator(R.drawable.ic_close_24dp) } - } private fun setupAvatar(preferences: SharedPreferences, activeAccount: AccountEntity) { @@ -468,13 +481,15 @@ class ComposeActivity : BaseActivity(), val animateAvatars = preferences.getBoolean("animateGifAvatars", false) loadAvatar( - activeAccount.profilePictureUrl, - binding.composeAvatar, - avatarSize / 8, - animateAvatars + activeAccount.profilePictureUrl, + binding.composeAvatar, + avatarSize / 8, + animateAvatars + ) + binding.composeAvatar.contentDescription = getString( + R.string.compose_active_account_description, + activeAccount.fullName ) - binding.composeAvatar.contentDescription = getString(R.string.compose_active_account_description, - activeAccount.fullName) } private fun replaceTextAtCaret(text: CharSequence) { @@ -532,7 +547,6 @@ class ComposeActivity : BaseActivity(), } } - private fun atButtonClicked() { prependSelectedWordsWith("@") } @@ -548,7 +562,7 @@ class ComposeActivity : BaseActivity(), private fun displayTransientError(@StringRes stringId: Int) { val bar = Snackbar.make(binding.activityCompose, stringId, Snackbar.LENGTH_LONG) - //necessary so snackbar is shown over everything + // necessary so snackbar is shown over everything bar.view.elevation = resources.getDimension(R.dimen.compose_activity_snackbar_elevation) bar.show() } @@ -566,7 +580,6 @@ class ComposeActivity : BaseActivity(), binding.composeHideMediaButton.setImageResource(R.drawable.ic_hide_media_24dp) binding.composeHideMediaButton.isClickable = false ContextCompat.getColor(this, R.color.transparent_tusky_blue) - } else { binding.composeHideMediaButton.isClickable = true if (markMediaSensitive) { @@ -676,15 +689,17 @@ class ComposeActivity : BaseActivity(), private fun onMediaPick() { addMediaBehavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() { override fun onStateChanged(bottomSheet: View, newState: Int) { - //Wait until bottom sheet is not collapsed and show next screen after + // Wait until bottom sheet is not collapsed and show next screen after if (newState == BottomSheetBehavior.STATE_COLLAPSED) { addMediaBehavior.removeBottomSheetCallback(this) if (ContextCompat.checkSelfPermission(this@ComposeActivity, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { - ActivityCompat.requestPermissions(this@ComposeActivity, - arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), - PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE) + ActivityCompat.requestPermissions( + this@ComposeActivity, + arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), + PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE + ) } else { - initiateMediaPicking() + pickMediaFile.launch(true) } } } @@ -698,8 +713,10 @@ class ComposeActivity : BaseActivity(), private fun openPollDialog() { addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED val instanceParams = viewModel.instanceParams.value!! - showAddPollDialog(this, viewModel.poll.value, instanceParams.pollMaxOptions, - instanceParams.pollMaxLength, viewModel::updatePoll) + showAddPollDialog( + this, viewModel.poll.value, instanceParams.pollMaxOptions, + instanceParams.pollMaxLength, viewModel::updatePoll + ) } private fun setupPollView() { @@ -826,35 +843,40 @@ class ComposeActivity : BaseActivity(), if (viewModel.media.value!!.isNotEmpty()) { finishingUploadDialog = ProgressDialog.show( - this, getString(R.string.dialog_title_finishing_media_upload), - getString(R.string.dialog_message_uploading_media), true, true) + this, getString(R.string.dialog_title_finishing_media_upload), + getString(R.string.dialog_message_uploading_media), true, true + ) } - viewModel.sendStatus(contentText, spoilerText).observe(this, { - finishingUploadDialog?.dismiss() - deleteDraftAndFinish() - }) - + viewModel.sendStatus(contentText, spoilerText).observe( + this, + { + finishingUploadDialog?.dismiss() + deleteDraftAndFinish() + } + ) } else { binding.composeEditField.error = getString(R.string.error_compose_character_limit) enableButtons(true) } } - override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, - grantResults: IntArray) { + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode == PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE) { if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { - initiateMediaPicking() + pickMediaFile.launch(true) } else { - val bar = Snackbar.make(binding.activityCompose, R.string.error_media_upload_permission, - Snackbar.LENGTH_SHORT).apply { - + Snackbar.make( + binding.activityCompose, R.string.error_media_upload_permission, + Snackbar.LENGTH_SHORT + ).apply { + setAction(R.string.action_retry) { onMediaPick() } + // necessary so snackbar is shown over everything + view.elevation = resources.getDimension(R.dimen.compose_activity_snackbar_elevation) + show() } - bar.setAction(R.string.action_retry) { onMediaPick() } - //necessary so snackbar is shown over everything - bar.view.elevation = resources.getDimension(R.dimen.compose_activity_snackbar_elevation) - bar.show() } } } @@ -862,50 +884,38 @@ class ComposeActivity : BaseActivity(), private fun initiateCameraApp() { addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED - // We don't need to ask for permission in this case, because the used calls require - // android.permission.WRITE_EXTERNAL_STORAGE only on SDKs *older* than Kitkat, which was - // way before permission dialogues have been introduced. - val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE) - if (intent.resolveActivity(packageManager) != null) { - val photoFile: File = try { - createNewImageFile(this) - } catch (ex: IOException) { - displayTransientError(R.string.error_media_upload_opening) - return - } - - // Continue only if the File was successfully created - photoUploadUri = FileProvider.getUriForFile(this, - BuildConfig.APPLICATION_ID + ".fileprovider", - photoFile) - intent.putExtra(MediaStore.EXTRA_OUTPUT, photoUploadUri) - startActivityForResult(intent, MEDIA_TAKE_PHOTO_RESULT) + val photoFile: File = try { + createNewImageFile(this) + } catch (ex: IOException) { + displayTransientError(R.string.error_media_upload_opening) + return } - } - private fun initiateMediaPicking() { - val intent = Intent(Intent.ACTION_GET_CONTENT) - intent.addCategory(Intent.CATEGORY_OPENABLE) - - val mimeTypes = arrayOf("image/*", "video/*", "audio/*") - intent.type = "*/*" - intent.putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes) - intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) - startActivityForResult(intent, MEDIA_PICK_RESULT) + // Continue only if the File was successfully created + photoUploadUri = FileProvider.getUriForFile( + this, + BuildConfig.APPLICATION_ID + ".fileprovider", + photoFile + ) + takePicture.launch(photoUploadUri) } private fun enableButton(button: ImageButton, clickable: Boolean, colorActive: Boolean) { button.isEnabled = clickable - ThemeUtils.setDrawableTint(this, button.drawable, - if (colorActive) android.R.attr.textColorTertiary - else R.attr.textColorDisabled) + ThemeUtils.setDrawableTint( + this, button.drawable, + if (colorActive) android.R.attr.textColorTertiary + else R.attr.textColorDisabled + ) } private fun enablePollButton(enable: Boolean) { binding.addPollTextActionTextView.isEnabled = enable - val textColor = ThemeUtils.getColor(this, - if (enable) android.R.attr.textColorTertiary - else R.attr.textColorDisabled) + val textColor = ThemeUtils.getColor( + this, + if (enable) android.R.attr.textColorTertiary + else R.attr.textColorDisabled + ) binding.addPollTextActionTextView.setTextColor(textColor) binding.addPollTextActionTextView.compoundDrawablesRelative[0].colorFilter = PorterDuffColorFilter(textColor, PorterDuff.Mode.SRC_IN) } @@ -914,31 +924,6 @@ class ComposeActivity : BaseActivity(), viewModel.removeMediaFromQueue(item) } - override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) { - super.onActivityResult(requestCode, resultCode, intent) - if (resultCode == Activity.RESULT_OK && requestCode == MEDIA_PICK_RESULT && intent != null) { - if (intent.data != null) { - // Single media, upload it and done. - pickMedia(intent.data!!) - } else if (intent.clipData != null) { - val clipData = intent.clipData!! - val count = clipData.itemCount - if (mediaCount + count > maxUploadMediaNumber) { - // check if exist media + upcoming media > 4, then prob error message. - Toast.makeText(this, resources.getQuantityString(R.plurals.error_upload_max_media_reached, maxUploadMediaNumber, maxUploadMediaNumber), Toast.LENGTH_SHORT).show() - } else { - // if not grater then 4, upload all multiple media. - for (i in 0 until count) { - val imageUri = clipData.getItemAt(i).getUri() - pickMedia(imageUri) - } - } - } - } else if (resultCode == Activity.RESULT_OK && requestCode == MEDIA_TAKE_PHOTO_RESULT) { - pickMedia(photoUploadUri!!) - } - } - private fun pickMedia(uri: Uri, contentInfoCompat: InputContentInfoCompat? = null) { withLifecycleContext { viewModel.pickMedia(uri).observe { exceptionOrItem -> @@ -962,7 +947,6 @@ class ComposeActivity : BaseActivity(), } displayTransientError(errorId) } - } } } @@ -994,9 +978,10 @@ class ComposeActivity : BaseActivity(), override fun onBackPressed() { // Acting like a teen: deliberately ignoring parent. if (composeOptionsBehavior.state == BottomSheetBehavior.STATE_EXPANDED || - addMediaBehavior.state == BottomSheetBehavior.STATE_EXPANDED || - emojiBehavior.state == BottomSheetBehavior.STATE_EXPANDED || - scheduleBehavior.state == BottomSheetBehavior.STATE_EXPANDED) { + addMediaBehavior.state == BottomSheetBehavior.STATE_EXPANDED || + emojiBehavior.state == BottomSheetBehavior.STATE_EXPANDED || + scheduleBehavior.state == BottomSheetBehavior.STATE_EXPANDED + ) { composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN @@ -1031,12 +1016,12 @@ class ComposeActivity : BaseActivity(), val contentWarning = binding.composeContentWarningField.text.toString() if (viewModel.didChange(contentText, contentWarning)) { AlertDialog.Builder(this) - .setMessage(R.string.compose_save_draft) - .setPositiveButton(R.string.action_save) { _, _ -> - saveDraftAndFinish(contentText, contentWarning) - } - .setNegativeButton(R.string.action_delete) { _, _ -> deleteDraftAndFinish() } - .show() + .setMessage(R.string.compose_save_draft) + .setPositiveButton(R.string.action_save) { _, _ -> + saveDraftAndFinish(contentText, contentWarning) + } + .setNegativeButton(R.string.action_delete) { _, _ -> deleteDraftAndFinish() } + .show() } else { finishWithoutSlideOutAnimation() } @@ -1068,13 +1053,13 @@ class ComposeActivity : BaseActivity(), } data class QueuedMedia( - val localId: Long, - val uri: Uri, - val type: Type, - val mediaSize: Long, - val uploadPercent: Int = 0, - val id: String? = null, - val description: String? = null + val localId: Long, + val uri: Uri, + val type: Type, + val mediaSize: Long, + val uploadPercent: Int = 0, + val id: String? = null, + val description: String? = null ) { enum class Type { IMAGE, VIDEO, AUDIO; @@ -1099,7 +1084,6 @@ class ComposeActivity : BaseActivity(), data class ComposeOptions( // Let's keep fields var until all consumers are Kotlin var scheduledTootId: String? = null, - var savedTootUid: Int? = null, var draftId: Int? = null, var tootText: String? = null, var mediaUrls: List? = null, @@ -1125,8 +1109,6 @@ class ComposeActivity : BaseActivity(), companion object { private const val TAG = "ComposeActivity" // logging tag - private const val MEDIA_PICK_RESULT = 1 - private const val MEDIA_TAKE_PHOTO_RESULT = 2 private const val PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE = 1 internal const val COMPOSE_OPTIONS_EXTRA = "COMPOSE_OPTIONS" diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt index ad5debb7b..8a1babf23 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt @@ -21,38 +21,48 @@ import androidx.core.net.toUri import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Observer +import androidx.lifecycle.viewModelScope import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia import com.keylesspalace.tusky.components.drafts.DraftHelper import com.keylesspalace.tusky.components.search.SearchType import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.InstanceEntity -import com.keylesspalace.tusky.entity.* +import com.keylesspalace.tusky.entity.Attachment +import com.keylesspalace.tusky.entity.Emoji +import com.keylesspalace.tusky.entity.NewPoll import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.service.ServiceClient import com.keylesspalace.tusky.service.TootToSend -import com.keylesspalace.tusky.util.* -import io.reactivex.Observable.just -import io.reactivex.disposables.Disposable -import io.reactivex.rxkotlin.Singles -import java.util.* +import com.keylesspalace.tusky.util.Either +import com.keylesspalace.tusky.util.RxAwareViewModel +import com.keylesspalace.tusky.util.VersionUtils +import com.keylesspalace.tusky.util.combineLiveData +import com.keylesspalace.tusky.util.filter +import com.keylesspalace.tusky.util.map +import com.keylesspalace.tusky.util.randomAlphanumericString +import com.keylesspalace.tusky.util.toLiveData +import com.keylesspalace.tusky.util.withoutFirstWhich +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.disposables.Disposable +import kotlinx.coroutines.launch +import java.util.Locale import javax.inject.Inject class ComposeViewModel @Inject constructor( - private val api: MastodonApi, - private val accountManager: AccountManager, - private val mediaUploader: MediaUploader, - private val serviceClient: ServiceClient, - private val draftHelper: DraftHelper, - private val saveTootHelper: SaveTootHelper, - private val db: AppDatabase + private val api: MastodonApi, + private val accountManager: AccountManager, + private val mediaUploader: MediaUploader, + private val serviceClient: ServiceClient, + private val draftHelper: DraftHelper, + private val db: AppDatabase ) : RxAwareViewModel() { private var replyingStatusAuthor: String? = null private var replyingStatusContent: String? = null internal var startingText: String? = null - private var savedTootUid: Int = 0 private var draftId: Int = 0 private var scheduledTootId: String? = null private var startingContentWarning: String = "" @@ -69,15 +79,15 @@ class ComposeViewModel @Inject constructor( val instanceParams: LiveData = instance.map { instance -> ComposeInstanceParams( - maxChars = instance?.maximumTootCharacters ?: DEFAULT_CHARACTER_LIMIT, - pollMaxOptions = instance?.maxPollOptions ?: DEFAULT_MAX_OPTION_COUNT, - pollMaxLength = instance?.maxPollOptionLength ?: DEFAULT_MAX_OPTION_LENGTH, - supportsScheduled = instance?.version?.let { VersionUtils(it).supportsScheduledToots() } ?: false + maxChars = instance?.maximumTootCharacters ?: DEFAULT_CHARACTER_LIMIT, + pollMaxOptions = instance?.maxPollOptions ?: DEFAULT_MAX_OPTION_COUNT, + pollMaxLength = instance?.maxPollOptionLength ?: DEFAULT_MAX_OPTION_LENGTH, + supportsScheduled = instance?.version?.let { VersionUtils(it).supportsScheduledToots() } ?: false ) } val emoji: MutableLiveData?> = MutableLiveData() val markMediaAsSensitive = - mutableLiveData(accountManager.activeAccount?.defaultMediaSensitivity ?: false) + mutableLiveData(accountManager.activeAccount?.defaultMediaSensitivity ?: false) val statusVisibility = mutableLiveData(Status.Visibility.UNKNOWN) val showContentWarning = mutableLiveData(false) @@ -94,44 +104,40 @@ class ComposeViewModel @Inject constructor( private val isEditingScheduledToot get() = !scheduledTootId.isNullOrEmpty() - fun loadInstanceDataFromNetwork() { - - Singles.zip(api.getCustomEmojis(), api.getInstance()) { emojis, instance -> - InstanceEntity( - instance = domain, - emojiList = emojis, - maximumTootCharacters = instance.maxTootChars, - maxPollOptions = instance.pollLimits?.maxOptions, - maxPollOptionLength = instance.pollLimits?.maxOptionChars, - version = instance.version - ) - } - .doOnSuccess { - db.instanceDao().insertOrReplace(it) + fun loadInstanceDataFromNetwork(loadActually: Boolean) { + when (loadActually) { + true -> Single.zip( + api.getCustomEmojis(), api.getInstance(), + { emojis, instance -> + InstanceEntity( + instance = domain, + emojiList = emojis, + maximumTootCharacters = instance.maxTootChars, + maxPollOptions = instance.pollLimits?.maxOptions, + maxPollOptionLength = instance.pollLimits?.maxOptionChars, + version = instance.version + ) } - .onErrorResumeNext( - db.instanceDao().loadMetadataForInstance(accountManager.activeAccount?.domain!!) - ) - .subscribe ({ instanceEntity -> + ) + false -> Single.error(Exception("skipped network access")) + } + .doOnSuccess { + db.instanceDao().insertOrReplace(it) + } + .onErrorResumeNext { + db.instanceDao().loadMetadataForInstance(accountManager.activeAccount?.domain!!) + } + .subscribe( + { instanceEntity -> emoji.postValue(instanceEntity.emojiList) instance.postValue(instanceEntity) - }, { throwable -> + }, + { throwable -> // this can happen on network error when no cached data is available Log.w(TAG, "error loading instance data", throwable) - }) - .autoDispose() - } - - fun loadInstanceDataFromCache() { - db.instanceDao().loadMetadataForInstance(accountManager.activeAccount?.domain!!) - .subscribe ({ instanceEntity -> - emoji.postValue(instanceEntity.emojiList) - instance.postValue(instanceEntity) - }, { throwable -> - // this can happen on network error when no cached data is available - Log.w(TAG, "error loading instance data", throwable) - }) - .autoDispose() + } + ) + .autoDispose() } fun pickMedia(uri: Uri, description: String? = null): LiveData> { @@ -139,44 +145,49 @@ class ComposeViewModel @Inject constructor( // the Activity goes away temporarily (like on screen rotation). val liveData = MutableLiveData>() mediaUploader.prepareMedia(uri) - .map { (type, uri, size) -> - val mediaItems = media.value!! - if (type != QueuedMedia.Type.IMAGE - && mediaItems.isNotEmpty() - && mediaItems[0].type == QueuedMedia.Type.IMAGE) { - throw VideoOrImageException() - } else { - addMediaToQueue(type, uri, size, description) - } + .map { (type, uri, size) -> + val mediaItems = media.value!! + if (type != QueuedMedia.Type.IMAGE && + mediaItems.isNotEmpty() && + mediaItems[0].type == QueuedMedia.Type.IMAGE + ) { + throw VideoOrImageException() + } else { + addMediaToQueue(type, uri, size, description) } - .subscribe({ queuedMedia -> + } + .subscribe( + { queuedMedia -> liveData.postValue(Either.Right(queuedMedia)) - }, { error -> + }, + { error -> liveData.postValue(Either.Left(error)) - }) - .autoDispose() + } + ) + .autoDispose() return liveData } private fun addMediaToQueue( - type: QueuedMedia.Type, - uri: Uri, - mediaSize: Long, - description: String? = null + type: QueuedMedia.Type, + uri: Uri, + mediaSize: Long, + description: String? = null ): QueuedMedia { val mediaItem = QueuedMedia( - localId = System.currentTimeMillis(), - uri = uri, - type = type, - mediaSize = mediaSize, - description = description + localId = System.currentTimeMillis(), + uri = uri, + type = type, + mediaSize = mediaSize, + description = description ) media.value = media.value!! + mediaItem mediaToDisposable[mediaItem.localId] = mediaUploader - .uploadMedia(mediaItem) - .subscribe({ event -> + .uploadMedia(mediaItem) + .subscribe( + { event -> val item = media.value?.find { it.localId == mediaItem.localId } - ?: return@subscribe + ?: return@subscribe val newMediaItem = when (event) { is UploadEvent.ProgressEvent -> item.copy(uploadPercent = event.percentage) @@ -186,16 +197,20 @@ class ComposeViewModel @Inject constructor( synchronized(media) { val mediaValue = media.value!! val index = mediaValue.indexOfFirst { it.localId == newMediaItem.localId } - media.postValue(if (index == -1) { - mediaValue + newMediaItem - } else { - mediaValue.toMutableList().also { it[index] = newMediaItem } - }) + media.postValue( + if (index == -1) { + mediaValue + newMediaItem + } else { + mediaValue.toMutableList().also { it[index] = newMediaItem } + } + ) } - }, { error -> + }, + { error -> media.postValue(media.value?.filter { it.localId != mediaItem.localId } ?: emptyList()) uploadError.postValue(error) - }) + } + ) return mediaItem } @@ -215,12 +230,14 @@ class ComposeViewModel @Inject constructor( fun didChange(content: String?, contentWarning: String?): Boolean { - val textChanged = !(content.isNullOrEmpty() - || startingText?.startsWith(content.toString()) ?: false) + val textChanged = !( + content.isNullOrEmpty() || + startingText?.startsWith(content.toString()) ?: false + ) - val contentWarningChanged = showContentWarning.value!! - && !contentWarning.isNullOrEmpty() - && !startingContentWarning.startsWith(contentWarning.toString()) + val contentWarningChanged = showContentWarning.value!! && + !contentWarning.isNullOrEmpty() && + !startingContentWarning.startsWith(contentWarning.toString()) val mediaChanged = !media.value.isNullOrEmpty() val pollChanged = poll.value != null @@ -233,25 +250,23 @@ class ComposeViewModel @Inject constructor( } fun deleteDraft() { - if (savedTootUid != 0) { - saveTootHelper.deleteDraft(savedTootUid) - } - if (draftId != 0) { - draftHelper.deleteDraftAndAttachments(draftId) - .subscribe() + viewModelScope.launch { + if (draftId != 0) { + draftHelper.deleteDraftAndAttachments(draftId) + } } } fun saveDraft(content: String, contentWarning: String) { + viewModelScope.launch { + val mediaUris: MutableList = mutableListOf() + val mediaDescriptions: MutableList = mutableListOf() + media.value?.forEach { item -> + mediaUris.add(item.uri.toString()) + mediaDescriptions.add(item.description) + } - val mediaUris: MutableList = mutableListOf() - val mediaDescriptions: MutableList = mutableListOf() - media.value?.forEach { item -> - mediaUris.add(item.uri.toString()) - mediaDescriptions.add(item.description) - } - - draftHelper.saveDraft( + draftHelper.saveDraft( draftId = draftId, accountId = accountManager.activeAccount?.id!!, inReplyToId = inReplyToId, @@ -263,7 +278,8 @@ class ComposeViewModel @Inject constructor( mediaDescriptions = mediaDescriptions, poll = poll.value, failedToSend = false - ).subscribe() + ) + } } /** @@ -272,27 +288,27 @@ class ComposeViewModel @Inject constructor( * @return LiveData which will signal once the screen can be closed or null if there are errors */ fun sendStatus( - content: String, - spoilerText: String + content: String, + spoilerText: String ): LiveData { val deletionObservable = if (isEditingScheduledToot) { api.deleteScheduledStatus(scheduledTootId.toString()).toObservable().map { } } else { - just(Unit) + Observable.just(Unit) }.toLiveData() val sendObservable = media - .filter { items -> items.all { it.uploadPercent == -1 } } - .map { - val mediaIds = ArrayList() - val mediaUris = ArrayList() - val mediaDescriptions = ArrayList() - for (item in media.value!!) { - mediaIds.add(item.id!!) - mediaUris.add(item.uri) - mediaDescriptions.add(item.description ?: "") - } + .filter { items -> items.all { it.uploadPercent == -1 } } + .map { + val mediaIds = ArrayList() + val mediaUris = ArrayList() + val mediaDescriptions = ArrayList() + for (item in media.value!!) { + mediaIds.add(item.id!!) + mediaUris.add(item.uri) + mediaDescriptions.add(item.description ?: "") + } val tootToSend = TootToSend( text = content, @@ -309,14 +325,13 @@ class ComposeViewModel @Inject constructor( replyingStatusAuthorUsername = null, quoteId = quoteId, accountId = accountManager.activeAccount!!.id, - savedTootUid = savedTootUid, draftId = draftId, idempotencyKey = randomAlphanumericString(16), retries = 0 ) - serviceClient.sendToot(tootToSend) - } + serviceClient.sendToot(tootToSend) + } return combineLiveData(deletionObservable, sendObservable) { _, _ -> } } @@ -336,12 +351,15 @@ class ComposeViewModel @Inject constructor( media.removeObserver(this) } else if (updatedItem.id != null) { api.updateMedia(updatedItem.id, description) - .subscribe({ + .subscribe( + { completedCaptioningLiveData.postValue(true) - }, { + }, + { completedCaptioningLiveData.postValue(false) - }) - .autoDispose() + } + ) + .autoDispose() media.removeObserver(this) } } @@ -354,8 +372,8 @@ class ComposeViewModel @Inject constructor( '@' -> { return try { api.searchAccounts(query = token.substring(1), limit = 10) - .blockingGet() - .map { ComposeAutoCompleteAdapter.AccountResult(it) } + .blockingGet() + .map { ComposeAutoCompleteAdapter.AccountResult(it) } } catch (e: Throwable) { Log.e(TAG, String.format("Autocomplete search for %s failed.", token), e) emptyList() @@ -364,9 +382,9 @@ class ComposeViewModel @Inject constructor( '#' -> { return try { api.searchObservable(query = token, type = SearchType.Hashtag.apiParameter, limit = 10) - .blockingGet() - .hashtags - .map { ComposeAutoCompleteAdapter.HashtagResult(it) } + .blockingGet() + .hashtags + .map { ComposeAutoCompleteAdapter.HashtagResult(it) } } catch (e: Throwable) { Log.e(TAG, String.format("Autocomplete search for %s failed.", token), e) emptyList() @@ -375,11 +393,11 @@ class ComposeViewModel @Inject constructor( ':' -> { val emojiList = emoji.value ?: return emptyList() - val incomplete = token.substring(1).toLowerCase(Locale.ROOT) + val incomplete = token.substring(1).lowercase(Locale.ROOT) val results = ArrayList() val resultsInside = ArrayList() for (emoji in emojiList) { - val shortcode = emoji.shortcode.toLowerCase(Locale.ROOT) + val shortcode = emoji.shortcode.lowercase(Locale.ROOT) if (shortcode.startsWith(incomplete)) { results.add(ComposeAutoCompleteAdapter.EmojiResult(emoji)) } else if (shortcode.indexOf(incomplete, 1) != -1) { @@ -409,7 +427,8 @@ class ComposeViewModel @Inject constructor( val replyVisibility = composeOptions?.replyVisibility ?: Status.Visibility.UNKNOWN startingVisibility = Status.Visibility.byNum( - preferredVisibility.num.coerceAtLeast(replyVisibility.num)) + preferredVisibility.num.coerceAtLeast(replyVisibility.num) + ) inReplyToId = composeOptions?.inReplyToId @@ -428,20 +447,8 @@ class ComposeViewModel @Inject constructor( } // recreate media list - val loadedDraftMediaUris = composeOptions?.mediaUrls - val loadedDraftMediaDescriptions: List? = composeOptions?.mediaDescriptions val draftAttachments = composeOptions?.draftAttachments - if (loadedDraftMediaUris != null && loadedDraftMediaDescriptions != null) { - // when coming from SavedTootActivity - loadedDraftMediaUris.zip(loadedDraftMediaDescriptions) - .forEach { (uri, description) -> - pickMedia(uri.toUri()).observeForever { errorOrItem -> - if (errorOrItem.isRight() && description != null) { - updateDescription(errorOrItem.asRight().localId, description) - } - } - } - } else if (draftAttachments != null) { + if (draftAttachments != null) { // when coming from DraftActivity draftAttachments.forEach { attachment -> pickMedia(attachment.uri, attachment.description) } } else composeOptions?.mediaAttachments?.forEach { a -> @@ -454,7 +461,6 @@ class ComposeViewModel @Inject constructor( addUploadedMedia(a.id, mediaType, a.url.toUri(), a.description) } - savedTootUid = composeOptions?.savedTootUid ?: 0 draftId = composeOptions?.draftId ?: 0 scheduledTootId = composeOptions?.scheduledTootId startingText = composeOptions?.tootText @@ -508,7 +514,6 @@ class ComposeViewModel @Inject constructor( private companion object { const val TAG = "ComposeViewModel" } - } fun mutableLiveData(default: T) = MutableLiveData().apply { value = default } @@ -522,10 +527,10 @@ val CAN_USE_QUOTE_ID = arrayOf("odakyu.app", "itabashi.0j0.jp", "biwakodon.com", "pomdon.work", "obapom.work") data class ComposeInstanceParams( - val maxChars: Int, - val pollMaxOptions: Int, - val pollMaxLength: Int, - val supportsScheduled: Boolean + val maxChars: Int, + val pollMaxOptions: Int, + val pollMaxLength: Int, + val supportsScheduled: Boolean ) /** diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaPreviewAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaPreviewAdapter.kt index a08aebc08..0b1fa8c41 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaPreviewAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaPreviewAdapter.kt @@ -30,9 +30,9 @@ import com.keylesspalace.tusky.R import com.keylesspalace.tusky.components.compose.view.ProgressImageView class MediaPreviewAdapter( - context: Context, - private val onAddCaption: (ComposeActivity.QueuedMedia) -> Unit, - private val onRemove: (ComposeActivity.QueuedMedia) -> Unit + context: Context, + private val onAddCaption: (ComposeActivity.QueuedMedia) -> Unit, + private val onRemove: (ComposeActivity.QueuedMedia) -> Unit ) : RecyclerView.Adapter() { fun submitList(list: List) { @@ -57,7 +57,7 @@ class MediaPreviewAdapter( } private val thumbnailViewSize = - context.resources.getDimensionPixelSize(R.dimen.compose_media_preview_size) + context.resources.getDimensionPixelSize(R.dimen.compose_media_preview_size) override fun getItemCount(): Int = differ.currentList.size @@ -74,31 +74,34 @@ class MediaPreviewAdapter( holder.progressImageView.setImageResource(R.drawable.ic_music_box_preview_24dp) } else { Glide.with(holder.itemView.context) - .load(item.uri) - .diskCacheStrategy(DiskCacheStrategy.NONE) - .dontAnimate() - .into(holder.progressImageView) + .load(item.uri) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .dontAnimate() + .into(holder.progressImageView) } } - private val differ = AsyncListDiffer(this, object : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: ComposeActivity.QueuedMedia, newItem: ComposeActivity.QueuedMedia): Boolean { - return oldItem.localId == newItem.localId - } + private val differ = AsyncListDiffer( + this, + object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: ComposeActivity.QueuedMedia, newItem: ComposeActivity.QueuedMedia): Boolean { + return oldItem.localId == newItem.localId + } - override fun areContentsTheSame(oldItem: ComposeActivity.QueuedMedia, newItem: ComposeActivity.QueuedMedia): Boolean { - return oldItem == newItem + override fun areContentsTheSame(oldItem: ComposeActivity.QueuedMedia, newItem: ComposeActivity.QueuedMedia): Boolean { + return oldItem == newItem + } } - }) + ) - inner class PreviewViewHolder(val progressImageView: ProgressImageView) - : RecyclerView.ViewHolder(progressImageView) { + inner class PreviewViewHolder(val progressImageView: ProgressImageView) : + RecyclerView.ViewHolder(progressImageView) { init { val layoutParams = ConstraintLayout.LayoutParams(thumbnailViewSize, thumbnailViewSize) val margin = itemView.context.resources - .getDimensionPixelSize(R.dimen.compose_media_preview_margin) + .getDimensionPixelSize(R.dimen.compose_media_preview_margin) val marginBottom = itemView.context.resources - .getDimensionPixelSize(R.dimen.compose_media_preview_margin_bottom) + .getDimensionPixelSize(R.dimen.compose_media_preview_margin_bottom) layoutParams.setMargins(margin, 0, margin, marginBottom) progressImageView.layoutParams = layoutParams progressImageView.scaleType = ImageView.ScaleType.CENTER_CROP @@ -107,4 +110,4 @@ class MediaPreviewAdapter( } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt index 965f3d30a..0e2bcdd85 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt @@ -28,16 +28,19 @@ import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.ProgressRequestBody -import com.keylesspalace.tusky.util.* -import io.reactivex.Observable -import io.reactivex.Single -import io.reactivex.schedulers.Schedulers +import com.keylesspalace.tusky.util.MEDIA_SIZE_UNKNOWN +import com.keylesspalace.tusky.util.getImageSquarePixels +import com.keylesspalace.tusky.util.getMediaSize +import com.keylesspalace.tusky.util.randomAlphanumericString +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MultipartBody import java.io.File import java.io.FileOutputStream import java.io.IOException -import java.util.* +import java.util.Date sealed class UploadEvent { data class ProgressEvent(val percentage: Int) : UploadEvent() @@ -50,9 +53,9 @@ fun createNewImageFile(context: Context): File { val imageFileName = "Tusky_${randomId}_" val storageDir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES) return File.createTempFile( - imageFileName, /* prefix */ - ".jpg", /* suffix */ - storageDir /* directory */ + imageFileName, /* prefix */ + ".jpg", /* suffix */ + storageDir /* directory */ ) } @@ -69,18 +72,18 @@ class MediaTypeException : Exception() class CouldNotOpenFileException : Exception() class MediaUploaderImpl( - private val context: Context, - private val mastodonApi: MastodonApi + private val context: Context, + private val mastodonApi: MastodonApi ) : MediaUploader { override fun uploadMedia(media: QueuedMedia): Observable { return Observable - .fromCallable { - if (shouldResizeMedia(media)) { - downsize(media) - } else media - } - .switchMap { upload(it) } - .subscribeOn(Schedulers.io()) + .fromCallable { + if (shouldResizeMedia(media)) { + downsize(media) + } else media + } + .switchMap { upload(it) } + .subscribeOn(Schedulers.io()) } override fun prepareMedia(inUri: Uri): Single { @@ -101,12 +104,13 @@ class MediaUploaderImpl( val file = File.createTempFile("randomTemp1", suffix, context.cacheDir) FileOutputStream(file.absoluteFile).use { out -> input.copyTo(out) - uri = FileProvider.getUriForFile(context, - BuildConfig.APPLICATION_ID + ".fileprovider", - file) + uri = FileProvider.getUriForFile( + context, + BuildConfig.APPLICATION_ID + ".fileprovider", + file + ) mediaSize = getMediaSize(contentResolver, uri) } - } } catch (e: IOException) { Log.w(TAG, e) @@ -151,20 +155,22 @@ class MediaUploaderImpl( var mimeType = contentResolver.getType(media.uri) val map = MimeTypeMap.getSingleton() val fileExtension = map.getExtensionFromMimeType(mimeType) - val filename = String.format("%s_%s_%s.%s", - context.getString(R.string.app_name), - Date().time.toString(), - randomAlphanumericString(10), - fileExtension) + val filename = "%s_%s_%s.%s".format( + context.getString(R.string.app_name), + Date().time.toString(), + randomAlphanumericString(10), + fileExtension + ) val stream = contentResolver.openInputStream(media.uri) if (mimeType == null) mimeType = "multipart/form-data" - var lastProgress = -1 - val fileBody = ProgressRequestBody(stream, media.mediaSize, - mimeType.toMediaTypeOrNull()) { percentage -> + val fileBody = ProgressRequestBody( + stream, media.mediaSize, + mimeType.toMediaTypeOrNull() + ) { percentage -> if (percentage != lastProgress) { emitter.onNext(UploadEvent.ProgressEvent(percentage)) } @@ -180,7 +186,8 @@ class MediaUploaderImpl( } val uploadDisposable = mastodonApi.uploadMedia(body, description) - .subscribe({ attachment -> + .subscribe( + { attachment -> if (media.uri.scheme == "file") { media.uri.path?.let { File(it).delete() @@ -189,9 +196,11 @@ class MediaUploaderImpl( emitter.onNext(UploadEvent.FinishedEvent(attachment)) emitter.onComplete() - }, { e -> + }, + { e -> emitter.onError(e) - }) + } + ) // Cancel the request when our observable is cancelled emitter.setDisposable(uploadDisposable) @@ -200,15 +209,16 @@ class MediaUploaderImpl( private fun downsize(media: QueuedMedia): QueuedMedia { val file = createNewImageFile(context) - DownsizeImageTask.resize(arrayOf(media.uri), - STATUS_IMAGE_SIZE_LIMIT, context.contentResolver, file) + DownsizeImageTask.resize( + arrayOf(media.uri), + STATUS_IMAGE_SIZE_LIMIT, context.contentResolver, file + ) return media.copy(uri = file.toUri(), mediaSize = file.length()) } private fun shouldResizeMedia(media: QueuedMedia): Boolean { - return media.type == QueuedMedia.Type.IMAGE - && (media.mediaSize > STATUS_IMAGE_SIZE_LIMIT - || getImageSquarePixels(context.contentResolver, media.uri) > STATUS_IMAGE_PIXEL_SIZE_LIMIT) + return media.type == QueuedMedia.Type.IMAGE && + (media.mediaSize > STATUS_IMAGE_SIZE_LIMIT || getImageSquarePixels(context.contentResolver, media.uri) > STATUS_IMAGE_PIXEL_SIZE_LIMIT) } private companion object { @@ -217,6 +227,5 @@ class MediaUploaderImpl( private const val STATUS_AUDIO_SIZE_LIMIT = 41943040 // 40MiB private const val STATUS_IMAGE_SIZE_LIMIT = 8388608 // 8MiB private const val STATUS_IMAGE_PIXEL_SIZE_LIMIT = 16777216 // 4096^2 Pixels - } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/AddPollDialog.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/AddPollDialog.kt index 6ace77bc3..7a4f73898 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/AddPollDialog.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/AddPollDialog.kt @@ -26,33 +26,33 @@ import com.keylesspalace.tusky.databinding.DialogAddPollBinding import com.keylesspalace.tusky.entity.NewPoll fun showAddPollDialog( - context: Context, - poll: NewPoll?, - maxOptionCount: Int, - maxOptionLength: Int, - onUpdatePoll: (NewPoll) -> Unit + context: Context, + poll: NewPoll?, + maxOptionCount: Int, + maxOptionLength: Int, + onUpdatePoll: (NewPoll) -> Unit ) { val binding = DialogAddPollBinding.inflate(LayoutInflater.from(context)) val dialog = AlertDialog.Builder(context) - .setIcon(R.drawable.ic_poll_24dp) - .setTitle(R.string.create_poll_title) - .setView(binding.root) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(android.R.string.ok, null) - .create() + .setIcon(R.drawable.ic_poll_24dp) + .setTitle(R.string.create_poll_title) + .setView(binding.root) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok, null) + .create() val adapter = AddPollOptionsAdapter( - options = poll?.options?.toMutableList() ?: mutableListOf("", ""), - maxOptionLength = maxOptionLength, - onOptionRemoved = { valid -> - binding.addChoiceButton.isEnabled = true - dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = valid - }, - onOptionChanged = { valid -> - dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = valid - } + options = poll?.options?.toMutableList() ?: mutableListOf("", ""), + maxOptionLength = maxOptionLength, + onOptionRemoved = { valid -> + binding.addChoiceButton.isEnabled = true + dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = valid + }, + onOptionChanged = { valid -> + dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = valid + } ) binding.pollChoices.adapter = adapter @@ -80,13 +80,15 @@ fun showAddPollDialog( val selectedPollDurationId = binding.pollDurationSpinner.selectedItemPosition val pollDuration = context.resources - .getIntArray(R.array.poll_duration_values)[selectedPollDurationId] + .getIntArray(R.array.poll_duration_values)[selectedPollDurationId] - onUpdatePoll(NewPoll( + onUpdatePoll( + NewPoll( options = adapter.pollOptions, expiresIn = pollDuration, multiple = binding.multipleChoicesCheckBox.isChecked - )) + ) + ) dialog.dismiss() } @@ -96,4 +98,4 @@ fun showAddPollDialog( // make the dialog focusable so the keyboard does not stay behind it dialog.window?.clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/AddPollOptionsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/AddPollOptionsAdapter.kt index 6a0b6a871..3640ffa97 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/AddPollOptionsAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/AddPollOptionsAdapter.kt @@ -27,11 +27,11 @@ import com.keylesspalace.tusky.util.onTextChanged import com.keylesspalace.tusky.util.visible class AddPollOptionsAdapter( - private var options: MutableList, - private val maxOptionLength: Int, - private val onOptionRemoved: (Boolean) -> Unit, - private val onOptionChanged: (Boolean) -> Unit -): RecyclerView.Adapter>() { + private var options: MutableList, + private val maxOptionLength: Int, + private val onOptionRemoved: (Boolean) -> Unit, + private val onOptionChanged: (Boolean) -> Unit +) : RecyclerView.Adapter>() { val pollOptions: List get() = options.toList() @@ -47,8 +47,8 @@ class AddPollOptionsAdapter( binding.optionEditText.filters = arrayOf(InputFilter.LengthFilter(maxOptionLength)) binding.optionEditText.onTextChanged { s, _, _, _ -> - val pos = holder.adapterPosition - if(pos != RecyclerView.NO_POSITION) { + val pos = holder.bindingAdapterPosition + if (pos != RecyclerView.NO_POSITION) { options[pos] = s.toString() onOptionChanged(validateInput()) } @@ -68,8 +68,8 @@ class AddPollOptionsAdapter( holder.binding.deleteButton.setOnClickListener { holder.binding.optionEditText.clearFocus() - options.removeAt(holder.adapterPosition) - notifyItemRemoved(holder.adapterPosition) + options.removeAt(holder.bindingAdapterPosition) + notifyItemRemoved(holder.bindingAdapterPosition) onOptionRemoved(validateInput()) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/CaptionDialog.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/CaptionDialog.kt index 50d5c23b6..0c15eff0d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/CaptionDialog.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/CaptionDialog.kt @@ -21,7 +21,6 @@ import android.graphics.drawable.Drawable import android.net.Uri import android.text.InputFilter import android.text.InputType -import android.util.DisplayMetrics import android.view.WindowManager import android.widget.EditText import android.widget.LinearLayout @@ -31,6 +30,7 @@ import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData import at.connyduck.sparkbutton.helpers.Utils import com.bumptech.glide.Glide +import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy import com.bumptech.glide.request.target.CustomTarget import com.bumptech.glide.request.transition.Transition import com.github.chrisbanes.photoview.PhotoView @@ -40,9 +40,10 @@ import com.keylesspalace.tusky.util.withLifecycleContext // https://github.com/tootsuite/mastodon/blob/c6904c0d3766a2ea8a81ab025c127169ecb51373/app/models/media_attachment.rb#L32 private const val MEDIA_DESCRIPTION_CHARACTER_LIMIT = 1500 -fun T.makeCaptionDialog(existingDescription: String?, - previewUri: Uri, - onUpdateDescription: (String) -> LiveData +fun T.makeCaptionDialog( + existingDescription: String?, + previewUri: Uri, + onUpdateDescription: (String) -> LiveData ) where T : Activity, T : LifecycleOwner { val dialogLayout = LinearLayout(this) val padding = Utils.dpToPx(this, 8) @@ -53,9 +54,6 @@ fun T.makeCaptionDialog(existingDescription: String?, maximumScale = 6f } - val displayMetrics = DisplayMetrics() - windowManager.defaultDisplay.getMetrics(displayMetrics) - val margin = Utils.dpToPx(this, 4) dialogLayout.addView(imageView) (imageView.layoutParams as LinearLayout.LayoutParams).weight = 1f @@ -63,14 +61,18 @@ fun T.makeCaptionDialog(existingDescription: String?, (imageView.layoutParams as LinearLayout.LayoutParams).setMargins(0, margin, 0, 0) val input = EditText(this) - input.hint = resources.getQuantityString(R.plurals.hint_describe_for_visually_impaired, - MEDIA_DESCRIPTION_CHARACTER_LIMIT, MEDIA_DESCRIPTION_CHARACTER_LIMIT) + input.hint = resources.getQuantityString( + R.plurals.hint_describe_for_visually_impaired, + MEDIA_DESCRIPTION_CHARACTER_LIMIT, MEDIA_DESCRIPTION_CHARACTER_LIMIT + ) dialogLayout.addView(input) (input.layoutParams as LinearLayout.LayoutParams).setMargins(margin, margin, margin, margin) input.setLines(2) - input.inputType = (InputType.TYPE_CLASS_TEXT - or InputType.TYPE_TEXT_FLAG_MULTI_LINE - or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES) + input.inputType = ( + InputType.TYPE_CLASS_TEXT + or InputType.TYPE_TEXT_FLAG_MULTI_LINE + or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES + ) input.setText(existingDescription) input.filters = arrayOf(InputFilter.LengthFilter(MEDIA_DESCRIPTION_CHARACTER_LIMIT)) @@ -78,39 +80,40 @@ fun T.makeCaptionDialog(existingDescription: String?, onUpdateDescription(input.text.toString()) withLifecycleContext { onUpdateDescription(input.text.toString()) - .observe { success -> if (!success) showFailedCaptionMessage() } - + .observe { success -> if (!success) showFailedCaptionMessage() } } dialog.dismiss() } val dialog = AlertDialog.Builder(this) - .setView(dialogLayout) - .setPositiveButton(android.R.string.ok, okListener) - .setNegativeButton(android.R.string.cancel, null) - .create() + .setView(dialogLayout) + .setPositiveButton(android.R.string.ok, okListener) + .setNegativeButton(android.R.string.cancel, null) + .create() val window = dialog.window window?.setSoftInputMode( - WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) + WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE + ) dialog.show() - // Load the image and manually set it into the ImageView because it doesn't have a fixed - // size. Maybe we should limit the size of CustomTarget + // Load the image and manually set it into the ImageView because it doesn't have a fixed size. Glide.with(this) - .load(previewUri) - .into(object : CustomTarget() { - override fun onLoadCleared(placeholder: Drawable?) {} + .load(previewUri) + .downsample(DownsampleStrategy.CENTER_INSIDE) + .into(object : CustomTarget(4096, 4096) { + override fun onLoadCleared(placeholder: Drawable?) { + imageView.setImageDrawable(placeholder) + } - override fun onResourceReady(resource: Drawable, transition: Transition?) { - imageView.setImageDrawable(resource) - } - }) + override fun onResourceReady(resource: Drawable, transition: Transition?) { + imageView.setImageDrawable(resource) + } + }) } - private fun Activity.showFailedCaptionMessage() { Toast.makeText(this, R.string.error_failed_set_caption, Toast.LENGTH_SHORT).show() } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ComposeOptionsView.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ComposeOptionsView.kt index 522669ff2..8be152b90 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ComposeOptionsView.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ComposeOptionsView.kt @@ -71,12 +71,10 @@ class ComposeOptionsView @JvmOverloads constructor(context: Context, attrs: Attr R.id.directRadioButton else -> R.id.directRadioButton - } check(selectedButton) } - } interface ComposeOptionsListener { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/EditTextTyped.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/EditTextTyped.kt index 0a5e1c33a..a8403c954 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/EditTextTyped.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/EditTextTyped.kt @@ -16,25 +16,27 @@ package com.keylesspalace.tusky.components.compose.view import android.content.Context -import androidx.emoji.widget.EmojiEditTextHelper -import androidx.core.view.inputmethod.EditorInfoCompat -import androidx.core.view.inputmethod.InputConnectionCompat -import androidx.appcompat.widget.AppCompatMultiAutoCompleteTextView import android.text.InputType import android.text.method.KeyListener import android.util.AttributeSet import android.view.inputmethod.EditorInfo import android.view.inputmethod.InputConnection +import androidx.appcompat.widget.AppCompatMultiAutoCompleteTextView +import androidx.core.view.inputmethod.EditorInfoCompat +import androidx.core.view.inputmethod.InputConnectionCompat +import androidx.emoji.widget.EmojiEditTextHelper -class EditTextTyped @JvmOverloads constructor(context: Context, - attributeSet: AttributeSet? = null) - : AppCompatMultiAutoCompleteTextView(context, attributeSet) { +class EditTextTyped @JvmOverloads constructor( + context: Context, + attributeSet: AttributeSet? = null +) : + AppCompatMultiAutoCompleteTextView(context, attributeSet) { private var onCommitContentListener: InputConnectionCompat.OnCommitContentListener? = null private val emojiEditTextHelper: EmojiEditTextHelper = EmojiEditTextHelper(this) init { - //fix a bug with autocomplete and some keyboards + // fix a bug with autocomplete and some keyboards val newInputType = inputType and (inputType xor InputType.TYPE_TEXT_FLAG_AUTO_COMPLETE) inputType = newInputType super.setKeyListener(getEmojiEditTextHelper().getKeyListener(keyListener)) @@ -52,8 +54,13 @@ class EditTextTyped @JvmOverloads constructor(context: Context, val connection = super.onCreateInputConnection(editorInfo) return if (onCommitContentListener != null) { EditorInfoCompat.setContentMimeTypes(editorInfo, arrayOf("image/*")) - getEmojiEditTextHelper().onCreateInputConnection(InputConnectionCompat.createWrapper(connection, editorInfo, - onCommitContentListener!!), editorInfo)!! + getEmojiEditTextHelper().onCreateInputConnection( + InputConnectionCompat.createWrapper( + connection, editorInfo, + onCommitContentListener!! + ), + editorInfo + )!! } else { connection } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/PollPreviewView.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/PollPreviewView.kt index 1126047d8..c55e8fce7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/PollPreviewView.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/PollPreviewView.kt @@ -25,10 +25,11 @@ import com.keylesspalace.tusky.databinding.ViewPollPreviewBinding import com.keylesspalace.tusky.entity.NewPoll class PollPreviewView @JvmOverloads constructor( - context: Context?, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0) - : LinearLayout(context, attrs, defStyleAttr) { + context: Context?, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : + LinearLayout(context, attrs, defStyleAttr) { private val adapter = PreviewPollOptionsAdapter() @@ -46,7 +47,7 @@ class PollPreviewView @JvmOverloads constructor( binding.pollPreviewOptions.adapter = adapter } - fun setPoll(poll: NewPoll){ + fun setPoll(poll: NewPoll) { adapter.update(poll.options, poll.multiple) val pollDurationId = resources.getIntArray(R.array.poll_duration_values).indexOfLast { @@ -59,4 +60,4 @@ class PollPreviewView @JvmOverloads constructor( super.setOnClickListener(l) adapter.setOnClickListener(l) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/TootButton.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/TootButton.kt index d3cd3c270..a3a6078cd 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/TootButton.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/TootButton.kt @@ -28,15 +28,15 @@ import com.mikepenz.iconics.utils.sizeDp class TootButton @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0 + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 ) : MaterialButton(context, attrs, defStyleAttr) { private val smallStyle: Boolean = context.resources.getBoolean(R.bool.show_small_toot_button) init { - if(smallStyle) { + if (smallStyle) { setIconResource(R.drawable.ic_send_24dp) } else { setText(R.string.action_send) @@ -47,7 +47,7 @@ class TootButton } fun setStatusVisibility(visibility: Status.Visibility) { - if(!smallStyle) { + if (!smallStyle) { icon = when (visibility) { Status.Visibility.PUBLIC -> { @@ -69,8 +69,5 @@ class TootButton } } } - } - } - diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationAdapter.kt index 376d3cd56..89c1ad0f1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationAdapter.kt @@ -17,114 +17,39 @@ package com.keylesspalace.tusky.components.conversation import android.view.LayoutInflater import android.view.ViewGroup -import androidx.paging.AsyncPagedListDiffer -import androidx.paging.PagedList -import androidx.recyclerview.widget.AsyncDifferConfig +import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListUpdateCallback -import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.adapter.NetworkStateViewHolder -import com.keylesspalace.tusky.databinding.ItemNetworkStateBinding import com.keylesspalace.tusky.interfaces.StatusActionListener -import com.keylesspalace.tusky.util.NetworkState import com.keylesspalace.tusky.util.StatusDisplayOptions class ConversationAdapter( - private val statusDisplayOptions: StatusDisplayOptions, - private val listener: StatusActionListener, - private val topLoadedCallback: () -> Unit, - private val retryCallback: () -> Unit -) : RecyclerView.Adapter() { + private val statusDisplayOptions: StatusDisplayOptions, + private val listener: StatusActionListener +) : PagingDataAdapter(CONVERSATION_COMPARATOR) { - private var networkState: NetworkState? = null - - private val differ: AsyncPagedListDiffer = AsyncPagedListDiffer(object : ListUpdateCallback { - override fun onInserted(position: Int, count: Int) { - notifyItemRangeInserted(position, count) - if (position == 0) { - topLoadedCallback() - } - } - - override fun onRemoved(position: Int, count: Int) { - notifyItemRangeRemoved(position, count) - } - - override fun onMoved(fromPosition: Int, toPosition: Int) { - notifyItemMoved(fromPosition, toPosition) - } - - override fun onChanged(position: Int, count: Int, payload: Any?) { - notifyItemRangeChanged(position, count, payload) - } - }, AsyncDifferConfig.Builder(CONVERSATION_COMPARATOR).build()) - - fun submitList(list: PagedList) { - differ.submitList(list) + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ConversationViewHolder { + val view = LayoutInflater.from(parent.context).inflate(R.layout.item_conversation, parent, false) + return ConversationViewHolder(view, statusDisplayOptions, listener) } - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return when (viewType) { - R.layout.item_network_state -> { - val binding = ItemNetworkStateBinding.inflate(LayoutInflater.from(parent.context), parent, false) - NetworkStateViewHolder(binding, retryCallback) - } - R.layout.item_conversation -> { - val view = LayoutInflater.from(parent.context).inflate(viewType, parent, false) - ConversationViewHolder(view, statusDisplayOptions, listener) - } - else -> throw IllegalArgumentException("unknown view type $viewType") - } + override fun onBindViewHolder(holder: ConversationViewHolder, position: Int) { + holder.setupWithConversation(getItem(position)) } - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (getItemViewType(position)) { - R.layout.item_network_state -> (holder as NetworkStateViewHolder).setUpWithNetworkState(networkState, differ.itemCount == 0) - R.layout.item_conversation -> (holder as ConversationViewHolder).setupWithConversation(differ.getItem(position)) - } - } - - private fun hasExtraRow() = networkState != null && networkState != NetworkState.LOADED - - override fun getItemViewType(position: Int): Int { - return if (hasExtraRow() && position == itemCount - 1) { - R.layout.item_network_state - } else { - R.layout.item_conversation - } - } - - override fun getItemCount(): Int { - return differ.itemCount + if (hasExtraRow()) 1 else 0 - } - - fun setNetworkState(newNetworkState: NetworkState?) { - val previousState = this.networkState - val hadExtraRow = hasExtraRow() - this.networkState = newNetworkState - val hasExtraRow = hasExtraRow() - if (hadExtraRow != hasExtraRow) { - if (hadExtraRow) { - notifyItemRemoved(differ.itemCount) - } else { - notifyItemInserted(differ.itemCount) - } - } else if (hasExtraRow && previousState != newNetworkState) { - notifyItemChanged(itemCount - 1) - } + fun item(position: Int): ConversationEntity? { + return getItem(position) } companion object { - val CONVERSATION_COMPARATOR = object : DiffUtil.ItemCallback() { - override fun areContentsTheSame(oldItem: ConversationEntity, newItem: ConversationEntity): Boolean = - oldItem == newItem + override fun areItemsTheSame(oldItem: ConversationEntity, newItem: ConversationEntity): Boolean { + return oldItem.id == newItem.id + } - override fun areItemsTheSame(oldItem: ConversationEntity, newItem: ConversationEntity): Boolean = - oldItem.id == newItem.id + override fun areContentsTheSame(oldItem: ConversationEntity, newItem: ConversationEntity): Boolean { + return oldItem == newItem + } } - } - -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt index c899ecf60..2e9efa6a5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt @@ -1,4 +1,4 @@ -/* Copyright 2019 Conny Duck +/* Copyright 2021 Tusky Contributors * * This file is a part of Tusky. * @@ -21,65 +21,70 @@ import androidx.room.Embedded import androidx.room.Entity import androidx.room.TypeConverters import com.keylesspalace.tusky.db.Converters -import com.keylesspalace.tusky.entity.* +import com.keylesspalace.tusky.entity.Account +import com.keylesspalace.tusky.entity.Attachment +import com.keylesspalace.tusky.entity.Conversation +import com.keylesspalace.tusky.entity.Emoji +import com.keylesspalace.tusky.entity.Poll +import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.util.shouldTrimStatus -import java.util.* +import java.util.Date -@Entity(primaryKeys = ["id","accountId"]) +@Entity(primaryKeys = ["id", "accountId"]) @TypeConverters(Converters::class) data class ConversationEntity( - val accountId: Long, - val id: String, - val accounts: List, - val unread: Boolean, - @Embedded(prefix = "s_") val lastStatus: ConversationStatusEntity + val accountId: Long, + val id: String, + val accounts: List, + val unread: Boolean, + @Embedded(prefix = "s_") val lastStatus: ConversationStatusEntity ) data class ConversationAccountEntity( - val id: String, - val username: String, - val displayName: String, - val avatar: String, - val emojis: List + val id: String, + val username: String, + val displayName: String, + val avatar: String, + val emojis: List ) { fun toAccount(): Account { return Account( - id = id, - username = username, - displayName = displayName, - avatar = avatar, - emojis = emojis, - url = "", - localUsername = "", - note = SpannedString(""), - header = "" + id = id, + username = username, + displayName = displayName, + avatar = avatar, + emojis = emojis, + url = "", + localUsername = "", + note = SpannedString(""), + header = "" ) } } @TypeConverters(Converters::class) data class ConversationStatusEntity( - val id: String, - val url: String?, - val inReplyToId: String?, - val inReplyToAccountId: String?, - val account: ConversationAccountEntity, - val content: Spanned, - val createdAt: Date, - val emojis: List, - val favouritesCount: Int, - val favourited: Boolean, - val bookmarked: Boolean, - val sensitive: Boolean, - val spoilerText: String, - val attachments: ArrayList, - val mentions: Array, - val showingHiddenContent: Boolean, - val expanded: Boolean, - val collapsible: Boolean, - val collapsed: Boolean, - val poll: Poll? - + val id: String, + val url: String?, + val inReplyToId: String?, + val inReplyToAccountId: String?, + val account: ConversationAccountEntity, + val content: Spanned, + val createdAt: Date, + val emojis: List, + val favouritesCount: Int, + val favourited: Boolean, + val bookmarked: Boolean, + val sensitive: Boolean, + val spoilerText: String, + val attachments: ArrayList, + val mentions: List, + val showingHiddenContent: Boolean, + val expanded: Boolean, + val collapsible: Boolean, + val collapsed: Boolean, + val muted: Boolean, + val poll: Poll? ) { /** its necessary to override this because Spanned.equals does not work as expected */ override fun equals(other: Any?): Boolean { @@ -93,7 +98,7 @@ data class ConversationStatusEntity( if (inReplyToId != other.inReplyToId) return false if (inReplyToAccountId != other.inReplyToAccountId) return false if (account != other.account) return false - if (content.toString() != other.content.toString()) return false //TODO find a better method to compare two spanned strings + if (content.toString() != other.content.toString()) return false // TODO find a better method to compare two spanned strings if (createdAt != other.createdAt) return false if (emojis != other.emojis) return false if (favouritesCount != other.favouritesCount) return false @@ -101,11 +106,12 @@ data class ConversationStatusEntity( if (sensitive != other.sensitive) return false if (spoilerText != other.spoilerText) return false if (attachments != other.attachments) return false - if (!mentions.contentEquals(other.mentions)) return false + if (mentions != other.mentions) return false if (showingHiddenContent != other.showingHiddenContent) return false if (expanded != other.expanded) return false if (collapsible != other.collapsible) return false if (collapsed != other.collapsed) return false + if (muted != other.muted) return false if (poll != other.poll) return false return true @@ -125,72 +131,86 @@ data class ConversationStatusEntity( result = 31 * result + sensitive.hashCode() result = 31 * result + spoilerText.hashCode() result = 31 * result + attachments.hashCode() - result = 31 * result + mentions.contentHashCode() + result = 31 * result + mentions.hashCode() result = 31 * result + showingHiddenContent.hashCode() result = 31 * result + expanded.hashCode() result = 31 * result + collapsible.hashCode() result = 31 * result + collapsed.hashCode() + result = 31 * result + muted.hashCode() result = 31 * result + poll.hashCode() return result } fun toStatus(): Status { return Status( - id = id, - url = url, - account = account.toAccount(), - inReplyToId = inReplyToId, - inReplyToAccountId = inReplyToAccountId, - content = content, - reblog = null, - createdAt = createdAt, - emojis = emojis, - reblogsCount = 0, - favouritesCount = favouritesCount, - reblogged = false, - favourited = favourited, - bookmarked = bookmarked, - sensitive= sensitive, - spoilerText = spoilerText, - visibility = Status.Visibility.DIRECT, - attachments = attachments, - mentions = mentions, - application = null, - pinned = false, - muted = false, - poll = poll, - card = null, - quote = null) + id = id, + url = url, + account = account.toAccount(), + inReplyToId = inReplyToId, + inReplyToAccountId = inReplyToAccountId, + content = content, + reblog = null, + createdAt = createdAt, + emojis = emojis, + reblogsCount = 0, + favouritesCount = favouritesCount, + reblogged = false, + favourited = favourited, + bookmarked = bookmarked, + sensitive = sensitive, + spoilerText = spoilerText, + visibility = Status.Visibility.DIRECT, + attachments = attachments, + mentions = mentions, + application = null, + pinned = false, + muted = muted, + poll = poll, + card = null, + quote = null, + ) } } fun Account.toEntity() = - ConversationAccountEntity( - id, - username, - name, - avatar, - emojis ?: emptyList() - ) + ConversationAccountEntity( + id = id, + username = username, + displayName = name, + avatar = avatar, + emojis = emojis ?: emptyList() + ) fun Status.toEntity() = - ConversationStatusEntity( - id, url, inReplyToId, inReplyToAccountId, account.toEntity(), content, - createdAt, emojis, favouritesCount, favourited, bookmarked, sensitive, - spoilerText, attachments, mentions, - false, - false, - shouldTrimStatus(content), - true, - poll - ) - + ConversationStatusEntity( + id = id, + url = url, + inReplyToId = inReplyToId, + inReplyToAccountId = inReplyToAccountId, + account = account.toEntity(), + content = content, + createdAt = createdAt, + emojis = emojis, + favouritesCount = favouritesCount, + favourited = favourited, + bookmarked = bookmarked, + sensitive = sensitive, + spoilerText = spoilerText, + attachments = attachments, + mentions = mentions, + showingHiddenContent = false, + expanded = false, + collapsible = shouldTrimStatus(content), + collapsed = true, + muted = muted ?: false, + poll = poll + ) fun Conversation.toEntity(accountId: Long) = - ConversationEntity( - accountId, - id, - accounts.map { it.toEntity() }, - unread, - lastStatus!!.toEntity() - ) + ConversationEntity( + accountId = accountId, + id = id, + accounts = accounts.map { it.toEntity() }, + unread = unread, + lastStatus = lastStatus!!.toEntity() + ) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationLoadStateAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationLoadStateAdapter.kt new file mode 100644 index 000000000..c7224c4d2 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationLoadStateAdapter.kt @@ -0,0 +1,40 @@ +/* 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 . */ + +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() { + + 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) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsBoundaryCallback.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsBoundaryCallback.kt deleted file mode 100644 index 5d3590157..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsBoundaryCallback.kt +++ /dev/null @@ -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. - *

- * 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?) -> Unit, - private val ioExecutor: Executor, - private val networkPageSize: Int) - : PagedList.BoundaryCallback() { - - 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>, - 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> { - return object : Callback> { - override fun onFailure(call: Call>, t: Throwable) { - it.recordFailure(t) - } - - override fun onResponse(call: Call>, response: Response>) { - insertItemsIntoDb(response, it) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt index ac08e71a5..5b63b6124 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt @@ -1,4 +1,4 @@ -/* Copyright 2019 Conny Duck +/* Copyright 2021 Tusky Contributors * * This file is a part of Tusky. * @@ -20,7 +20,12 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.widget.PopupMenu import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import androidx.paging.ExperimentalPagingApi +import androidx.paging.LoadState import androidx.preference.PreferenceManager import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager @@ -37,10 +42,15 @@ import com.keylesspalace.tusky.interfaces.ReselectableFragment import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.util.CardViewMode -import com.keylesspalace.tusky.util.NetworkState import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.viewBinding +import com.keylesspalace.tusky.util.visible +import com.keylesspalace.tusky.viewdata.AttachmentViewData +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import java.io.IOException import javax.inject.Inject class ConversationsFragment : SFragment(), StatusActionListener, Injectable, ReselectableFragment { @@ -53,35 +63,40 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res private val binding by viewBinding(FragmentTimelineBinding::bind) private lateinit var adapter: ConversationAdapter + private lateinit var loadStateAdapter: ConversationLoadStateAdapter private var layoutManager: LinearLayoutManager? = null + private var initialRefreshDone: Boolean = false + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { return inflater.inflate(R.layout.fragment_timeline, container, false) } + @ExperimentalPagingApi override fun onViewCreated(view: View, savedInstanceState: Bundle?) { val preferences = PreferenceManager.getDefaultSharedPreferences(view.context) val statusDisplayOptions = StatusDisplayOptions( - animateAvatars = preferences.getBoolean("animateGifAvatars", false), - mediaPreviewEnabled = accountManager.activeAccount?.mediaPreviewEnabled ?: true, - useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false), - showBotOverlay = preferences.getBoolean("showBotOverlay", true), - useBlurhash = preferences.getBoolean("useBlurhash", true), - cardViewMode = CardViewMode.NONE, - confirmReblogs = preferences.getBoolean("confirmReblogs", true), - hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), - animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), - quoteEnabled = accountManager.activeAccount?.domain in CAN_USE_QUOTE_ID + animateAvatars = preferences.getBoolean("animateGifAvatars", false), + mediaPreviewEnabled = accountManager.activeAccount?.mediaPreviewEnabled ?: true, + useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false), + showBotOverlay = preferences.getBoolean("showBotOverlay", true), + useBlurhash = preferences.getBoolean("useBlurhash", true), + cardViewMode = CardViewMode.NONE, + confirmReblogs = preferences.getBoolean("confirmReblogs", true), + hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), + animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), + quoteEnabled = accountManager.activeAccount?.domain in CAN_USE_QUOTE_ID, ) - adapter = ConversationAdapter(statusDisplayOptions, this, ::onTopLoaded, viewModel::retry) + adapter = ConversationAdapter(statusDisplayOptions, this) + loadStateAdapter = ConversationLoadStateAdapter(adapter::retry) binding.recyclerView.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL)) layoutManager = LinearLayoutManager(view.context) binding.recyclerView.layoutManager = layoutManager - binding.recyclerView.adapter = adapter + binding.recyclerView.adapter = adapter.withLoadStateFooter(loadStateAdapter) (binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false binding.progressBar.hide() @@ -89,37 +104,60 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res initSwipeToRefresh() - viewModel.conversations.observe(viewLifecycleOwner) { - adapter.submitList(it) - } - viewModel.networkState.observe(viewLifecycleOwner) { - adapter.setNetworkState(it) + lifecycleScope.launch { + viewModel.conversationFlow.collectLatest { pagingData -> + adapter.submitData(pagingData) + } } - viewModel.load() + adapter.addLoadStateListener { loadStates -> + loadStates.refresh.let { refreshState -> + if (refreshState is LoadState.Error) { + binding.statusView.show() + if (refreshState.error is IOException) { + binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) { + adapter.refresh() + } + } else { + binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic) { + adapter.refresh() + } + } + } else { + binding.statusView.hide() + } + + binding.progressBar.visible(refreshState == LoadState.Loading && adapter.itemCount == 0) + + if (refreshState is LoadState.NotLoading && !initialRefreshDone) { + // jump to top after the initial refresh finished + binding.recyclerView.scrollToPosition(0) + initialRefreshDone = true + } + + if (refreshState != LoadState.Loading) { + binding.swipeRefreshLayout.isRefreshing = false + } + } + } } private fun initSwipeToRefresh() { - viewModel.refreshState.observe(viewLifecycleOwner) { - binding.swipeRefreshLayout.isRefreshing = it == NetworkState.LOADING - } binding.swipeRefreshLayout.setOnRefreshListener { - viewModel.refresh() + adapter.refresh() } binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue) } - private fun onTopLoaded() { - binding.recyclerView.scrollToPosition(0) - } - override fun onReblog(reblog: Boolean, position: Int) { // its impossible to reblog private messages } override fun onFavourite(favourite: Boolean, position: Int) { - viewModel.favourite(favourite, position) + adapter.item(position)?.let { conversation -> + viewModel.favourite(favourite, conversation) + } } override fun onQuote(position: Int) { @@ -127,24 +165,44 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res } override fun onBookmark(favourite: Boolean, position: Int) { - viewModel.bookmark(favourite, position) + adapter.item(position)?.let { conversation -> + viewModel.bookmark(favourite, conversation) + } } override fun onMore(view: View, position: Int) { - viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let { - more(it.toStatus(), view, position) + adapter.item(position)?.let { conversation -> + + val popup = PopupMenu(requireContext(), view) + popup.inflate(R.menu.conversation_more) + + if (conversation.lastStatus.muted) { + popup.menu.removeItem(R.id.status_mute_conversation) + } else { + popup.menu.removeItem(R.id.status_unmute_conversation) + } + + popup.setOnMenuItemClickListener { item -> + when (item.itemId) { + R.id.status_mute_conversation -> viewModel.muteConversation(conversation) + R.id.status_unmute_conversation -> viewModel.muteConversation(conversation) + R.id.conversation_delete -> deleteConversation(conversation) + } + true + } + popup.show() } } override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { - viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let { - viewMedia(attachmentIndex, it.toStatus(), view) + adapter.item(position)?.let { conversation -> + viewMedia(attachmentIndex, AttachmentViewData.list(conversation.lastStatus.toStatus()), view) } } override fun onViewThread(position: Int) { - viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let { - viewThread(it.toStatus()) + adapter.item(position)?.let { conversation -> + viewThread(conversation.lastStatus.id, conversation.lastStatus.url) } } @@ -153,11 +211,15 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res } override fun onExpandedChange(expanded: Boolean, position: Int) { - viewModel.expandHiddenStatus(expanded, position) + adapter.item(position)?.let { conversation -> + viewModel.expandHiddenStatus(expanded, conversation) + } } override fun onContentHiddenChange(isShowing: Boolean, position: Int) { - viewModel.showContent(isShowing, position) + adapter.item(position)?.let { conversation -> + viewModel.showContent(isShowing, conversation) + } } override fun onLoadMore(position: Int) { @@ -165,7 +227,9 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res } override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { - viewModel.collapseLongStatus(isCollapsed, position) + adapter.item(position)?.let { conversation -> + viewModel.collapseLongStatus(isCollapsed, conversation) + } } override fun onViewAccount(id: String) { @@ -180,15 +244,25 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res } override fun removeItem(position: Int) { - viewModel.remove(position) + // not needed } override fun onReply(position: Int) { - viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let { - reply(it.toStatus()) + adapter.item(position)?.let { conversation -> + reply(conversation.lastStatus.toStatus()) } } + private fun deleteConversation(conversation: ConversationEntity) { + AlertDialog.Builder(requireContext()) + .setMessage(R.string.dialog_delete_conversation_warning) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok) { _, _ -> + viewModel.remove(conversation) + } + .show() + } + private fun jumpToTop() { if (isAdded) { layoutManager?.scrollToPosition(0) @@ -200,12 +274,10 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res jumpToTop() } - override fun onReset() { - viewModel.refresh() - } - override fun onVoteInPoll(position: Int, choices: MutableList) { - viewModel.voteInPoll(position, choices) + adapter.item(position)?.let { conversation -> + viewModel.voteInPoll(choices, conversation) + } } companion object { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRemoteMediator.kt new file mode 100644 index 000000000..7418c3b08 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRemoteMediator.kt @@ -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() { + + override suspend fun load( + loadType: LoadType, + state: PagingState + ): 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 +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRepository.kt index 3cb4745ad..12c5eb0bb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRepository.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRepository.kt @@ -1,111 +1,37 @@ +/* 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 . */ + package com.keylesspalace.tusky.components.conversation -import androidx.annotation.MainThread -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.Transformations -import androidx.paging.Config -import androidx.paging.toLiveData import com.keylesspalace.tusky.db.AppDatabase -import com.keylesspalace.tusky.entity.Conversation import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.util.Listing -import com.keylesspalace.tusky.util.NetworkState -import io.reactivex.Single -import io.reactivex.schedulers.Schedulers -import retrofit2.Call -import retrofit2.Callback -import retrofit2.Response -import java.util.concurrent.Executors +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers import javax.inject.Inject import javax.inject.Singleton @Singleton -class ConversationsRepository @Inject constructor(val mastodonApi: MastodonApi, val db: AppDatabase) { - - private val ioExecutor = Executors.newSingleThreadExecutor() - - companion object { - private const val DEFAULT_PAGE_SIZE = 20 - } - - @MainThread - fun refresh(accountId: Long, showLoadingIndicator: Boolean): LiveData { - val networkState = MutableLiveData() - if(showLoadingIndicator) { - networkState.value = NetworkState.LOADING - } - - mastodonApi.getConversations(limit = DEFAULT_PAGE_SIZE).enqueue( - object : Callback> { - override fun onFailure(call: Call>, 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>, response: Response>) { - 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 { - // 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() - val refreshState = Transformations.switchMap(refreshTrigger) { - refresh(accountId, true) - } - - // We use toLiveData Kotlin extension function here, you could also use LivePagedListBuilder - val livePagedList = db.conversationDao().conversationsForAccount(accountId).toLiveData( - config = Config(pageSize = DEFAULT_PAGE_SIZE, prefetchDistance = DEFAULT_PAGE_SIZE / 2, enablePlaceholders = false), - boundaryCallback = boundaryCallback - ) - - return Listing( - pagedList = livePagedList, - networkState = boundaryCallback.networkState, - retry = { - boundaryCallback.helper.retryAllFailed() - }, - refresh = { - refreshTrigger.value = null - }, - refreshState = refreshState - ) - } +class ConversationsRepository @Inject constructor( + val mastodonApi: MastodonApi, + val db: AppDatabase +) { fun deleteCacheForAccount(accountId: Long) { Single.fromCallable { db.conversationDao().deleteForAccount(accountId) }.subscribeOn(Schedulers.io()) - .subscribe() + .subscribe() } - - private fun insertResultIntoDb(accountId: Long, result: List?) { - result?.filter { it.lastStatus != null } - ?.map{ it.toEntity(accountId) } - ?.let { db.conversationDao().insert(it) } - - } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt index c6fa84b40..eafdbdf27 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt @@ -1,142 +1,161 @@ +/* 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 . */ + package com.keylesspalace.tusky.components.conversation import android.util.Log -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.Transformations -import androidx.paging.PagedList +import androidx.lifecycle.viewModelScope +import androidx.paging.ExperimentalPagingApi +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.cachedIn import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.TimelineCases -import com.keylesspalace.tusky.util.Listing -import com.keylesspalace.tusky.util.NetworkState import com.keylesspalace.tusky.util.RxAwareViewModel -import io.reactivex.schedulers.Schedulers +import kotlinx.coroutines.launch +import kotlinx.coroutines.rx3.await import javax.inject.Inject class ConversationsViewModel @Inject constructor( - private val repository: ConversationsRepository, - private val timelineCases: TimelineCases, - private val database: AppDatabase, - private val accountManager: AccountManager + private val timelineCases: TimelineCases, + private val database: AppDatabase, + private val accountManager: AccountManager, + private val api: MastodonApi ) : RxAwareViewModel() { - private val repoResult = MutableLiveData>() + @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> = Transformations.switchMap(repoResult) { it.pagedList } - val networkState: LiveData = Transformations.switchMap(repoResult) { it.networkState } - val refreshState: LiveData = Transformations.switchMap(repoResult) { it.refreshState } + fun favourite(favourite: Boolean, conversation: ConversationEntity) { + viewModelScope.launch { + try { + timelineCases.favourite(conversation.lastStatus.id, favourite).await() - fun load() { - val accountId = accountManager.activeAccount?.id ?: return - if (repoResult.value == null) { - repository.refresh(accountId, false) + val newConversation = conversation.copy( + lastStatus = conversation.lastStatus.copy(favourited = favourite) + ) + + database.conversationDao().insert(newConversation) + } catch (e: Exception) { + Log.w(TAG, "failed to favourite status", e) + } } - repoResult.value = repository.conversations(accountId) } - fun refresh() { - repoResult.value?.refresh?.invoke() - } + fun bookmark(bookmark: Boolean, conversation: ConversationEntity) { + viewModelScope.launch { + try { + timelineCases.bookmark(conversation.lastStatus.id, bookmark).await() - fun retry() { - repoResult.value?.retry?.invoke() - } + val newConversation = conversation.copy( + lastStatus = conversation.lastStatus.copy(bookmarked = bookmark) + ) - fun favourite(favourite: Boolean, position: Int) { - conversations.value?.getOrNull(position)?.let { conversation -> - timelineCases.favourite(conversation.lastStatus.toStatus(), favourite) - .flatMap { - val newConversation = conversation.copy( - lastStatus = conversation.lastStatus.copy(favourited = favourite) - ) - - database.conversationDao().insert(newConversation) - } - .subscribeOn(Schedulers.io()) - .doOnError { t -> Log.w("ConversationViewModel", "Failed to favourite conversation", t) } - .onErrorReturnItem(0) - .subscribe() - .autoDispose() + database.conversationDao().insert(newConversation) + } catch (e: Exception) { + Log.w(TAG, "failed to bookmark status", e) + } } - } - fun bookmark(bookmark: Boolean, position: Int) { - conversations.value?.getOrNull(position)?.let { conversation -> - timelineCases.bookmark(conversation.lastStatus.toStatus(), bookmark) - .flatMap { - val newConversation = conversation.copy( - lastStatus = conversation.lastStatus.copy(bookmarked = bookmark) - ) + fun voteInPoll(choices: List, conversation: ConversationEntity) { + viewModelScope.launch { + try { + val poll = timelineCases.voteInPoll(conversation.lastStatus.id, conversation.lastStatus.poll?.id!!, choices).await() + val newConversation = conversation.copy( + lastStatus = conversation.lastStatus.copy(poll = poll) + ) - database.conversationDao().insert(newConversation) - } - .subscribeOn(Schedulers.io()) - .doOnError { t -> Log.w("ConversationViewModel", "Failed to bookmark conversation", t) } - .onErrorReturnItem(0) - .subscribe() - .autoDispose() + database.conversationDao().insert(newConversation) + } catch (e: Exception) { + Log.w(TAG, "failed to vote in poll", e) + } } - } - fun voteInPoll(position: Int, choices: MutableList) { - conversations.value?.getOrNull(position)?.let { conversation -> - timelineCases.voteInPoll(conversation.lastStatus.toStatus(), choices) - .flatMap { poll -> - val newConversation = conversation.copy( - lastStatus = conversation.lastStatus.copy(poll = poll) - ) - - database.conversationDao().insert(newConversation) - } - .subscribeOn(Schedulers.io()) - .doOnError { t -> Log.w("ConversationViewModel", "Failed to favourite conversation", t) } - .onErrorReturnItem(0) - .subscribe() - .autoDispose() - } - - } - - fun expandHiddenStatus(expanded: Boolean, position: Int) { - conversations.value?.getOrNull(position)?.let { conversation -> + fun expandHiddenStatus(expanded: Boolean, conversation: ConversationEntity) { + viewModelScope.launch { val newConversation = conversation.copy( - lastStatus = conversation.lastStatus.copy(expanded = expanded) + lastStatus = conversation.lastStatus.copy(expanded = expanded) ) saveConversationToDb(newConversation) } } - fun collapseLongStatus(collapsed: Boolean, position: Int) { - conversations.value?.getOrNull(position)?.let { conversation -> + fun collapseLongStatus(collapsed: Boolean, conversation: ConversationEntity) { + viewModelScope.launch { val newConversation = conversation.copy( - lastStatus = conversation.lastStatus.copy(collapsed = collapsed) + lastStatus = conversation.lastStatus.copy(collapsed = collapsed) ) saveConversationToDb(newConversation) } } - fun showContent(showing: Boolean, position: Int) { - conversations.value?.getOrNull(position)?.let { conversation -> + fun showContent(showing: Boolean, conversation: ConversationEntity) { + viewModelScope.launch { val newConversation = conversation.copy( - lastStatus = conversation.lastStatus.copy(showingHiddenContent = showing) + lastStatus = conversation.lastStatus.copy(showingHiddenContent = showing) ) saveConversationToDb(newConversation) } } - fun remove(position: Int) { - conversations.value?.getOrNull(position)?.let { - refresh() + fun remove(conversation: ConversationEntity) { + viewModelScope.launch { + try { + api.deleteConversation(conversationId = conversation.id) + + database.conversationDao().delete(conversation) + } catch (e: Exception) { + Log.w(TAG, "failed to delete conversation", e) + } } } - private fun saveConversationToDb(conversation: ConversationEntity) { + 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) - .subscribeOn(Schedulers.io()) - .subscribe() } + companion object { + private const val TAG = "ConversationsViewModel" + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftHelper.kt b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftHelper.kt index 5038ac00c..7511dc3c3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftHelper.kt @@ -28,128 +28,119 @@ import com.keylesspalace.tusky.db.DraftEntity import com.keylesspalace.tusky.entity.NewPoll import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.util.IOUtils -import io.reactivex.Completable -import io.reactivex.Observable -import io.reactivex.Single -import io.reactivex.schedulers.Schedulers +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import java.io.File import java.text.SimpleDateFormat -import java.util.* +import java.util.Date +import java.util.Locale import javax.inject.Inject class DraftHelper @Inject constructor( - val context: Context, - db: AppDatabase + val context: Context, + db: AppDatabase ) { private val draftDao = db.draftDao() - fun saveDraft( - draftId: Int, - accountId: Long, - inReplyToId: String?, - content: String?, - contentWarning: String?, - sensitive: Boolean, - visibility: Status.Visibility, - mediaUris: List, - mediaDescriptions: List, - poll: NewPoll?, - failedToSend: Boolean - ): Completable { - return Single.fromCallable { + suspend fun saveDraft( + draftId: Int, + accountId: Long, + inReplyToId: String?, + content: String?, + contentWarning: String?, + sensitive: Boolean, + visibility: Status.Visibility, + mediaUris: List, + mediaDescriptions: List, + poll: NewPoll?, + failedToSend: Boolean + ) = withContext(Dispatchers.IO) { + val externalFilesDir = context.getExternalFilesDir("Tusky") - val externalFilesDir = context.getExternalFilesDir("Tusky") + if (externalFilesDir == null || !(externalFilesDir.exists())) { + Log.e("DraftHelper", "Error obtaining directory to save media.") + throw Exception() + } - if (externalFilesDir == null || !(externalFilesDir.exists())) { - Log.e("DraftHelper", "Error obtaining directory to save media.") - throw Exception() + val draftDirectory = File(externalFilesDir, "Drafts") + + if (!draftDirectory.exists()) { + draftDirectory.mkdir() + } + + val uris = mediaUris.map { uriString -> + uriString.toUri() + }.map { uri -> + if (uri.isNotInFolder(draftDirectory)) { + uri.copyToFolder(draftDirectory) + } else { + uri } + } - val draftDirectory = File(externalFilesDir, "Drafts") - - if (!draftDirectory.exists()) { - draftDirectory.mkdir() + val types = uris.map { uri -> + val mimeType = context.contentResolver.getType(uri) + when (mimeType?.substring(0, mimeType.indexOf('/'))) { + "video" -> DraftAttachment.Type.VIDEO + "image" -> DraftAttachment.Type.IMAGE + "audio" -> DraftAttachment.Type.AUDIO + else -> throw IllegalStateException("unknown media type") } + } - val uris = mediaUris.map { uriString -> - uriString.toUri() - }.map { uri -> - if (uri.isNotInFolder(draftDirectory)) { - uri.copyToFolder(draftDirectory) - } else { - uri - } - } - - val types = uris.map { uri -> - val mimeType = context.contentResolver.getType(uri) - when (mimeType?.substring(0, mimeType.indexOf('/'))) { - "video" -> DraftAttachment.Type.VIDEO - "image" -> DraftAttachment.Type.IMAGE - "audio" -> DraftAttachment.Type.AUDIO - else -> throw IllegalStateException("unknown media type") - } - } - - val attachments: MutableList = mutableListOf() - for (i in mediaUris.indices) { - attachments.add( - DraftAttachment( - uriString = uris[i].toString(), - description = mediaDescriptions[i], - type = types[i] - ) + val attachments: MutableList = mutableListOf() + for (i in mediaUris.indices) { + attachments.add( + DraftAttachment( + uriString = uris[i].toString(), + description = mediaDescriptions[i], + type = types[i] ) - } - - DraftEntity( - id = draftId, - accountId = accountId, - inReplyToId = inReplyToId, - content = content, - contentWarning = contentWarning, - sensitive = sensitive, - visibility = visibility, - attachments = attachments, - poll = poll, - failedToSend = failedToSend ) + } - }.flatMapCompletable { draft -> - draftDao.insertOrReplace(draft) - }.subscribeOn(Schedulers.io()) + val draft = DraftEntity( + id = draftId, + accountId = accountId, + inReplyToId = inReplyToId, + content = content, + contentWarning = contentWarning, + sensitive = sensitive, + visibility = visibility, + attachments = attachments, + poll = poll, + failedToSend = failedToSend + ) + + draftDao.insertOrReplace(draft) } - fun deleteDraftAndAttachments(draftId: Int): Completable { - return draftDao.find(draftId) - .flatMapCompletable { draft -> - deleteDraftAndAttachments(draft) - } + suspend fun deleteDraftAndAttachments(draftId: Int) { + draftDao.find(draftId)?.let { draft -> + deleteDraftAndAttachments(draft) + } } - fun deleteDraftAndAttachments(draft: DraftEntity): Completable { - return deleteAttachments(draft) - .andThen(draftDao.delete(draft.id)) + suspend fun deleteDraftAndAttachments(draft: DraftEntity) { + deleteAttachments(draft) + draftDao.delete(draft.id) } - fun deleteAllDraftsAndAttachmentsForAccount(accountId: Long) { - draftDao.loadDraftsSingle(accountId) - .flatMapObservable { Observable.fromIterable(it) } - .flatMapCompletable { draft -> - deleteDraftAndAttachments(draft) - }.subscribeOn(Schedulers.io()) - .subscribe() + suspend fun deleteAllDraftsAndAttachmentsForAccount(accountId: Long) { + draftDao.loadDrafts(accountId).forEach { draft -> + deleteDraftAndAttachments(draft) + } } - fun deleteAttachments(draft: DraftEntity): Completable { - return Completable.fromCallable { + suspend fun deleteAttachments(draft: DraftEntity) { + withContext(Dispatchers.IO) { draft.attachments.forEach { attachment -> if (context.contentResolver.delete(attachment.uri, null, null) == 0) { Log.e("DraftHelper", "Did not delete file ${attachment.uriString}") } } - }.subscribeOn(Schedulers.io()) + } } private fun Uri.isNotInFolder(folder: File): Boolean { @@ -171,5 +162,4 @@ class DraftHelper @Inject constructor( IOUtils.copyToFile(contentResolver, this, file) return FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".fileprovider", file) } - -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftMediaAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftMediaAdapter.kt index 69403fdb5..acee683b6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftMediaAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftMediaAdapter.kt @@ -28,18 +28,17 @@ import com.keylesspalace.tusky.R import com.keylesspalace.tusky.db.DraftAttachment class DraftMediaAdapter( - private val attachmentClick: () -> Unit + private val attachmentClick: () -> Unit ) : ListAdapter( - object: DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: DraftAttachment, newItem: DraftAttachment): Boolean { - return oldItem == newItem - } - - override fun areContentsTheSame(oldItem: DraftAttachment, newItem: DraftAttachment): Boolean { - return oldItem == newItem - } - + object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: DraftAttachment, newItem: DraftAttachment): Boolean { + return oldItem == newItem } + + override fun areContentsTheSame(oldItem: DraftAttachment, newItem: DraftAttachment): Boolean { + return oldItem == newItem + } + } ) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DraftMediaViewHolder { @@ -52,24 +51,24 @@ class DraftMediaAdapter( holder.imageView.setImageResource(R.drawable.ic_music_box_preview_24dp) } else { Glide.with(holder.itemView.context) - .load(attachment.uri) - .diskCacheStrategy(DiskCacheStrategy.NONE) - .dontAnimate() - .into(holder.imageView) + .load(attachment.uri) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .dontAnimate() + .into(holder.imageView) } } } - inner class DraftMediaViewHolder(val imageView: ImageView) - : RecyclerView.ViewHolder(imageView) { + inner class DraftMediaViewHolder(val imageView: ImageView) : + RecyclerView.ViewHolder(imageView) { init { val thumbnailViewSize = - imageView.context.resources.getDimensionPixelSize(R.dimen.compose_media_preview_size) + imageView.context.resources.getDimensionPixelSize(R.dimen.compose_media_preview_size) val layoutParams = ConstraintLayout.LayoutParams(thumbnailViewSize, thumbnailViewSize) val margin = itemView.context.resources - .getDimensionPixelSize(R.dimen.compose_media_preview_margin) + .getDimensionPixelSize(R.dimen.compose_media_preview_margin) val marginBottom = itemView.context.resources - .getDimensionPixelSize(R.dimen.compose_media_preview_margin_bottom) + .getDimensionPixelSize(R.dimen.compose_media_preview_margin_bottom) layoutParams.setMargins(margin, 0, margin, marginBottom) imageView.layoutParams = layoutParams imageView.scaleType = ImageView.ScaleType.CENTER_CROP @@ -78,4 +77,4 @@ class DraftMediaAdapter( } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsActivity.kt index ddf8a8385..ce0048011 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsActivity.kt @@ -19,28 +19,26 @@ import android.content.Context import android.content.Intent import android.os.Bundle import android.util.Log -import android.view.Menu -import android.view.MenuItem import android.widget.LinearLayout import android.widget.Toast import androidx.activity.viewModels -import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager +import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from +import autodispose2.autoDispose import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.SavedTootActivity import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.databinding.ActivityDraftsBinding import com.keylesspalace.tusky.db.DraftEntity import com.keylesspalace.tusky.di.ViewModelFactory -import com.keylesspalace.tusky.util.hide -import com.keylesspalace.tusky.util.show -import com.uber.autodispose.android.lifecycle.autoDispose -import io.reactivex.android.schedulers.AndroidSchedulers -import io.reactivex.schedulers.Schedulers +import com.keylesspalace.tusky.util.visible +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch import retrofit2.HttpException import javax.inject.Inject @@ -54,10 +52,7 @@ class DraftsActivity : BaseActivity(), DraftActionListener { private lateinit var binding: ActivityDraftsBinding private lateinit var bottomSheet: BottomSheetBehavior - private var oldDraftsButton: MenuItem? = null - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) binding = ActivityDraftsBinding.inflate(layoutInflater) @@ -70,7 +65,7 @@ class DraftsActivity : BaseActivity(), DraftActionListener { setDisplayShowHomeEnabled(true) } - binding.draftsErrorMessageView.setup(R.drawable.elephant_friend_empty, R.string.no_saved_status) + binding.draftsErrorMessageView.setup(R.drawable.elephant_friend_empty, R.string.no_drafts) val adapter = DraftsAdapter(this) @@ -80,44 +75,15 @@ class DraftsActivity : BaseActivity(), DraftActionListener { bottomSheet = BottomSheetBehavior.from(binding.bottomSheet.root) - viewModel.drafts.observe(this) { draftList -> - if (draftList.isEmpty()) { - binding.draftsRecyclerView.hide() - binding.draftsErrorMessageView.show() - } else { - binding.draftsRecyclerView.show() - binding.draftsErrorMessageView.hide() - adapter.submitList(draftList) + lifecycleScope.launch { + viewModel.drafts.collectLatest { draftData -> + adapter.submitData(draftData) } } - } - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.drafts, menu) - oldDraftsButton = menu.findItem(R.id.action_old_drafts) - viewModel.showOldDraftsButton() - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(this, Lifecycle.Event.ON_DESTROY) - .subscribe { showOldDraftsButton -> - oldDraftsButton?.isVisible = showOldDraftsButton - } - return true - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - android.R.id.home -> { - onBackPressed() - return true - } - R.id.action_old_drafts -> { - val intent = Intent(this, SavedTootActivity::class.java) - startActivityWithSlideInAnimation(intent) - return true - } + adapter.addLoadStateListener { + binding.draftsErrorMessageView.visible(adapter.itemCount == 0) } - return super.onOptionsItemSelected(item) } override fun onOpenDraft(draft: DraftEntity) { @@ -125,27 +91,28 @@ class DraftsActivity : BaseActivity(), DraftActionListener { if (draft.inReplyToId != null) { bottomSheet.state = BottomSheetBehavior.STATE_COLLAPSED viewModel.getToot(draft.inReplyToId) - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(this) - .subscribe({ status -> + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(from(this)) + .subscribe( + { status -> val composeOptions = ComposeActivity.ComposeOptions( - draftId = draft.id, - tootText = draft.content, - contentWarning = draft.contentWarning, - inReplyToId = draft.inReplyToId, - replyingStatusContent = status.content.toString(), - replyingStatusAuthor = status.account.localUsername, - draftAttachments = draft.attachments, - poll = draft.poll, - sensitive = draft.sensitive, - visibility = draft.visibility + draftId = draft.id, + tootText = draft.content, + contentWarning = draft.contentWarning, + inReplyToId = draft.inReplyToId, + replyingStatusContent = status.content.toString(), + replyingStatusAuthor = status.account.localUsername, + draftAttachments = draft.attachments, + poll = draft.poll, + sensitive = draft.sensitive, + visibility = draft.visibility ) bottomSheet.state = BottomSheetBehavior.STATE_HIDDEN startActivity(ComposeActivity.startIntent(this, composeOptions)) - - }, { throwable -> + }, + { throwable -> bottomSheet.state = BottomSheetBehavior.STATE_HIDDEN @@ -158,9 +125,10 @@ class DraftsActivity : BaseActivity(), DraftActionListener { openDraftWithoutReply(draft) } else { Snackbar.make(binding.root, getString(R.string.drafts_failed_loading_reply), Snackbar.LENGTH_SHORT) - .show() + .show() } - }) + } + ) } else { openDraftWithoutReply(draft) } @@ -168,13 +136,13 @@ class DraftsActivity : BaseActivity(), DraftActionListener { private fun openDraftWithoutReply(draft: DraftEntity) { val composeOptions = ComposeActivity.ComposeOptions( - draftId = draft.id, - tootText = draft.content, - contentWarning = draft.contentWarning, - draftAttachments = draft.attachments, - poll = draft.poll, - sensitive = draft.sensitive, - visibility = draft.visibility + draftId = draft.id, + tootText = draft.content, + contentWarning = draft.contentWarning, + draftAttachments = draft.attachments, + poll = draft.poll, + sensitive = draft.sensitive, + visibility = draft.visibility ) startActivity(ComposeActivity.startIntent(this, composeOptions)) @@ -183,10 +151,10 @@ class DraftsActivity : BaseActivity(), DraftActionListener { override fun onDeleteDraft(draft: DraftEntity) { viewModel.deleteDraft(draft) Snackbar.make(binding.root, getString(R.string.draft_deleted), Snackbar.LENGTH_LONG) - .setAction(R.string.action_undo) { - viewModel.restoreDraft(draft) - } - .show() + .setAction(R.string.action_undo) { + viewModel.restoreDraft(draft) + } + .show() } companion object { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsAdapter.kt index 5ba3716eb..18621fd3f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsAdapter.kt @@ -17,7 +17,7 @@ package com.keylesspalace.tusky.components.drafts import android.view.LayoutInflater import android.view.ViewGroup -import androidx.paging.PagedListAdapter +import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView @@ -34,17 +34,17 @@ interface DraftActionListener { } class DraftsAdapter( - private val listener: DraftActionListener -) : PagedListAdapter>( - object : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: DraftEntity, newItem: DraftEntity): Boolean { - return oldItem.id == newItem.id - } - - override fun areContentsTheSame(oldItem: DraftEntity, newItem: DraftEntity): Boolean { - return oldItem == newItem - } + private val listener: DraftActionListener +) : PagingDataAdapter>( + object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: DraftEntity, newItem: DraftEntity): Boolean { + return oldItem.id == newItem.id } + + override fun areContentsTheSame(oldItem: DraftEntity, newItem: DraftEntity): Boolean { + return oldItem == newItem + } + } ) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder { @@ -87,6 +87,5 @@ class DraftsAdapter( holder.binding.draftPoll.hide() } } - } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsViewModel.kt index f928b6d03..78853d1e5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsViewModel.kt @@ -16,14 +16,17 @@ package com.keylesspalace.tusky.components.drafts import androidx.lifecycle.ViewModel -import androidx.paging.toLiveData +import androidx.lifecycle.viewModelScope +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.cachedIn import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.DraftEntity import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi -import io.reactivex.Observable -import io.reactivex.Single +import io.reactivex.rxjava3.core.Single +import kotlinx.coroutines.launch import javax.inject.Inject class DraftsViewModel @Inject constructor( @@ -33,27 +36,28 @@ class DraftsViewModel @Inject constructor( val draftHelper: DraftHelper ) : ViewModel() { - val drafts = database.draftDao().loadDrafts(accountManager.activeAccount?.id!!).toLiveData(pageSize = 20) + val drafts = Pager( + config = PagingConfig(pageSize = 20), + pagingSourceFactory = { database.draftDao().draftsPagingSource(accountManager.activeAccount?.id!!) } + ).flow + .cachedIn(viewModelScope) private val deletedDrafts: MutableList = mutableListOf() - fun showOldDraftsButton(): Observable { - return database.tootDao().savedTootCount() - .map { count -> count > 0 } - } - fun deleteDraft(draft: DraftEntity) { // this does not immediately delete media files to avoid unnecessary file operations // in case the user decides to restore the draft - database.draftDao().delete(draft.id) - .subscribe() - deletedDrafts.add(draft) + viewModelScope.launch { + database.draftDao().delete(draft.id) + deletedDrafts.add(draft) + } } fun restoreDraft(draft: DraftEntity) { - database.draftDao().insertOrReplace(draft) - .subscribe() - deletedDrafts.remove(draft) + viewModelScope.launch { + database.draftDao().insertOrReplace(draft) + deletedDrafts.remove(draft) + } } fun getToot(tootId: String): Single { @@ -61,9 +65,10 @@ class DraftsViewModel @Inject constructor( } override fun onCleared() { - deletedDrafts.forEach { - draftHelper.deleteAttachments(it).subscribe() + viewModelScope.launch { + deletedDrafts.forEach { + draftHelper.deleteAttachments(it) + } } } - } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/instancemute/InstanceListActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/instancemute/InstanceListActivity.kt index 7253112a1..83a2191fe 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/instancemute/InstanceListActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/instancemute/InstanceListActivity.kt @@ -9,7 +9,7 @@ import dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector import javax.inject.Inject -class InstanceListActivity: BaseActivity(), HasAndroidInjector { +class InstanceListActivity : BaseActivity(), HasAndroidInjector { @Inject lateinit var androidInjector: DispatchingAndroidInjector @@ -27,11 +27,10 @@ class InstanceListActivity: BaseActivity(), HasAndroidInjector { } supportFragmentManager - .beginTransaction() - .replace(R.id.fragment_container, InstanceListFragment()) - .commit() + .beginTransaction() + .replace(R.id.fragment_container, InstanceListFragment()) + .commit() } override fun androidInjector() = androidInjector - -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/instancemute/adapter/DomainMutesAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/instancemute/adapter/DomainMutesAdapter.kt index f475f3942..509c9561d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/instancemute/adapter/DomainMutesAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/instancemute/adapter/DomainMutesAdapter.kt @@ -8,8 +8,8 @@ import com.keylesspalace.tusky.databinding.ItemMutedDomainBinding import com.keylesspalace.tusky.util.BindingHolder class DomainMutesAdapter( - private val actionListener: InstanceActionListener -): RecyclerView.Adapter>() { + private val actionListener: InstanceActionListener +) : RecyclerView.Adapter>() { var instances: MutableList = mutableListOf() var bottomLoading: Boolean = false diff --git a/app/src/main/java/com/keylesspalace/tusky/components/instancemute/fragment/InstanceListFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/instancemute/fragment/InstanceListFragment.kt index 005432d88..ccfe52b3c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/instancemute/fragment/InstanceListFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/instancemute/fragment/InstanceListFragment.kt @@ -8,6 +8,8 @@ import androidx.lifecycle.Lifecycle import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from +import autodispose2.autoDispose import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.R import com.keylesspalace.tusky.components.instancemute.adapter.DomainMutesAdapter @@ -20,16 +22,14 @@ import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.view.EndlessOnScrollListener -import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from -import com.uber.autodispose.autoDispose -import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import retrofit2.Call import retrofit2.Callback import retrofit2.Response import java.io.IOException import javax.inject.Inject -class InstanceListFragment: Fragment(R.layout.fragment_instance_list), Injectable, InstanceActionListener { +class InstanceListFragment : Fragment(R.layout.fragment_instance_list), Injectable, InstanceActionListener { @Inject lateinit var api: MastodonApi @@ -65,7 +65,7 @@ class InstanceListFragment: Fragment(R.layout.fragment_instance_list), Injectabl override fun mute(mute: Boolean, instance: String, position: Int) { if (mute) { - api.blockDomain(instance).enqueue(object: Callback { + api.blockDomain(instance).enqueue(object : Callback { override fun onFailure(call: Call, t: Throwable) { Log.e(TAG, "Error muting domain $instance") } @@ -79,7 +79,7 @@ class InstanceListFragment: Fragment(R.layout.fragment_instance_list), Injectabl } }) } else { - api.unblockDomain(instance).enqueue(object: Callback { + api.unblockDomain(instance).enqueue(object : Callback { override fun onFailure(call: Call, t: Throwable) { Log.e(TAG, "Error unmuting domain $instance") } @@ -88,10 +88,10 @@ class InstanceListFragment: Fragment(R.layout.fragment_instance_list), Injectabl if (response.isSuccessful) { adapter.removeItem(position) Snackbar.make(binding.recyclerView, getString(R.string.confirmation_domain_unmuted, instance), Snackbar.LENGTH_LONG) - .setAction(R.string.action_undo) { - mute(true, instance, position) - } - .show() + .setAction(R.string.action_undo) { + mute(true, instance, position) + } + .show() } else { Log.e(TAG, "Error unmuting domain $instance") } @@ -112,9 +112,10 @@ class InstanceListFragment: Fragment(R.layout.fragment_instance_list), Injectabl } api.domainBlocks(id, bottomId) - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(from(this, Lifecycle.Event.ON_DESTROY)) - .subscribe({ response -> + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(from(this, Lifecycle.Event.ON_DESTROY)) + .subscribe( + { response -> val instances = response.body() if (response.isSuccessful && instances != null) { @@ -122,9 +123,11 @@ class InstanceListFragment: Fragment(R.layout.fragment_instance_list), Injectabl } else { onFetchInstancesFailure(Exception(response.message())) } - }, {throwable -> + }, + { throwable -> onFetchInstancesFailure(throwable) - }) + } + ) } private fun onFetchInstancesSuccess(instances: List, linkHeader: String?) { @@ -141,9 +144,9 @@ class InstanceListFragment: Fragment(R.layout.fragment_instance_list), Injectabl if (adapter.itemCount == 0) { binding.messageView.show() binding.messageView.setup( - R.drawable.elephant_friend_empty, - R.string.message_empty, - null + R.drawable.elephant_friend_empty, + R.string.message_empty, + null ) } else { binding.messageView.hide() @@ -174,4 +177,4 @@ class InstanceListFragment: Fragment(R.layout.fragment_instance_list), Injectabl companion object { private const val TAG = "InstanceList" // logging tag } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/instancemute/interfaces/InstanceActionListener.kt b/app/src/main/java/com/keylesspalace/tusky/components/instancemute/interfaces/InstanceActionListener.kt index 97d59cc96..9b88ad966 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/instancemute/interfaces/InstanceActionListener.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/instancemute/interfaces/InstanceActionListener.kt @@ -2,4 +2,4 @@ package com.keylesspalace.tusky.components.instancemute.interfaces interface InstanceActionListener { fun mute(mute: Boolean, instance: String, position: Int) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationFetcher.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationFetcher.kt index 394a68466..fe48a16b3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationFetcher.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationFetcher.kt @@ -10,9 +10,9 @@ import com.keylesspalace.tusky.util.isLessThan import javax.inject.Inject class NotificationFetcher @Inject constructor( - private val mastodonApi: MastodonApi, - private val accountManager: AccountManager, - private val notifier: Notifier + private val mastodonApi: MastodonApi, + private val accountManager: AccountManager, + private val notifier: Notifier ) { fun fetchAndShow() { for (account in accountManager.getAllAccountsOrderedByActive()) { @@ -39,9 +39,9 @@ class NotificationFetcher @Inject constructor( } Log.d(TAG, "getting Notifications for " + account.fullName) val notifications = mastodonApi.notificationsWithAuth( - authHeader, - account.domain, - account.lastNotificationId + authHeader, + account.domain, + account.lastNotificationId ).blockingGet() val newId = account.lastNotificationId @@ -63,9 +63,9 @@ class NotificationFetcher @Inject constructor( private fun fetchMarker(authHeader: String, account: AccountEntity): Marker? { return try { val allMarkers = mastodonApi.markersWithAuth( - authHeader, - account.domain, - listOf("notifications") + authHeader, + account.domain, + listOf("notifications") ).blockingGet() val notificationMarker = allMarkers["notifications"] Log.d(TAG, "Fetched marker: $notificationMarker") @@ -79,4 +79,4 @@ class NotificationFetcher @Inject constructor( companion object { const val TAG = "NotificationFetcher" } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java index 84a07490f..2218e0b64 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java @@ -70,8 +70,8 @@ import java.util.List; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; -import io.reactivex.Single; -import io.reactivex.schedulers.Schedulers; +import io.reactivex.rxjava3.core.Single; +import io.reactivex.rxjava3.schedulers.Schedulers; import static com.keylesspalace.tusky.viewdata.PollViewDataKt.buildDescription; @@ -316,7 +316,7 @@ public class NotificationHelper { Status actionableStatus = status.getActionableStatus(); Status.Visibility replyVisibility = actionableStatus.getVisibility(); String contentWarning = actionableStatus.getSpoilerText(); - Status.Mention[] mentions = actionableStatus.getMentions(); + List mentions = actionableStatus.getMentions(); List mentionedUsernames = new ArrayList<>(); mentionedUsernames.add(actionableStatus.getAccount().getUsername()); for (Status.Mention mention : mentions) { @@ -381,7 +381,6 @@ public class NotificationHelper { NotificationChannelGroup channelGroup = new NotificationChannelGroup(account.getIdentifier(), account.getFullName()); - //noinspection ConstantConditions notificationManager.createNotificationChannelGroup(channelGroup); for (int i = 0; i < channelIds.length; i++) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationWorker.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationWorker.kt index ae7d4d3fb..42b9c869e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationWorker.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationWorker.kt @@ -23,9 +23,9 @@ import androidx.work.WorkerParameters import javax.inject.Inject class NotificationWorker( - context: Context, - params: WorkerParameters, - private val notificationsFetcher: NotificationFetcher + context: Context, + params: WorkerParameters, + private val notificationsFetcher: NotificationFetcher ) : Worker(context, params) { override fun doWork(): Result { @@ -35,13 +35,13 @@ class NotificationWorker( } class NotificationWorkerFactory @Inject constructor( - private val notificationsFetcher: NotificationFetcher + private val notificationsFetcher: NotificationFetcher ) : WorkerFactory() { override fun createWorker( - appContext: Context, - workerClassName: String, - workerParameters: WorkerParameters + appContext: Context, + workerClassName: String, + workerParameters: WorkerParameters ): ListenableWorker? { if (workerClassName == NotificationWorker::class.java.name) { return NotificationWorker(appContext, workerParameters, notificationsFetcher) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/Notifier.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/Notifier.kt index 35c33a9b8..5092530bd 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/Notifier.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/Notifier.kt @@ -12,9 +12,9 @@ interface Notifier { } class SystemNotifier( - private val context: Context + private val context: Context ) : Notifier { override fun show(notification: Notification, account: AccountEntity, isFirstInBatch: Boolean) { NotificationHelper.make(context, notification, account, isFirstInBatch) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt index 286b49b56..e6bf83fbc 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt @@ -22,7 +22,11 @@ import android.util.Log import androidx.annotation.DrawableRes import androidx.preference.PreferenceFragmentCompat import com.google.android.material.snackbar.Snackbar -import com.keylesspalace.tusky.* +import com.keylesspalace.tusky.AccountListActivity +import com.keylesspalace.tusky.BuildConfig +import com.keylesspalace.tusky.FiltersActivity +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.TabPreferenceActivity import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.components.instancemute.InstanceListActivity @@ -33,7 +37,12 @@ import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.settings.* +import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.settings.listPreference +import com.keylesspalace.tusky.settings.makePreferenceScreen +import com.keylesspalace.tusky.settings.preference +import com.keylesspalace.tusky.settings.preferenceCategory +import com.keylesspalace.tusky.settings.switchPreference import com.keylesspalace.tusky.util.ThemeUtils import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial @@ -75,8 +84,10 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { setOnPreferenceClickListener { val intent = Intent(context, TabPreferenceActivity::class.java) activity?.startActivity(intent) - activity?.overridePendingTransition(R.anim.slide_from_right, - R.anim.slide_to_left) + activity?.overridePendingTransition( + R.anim.slide_from_right, + R.anim.slide_to_left + ) true } } @@ -88,8 +99,10 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { val intent = Intent(context, AccountListActivity::class.java) intent.putExtra("type", AccountListActivity.Type.MUTES) activity?.startActivity(intent) - activity?.overridePendingTransition(R.anim.slide_from_right, - R.anim.slide_to_left) + activity?.overridePendingTransition( + R.anim.slide_from_right, + R.anim.slide_to_left + ) true } } @@ -104,8 +117,10 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { val intent = Intent(context, AccountListActivity::class.java) intent.putExtra("type", AccountListActivity.Type.BLOCKS) activity?.startActivity(intent) - activity?.overridePendingTransition(R.anim.slide_from_right, - R.anim.slide_to_left) + activity?.overridePendingTransition( + R.anim.slide_from_right, + R.anim.slide_to_left + ) true } } @@ -116,8 +131,10 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { setOnPreferenceClickListener { val intent = Intent(context, InstanceListActivity::class.java) activity?.startActivity(intent) - activity?.overridePendingTransition(R.anim.slide_from_right, - R.anim.slide_to_left) + activity?.overridePendingTransition( + R.anim.slide_from_right, + R.anim.slide_to_left + ) true } } @@ -130,7 +147,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { key = PrefKeys.DEFAULT_POST_PRIVACY setSummaryProvider { entry } val visibility = accountManager.activeAccount?.defaultPostPrivacy - ?: Status.Visibility.PUBLIC + ?: Status.Visibility.PUBLIC value = visibility.serverString() setIcon(getIconForVisibility(visibility)) setOnPreferenceChangeListener { _, newValue -> @@ -147,7 +164,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { key = PrefKeys.DEFAULT_MEDIA_SENSITIVITY isSingleLineTitle = false val sensitivity = accountManager.activeAccount?.defaultMediaSensitivity - ?: false + ?: false setDefaultValue(sensitivity) setIcon(getIconForSensitivity(sensitivity)) setOnPreferenceChangeListener { _, newValue -> @@ -201,8 +218,10 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { preference { setTitle(R.string.pref_title_public_filter_keywords) setOnPreferenceClickListener { - launchFilterActivity(Filter.PUBLIC, - R.string.pref_title_public_filter_keywords) + launchFilterActivity( + Filter.PUBLIC, + R.string.pref_title_public_filter_keywords + ) true } } @@ -226,8 +245,10 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { preference { setTitle(R.string.pref_title_thread_filter_keywords) setOnPreferenceClickListener { - launchFilterActivity(Filter.THREAD, - R.string.pref_title_thread_filter_keywords) + launchFilterActivity( + Filter.THREAD, + R.string.pref_title_thread_filter_keywords + ) true } } @@ -255,7 +276,6 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { it.startActivity(intent) it.overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left) } - } } @@ -268,36 +288,35 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { private fun syncWithServer(visibility: String? = null, sensitive: Boolean? = null) { mastodonApi.accountUpdateSource(visibility, sensitive) - .enqueue(object : Callback { - override fun onResponse(call: Call, response: Response) { - val account = response.body() - if (response.isSuccessful && account != null) { + .enqueue(object : Callback { + override fun onResponse(call: Call, response: Response) { + val account = response.body() + if (response.isSuccessful && account != null) { - accountManager.activeAccount?.let { - it.defaultPostPrivacy = account.source?.privacy - ?: Status.Visibility.PUBLIC - it.defaultMediaSensitivity = account.source?.sensitive ?: false - accountManager.saveAccount(it) - } - } else { - Log.e("AccountPreferences", "failed updating settings on server") - showErrorSnackbar(visibility, sensitive) + accountManager.activeAccount?.let { + it.defaultPostPrivacy = account.source?.privacy + ?: Status.Visibility.PUBLIC + it.defaultMediaSensitivity = account.source?.sensitive ?: false + accountManager.saveAccount(it) } - } - - override fun onFailure(call: Call, t: Throwable) { - Log.e("AccountPreferences", "failed updating settings on server", t) + } else { + Log.e("AccountPreferences", "failed updating settings on server") showErrorSnackbar(visibility, sensitive) } + } - }) + override fun onFailure(call: Call, t: Throwable) { + Log.e("AccountPreferences", "failed updating settings on server", t) + showErrorSnackbar(visibility, sensitive) + } + }) } private fun showErrorSnackbar(visibility: String?, sensitive: Boolean?) { view?.let { view -> Snackbar.make(view, R.string.pref_failed_to_sync, Snackbar.LENGTH_LONG) - .setAction(R.string.action_retry) { syncWithServer(visibility, sensitive) } - .show() + .setAction(R.string.action_retry) { syncWithServer(visibility, sensitive) } + .show() } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/EmojiPreference.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/EmojiPreference.kt index 2d32723fc..e793f17f1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/EmojiPreference.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/EmojiPreference.kt @@ -25,8 +25,8 @@ import com.keylesspalace.tusky.util.EmojiCompatFont.Companion.SYSTEM_DEFAULT import com.keylesspalace.tusky.util.EmojiCompatFont.Companion.TWEMOJI import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.show -import io.reactivex.android.schedulers.AndroidSchedulers -import io.reactivex.disposables.Disposable +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.disposables.Disposable import okhttp3.OkHttpClient import kotlin.system.exitProcess @@ -34,8 +34,8 @@ import kotlin.system.exitProcess * This Preference lets the user select their preferred emoji font */ class EmojiPreference( - context: Context, - private val okHttpClient: OkHttpClient + context: Context, + private val okHttpClient: OkHttpClient ) : Preference(context) { private lateinit var selected: EmojiCompatFont @@ -51,7 +51,7 @@ class EmojiPreference( // Find out which font is currently active selected = EmojiCompatFont.byId( - PreferenceManager.getDefaultSharedPreferences(context).getInt(key, 0) + PreferenceManager.getDefaultSharedPreferences(context).getInt(key, 0) ) // We'll use this later to determine if anything has changed original = selected @@ -67,10 +67,10 @@ class EmojiPreference( setupItem(SYSTEM_DEFAULT, binding.itemNomoji) AlertDialog.Builder(context) - .setView(binding.root) - .setPositiveButton(android.R.string.ok) { _, _ -> onDialogOk() } - .setNegativeButton(android.R.string.cancel, null) - .show() + .setView(binding.root) + .setPositiveButton(android.R.string.ok) { _, _ -> onDialogOk() } + .setNegativeButton(android.R.string.cancel, null) + .show() } private fun setupItem(font: EmojiCompatFont, binding: ItemEmojiPrefBinding) { @@ -100,32 +100,30 @@ class EmojiPreference( binding.emojiProgress.progress = 0 binding.emojiDownloadCancel.show() font.downloadFontFile(context, okHttpClient) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - { progress -> - // The progress is returned as a float between 0 and 1, or -1 if it could not determined - if (progress >= 0) { - binding.emojiProgress.isIndeterminate = false - val max = binding.emojiProgress.max.toFloat() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - binding.emojiProgress.setProgress((max * progress).toInt(), true) - } else { - binding.emojiProgress.progress = (max * progress).toInt() - } - } else { - binding.emojiProgress.isIndeterminate = true - } - }, - { - Toast.makeText(context, R.string.download_failed, Toast.LENGTH_SHORT).show() - updateItem(font, binding) - }, - { - finishDownload(font, binding) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { progress -> + // The progress is returned as a float between 0 and 1, or -1 if it could not determined + if (progress >= 0) { + binding.emojiProgress.isIndeterminate = false + val max = binding.emojiProgress.max.toFloat() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + binding.emojiProgress.setProgress((max * progress).toInt(), true) + } else { + binding.emojiProgress.progress = (max * progress).toInt() } - ).also { downloadDisposables[font.id] = it } - - + } else { + binding.emojiProgress.isIndeterminate = true + } + }, + { + Toast.makeText(context, R.string.download_failed, Toast.LENGTH_SHORT).show() + updateItem(font, binding) + }, + { + finishDownload(font, binding) + } + ).also { downloadDisposables[font.id] = it } } private fun cancelDownload(font: EmojiCompatFont, binding: ItemEmojiPrefBinding) { @@ -197,10 +195,10 @@ class EmojiPreference( val index = selected.id Log.i(TAG, "saveSelectedFont: Font ID: $index") PreferenceManager - .getDefaultSharedPreferences(context) - .edit() - .putInt(key, index) - .apply() + .getDefaultSharedPreferences(context) + .edit() + .putInt(key, index) + .apply() summary = selected.getDisplay(context) } @@ -211,29 +209,31 @@ class EmojiPreference( saveSelectedFont() if (selected !== original || updated) { AlertDialog.Builder(context) - .setTitle(R.string.restart_required) - .setMessage(R.string.restart_emoji) - .setNegativeButton(R.string.later, null) - .setPositiveButton(R.string.restart) { _, _ -> - // Restart the app - // From https://stackoverflow.com/a/17166729/5070653 - val launchIntent = Intent(context, SplashActivity::class.java) - val mPendingIntent = PendingIntent.getActivity( - context, - 0x1f973, // This is the codepoint of the party face emoji :D - launchIntent, - PendingIntent.FLAG_CANCEL_CURRENT) - val mgr = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager - mgr.set( - AlarmManager.RTC, - System.currentTimeMillis() + 100, - mPendingIntent) - exitProcess(0) - }.show() + .setTitle(R.string.restart_required) + .setMessage(R.string.restart_emoji) + .setNegativeButton(R.string.later, null) + .setPositiveButton(R.string.restart) { _, _ -> + // Restart the app + // From https://stackoverflow.com/a/17166729/5070653 + val launchIntent = Intent(context, SplashActivity::class.java) + val mPendingIntent = PendingIntent.getActivity( + context, + 0x1f973, // This is the codepoint of the party face emoji :D + launchIntent, + PendingIntent.FLAG_CANCEL_CURRENT + ) + val mgr = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + mgr.set( + AlarmManager.RTC, + System.currentTimeMillis() + 100, + mPendingIntent + ) + exitProcess(0) + }.show() } } companion object { private const val TAG = "EmojiPreference" } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/NotificationPreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/NotificationPreferencesFragment.kt index 1e90abc85..4d8ba84f3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/NotificationPreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/NotificationPreferencesFragment.kt @@ -111,7 +111,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat(), Injectable { true } } - + switchPreference { setTitle(R.string.pref_title_notification_filter_subscriptions) key = PrefKeys.NOTIFICATION_FILTER_SUBSCRIPTIONS @@ -176,5 +176,4 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat(), Injectable { return NotificationPreferencesFragment() } } - } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesActivity.kt index 1e7f095ed..037e0c000 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesActivity.kt @@ -36,8 +36,10 @@ import dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector import javax.inject.Inject -class PreferencesActivity : BaseActivity(), SharedPreferences.OnSharedPreferenceChangeListener, - HasAndroidInjector { +class PreferencesActivity : + BaseActivity(), + SharedPreferences.OnSharedPreferenceChangeListener, + HasAndroidInjector { @Inject lateinit var eventHub: EventHub @@ -62,36 +64,35 @@ class PreferencesActivity : BaseActivity(), SharedPreferences.OnSharedPreference val fragmentTag = "preference_fragment_$EXTRA_PREFERENCE_TYPE" val fragment: Fragment = supportFragmentManager.findFragmentByTag(fragmentTag) - ?: when (intent.getIntExtra(EXTRA_PREFERENCE_TYPE, 0)) { - GENERAL_PREFERENCES -> { - setTitle(R.string.action_view_preferences) - PreferencesFragment.newInstance() - } - ACCOUNT_PREFERENCES -> { - setTitle(R.string.action_view_account_preferences) - AccountPreferencesFragment.newInstance() - } - NOTIFICATION_PREFERENCES -> { - setTitle(R.string.pref_title_edit_notification_settings) - NotificationPreferencesFragment.newInstance() - } - TAB_FILTER_PREFERENCES -> { - setTitle(R.string.pref_title_status_tabs) - TabFilterPreferencesFragment.newInstance() - } - PROXY_PREFERENCES -> { - setTitle(R.string.pref_title_http_proxy_settings) - ProxyPreferencesFragment.newInstance() - } - else -> throw IllegalArgumentException("preferenceType not known") + ?: when (intent.getIntExtra(EXTRA_PREFERENCE_TYPE, 0)) { + GENERAL_PREFERENCES -> { + setTitle(R.string.action_view_preferences) + PreferencesFragment.newInstance() } + ACCOUNT_PREFERENCES -> { + setTitle(R.string.action_view_account_preferences) + AccountPreferencesFragment.newInstance() + } + NOTIFICATION_PREFERENCES -> { + setTitle(R.string.pref_title_edit_notification_settings) + NotificationPreferencesFragment.newInstance() + } + TAB_FILTER_PREFERENCES -> { + setTitle(R.string.pref_title_status_tabs) + TabFilterPreferencesFragment.newInstance() + } + PROXY_PREFERENCES -> { + setTitle(R.string.pref_title_http_proxy_settings) + ProxyPreferencesFragment.newInstance() + } + else -> throw IllegalArgumentException("preferenceType not known") + } supportFragmentManager.commit { replace(R.id.fragment_container, fragment, fragmentTag) } restartActivitiesOnExit = intent.getBooleanExtra("restart", false) - } override fun onResume() { @@ -122,7 +123,6 @@ class PreferencesActivity : BaseActivity(), SharedPreferences.OnSharedPreference restartActivitiesOnExit = true this.restartCurrentActivity() - } "statusTextSize", "absoluteTimeView", "showBotOverlay", "animateGifAvatars", "useBlurhash", "showCardsInTimelines", "confirmReblogs", "enableSwipeForTabs", "mainNavPosition", PrefKeys.HIDE_TOP_TOOLBAR, "viewPagerOffScreenLimit" -> { @@ -179,5 +179,4 @@ class PreferencesActivity : BaseActivity(), SharedPreferences.OnSharedPreference return intent } } - } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt index a2035fcbd..4a3a50c57 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt @@ -25,7 +25,14 @@ import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeOptions import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.entity.Notification -import com.keylesspalace.tusky.settings.* +import com.keylesspalace.tusky.settings.AppTheme +import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.settings.emojiPreference +import com.keylesspalace.tusky.settings.listPreference +import com.keylesspalace.tusky.settings.makePreferenceScreen +import com.keylesspalace.tusky.settings.preference +import com.keylesspalace.tusky.settings.preferenceCategory +import com.keylesspalace.tusky.settings.switchPreference import com.keylesspalace.tusky.util.ThemeUtils import com.keylesspalace.tusky.util.deserialize import com.keylesspalace.tusky.util.getNonNullString @@ -342,7 +349,7 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable { try { val httpPort = sharedPreferences.getNonNullString(PrefKeys.HTTP_PROXY_PORT, "-1") - .toInt() + .toInt() if (httpProxyEnabled && httpServer.isNotBlank() && httpPort > 0 && httpPort < 65535) { httpProxyPref?.summary = "$httpServer:$httpPort" diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/ProxyPreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/ProxyPreferencesFragment.kt index 922d5a7a1..322b0c1da 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/ProxyPreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/ProxyPreferencesFragment.kt @@ -50,7 +50,6 @@ class ProxyPreferencesFragment : PreferenceFragmentCompat() { setSummaryProvider { text } } } - } override fun onPause() { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/ReportActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/ReportActivity.kt index 57c9214cf..82526706e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/ReportActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/ReportActivity.kt @@ -51,7 +51,6 @@ class ReportActivity : BottomSheetActivity(), HasAndroidInjector { viewModel.init(accountId, accountUserName, intent?.getStringExtra(STATUS_ID)) - setContentView(binding.root) setSupportActionBar(binding.includedToolbar.toolbar) @@ -127,12 +126,12 @@ class ReportActivity : BottomSheetActivity(), HasAndroidInjector { @JvmStatic fun getIntent(context: Context, accountId: String, userName: String, statusId: String? = null) = - Intent(context, ReportActivity::class.java) - .apply { - putExtra(ACCOUNT_ID, accountId) - putExtra(ACCOUNT_USERNAME, userName) - putExtra(STATUS_ID, statusId) - } + Intent(context, ReportActivity::class.java) + .apply { + putExtra(ACCOUNT_ID, accountId) + putExtra(ACCOUNT_USERNAME, userName) + putExtra(STATUS_ID, statusId) + } } override fun androidInjector() = androidInjector diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/ReportViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/ReportViewModel.kt index fdc731632..f8991282d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/ReportViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/ReportViewModel.kt @@ -17,28 +17,38 @@ package com.keylesspalace.tusky.components.report import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.Transformations -import androidx.paging.PagedList +import androidx.lifecycle.viewModelScope +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.cachedIn import com.keylesspalace.tusky.appstore.BlockEvent import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.MuteEvent -import com.keylesspalace.tusky.components.report.adapter.StatusesRepository +import com.keylesspalace.tusky.components.report.adapter.StatusesPagingSource import com.keylesspalace.tusky.components.report.model.StatusViewState import com.keylesspalace.tusky.entity.Relationship import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.util.* -import io.reactivex.android.schedulers.AndroidSchedulers -import io.reactivex.schedulers.Schedulers +import com.keylesspalace.tusky.util.Error +import com.keylesspalace.tusky.util.Loading +import com.keylesspalace.tusky.util.Resource +import com.keylesspalace.tusky.util.RxAwareViewModel +import com.keylesspalace.tusky.util.Success +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.schedulers.Schedulers +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.launch import javax.inject.Inject class ReportViewModel @Inject constructor( - private val mastodonApi: MastodonApi, - private val eventHub: EventHub, - private val statusesRepository: StatusesRepository) : RxAwareViewModel() { + private val mastodonApi: MastodonApi, + private val eventHub: EventHub +) : RxAwareViewModel() { - private val navigationMutable = MutableLiveData() - val navigation: LiveData = navigationMutable + private val navigationMutable = MutableLiveData() + val navigation: LiveData = navigationMutable private val muteStateMutable = MutableLiveData>() val muteState: LiveData> = muteStateMutable @@ -49,14 +59,22 @@ class ReportViewModel @Inject constructor( private val reportingStateMutable = MutableLiveData>() var reportingState: LiveData> = reportingStateMutable - private val checkUrlMutable = MutableLiveData() - val checkUrl: LiveData = checkUrlMutable + private val checkUrlMutable = MutableLiveData() + val checkUrl: LiveData = checkUrlMutable - private val repoResult = MutableLiveData>() - val statuses: LiveData> = Transformations.switchMap(repoResult) { it.pagedList } - val networkStateAfter: LiveData = Transformations.switchMap(repoResult) { it.networkStateAfter } - val networkStateBefore: LiveData = Transformations.switchMap(repoResult) { it.networkStateBefore } - val networkStateRefresh: LiveData = Transformations.switchMap(repoResult) { it.refreshState } + private val accountIdFlow = MutableSharedFlow( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + + val statusesFlow = accountIdFlow.flatMapLatest { accountId -> + Pager( + initialKey = statusId, + config = PagingConfig(pageSize = 20, initialLoadSize = 20), + pagingSourceFactory = { StatusesPagingSource(accountId, mastodonApi) } + ).flow + } + .cachedIn(viewModelScope) private val selectedIds = HashSet() val statusViewState = StatusViewState() @@ -84,7 +102,10 @@ class ReportViewModel @Inject constructor( } obtainRelationship() - repoResult.value = statusesRepository.getStatuses(accountId, statusId, disposables) + + viewModelScope.launch { + accountIdFlow.emit(accountId) + } } fun navigateTo(screen: Screen) { @@ -95,27 +116,24 @@ class ReportViewModel @Inject constructor( navigationMutable.value = null } - private fun obtainRelationship() { val ids = listOf(accountId) muteStateMutable.value = Loading() blockStateMutable.value = Loading() mastodonApi.relationships(ids) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - { data -> - updateRelationship(data.getOrNull(0)) - - }, - { - updateRelationship(null) - } - ) - .autoDispose() + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { data -> + updateRelationship(data.getOrNull(0)) + }, + { + updateRelationship(null) + } + ) + .autoDispose() } - private fun updateRelationship(relationship: Relationship?) { if (relationship != null) { muteStateMutable.value = Success(relationship.muting) @@ -133,20 +151,20 @@ class ReportViewModel @Inject constructor( } else { mastodonApi.muteAccount(accountId) } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - { relationship -> - val muting = relationship?.muting == true - muteStateMutable.value = Success(muting) - if (muting) { - eventHub.dispatch(MuteEvent(accountId)) - } - }, - { error -> - muteStateMutable.value = Error(false, error.message) - } - ).autoDispose() + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { relationship -> + val muting = relationship?.muting == true + muteStateMutable.value = Success(muting) + if (muting) { + eventHub.dispatch(MuteEvent(accountId)) + } + }, + { error -> + muteStateMutable.value = Error(false, error.message) + } + ).autoDispose() muteStateMutable.value = Loading() } @@ -158,21 +176,21 @@ class ReportViewModel @Inject constructor( } else { mastodonApi.blockAccount(accountId) } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - { relationship -> - val blocking = relationship?.blocking == true - blockStateMutable.value = Success(blocking) - if (blocking) { - eventHub.dispatch(BlockEvent(accountId)) - } - }, - { error -> - blockStateMutable.value = Error(false, error.message) - } - ) - .autoDispose() + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { relationship -> + val blocking = relationship?.blocking == true + blockStateMutable.value = Success(blocking) + if (blocking) { + eventHub.dispatch(BlockEvent(accountId)) + } + }, + { error -> + blockStateMutable.value = Error(false, error.message) + } + ) + .autoDispose() blockStateMutable.value = Loading() } @@ -180,26 +198,17 @@ class ReportViewModel @Inject constructor( fun doReport() { reportingStateMutable.value = Loading() mastodonApi.reportObservable(accountId, selectedIds.toList(), reportNote, if (isRemoteAccount) isRemoteNotify else null) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - { - reportingStateMutable.value = Success(true) - }, - { error -> - reportingStateMutable.value = Error(cause = error) - } - ) - .autoDispose() - - } - - fun retryStatusLoad() { - repoResult.value?.retry?.invoke() - } - - fun refreshStatuses() { - repoResult.value?.refresh?.invoke() + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { + reportingStateMutable.value = Success(true) + }, + { error -> + reportingStateMutable.value = Error(cause = error) + } + ) + .autoDispose() } fun checkClickedUrl(url: String?) { @@ -221,5 +230,4 @@ class ReportViewModel @Inject constructor( fun isStatusChecked(id: String): Boolean { return selectedIds.contains(id) } - -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/Screen.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/Screen.kt index 643c46c18..fb0b15cae 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/Screen.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/Screen.kt @@ -21,4 +21,4 @@ enum class Screen { Done, Back, Finish -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/AdapterHandler.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/AdapterHandler.kt index 957d5b325..fd150f91a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/AdapterHandler.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/AdapterHandler.kt @@ -19,8 +19,8 @@ import android.view.View import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.interfaces.LinkListener -interface AdapterHandler: LinkListener { +interface AdapterHandler : LinkListener { fun showMedia(v: View?, status: Status?, idx: Int) fun setStatusChecked(status: Status, isChecked: Boolean) fun isStatusChecked(id: String): Boolean -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/ReportPagerAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/ReportPagerAdapter.kt index 506d99afe..fa5acc2d6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/ReportPagerAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/ReportPagerAdapter.kt @@ -33,4 +33,4 @@ class ReportPagerAdapter(activity: FragmentActivity) : FragmentStateAdapter(acti } override fun getItemCount() = 3 -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt index d830c5b3b..63c5964ba 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt @@ -25,18 +25,25 @@ import com.keylesspalace.tusky.databinding.ItemReportStatusBinding import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.interfaces.LinkListener -import com.keylesspalace.tusky.util.* +import com.keylesspalace.tusky.util.LinkHelper +import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.util.StatusViewHelper import com.keylesspalace.tusky.util.StatusViewHelper.Companion.COLLAPSE_INPUT_FILTER import com.keylesspalace.tusky.util.StatusViewHelper.Companion.NO_INPUT_FILTER +import com.keylesspalace.tusky.util.TimestampUtils +import com.keylesspalace.tusky.util.emojify +import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.shouldTrimStatus +import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.viewdata.toViewData -import java.util.* +import java.util.Date class StatusViewHolder( - private val binding: ItemReportStatusBinding, - private val statusDisplayOptions: StatusDisplayOptions, - private val viewState: StatusViewState, - private val adapterHandler: AdapterHandler, - private val getStatusForPosition: (Int) -> Status? + private val binding: ItemReportStatusBinding, + private val statusDisplayOptions: StatusDisplayOptions, + private val viewState: StatusViewState, + private val adapterHandler: AdapterHandler, + private val getStatusForPosition: (Int) -> Status? ) : RecyclerView.ViewHolder(binding.root) { private val mediaViewHeight = itemView.context.resources.getDimensionPixelSize(R.dimen.status_media_preview_height) private val statusViewHelper = StatusViewHelper(itemView) @@ -71,9 +78,11 @@ class StatusViewHolder( val sensitive = status.sensitive - statusViewHelper.setMediasPreview(statusDisplayOptions, status.attachments, - sensitive, previewListener, viewState.isMediaShow(status.id, status.sensitive), - mediaViewHeight) + statusViewHelper.setMediasPreview( + statusDisplayOptions, status.attachments, + sensitive, previewListener, viewState.isMediaShow(status.id, status.sensitive), + mediaViewHeight + ) statusViewHelper.setupPollReadonly(status.poll.toViewData(), status.emojis, statusDisplayOptions) setCreatedAt(status.createdAt) @@ -81,8 +90,10 @@ class StatusViewHolder( private fun updateTextView() { status()?.let { status -> - setupCollapsedState(shouldTrimStatus(status.content), viewState.isCollapsed(status.id, true), - viewState.isContentShow(status.id, status.sensitive), status.spoilerText) + setupCollapsedState( + shouldTrimStatus(status.content), viewState.isCollapsed(status.id, true), + viewState.isContentShow(status.id, status.sensitive), status.spoilerText + ) if (status.spoilerText.isBlank()) { setTextVisible(true, status.content, status.mentions, status.emojis, adapterHandler, status.quote != null) @@ -111,19 +122,21 @@ class StatusViewHolder( } private fun setContentWarningButtonText(contentShown: Boolean) { - if(contentShown) { + if (contentShown) { binding.statusContentWarningButton.setText(R.string.status_content_warning_show_less) } else { binding.statusContentWarningButton.setText(R.string.status_content_warning_show_more) } } - private fun setTextVisible(expanded: Boolean, - content: Spanned, - mentions: Array?, - emojis: List, - listener: LinkListener, - removeQuote: Boolean) { + private fun setTextVisible( + expanded: Boolean, + content: Spanned, + mentions: List?, + emojis: List, + listener: LinkListener, + removeQuote: Boolean, + ) { if (expanded) { val emojifiedText = content.emojify(emojis, binding.statusContent, statusDisplayOptions.animateEmojis) LinkHelper.setClickableText(binding.statusContent, emojifiedText, mentions, listener) @@ -155,7 +168,7 @@ class StatusViewHolder( private fun setupCollapsedState(collapsible: Boolean, collapsed: Boolean, expanded: Boolean, spoilerText: String) { /* input filter for TextViews have to be set before text */ if (collapsible && (expanded || TextUtils.isEmpty(spoilerText))) { - binding.buttonToggleContent.setOnClickListener{ + binding.buttonToggleContent.setOnClickListener { status()?.let { status -> viewState.setCollapsed(status.id, !collapsed) updateTextView() diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesAdapter.kt index b66ac4f3c..76ed2ebea 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesAdapter.kt @@ -17,7 +17,7 @@ package com.keylesspalace.tusky.components.report.adapter import android.view.LayoutInflater import android.view.ViewGroup -import androidx.paging.PagedListAdapter +import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.components.report.model.StatusViewState @@ -26,10 +26,10 @@ import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.util.StatusDisplayOptions class StatusesAdapter( - private val statusDisplayOptions: StatusDisplayOptions, - private val statusViewState: StatusViewState, - private val adapterHandler: AdapterHandler -) : PagedListAdapter(STATUS_COMPARATOR) { + private val statusDisplayOptions: StatusDisplayOptions, + private val statusViewState: StatusViewState, + private val adapterHandler: AdapterHandler +) : PagingDataAdapter(STATUS_COMPARATOR) { private val statusForPosition: (Int) -> Status? = { position: Int -> if (position != RecyclerView.NO_POSITION) getItem(position) else null @@ -37,8 +37,10 @@ class StatusesAdapter( override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StatusViewHolder { val binding = ItemReportStatusBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return StatusViewHolder(binding, statusDisplayOptions, statusViewState, adapterHandler, - statusForPosition) + return StatusViewHolder( + binding, statusDisplayOptions, statusViewState, adapterHandler, + statusForPosition + ) } override fun onBindViewHolder(holder: StatusViewHolder, position: Int) { @@ -50,10 +52,10 @@ class StatusesAdapter( companion object { val STATUS_COMPARATOR = object : DiffUtil.ItemCallback() { override fun areContentsTheSame(oldItem: Status, newItem: Status): Boolean = - oldItem == newItem + oldItem == newItem override fun areItemsTheSame(oldItem: Status, newItem: Status): Boolean = - oldItem.id == newItem.id + oldItem.id == newItem.id } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesDataSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesDataSource.kt deleted file mode 100644 index 10635dda1..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesDataSource.kt +++ /dev/null @@ -1,150 +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 . */ - -package com.keylesspalace.tusky.components.report.adapter - -import android.annotation.SuppressLint -import androidx.lifecycle.MutableLiveData -import androidx.paging.ItemKeyedDataSource -import com.keylesspalace.tusky.entity.Status -import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.util.NetworkState -import io.reactivex.disposables.CompositeDisposable -import io.reactivex.functions.BiFunction -import java.util.concurrent.Executor - -class StatusesDataSource(private val accountId: String, - private val mastodonApi: MastodonApi, - private val disposables: CompositeDisposable, - private val retryExecutor: Executor) : ItemKeyedDataSource() { - - val networkStateAfter = MutableLiveData() - val networkStateBefore = MutableLiveData() - - private var retryAfter: (() -> Any)? = null - private var retryBefore: (() -> Any)? = null - private var retryInitial: (() -> Any)? = null - - val initialLoad = MutableLiveData() - fun retryAllFailed() { - var prevRetry = retryInitial - retryInitial = null - prevRetry?.let { - retryExecutor.execute { - it.invoke() - } - } - - prevRetry = retryAfter - retryAfter = null - prevRetry?.let { - retryExecutor.execute { - it.invoke() - } - } - - prevRetry = retryBefore - retryBefore = null - prevRetry?.let { - retryExecutor.execute { - it.invoke() - } - } - } - - @SuppressLint("CheckResult") - override fun loadInitial(params: LoadInitialParams, callback: LoadInitialCallback) { - networkStateAfter.postValue(NetworkState.LOADED) - networkStateBefore.postValue(NetworkState.LOADED) - retryAfter = null - retryBefore = null - retryInitial = null - initialLoad.postValue(NetworkState.LOADING) - val initialKey = params.requestedInitialKey - if (initialKey == null) { - mastodonApi.accountStatusesObservable(accountId, null, null, params.requestedLoadSize, true) - } else { - mastodonApi.statusObservable(initialKey).zipWith( - mastodonApi.accountStatusesObservable(accountId, params.requestedInitialKey, null, params.requestedLoadSize - 1, true), - BiFunction { status: Status, list: List -> - val ret = ArrayList() - ret.add(status) - ret.addAll(list) - return@BiFunction ret - }) - } - .doOnSubscribe { - disposables.add(it) - } - .subscribe( - { - callback.onResult(it) - initialLoad.postValue(NetworkState.LOADED) - }, - { - retryInitial = { - loadInitial(params, callback) - } - initialLoad.postValue(NetworkState.error(it.message)) - } - ) - } - - @SuppressLint("CheckResult") - override fun loadAfter(params: LoadParams, callback: LoadCallback) { - networkStateAfter.postValue(NetworkState.LOADING) - retryAfter = null - mastodonApi.accountStatusesObservable(accountId, params.key, null, params.requestedLoadSize, true) - .doOnSubscribe { - disposables.add(it) - } - .subscribe( - { - callback.onResult(it) - networkStateAfter.postValue(NetworkState.LOADED) - }, - { - retryAfter = { - loadAfter(params, callback) - } - networkStateAfter.postValue(NetworkState.error(it.message)) - } - ) - } - - @SuppressLint("CheckResult") - override fun loadBefore(params: LoadParams, callback: LoadCallback) { - networkStateBefore.postValue(NetworkState.LOADING) - retryBefore = null - mastodonApi.accountStatusesObservable(accountId, null, params.key, params.requestedLoadSize, true) - .doOnSubscribe { - disposables.add(it) - } - .subscribe( - { - callback.onResult(it) - networkStateBefore.postValue(NetworkState.LOADED) - }, - { - retryBefore = { - loadBefore(params, callback) - } - networkStateBefore.postValue(NetworkState.error(it.message)) - } - ) - } - - override fun getKey(item: Status): String = item.id -} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesDataSourceFactory.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesDataSourceFactory.kt deleted file mode 100644 index 4cf8ff1c3..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesDataSourceFactory.kt +++ /dev/null @@ -1,36 +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 . */ - -package com.keylesspalace.tusky.components.report.adapter - -import androidx.lifecycle.MutableLiveData -import androidx.paging.DataSource -import com.keylesspalace.tusky.entity.Status -import com.keylesspalace.tusky.network.MastodonApi -import io.reactivex.disposables.CompositeDisposable -import java.util.concurrent.Executor - -class StatusesDataSourceFactory( - private val accountId: String, - private val mastodonApi: MastodonApi, - private val disposables: CompositeDisposable, - private val retryExecutor: Executor) : DataSource.Factory() { - val sourceLiveData = MutableLiveData() - override fun create(): DataSource { - val source = StatusesDataSource(accountId, mastodonApi, disposables, retryExecutor) - sourceLiveData.postValue(source) - return source - } -} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesPagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesPagingSource.kt new file mode 100644 index 000000000..c007239d8 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesPagingSource.kt @@ -0,0 +1,88 @@ +/* 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 . */ + +package com.keylesspalace.tusky.components.report.adapter + +import android.util.Log +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.network.MastodonApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.rx3.await +import kotlinx.coroutines.withContext + +class StatusesPagingSource( + private val accountId: String, + private val mastodonApi: MastodonApi +) : PagingSource() { + + override fun getRefreshKey(state: PagingState): String? { + return state.anchorPosition?.let { anchorPosition -> + state.closestItemToPosition(anchorPosition)?.id + } + } + + override suspend fun load(params: LoadParams): LoadResult { + val key = params.key + try { + val result = if (params is LoadParams.Refresh && key != null) { + withContext(Dispatchers.IO) { + val initialStatus = async { getSingleStatus(key) } + val additionalStatuses = async { getStatusList(maxId = key, limit = params.loadSize - 1) } + listOf(initialStatus.await()) + additionalStatuses.await() + } + } else { + val maxId = if (params is LoadParams.Refresh || params is LoadParams.Append) { + params.key + } else { + null + } + + val minId = if (params is LoadParams.Prepend) { + params.key + } else { + null + } + + getStatusList(minId = minId, maxId = maxId, limit = params.loadSize) + } + return LoadResult.Page( + data = result, + prevKey = result.firstOrNull()?.id, + nextKey = result.lastOrNull()?.id + ) + } catch (e: Exception) { + Log.w("StatusesPagingSource", "failed to load statuses", e) + return LoadResult.Error(e) + } + } + + private suspend fun getSingleStatus(statusId: String): Status { + return mastodonApi.statusObservable(statusId).await() + } + + private suspend fun getStatusList(minId: String? = null, maxId: String? = null, limit: Int): List { + return mastodonApi.accountStatusesObservable( + accountId = accountId, + maxId = maxId, + sinceId = null, + minId = minId, + limit = limit, + excludeReblogs = true + ).await() + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesRepository.kt deleted file mode 100644 index cea3080ea..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesRepository.kt +++ /dev/null @@ -1,60 +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 . */ - -package com.keylesspalace.tusky.components.report.adapter - -import androidx.lifecycle.Transformations -import androidx.paging.Config -import androidx.paging.toLiveData -import com.keylesspalace.tusky.entity.Status -import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.util.BiListing -import io.reactivex.disposables.CompositeDisposable -import java.util.concurrent.Executors -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class StatusesRepository @Inject constructor(private val mastodonApi: MastodonApi) { - - private val executor = Executors.newSingleThreadExecutor() - - fun getStatuses(accountId: String, initialStatus: String?, disposables: CompositeDisposable, pageSize: Int = 20): BiListing { - val sourceFactory = StatusesDataSourceFactory(accountId, mastodonApi, disposables, executor) - val livePagedList = sourceFactory.toLiveData( - config = Config(pageSize = pageSize, enablePlaceholders = false, initialLoadSizeHint = pageSize * 2), - fetchExecutor = executor, initialLoadKey = initialStatus - ) - return BiListing( - pagedList = livePagedList, - networkStateBefore = Transformations.switchMap(sourceFactory.sourceLiveData) { - it.networkStateBefore - }, - networkStateAfter = Transformations.switchMap(sourceFactory.sourceLiveData) { - it.networkStateAfter - }, - retry = { - sourceFactory.sourceLiveData.value?.retryAllFailed() - }, - refresh = { - sourceFactory.sourceLiveData.value?.invalidate() - }, - refreshState = Transformations.switchMap(sourceFactory.sourceLiveData) { - it.initialLoad - } - - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportDoneFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportDoneFragment.kt index 794cb287b..0f8065776 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportDoneFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportDoneFragment.kt @@ -56,27 +56,29 @@ class ReportDoneFragment : Fragment(R.layout.fragment_report_done), Injectable { binding.progressMute.hide() } - binding.buttonMute.setText(when (it.data) { - true -> R.string.action_unmute - else -> R.string.action_mute - }) + binding.buttonMute.setText( + when (it.data) { + true -> R.string.action_unmute + else -> R.string.action_mute + } + ) } viewModel.blockState.observe(viewLifecycleOwner) { if (it !is Loading) { binding.buttonBlock.show() binding.progressBlock.show() - } - else { + } else { binding.buttonBlock.hide() binding.progressBlock.hide() } - binding.buttonBlock.setText(when (it.data) { - true -> R.string.action_unblock - else -> R.string.action_block - }) + binding.buttonBlock.setText( + when (it.data) { + true -> R.string.action_unblock + else -> R.string.action_block + } + ) } - } private fun handleClicks() { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportNoteFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportNoteFragment.kt index b47b586a5..56f812a05 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportNoteFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportNoteFragment.kt @@ -27,7 +27,12 @@ import com.keylesspalace.tusky.components.report.Screen import com.keylesspalace.tusky.databinding.FragmentReportNoteBinding import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.ViewModelFactory -import com.keylesspalace.tusky.util.* +import com.keylesspalace.tusky.util.Error +import com.keylesspalace.tusky.util.Loading +import com.keylesspalace.tusky.util.Success +import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.util.viewBinding import java.io.IOException import javax.inject.Inject @@ -59,11 +64,10 @@ class ReportNoteFragment : Fragment(R.layout.fragment_report_note), Injectable { private fun fillViews() { binding.editNote.setText(viewModel.reportNote) - if (viewModel.isRemoteAccount){ + if (viewModel.isRemoteAccount) { binding.checkIsNotifyRemote.show() binding.reportDescriptionRemoteInstance.show() - } - else{ + } else { binding.checkIsNotifyRemote.hide() binding.reportDescriptionRemoteInstance.hide() } @@ -79,7 +83,6 @@ class ReportNoteFragment : Fragment(R.layout.fragment_report_note), Injectable { is Success -> viewModel.navigateTo(Screen.Done) is Loading -> showLoading() is Error -> showError(it.cause) - } } } @@ -92,12 +95,10 @@ class ReportNoteFragment : Fragment(R.layout.fragment_report_note), Injectable { binding.progressBar.hide() Snackbar.make(binding.buttonBack, if (error is IOException) R.string.error_network else R.string.error_generic, Snackbar.LENGTH_LONG) - .apply { - setAction(R.string.action_retry) { - sendReport() - } - } - .show() + .setAction(R.string.action_retry) { + sendReport() + } + .show() } private fun sendReport() { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt index ee96e6954..3247e6584 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt @@ -21,6 +21,8 @@ import androidx.core.app.ActivityOptionsCompat import androidx.core.view.ViewCompat import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels +import androidx.lifecycle.lifecycleScope +import androidx.paging.LoadState import androidx.preference.PreferenceManager import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager @@ -44,10 +46,11 @@ import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.util.CardViewMode import com.keylesspalace.tusky.util.StatusDisplayOptions -import com.keylesspalace.tusky.util.hide -import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.viewBinding +import com.keylesspalace.tusky.util.visible import com.keylesspalace.tusky.viewdata.AttachmentViewData +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch import javax.inject.Inject class ReportStatusesFragment : Fragment(R.layout.fragment_report_statuses), Injectable, AdapterHandler { @@ -71,13 +74,11 @@ class ReportStatusesFragment : Fragment(R.layout.fragment_report_statuses), Inje when (actionable.attachments[idx].type) { Attachment.Type.GIFV, Attachment.Type.VIDEO, Attachment.Type.IMAGE, Attachment.Type.AUDIO -> { val attachments = AttachmentViewData.list(actionable) - val intent = ViewMediaActivity.newIntent(context, attachments, - idx) + val intent = ViewMediaActivity.newIntent(context, attachments, idx) if (v != null) { val url = actionable.attachments[idx].url ViewCompat.setTransitionName(v, url) - val options = ActivityOptionsCompat.makeSceneTransitionAnimation(requireActivity(), - v, url) + val options = ActivityOptionsCompat.makeSceneTransitionAnimation(requireActivity(), v, url) startActivity(intent, options.toBundle()) } else { startActivity(intent) @@ -86,7 +87,6 @@ class ReportStatusesFragment : Fragment(R.layout.fragment_report_statuses), Inje Attachment.Type.UNKNOWN -> { } } - } } @@ -101,81 +101,66 @@ class ReportStatusesFragment : Fragment(R.layout.fragment_report_statuses), Inje binding.swipeRefreshLayout.setOnRefreshListener { snackbarErrorRetry?.dismiss() - viewModel.refreshStatuses() + adapter.refresh() } } private fun initStatusesView() { val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) val statusDisplayOptions = StatusDisplayOptions( - animateAvatars = false, - mediaPreviewEnabled = accountManager.activeAccount?.mediaPreviewEnabled ?: true, - useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false), - showBotOverlay = false, - useBlurhash = preferences.getBoolean("useBlurhash", true), - cardViewMode = CardViewMode.NONE, - confirmReblogs = preferences.getBoolean("confirmReblogs", true), - hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), - animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), - quoteEnabled = accountManager.activeAccount?.domain in CAN_USE_QUOTE_ID + animateAvatars = false, + mediaPreviewEnabled = accountManager.activeAccount?.mediaPreviewEnabled ?: true, + useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false), + showBotOverlay = false, + useBlurhash = preferences.getBoolean("useBlurhash", true), + cardViewMode = CardViewMode.NONE, + confirmReblogs = preferences.getBoolean("confirmReblogs", true), + hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), + animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), + quoteEnabled = accountManager.activeAccount?.domain in CAN_USE_QUOTE_ID, ) - adapter = StatusesAdapter(statusDisplayOptions, - viewModel.statusViewState, this) + adapter = StatusesAdapter(statusDisplayOptions, viewModel.statusViewState, this) binding.recyclerView.addItemDecoration(DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL)) binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) binding.recyclerView.adapter = adapter (binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false - viewModel.statuses.observe(viewLifecycleOwner) { - adapter.submitList(it) + lifecycleScope.launch { + viewModel.statusesFlow.collectLatest { pagingData -> + adapter.submitData(pagingData) + } } - viewModel.networkStateAfter.observe(viewLifecycleOwner) { - if (it?.status == com.keylesspalace.tusky.util.Status.RUNNING) - binding.progressBarBottom.show() - else - binding.progressBarBottom.hide() + adapter.addLoadStateListener { loadState -> + if (loadState.refresh is LoadState.Error || + loadState.append is LoadState.Error || + loadState.prepend is LoadState.Error + ) { + showError() + } - if (it?.status == com.keylesspalace.tusky.util.Status.FAILED) - showError(it.msg) - } + binding.progressBarBottom.visible(loadState.append == LoadState.Loading) + binding.progressBarTop.visible(loadState.prepend == LoadState.Loading) + binding.progressBarLoading.visible(loadState.refresh == LoadState.Loading && !binding.swipeRefreshLayout.isRefreshing) - viewModel.networkStateBefore.observe(viewLifecycleOwner) { - if (it?.status == com.keylesspalace.tusky.util.Status.RUNNING) - binding.progressBarTop.show() - else - binding.progressBarTop.hide() - - if (it?.status == com.keylesspalace.tusky.util.Status.FAILED) - showError(it.msg) - } - - viewModel.networkStateRefresh.observe(viewLifecycleOwner) { - if (it?.status == com.keylesspalace.tusky.util.Status.RUNNING && !binding.swipeRefreshLayout.isRefreshing) - binding.progressBarLoading.show() - else - binding.progressBarLoading.hide() - - if (it?.status != com.keylesspalace.tusky.util.Status.RUNNING) + if (loadState.refresh != LoadState.Loading) { binding.swipeRefreshLayout.isRefreshing = false - if (it?.status == com.keylesspalace.tusky.util.Status.FAILED) - showError(it.msg) + } } } - private fun showError(@Suppress("UNUSED_PARAMETER") msg: String?) { + private fun showError() { if (snackbarErrorRetry?.isShown != true) { snackbarErrorRetry = Snackbar.make(binding.swipeRefreshLayout, R.string.failed_fetch_statuses, Snackbar.LENGTH_INDEFINITE) snackbarErrorRetry?.setAction(R.string.action_retry) { - viewModel.retryStatusLoad() + adapter.retry() } snackbarErrorRetry?.show() } } - private fun handleClicks() { binding.buttonCancel.setOnClickListener { viewModel.navigateTo(Screen.Back) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/model/StatusViewState.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/model/StatusViewState.kt index 664ddc6a5..2bcade2fe 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/model/StatusViewState.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/model/StatusViewState.kt @@ -30,7 +30,7 @@ class StatusViewState { fun setCollapsed(id: String, isCollapsed: Boolean) = setStateEnabled(longContentCollapsedState, id, isCollapsed) private fun isStateEnabled(map: Map, id: String, def: Boolean): Boolean = map[id] - ?: def + ?: def private fun setStateEnabled(map: MutableMap, id: String, state: Boolean) = map.put(id, state) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledTootActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledTootActivity.kt index 4af5771c1..06a4dee00 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledTootActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledTootActivity.kt @@ -19,18 +19,25 @@ import android.content.Context import android.content.Intent import android.os.Bundle import androidx.activity.viewModels +import androidx.lifecycle.lifecycleScope +import androidx.paging.LoadState import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager +import autodispose2.androidx.lifecycle.autoDispose import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.appstore.StatusScheduledEvent import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.databinding.ActivityScheduledTootBinding import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.entity.ScheduledStatus -import com.keylesspalace.tusky.util.Status import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.show +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch import javax.inject.Inject class ScheduledTootActivity : BaseActivity(), ScheduledTootActionListener, Injectable { @@ -38,6 +45,9 @@ class ScheduledTootActivity : BaseActivity(), ScheduledTootActionListener, Injec @Inject lateinit var viewModelFactory: ViewModelFactory + @Inject + lateinit var eventHub: EventHub + private val viewModel: ScheduledTootViewModel by viewModels { viewModelFactory } private val adapter = ScheduledTootAdapter(this) @@ -64,50 +74,52 @@ class ScheduledTootActivity : BaseActivity(), ScheduledTootActionListener, Injec binding.scheduledTootList.addItemDecoration(divider) binding.scheduledTootList.adapter = adapter - viewModel.data.observe(this) { - adapter.submitList(it) + lifecycleScope.launch { + viewModel.data.collectLatest { pagingData -> + adapter.submitData(pagingData) + } } - viewModel.networkState.observe(this) { (status) -> - when(status) { - Status.SUCCESS -> { - binding.progressBar.hide() - binding.swipeRefreshLayout.isRefreshing = false - if(viewModel.data.value?.loadedCount == 0) { - binding.errorMessageView.setup(R.drawable.elephant_friend_empty, R.string.no_scheduled_status) - binding.errorMessageView.show() - } else { - binding.errorMessageView.hide() - } + adapter.addLoadStateListener { loadState -> + if (loadState.refresh is Error) { + binding.progressBar.hide() + binding.errorMessageView.setup(R.drawable.elephant_error, R.string.error_generic) { + refreshStatuses() } - Status.RUNNING -> { + binding.errorMessageView.show() + } + if (loadState.refresh != LoadState.Loading) { + binding.swipeRefreshLayout.isRefreshing = false + } + if (loadState.refresh is LoadState.NotLoading) { + binding.progressBar.hide() + if (adapter.itemCount == 0) { + binding.errorMessageView.setup(R.drawable.elephant_friend_empty, R.string.no_scheduled_status) + binding.errorMessageView.show() + } else { binding.errorMessageView.hide() - if(viewModel.data.value?.loadedCount ?: 0 > 0) { - binding.swipeRefreshLayout.isRefreshing = true - } else { - binding.progressBar.show() - } - } - Status.FAILED -> { - if(viewModel.data.value?.loadedCount ?: 0 >= 0) { - binding.progressBar.hide() - binding.swipeRefreshLayout.isRefreshing = false - binding.errorMessageView.setup(R.drawable.elephant_error, R.string.error_generic) { - refreshStatuses() - } - binding.errorMessageView.show() - } } } } + + eventHub.events + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(this) + .subscribe { event -> + if (event is StatusScheduledEvent) { + adapter.refresh() + } + } } private fun refreshStatuses() { - viewModel.reload() + adapter.refresh() } override fun edit(item: ScheduledStatus) { - val intent = ComposeActivity.startIntent(this, ComposeActivity.ComposeOptions( + val intent = ComposeActivity.startIntent( + this, + ComposeActivity.ComposeOptions( scheduledTootId = item.id, tootText = item.params.text, contentWarning = item.params.spoilerText, @@ -116,7 +128,8 @@ class ScheduledTootActivity : BaseActivity(), ScheduledTootActionListener, Injec visibility = item.params.visibility, scheduledAt = item.scheduledAt, sensitive = item.params.sensitive - )) + ) + ) startActivity(intent) } @@ -125,9 +138,6 @@ class ScheduledTootActivity : BaseActivity(), ScheduledTootActionListener, Injec } companion object { - @JvmStatic - fun newIntent(context: Context): Intent { - return Intent(context, ScheduledTootActivity::class.java) - } + fun newIntent(context: Context) = Intent(context, ScheduledTootActivity::class.java) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledTootAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledTootAdapter.kt index 414130ddb..75b83e5d2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledTootAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledTootAdapter.kt @@ -18,7 +18,7 @@ package com.keylesspalace.tusky.components.scheduled import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.paging.PagedListAdapter +import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil import com.keylesspalace.tusky.databinding.ItemScheduledTootBinding import com.keylesspalace.tusky.entity.ScheduledStatus @@ -30,18 +30,17 @@ interface ScheduledTootActionListener { } class ScheduledTootAdapter( - val listener: ScheduledTootActionListener -) : PagedListAdapter>( - object: DiffUtil.ItemCallback(){ - override fun areItemsTheSame(oldItem: ScheduledStatus, newItem: ScheduledStatus): Boolean { - return oldItem.id == newItem.id - } - - override fun areContentsTheSame(oldItem: ScheduledStatus, newItem: ScheduledStatus): Boolean { - return oldItem == newItem - } - + val listener: ScheduledTootActionListener +) : PagingDataAdapter>( + object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: ScheduledStatus, newItem: ScheduledStatus): Boolean { + return oldItem.id == newItem.id } + + override fun areContentsTheSame(oldItem: ScheduledStatus, newItem: ScheduledStatus): Boolean { + return oldItem == newItem + } + } ) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder { @@ -50,7 +49,7 @@ class ScheduledTootAdapter( } override fun onBindViewHolder(holder: BindingHolder, position: Int) { - getItem(position)?.let{ item -> + getItem(position)?.let { item -> holder.binding.edit.isEnabled = true holder.binding.delete.isEnabled = true holder.binding.text.text = item.params.text diff --git a/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledTootDataSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledTootDataSource.kt deleted file mode 100644 index 6c9ba31bd..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledTootDataSource.kt +++ /dev/null @@ -1,102 +0,0 @@ -/* Copyright 2019 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 . */ - -package com.keylesspalace.tusky.components.scheduled - -import android.util.Log -import androidx.lifecycle.MutableLiveData -import androidx.paging.DataSource -import androidx.paging.ItemKeyedDataSource -import com.keylesspalace.tusky.entity.ScheduledStatus -import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.util.NetworkState -import io.reactivex.disposables.CompositeDisposable -import io.reactivex.rxkotlin.addTo - -class ScheduledTootDataSourceFactory( - private val mastodonApi: MastodonApi, - private val disposables: CompositeDisposable -): DataSource.Factory() { - - private val scheduledTootsCache = mutableListOf() - - private var dataSource: ScheduledTootDataSource? = null - - val networkState = MutableLiveData() - - override fun create(): DataSource { - return ScheduledTootDataSource(mastodonApi, disposables, scheduledTootsCache, networkState).also { - dataSource = it - } - } - - fun reload() { - scheduledTootsCache.clear() - dataSource?.invalidate() - } - - fun remove(status: ScheduledStatus) { - scheduledTootsCache.remove(status) - dataSource?.invalidate() - } - -} - - -class ScheduledTootDataSource( - private val mastodonApi: MastodonApi, - private val disposables: CompositeDisposable, - private val scheduledTootsCache: MutableList, - private val networkState: MutableLiveData -): ItemKeyedDataSource() { - override fun loadInitial(params: LoadInitialParams, callback: LoadInitialCallback) { - if(scheduledTootsCache.isNotEmpty()) { - callback.onResult(scheduledTootsCache.toList()) - } else { - networkState.postValue(NetworkState.LOADING) - mastodonApi.scheduledStatuses(limit = params.requestedLoadSize) - .subscribe({ newData -> - scheduledTootsCache.addAll(newData) - callback.onResult(newData) - networkState.postValue(NetworkState.LOADED) - }, { throwable -> - Log.w("ScheduledTootDataSource", "Error loading scheduled statuses", throwable) - networkState.postValue(NetworkState.error(throwable.message)) - }) - .addTo(disposables) - } - } - - override fun loadAfter(params: LoadParams, callback: LoadCallback) { - mastodonApi.scheduledStatuses(limit = params.requestedLoadSize, maxId = params.key) - .subscribe({ newData -> - scheduledTootsCache.addAll(newData) - callback.onResult(newData) - }, { throwable -> - Log.w("ScheduledTootDataSource", "Error loading scheduled statuses", throwable) - networkState.postValue(NetworkState.error(throwable.message)) - }) - .addTo(disposables) - } - - override fun loadBefore(params: LoadParams, callback: LoadCallback) { - // we are always loading from beginning to end - } - - override fun getKey(item: ScheduledStatus): String { - return item.id - } - -} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledTootPagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledTootPagingSource.kt new file mode 100644 index 000000000..c4994cef6 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledTootPagingSource.kt @@ -0,0 +1,79 @@ +/* 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 . */ + +package com.keylesspalace.tusky.components.scheduled + +import android.util.Log +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.keylesspalace.tusky.entity.ScheduledStatus +import com.keylesspalace.tusky.network.MastodonApi +import kotlinx.coroutines.rx3.await + +class ScheduledTootPagingSourceFactory( + private val mastodonApi: MastodonApi +) : () -> ScheduledTootPagingSource { + + private val scheduledTootsCache = mutableListOf() + + private var pagingSource: ScheduledTootPagingSource? = null + + override fun invoke(): ScheduledTootPagingSource { + return ScheduledTootPagingSource(mastodonApi, scheduledTootsCache).also { + pagingSource = it + } + } + + fun remove(status: ScheduledStatus) { + scheduledTootsCache.remove(status) + pagingSource?.invalidate() + } +} + +class ScheduledTootPagingSource( + private val mastodonApi: MastodonApi, + private val scheduledTootsCache: MutableList +) : PagingSource() { + + override fun getRefreshKey(state: PagingState): String? { + return null + } + + override suspend fun load(params: LoadParams): LoadResult { + return if (params is LoadParams.Refresh && scheduledTootsCache.isNotEmpty()) { + LoadResult.Page( + data = scheduledTootsCache, + prevKey = null, + nextKey = scheduledTootsCache.lastOrNull()?.id + ) + } else { + try { + val result = mastodonApi.scheduledStatuses( + maxId = params.key, + limit = params.loadSize + ).await() + + LoadResult.Page( + data = result, + prevKey = null, + nextKey = result.lastOrNull()?.id + ) + } catch (e: Exception) { + Log.w("ScheduledTootPgngSrc", "Error loading scheduled statuses", e) + LoadResult.Error(e) + } + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledTootViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledTootViewModel.kt index 3584168ee..14f012ba6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledTootViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledTootViewModel.kt @@ -16,53 +16,39 @@ package com.keylesspalace.tusky.components.scheduled import android.util.Log -import androidx.paging.Config -import androidx.paging.toLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.cachedIn import com.keylesspalace.tusky.appstore.EventHub -import com.keylesspalace.tusky.appstore.StatusScheduledEvent import com.keylesspalace.tusky.entity.ScheduledStatus import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.util.RxAwareViewModel -import io.reactivex.android.schedulers.AndroidSchedulers +import kotlinx.coroutines.launch +import kotlinx.coroutines.rx3.await import javax.inject.Inject class ScheduledTootViewModel @Inject constructor( - val mastodonApi: MastodonApi, - val eventHub: EventHub -): RxAwareViewModel() { + val mastodonApi: MastodonApi, + val eventHub: EventHub +) : ViewModel() { - private val dataSourceFactory = ScheduledTootDataSourceFactory(mastodonApi, disposables) + private val pagingSourceFactory = ScheduledTootPagingSourceFactory(mastodonApi) - val data = dataSourceFactory.toLiveData( - config = Config(pageSize = 20, initialLoadSizeHint = 20, enablePlaceholders = false) - ) - - val networkState = dataSourceFactory.networkState - - init { - eventHub.events - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { event -> - if (event is StatusScheduledEvent) { - reload() - } - } - .autoDispose() - } - - fun reload() { - dataSourceFactory.reload() - } + val data = Pager( + config = PagingConfig(pageSize = 20, initialLoadSize = 20), + pagingSourceFactory = pagingSourceFactory + ).flow + .cachedIn(viewModelScope) fun deleteScheduledStatus(status: ScheduledStatus) { - mastodonApi.deleteScheduledStatus(status.id) - .subscribe({ - dataSourceFactory.remove(status) - },{ throwable -> - Log.w("ScheduledTootViewModel", "Error deleting scheduled status", throwable) - }) - .autoDispose() - + viewModelScope.launch { + try { + mastodonApi.deleteScheduledStatus(status.id).await() + pagingSourceFactory.remove(status) + } catch (throwable: Throwable) { + Log.w("ScheduledTootViewModel", "Error deleting scheduled status", throwable) + } + } } - -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchActivity.kt index 06f48eaca..a8f8b5bbd 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchActivity.kt @@ -77,7 +77,7 @@ class SearchActivity : BottomSheetActivity(), HasAndroidInjector { menuInflater.inflate(R.menu.search_toolbar, menu) val searchView = menu.findItem(R.id.action_search) - .actionView as SearchView + .actionView as SearchView setupSearchView(searchView) searchView.setQuery(viewModel.currentQuery, false) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchType.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchType.kt index 5df657449..235f8ce03 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchType.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchType.kt @@ -1,7 +1,22 @@ +/* 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 . */ + package com.keylesspalace.tusky.components.search enum class SearchType(val apiParameter: String) { Status("statuses"), Account("accounts"), Hashtag("hashtags") -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt index 97d250311..04ef8e966 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt @@ -1,30 +1,48 @@ +/* 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 . */ + package com.keylesspalace.tusky.components.search import android.util.Log -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.paging.PagedList +import androidx.lifecycle.viewModelScope +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.cachedIn import com.keylesspalace.tusky.components.compose.CAN_USE_QUOTE_ID -import com.keylesspalace.tusky.components.search.adapter.SearchNotestockRepository -import com.keylesspalace.tusky.components.search.adapter.SearchRepository +import com.keylesspalace.tusky.components.search.adapter.SearchNotestockPagingSourceFactory +import com.keylesspalace.tusky.components.search.adapter.SearchPagingSourceFactory import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountManager -import com.keylesspalace.tusky.entity.* +import com.keylesspalace.tusky.entity.DeletedStatus +import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.NotestockApi import com.keylesspalace.tusky.network.TimelineCases -import com.keylesspalace.tusky.util.* +import com.keylesspalace.tusky.util.RxAwareViewModel +import com.keylesspalace.tusky.util.toViewData import com.keylesspalace.tusky.viewdata.StatusViewData -import io.reactivex.Single -import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Single import javax.inject.Inject class SearchViewModel @Inject constructor( - mastodonApi: MastodonApi, - notestockApi: NotestockApi, - private val timelineCases: TimelineCases, - private val accountManager: AccountManager + mastodonApi: MastodonApi, + notestockApi: NotestockApi, + private val timelineCases: TimelineCases, + private val accountManager: AccountManager ) : RxAwareViewModel() { var currentQuery: String = "" @@ -40,217 +58,194 @@ class SearchViewModel @Inject constructor( val alwaysOpenSpoiler = activeAccount?.alwaysOpenSpoiler ?: false val quoteEnabled = activeAccount?.domain in CAN_USE_QUOTE_ID - private val statusesRepository = SearchRepository>(mastodonApi) - private val accountsRepository = SearchRepository(mastodonApi) - private val hashtagsRepository = SearchRepository(mastodonApi) - private val notestockRepository = SearchNotestockRepository(notestockApi) + private val loadedStatuses: MutableList> = mutableListOf() + private val loadedNotestockStatuses: MutableList> = mutableListOf() - private val repoResultStatus = MutableLiveData>>() - val statuses: LiveData>> = repoResultStatus.switchMap { it.pagedList } - val networkStateStatus: LiveData = repoResultStatus.switchMap { it.networkState } - val networkStateStatusRefresh: LiveData = repoResultStatus.switchMap { it.refreshState } + private val statusesPagingSourceFactory = SearchPagingSourceFactory(mastodonApi, SearchType.Status, loadedStatuses) { + it.statuses.map { status -> Pair(status, status.toViewData(alwaysShowSensitiveMedia, alwaysOpenSpoiler)) } + .apply { + loadedStatuses.addAll(this) + } + } + private val accountsPagingSourceFactory = SearchPagingSourceFactory(mastodonApi, SearchType.Account) { + it.accounts + } + private val hashtagsPagingSourceFactory = SearchPagingSourceFactory(mastodonApi, SearchType.Hashtag) { + it.hashtags + } + private val notestockStatusesPagingSourceFactory = SearchNotestockPagingSourceFactory(notestockApi) { + it.statuses.map { status -> Pair(status, status.toViewData(alwaysShowSensitiveMedia, alwaysOpenSpoiler)) } + .apply { + loadedNotestockStatuses.addAll(this) + } + } - private val repoResultAccount = MutableLiveData>() - val accounts: LiveData> = repoResultAccount.switchMap { it.pagedList } - val networkStateAccount: LiveData = repoResultAccount.switchMap { it.networkState } - val networkStateAccountRefresh: LiveData = repoResultAccount.switchMap { it.refreshState } + val statusesFlow = Pager( + config = PagingConfig(pageSize = DEFAULT_LOAD_SIZE, initialLoadSize = DEFAULT_LOAD_SIZE), + pagingSourceFactory = statusesPagingSourceFactory + ).flow + .cachedIn(viewModelScope) - private val repoResultHashTag = MutableLiveData>() - val hashtags: LiveData> = repoResultHashTag.switchMap { it.pagedList } - val networkStateHashTag: LiveData = repoResultHashTag.switchMap { it.networkState } - val networkStateHashTagRefresh: LiveData = repoResultHashTag.switchMap { it.refreshState } + val accountsFlow = Pager( + config = PagingConfig(pageSize = DEFAULT_LOAD_SIZE, initialLoadSize = DEFAULT_LOAD_SIZE), + pagingSourceFactory = accountsPagingSourceFactory + ).flow + .cachedIn(viewModelScope) - private val repoResultNotestock = MutableLiveData>>() - val notestockStatuses: LiveData>> = repoResultNotestock.switchMap { it.pagedList } - val networkStateNotestock: LiveData = repoResultNotestock.switchMap { it.networkState } - val networkStateNotestockRefresh: LiveData = repoResultNotestock.switchMap { it.networkState } + val hashtagsFlow = Pager( + config = PagingConfig(pageSize = DEFAULT_LOAD_SIZE, initialLoadSize = DEFAULT_LOAD_SIZE), + pagingSourceFactory = hashtagsPagingSourceFactory + ).flow + .cachedIn(viewModelScope) + + val notestockStatusesFlow = Pager( + config = PagingConfig(pageSize = DEFAULT_LOAD_SIZE, initialLoadSize = DEFAULT_LOAD_SIZE), + pagingSourceFactory = notestockStatusesPagingSourceFactory + ).flow + .cachedIn(viewModelScope) - private val loadedStatuses = ArrayList>() - private val loadedNotestockStatuses = ArrayList>() fun search(query: String) { loadedStatuses.clear() - repoResultStatus.value = statusesRepository.getSearchData(SearchType.Status, query, disposables, initialItems = loadedStatuses) { - it?.statuses?.map { status -> Pair(status, ViewDataUtils.statusToViewData(status, alwaysShowSensitiveMedia, alwaysOpenSpoiler)!!) } - .orEmpty() - .apply { - loadedStatuses.addAll(this) - } - } - repoResultAccount.value = accountsRepository.getSearchData(SearchType.Account, query, disposables) { - it?.accounts.orEmpty() - } - val hashtagQuery = if (query.startsWith("#")) query else "#$query" - repoResultHashTag.value = - hashtagsRepository.getSearchData(SearchType.Hashtag, hashtagQuery, disposables) { - it?.hashtags.orEmpty() - } - loadedNotestockStatuses.clear() - repoResultNotestock.value = notestockRepository.getSearchData(query, disposables) { - (it?.statuses?.map { status -> Pair(status, ViewDataUtils.statusToViewData(status, alwaysShowSensitiveMedia, alwaysOpenSpoiler)!!) } - ?: emptyList()) - .apply { - loadedNotestockStatuses.addAll(this) - } - } - + statusesPagingSourceFactory.newSearch(query) + accountsPagingSourceFactory.newSearch(query) + hashtagsPagingSourceFactory.newSearch(query) + notestockStatusesPagingSourceFactory.newSearch(query) } fun removeItem(status: Pair) { timelineCases.delete(status.first.id) - .subscribe({ + .subscribe( + { if (loadedStatuses.remove(status)) - repoResultStatus.value?.refresh?.invoke() - }, { - err -> Log.d(TAG, "Failed to delete status", err) - }) - .autoDispose() - + statusesPagingSourceFactory.invalidate() + }, + { + err -> + Log.d(TAG, "Failed to delete status", err) + } + ) + .autoDispose() } fun removeNotestockItem(status: Pair) { if (loadedNotestockStatuses.remove(status)) - repoResultNotestock.value?.refresh?.invoke() + notestockStatusesPagingSourceFactory.invalidate() } fun expandedChange(status: Pair, expanded: Boolean) { val idx = loadedStatuses.indexOf(status) if (idx >= 0) { - val newPair = Pair(status.first, StatusViewData.Builder(status.second).setIsExpanded(expanded).createStatusViewData()) - loadedStatuses[idx] = newPair - repoResultStatus.value?.refresh?.invoke() + loadedStatuses[idx] = Pair(status.first, status.second.copy(isExpanded = expanded)) + statusesPagingSourceFactory.invalidate() } } fun expandedNotestockChange(status: Pair, expanded: Boolean) { val idx = loadedNotestockStatuses.indexOf(status) if (idx >= 0) { - val newPair = Pair(status.first, StatusViewData.Builder(status.second).setIsExpanded(expanded).createStatusViewData()) - loadedNotestockStatuses[idx] = newPair - repoResultNotestock.value?.refresh?.invoke() + loadedNotestockStatuses[idx] = Pair(status.first, status.second.copy(isExpanded = expanded)) + notestockStatusesPagingSourceFactory.invalidate() } } fun reblog(status: Pair, reblog: Boolean) { - timelineCases.reblog(status.first, reblog) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - { setRebloggedForStatus(status, reblog) }, - { err -> Log.d(TAG, "Failed to reblog status ${status.first.id}", err) } - ) - .autoDispose() + timelineCases.reblog(status.first.id, reblog) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { setRebloggedForStatus(status, reblog) }, + { t -> Log.d(TAG, "Failed to reblog status ${status.first.id}", t) } + ) + .autoDispose() } private fun setRebloggedForStatus(status: Pair, reblog: Boolean) { status.first.reblogged = reblog status.first.reblog?.reblogged = reblog - - val idx = loadedStatuses.indexOf(status) - if (idx >= 0) { - val newPair = Pair(status.first, StatusViewData.Builder(status.second).setReblogged(reblog).createStatusViewData()) - loadedStatuses[idx] = newPair - repoResultStatus.value?.refresh?.invoke() - } + statusesPagingSourceFactory.invalidate() } fun contentHiddenChange(status: Pair, isShowing: Boolean) { val idx = loadedStatuses.indexOf(status) if (idx >= 0) { - val newPair = Pair(status.first, StatusViewData.Builder(status.second).setIsShowingSensitiveContent(isShowing).createStatusViewData()) - loadedStatuses[idx] = newPair - repoResultStatus.value?.refresh?.invoke() + loadedStatuses[idx] = Pair(status.first, status.second.copy(isShowingContent = isShowing)) + statusesPagingSourceFactory.invalidate() } } fun contentHiddenNotestockChange(status: Pair, isShowing: Boolean) { val idx = loadedNotestockStatuses.indexOf(status) if (idx >= 0) { - val newPair = Pair(status.first, StatusViewData.Builder(status.second).setIsShowingSensitiveContent(isShowing).createStatusViewData()) - loadedNotestockStatuses[idx] = newPair - repoResultNotestock.value?.refresh?.invoke() + loadedNotestockStatuses[idx] = Pair(status.first, status.second.copy(isShowingContent = isShowing)) + notestockStatusesPagingSourceFactory.invalidate() } } fun collapsedChange(status: Pair, collapsed: Boolean) { val idx = loadedStatuses.indexOf(status) if (idx >= 0) { - val newPair = Pair(status.first, StatusViewData.Builder(status.second).setCollapsed(collapsed).createStatusViewData()) - loadedStatuses[idx] = newPair - repoResultStatus.value?.refresh?.invoke() + loadedStatuses[idx] = Pair(status.first, status.second.copy(isCollapsed = collapsed)) + statusesPagingSourceFactory.invalidate() } } fun collapsedNotestockChange(status: Pair, collapsed: Boolean) { val idx = loadedNotestockStatuses.indexOf(status) if (idx >= 0) { - val newPair = Pair(status.first, StatusViewData.Builder(status.second).setCollapsed(collapsed).createStatusViewData()) - loadedNotestockStatuses[idx] = newPair - repoResultNotestock.value?.refresh?.invoke() + loadedNotestockStatuses[idx] = Pair(status.first, status.second.copy(isCollapsed = collapsed)) + notestockStatusesPagingSourceFactory.invalidate() } } fun voteInPoll(status: Pair, choices: MutableList) { val votedPoll = status.first.actionableStatus.poll!!.votedCopy(choices) updateStatus(status, votedPoll) - timelineCases.voteInPoll(status.first, choices) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - { newPoll -> updateStatus(status, newPoll) }, - { t -> - Log.d(TAG, - "Failed to vote in poll: ${status.first.id}", t) - } - ) - .autoDispose() + timelineCases.voteInPoll(status.first.id, votedPoll.id, choices) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { newPoll -> updateStatus(status, newPoll) }, + { t -> Log.d(TAG, "Failed to vote in poll: ${status.first.id}", t) } + ) + .autoDispose() } private fun updateStatus(status: Pair, newPoll: Poll) { val idx = loadedStatuses.indexOf(status) if (idx >= 0) { - - val newViewData = StatusViewData.Builder(status.second) - .setPoll(newPoll) - .createStatusViewData() - loadedStatuses[idx] = Pair(status.first, newViewData) - repoResultStatus.value?.refresh?.invoke() + val newStatus = status.first.copy(poll = newPoll) + val newViewData = status.second.copy(status = newStatus) + loadedStatuses[idx] = Pair(newStatus, newViewData) + statusesPagingSourceFactory.invalidate() } } fun favorite(status: Pair, isFavorited: Boolean) { - val idx = loadedStatuses.indexOf(status) - if (idx >= 0) { - val newPair = Pair(status.first, StatusViewData.Builder(status.second).setFavourited(isFavorited).createStatusViewData()) - loadedStatuses[idx] = newPair - repoResultStatus.value?.refresh?.invoke() - } - timelineCases.favourite(status.first, isFavorited) - .onErrorReturnItem(status.first) - .subscribe() - .autoDispose() + status.first.favourited = isFavorited + statusesPagingSourceFactory.invalidate() + timelineCases.favourite(status.first.id, isFavorited) + .onErrorReturnItem(status.first) + .subscribe() + .autoDispose() } fun bookmark(status: Pair, isBookmarked: Boolean) { - val idx = loadedStatuses.indexOf(status) - if (idx >= 0) { - val newPair = Pair(status.first, StatusViewData.Builder(status.second).setBookmarked(isBookmarked).createStatusViewData()) - loadedStatuses[idx] = newPair - repoResultStatus.value?.refresh?.invoke() - } - timelineCases.bookmark(status.first, isBookmarked) - .onErrorReturnItem(status.first) - .subscribe() - .autoDispose() + status.first.bookmarked = isBookmarked + statusesPagingSourceFactory.invalidate() + timelineCases.bookmark(status.first.id, isBookmarked) + .onErrorReturnItem(status.first) + .subscribe() + .autoDispose() } fun getAllAccountsOrderedByActive(): List { return accountManager.getAllAccountsOrderedByActive() } - fun muteAccount(accountId: String, notifications: Boolean, duration: Int) { + fun muteAccount(accountId: String, notifications: Boolean, duration: Int?) { timelineCases.mute(accountId, notifications, duration) } fun pinAccount(status: Status, isPin: Boolean) { - timelineCases.pin(status, isPin) + timelineCases.pin(status.id, isPin) } fun blockAccount(accountId: String) { @@ -261,24 +256,25 @@ class SearchViewModel @Inject constructor( return timelineCases.delete(id) } - fun retryAllSearches() { - search(currentQuery) - } - fun muteConversation(status: Pair, mute: Boolean) { val idx = loadedStatuses.indexOf(status) if (idx >= 0) { - val newPair = Pair(status.first, StatusViewData.Builder(status.second).setMuted(mute).createStatusViewData()) + val newStatus = status.first.copy(muted = mute) + val newPair = Pair( + newStatus, + status.second.copy(status = newStatus) + ) loadedStatuses[idx] = newPair - repoResultStatus.value?.refresh?.invoke() + statusesPagingSourceFactory.invalidate() } - timelineCases.muteConversation(status.first, mute) - .onErrorReturnItem(status.first) - .subscribe() - .autoDispose() + timelineCases.muteConversation(status.first.id, mute) + .onErrorReturnItem(status.first) + .subscribe() + .autoDispose() } companion object { private const val TAG = "SearchViewModel" + private const val DEFAULT_LOAD_SIZE = 20 } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchAccountsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchAccountsAdapter.kt index b6bc95681..71d582680 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchAccountsAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchAccountsAdapter.kt @@ -1,4 +1,4 @@ -/* Copyright 2019 Joel Pyska +/* Copyright 2021 Tusky Contributors * * This file is a part of Tusky. * @@ -17,26 +17,25 @@ package com.keylesspalace.tusky.components.search.adapter import android.view.LayoutInflater import android.view.ViewGroup -import androidx.paging.PagedListAdapter +import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R import com.keylesspalace.tusky.adapter.AccountViewHolder import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.interfaces.LinkListener -class SearchAccountsAdapter(private val linkListener: LinkListener, private val animateAvatars: Boolean, private val animateEmojis: Boolean) - : PagedListAdapter(ACCOUNT_COMPARATOR) { +class SearchAccountsAdapter(private val linkListener: LinkListener, private val animateAvatars: Boolean, private val animateEmojis: Boolean) : + PagingDataAdapter(ACCOUNT_COMPARATOR) { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AccountViewHolder { val view = LayoutInflater.from(parent.context) - .inflate(R.layout.item_account, parent, false) + .inflate(R.layout.item_account, parent, false) return AccountViewHolder(view) } - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + override fun onBindViewHolder(holder: AccountViewHolder, position: Int) { getItem(position)?.let { item -> - (holder as AccountViewHolder).apply { + holder.apply { setupWithAccount(item, animateAvatars, animateEmojis) setupLinkListener(linkListener) } @@ -47,12 +46,10 @@ class SearchAccountsAdapter(private val linkListener: LinkListener, private val val ACCOUNT_COMPARATOR = object : DiffUtil.ItemCallback() { override fun areContentsTheSame(oldItem: Account, newItem: Account): Boolean = - oldItem.deepEquals(newItem) + oldItem.deepEquals(newItem) override fun areItemsTheSame(oldItem: Account, newItem: Account): Boolean = - oldItem.id == newItem.id + oldItem.id == newItem.id } - } - -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchDataSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchDataSource.kt deleted file mode 100644 index 2b706288e..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchDataSource.kt +++ /dev/null @@ -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 . */ - -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.disposables.CompositeDisposable -import io.reactivex.rxkotlin.addTo -import java.util.concurrent.Executor - -class SearchDataSource( - private val mastodonApi: MastodonApi, - private val searchType: SearchType, - private val searchRequest: String, - private val disposables: CompositeDisposable, - private val retryExecutor: Executor, - private val initialItems: List? = null, - private val parser: (SearchResult?) -> List, - private val source: SearchDataSourceFactory) : PositionalDataSource() { - - val networkState = MutableLiveData() - - private var retry: (() -> Any)? = null - - val initialLoad = MutableLiveData() - - fun retry() { - retry?.let { - retryExecutor.execute { - it.invoke() - } - } - } - - override fun loadInitial(params: LoadInitialParams, callback: LoadInitialCallback) { - 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) { - 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) - - - } -} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchHashtagsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchHashtagsAdapter.kt index ebc021602..50bd0f933 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchHashtagsAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchHashtagsAdapter.kt @@ -1,4 +1,4 @@ -/* Copyright 2019 Joel Pyska +/* Copyright 2021 Tusky Contributors * * This file is a part of Tusky. * @@ -17,15 +17,15 @@ package com.keylesspalace.tusky.components.search.adapter import android.view.LayoutInflater import android.view.ViewGroup -import androidx.paging.PagedListAdapter +import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil import com.keylesspalace.tusky.databinding.ItemHashtagBinding import com.keylesspalace.tusky.entity.HashTag import com.keylesspalace.tusky.interfaces.LinkListener import com.keylesspalace.tusky.util.BindingHolder -class SearchHashtagsAdapter(private val linkListener: LinkListener) - : PagedListAdapter>(HASHTAG_COMPARATOR) { +class SearchHashtagsAdapter(private val linkListener: LinkListener) : + PagingDataAdapter>(HASHTAG_COMPARATOR) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder { val binding = ItemHashtagBinding.inflate(LayoutInflater.from(parent.context), parent, false) @@ -43,12 +43,10 @@ class SearchHashtagsAdapter(private val linkListener: LinkListener) val HASHTAG_COMPARATOR = object : DiffUtil.ItemCallback() { override fun areContentsTheSame(oldItem: HashTag, newItem: HashTag): Boolean = - oldItem.name == newItem.name + oldItem.name == newItem.name override fun areItemsTheSame(oldItem: HashTag, newItem: HashTag): Boolean = - oldItem.name == newItem.name + oldItem.name == newItem.name } - } - -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchNotestockDataSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchNotestockDataSource.kt deleted file mode 100644 index 5a7919245..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchNotestockDataSource.kt +++ /dev/null @@ -1,97 +0,0 @@ -package com.keylesspalace.tusky.components.search.adapter - -import androidx.lifecycle.MutableLiveData -import androidx.paging.PositionalDataSource -import com.keylesspalace.tusky.entity.SearchResult -import com.keylesspalace.tusky.entity.Status -import com.keylesspalace.tusky.network.NotestockApi -import com.keylesspalace.tusky.util.NetworkState -import com.keylesspalace.tusky.viewdata.StatusViewData -import io.reactivex.disposables.CompositeDisposable -import io.reactivex.rxkotlin.addTo -import java.util.concurrent.Executor - -class SearchNotestockDataSource( - private val notestockApi: NotestockApi, - private val searchRequest: String, - private val disposables: CompositeDisposable, - private val retryExecutor: Executor, - private val initialItems: List>? = null, - private val parser: (SearchResult?) -> List>, - private val source: SearchNotestockDataSourceFactory) : PositionalDataSource>() { - - val networkState = MutableLiveData() - - private var retry: (() -> Any)? = null - - val initialLoad = MutableLiveData() - - fun retry() { - retry?.let { - retryExecutor.execute { - it.invoke() - } - } - } - - override fun loadInitial(params: LoadInitialParams, callback: LoadInitialCallback>) { - if (!initialItems.isNullOrEmpty()) { - callback.onResult(initialItems.toList(), 0) - } else { - networkState.postValue(NetworkState.LOADED) - retry = null - initialLoad.postValue(NetworkState.LOADING) - notestockApi.searchObservable( - q = searchRequest) - .subscribe( - { data -> - val res = parser(data) - callback.onResult(res, params.requestedStartPosition) - initialLoad.postValue(NetworkState.LOADED) - try { - source.lastDt = data.statuses.last().createdAt - } catch (e: NoSuchElementException) { - } - }, - { error -> - retry = { - loadInitial(params, callback) - } - initialLoad.postValue(NetworkState.error(error.message)) - } - ).addTo(disposables) - } - - } - - override fun loadRange(params: LoadRangeParams, callback: LoadRangeCallback>) { - networkState.postValue(NetworkState.LOADING) - retry = null - if (source.exhausted || source.lastDt === null) { - return callback.onResult(emptyList()) - } - notestockApi.searchObservable( - q = searchRequest, - maxDt = source.iso8601.format(source.lastDt!!)) - .subscribe( - { data -> - val res = parser(data) - if (res.isEmpty()) { - source.exhausted = true - } - callback.onResult(res) - networkState.postValue(NetworkState.LOADED) - try { - source.lastDt = data.statuses.last().createdAt - } catch (e: NoSuchElementException) { - } - }, - { error -> - retry = { - loadRange(params, callback) - } - networkState.postValue(NetworkState.error(error.message)) - } - ).addTo(disposables) - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchNotestockDataSourceFactory.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchNotestockDataSourceFactory.kt deleted file mode 100644 index 62a89b14c..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchNotestockDataSourceFactory.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.keylesspalace.tusky.components.search.adapter - -import androidx.lifecycle.MutableLiveData -import androidx.paging.DataSource -import com.keylesspalace.tusky.entity.SearchResult -import com.keylesspalace.tusky.entity.Status -import com.keylesspalace.tusky.network.NotestockApi -import com.keylesspalace.tusky.viewdata.StatusViewData -import io.reactivex.disposables.CompositeDisposable -import okhttp3.internal.UTC -import java.util.* -import java.util.concurrent.Executor - -class SearchNotestockDataSourceFactory( - private val notestockApi: NotestockApi, - private val searchRequest: String, - private val disposables: CompositeDisposable, - private val retryExecutor: Executor, - private val cacheData: List>? = null, - private val parser: (SearchResult?) -> List>) : DataSource.Factory>() { - - val sourceLiveData = MutableLiveData() - - var lastDt: Date? = null - var exhausted = false - - val iso8601 = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.getDefault()) - - override fun create(): DataSource> { - iso8601.timeZone = UTC - val source = SearchNotestockDataSource(notestockApi, searchRequest, disposables, retryExecutor, cacheData, parser, this) - sourceLiveData.postValue(source) - return source - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchNotestockPagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchNotestockPagingSource.kt new file mode 100644 index 000000000..d2d7491f9 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchNotestockPagingSource.kt @@ -0,0 +1,62 @@ +package com.keylesspalace.tusky.components.search.adapter + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.keylesspalace.tusky.entity.SearchResult +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.network.NotestockApi +import com.keylesspalace.tusky.viewdata.StatusViewData +import kotlinx.coroutines.rx3.await +import okhttp3.internal.UTC +import java.util.* + +class SearchNotestockPagingSource( + private val notestockApi: NotestockApi, + private val searchRequest: String, + private val initialItems: List>? = null, + private val parser: (SearchResult) -> List> +) : PagingSource>() { + + @Suppress("SpellCheckingInspection") + private val iso8601 = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.getDefault()) + .apply { timeZone = UTC } + + override fun getRefreshKey(state: PagingState>): Date? { + return null + } + + override suspend fun load(params: LoadParams): LoadResult> { + 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.last().first.createdAt + ) + } + + try { + val data = notestockApi.searchObservable( + q = searchRequest, + maxDt = params.key?.let { iso8601.format(it) }, + ).await() + + val res = parser(data) + + return LoadResult.Page( + data = res, + prevKey = null, + nextKey = res.last().first.createdAt, + ) + } catch (e: Exception) { + return LoadResult.Error(e) + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchNotestockPagingSourceFactory.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchNotestockPagingSourceFactory.kt new file mode 100644 index 000000000..c2b21de9a --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchNotestockPagingSourceFactory.kt @@ -0,0 +1,37 @@ +package com.keylesspalace.tusky.components.search.adapter + +import com.keylesspalace.tusky.entity.SearchResult +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.network.NotestockApi +import com.keylesspalace.tusky.viewdata.StatusViewData + +class SearchNotestockPagingSourceFactory( + private val notestockApi: NotestockApi, + private val initialItems: List>? = null, + private val parser: (SearchResult) -> List> +) : () -> SearchNotestockPagingSource { + + private var searchRequest: String = "" + + private var currentSource: SearchNotestockPagingSource? = null + + override fun invoke(): SearchNotestockPagingSource { + return SearchNotestockPagingSource( + notestockApi = notestockApi, + searchRequest = searchRequest, + initialItems = initialItems, + parser = parser, + ).also { source -> + currentSource = source + } + } + + fun newSearch(newSearchRequest: String) { + this.searchRequest = newSearchRequest + currentSource?.invalidate() + } + + fun invalidate() { + currentSource?.invalidate() + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchNotestockRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchNotestockRepository.kt deleted file mode 100644 index 8b05e4bd7..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchNotestockRepository.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.keylesspalace.tusky.components.search.adapter - -import androidx.lifecycle.Transformations -import androidx.paging.Config -import androidx.paging.toLiveData -import com.keylesspalace.tusky.entity.SearchResult -import com.keylesspalace.tusky.entity.Status -import com.keylesspalace.tusky.network.NotestockApi -import com.keylesspalace.tusky.util.Listing -import com.keylesspalace.tusky.viewdata.StatusViewData -import io.reactivex.disposables.CompositeDisposable -import java.util.concurrent.Executors - -class SearchNotestockRepository(private val notestockApi: NotestockApi) { - - private val executor = Executors.newSingleThreadExecutor() - - fun getSearchData(searchRequest: String, disposables: CompositeDisposable, pageSize: Int = 20, - initialItems: List>? = null, - parser: (SearchResult?) -> List>): Listing> { - val sourceFactory = SearchNotestockDataSourceFactory(notestockApi, 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 - } - - ) - } - -} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchPagerAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchPagerAdapter.kt index d1518dd2d..0c0e17ccc 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchPagerAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchPagerAdapter.kt @@ -37,4 +37,4 @@ class SearchPagerAdapter(activity: FragmentActivity) : FragmentStateAdapter(acti override fun getItemCount() = 4 -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchPagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchPagingSource.kt new file mode 100644 index 000000000..5ced44037 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchPagingSource.kt @@ -0,0 +1,84 @@ +/* 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 . */ + +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( + private val mastodonApi: MastodonApi, + private val searchType: SearchType, + private val searchRequest: String, + private val initialItems: List?, + private val parser: (SearchResult) -> List +) : PagingSource() { + + override fun getRefreshKey(state: PagingState): Int? { + return null + } + + override suspend fun load(params: LoadParams): LoadResult { + 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) + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchDataSourceFactory.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchPagingSourceFactory.kt similarity index 50% rename from app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchDataSourceFactory.kt rename to app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchPagingSourceFactory.kt index b47da7017..f995d029b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchDataSourceFactory.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchPagingSourceFactory.kt @@ -1,4 +1,4 @@ -/* Copyright 2019 Joel Pyska +/* Copyright 2021 Tusky Contributors * * This file is a part of Tusky. * @@ -15,30 +15,39 @@ package com.keylesspalace.tusky.components.search.adapter -import androidx.lifecycle.MutableLiveData -import androidx.paging.DataSource import com.keylesspalace.tusky.components.search.SearchType import com.keylesspalace.tusky.entity.SearchResult import com.keylesspalace.tusky.network.MastodonApi -import io.reactivex.disposables.CompositeDisposable -import java.util.concurrent.Executor -class SearchDataSourceFactory( - private val mastodonApi: MastodonApi, - private val searchType: SearchType, - private val searchRequest: String, - private val disposables: CompositeDisposable, - private val retryExecutor: Executor, - private val cacheData: List? = null, - private val parser: (SearchResult?) -> List) : DataSource.Factory() { +class SearchPagingSourceFactory( + private val mastodonApi: MastodonApi, + private val searchType: SearchType, + private val initialItems: List? = null, + private val parser: (SearchResult) -> List +) : () -> SearchPagingSource { - val sourceLiveData = MutableLiveData>() + private var searchRequest: String = "" - var exhausted = false + private var currentSource: SearchPagingSource? = null - override fun create(): DataSource { - val source = SearchDataSource(mastodonApi, searchType, searchRequest, disposables, retryExecutor, cacheData, parser, this) - sourceLiveData.postValue(source) - return source + override fun invoke(): SearchPagingSource { + return SearchPagingSource( + mastodonApi = mastodonApi, + searchType = searchType, + searchRequest = searchRequest, + initialItems = initialItems, + parser = parser + ).also { source -> + currentSource = source + } } -} \ No newline at end of file + + fun newSearch(newSearchRequest: String) { + this.searchRequest = newSearchRequest + currentSource?.invalidate() + } + + fun invalidate() { + currentSource?.invalidate() + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchRepository.kt deleted file mode 100644 index 28d9564dc..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchRepository.kt +++ /dev/null @@ -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 . */ - -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.disposables.CompositeDisposable -import java.util.concurrent.Executors - -class SearchRepository(private val mastodonApi: MastodonApi) { - - private val executor = Executors.newSingleThreadExecutor() - - fun getSearchData(searchType: SearchType, searchRequest: String, disposables: CompositeDisposable, pageSize: Int = 20, - initialItems: List? = null, parser: (SearchResult?) -> List): Listing { - 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 - } - - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchStatusesAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchStatusesAdapter.kt index 0fcee37d1..d1ad35864 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchStatusesAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchStatusesAdapter.kt @@ -1,4 +1,4 @@ -/* Copyright 2019 Joel Pyska +/* Copyright 2021 Tusky Contributors * * This file is a part of Tusky. * @@ -17,9 +17,8 @@ package com.keylesspalace.tusky.components.search.adapter import android.view.LayoutInflater import android.view.ViewGroup -import androidx.paging.PagedListAdapter +import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R import com.keylesspalace.tusky.adapter.StatusViewHolder import com.keylesspalace.tusky.entity.Status @@ -28,36 +27,34 @@ import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.viewdata.StatusViewData class SearchStatusesAdapter( - private val statusDisplayOptions: StatusDisplayOptions, - private val statusListener: StatusActionListener -) : PagedListAdapter, RecyclerView.ViewHolder>(STATUS_COMPARATOR) { + private val statusDisplayOptions: StatusDisplayOptions, + private val statusListener: StatusActionListener +) : PagingDataAdapter, StatusViewHolder>(STATUS_COMPARATOR) { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StatusViewHolder { val view = LayoutInflater.from(parent.context) - .inflate(R.layout.item_status, parent, false) + .inflate(R.layout.item_status, parent, false) return StatusViewHolder(view) } - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + override fun onBindViewHolder(holder: StatusViewHolder, position: Int) { getItem(position)?.let { item -> - (holder as StatusViewHolder).setupWithStatus(item.second, statusListener, statusDisplayOptions) + holder.setupWithStatus(item.second, statusListener, statusDisplayOptions) } } - public override fun getItem(position: Int): Pair? { - return super.getItem(position) + fun item(position: Int): Pair? { + return getItem(position) } companion object { val STATUS_COMPARATOR = object : DiffUtil.ItemCallback>() { override fun areContentsTheSame(oldItem: Pair, newItem: Pair): Boolean = - oldItem.second.deepEquals(newItem.second) + oldItem == newItem override fun areItemsTheSame(oldItem: Pair, newItem: Pair): Boolean = - oldItem.second.id == newItem.second.id + oldItem.second.id == newItem.second.id } - } - -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchAccountsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchAccountsFragment.kt index 8715e1ab2..d5e2a7aba 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchAccountsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchAccountsFragment.kt @@ -1,4 +1,4 @@ -/* Copyright 2019 Joel Pyska +/* Copyright 2021 Tusky Contributors * * This file is a part of Tusky. * @@ -15,32 +15,27 @@ package com.keylesspalace.tusky.components.search.fragments -import androidx.lifecycle.LiveData -import androidx.paging.PagedList -import androidx.paging.PagedListAdapter +import androidx.paging.PagingData +import androidx.paging.PagingDataAdapter import androidx.preference.PreferenceManager import com.keylesspalace.tusky.components.search.adapter.SearchAccountsAdapter import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.settings.PrefKeys -import com.keylesspalace.tusky.util.NetworkState +import kotlinx.coroutines.flow.Flow class SearchAccountsFragment : SearchFragment() { - override fun createAdapter(): PagedListAdapter { + override fun createAdapter(): PagingDataAdapter { val preferences = PreferenceManager.getDefaultSharedPreferences(binding.searchRecyclerView.context) return SearchAccountsAdapter( - this, - preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), - preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) + this, + preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), + preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) ) } - override val networkStateRefresh: LiveData - get() = viewModel.networkStateAccountRefresh - override val networkState: LiveData - get() = viewModel.networkStateAccount - override val data: LiveData> - get() = viewModel.accounts + override val data: Flow> + get() = viewModel.accountsFlow companion object { fun newInstance() = SearchAccountsFragment() diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchFragment.kt index cf4ea70c3..3a97aebb9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchFragment.kt @@ -4,9 +4,10 @@ import android.os.Bundle import android.view.View import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels -import androidx.lifecycle.LiveData -import androidx.paging.PagedList -import androidx.paging.PagedListAdapter +import androidx.lifecycle.lifecycleScope +import androidx.paging.LoadState +import androidx.paging.PagingData +import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.SimpleItemAnimator @@ -21,11 +22,18 @@ import com.keylesspalace.tusky.databinding.FragmentSearchBinding import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.interfaces.LinkListener -import com.keylesspalace.tusky.util.* +import com.keylesspalace.tusky.util.viewBinding +import com.keylesspalace.tusky.util.visible +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch import javax.inject.Inject -abstract class SearchFragment : Fragment(R.layout.fragment_search), - LinkListener, Injectable, SwipeRefreshLayout.OnRefreshListener { +abstract class SearchFragment : + Fragment(R.layout.fragment_search), + LinkListener, + Injectable, + SwipeRefreshLayout.OnRefreshListener { @Inject lateinit var viewModelFactory: ViewModelFactory @@ -36,12 +44,12 @@ abstract class SearchFragment : Fragment(R.layout.fragment_search), private var snackbarErrorRetry: Snackbar? = null - abstract fun createAdapter(): PagedListAdapter + abstract fun createAdapter(): PagingDataAdapter - abstract val networkStateRefresh: LiveData - abstract val networkState: LiveData - abstract val data: LiveData> - protected lateinit var adapter: PagedListAdapter + abstract val data: Flow> + protected lateinit var adapter: PagingDataAdapter + + private var currentQuery: String = "" override fun onViewCreated(view: View, savedInstanceState: Bundle?) { initAdapter() @@ -55,32 +63,32 @@ abstract class SearchFragment : Fragment(R.layout.fragment_search), } private fun subscribeObservables() { - data.observe(viewLifecycleOwner) { - adapter.submitList(it) - } - - networkStateRefresh.observe(viewLifecycleOwner) { - - binding.searchProgressBar.visible(it == NetworkState.LOADING) - - if (it.status == Status.FAILED) { - showError() - } - checkNoData() - } - - networkState.observe(viewLifecycleOwner) { - - binding.progressBarBottom.visible(it == NetworkState.LOADING) - - if (it.status == Status.FAILED) { - showError() + viewLifecycleOwner.lifecycleScope.launch { + data.collectLatest { pagingData -> + adapter.submitData(pagingData) } } - } - private fun checkNoData() { - showNoData(adapter.itemCount == 0) + adapter.addLoadStateListener { loadState -> + + if (loadState.refresh is LoadState.Error) { + showError() + } + + val isNewSearch = currentQuery != viewModel.currentQuery + + binding.searchProgressBar.visible(loadState.refresh == LoadState.Loading && isNewSearch && !binding.swipeRefreshLayout.isRefreshing) + binding.searchRecyclerView.visible(loadState.refresh is LoadState.NotLoading || !isNewSearch || binding.swipeRefreshLayout.isRefreshing) + + if (loadState.refresh != LoadState.Loading) { + binding.swipeRefreshLayout.isRefreshing = false + currentQuery = viewModel.currentQuery + } + + binding.progressBarBottom.visible(loadState.append == LoadState.Loading) + + binding.searchNoResultsText.visible(loadState.refresh is LoadState.NotLoading && adapter.itemCount == 0 && viewModel.currentQuery.isNotEmpty()) + } } private fun initAdapter() { @@ -92,20 +100,12 @@ abstract class SearchFragment : Fragment(R.layout.fragment_search), (binding.searchRecyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false } - private fun showNoData(isEmpty: Boolean) { - if (isEmpty && networkStateRefresh.value == NetworkState.LOADED) { - binding.searchNoResultsText.show() - } else { - binding.searchNoResultsText.hide() - } - } - private fun showError() { if (snackbarErrorRetry?.isShown != true) { snackbarErrorRetry = Snackbar.make(binding.root, R.string.failed_search, Snackbar.LENGTH_INDEFINITE) snackbarErrorRetry?.setAction(R.string.action_retry) { snackbarErrorRetry = null - viewModel.retryAllSearches() + adapter.retry() } snackbarErrorRetry?.show() } @@ -123,11 +123,6 @@ abstract class SearchFragment : Fragment(R.layout.fragment_search), get() = (activity as? BottomSheetActivity) override fun onRefresh() { - - // Dismissed here because the RecyclerView bottomProgressBar is shown as soon as the retry begins. - binding.swipeRefreshLayout.post { - binding.swipeRefreshLayout.isRefreshing = false - } - viewModel.retryAllSearches() + adapter.refresh() } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchHashtagsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchHashtagsFragment.kt index 15310d3c1..d0b7e8fa9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchHashtagsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchHashtagsFragment.kt @@ -1,4 +1,4 @@ -/* Copyright 2019 Joel Pyska +/* Copyright 2021 Tusky Contributors * * This file is a part of Tusky. * @@ -15,22 +15,18 @@ package com.keylesspalace.tusky.components.search.fragments -import androidx.lifecycle.LiveData -import androidx.paging.PagedList -import androidx.paging.PagedListAdapter +import androidx.paging.PagingData +import androidx.paging.PagingDataAdapter import com.keylesspalace.tusky.components.search.adapter.SearchHashtagsAdapter import com.keylesspalace.tusky.entity.HashTag -import com.keylesspalace.tusky.util.NetworkState +import kotlinx.coroutines.flow.Flow class SearchHashtagsFragment : SearchFragment() { - override val networkStateRefresh: LiveData - get() = viewModel.networkStateHashTagRefresh - override val networkState: LiveData - get() = viewModel.networkStateHashTag - override val data: LiveData> - get() = viewModel.hashtags - override fun createAdapter(): PagedListAdapter = SearchHashtagsAdapter(this) + override val data: Flow> + get() = viewModel.hashtagsFlow + + override fun createAdapter(): PagingDataAdapter = SearchHashtagsAdapter(this) companion object { fun newInstance() = SearchHashtagsFragment() diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchNotestockFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchNotestockFragment.kt index d6667bee0..c82421685 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchNotestockFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchNotestockFragment.kt @@ -17,12 +17,12 @@ import androidx.appcompat.widget.PopupMenu import androidx.core.app.ActivityOptionsCompat import androidx.core.view.ViewCompat import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LiveData -import androidx.paging.PagedList -import androidx.paging.PagedListAdapter +import androidx.paging.PagingData +import androidx.paging.PagingDataAdapter import androidx.preference.PreferenceManager import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager +import autodispose2.androidx.lifecycle.autoDispose import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.MainActivity import com.keylesspalace.tusky.R @@ -39,40 +39,34 @@ import com.keylesspalace.tusky.interfaces.AccountSelectionListener import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.util.CardViewMode -import com.keylesspalace.tusky.util.NetworkState import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.view.showMuteAccountDialog import com.keylesspalace.tusky.viewdata.AttachmentViewData import com.keylesspalace.tusky.viewdata.StatusViewData -import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from -import com.uber.autodispose.autoDispose -import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import kotlinx.coroutines.flow.Flow class SearchNotestockFragment : SearchFragment>(), StatusActionListener { - override val networkStateRefresh: LiveData - get() = viewModel.networkStateNotestockRefresh - override val networkState: LiveData - get() = viewModel.networkStateNotestock - override val data: LiveData>> - get() = viewModel.notestockStatuses + override val data: Flow>> + get() = viewModel.notestockStatusesFlow private val searchAdapter get() = super.adapter as SearchStatusesAdapter - override fun createAdapter(): PagedListAdapter, *> { + override fun createAdapter(): PagingDataAdapter, *> { val preferences = PreferenceManager.getDefaultSharedPreferences(binding.searchRecyclerView.context) val statusDisplayOptions = StatusDisplayOptions( - animateAvatars = preferences.getBoolean("animateGifAvatars", false), - mediaPreviewEnabled = viewModel.mediaPreviewEnabled, - useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false), - showBotOverlay = preferences.getBoolean("showBotOverlay", true), - useBlurhash = preferences.getBoolean("useBlurhash", true), - cardViewMode = CardViewMode.NONE, - confirmReblogs = preferences.getBoolean("confirmReblogs", false), - hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), - animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), - quoteEnabled = viewModel.quoteEnabled + animateAvatars = preferences.getBoolean("animateGifAvatars", false), + mediaPreviewEnabled = viewModel.mediaPreviewEnabled, + useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false), + showBotOverlay = preferences.getBoolean("showBotOverlay", true), + useBlurhash = preferences.getBoolean("useBlurhash", true), + cardViewMode = CardViewMode.NONE, + confirmReblogs = preferences.getBoolean("confirmReblogs", false), + hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), + animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), + quoteEnabled = viewModel.quoteEnabled ) binding.searchRecyclerView.addItemDecoration(DividerItemDecoration(binding.searchRecyclerView.context, DividerItemDecoration.VERTICAL)) @@ -82,13 +76,13 @@ class SearchNotestockFragment : SearchFragment + searchAdapter.item(position)?.first?.let { status -> reply(status) } } @@ -98,35 +92,39 @@ class SearchNotestockFragment : SearchFragment + searchAdapter.item(position)?.first?.let { status -> quote(status) } } override fun onBookmark(bookmark: Boolean, position: Int) { - searchAdapter.getItem(position)?.let { status -> + searchAdapter.item(position)?.let { status -> viewModel.bookmark(status, bookmark) } } override fun onMore(view: View, position: Int) { - searchAdapter.getItem(position)?.first?.let { + searchAdapter.item(position)?.first?.let { more(it, view, position) } } override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { - searchAdapter.getItem(position)?.first?.actionableStatus?.let { actionable -> + searchAdapter.item(position)?.first?.actionableStatus?.let { actionable -> when (actionable.attachments[attachmentIndex].type) { Attachment.Type.GIFV, Attachment.Type.VIDEO, Attachment.Type.IMAGE, Attachment.Type.AUDIO -> { val attachments = AttachmentViewData.list(actionable) - val intent = ViewMediaActivity.newIntent(context, attachments, - attachmentIndex) + val intent = ViewMediaActivity.newIntent( + context, attachments, + attachmentIndex + ) if (view != null) { val url = actionable.attachments[attachmentIndex].url ViewCompat.setTransitionName(view, url) - val options = ActivityOptionsCompat.makeSceneTransitionAnimation(requireActivity(), - view, url) + val options = ActivityOptionsCompat.makeSceneTransitionAnimation( + requireActivity(), + view, url + ) startActivity(intent, options.toBundle()) } else { startActivity(intent) @@ -141,20 +139,20 @@ class SearchNotestockFragment : SearchFragment + searchAdapter.item(position)?.first?.let { status -> val actionableStatus = status.actionableStatus bottomSheetActivity?.viewUrl(actionableStatus.id) } } override fun onOpenReblog(position: Int) { - searchAdapter.getItem(position)?.first?.let { status -> + searchAdapter.item(position)?.first?.let { status -> bottomSheetActivity?.viewAccount(status.account.id) } } override fun onExpandedChange(expanded: Boolean, position: Int) { - (adapter as? SearchStatusesAdapter)?.getItem(position)?.let { + searchAdapter.item(position)?.let { viewModel.expandedNotestockChange(it, expanded) } } @@ -164,7 +162,7 @@ class SearchNotestockFragment : SearchFragment { - val textId = getString(if (status.isPinned()) R.string.unpin_action else R.string.pin_action) + val textId = + getString(if (status.isPinned()) R.string.unpin_action else R.string.pin_action) menu.add(0, R.id.pin, 1, textId) } Status.Visibility.PRIVATE -> { @@ -256,7 +261,7 @@ class SearchNotestockFragment : SearchFragment { - } //Ignore + } // Ignore } } else { popup.inflate(R.menu.status_more) @@ -269,7 +274,8 @@ class SearchNotestockFragment : SearchFragment openAsItem.isVisible = false 2 -> for (account in accounts) { if (account !== viewModel.activeAccount) { - openAsTitle = String.format(getString(R.string.action_open_as), account.fullName) + openAsTitle = + String.format(getString(R.string.action_open_as), account.fullName) break } } @@ -277,17 +283,19 @@ class SearchNotestockFragment : SearchFragment @@ -299,11 +307,16 @@ class SearchNotestockFragment : SearchFragment { @@ -311,11 +324,17 @@ class SearchNotestockFragment : SearchFragment { - val clipboard = requireActivity().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clipboard = + requireActivity().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager clipboard.setPrimaryClip(ClipData.newPlainText(null, statusUrl)) return@setOnMenuItemClickListener true } @@ -328,7 +347,7 @@ class SearchNotestockFragment : SearchFragment { - searchAdapter.getItem(position)?.let { foundStatus -> + searchAdapter.item(position)?.let { foundStatus -> viewModel.muteConversation(foundStatus, status.muted != true) } return@setOnMenuItemClickListener true @@ -373,33 +392,37 @@ class SearchNotestockFragment : SearchFragment viewModel.blockAccount(accountId) } - .setNegativeButton(android.R.string.cancel, null) - .show() + .setMessage(getString(R.string.dialog_block_warning, accountUsername)) + .setPositiveButton(android.R.string.ok) { _, _ -> viewModel.blockAccount(accountId) } + .setNegativeButton(android.R.string.cancel, null) + .show() } private fun onMute(accountId: String, accountUsername: String) { showMuteAccountDialog( - this.requireActivity(), - accountUsername, + this.requireActivity(), + accountUsername, ) { notifications, duration -> viewModel.muteAccount(accountId, notifications, duration) } } - private fun accountIsInMentions(account: AccountEntity?, mentions: Array): Boolean { + private fun accountIsInMentions(account: AccountEntity?, mentions: List): Boolean { return mentions.firstOrNull { account?.username == it.username && account.domain == Uri.parse(it.url)?.host } != null } private fun showOpenAsDialog(statusUrl: String, dialogTitle: CharSequence) { - bottomSheetActivity?.showAccountChooserDialog(dialogTitle, false, object : AccountSelectionListener { - override fun onAccountSelected(account: AccountEntity) { - openAsAccount(statusUrl, account) + bottomSheetActivity?.showAccountChooserDialog( + dialogTitle, + false, + object : AccountSelectionListener { + override fun onAccountSelected(account: AccountEntity) { + openAsAccount(statusUrl, account) + } } - }) + ) } private fun openAsAccount(statusUrl: String, account: AccountEntity) { @@ -442,51 +465,54 @@ class SearchNotestockFragment : SearchFragment - viewModel.deleteStatus(id) - removeItem(position) - } - .setNegativeButton(android.R.string.cancel, null) - .show() + .setMessage(R.string.dialog_delete_toot_warning) + .setPositiveButton(android.R.string.ok) { _, _ -> + viewModel.deleteStatus(id) + removeItem(position) + } + .setNegativeButton(android.R.string.cancel, null) + .show() } } private fun showConfirmEditDialog(id: String, position: Int, status: Status) { activity?.let { AlertDialog.Builder(it) - .setMessage(R.string.dialog_redraft_toot_warning) - .setPositiveButton(android.R.string.ok) { _, _ -> - viewModel.deleteStatus(id) - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(from(this, Lifecycle.Event.ON_DESTROY)) - .subscribe({ deletedStatus -> - removeItem(position) + .setMessage(R.string.dialog_redraft_toot_warning) + .setPositiveButton(android.R.string.ok) { _, _ -> + viewModel.deleteStatus(id) + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(this, Lifecycle.Event.ON_DESTROY) + .subscribe({ deletedStatus -> + removeItem(position) - val redraftStatus = if (deletedStatus.isEmpty()) { - status.toDeletedStatus() - } else { - deletedStatus - } + val redraftStatus = if (deletedStatus.isEmpty()) { + status.toDeletedStatus() + } else { + deletedStatus + } - val intent = ComposeActivity.startIntent(requireContext(), ComposeOptions( - tootText = redraftStatus.text ?: "", - inReplyToId = redraftStatus.inReplyToId, - visibility = redraftStatus.visibility, - contentWarning = redraftStatus.spoilerText, - mediaAttachments = redraftStatus.attachments, - sensitive = redraftStatus.sensitive, - poll = redraftStatus.poll?.toNewPoll(status.createdAt) - )) - startActivity(intent) - }, { error -> - Log.w("SearchStatusesFragment", "error deleting status", error) - Toast.makeText(context, R.string.error_generic, Toast.LENGTH_SHORT).show() - }) + val intent = ComposeActivity.startIntent( + requireContext(), + ComposeOptions( + tootText = redraftStatus.text ?: "", + inReplyToId = redraftStatus.inReplyToId, + visibility = redraftStatus.visibility, + contentWarning = redraftStatus.spoilerText, + mediaAttachments = redraftStatus.attachments, + sensitive = redraftStatus.sensitive, + poll = redraftStatus.poll?.toNewPoll(status.createdAt) + ) + ) + startActivity(intent) + }, { error -> + Log.w("SearchStatusesFragment", "error deleting status", error) + Toast.makeText(context, R.string.error_generic, Toast.LENGTH_SHORT).show() + }) - } - .setNegativeButton(android.R.string.cancel, null) - .show() + } + .setNegativeButton(android.R.string.cancel, null) + .show() } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt index 9b80bb989..9546bf142 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt @@ -1,4 +1,4 @@ -/* Copyright 2019 Joel Pyska +/* Copyright 2021 Tusky Contributors * * This file is a part of Tusky. * @@ -32,12 +32,13 @@ import androidx.appcompat.widget.PopupMenu import androidx.core.app.ActivityOptionsCompat import androidx.core.view.ViewCompat import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LiveData -import androidx.paging.PagedList -import androidx.paging.PagedListAdapter +import androidx.paging.PagingData +import androidx.paging.PagingDataAdapter import androidx.preference.PreferenceManager import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager +import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from +import autodispose2.autoDispose import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.MainActivity import com.keylesspalace.tusky.R @@ -55,40 +56,34 @@ import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.util.CardViewMode import com.keylesspalace.tusky.util.LinkHelper -import com.keylesspalace.tusky.util.NetworkState import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.view.showMuteAccountDialog import com.keylesspalace.tusky.viewdata.AttachmentViewData import com.keylesspalace.tusky.viewdata.StatusViewData -import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from -import com.uber.autodispose.autoDispose -import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import kotlinx.coroutines.flow.Flow class SearchStatusesFragment : SearchFragment>(), StatusActionListener { - override val networkStateRefresh: LiveData - get() = viewModel.networkStateStatusRefresh - override val networkState: LiveData - get() = viewModel.networkStateStatus - override val data: LiveData>> - get() = viewModel.statuses + override val data: Flow>> + get() = viewModel.statusesFlow private val searchAdapter get() = super.adapter as SearchStatusesAdapter - override fun createAdapter(): PagedListAdapter, *> { + override fun createAdapter(): PagingDataAdapter, *> { val preferences = PreferenceManager.getDefaultSharedPreferences(binding.searchRecyclerView.context) val statusDisplayOptions = StatusDisplayOptions( - animateAvatars = preferences.getBoolean("animateGifAvatars", false), - mediaPreviewEnabled = viewModel.mediaPreviewEnabled, - useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false), - showBotOverlay = preferences.getBoolean("showBotOverlay", true), - useBlurhash = preferences.getBoolean("useBlurhash", true), - cardViewMode = CardViewMode.NONE, - confirmReblogs = preferences.getBoolean("confirmReblogs", true), - hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), - animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), - quoteEnabled = viewModel.quoteEnabled + animateAvatars = preferences.getBoolean("animateGifAvatars", false), + mediaPreviewEnabled = viewModel.mediaPreviewEnabled, + useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false), + showBotOverlay = preferences.getBoolean("showBotOverlay", true), + useBlurhash = preferences.getBoolean("useBlurhash", true), + cardViewMode = CardViewMode.NONE, + confirmReblogs = preferences.getBoolean("confirmReblogs", true), + hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), + animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), + quoteEnabled = viewModel.quoteEnabled, ) binding.searchRecyclerView.addItemDecoration(DividerItemDecoration(binding.searchRecyclerView.context, DividerItemDecoration.VERTICAL)) @@ -97,53 +92,57 @@ class SearchStatusesFragment : SearchFragment + searchAdapter.item(position)?.first?.let { status -> reply(status) } } override fun onFavourite(favourite: Boolean, position: Int) { - searchAdapter.getItem(position)?.let { status -> + searchAdapter.item(position)?.let { status -> viewModel.favorite(status, favourite) } } override fun onQuote(position: Int) { - searchAdapter.getItem(position)?.first?.let { status -> + searchAdapter.item(position)?.first?.let { status -> quote(status) } } override fun onBookmark(bookmark: Boolean, position: Int) { - searchAdapter.getItem(position)?.let { status -> + searchAdapter.item(position)?.let { status -> viewModel.bookmark(status, bookmark) } } override fun onMore(view: View, position: Int) { - searchAdapter.getItem(position)?.first?.let { + searchAdapter.item(position)?.first?.let { more(it, view, position) } } override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { - searchAdapter.getItem(position)?.first?.actionableStatus?.let { actionable -> + searchAdapter.item(position)?.first?.actionableStatus?.let { actionable -> when (actionable.attachments[attachmentIndex].type) { Attachment.Type.GIFV, Attachment.Type.VIDEO, Attachment.Type.IMAGE, Attachment.Type.AUDIO -> { val attachments = AttachmentViewData.list(actionable) - val intent = ViewMediaActivity.newIntent(context, attachments, - attachmentIndex) + val intent = ViewMediaActivity.newIntent( + context, attachments, + attachmentIndex + ) if (view != null) { val url = actionable.attachments[attachmentIndex].url ViewCompat.setTransitionName(view, url) - val options = ActivityOptionsCompat.makeSceneTransitionAnimation(requireActivity(), - view, url) + val options = ActivityOptionsCompat.makeSceneTransitionAnimation( + requireActivity(), + view, url + ) startActivity(intent, options.toBundle()) } else { startActivity(intent) @@ -153,26 +152,24 @@ class SearchStatusesFragment : SearchFragment + searchAdapter.item(position)?.first?.let { status -> val actionableStatus = status.actionableStatus bottomSheetActivity?.viewThread(actionableStatus.id, actionableStatus.url) } } override fun onOpenReblog(position: Int) { - searchAdapter.getItem(position)?.first?.let { status -> + searchAdapter.item(position)?.first?.let { status -> bottomSheetActivity?.viewAccount(status.account.id) } } override fun onExpandedChange(expanded: Boolean, position: Int) { - searchAdapter.getItem(position)?.let { + searchAdapter.item(position)?.let { viewModel.expandedChange(it, expanded) } } @@ -182,25 +179,25 @@ class SearchStatusesFragment : SearchFragment) { - searchAdapter.getItem(position)?.let { + searchAdapter.item(position)?.let { viewModel.voteInPoll(it, choices) } } fun removeItem(position: Int) { - searchAdapter.getItem(position)?.let { + searchAdapter.item(position)?.let { viewModel.removeItem(it) } } override fun onReblog(reblog: Boolean, position: Int) { - searchAdapter.getItem(position)?.let { status -> + searchAdapter.item(position)?.let { status -> viewModel.reblog(status, reblog) } } @@ -212,40 +209,46 @@ class SearchStatusesFragment : SearchFragment { - } //Ignore + } // Ignore } } else { popup.inflate(R.menu.status_more) @@ -305,11 +308,12 @@ class SearchStatusesFragment : SearchFragment @@ -321,8 +325,8 @@ class SearchStatusesFragment : SearchFragment { - searchAdapter.getItem(position)?.let { foundStatus -> + searchAdapter.item(position)?.let { foundStatus -> viewModel.muteConversation(foundStatus, status.muted != true) } return@setOnMenuItemClickListener true @@ -395,10 +399,10 @@ class SearchStatusesFragment : SearchFragment viewModel.blockAccount(accountId) } - .setNegativeButton(android.R.string.cancel, null) - .show() + .setMessage(getString(R.string.dialog_block_warning, accountUsername)) + .setPositiveButton(android.R.string.ok) { _, _ -> viewModel.blockAccount(accountId) } + .setNegativeButton(android.R.string.cancel, null) + .show() } private fun onMute(accountId: String, accountUsername: String) { @@ -410,18 +414,21 @@ class SearchStatusesFragment : SearchFragment): Boolean { + private fun accountIsInMentions(account: AccountEntity?, mentions: List): Boolean { return mentions.firstOrNull { account?.username == it.username && account.domain == Uri.parse(it.url)?.host } != null } private fun showOpenAsDialog(statusUrl: String, dialogTitle: CharSequence) { - bottomSheetActivity?.showAccountChooserDialog(dialogTitle, false, object : AccountSelectionListener { - override fun onAccountSelected(account: AccountEntity) { - openAsAccount(statusUrl, account) + bottomSheetActivity?.showAccountChooserDialog( + dialogTitle, false, + object : AccountSelectionListener { + override fun onAccountSelected(account: AccountEntity) { + openAsAccount(statusUrl, account) + } } - }) + ) } private fun openAsAccount(statusUrl: String, account: AccountEntity) { @@ -464,51 +471,56 @@ class SearchStatusesFragment : SearchFragment - viewModel.deleteStatus(id) - removeItem(position) - } - .setNegativeButton(android.R.string.cancel, null) - .show() + .setMessage(R.string.dialog_delete_toot_warning) + .setPositiveButton(android.R.string.ok) { _, _ -> + viewModel.deleteStatus(id) + removeItem(position) + } + .setNegativeButton(android.R.string.cancel, null) + .show() } } private fun showConfirmEditDialog(id: String, position: Int, status: Status) { activity?.let { AlertDialog.Builder(it) - .setMessage(R.string.dialog_redraft_toot_warning) - .setPositiveButton(android.R.string.ok) { _, _ -> - viewModel.deleteStatus(id) - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(from(this, Lifecycle.Event.ON_DESTROY)) - .subscribe({ deletedStatus -> - removeItem(position) + .setMessage(R.string.dialog_redraft_toot_warning) + .setPositiveButton(android.R.string.ok) { _, _ -> + viewModel.deleteStatus(id) + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(from(this, Lifecycle.Event.ON_DESTROY)) + .subscribe( + { deletedStatus -> + removeItem(position) - val redraftStatus = if (deletedStatus.isEmpty()) { - status.toDeletedStatus() - } else { - deletedStatus - } + val redraftStatus = if (deletedStatus.isEmpty()) { + status.toDeletedStatus() + } else { + deletedStatus + } - val intent = ComposeActivity.startIntent(requireContext(), ComposeOptions( - tootText = redraftStatus.text ?: "", - inReplyToId = redraftStatus.inReplyToId, - visibility = redraftStatus.visibility, - contentWarning = redraftStatus.spoilerText, - mediaAttachments = redraftStatus.attachments, - sensitive = redraftStatus.sensitive, - poll = redraftStatus.poll?.toNewPoll(status.createdAt) - )) - startActivity(intent) - }, { error -> - Log.w("SearchStatusesFragment", "error deleting status", error) - Toast.makeText(context, R.string.error_generic, Toast.LENGTH_SHORT).show() - }) - - } - .setNegativeButton(android.R.string.cancel, null) - .show() + val intent = ComposeActivity.startIntent( + requireContext(), + ComposeOptions( + tootText = redraftStatus.text ?: "", + inReplyToId = redraftStatus.inReplyToId, + visibility = redraftStatus.visibility, + contentWarning = redraftStatus.spoilerText, + mediaAttachments = redraftStatus.attachments, + sensitive = redraftStatus.sensitive, + poll = redraftStatus.poll?.toNewPoll(status.createdAt) + ) + ) + startActivity(intent) + }, + { error -> + Log.w("SearchStatusesFragment", "error deleting status", error) + Toast.makeText(context, R.string.error_generic, Toast.LENGTH_SHORT).show() + } + ) + } + .setNegativeButton(android.R.string.cancel, null) + .show() } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/TimelineAdapter.java b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineAdapter.java similarity index 96% rename from app/src/main/java/com/keylesspalace/tusky/adapter/TimelineAdapter.java rename to app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineAdapter.java index 3a8b95d97..9d0c04aee 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/TimelineAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineAdapter.java @@ -13,7 +13,7 @@ * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ -package com.keylesspalace.tusky.adapter; +package com.keylesspalace.tusky.components.timeline; import android.view.LayoutInflater; import android.view.View; @@ -24,6 +24,8 @@ import androidx.annotation.Nullable; import androidx.recyclerview.widget.RecyclerView; import com.keylesspalace.tusky.R; +import com.keylesspalace.tusky.adapter.PlaceholderViewHolder; +import com.keylesspalace.tusky.adapter.StatusViewHolder; import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.util.StatusDisplayOptions; import com.keylesspalace.tusky.viewdata.StatusViewData; diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt new file mode 100644 index 000000000..8cfd50470 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -0,0 +1,620 @@ +/* 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 . */ + +package com.keylesspalace.tusky.components.timeline + +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.accessibility.AccessibilityManager +import androidx.core.content.ContextCompat +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.AsyncDifferConfig +import androidx.recyclerview.widget.AsyncListDiffer +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.ListUpdateCallback +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.SimpleItemAnimator +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener +import at.connyduck.sparkbutton.helpers.Utils +import autodispose2.androidx.lifecycle.autoDispose +import com.keylesspalace.tusky.AccountListActivity +import com.keylesspalace.tusky.AccountListActivity.Companion.newIntent +import com.keylesspalace.tusky.BaseActivity +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.adapter.StatusBaseViewHolder +import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.appstore.PreferenceChangedEvent +import com.keylesspalace.tusky.appstore.QuickReplyEvent +import com.keylesspalace.tusky.components.compose.CAN_USE_QUOTE_ID +import com.keylesspalace.tusky.databinding.FragmentTimelineBinding +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.di.ViewModelFactory +import com.keylesspalace.tusky.fragment.SFragment +import com.keylesspalace.tusky.interfaces.ActionButtonActivity +import com.keylesspalace.tusky.interfaces.RefreshableFragment +import com.keylesspalace.tusky.interfaces.ReselectableFragment +import com.keylesspalace.tusky.interfaces.StatusActionListener +import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.util.CardViewMode +import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate +import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.util.viewBinding +import com.keylesspalace.tusky.util.visible +import com.keylesspalace.tusky.view.EndlessOnScrollListener +import com.keylesspalace.tusky.viewdata.AttachmentViewData +import com.keylesspalace.tusky.viewdata.StatusViewData +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Observable +import java.util.concurrent.TimeUnit +import javax.inject.Inject + +class TimelineFragment : + SFragment(), + OnRefreshListener, + StatusActionListener, + Injectable, + ReselectableFragment, + RefreshableFragment { + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + @Inject + lateinit var eventHub: EventHub + + @Inject + lateinit var accountManager: AccountManager + + private val viewModel: TimelineViewModel by viewModels { viewModelFactory } + + private val binding by viewBinding(FragmentTimelineBinding::bind) + + private lateinit var adapter: TimelineAdapter + + private var isSwipeToRefreshEnabled = true + + private var eventRegistered = false + + private var layoutManager: LinearLayoutManager? = null + private var scrollListener: EndlessOnScrollListener? = null + private var hideFab = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val arguments = requireArguments() + val kind = TimelineViewModel.Kind.valueOf(arguments.getString(KIND_ARG)!!) + val id: String? = if (kind == TimelineViewModel.Kind.USER || + kind == TimelineViewModel.Kind.USER_PINNED || + kind == TimelineViewModel.Kind.USER_WITH_REPLIES || + kind == TimelineViewModel.Kind.LIST + ) { + arguments.getString(ID_ARG)!! + } else { + null + } + + val tags = if (kind == TimelineViewModel.Kind.TAG) { + arguments.getStringArrayList(HASHTAGS_ARG)!! + } else { + listOf() + } + + val isStreamingEnabled = arguments.getBoolean(ARG_ENABLE_STREAMING) + + viewModel.init( + kind, + id, + tags, + isStreamingEnabled + ) + + viewModel.viewUpdates + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(this) + .subscribe { this.updateViews() } + + isSwipeToRefreshEnabled = arguments.getBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true) + + val preferences = PreferenceManager.getDefaultSharedPreferences(activity) + val statusDisplayOptions = StatusDisplayOptions( + animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), + mediaPreviewEnabled = accountManager.activeAccount!!.mediaPreviewEnabled, + useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false), + showBotOverlay = preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true), + useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true), + cardViewMode = if (preferences.getBoolean( + PrefKeys.SHOW_CARDS_IN_TIMELINES, + false + ) + ) CardViewMode.INDENTED else CardViewMode.NONE, + confirmReblogs = preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true), + hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), + animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), + quoteEnabled = CAN_USE_QUOTE_ID.contains(accountManager.activeAccount?.domain), + ) + adapter = TimelineAdapter( + dataSource, + statusDisplayOptions, + this + ) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return inflater.inflate(R.layout.fragment_timeline, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + setupSwipeRefreshLayout() + setupRecyclerView() + updateViews() + viewModel.loadInitial() + } + + private fun setupSwipeRefreshLayout() { + binding.swipeRefreshLayout.isEnabled = isSwipeToRefreshEnabled + binding.swipeRefreshLayout.setOnRefreshListener(this) + binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue) + } + + private fun setupRecyclerView() { + binding.recyclerView.setAccessibilityDelegateCompat( + ListStatusAccessibilityDelegate(binding.recyclerView, this) { pos -> viewModel.statuses.getOrNull(pos) } + ) + binding.recyclerView.setHasFixedSize(true) + layoutManager = LinearLayoutManager(context) + binding.recyclerView.layoutManager = layoutManager + val divider = DividerItemDecoration(context, RecyclerView.VERTICAL) + binding.recyclerView.addItemDecoration(divider) + + // CWs are expanded without animation, buttons animate itself, we don't need it basically + (binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false + binding.recyclerView.adapter = adapter + } + + private fun showEmptyView() { + binding.statusView.show() + binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null) + } + + override fun onActivityCreated(savedInstanceState: Bundle?) { + super.onActivityCreated(savedInstanceState) + + /* This is delayed until onActivityCreated solely because MainActivity.composeButton isn't + * guaranteed to be set until then. */ + scrollListener = if (actionButtonPresent()) { + /* Use a modified scroll listener that both loads more statuses as it goes, and hides + * the follow button on down-scroll. */ + val preferences = PreferenceManager.getDefaultSharedPreferences(context) + hideFab = preferences.getBoolean("fabHide", false) + object : EndlessOnScrollListener(layoutManager) { + override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) { + super.onScrolled(view, dx, dy) + val composeButton = (activity as ActionButtonActivity).actionButton + if (composeButton != null) { + if (hideFab) { + if (dy > 0 && composeButton.isShown) { + composeButton.hide() // hides the button if we're scrolling down + } else if (dy < 0 && !composeButton.isShown) { + composeButton.show() // shows it if we are scrolling up + } + } else if (!composeButton.isShown) { + composeButton.show() + } + } + } + + override fun onLoadMore(totalItemsCount: Int, view: RecyclerView) { + this@TimelineFragment.onLoadMore() + } + } + } else { + // Just use the basic scroll listener to load more statuses. + object : EndlessOnScrollListener(layoutManager) { + override fun onLoadMore(totalItemsCount: Int, view: RecyclerView) { + this@TimelineFragment.onLoadMore() + } + } + }.also { + binding.recyclerView.addOnScrollListener(it) + } + + if (!eventRegistered) { + eventHub.events + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(this, Lifecycle.Event.ON_DESTROY) + .subscribe { event -> + when (event) { + is PreferenceChangedEvent -> { + onPreferenceChanged(event.preferenceKey) + } + } + } + eventRegistered = true + } + } + + override fun onStart() { + super.onStart() + + viewModel.tryStartStreaming() + } + + override fun onStop() { + super.onStop() + + viewModel.stopStreaming() + } + + fun toggleStreaming(): Boolean = + (!viewModel.isStreamingEnabled).also { + viewModel.isStreamingEnabled = it + } + + override fun onRefresh() { + binding.swipeRefreshLayout.isEnabled = isSwipeToRefreshEnabled + binding.statusView.hide() + + viewModel.fullyRefresh() + } + + override fun onReply(position: Int) { + val status = viewModel.statuses[position].asStatusOrNull() ?: return + if (viewModel.shouldReplyInQuick()) { + eventHub.dispatch(QuickReplyEvent(status.status)) + } else { + super.reply(status.status) + } + } + + override fun onReblog(reblog: Boolean, position: Int) { + viewModel.reblog(reblog, position) + } + + override fun onFavourite(favourite: Boolean, position: Int) { + viewModel.favorite(favourite, position) + } + + override fun onQuote(position: Int) { + val status = viewModel.statuses[position].asStatusOrNull() ?: return + super.quote(status.status) + } + + override fun onBookmark(bookmark: Boolean, position: Int) { + viewModel.bookmark(bookmark, position) + } + + override fun onVoteInPoll(position: Int, choices: List) { + viewModel.voteInPoll(position, choices) + } + + override fun onMore(view: View, position: Int) { + val status = viewModel.statuses[position].asStatusOrNull()?.status ?: return + super.more(status, view, position) + } + + override fun onOpenReblog(position: Int) { + val status = viewModel.statuses[position].asStatusOrNull()?.status ?: return + super.openReblog(status) + } + + override fun onExpandedChange(expanded: Boolean, position: Int) { + viewModel.changeExpanded(expanded, position) + updateViews() + } + + override fun onContentHiddenChange(isShowing: Boolean, position: Int) { + viewModel.changeContentHidden(isShowing, position) + updateViews() + } + + override fun onShowReblogs(position: Int) { + val statusId = viewModel.statuses[position].asStatusOrNull()?.id ?: return + val intent = newIntent(requireContext(), AccountListActivity.Type.REBLOGGED, statusId) + (activity as BaseActivity).startActivityWithSlideInAnimation(intent) + } + + override fun onShowFavs(position: Int) { + val statusId = viewModel.statuses[position].asStatusOrNull()?.id ?: return + val intent = newIntent(requireContext(), AccountListActivity.Type.FAVOURITED, statusId) + (activity as BaseActivity).startActivityWithSlideInAnimation(intent) + } + + override fun onLoadMore(position: Int) { + viewModel.loadGap(position) + } + + override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { + viewModel.changeContentCollapsed(isCollapsed, position) + } + + override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { + val status = viewModel.statuses[position].asStatusOrNull() ?: return + super.viewMedia( + attachmentIndex, + AttachmentViewData.list(status.actionable), + view + ) + } + + override fun onViewThread(position: Int) { + val status = viewModel.statuses[position].asStatusOrNull() ?: return + super.viewThread(status.actionable.id, status.actionable.url) + } + + override fun onViewTag(tag: String) { + if (viewModel.kind == TimelineViewModel.Kind.TAG && viewModel.tags.size == 1 && + viewModel.tags.contains(tag) + ) { + // If already viewing a tag page, then ignore any request to view that tag again. + return + } + super.viewTag(tag) + } + + override fun onViewAccount(id: String) { + if (( + viewModel.kind == TimelineViewModel.Kind.USER || + viewModel.kind == TimelineViewModel.Kind.USER_WITH_REPLIES + ) && + viewModel.id == id + ) { + /* If already viewing an account page, then any requests to view that account page + * should be ignored. */ + return + } + super.viewAccount(id) + } + + private fun onPreferenceChanged(key: String) { + val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) + when (key) { + PrefKeys.FAB_HIDE -> { + hideFab = sharedPreferences.getBoolean(PrefKeys.FAB_HIDE, false) + } + PrefKeys.MEDIA_PREVIEW_ENABLED -> { + val enabled = accountManager.activeAccount!!.mediaPreviewEnabled + val oldMediaPreviewEnabled = adapter.mediaPreviewEnabled + if (enabled != oldMediaPreviewEnabled) { + adapter.mediaPreviewEnabled = enabled + updateViews() + } + } + } + } + + public override fun removeItem(position: Int) { + viewModel.statuses.removeAt(position) + updateViews() + } + + private fun onLoadMore() { + viewModel.loadMore() + } + + private fun actionButtonPresent(): Boolean { + return viewModel.kind != TimelineViewModel.Kind.TAG && + viewModel.kind != TimelineViewModel.Kind.FAVOURITES && + viewModel.kind != TimelineViewModel.Kind.BOOKMARKS && + activity is ActionButtonActivity + } + + private fun updateViews() { + differ.submitList(viewModel.statuses.toList()) + binding.swipeRefreshLayout.isEnabled = viewModel.failure == null + + if (isAdded) { + binding.swipeRefreshLayout.isRefreshing = viewModel.isRefreshing + binding.progressBar.visible(viewModel.isLoadingInitially) + if (viewModel.failure == null && viewModel.statuses.isEmpty() && !viewModel.isLoadingInitially) { + showEmptyView() + } else { + when (viewModel.failure) { + TimelineViewModel.FailureReason.NETWORK -> { + binding.statusView.show() + binding.statusView.setup( + R.drawable.elephant_offline, + R.string.error_network + ) { + binding.statusView.hide() + viewModel.loadInitial() + } + } + TimelineViewModel.FailureReason.OTHER -> { + binding.statusView.show() + binding.statusView.setup( + R.drawable.elephant_error, + R.string.error_generic + ) { + binding.statusView.hide() + viewModel.loadInitial() + } + } + null -> binding.statusView.hide() + } + } + } + } + + private val listUpdateCallback: ListUpdateCallback = object : ListUpdateCallback { + override fun onInserted(position: Int, count: Int) { + if (isAdded) { + adapter.notifyItemRangeInserted(position, count) + val context = context + // scroll up when new items at the top are loaded while being in the first position + // https://github.com/tuskyapp/Tusky/pull/1905#issuecomment-677819724 + if (position == 0 && context != null && layoutManager?.findFirstVisibleItemPosition() == 0 && adapter.itemCount != count) { + if (count == 1) { + layoutManager?.scrollToPosition(0) + binding.recyclerView.stopScroll() + scrollListener?.reset() + } + if (isSwipeToRefreshEnabled) { + binding.recyclerView.scrollBy(0, Utils.dpToPx(context, -30)) + } else binding.recyclerView.scrollToPosition(0) + } + } + } + + override fun onRemoved(position: Int, count: Int) { + adapter.notifyItemRangeRemoved(position, count) + } + + override fun onMoved(fromPosition: Int, toPosition: Int) { + adapter.notifyItemMoved(fromPosition, toPosition) + } + + override fun onChanged(position: Int, count: Int, payload: Any?) { + adapter.notifyItemRangeChanged(position, count, payload) + } + } + private val differ = AsyncListDiffer( + listUpdateCallback, + AsyncDifferConfig.Builder(diffCallback).build() + ) + + private val dataSource: TimelineAdapter.AdapterDataSource = + object : TimelineAdapter.AdapterDataSource { + override fun getItemCount(): Int { + return differ.currentList.size + } + + override fun getItemAt(pos: Int): StatusViewData { + return differ.currentList[pos] + } + } + + private var talkBackWasEnabled = false + + override fun onResume() { + super.onResume() + val a11yManager = + ContextCompat.getSystemService(requireContext(), AccessibilityManager::class.java) + + val wasEnabled = talkBackWasEnabled + talkBackWasEnabled = a11yManager?.isEnabled == true + Log.d(TAG, "talkback was enabled: $wasEnabled, now $talkBackWasEnabled") + if (talkBackWasEnabled && !wasEnabled) { + adapter.notifyDataSetChanged() + } + startUpdateTimestamp() + } + + /** + * Start to update adapter every minute to refresh timestamp + * If setting absoluteTimeView is false + * Auto dispose observable on pause + */ + private fun startUpdateTimestamp() { + val preferences = PreferenceManager.getDefaultSharedPreferences(activity) + val useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false) + if (!useAbsoluteTime) { + Observable.interval(1, TimeUnit.MINUTES) + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(this, Lifecycle.Event.ON_PAUSE) + .subscribe { updateViews() } + } + } + + override fun onReselect() { + if (isAdded) { + layoutManager!!.scrollToPosition(0) + binding.recyclerView.stopScroll() + scrollListener!!.reset() + } + } + + override fun refreshContent() { + onRefresh() + } + + companion object { + private const val TAG = "TimelineF" // logging tag + private const val KIND_ARG = "kind" + private const val ID_ARG = "id" + private const val HASHTAGS_ARG = "hashtags" + private const val ARG_ENABLE_SWIPE_TO_REFRESH = "enableSwipeToRefresh" + private const val ARG_ENABLE_STREAMING = "enableStreaming" + + fun newInstance( + kind: TimelineViewModel.Kind, + hashtagOrId: String? = null, + enableSwipeToRefresh: Boolean = true, + enableStreaming: Boolean = false + ): TimelineFragment { + val fragment = TimelineFragment() + val arguments = Bundle(3) + arguments.putString(KIND_ARG, kind.name) + arguments.putString(ID_ARG, hashtagOrId) + arguments.putBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, enableSwipeToRefresh) + arguments.putBoolean(ARG_ENABLE_STREAMING, enableStreaming) + fragment.arguments = arguments + return fragment + } + + @JvmStatic + fun newHashtagInstance(hashtags: List): TimelineFragment { + val fragment = TimelineFragment() + val arguments = Bundle(3) + arguments.putString(KIND_ARG, TimelineViewModel.Kind.TAG.name) + arguments.putStringArrayList(HASHTAGS_ARG, ArrayList(hashtags)) + arguments.putBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true) + fragment.arguments = arguments + return fragment + } + + private val diffCallback: DiffUtil.ItemCallback = + object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: StatusViewData, + newItem: StatusViewData + ): Boolean { + return oldItem.viewDataId == newItem.viewDataId + } + + override fun areContentsTheSame( + oldItem: StatusViewData, + newItem: StatusViewData + ): Boolean { + return false // Items are different always. It allows to refresh timestamp on every view holder update + } + + override fun getChangePayload( + oldItem: StatusViewData, + newItem: StatusViewData + ): Any? { + return if (oldItem === newItem) { + // If items are equal - update timestamp only + listOf(StatusBaseViewHolder.Key.KEY_CREATED) + } else // If items are different - update the whole view holder + null + } + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineRepository.kt new file mode 100644 index 000000000..f43c589ad --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineRepository.kt @@ -0,0 +1,464 @@ +package com.keylesspalace.tusky.components.timeline + +import android.text.SpannedString +import androidx.core.text.parseAsHtml +import androidx.core.text.toHtml +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import com.keylesspalace.tusky.components.timeline.TimelineRequestMode.DISK +import com.keylesspalace.tusky.components.timeline.TimelineRequestMode.NETWORK +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.db.TimelineAccountEntity +import com.keylesspalace.tusky.db.TimelineDao +import com.keylesspalace.tusky.db.TimelineStatusEntity +import com.keylesspalace.tusky.db.TimelineStatusWithAccount +import com.keylesspalace.tusky.entity.Account +import com.keylesspalace.tusky.entity.Attachment +import com.keylesspalace.tusky.entity.Emoji +import com.keylesspalace.tusky.entity.Poll +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.Either +import com.keylesspalace.tusky.util.dec +import com.keylesspalace.tusky.util.inc +import com.keylesspalace.tusky.util.trimTrailingWhitespace +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers +import java.io.IOException +import java.util.Date +import java.util.concurrent.TimeUnit + +data class Placeholder(val id: String) + +typealias TimelineStatus = Either + +enum class TimelineRequestMode { + DISK, NETWORK, ANY +} + +interface TimelineRepository { + fun getStatuses( + maxId: String?, + sinceId: String?, + sincedIdMinusOne: String?, + limit: Int, + requestMode: TimelineRequestMode + ): Single> + + fun addStatuses( + statuses: List, + ) + + companion object { + val CLEANUP_INTERVAL = TimeUnit.DAYS.toMillis(14) + } +} + +class TimelineRepositoryImpl( + private val timelineDao: TimelineDao, + private val mastodonApi: MastodonApi, + private val accountManager: AccountManager, + private val gson: Gson +) : TimelineRepository { + + init { + this.cleanup() + } + + override fun getStatuses( + maxId: String?, + sinceId: String?, + sincedIdMinusOne: String?, + limit: Int, + requestMode: TimelineRequestMode + ): Single> { + val acc = accountManager.activeAccount ?: throw IllegalStateException() + val accountId = acc.id + + return if (requestMode == DISK) { + this.getStatusesFromDb(accountId, maxId, sinceId, limit) + } else { + getStatusesFromNetwork(maxId, sinceId, sincedIdMinusOne, limit, accountId, requestMode) + } + } + + override fun addStatuses(statuses: List) { + val acc = accountManager.activeAccount ?: throw IllegalStateException() + val accountId = acc.id + + statuses.forEach { either -> + when { + either.isRight() -> { + val status = either.asRight() + timelineDao.insertInTransaction( + status.toEntity(accountId, gson), + status.account.toEntity(accountId, gson), + status.reblog?.account?.toEntity(accountId, gson), + ) + } + either.isLeft() -> { + val placeholder = either.asLeft() + timelineDao.insertStatusIfNotThere(placeholder.toEntity(accountId)) + } + } + } + } + + private fun getStatusesFromNetwork( + maxId: String?, + sinceId: String?, + sinceIdMinusOne: String?, + limit: Int, + accountId: Long, + requestMode: TimelineRequestMode + ): Single> { + return mastodonApi.homeTimeline(maxId, sinceIdMinusOne, limit + 1) + .map { response -> + this.saveStatusesToDb(accountId, response.body().orEmpty(), maxId, sinceId) + } + .flatMap { statuses -> + this.addFromDbIfNeeded(accountId, statuses, maxId, sinceId, limit, requestMode) + } + .onErrorResumeNext { error -> + if (error is IOException && requestMode != NETWORK) { + this.getStatusesFromDb(accountId, maxId, sinceId, limit) + } else { + Single.error(error) + } + } + } + + private fun addFromDbIfNeeded( + accountId: Long, + statuses: List>, + maxId: String?, + sinceId: String?, + limit: Int, + requestMode: TimelineRequestMode + ): Single> { + return if (requestMode != NETWORK && statuses.size < 2) { + val newMaxID = if (statuses.isEmpty()) { + maxId + } else { + statuses.last { it.isRight() }.asRight().id + } + this.getStatusesFromDb(accountId, newMaxID, sinceId, limit) + .map { fromDb -> + // If it's just placeholders and less than limit (so we exhausted both + // db and server at this point) + if (fromDb.size < limit && fromDb.all { !it.isRight() }) { + statuses + } else { + statuses + fromDb + } + } + } else { + Single.just(statuses) + } + } + + private fun getStatusesFromDb( + accountId: Long, + maxId: String?, + sinceId: String?, + limit: Int + ): Single> { + return timelineDao.getStatusesForAccount(accountId, maxId, sinceId, limit) + .subscribeOn(Schedulers.io()) + .map { statuses -> + statuses.map { it.toStatus() } + } + } + + private fun saveStatusesToDb( + accountId: Long, + statuses: List, + maxId: String?, + sinceId: String? + ): List> { + var placeholderToInsert: Placeholder? = null + + // Look for overlap + val resultStatuses = if (statuses.isNotEmpty() && sinceId != null) { + val indexOfSince = statuses.indexOfLast { it.id == sinceId } + if (indexOfSince == -1) { + // We didn't find the status which must be there. Add a placeholder + placeholderToInsert = Placeholder(sinceId.inc()) + statuses.mapTo(mutableListOf(), Status::lift) + .apply { + add(Either.Left(placeholderToInsert)) + } + } else { + // There was an overlap. Remove all overlapped statuses. No need for a placeholder. + statuses.mapTo(mutableListOf(), Status::lift) + .apply { + subList(indexOfSince, size).clear() + } + } + } else { + // Just a normal case. + statuses.map(Status::lift) + } + + Single.fromCallable { + + if (statuses.isNotEmpty()) { + timelineDao.deleteRange(accountId, statuses.last().id, statuses.first().id) + } + + for (status in statuses) { + timelineDao.insertInTransaction( + status.toEntity(accountId, gson), + status.account.toEntity(accountId, gson), + status.reblog?.account?.toEntity(accountId, gson) + ) + } + + placeholderToInsert?.let { + timelineDao.insertStatusIfNotThere(placeholderToInsert.toEntity(accountId)) + } + + // If we're loading in the bottom insert placeholder after every load + // (for requests on next launches) but not return it. + if (sinceId == null && statuses.isNotEmpty()) { + timelineDao.insertStatusIfNotThere( + Placeholder(statuses.last().id.dec()).toEntity(accountId) + ) + } + + // There may be placeholders which we thought could be from our TL but they are not + if (statuses.size > 2) { + timelineDao.removeAllPlaceholdersBetween( + accountId, statuses.first().id, + statuses.last().id + ) + } else if (placeholderToInsert == null && maxId != null && sinceId != null) { + timelineDao.removeAllPlaceholdersBetween(accountId, maxId, sinceId) + } + } + .subscribeOn(Schedulers.io()) + .subscribe() + + return resultStatuses + } + + private fun cleanup() { + Schedulers.io().scheduleDirect { + val olderThan = System.currentTimeMillis() - TimelineRepository.CLEANUP_INTERVAL + timelineDao.cleanup(olderThan) + } + } + + private fun TimelineStatusWithAccount.toStatus(): TimelineStatus { + if (this.status.authorServerId == null) { + return Either.Left(Placeholder(this.status.serverId)) + } + + val attachments: ArrayList = gson.fromJson( + status.attachments, + object : TypeToken>() {}.type + ) ?: ArrayList() + val mentions: List = gson.fromJson( + status.mentions, + object : TypeToken>() {}.type + ) ?: listOf() + val application = gson.fromJson(status.application, Status.Application::class.java) + val emojis: List = gson.fromJson( + status.emojis, + object : TypeToken>() {}.type + ) ?: listOf() + val poll: Poll? = gson.fromJson(status.poll, Poll::class.java) + + val reblog = status.reblogServerId?.let { id -> + Status( + id = id, + url = status.url, + account = account.toAccount(gson), + inReplyToId = status.inReplyToId, + inReplyToAccountId = status.inReplyToAccountId, + reblog = null, + content = status.content?.parseAsHtml()?.trimTrailingWhitespace() + ?: SpannedString(""), + createdAt = Date(status.createdAt), + emojis = emojis, + reblogsCount = status.reblogsCount, + favouritesCount = status.favouritesCount, + reblogged = status.reblogged, + favourited = status.favourited, + bookmarked = status.bookmarked, + sensitive = status.sensitive, + spoilerText = status.spoilerText!!, + visibility = status.visibility!!, + attachments = attachments, + mentions = mentions, + application = application, + pinned = false, + muted = status.muted, + poll = poll, + card = null, + quote = null + ) + } + val status = if (reblog != null) { + Status( + id = status.serverId, + url = null, // no url for reblogs + account = this.reblogAccount!!.toAccount(gson), + inReplyToId = null, + inReplyToAccountId = null, + reblog = reblog, + content = SpannedString(""), + createdAt = Date(status.createdAt), // lie but whatever? + emojis = listOf(), + reblogsCount = 0, + favouritesCount = 0, + reblogged = false, + favourited = false, + bookmarked = false, + sensitive = false, + spoilerText = "", + visibility = status.visibility!!, + attachments = ArrayList(), + mentions = listOf(), + application = null, + pinned = false, + muted = status.muted, + poll = null, + card = null, + quote = null + ) + } else { + Status( + id = status.serverId, + url = status.url, + account = account.toAccount(gson), + inReplyToId = status.inReplyToId, + inReplyToAccountId = status.inReplyToAccountId, + reblog = null, + content = status.content?.parseAsHtml()?.trimTrailingWhitespace() + ?: SpannedString(""), + createdAt = Date(status.createdAt), + emojis = emojis, + reblogsCount = status.reblogsCount, + favouritesCount = status.favouritesCount, + reblogged = status.reblogged, + favourited = status.favourited, + bookmarked = status.bookmarked, + sensitive = status.sensitive, + spoilerText = status.spoilerText!!, + visibility = status.visibility!!, + attachments = attachments, + mentions = mentions, + application = application, + pinned = false, + muted = status.muted, + poll = poll, + card = null, + quote = null + ) + } + return Either.Right(status) + } +} + +private val emojisListTypeToken = object : TypeToken>() {} + +fun Account.toEntity(accountId: Long, gson: Gson): TimelineAccountEntity { + return TimelineAccountEntity( + serverId = id, + timelineUserId = accountId, + localUsername = localUsername, + username = username, + displayName = name, + url = url, + avatar = avatar, + emojis = gson.toJson(emojis), + bot = bot + ) +} + +fun TimelineAccountEntity.toAccount(gson: Gson): Account { + return Account( + id = serverId, + localUsername = localUsername, + username = username, + displayName = displayName, + note = SpannedString(""), + url = url, + avatar = avatar, + header = "", + locked = false, + followingCount = 0, + followersCount = 0, + statusesCount = 0, + source = null, + bot = bot, + emojis = gson.fromJson(this.emojis, emojisListTypeToken.type), + fields = null, + moved = null + ) +} + +fun Placeholder.toEntity(timelineUserId: Long): TimelineStatusEntity { + return TimelineStatusEntity( + serverId = this.id, + url = null, + timelineUserId = timelineUserId, + authorServerId = null, + inReplyToId = null, + inReplyToAccountId = null, + content = null, + createdAt = 0L, + emojis = null, + reblogsCount = 0, + favouritesCount = 0, + reblogged = false, + favourited = false, + bookmarked = false, + sensitive = false, + spoilerText = null, + visibility = null, + attachments = null, + mentions = null, + application = null, + reblogServerId = null, + reblogAccountId = null, + poll = null, + muted = false + ) +} + +fun Status.toEntity( + timelineUserId: Long, + gson: Gson +): TimelineStatusEntity { + val actionable = actionableStatus + return TimelineStatusEntity( + serverId = this.id, + url = actionable.url!!, + timelineUserId = timelineUserId, + authorServerId = actionable.account.id, + inReplyToId = actionable.inReplyToId, + inReplyToAccountId = actionable.inReplyToAccountId, + content = actionable.content.toHtml(), + createdAt = actionable.createdAt.time, + emojis = actionable.emojis.let(gson::toJson), + reblogsCount = actionable.reblogsCount, + favouritesCount = actionable.favouritesCount, + reblogged = actionable.reblogged, + favourited = actionable.favourited, + bookmarked = actionable.bookmarked, + sensitive = actionable.sensitive, + spoilerText = actionable.spoilerText, + visibility = actionable.visibility, + attachments = actionable.attachments.let(gson::toJson), + mentions = actionable.mentions.let(gson::toJson), + application = actionable.application.let(gson::toJson), + reblogServerId = reblog?.id, + reblogAccountId = reblog?.let { this.account.id }, + poll = actionable.poll.let(gson::toJson), + muted = actionable.muted + ) +} + +fun Status.lift(): Either = Either.Right(this) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineViewModel.kt new file mode 100644 index 000000000..5198efbb6 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineViewModel.kt @@ -0,0 +1,1079 @@ +package com.keylesspalace.tusky.components.timeline + +import android.content.SharedPreferences +import android.net.ConnectivityManager +import android.util.Log +import androidx.lifecycle.viewModelScope +import com.google.gson.Gson +import com.keylesspalace.tusky.appstore.* +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.entity.Filter +import com.keylesspalace.tusky.entity.Poll +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.network.FilterModel +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.network.TimelineCases +import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.util.Either +import com.keylesspalace.tusky.util.HttpHeaderLink +import com.keylesspalace.tusky.util.LinkHelper +import com.keylesspalace.tusky.util.RxAwareViewModel +import com.keylesspalace.tusky.util.dec +import com.keylesspalace.tusky.util.firstIsInstanceOrNull +import com.keylesspalace.tusky.util.inc +import com.keylesspalace.tusky.util.isLessThan +import com.keylesspalace.tusky.util.toViewData +import com.keylesspalace.tusky.viewdata.StatusViewData +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.subjects.PublishSubject +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlinx.coroutines.rx3.asFlow +import kotlinx.coroutines.rx3.await +import net.accelf.yuito.TimelineStreamingListener +import okhttp3.HttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.WebSocket +import retrofit2.HttpException +import retrofit2.Response +import java.io.IOException +import javax.inject.Inject + +class TimelineViewModel @Inject constructor( + private val timelineRepo: TimelineRepository, + private val timelineCases: TimelineCases, + private val api: MastodonApi, + private val eventHub: EventHub, + private val accountManager: AccountManager, + private val sharedPreferences: SharedPreferences, + private val filterModel: FilterModel, + private val connectivityManager: ConnectivityManager, + private val okHttpClient: OkHttpClient, + private val gson: Gson, +) : RxAwareViewModel() { + + enum class FailureReason { + NETWORK, + OTHER, + } + + val viewUpdates: Observable + get() = updateViewSubject + + var kind: Kind = Kind.HOME + private set + + var isLoadingInitially = false + private set + var isRefreshing = false + private set + var bottomLoading = false + private set + var initialUpdateFailed = false + private set + var failure: FailureReason? = null + private set + var id: String? = null + private set + var tags: List = emptyList() + private set + + var isStreamingEnabled = false + set(value) { + field = value + when (value) { + true -> startStreaming() + false -> stopStreaming() + } + } + private var webSocket: WebSocket? = null + private var firstOfStreaming = false + + private var alwaysShowSensitiveMedia = false + private var alwaysOpenSpoilers = false + private var filterRemoveReplies = false + private var filterRemoveReblogs = false + private var didLoadEverythingBottom = false + + private var updateViewSubject = PublishSubject.create() + + private var reduceTimelineLoading = false + private var checkMobileNetwork = true + + /** + * For some timeline kinds we must use LINK headers and not just status ids. + */ + private var nextId: String? = null + + val statuses = mutableListOf() + + fun init( + kind: Kind, + id: String?, + tags: List, + isStreamingEnabled: Boolean, + ) { + this.kind = kind + this.id = id + this.tags = tags + this.isStreamingEnabled = isStreamingEnabled + + if (kind == Kind.HOME) { + filterRemoveReplies = + !sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_REPLIES, true) + filterRemoveReblogs = + !sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_BOOSTS, true) + } + this.alwaysShowSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia + this.alwaysOpenSpoilers = accountManager.activeAccount!!.alwaysOpenSpoiler + + viewModelScope.launch { + eventHub.events + .asFlow() + .collect { event -> handleEvent(event) } + } + + reloadFilters() + updateLimitedBandwidthStatus() + } + + override fun onCleared() { + stopStreaming() + super.onCleared() + } + + fun tryStartStreaming() { + if (isStreamingEnabled) { + startStreaming() + } + } + + private fun startStreaming() { + val url = HttpUrl.Builder() + .scheme("https") + .host(MastodonApi.PLACEHOLDER_DOMAIN) + .addPathSegments("api/v1/streaming") + .addQueryParameter( + "stream", + when (kind) { + Kind.HOME -> "user" + Kind.PUBLIC_LOCAL -> "public:local" + Kind.PUBLIC_FEDERATED -> "public" + Kind.LIST -> "list" + else -> throw IllegalStateException("unexpected streaming request: $kind") + } + ) + .apply { + if (kind == Kind.LIST) { + addQueryParameter("list", id) + } + } + .build() + .toString() + .replace("https://", "wss://") + + webSocket?.let { + webSocket = null + } + + val request = Request.Builder().url(url).build() + webSocket = okHttpClient.newWebSocket(request, TimelineStreamingListener(eventHub, gson, kind, id)) + firstOfStreaming = true + } + + fun stopStreaming() { + webSocket?.let { + it.close(1000, null) + webSocket = null + } + } + + private suspend fun updateCurrent() { + val topId = statuses.firstIsInstanceOrNull()?.id ?: return + // Request statuses including current top to refresh all of them + val topIdMinusOne = topId.inc() + val statuses = try { + loadStatuses( + maxId = topIdMinusOne, + sinceId = null, + sinceIdMinusOne = null, + TimelineRequestMode.NETWORK, + ) + } catch (t: Exception) { + initialUpdateFailed = true + if (isExpectedRequestException(t)) { + Log.d(TAG, "Failed updating timeline", t) + triggerViewUpdate() + return + } else { + throw t + } + } + + initialUpdateFailed = false + + // When cached timeline is too old, we would replace it with nothing + if (statuses.isNotEmpty()) { + val mutableStatuses = statuses.toMutableList() + filterStatuses(mutableStatuses) + this.statuses.removeAll { item -> + val id = when (item) { + is StatusViewData.Concrete -> item.id + is StatusViewData.Placeholder -> item.id + } + + id == topId || id.isLessThan(topId) + } + this.statuses.addAll(mutableStatuses.toViewData()) + } + triggerViewUpdate() + } + + private fun isExpectedRequestException(t: Exception) = t is IOException || t is HttpException + + fun refresh(): Job { + return viewModelScope.launch { + isRefreshing = true + failure = null + triggerViewUpdate() + + try { + if (initialUpdateFailed) updateCurrent() + loadAbove() + } catch (e: Exception) { + if (isExpectedRequestException(e)) { + Log.e(TAG, "Failed to refresh", e) + } else { + throw e + } + } finally { + isRefreshing = false + triggerViewUpdate() + } + } + } + + /** When reaching the end of list. WIll optionally show spinner in the end of list. */ + fun loadMore(): Job { + return viewModelScope.launch { + if (didLoadEverythingBottom || bottomLoading) { + return@launch + } + if (statuses.isEmpty()) { + loadInitial().join() + return@launch + } + setLoadingPlaceholderBelow() + + val bottomId: String? = + if (kind == Kind.FAVOURITES || kind == Kind.BOOKMARKS) { + nextId + } else { + statuses.lastOrNull { it is StatusViewData.Concrete } + ?.let { (it as StatusViewData.Concrete).id } + } + try { + loadBelow(bottomId) + } catch (e: Exception) { + if (isExpectedRequestException(e)) { + if (statuses.lastOrNull() is StatusViewData.Placeholder) { + statuses.removeAt(statuses.lastIndex) + } + } else { + throw e + } + } finally { + triggerViewUpdate() + } + } + } + + /** Load and insert statuses below the [bottomId]. Does not indicate progress. */ + private suspend fun loadBelow(bottomId: String?) { + this.bottomLoading = true + try { + val statuses = loadStatuses( + bottomId, + null, + null, + TimelineRequestMode.ANY + ) + addStatusesBelow(statuses.toMutableList()) + } finally { + this.bottomLoading = false + } + } + + private fun setLoadingPlaceholderBelow() { + val last = statuses.last() + val placeholder: StatusViewData.Placeholder + if (last is StatusViewData.Concrete) { + val placeholderId = last.id.dec() + placeholder = StatusViewData.Placeholder(placeholderId, true) + statuses.add(placeholder) + } else { + placeholder = last as StatusViewData.Placeholder + } + statuses[statuses.lastIndex] = placeholder + triggerViewUpdate() + } + + private fun addStatusesBelow(statuses: MutableList>) { + val fullFetch = isFullFetch(statuses) + // Remove placeholder in the bottom if it's there + if (this.statuses.isNotEmpty() && + this.statuses.last() !is StatusViewData.Concrete + ) { + this.statuses.removeAt(this.statuses.lastIndex) + } + + // Removing placeholder if it's the last one from the cache + if (statuses.isNotEmpty() && !statuses[statuses.size - 1].isRight()) { + statuses.removeAt(statuses.size - 1) + } + + val oldSize = this.statuses.size + if (this.statuses.isNotEmpty()) { + addItems(statuses) + } else { + updateStatuses(statuses, fullFetch) + } + if (this.statuses.size == oldSize) { + // This may be a brittle check but seems like it works + // Can we check it using headers somehow? Do all server support them? + didLoadEverythingBottom = true + } + } + + fun loadGap(position: Int): Job { + return viewModelScope.launch { + // check bounds before accessing list, + if (statuses.size < position || position <= 0) { + Log.e(TAG, "Wrong gap position: $position") + return@launch + } + + val fromStatus = statuses[position - 1].asStatusOrNull() + val toStatus = statuses[position + 1].asStatusOrNull() + val toMinusOne = statuses.getOrNull(position + 2)?.asStatusOrNull()?.id + if (fromStatus == null || toStatus == null) { + Log.e(TAG, "Failed to load more at $position, wrong placeholder position") + return@launch + } + val placeholder = statuses[position].asPlaceholderOrNull() ?: run { + Log.e(TAG, "Not a placeholder at $position") + return@launch + } + + val newViewData: StatusViewData = StatusViewData.Placeholder(placeholder.id, true) + statuses[position] = newViewData + triggerViewUpdate() + + try { + val statuses = loadStatuses( + fromStatus.id, + toStatus.id, + toMinusOne, + TimelineRequestMode.NETWORK + ) + replacePlaceholderWithStatuses( + statuses.toMutableList(), + isFullFetch(statuses), + position + ) + } catch (t: Exception) { + if (isExpectedRequestException(t)) { + Log.e(TAG, "Failed to load gap", t) + if (statuses[position] is StatusViewData.Placeholder) { + statuses[position] = StatusViewData.Placeholder(placeholder.id, false) + } + } else { + throw t + } + } + } + } + + fun shouldReplyInQuick(): Boolean { + return when (kind) { + Kind.HOME, + Kind.PUBLIC_LOCAL, + Kind.PUBLIC_FEDERATED, + Kind.TAG, + Kind.FAVOURITES, + Kind.LIST -> true + Kind.BOOKMARKS, + Kind.USER, + Kind.USER_PINNED, + Kind.USER_WITH_REPLIES -> false + } + } + + fun reblog(reblog: Boolean, position: Int): Job = viewModelScope.launch { + val status = statuses[position].asStatusOrNull() ?: return@launch + try { + timelineCases.reblog(status.actionableId, reblog).await() + } catch (t: Exception) { + ifExpected(t) { + Log.d(TAG, "Failed to reblog status " + status.actionableId, t) + } + } + } + + fun favorite(favorite: Boolean, position: Int): Job = viewModelScope.launch { + val status = statuses[position].asStatusOrNull() ?: return@launch + + try { + timelineCases.favourite(status.actionableId, favorite).await() + } catch (t: Exception) { + ifExpected(t) { + Log.d(TAG, "Failed to favourite status " + status.actionableId, t) + } + } + } + + fun bookmark(bookmark: Boolean, position: Int): Job = viewModelScope.launch { + val status = statuses[position].asStatusOrNull() ?: return@launch + try { + timelineCases.bookmark(status.actionableId, bookmark).await() + } catch (t: Exception) { + ifExpected(t) { + Log.d(TAG, "Failed to favourite status " + status.actionableId, t) + } + } + } + + fun voteInPoll(position: Int, choices: List): Job = viewModelScope.launch { + val status = statuses[position].asStatusOrNull() ?: return@launch + + val poll = status.status.poll ?: run { + Log.w(TAG, "No poll on status ${status.id}") + return@launch + } + + val votedPoll = poll.votedCopy(choices) + updatePoll(status, votedPoll) + + try { + timelineCases.voteInPoll(status.actionableId, poll.id, choices).await() + } catch (t: Exception) { + ifExpected(t) { + Log.d(TAG, "Failed to vote in poll: " + status.actionableId, t) + } + } + } + + private fun updatePoll( + status: StatusViewData.Concrete, + newPoll: Poll + ) { + updateStatusById(status.id) { + it.copy(status = it.status.copy(poll = newPoll)) + } + } + + fun changeExpanded(expanded: Boolean, position: Int) { + updateStatusAt(position) { it.copy(isExpanded = expanded) } + triggerViewUpdate() + } + + fun changeContentHidden(isShowing: Boolean, position: Int) { + updateStatusAt(position) { it.copy(isShowingContent = isShowing) } + triggerViewUpdate() + } + + fun changeContentCollapsed(isCollapsed: Boolean, position: Int) { + updateStatusAt(position) { it.copy(isCollapsed = isCollapsed) } + triggerViewUpdate() + } + + private fun removeAllByAccountId(accountId: String) { + statuses.removeAll { vm -> + val status = vm.asStatusOrNull()?.status ?: return@removeAll false + status.account.id == accountId || status.actionableStatus.account.id == accountId + } + } + + private fun removeAllByInstance(instance: String) { + statuses.removeAll { vd -> + val status = vd.asStatusOrNull()?.status ?: return@removeAll false + LinkHelper.getDomain(status.account.url) == instance + } + } + + private fun triggerViewUpdate() { + this.updateViewSubject.onNext(Unit) + } + + private suspend fun loadStatuses( + maxId: String?, + sinceId: String?, + sinceIdMinusOne: String?, + homeMode: TimelineRequestMode, + ): List { + val statuses = if (kind == Kind.HOME) { + timelineRepo.getStatuses(maxId, sinceId, sinceIdMinusOne, LOAD_AT_ONCE, homeMode) + .await() + } else { + val response = fetchStatusesForKind(maxId, sinceId, LOAD_AT_ONCE).await() + if (response.isSuccessful) { + val newNextId = extractNextId(response) + if (newNextId != null) { + // when we reach the bottom of the list, we won't have a new link. If + // we blindly write `null` here we will start loading from the top + // again. + nextId = newNextId + } + response.body()?.map { Either.Right(it) } ?: listOf() + } else { + throw HttpException(response) + } + } + + filterStatuses(statuses.toMutableList()) + + return statuses + } + + private fun updateStatuses( + newStatuses: MutableList>, + fullFetch: Boolean + ) { + if (statuses.isEmpty()) { + statuses.addAll(newStatuses.toViewData()) + } else { + val lastOfNew = newStatuses.lastOrNull() + val index = if (lastOfNew == null) -1 + else statuses.indexOfLast { it.asStatusOrNull()?.id === lastOfNew.asRightOrNull()?.id } + if (index >= 0) { + statuses.subList(0, index).clear() + } + + val newIndex = + newStatuses.indexOfFirst { + it.isRight() && it.asRight().id == (statuses[0] as? StatusViewData.Concrete)?.id + } + if (newIndex == -1) { + if (index == -1 && fullFetch) { + val placeholderId = + newStatuses.last { status -> status.isRight() }.asRight().id.inc() + newStatuses.add(Either.Left(Placeholder(placeholderId))) + } + statuses.addAll(0, newStatuses.toViewData()) + } else { + statuses.addAll(0, newStatuses.subList(0, newIndex).toViewData()) + } + } + // Remove all consecutive placeholders + removeConsecutivePlaceholders() + this.triggerViewUpdate() + } + + private fun filterViewData(viewData: MutableList) { + viewData.removeAll { vd -> + vd.asStatusOrNull()?.status?.let { shouldFilterStatus(it) } ?: false + } + } + + private fun filterStatuses(statuses: MutableList>) { + statuses.removeAll { status -> + status.asRightOrNull()?.let { shouldFilterStatus(it) } ?: false + } + } + + private fun shouldFilterStatus(status: Status): Boolean { + return status.inReplyToId != null && filterRemoveReplies || + status.reblog != null && filterRemoveReblogs || + filterModel.shouldFilterStatus(status.actionableStatus) + } + + private fun extractNextId(response: Response<*>): String? { + val linkHeader = response.headers()["Link"] ?: return null + val links = HttpHeaderLink.parse(linkHeader) + val nextHeader = HttpHeaderLink.findByRelationType(links, "next") ?: return null + val nextLink = nextHeader.uri ?: return null + return nextLink.getQueryParameter("max_id") + } + + private suspend fun tryCache() { + // Request timeline from disk to make it quick, then replace it with timeline from + // the server to update it + val statuses = + timelineRepo.getStatuses(null, null, null, LOAD_AT_ONCE, TimelineRequestMode.DISK) + .await() + + val mutableStatusResponse = statuses.toMutableList() + filterStatuses(mutableStatusResponse) + if (statuses.size > 1) { + clearPlaceholdersForResponse(mutableStatusResponse) + this.statuses.clear() + this.statuses.addAll(statuses.toViewData()) + } + } + + fun loadInitial(): Job { + return viewModelScope.launch { + if (statuses.isNotEmpty() || initialUpdateFailed || isLoadingInitially) { + return@launch + } + isLoadingInitially = true + failure = null + triggerViewUpdate() + + if (kind == Kind.HOME) { + tryCache() + isLoadingInitially = statuses.isEmpty() + updateCurrent() + try { + loadAbove() + } catch (e: Exception) { + Log.e(TAG, "Loading above failed", e) + if (!isExpectedRequestException(e)) { + throw e + } else if (statuses.isEmpty()) { + failure = + if (e is IOException) FailureReason.NETWORK + else FailureReason.OTHER + } + } finally { + isLoadingInitially = false + triggerViewUpdate() + } + } else { + try { + loadBelow(null) + } catch (e: IOException) { + failure = FailureReason.NETWORK + } catch (e: HttpException) { + failure = FailureReason.OTHER + } finally { + isLoadingInitially = false + triggerViewUpdate() + } + } + } + } + + private suspend fun loadAbove() { + var firstOrNull: String? = null + var secondOrNull: String? = null + for (i in statuses.indices) { + val status = statuses[i].asStatusOrNull() ?: continue + firstOrNull = status.id + secondOrNull = statuses.getOrNull(i + 1)?.asStatusOrNull()?.id + break + } + + try { + if (firstOrNull != null) { + triggerViewUpdate() + + val statuses = loadStatuses( + maxId = null, + sinceId = firstOrNull, + sinceIdMinusOne = secondOrNull, + homeMode = TimelineRequestMode.NETWORK + ) + + val fullFetch = isFullFetch(statuses) + updateStatuses(statuses.toMutableList(), fullFetch) + } else { + loadBelow(null) + } + } finally { + triggerViewUpdate() + } + } + + private fun isFullFetch(statuses: List) = statuses.size >= LOAD_AT_ONCE + + fun fullyRefresh(): Job { + this.statuses.clear() + return loadInitial() + } + + private fun fetchStatusesForKind( + fromId: String?, + uptoId: String?, + limit: Int + ): Single>> { + return when (kind) { + Kind.HOME -> api.homeTimeline(fromId, uptoId, limit) + Kind.PUBLIC_FEDERATED -> api.publicTimeline(null, fromId, uptoId, limit) + Kind.PUBLIC_LOCAL -> api.publicTimeline(true, fromId, uptoId, limit) + Kind.TAG -> { + val firstHashtag = tags[0] + val additionalHashtags = tags.subList(1, tags.size) + api.hashtagTimeline(firstHashtag, additionalHashtags, null, fromId, uptoId, limit) + } + Kind.USER -> api.accountStatuses( + id!!, + fromId, + uptoId, + limit, + excludeReplies = true, + onlyMedia = null, + pinned = null + ) + Kind.USER_PINNED -> api.accountStatuses( + id!!, + fromId, + uptoId, + limit, + excludeReplies = null, + onlyMedia = null, + pinned = true + ) + Kind.USER_WITH_REPLIES -> api.accountStatuses( + id!!, + fromId, + uptoId, + limit, + excludeReplies = null, + onlyMedia = null, + pinned = null + ) + Kind.FAVOURITES -> api.favourites(fromId, uptoId, limit) + Kind.BOOKMARKS -> api.bookmarks(fromId, uptoId, limit) + Kind.LIST -> api.listTimeline(id!!, fromId, uptoId, limit) + } + } + + private fun replacePlaceholderWithStatuses( + newStatuses: MutableList>, + fullFetch: Boolean, + pos: Int + ) { + val placeholder = statuses[pos] + if (placeholder is StatusViewData.Placeholder) { + statuses.removeAt(pos) + } + if (newStatuses.isEmpty()) { + return + } + val newViewData = newStatuses + .toViewData() + .toMutableList() + + if (fullFetch) { + newViewData.add(placeholder) + } + statuses.addAll(pos, newViewData) + removeConsecutivePlaceholders() + triggerViewUpdate() + } + + private fun removeConsecutivePlaceholders() { + for (i in 0 until statuses.size - 1) { + if (statuses[i] is StatusViewData.Placeholder && + statuses[i + 1] is StatusViewData.Placeholder + ) { + statuses.removeAt(i) + } + } + } + + private fun addItems(newStatuses: List>) { + if (newStatuses.isEmpty()) { + return + } + statuses.addAll(newStatuses.toViewData()) + removeConsecutivePlaceholders() + } + + /** + * For certain requests we don't want to see placeholders, they will be removed some other way + */ + private fun clearPlaceholdersForResponse(statuses: MutableList>) { + statuses.removeAll { status -> status.isLeft() } + } + + private fun handleReblogEvent(reblogEvent: ReblogEvent) { + updateStatusById(reblogEvent.statusId) { + it.copy(status = it.status.copy(reblogged = reblogEvent.reblog)) + } + } + + private fun handleFavEvent(favEvent: FavoriteEvent) { + updateActionableStatusById(favEvent.statusId) { + it.copy(favourited = favEvent.favourite) + } + } + + private fun handleBookmarkEvent(bookmarkEvent: BookmarkEvent) { + updateActionableStatusById(bookmarkEvent.statusId) { + it.copy(bookmarked = bookmarkEvent.bookmark) + } + } + + private fun handlePinEvent(pinEvent: PinEvent) { + updateActionableStatusById(pinEvent.statusId) { + it.copy(pinned = pinEvent.pinned) + } + } + + private fun handleStatusComposeEvent(status: Status) { + if (isStreamingEnabled) { + return + } + + var reload = when (kind) { + Kind.HOME, Kind.PUBLIC_FEDERATED, Kind.PUBLIC_LOCAL -> true + Kind.USER, Kind.USER_WITH_REPLIES -> status.account.id == id + Kind.TAG, Kind.FAVOURITES, Kind.LIST, Kind.BOOKMARKS, Kind.USER_PINNED -> false + } + + if (!reload) { + return + } + + if (reduceTimelineLoading) { + reload = checkMobileNetwork && !connectivityManager.isActiveNetworkMetered + } + + if (reload) { + refresh() + } + } + + private fun handleStreamUpdateEvent(event: StreamUpdateEvent) { + val status = event.status + if (shouldFilterStatus(status)) { + return + } + + val newStatuses: MutableList> = mutableListOf(Either.Right(status)) + + val knownIndex = statuses.indexOfFirst { it.asStatusOrNull()?.id == status.id } + if (knownIndex == -1) { + if (firstOfStreaming) { + firstOfStreaming = false + val first = statuses.first() + if (first is StatusViewData.Concrete) { + val placeholder = Placeholder(first.id + 1) + newStatuses.add(Either.Left(placeholder)) + } + } + + statuses.addAll(0, newStatuses.toViewData()) + } else { + statuses[knownIndex] = status.toViewData(alwaysShowSensitiveMedia, alwaysOpenSpoilers) + } + + triggerViewUpdate() + + if (kind == Kind.HOME) { + timelineRepo.addStatuses(newStatuses) + } + } + + private fun deleteStatusById(id: String) { + for (i in statuses.indices) { + val either = statuses[i] + if (either.asStatusOrNull()?.id == id) { + statuses.removeAt(i) + break + } + } + } + + private fun onPreferenceChanged(key: String) { + when (key) { + PrefKeys.TAB_FILTER_HOME_REPLIES -> { + val filter = sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_REPLIES, true) + val oldRemoveReplies = filterRemoveReplies + filterRemoveReplies = kind == Kind.HOME && !filter + if (statuses.isNotEmpty() && oldRemoveReplies != filterRemoveReplies) { + fullyRefresh() + } + } + PrefKeys.TAB_FILTER_HOME_BOOSTS -> { + val filter = sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_BOOSTS, true) + val oldRemoveReblogs = filterRemoveReblogs + filterRemoveReblogs = kind == Kind.HOME && !filter + if (statuses.isNotEmpty() && oldRemoveReblogs != filterRemoveReblogs) { + fullyRefresh() + } + } + Filter.HOME, Filter.NOTIFICATIONS, Filter.THREAD, Filter.PUBLIC, Filter.ACCOUNT -> { + if (filterContextMatchesKind(kind, listOf(key))) { + reloadFilters() + } + } + PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA -> { + // it is ok if only newly loaded statuses are affected, no need to fully refresh + alwaysShowSensitiveMedia = + accountManager.activeAccount!!.alwaysShowSensitiveMedia + } + PrefKeys.LIMITED_BANDWIDTH_ACTIVE, + PrefKeys.LIMITED_BANDWIDTH_TIMELINE_LOADING, + PrefKeys.LIMITED_BANDWIDTH_ONLY_MOBILE_NETWORK -> { + updateLimitedBandwidthStatus() + } + } + } + + private fun updateLimitedBandwidthStatus() { + reduceTimelineLoading = sharedPreferences.getBoolean(PrefKeys.LIMITED_BANDWIDTH_ACTIVE, false) + && sharedPreferences.getBoolean(PrefKeys.LIMITED_BANDWIDTH_TIMELINE_LOADING, true) + checkMobileNetwork = sharedPreferences.getBoolean(PrefKeys.LIMITED_BANDWIDTH_ONLY_MOBILE_NETWORK, true) + } + + // public for now + fun filterContextMatchesKind( + kind: Kind, + filterContext: List + ): Boolean { + // home, notifications, public, thread + return when (kind) { + Kind.HOME, Kind.LIST -> filterContext.contains( + Filter.HOME + ) + Kind.PUBLIC_FEDERATED, Kind.PUBLIC_LOCAL, Kind.TAG -> filterContext.contains( + Filter.PUBLIC + ) + Kind.FAVOURITES -> filterContext.contains(Filter.PUBLIC) || filterContext.contains( + Filter.NOTIFICATIONS + ) + Kind.USER, Kind.USER_WITH_REPLIES, Kind.USER_PINNED -> filterContext.contains( + Filter.ACCOUNT + ) + else -> false + } + } + + private fun handleEvent(event: Event) { + when (event) { + is FavoriteEvent -> handleFavEvent(event) + is ReblogEvent -> handleReblogEvent(event) + is BookmarkEvent -> handleBookmarkEvent(event) + is PinEvent -> handlePinEvent(event) + is MuteConversationEvent -> fullyRefresh() + is UnfollowEvent -> { + if (kind == Kind.HOME) { + val id = event.accountId + removeAllByAccountId(id) + } + } + is BlockEvent -> { + if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { + val id = event.accountId + removeAllByAccountId(id) + } + } + is MuteEvent -> { + if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { + val id = event.accountId + removeAllByAccountId(id) + } + } + is DomainMuteEvent -> { + if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { + val instance = event.instance + removeAllByInstance(instance) + } + } + is StatusDeletedEvent -> { + if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { + val id = event.statusId + deleteStatusById(id) + } + } + is StatusComposedEvent -> { + val status = event.status + handleStatusComposeEvent(status) + } + is PreferenceChangedEvent -> { + onPreferenceChanged(event.preferenceKey) + } + is StreamUpdateEvent -> { + if (isStreamingEnabled && event.targetKind == kind && event.targetIdentifier == id) { + handleStreamUpdateEvent(event) + } + } + } + } + + private inline fun updateActionableStatusById( + id: String, + updater: (Status) -> Status + ) { + val pos = statuses.indexOfFirst { it.asStatusOrNull()?.id == id } + if (pos == -1) return + updateStatusAt(pos) { + if (it.status.reblog != null) { + it.copy(status = it.status.copy(reblog = updater(it.status.reblog))) + } else { + it.copy(status = updater(it.status)) + } + } + } + + private inline fun updateStatusById( + id: String, + updater: (StatusViewData.Concrete) -> StatusViewData.Concrete + ) { + val pos = statuses.indexOfFirst { it.asStatusOrNull()?.id == id } + if (pos == -1) return + updateStatusAt(pos, updater) + } + + private inline fun updateStatusAt( + position: Int, + updater: (StatusViewData.Concrete) -> StatusViewData.Concrete + ) { + val status = statuses.getOrNull(position)?.asStatusOrNull() ?: return + statuses[position] = updater(status) + triggerViewUpdate() + } + + private fun List.toViewData(): List = this.map { + when (it) { + is Either.Right -> it.value.toViewData( + alwaysShowSensitiveMedia, + alwaysOpenSpoilers + ) + is Either.Left -> StatusViewData.Placeholder(it.value.id, false) + } + } + + private fun reloadFilters() { + viewModelScope.launch { + val filters = try { + api.getFilters().await() + } catch (t: Exception) { + Log.e(TAG, "Failed to fetch filters", t) + return@launch + } + filterModel.initWithFilters( + filters.filter { + filterContextMatchesKind(kind, it.context) + } + ) + filterViewData(this@TimelineViewModel.statuses) + } + } + + private inline fun ifExpected( + t: Exception, + cb: () -> Unit + ) { + if (isExpectedRequestException(t)) { + cb() + } else { + throw t + } + } + + companion object { + private const val TAG = "TimelineVM" + internal const val LOAD_AT_ONCE = 30 + } + + enum class Kind { + HOME, PUBLIC_LOCAL, PUBLIC_FEDERATED, TAG, USER, USER_PINNED, USER_WITH_REPLIES, FAVOURITES, LIST, BOOKMARKS + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AccountDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/AccountDao.kt index e1c64e28d..218c9b8f4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AccountDao.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/AccountDao.kt @@ -15,7 +15,11 @@ package com.keylesspalace.tusky.db -import androidx.room.* +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query @Dao interface AccountDao { @@ -27,5 +31,4 @@ interface AccountDao { @Query("SELECT * FROM AccountEntity ORDER BY id ASC") fun loadAll(): List - } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt index ab6dbb7eb..0c25cbbc9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt @@ -21,42 +21,49 @@ import androidx.room.PrimaryKey import androidx.room.TypeConverters import com.keylesspalace.tusky.TabData import com.keylesspalace.tusky.defaultTabs - import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.Status -@Entity(indices = [Index(value = ["domain", "accountId"], - unique = true)]) +@Entity( + indices = [ + Index( + value = ["domain", "accountId"], + unique = true + ) + ] +) @TypeConverters(Converters::class) -data class AccountEntity(@field:PrimaryKey(autoGenerate = true) var id: Long, - val domain: String, - var accessToken: String, - var isActive: Boolean, - var accountId: String = "", - var username: String = "", - var displayName: String = "", - var profilePictureUrl: String = "", - var notificationsEnabled: Boolean = true, - var notificationsMentioned: Boolean = true, - var notificationsFollowed: Boolean = true, - var notificationsFollowRequested: Boolean = false, - var notificationsReblogged: Boolean = true, - var notificationsFavorited: Boolean = true, - var notificationsPolls: Boolean = true, - var notificationsSubscriptions: Boolean = true, - var notificationSound: Boolean = true, - var notificationVibration: Boolean = true, - var notificationLight: Boolean = true, - var defaultPostPrivacy: Status.Visibility = Status.Visibility.PUBLIC, - var defaultMediaSensitivity: Boolean = false, - var alwaysShowSensitiveMedia: Boolean = false, - var alwaysOpenSpoiler: Boolean = false, - var mediaPreviewEnabled: Boolean = true, - var lastNotificationId: String = "0", - var activeNotifications: String = "[]", - var emojis: List = emptyList(), - var tabPreferences: List = defaultTabs(), - var notificationsFilter: String = "[\"follow_request\"]") { +data class AccountEntity( + @field:PrimaryKey(autoGenerate = true) var id: Long, + val domain: String, + var accessToken: String, + var isActive: Boolean, + var accountId: String = "", + var username: String = "", + var displayName: String = "", + var profilePictureUrl: String = "", + var notificationsEnabled: Boolean = true, + var notificationsMentioned: Boolean = true, + var notificationsFollowed: Boolean = true, + var notificationsFollowRequested: Boolean = false, + var notificationsReblogged: Boolean = true, + var notificationsFavorited: Boolean = true, + var notificationsPolls: Boolean = true, + var notificationsSubscriptions: Boolean = true, + var notificationSound: Boolean = true, + var notificationVibration: Boolean = true, + var notificationLight: Boolean = true, + var defaultPostPrivacy: Status.Visibility = Status.Visibility.PUBLIC, + var defaultMediaSensitivity: Boolean = false, + var alwaysShowSensitiveMedia: Boolean = false, + var alwaysOpenSpoiler: Boolean = false, + var mediaPreviewEnabled: Boolean = true, + var lastNotificationId: String = "0", + var activeNotifications: String = "[]", + var emojis: List = emptyList(), + var tabPreferences: List = defaultTabs(), + var notificationsFilter: String = "[\"follow_request\"]" +) { val identifier: String get() = "$domain:$accountId" diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt b/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt index fc10adb63..3de34f55e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt @@ -18,7 +18,7 @@ package com.keylesspalace.tusky.db import android.util.Log import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Status -import java.util.* +import java.util.Locale import javax.inject.Inject import javax.inject.Singleton @@ -65,8 +65,7 @@ class AccountManager @Inject constructor(db: AppDatabase) { val maxAccountId = accounts.maxByOrNull { it.id }?.id ?: 0 val newAccountId = maxAccountId + 1 - activeAccount = AccountEntity(id = newAccountId, domain = domain.toLowerCase(Locale.ROOT), accessToken = accessToken, isActive = true) - + activeAccount = AccountEntity(id = newAccountId, domain = domain.lowercase(Locale.ROOT), accessToken = accessToken, isActive = true) } /** @@ -79,7 +78,6 @@ class AccountManager @Inject constructor(db: AppDatabase) { Log.d(TAG, "saveAccount: saving account with id " + account.id) accountDao.insertOrReplace(account) } - } /** @@ -103,9 +101,7 @@ class AccountManager @Inject constructor(db: AppDatabase) { activeAccount = null } return activeAccount - } - } /** @@ -129,13 +125,12 @@ class AccountManager @Inject constructor(db: AppDatabase) { val accountIndex = accounts.indexOf(it) if (accountIndex != -1) { - //in case the user was already logged in with this account, remove the old information + // in case the user was already logged in with this account, remove the old information accounts.removeAt(accountIndex) accounts.add(accountIndex, it) } else { accounts.add(it) } - } } @@ -194,5 +189,4 @@ class AccountManager @Inject constructor(db: AppDatabase) { id == accountId } } - -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java index d35fd3891..624c15ac1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java +++ b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java @@ -24,16 +24,17 @@ import androidx.sqlite.db.SupportSQLiteDatabase; import com.keylesspalace.tusky.TabDataKt; import com.keylesspalace.tusky.components.conversation.ConversationEntity; +import java.io.File; + /** * DB version & declare DAO */ -@Database(entities = { TootEntity.class, DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class, +@Database(entities = { DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class, TimelineAccountEntity.class, ConversationEntity.class - }, version = 25) + }, version = 27) public abstract class AppDatabase extends RoomDatabase { - public abstract TootDao tootDao(); public abstract AccountDao accountDao(); public abstract InstanceDao instanceDao(); public abstract ConversationsDao conversationDao(); @@ -365,4 +366,38 @@ public abstract class AppDatabase extends RoomDatabase { ); } }; + + public static class Migration25_26 extends Migration { + + private final File oldDraftDirectory; + + public Migration25_26(File oldDraftDirectory) { + super(25, 26); + this.oldDraftDirectory = oldDraftDirectory; + } + + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL("DROP TABLE `TootEntity`"); + + if (oldDraftDirectory != null && oldDraftDirectory.isDirectory()) { + File[] oldDraftFiles = oldDraftDirectory.listFiles(); + if (oldDraftFiles != null) { + for (File file : oldDraftFiles) { + if (!file.isDirectory()) { + file.delete(); + } + } + } + + } + } + } + + 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"); + } + }; } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/ConversationsDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/ConversationsDao.kt index 00f32f533..393a23925 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/ConversationsDao.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/ConversationsDao.kt @@ -15,27 +15,28 @@ package com.keylesspalace.tusky.db -import androidx.paging.DataSource -import androidx.room.* +import androidx.paging.PagingSource +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query import com.keylesspalace.tusky.components.conversation.ConversationEntity -import io.reactivex.Single @Dao interface ConversationsDao { @Insert(onConflict = OnConflictStrategy.REPLACE) - fun insert(conversations: List) + suspend fun insert(conversations: List) @Insert(onConflict = OnConflictStrategy.REPLACE) - fun insert(conversation: ConversationEntity): Single + suspend fun insert(conversation: ConversationEntity): Long @Delete - fun delete(conversation: ConversationEntity): Single + suspend fun delete(conversation: ConversationEntity): Int @Query("SELECT * FROM ConversationEntity WHERE accountId = :accountId ORDER BY s_createdAt DESC") - fun conversationsForAccount(accountId: Long) : DataSource.Factory + fun conversationsForAccount(accountId: Long): PagingSource @Query("DELETE FROM ConversationEntity WHERE accountId = :accountId") fun deleteForAccount(accountId: Long) - - } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt b/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt index 235f11347..07e298620 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt @@ -26,18 +26,23 @@ import com.keylesspalace.tusky.STREAMING import com.keylesspalace.tusky.TabData import com.keylesspalace.tusky.components.conversation.ConversationAccountEntity import com.keylesspalace.tusky.createTabDataFromId -import com.keylesspalace.tusky.entity.* +import com.keylesspalace.tusky.entity.Attachment +import com.keylesspalace.tusky.entity.Emoji +import com.keylesspalace.tusky.entity.NewPoll +import com.keylesspalace.tusky.entity.Poll +import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.util.trimTrailingWhitespace import java.net.URLDecoder import java.net.URLEncoder -import java.util.* +import java.util.ArrayList +import java.util.Date import javax.inject.Inject import javax.inject.Singleton @ProvidedTypeConverter @Singleton class Converters @Inject constructor ( - private val gson: Gson + private val gson: Gson ) { @TypeConverter @@ -63,10 +68,10 @@ class Converters @Inject constructor ( @TypeConverter fun stringToTabData(str: String?): List? { return str?.split(";") - ?.map { - val data = it.split(":") - createTabDataFromId(data[0], data.drop(1).map { s -> URLDecoder.decode(s, "UTF-8") }) - } + ?.map { + val data = it.split(":") + createTabDataFromId(data[0], data.drop(1).map { s -> URLDecoder.decode(s, "UTF-8") }) + } } @TypeConverter @@ -109,13 +114,13 @@ class Converters @Inject constructor ( } @TypeConverter - fun mentionArrayToJson(mentionArray: Array?): String? { + fun mentionListToJson(mentionArray: List?): String? { return gson.toJson(mentionArray) } @TypeConverter - fun jsonToMentionArray(mentionListJson: String?): Array? { - return gson.fromJson(mentionListJson, object : TypeToken>() {}.type) + fun jsonToMentionArray(mentionListJson: String?): List? { + return gson.fromJson(mentionListJson, object : TypeToken>() {}.type) } @TypeConverter @@ -130,7 +135,7 @@ class Converters @Inject constructor ( @TypeConverter fun spannedToString(spanned: Spanned?): String? { - if(spanned == null) { + if (spanned == null) { return null } return spanned.toHtml() @@ -138,7 +143,7 @@ class Converters @Inject constructor ( @TypeConverter fun stringToSpanned(spannedString: String?): Spanned? { - if(spannedString == null) { + if (spannedString == null) { return null } return spannedString.parseAsHtml().trimTrailingWhitespace() diff --git a/app/src/main/java/com/keylesspalace/tusky/db/DraftDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/DraftDao.kt index 065af1aed..8029dd236 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/DraftDao.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/DraftDao.kt @@ -15,30 +15,27 @@ package com.keylesspalace.tusky.db -import androidx.paging.DataSource +import androidx.paging.PagingSource import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query -import io.reactivex.Completable -import io.reactivex.Single @Dao interface DraftDao { @Insert(onConflict = OnConflictStrategy.REPLACE) - fun insertOrReplace(draft: DraftEntity): Completable + suspend fun insertOrReplace(draft: DraftEntity) @Query("SELECT * FROM DraftEntity WHERE accountId = :accountId ORDER BY id ASC") - fun loadDrafts(accountId: Long): DataSource.Factory + fun draftsPagingSource(accountId: Long): PagingSource @Query("SELECT * FROM DraftEntity WHERE accountId = :accountId") - fun loadDraftsSingle(accountId: Long): Single> + suspend fun loadDrafts(accountId: Long): List @Query("DELETE FROM DraftEntity WHERE id = :id") - fun delete(id: Int): Completable + suspend fun delete(id: Int) @Query("SELECT * FROM DraftEntity WHERE id = :id") - fun find(id: Int): Single - + suspend fun find(id: Int): DraftEntity? } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/DraftEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/DraftEntity.kt index 184ff2c30..0df30040e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/DraftEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/DraftEntity.kt @@ -28,24 +28,24 @@ import kotlinx.parcelize.Parcelize @Entity @TypeConverters(Converters::class) data class DraftEntity( - @PrimaryKey(autoGenerate = true) val id: Int = 0, - val accountId: Long, - val inReplyToId: String?, - val content: String?, - val contentWarning: String?, - val sensitive: Boolean, - val visibility: Status.Visibility, - val attachments: List, - val poll: NewPoll?, - val failedToSend: Boolean + @PrimaryKey(autoGenerate = true) val id: Int = 0, + val accountId: Long, + val inReplyToId: String?, + val content: String?, + val contentWarning: String?, + val sensitive: Boolean, + val visibility: Status.Visibility, + val attachments: List, + val poll: NewPoll?, + val failedToSend: Boolean ) @Parcelize data class DraftAttachment( - val uriString: String, - val description: String?, - val type: Type -): Parcelable { + val uriString: String, + val description: String?, + val type: Type +) : Parcelable { val uri: Uri get() = uriString.toUri() diff --git a/app/src/main/java/com/keylesspalace/tusky/db/InstanceDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/InstanceDao.kt index 0c78349ef..52fc3aa86 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/InstanceDao.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/InstanceDao.kt @@ -19,7 +19,7 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query -import io.reactivex.Single +import io.reactivex.rxjava3.core.Single @Dao interface InstanceDao { diff --git a/app/src/main/java/com/keylesspalace/tusky/db/InstanceEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/InstanceEntity.kt index 1e2adaf04..ac4464f2d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/InstanceEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/InstanceEntity.kt @@ -23,10 +23,10 @@ import com.keylesspalace.tusky.entity.Emoji @Entity @TypeConverters(Converters::class) data class InstanceEntity( - @field:PrimaryKey var instance: String, - val emojiList: List?, - val maximumTootCharacters: Int?, - val maxPollOptions: Int?, - val maxPollOptionLength: Int?, - val version: String? + @field:PrimaryKey var instance: String, + val emojiList: List?, + val maximumTootCharacters: Int?, + val maxPollOptions: Int?, + val maxPollOptionLength: Int?, + val version: String? ) diff --git a/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt index 9c50ad03b..6bbc08047 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt @@ -6,7 +6,7 @@ import androidx.room.OnConflictStrategy.IGNORE import androidx.room.OnConflictStrategy.REPLACE import androidx.room.Query import androidx.room.Transaction -import io.reactivex.Single +import io.reactivex.rxjava3.core.Single @Dao abstract class TimelineDao { @@ -17,11 +17,11 @@ abstract class TimelineDao { @Insert(onConflict = REPLACE) abstract fun insertStatus(timelineAccountEntity: TimelineStatusEntity): Long - @Insert(onConflict = IGNORE) abstract fun insertStatusIfNotThere(timelineAccountEntity: TimelineStatusEntity): Long - @Query(""" + @Query( + """ SELECT s.serverId, s.url, s.timelineUserId, s.authorServerId, s.inReplyToId, s.inReplyToAccountId, s.createdAt, s.emojis, s.reblogsCount, s.favouritesCount, s.reblogged, s.favourited, s.bookmarked, s.sensitive, @@ -46,47 +46,62 @@ AND (CASE WHEN :sinceId IS NOT NULL THEN (LENGTH(s.serverId) > LENGTH(:sinceId) OR LENGTH(s.serverId) == LENGTH(:sinceId) AND s.serverId > :sinceId) ELSE 1 END) ORDER BY LENGTH(s.serverId) DESC, s.serverId DESC -LIMIT :limit""") +LIMIT :limit""" + ) abstract fun getStatusesForAccount(account: Long, maxId: String?, sinceId: String?, limit: Int): Single> - @Transaction - open fun insertInTransaction(status: TimelineStatusEntity, account: TimelineAccountEntity, - reblogAccount: TimelineAccountEntity?) { + open fun insertInTransaction( + status: TimelineStatusEntity, + account: TimelineAccountEntity, + reblogAccount: TimelineAccountEntity? + ) { insertAccount(account) reblogAccount?.let(this::insertAccount) insertStatus(status) } - @Query("""DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND + @Query( + """DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND (LENGTH(serverId) < LENGTH(:maxId) OR LENGTH(serverId) == LENGTH(:maxId) AND serverId < :maxId) AND (LENGTH(serverId) > LENGTH(:minId) OR LENGTH(serverId) == LENGTH(:minId) AND serverId > :minId) - """) + """ + ) abstract fun deleteRange(accountId: Long, minId: String, maxId: String) - @Query("""DELETE FROM TimelineStatusEntity WHERE authorServerId = null + @Query( + """DELETE FROM TimelineStatusEntity WHERE authorServerId = null AND timelineUserId = :account AND (LENGTH(serverId) < LENGTH(:maxId) OR LENGTH(serverId) == LENGTH(:maxId) AND serverId < :maxId) AND (LENGTH(serverId) > LENGTH(:sinceId) OR LENGTH(serverId) == LENGTH(:sinceId) AND serverId > :sinceId) -""") +""" + ) abstract fun removeAllPlaceholdersBetween(account: Long, maxId: String, sinceId: String) - @Query("""UPDATE TimelineStatusEntity SET favourited = :favourited -WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""") + @Query( + """UPDATE TimelineStatusEntity SET favourited = :favourited +WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""" + ) abstract fun setFavourited(accountId: Long, statusId: String, favourited: Boolean) - @Query("""UPDATE TimelineStatusEntity SET bookmarked = :bookmarked -WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""") + @Query( + """UPDATE TimelineStatusEntity SET bookmarked = :bookmarked +WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""" + ) abstract fun setBookmarked(accountId: Long, statusId: String, bookmarked: Boolean) - @Query("""UPDATE TimelineStatusEntity SET reblogged = :reblogged -WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""") + @Query( + """UPDATE TimelineStatusEntity SET reblogged = :reblogged +WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""" + ) abstract fun setReblogged(accountId: Long, statusId: String, reblogged: Boolean) - @Query("""DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND -(authorServerId = :userId OR reblogAccountId = :userId)""") + @Query( + """DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND +(authorServerId = :userId OR reblogAccountId = :userId)""" + ) abstract fun removeAllByUser(accountId: Long, userId: String) @Query("DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId") @@ -95,14 +110,18 @@ WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = @Query("DELETE FROM TimelineAccountEntity WHERE timelineUserId = :accountId") abstract fun removeAllUsersForAccount(accountId: Long) - @Query("""DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId -AND serverId = :statusId""") + @Query( + """DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId +AND serverId = :statusId""" + ) abstract fun delete(accountId: Long, statusId: String) @Query("""DELETE FROM TimelineStatusEntity WHERE createdAt < :olderThan""") abstract fun cleanup(olderThan: Long) - @Query("""UPDATE TimelineStatusEntity SET poll = :poll -WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""") + @Query( + """UPDATE TimelineStatusEntity SET poll = :poll +WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""" + ) abstract fun setVoted(accountId: Long, statusId: String, poll: String) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt index 296111d30..4e2db4ff3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt @@ -1,6 +1,10 @@ package com.keylesspalace.tusky.db -import androidx.room.* +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.TypeConverters import com.keylesspalace.tusky.entity.Status /** @@ -15,62 +19,63 @@ import com.keylesspalace.tusky.entity.Status * fields. */ @Entity( - primaryKeys = ["serverId", "timelineUserId"], - foreignKeys = ([ + primaryKeys = ["serverId", "timelineUserId"], + foreignKeys = ( + [ ForeignKey( - entity = TimelineAccountEntity::class, - parentColumns = ["serverId", "timelineUserId"], - childColumns = ["authorServerId", "timelineUserId"] + entity = TimelineAccountEntity::class, + parentColumns = ["serverId", "timelineUserId"], + childColumns = ["authorServerId", "timelineUserId"] ) - ]), - // Avoiding rescanning status table when accounts table changes. Recommended by Room(c). - indices = [Index("authorServerId", "timelineUserId")] + ] + ), + // Avoiding rescanning status table when accounts table changes. Recommended by Room(c). + indices = [Index("authorServerId", "timelineUserId")] ) @TypeConverters(Converters::class) data class TimelineStatusEntity( - val serverId: String, // id never flips: we need it for sorting so it's a real id - val url: String?, - // our local id for the logged in user in case there are multiple accounts per instance - val timelineUserId: Long, - val authorServerId: String?, - val inReplyToId: String?, - val inReplyToAccountId: String?, - val content: String?, - val createdAt: Long, - val emojis: String?, - val reblogsCount: Int, - val favouritesCount: Int, - val reblogged: Boolean, - val bookmarked: Boolean, - val favourited: Boolean, - val sensitive: Boolean, - val spoilerText: String?, - val visibility: Status.Visibility?, - val attachments: String?, - val mentions: String?, - val application: String?, - val reblogServerId: String?, // if it has a reblogged status, it's id is stored here - val reblogAccountId: String?, - val poll: String?, - val muted: Boolean? + val serverId: String, // id never flips: we need it for sorting so it's a real id + val url: String?, + // our local id for the logged in user in case there are multiple accounts per instance + val timelineUserId: Long, + val authorServerId: String?, + val inReplyToId: String?, + val inReplyToAccountId: String?, + val content: String?, + val createdAt: Long, + val emojis: String?, + val reblogsCount: Int, + val favouritesCount: Int, + val reblogged: Boolean, + val bookmarked: Boolean, + val favourited: Boolean, + val sensitive: Boolean, + val spoilerText: String?, + val visibility: Status.Visibility?, + val attachments: String?, + val mentions: String?, + val application: String?, + val reblogServerId: String?, // if it has a reblogged status, it's id is stored here + val reblogAccountId: String?, + val poll: String?, + val muted: Boolean? ) @Entity( - primaryKeys = ["serverId", "timelineUserId"] + primaryKeys = ["serverId", "timelineUserId"] ) data class TimelineAccountEntity( - val serverId: String, - val timelineUserId: Long, - val localUsername: String, - val username: String, - val displayName: String, - val url: String, - val avatar: String, - val emojis: String, - val bot: Boolean + val serverId: String, + val timelineUserId: Long, + val localUsername: String, + val username: String, + val displayName: String, + val url: String, + val avatar: String, + val emojis: String, + val bot: Boolean ) - class TimelineStatusWithAccount { @Embedded lateinit var status: TimelineStatusEntity @@ -78,4 +83,4 @@ class TimelineStatusWithAccount { lateinit var account: TimelineAccountEntity @Embedded(prefix = "rb_") var reblogAccount: TimelineAccountEntity? = null -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/db/TootEntity.java b/app/src/main/java/com/keylesspalace/tusky/db/TootEntity.java deleted file mode 100644 index b4b258d5c..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/db/TootEntity.java +++ /dev/null @@ -1,151 +0,0 @@ -/* Copyright 2017 Andrew Dawson - * - * 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 . */ - -package com.keylesspalace.tusky.db; - -import com.google.gson.Gson; -import com.keylesspalace.tusky.entity.NewPoll; -import com.keylesspalace.tusky.entity.Status; - -import androidx.annotation.Nullable; -import androidx.room.ColumnInfo; -import androidx.room.Entity; -import androidx.room.PrimaryKey; -import androidx.room.TypeConverter; -import androidx.room.TypeConverters; - -/** - * Toot model. - */ - -@Entity -@TypeConverters(TootEntity.Converters.class) -public class TootEntity { - @PrimaryKey(autoGenerate = true) - private final int uid; - - @ColumnInfo(name = "text") - private final String text; - - @ColumnInfo(name = "urls") - private final String urls; - - @ColumnInfo(name = "descriptions") - private final String descriptions; - - @ColumnInfo(name = "contentWarning") - private final String contentWarning; - - @ColumnInfo(name = "inReplyToId") - private final String inReplyToId; - - @Nullable - @ColumnInfo(name = "inReplyToText") - private final String inReplyToText; - - @Nullable - @ColumnInfo(name = "inReplyToUsername") - private final String inReplyToUsername; - - @ColumnInfo(name = "visibility") - private final Status.Visibility visibility; - - @Nullable - @ColumnInfo(name = "poll") - private final NewPoll poll; - - public TootEntity(int uid, String text, String urls, String descriptions, String contentWarning, String inReplyToId, - @Nullable String inReplyToText, @Nullable String inReplyToUsername, - Status.Visibility visibility, @Nullable NewPoll poll) { - this.uid = uid; - this.text = text; - this.urls = urls; - this.descriptions = descriptions; - this.contentWarning = contentWarning; - this.inReplyToId = inReplyToId; - this.inReplyToText = inReplyToText; - this.inReplyToUsername = inReplyToUsername; - this.visibility = visibility; - this.poll = poll; - } - - public String getText() { - return text; - } - - public String getContentWarning() { - return contentWarning; - } - - public int getUid() { - return uid; - } - - public String getUrls() { - return urls; - } - - public String getDescriptions() { - return descriptions; - } - - public String getInReplyToId() { - return inReplyToId; - } - - @Nullable - public String getInReplyToText() { - return inReplyToText; - } - - @Nullable - public String getInReplyToUsername() { - return inReplyToUsername; - } - - public Status.Visibility getVisibility() { - return visibility; - } - - @Nullable - public NewPoll getPoll() { - return poll; - } - - public static final class Converters { - - private static final Gson gson = new Gson(); - - @TypeConverter - public Status.Visibility visibilityFromInt(int number) { - return Status.Visibility.byNum(number); - } - - @TypeConverter - public int intFromVisibility(Status.Visibility visibility) { - return visibility == null ? Status.Visibility.UNKNOWN.getNum() : visibility.getNum(); - } - - @TypeConverter - public String pollToString(NewPoll poll) { - return gson.toJson(poll); - } - - @TypeConverter - public NewPoll stringToPoll(String poll) { - return gson.fromJson(poll, NewPoll.class); - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt index 69dfa2c09..4a99126c7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt @@ -15,7 +15,23 @@ package com.keylesspalace.tusky.di -import com.keylesspalace.tusky.* +import com.keylesspalace.tusky.AboutActivity +import com.keylesspalace.tusky.AccountActivity +import com.keylesspalace.tusky.AccountListActivity +import com.keylesspalace.tusky.BaseActivity +import com.keylesspalace.tusky.EditProfileActivity +import com.keylesspalace.tusky.FiltersActivity +import com.keylesspalace.tusky.LicenseActivity +import com.keylesspalace.tusky.ListsActivity +import com.keylesspalace.tusky.LoginActivity +import com.keylesspalace.tusky.MainActivity +import com.keylesspalace.tusky.ModalTimelineActivity +import com.keylesspalace.tusky.SplashActivity +import com.keylesspalace.tusky.StatusListActivity +import com.keylesspalace.tusky.TabPreferenceActivity +import com.keylesspalace.tusky.ViewMediaActivity +import com.keylesspalace.tusky.ViewTagActivity +import com.keylesspalace.tusky.ViewThreadActivity import com.keylesspalace.tusky.components.announcements.AnnouncementsActivity import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.components.drafts.DraftsActivity @@ -80,9 +96,6 @@ abstract class ActivitiesModule { @ContributesAndroidInjector abstract fun contributesSplashActivity(): SplashActivity - @ContributesAndroidInjector - abstract fun contributesSavedTootActivity(): SavedTootActivity - @ContributesAndroidInjector(modules = [FragmentBuildersModule::class]) abstract fun contributesPreferencesActivity(): PreferencesActivity diff --git a/app/src/main/java/com/keylesspalace/tusky/di/AppComponent.kt b/app/src/main/java/com/keylesspalace/tusky/di/AppComponent.kt index ff3d02669..51596ac71 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/AppComponent.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/AppComponent.kt @@ -21,23 +21,24 @@ import dagger.Component import dagger.android.support.AndroidSupportInjectionModule import javax.inject.Singleton - /** * Created by charlag on 3/21/18. */ @Singleton -@Component(modules = [ - AppModule::class, - NetworkModule::class, - AndroidSupportInjectionModule::class, - ActivitiesModule::class, - ServicesModule::class, - BroadcastReceiverModule::class, - ViewModelModule::class, - RepositoryModule::class, - MediaUploaderModule::class -]) +@Component( + modules = [ + AppModule::class, + NetworkModule::class, + AndroidSupportInjectionModule::class, + ActivitiesModule::class, + ServicesModule::class, + BroadcastReceiverModule::class, + ViewModelModule::class, + RepositoryModule::class, + MediaUploaderModule::class + ] +) interface AppComponent { @Component.Builder interface Builder { @@ -48,4 +49,4 @@ interface AppComponent { } fun inject(app: TuskyApplication) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/di/AppInjector.kt b/app/src/main/java/com/keylesspalace/tusky/di/AppInjector.kt index bd06bfc1e..6446a7357 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/AppInjector.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/AppInjector.kt @@ -34,31 +34,30 @@ import dagger.android.support.AndroidSupportInjection object AppInjector { fun init(app: TuskyApplication) { DaggerAppComponent.builder().application(app) - .build().inject(app) + .build().inject(app) app.registerActivityLifecycleCallbacks(object : Application.ActivityLifecycleCallbacks { override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { handleActivity(activity) } - override fun onActivityPaused(activity: Activity?) { + override fun onActivityPaused(activity: Activity) { } - override fun onActivityResumed(activity: Activity?) { + override fun onActivityResumed(activity: Activity) { } - override fun onActivityStarted(activity: Activity?) { + override fun onActivityStarted(activity: Activity) { } - override fun onActivityDestroyed(activity: Activity?) { + override fun onActivityDestroyed(activity: Activity) { } - override fun onActivitySaveInstanceState(activity: Activity?, outState: Bundle?) { + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) { } - override fun onActivityStopped(activity: Activity?) { + override fun onActivityStopped(activity: Activity) { } - }) } @@ -68,13 +67,15 @@ object AppInjector { } if (activity is FragmentActivity) { activity.supportFragmentManager.registerFragmentLifecycleCallbacks( - object : FragmentManager.FragmentLifecycleCallbacks() { - override fun onFragmentPreAttached(fm: FragmentManager, f: Fragment, context: Context) { - if (f is Injectable) { - AndroidSupportInjection.inject(f) - } + object : FragmentManager.FragmentLifecycleCallbacks() { + override fun onFragmentPreAttached(fm: FragmentManager, f: Fragment, context: Context) { + if (f is Injectable) { + AndroidSupportInjection.inject(f) } - }, true) + } + }, + true + ) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt index 13266851b..13120189d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt @@ -13,12 +13,12 @@ * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ - package com.keylesspalace.tusky.di import android.app.Application import android.content.Context import android.content.SharedPreferences +import android.net.ConnectivityManager import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.preference.PreferenceManager import androidx.room.Room @@ -60,8 +60,15 @@ class AppModule { } @Provides - fun providesTimelineUseCases(api: MastodonApi, - eventHub: EventHub): TimelineCases { + fun providesConnectivityManager(appContext: Context): ConnectivityManager { + return appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + } + + @Provides + fun providesTimelineUseCases( + api: MastodonApi, + eventHub: EventHub + ): TimelineCases { return TimelineCasesImpl(api, eventHub) } @@ -73,21 +80,24 @@ class AppModule { @Singleton fun providesDatabase(appContext: Context, converters: Converters): AppDatabase { return Room.databaseBuilder(appContext, AppDatabase::class.java, "tuskyDB") - .addTypeConverter(converters) - .allowMainThreadQueries() - .addMigrations(AppDatabase.MIGRATION_2_3, AppDatabase.MIGRATION_3_4, AppDatabase.MIGRATION_4_5, - AppDatabase.MIGRATION_5_6, AppDatabase.MIGRATION_6_7, AppDatabase.MIGRATION_7_8, - AppDatabase.MIGRATION_8_9, AppDatabase.MIGRATION_9_10, AppDatabase.MIGRATION_10_11, - AppDatabase.MIGRATION_11_12, AppDatabase.MIGRATION_12_13, AppDatabase.MIGRATION_10_13, - AppDatabase.MIGRATION_13_14, AppDatabase.MIGRATION_14_15, AppDatabase.MIGRATION_15_16, - AppDatabase.MIGRATION_16_17, AppDatabase.MIGRATION_17_18, AppDatabase.MIGRATION_18_19, - AppDatabase.MIGRATION_19_20, AppDatabase.MIGRATION_20_21, AppDatabase.MIGRATION_21_22, - AppDatabase.MIGRATION_22_23, AppDatabase.MIGRATION_23_24, AppDatabase.MIGRATION_24_25) - .build() + .addTypeConverter(converters) + .allowMainThreadQueries() + .addMigrations( + AppDatabase.MIGRATION_2_3, AppDatabase.MIGRATION_3_4, AppDatabase.MIGRATION_4_5, + AppDatabase.MIGRATION_5_6, AppDatabase.MIGRATION_6_7, AppDatabase.MIGRATION_7_8, + AppDatabase.MIGRATION_8_9, AppDatabase.MIGRATION_9_10, AppDatabase.MIGRATION_10_11, + AppDatabase.MIGRATION_11_12, AppDatabase.MIGRATION_12_13, AppDatabase.MIGRATION_10_13, + AppDatabase.MIGRATION_13_14, AppDatabase.MIGRATION_14_15, AppDatabase.MIGRATION_15_16, + AppDatabase.MIGRATION_16_17, AppDatabase.MIGRATION_17_18, AppDatabase.MIGRATION_18_19, + AppDatabase.MIGRATION_19_20, AppDatabase.MIGRATION_20_21, AppDatabase.MIGRATION_21_22, + AppDatabase.MIGRATION_22_23, AppDatabase.MIGRATION_23_24, AppDatabase.MIGRATION_24_25, + AppDatabase.MIGRATION_26_27, + AppDatabase.Migration25_26(appContext.getExternalFilesDir("Tusky")) + ) + .build() } @Provides @Singleton fun notifier(context: Context): Notifier = SystemNotifier(context) - } diff --git a/app/src/main/java/com/keylesspalace/tusky/di/BroadcastReceiverModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/BroadcastReceiverModule.kt index edf95341c..b7213fa64 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/BroadcastReceiverModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/BroadcastReceiverModule.kt @@ -16,16 +16,16 @@ package com.keylesspalace.tusky.di -import com.keylesspalace.tusky.receiver.SendStatusBroadcastReceiver import com.keylesspalace.tusky.receiver.NotificationClearBroadcastReceiver +import com.keylesspalace.tusky.receiver.SendStatusBroadcastReceiver import dagger.Module import dagger.android.ContributesAndroidInjector @Module abstract class BroadcastReceiverModule { @ContributesAndroidInjector - abstract fun contributeSendStatusBroadcastReceiver() : SendStatusBroadcastReceiver + abstract fun contributeSendStatusBroadcastReceiver(): SendStatusBroadcastReceiver @ContributesAndroidInjector - abstract fun contributeNotificationClearBroadcastReceiver() : NotificationClearBroadcastReceiver -} \ No newline at end of file + abstract fun contributeNotificationClearBroadcastReceiver(): NotificationClearBroadcastReceiver +} diff --git a/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt index 3d864fa0b..6f413ba09 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt @@ -13,7 +13,6 @@ * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ - package com.keylesspalace.tusky.di import com.keylesspalace.tusky.AccountsInListFragment @@ -29,7 +28,11 @@ import com.keylesspalace.tusky.components.search.fragments.SearchAccountsFragmen import com.keylesspalace.tusky.components.search.fragments.SearchHashtagsFragment import com.keylesspalace.tusky.components.search.fragments.SearchNotestockFragment import com.keylesspalace.tusky.components.search.fragments.SearchStatusesFragment -import com.keylesspalace.tusky.fragment.* +import com.keylesspalace.tusky.components.timeline.TimelineFragment +import com.keylesspalace.tusky.fragment.AccountListFragment +import com.keylesspalace.tusky.fragment.AccountMediaFragment +import com.keylesspalace.tusky.fragment.NotificationsFragment +import com.keylesspalace.tusky.fragment.ViewThreadFragment import dagger.Module import dagger.android.ContributesAndroidInjector diff --git a/app/src/main/java/com/keylesspalace/tusky/di/Injectable.kt b/app/src/main/java/com/keylesspalace/tusky/di/Injectable.kt index 1df715e70..f3b4e8105 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/Injectable.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/Injectable.kt @@ -13,11 +13,10 @@ * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ - package com.keylesspalace.tusky.di /** * Created by charlag on 3/24/18. */ -interface Injectable \ No newline at end of file +interface Injectable diff --git a/app/src/main/java/com/keylesspalace/tusky/di/MediaUploaderModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/MediaUploaderModule.kt index 66dc27110..00ec1b73e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/MediaUploaderModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/MediaUploaderModule.kt @@ -26,5 +26,5 @@ import dagger.Provides class MediaUploaderModule { @Provides fun providesMediaUploder(context: Context, mastodonApi: MastodonApi): MediaUploader = - MediaUploaderImpl(context, mastodonApi) -} \ No newline at end of file + MediaUploaderImpl(context, mastodonApi) +} diff --git a/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt index 155847b46..0488c836d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt @@ -36,7 +36,7 @@ import okhttp3.OkHttp import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit -import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory +import retrofit2.adapter.rxjava3.RxJava3CallAdapterFactory import retrofit2.converter.gson.GsonConverterFactory import retrofit2.create import java.net.InetSocketAddress @@ -55,16 +55,16 @@ class NetworkModule { @Singleton fun providesGson(): Gson { return GsonBuilder() - .registerTypeAdapter(Spanned::class.java, SpannedTypeAdapter()) - .create() + .registerTypeAdapter(Spanned::class.java, SpannedTypeAdapter()) + .create() } @Provides @Singleton fun providesHttpClient( - accountManager: AccountManager, - context: Context, - preferences: SharedPreferences + accountManager: AccountManager, + context: Context, + preferences: SharedPreferences ): OkHttpClient { val httpProxyEnabled = preferences.getBoolean("httpProxyEnabled", false) val httpServer = preferences.getNonNullString("httpProxyServer", "") @@ -94,28 +94,27 @@ class NetworkModule { builder.proxy(Proxy(Proxy.Type.HTTP, address)) } return builder - .apply { - addInterceptor(InstanceSwitchAuthInterceptor(accountManager)) - if (BuildConfig.DEBUG) { - addInterceptor(HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC }) - addInterceptor(HttpToastInterceptor(context)) - } + .apply { + addInterceptor(InstanceSwitchAuthInterceptor(accountManager)) + if (BuildConfig.DEBUG) { + addInterceptor(HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC }) + addInterceptor(HttpToastInterceptor(context)) } - .build() + } + .build() } @Provides @Singleton fun providesRetrofit( - httpClient: OkHttpClient, - gson: Gson + httpClient: OkHttpClient, + gson: Gson ): Retrofit { return Retrofit.Builder().baseUrl("https://" + MastodonApi.PLACEHOLDER_DOMAIN) - .client(httpClient) - .addConverterFactory(GsonConverterFactory.create(gson)) - .addCallAdapterFactory(RxJava2CallAdapterFactory.createAsync()) - .build() - + .client(httpClient) + .addConverterFactory(GsonConverterFactory.create(gson)) + .addCallAdapterFactory(RxJava3CallAdapterFactory.create()) + .build() } @Provides @@ -124,13 +123,15 @@ class NetworkModule { @Provides @Singleton - fun providesNotestockApi(okHttpClient: OkHttpClient, - gson: Gson): NotestockApi { + fun providesNotestockApi( + okHttpClient: OkHttpClient, + gson: Gson + ): NotestockApi { val retrofit = Retrofit.Builder().baseUrl("https://notestock.osa-p.net") - .client(okHttpClient) - .addConverterFactory(GsonConverterFactory.create(gson)) - .addCallAdapterFactory(RxJava2CallAdapterFactory.createAsync()) - .build() + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create(gson)) + .addCallAdapterFactory(RxJava3CallAdapterFactory.create()) + .build() return retrofit.create(NotestockApi::class.java) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/di/RepositoryModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/RepositoryModule.kt index af8cfb886..e94c55d19 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/RepositoryModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/RepositoryModule.kt @@ -1,11 +1,11 @@ package com.keylesspalace.tusky.di import com.google.gson.Gson +import com.keylesspalace.tusky.components.timeline.TimelineRepository +import com.keylesspalace.tusky.components.timeline.TimelineRepositoryImpl import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.repository.TimelineRepository -import com.keylesspalace.tusky.repository.TimelineRepositoryImpl import dagger.Module import dagger.Provides @@ -13,11 +13,11 @@ import dagger.Provides class RepositoryModule { @Provides fun providesTimelineRepository( - db: AppDatabase, - mastodonApi: MastodonApi, - accountManager: AccountManager, - gson: Gson + db: AppDatabase, + mastodonApi: MastodonApi, + accountManager: AccountManager, + gson: Gson ): TimelineRepository { return TimelineRepositoryImpl(db.timelineDao(), mastodonApi, accountManager, gson) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ServicesModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/ServicesModule.kt index 5f6495543..f34dc0750 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ServicesModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ServicesModule.kt @@ -36,4 +36,4 @@ abstract class ServicesModule { return ServiceClientImpl(context) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt index bed2c9833..b12d3be82 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt @@ -11,6 +11,7 @@ import com.keylesspalace.tusky.components.drafts.DraftsViewModel import com.keylesspalace.tusky.components.report.ReportViewModel import com.keylesspalace.tusky.components.scheduled.ScheduledTootViewModel import com.keylesspalace.tusky.components.search.SearchViewModel +import com.keylesspalace.tusky.components.timeline.TimelineViewModel import com.keylesspalace.tusky.viewmodel.AccountViewModel import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel import com.keylesspalace.tusky.viewmodel.EditProfileViewModel @@ -62,7 +63,6 @@ abstract class ViewModelModule { @ViewModelKey(ListsViewModel::class) internal abstract fun listsViewModel(viewModel: ListsViewModel): ViewModel - @Binds @IntoMap @ViewModelKey(AccountsInListViewModel::class) @@ -98,6 +98,11 @@ abstract class ViewModelModule { @ViewModelKey(DraftsViewModel::class) internal abstract fun draftsViewModel(viewModel: DraftsViewModel): ViewModel + @Binds + @IntoMap + @ViewModelKey(TimelineViewModel::class) + internal abstract fun timelineViewModel(viewModel: TimelineViewModel): ViewModel + @Binds @IntoMap @ViewModelKey(QuickTootViewModel::class) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/AccessToken.kt b/app/src/main/java/com/keylesspalace/tusky/entity/AccessToken.kt index 181078839..e974ce196 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/AccessToken.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/AccessToken.kt @@ -18,5 +18,5 @@ package com.keylesspalace.tusky.entity import com.google.gson.annotations.SerializedName data class AccessToken( - @SerializedName("access_token") val accessToken: String + @SerializedName("access_token") val accessToken: String ) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt index 208ecb87d..5f8296027 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt @@ -20,24 +20,24 @@ import com.google.gson.annotations.SerializedName import java.util.* data class Account( - val id: String, - @SerializedName("username") val localUsername: String, - @SerializedName("acct", alternate = ["subject"]) val username: String, - @SerializedName("display_name") private val displayName: String?, // should never be null per Api definition, but some servers break the contract - val note: Spanned, - val url: String, - val avatar: String, - val header: String, - val locked: Boolean = false, - @SerializedName("followers_count") val followersCount: Int = 0, - @SerializedName("following_count") val followingCount: Int = 0, - @SerializedName("statuses_count") val statusesCount: Int = 0, - val source: AccountSource? = null, - val bot: Boolean = false, - val emojis: List? = emptyList(), // nullable for backward compatibility - val fields: List? = emptyList(), //nullable for backward compatibility - val moved: Account? = null, - @SerializedName("name") val notestockUsername: String? = null + val id: String, + @SerializedName("username") val localUsername: String, + @SerializedName("acct", alternate = ["subject"]) val username: String, + @SerializedName("display_name") private val displayName: String?, // should never be null per Api definition, but some servers break the contract + val note: Spanned, + val url: String, + val avatar: String, + val header: String, + val locked: Boolean = false, + @SerializedName("followers_count") val followersCount: Int = 0, + @SerializedName("following_count") val followingCount: Int = 0, + @SerializedName("statuses_count") val statusesCount: Int = 0, + val source: AccountSource? = null, + val bot: Boolean = false, + val emojis: List? = emptyList(), // nullable for backward compatibility + val fields: List? = emptyList(), // nullable for backward compatibility + val moved: Account? = null, + @SerializedName("name") val notestockUsername: String? = null, ) { @@ -65,41 +65,41 @@ data class Account( } fun deepEquals(other: Account): Boolean { - return id == other.id - && localUsername == other.localUsername - && displayName == other.displayName - && note == other.note - && url == other.url - && avatar == other.avatar - && header == other.header - && locked == other.locked - && followersCount == other.followersCount - && followingCount == other.followingCount - && statusesCount == other.statusesCount - && source == other.source - && bot == other.bot - && emojis == other.emojis - && fields == other.fields - && moved == other.moved + return id == other.id && + localUsername == other.localUsername && + displayName == other.displayName && + note == other.note && + url == other.url && + avatar == other.avatar && + header == other.header && + locked == other.locked && + followersCount == other.followersCount && + followingCount == other.followingCount && + statusesCount == other.statusesCount && + source == other.source && + bot == other.bot && + emojis == other.emojis && + fields == other.fields && + moved == other.moved } fun isRemote(): Boolean = this.username != this.localUsername } data class AccountSource( - val privacy: Status.Visibility, - val sensitive: Boolean, - val note: String, - val fields: List? + val privacy: Status.Visibility, + val sensitive: Boolean, + val note: String, + val fields: List? ) -data class Field ( - val name: String, - val value: Spanned, - @SerializedName("verified_at") val verifiedAt: Date? +data class Field( + val name: String, + val value: Spanned, + @SerializedName("verified_at") val verifiedAt: Date? ) -data class StringField ( - val name: String, - val value: String +data class StringField( + val name: String, + val value: String ) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Announcement.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Announcement.kt index 5cd32fe8d..400e9764d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Announcement.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Announcement.kt @@ -17,22 +17,22 @@ package com.keylesspalace.tusky.entity import android.text.Spanned import com.google.gson.annotations.SerializedName -import java.util.* +import java.util.Date data class Announcement( - val id: String, - val content: Spanned, - @SerializedName("starts_at") val startsAt: Date?, - @SerializedName("ends_at") val endsAt: Date?, - @SerializedName("all_day") val allDay: Boolean, - @SerializedName("published_at") val publishedAt: Date, - @SerializedName("updated_at") val updatedAt: Date, - val read: Boolean, - val mentions: List, - val statuses: List, - val tags: List, - val emojis: List, - val reactions: List + val id: String, + val content: Spanned, + @SerializedName("starts_at") val startsAt: Date?, + @SerializedName("ends_at") val endsAt: Date?, + @SerializedName("all_day") val allDay: Boolean, + @SerializedName("published_at") val publishedAt: Date, + @SerializedName("updated_at") val updatedAt: Date, + val read: Boolean, + val mentions: List, + val statuses: List, + val tags: List, + val emojis: List, + val reactions: List ) { override fun equals(other: Any?): Boolean { @@ -48,10 +48,10 @@ data class Announcement( } data class Reaction( - val name: String, - var count: Int, - var me: Boolean, - val url: String?, - @SerializedName("static_url") val staticUrl: String? + val name: String, + var count: Int, + var me: Boolean, + val url: String?, + @SerializedName("static_url") val staticUrl: String? ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/AppCredentials.kt b/app/src/main/java/com/keylesspalace/tusky/entity/AppCredentials.kt index 95a829c14..fe6b0c3ce 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/AppCredentials.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/AppCredentials.kt @@ -18,6 +18,6 @@ package com.keylesspalace.tusky.entity import com.google.gson.annotations.SerializedName data class AppCredentials( - @SerializedName("client_id") val clientId: String, - @SerializedName("client_secret") val clientSecret: String + @SerializedName("client_id") val clientId: String, + @SerializedName("client_secret") val clientSecret: String ) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Attachment.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Attachment.kt index 3e14519ac..27fdc8be6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Attachment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Attachment.kt @@ -26,13 +26,13 @@ import kotlinx.parcelize.Parcelize @Parcelize data class Attachment( - val id: String, - val url: String, - @SerializedName("preview_url") val previewUrl: String?, // can be null for e.g. audio attachments - val meta: MetaData?, - val type: Type, - val description: String?, - val blurhash: String? + val id: String, + val url: String, + @SerializedName("preview_url") val previewUrl: String?, // can be null for e.g. audio attachments + val meta: MetaData?, + val type: Type, + val description: String?, + val blurhash: String? ) : Parcelable { @JsonAdapter(MediaTypeDeserializer::class) @@ -66,9 +66,9 @@ data class Attachment( * The meta data of an [Attachment]. */ @Parcelize - data class MetaData ( - val focus: Focus?, - val duration: Float? + data class MetaData( + val focus: Focus?, + val duration: Float? ) : Parcelable /** @@ -78,8 +78,8 @@ data class Attachment( * https://github.com/jonom/jquery-focuspoint#1-calculate-your-images-focus-point */ @Parcelize - data class Focus ( - val x: Float, - val y: Float + data class Focus( + val x: Float, + val y: Float ) : Parcelable } diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Card.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Card.kt index ada9ec205..52011f3d1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Card.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Card.kt @@ -19,16 +19,16 @@ import android.text.Spanned import com.google.gson.annotations.SerializedName data class Card( - val url: String, - val title: Spanned, - val description: Spanned, - @SerializedName("author_name") val authorName: String, - val image: String, - val type: String, - val width: Int, - val height: Int, - val blurhash: String?, - val embed_url: String? + val url: String, + val title: Spanned, + val description: Spanned, + @SerializedName("author_name") val authorName: String, + val image: String, + val type: String, + val width: Int, + val height: Int, + val blurhash: String?, + val embed_url: String? ) { override fun hashCode(): Int { diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Conversation.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Conversation.kt index 0e66385fd..cb09981db 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Conversation.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Conversation.kt @@ -18,8 +18,8 @@ package com.keylesspalace.tusky.entity import com.google.gson.annotations.SerializedName data class Conversation( - val id: String, - val accounts: List, - @SerializedName("last_status") val lastStatus: Status?, // should never be null, but apparently its possible https://github.com/tuskyapp/Tusky/issues/1038 - val unread: Boolean -) \ No newline at end of file + val id: String, + val accounts: List, + @SerializedName("last_status") val lastStatus: Status?, // should never be null, but apparently its possible https://github.com/tuskyapp/Tusky/issues/1038 + val unread: Boolean +) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/DeletedStatus.kt b/app/src/main/java/com/keylesspalace/tusky/entity/DeletedStatus.kt index 289a93fb7..92a35b69c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/DeletedStatus.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/DeletedStatus.kt @@ -16,19 +16,20 @@ package com.keylesspalace.tusky.entity import com.google.gson.annotations.SerializedName -import java.util.* +import java.util.ArrayList +import java.util.Date data class DeletedStatus( - var text: String?, - @SerializedName("in_reply_to_id") var inReplyToId: String?, - @SerializedName("spoiler_text") val spoilerText: String, - val visibility: Status.Visibility, - val sensitive: Boolean, - @SerializedName("media_attachments") var attachments: ArrayList?, - val poll: Poll?, - @SerializedName("created_at") val createdAt: Date + var text: String?, + @SerializedName("in_reply_to_id") var inReplyToId: String?, + @SerializedName("spoiler_text") val spoilerText: String, + val visibility: Status.Visibility, + val sensitive: Boolean, + @SerializedName("media_attachments") var attachments: ArrayList?, + val poll: Poll?, + @SerializedName("created_at") val createdAt: Date ) { fun isEmpty(): Boolean { return text == null && attachments == null } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Emoji.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Emoji.kt index 42bb99e93..130831a2d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Emoji.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Emoji.kt @@ -21,8 +21,8 @@ import kotlinx.parcelize.Parcelize @Parcelize data class Emoji( - val shortcode: String, - val url: String, - @SerializedName("static_url") val staticUrl: String, - @SerializedName("visible_in_picker") val visibleInPicker: Boolean? + val shortcode: String, + val url: String, + @SerializedName("static_url") val staticUrl: String, + @SerializedName("visible_in_picker") val visibleInPicker: Boolean? ) : Parcelable diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Filter.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Filter.kt index 58bdc79a9..34b80e83b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Filter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Filter.kt @@ -17,7 +17,7 @@ package com.keylesspalace.tusky.entity import com.google.gson.annotations.SerializedName -data class Filter ( +data class Filter( val id: String, val phrase: String, val context: List, @@ -45,4 +45,3 @@ data class Filter ( return filter?.id.equals(id) } } - diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/HashTag.kt b/app/src/main/java/com/keylesspalace/tusky/entity/HashTag.kt index 1eaaf68f9..a334257a1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/HashTag.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/HashTag.kt @@ -1,3 +1,3 @@ package com.keylesspalace.tusky.entity -data class HashTag(val name: String) \ No newline at end of file +data class HashTag(val name: String) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/IdentityProof.kt b/app/src/main/java/com/keylesspalace/tusky/entity/IdentityProof.kt index 9473f0372..98af734bf 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/IdentityProof.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/IdentityProof.kt @@ -3,7 +3,7 @@ package com.keylesspalace.tusky.entity import com.google.gson.annotations.SerializedName data class IdentityProof( - val provider: String, - @SerializedName("provider_username") val username: String, - @SerializedName("profile_url") val profileUrl: String + val provider: String, + @SerializedName("provider_username") val username: String, + @SerializedName("profile_url") val profileUrl: String ) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt index a9f6f499c..d1e2aca90 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt @@ -17,20 +17,20 @@ package com.keylesspalace.tusky.entity import com.google.gson.annotations.SerializedName -data class Instance ( - val uri: String, - val title: String, - val description: String, - val email: String, - val version: String, - val urls: Map, - val stats: Map?, - val thumbnail: String?, - val languages: List, - @SerializedName("contact_account") val contactAccount: Account, - @SerializedName("max_toot_chars") val maxTootChars: Int?, - @SerializedName("max_bio_chars") val maxBioChars: Int?, - @SerializedName("poll_limits") val pollLimits: PollLimits? +data class Instance( + val uri: String, + val title: String, + val description: String, + val email: String, + val version: String, + val urls: Map, + val stats: Map?, + val thumbnail: String?, + val languages: List, + @SerializedName("contact_account") val contactAccount: Account, + @SerializedName("max_toot_chars") val maxTootChars: Int?, + @SerializedName("max_bio_chars") val maxBioChars: Int?, + @SerializedName("poll_limits") val pollLimits: PollLimits? ) { override fun hashCode(): Int { return uri.hashCode() @@ -45,7 +45,7 @@ data class Instance ( } } -data class PollLimits ( - @SerializedName("max_options") val maxOptions: Int?, - @SerializedName("max_option_chars") val maxOptionChars: Int? +data class PollLimits( + @SerializedName("max_options") val maxOptions: Int?, + @SerializedName("max_option_chars") val maxOptionChars: Int? ) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Marker.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Marker.kt index 16fd9e318..78572054d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Marker.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Marker.kt @@ -1,15 +1,15 @@ package com.keylesspalace.tusky.entity import com.google.gson.annotations.SerializedName -import java.util.* +import java.util.Date /** * API type for saving the scroll position of a timeline. */ data class Marker( - @SerializedName("last_read_id") - val lastReadId: String, - val version: Int, - @SerializedName("updated_at") - val updatedAt: Date -) \ No newline at end of file + @SerializedName("last_read_id") + val lastReadId: String, + val version: Int, + @SerializedName("updated_at") + val updatedAt: Date +) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/MastoList.kt b/app/src/main/java/com/keylesspalace/tusky/entity/MastoList.kt index 2f8eecf3c..bfec7cc52 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/MastoList.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/MastoList.kt @@ -21,6 +21,6 @@ package com.keylesspalace.tusky.entity */ data class MastoList( - val id: String, - val title: String -) \ No newline at end of file + val id: String, + val title: String +) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/NewStatus.kt b/app/src/main/java/com/keylesspalace/tusky/entity/NewStatus.kt index 93fe53b6d..7dae8b24d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/NewStatus.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/NewStatus.kt @@ -20,20 +20,20 @@ import com.google.gson.annotations.SerializedName import kotlinx.parcelize.Parcelize data class NewStatus( - val status: String, - @SerializedName("spoiler_text") val warningText: String, - @SerializedName("in_reply_to_id") val inReplyToId: String?, - val visibility: String, - val sensitive: Boolean, - @SerializedName("media_ids") val mediaIds: List?, - @SerializedName("scheduled_at") val scheduledAt: String?, - val poll: NewPoll?, - @SerializedName("quote_id") val quoteId: String? + val status: String, + @SerializedName("spoiler_text") val warningText: String, + @SerializedName("in_reply_to_id") val inReplyToId: String?, + val visibility: String, + val sensitive: Boolean, + @SerializedName("media_ids") val mediaIds: List?, + @SerializedName("scheduled_at") val scheduledAt: String?, + val poll: NewPoll?, + @SerializedName("quote_id") val quoteId: String?, ) @Parcelize data class NewPoll( - val options: List, - @SerializedName("expires_in") val expiresIn: Int, - val multiple: Boolean -): Parcelable \ No newline at end of file + val options: List, + @SerializedName("expires_in") val expiresIn: Int, + val multiple: Boolean +) : Parcelable diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt index 0dbefd61b..6198867d9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt @@ -22,10 +22,11 @@ import com.google.gson.JsonParseException import com.google.gson.annotations.JsonAdapter data class Notification( - val type: Type, - val id: String, - val account: Account, - val status: Status?) { + val type: Type, + val id: String, + val account: Account, + val status: Status? +) { @JsonAdapter(NotificationTypeAdapter::class) enum class Type(val presentation: String) { @@ -71,18 +72,25 @@ data class Notification( class NotificationTypeAdapter : JsonDeserializer { @Throws(JsonParseException::class) - override fun deserialize(json: JsonElement, typeOfT: java.lang.reflect.Type, context: JsonDeserializationContext): Type { + override fun deserialize( + json: JsonElement, + typeOfT: java.lang.reflect.Type, + context: JsonDeserializationContext + ): Type { return Type.byString(json.asString) } - } - + + /** Helper for Java */ + fun copyWithStatus(status: Status?): Notification = copy(status = status) + // for Pleroma compatibility that uses Mention type - fun rewriteToStatusTypeIfNeeded(accountId: String) : Notification { + fun rewriteToStatusTypeIfNeeded(accountId: String): Notification { if (type == Type.MENTION && status != null) { return if (status.mentions.any { it.id == accountId - }) this else copy(type = Type.STATUS) + } + ) this else copy(type = Type.STATUS) } return this } diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Poll.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Poll.kt index 02c236c48..1a4c23548 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Poll.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Poll.kt @@ -1,22 +1,22 @@ package com.keylesspalace.tusky.entity import com.google.gson.annotations.SerializedName -import java.util.* +import java.util.Date data class Poll( - val id: String, - @SerializedName("expires_at") val expiresAt: Date?, - val expired: Boolean, - val multiple: Boolean, - @SerializedName("votes_count") val votesCount: Int, - @SerializedName("voters_count") val votersCount: Int?, // nullable for compatibility with Pleroma - val options: List, - val voted: Boolean + val id: String, + @SerializedName("expires_at") val expiresAt: Date?, + val expired: Boolean, + val multiple: Boolean, + @SerializedName("votes_count") val votesCount: Int, + @SerializedName("voters_count") val votersCount: Int?, // nullable for compatibility with Pleroma + val options: List, + val voted: Boolean ) { fun votedCopy(choices: List): Poll { val newOptions = options.mapIndexed { index, option -> - if(choices.contains(index)) { + if (choices.contains(index)) { option.copy(votesCount = option.votesCount + 1) } else { option @@ -24,24 +24,23 @@ data class Poll( } return copy( - options = newOptions, - votesCount = votesCount + choices.size, - votersCount = votersCount?.plus(1), - voted = true + options = newOptions, + votesCount = votesCount + choices.size, + votersCount = votersCount?.plus(1), + voted = true ) } fun toNewPoll(creationDate: Date) = NewPoll( - options.map { it.title }, - expiresAt?.let { - ((it.time - creationDate.time) / 1000).toInt() + 1 - }?: 3600, - multiple + options.map { it.title }, + expiresAt?.let { + ((it.time - creationDate.time) / 1000).toInt() + 1 + } ?: 3600, + multiple ) - } data class PollOption( - val title: String, - @SerializedName("votes_count") val votesCount: Int -) \ No newline at end of file + val title: String, + @SerializedName("votes_count") val votesCount: Int +) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Relationship.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Relationship.kt index e25a3d10d..17bddccaf 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Relationship.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Relationship.kt @@ -17,7 +17,7 @@ package com.keylesspalace.tusky.entity import com.google.gson.annotations.SerializedName -data class Relationship ( +data class Relationship( val id: String, val following: Boolean, @SerializedName("followed_by") val followedBy: Boolean, diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/ScheduledStatus.kt b/app/src/main/java/com/keylesspalace/tusky/entity/ScheduledStatus.kt index 2621bd5ed..dfaeb499c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/ScheduledStatus.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/ScheduledStatus.kt @@ -18,8 +18,8 @@ package com.keylesspalace.tusky.entity import com.google.gson.annotations.SerializedName data class ScheduledStatus( - val id: String, - @SerializedName("scheduled_at") val scheduledAt: String, - val params: StatusParams, - @SerializedName("media_attachments") val mediaAttachments: ArrayList + val id: String, + @SerializedName("scheduled_at") val scheduledAt: String, + val params: StatusParams, + @SerializedName("media_attachments") val mediaAttachments: ArrayList ) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/SearchResult.kt b/app/src/main/java/com/keylesspalace/tusky/entity/SearchResult.kt index 4307380ca..18e3d71b0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/SearchResult.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/SearchResult.kt @@ -15,7 +15,7 @@ package com.keylesspalace.tusky.entity -data class SearchResult ( +data class SearchResult( val accounts: List, val statuses: List, val hashtags: List diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt index ed6444a78..2412d407c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt @@ -19,34 +19,35 @@ import android.text.SpannableStringBuilder import android.text.Spanned import android.text.style.URLSpan import com.google.gson.annotations.SerializedName -import java.util.* +import java.util.ArrayList +import java.util.Date data class Status( - var id: String, - var url: String?, // not present if it's reblog - val account: Account, - @SerializedName("in_reply_to_id") var inReplyToId: String?, - @SerializedName("in_reply_to_account_id") val inReplyToAccountId: String?, - val reblog: Status?, - val content: Spanned, - @SerializedName("created_at", alternate = ["published"]) val createdAt: Date, - val emojis: List, - @SerializedName("reblogs_count") val reblogsCount: Int, - @SerializedName("favourites_count") val favouritesCount: Int, - var reblogged: Boolean, - var favourited: Boolean, - var bookmarked: Boolean, - var sensitive: Boolean, - @SerializedName("spoiler_text", alternate = ["summary"]) val spoilerText: String, - val visibility: Visibility, - @SerializedName("media_attachments", alternate = ["attachment"]) var attachments: ArrayList, - val mentions: Array, - val application: Application?, - var pinned: Boolean?, - var muted: Boolean?, - val poll: Poll?, - val card: Card?, - val quote: Status? + val id: String, + val url: String?, // not present if it's reblog + val account: Account, + @SerializedName("in_reply_to_id") var inReplyToId: String?, + @SerializedName("in_reply_to_account_id") val inReplyToAccountId: String?, + val reblog: Status?, + val content: Spanned, + @SerializedName("created_at", alternate = ["published"]) val createdAt: Date, + val emojis: List, + @SerializedName("reblogs_count") val reblogsCount: Int, + @SerializedName("favourites_count") val favouritesCount: Int, + var reblogged: Boolean, + var favourited: Boolean, + var bookmarked: Boolean, + var sensitive: Boolean, + @SerializedName("spoiler_text", alternate = ["summary"]) val spoilerText: String, + val visibility: Visibility, + @SerializedName("media_attachments", alternate = ["attachment"]) var attachments: ArrayList, + val mentions: List, + val application: Application?, + val pinned: Boolean?, + val muted: Boolean?, + val poll: Poll?, + val card: Card?, + val quote: Status?, ) { val actionableId: String @@ -58,6 +59,12 @@ data class Status( val isNotestock: Boolean get() = !account.notestockUsername.isNullOrEmpty() + /** Helper for Java */ + fun copyWithPoll(poll: Poll?): Status = copy(poll = poll) + + /** Helper for Java */ + fun copyWithPinned(pinned: Boolean): Status = copy(pinned = pinned) + enum class Visibility(val num: Int) { UNKNOWN(0), @SerializedName("public") @@ -122,14 +129,14 @@ data class Status( fun toDeletedStatus(): DeletedStatus { return DeletedStatus( - text = getEditableText(), - inReplyToId = inReplyToId, - spoilerText = spoilerText, - visibility = visibility, - sensitive = sensitive, - attachments = attachments, - poll = poll, - createdAt = createdAt + text = getEditableText(), + inReplyToId = inReplyToId, + spoilerText = spoilerText, + visibility = visibility, + sensitive = sensitive, + attachments = attachments, + poll = poll, + createdAt = createdAt ) } @@ -161,15 +168,14 @@ data class Status( return id.hashCode() } - - data class Mention ( + data class Mention( val id: String, val url: String, @SerializedName("acct") val username: String, @SerializedName("username") val localUsername: String ) - data class Application ( + data class Application( val name: String, val website: String? ) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/StatusContext.kt b/app/src/main/java/com/keylesspalace/tusky/entity/StatusContext.kt index 1287619b9..ce5bb1440 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/StatusContext.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/StatusContext.kt @@ -15,7 +15,7 @@ package com.keylesspalace.tusky.entity -data class StatusContext ( +data class StatusContext( val ancestors: List, val descendants: List ) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/StatusParams.kt b/app/src/main/java/com/keylesspalace/tusky/entity/StatusParams.kt index 0e25e6c16..d3235337b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/StatusParams.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/StatusParams.kt @@ -18,9 +18,9 @@ package com.keylesspalace.tusky.entity import com.google.gson.annotations.SerializedName data class StatusParams( - val text: String, - val sensitive: Boolean, - val visibility: Status.Visibility, - @SerializedName("spoiler_text") val spoilerText: String, - @SerializedName("in_reply_to_id") val inReplyToId: String? -) \ No newline at end of file + val text: String, + val sensitive: Boolean, + val visibility: Status.Visibility, + @SerializedName("spoiler_text") val spoilerText: String, + @SerializedName("in_reply_to_id") val inReplyToId: String? +) diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/AccountListFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/AccountListFragment.kt index cf7050b80..16e6523e4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/AccountListFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/AccountListFragment.kt @@ -26,14 +26,21 @@ import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.SimpleItemAnimator +import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from +import autodispose2.autoDispose import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.AccountActivity import com.keylesspalace.tusky.AccountListActivity.Type import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.adapter.* -import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.adapter.AccountAdapter +import com.keylesspalace.tusky.adapter.BlocksAdapter +import com.keylesspalace.tusky.adapter.FollowAdapter +import com.keylesspalace.tusky.adapter.FollowRequestsAdapter +import com.keylesspalace.tusky.adapter.FollowRequestsHeaderAdapter +import com.keylesspalace.tusky.adapter.MutesAdapter import com.keylesspalace.tusky.databinding.FragmentAccountListBinding +import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Relationship @@ -45,13 +52,11 @@ import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.view.EndlessOnScrollListener -import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from -import com.uber.autodispose.autoDispose -import io.reactivex.Single -import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Single import retrofit2.Response import java.io.IOException -import java.util.* +import java.util.HashMap import javax.inject.Inject class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountActionListener, Injectable { @@ -67,7 +72,7 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct private var id: String? = null private lateinit var scrollListener: EndlessOnScrollListener - private lateinit var adapter: AccountAdapter + private lateinit var adapter: AccountAdapter<*> private var fetching = false private var bottomId: String? = null @@ -133,12 +138,15 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct } else { api.muteAccount(id, notifications) } - .autoDispose(from(this)) - .subscribe({ + .autoDispose(from(this)) + .subscribe( + { onMuteSuccess(mute, id, position, notifications) - }, { + }, + { onMuteFailure(mute, id, notifications) - }) + } + ) } private fun onMuteSuccess(muted: Boolean, id: String, position: Int, notifications: Boolean) { @@ -151,11 +159,11 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct if (unmutedUser != null) { Snackbar.make(binding.recyclerView, R.string.confirmation_unmuted, Snackbar.LENGTH_LONG) - .setAction(R.string.action_undo) { - mutesAdapter.addItem(unmutedUser, position) - onMute(true, id, position, notifications) - } - .show() + .setAction(R.string.action_undo) { + mutesAdapter.addItem(unmutedUser, position) + onMute(true, id, position, notifications) + } + .show() } } @@ -178,12 +186,15 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct } else { api.blockAccount(id) } - .autoDispose(from(this)) - .subscribe({ + .autoDispose(from(this)) + .subscribe( + { onBlockSuccess(block, id, position) - }, { + }, + { onBlockFailure(block, id) - }) + } + ) } private fun onBlockSuccess(blocked: Boolean, id: String, position: Int) { @@ -195,11 +206,11 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct if (unblockedUser != null) { Snackbar.make(binding.recyclerView, R.string.confirmation_unblocked, Snackbar.LENGTH_LONG) - .setAction(R.string.action_undo) { - blocksAdapter.addItem(unblockedUser, position) - onBlock(true, id, position) - } - .show() + .setAction(R.string.action_undo) { + blocksAdapter.addItem(unblockedUser, position) + onBlock(true, id, position) + } + .show() } } @@ -212,26 +223,31 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct Log.e(TAG, "Failed to $verb account accountId $accountId") } - override fun onRespondToFollowRequest(accept: Boolean, accountId: String, - position: Int) { + override fun onRespondToFollowRequest( + accept: Boolean, + accountId: String, + position: Int + ) { if (accept) { api.authorizeFollowRequest(accountId) } else { api.rejectFollowRequest(accountId) }.observeOn(AndroidSchedulers.mainThread()) - .autoDispose(from(this, Lifecycle.Event.ON_DESTROY)) - .subscribe({ + .autoDispose(from(this, Lifecycle.Event.ON_DESTROY)) + .subscribe( + { onRespondToFollowRequestSuccess(position) - }, { throwable -> + }, + { throwable -> val verb = if (accept) { "accept" } else { "reject" } Log.e(TAG, "Failed to $verb account id $accountId.", throwable) - }) - + } + ) } private fun onRespondToFollowRequestSuccess(position: Int) { @@ -264,7 +280,7 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct } private fun requireId(type: Type, id: String?): String { - return requireNotNull(id) { "id must not be null for type "+type.name } + return requireNotNull(id) { "id must not be null for type " + type.name } } private fun fetchAccounts(fromId: String? = null) { @@ -278,9 +294,10 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct } getFetchCallByListType(fromId) - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(from(this, Lifecycle.Event.ON_DESTROY)) - .subscribe({ response -> + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(from(this, Lifecycle.Event.ON_DESTROY)) + .subscribe( + { response -> val accountList = response.body() if (response.isSuccessful && accountList != null) { @@ -289,10 +306,11 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct } else { onFetchAccountsFailure(Exception(response.message())) } - }, {throwable -> + }, + { throwable -> onFetchAccountsFailure(throwable) - }) - + } + ) } private fun onFetchAccountsSuccess(accounts: List, linkHeader: String?) { @@ -319,9 +337,9 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct if (adapter.itemCount == 0) { binding.messageView.show() binding.messageView.setup( - R.drawable.elephant_friend_empty, - R.string.message_empty, - null + R.drawable.elephant_friend_empty, + R.string.message_empty, + null ) } else { binding.messageView.hide() @@ -330,11 +348,11 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct private fun fetchRelationships(ids: List) { api.relationships(ids) - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(from(this)) - .subscribe(::onFetchRelationshipsSuccess) { - onFetchRelationshipsFailure(ids) - } + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(from(this)) + .subscribe(::onFetchRelationshipsSuccess) { + onFetchRelationshipsFailure(ids) + } } private fun onFetchRelationshipsSuccess(relationships: List) { diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/AccountMediaFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/AccountMediaFragment.kt index c299e1153..053299a53 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/AccountMediaFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/AccountMediaFragment.kt @@ -27,6 +27,7 @@ import androidx.fragment.app.Fragment import androidx.lifecycle.Lifecycle import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView +import autodispose2.androidx.lifecycle.autoDispose import com.bumptech.glide.Glide import com.keylesspalace.tusky.R import com.keylesspalace.tusky.ViewMediaActivity @@ -43,13 +44,12 @@ import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.view.SquareImageView import com.keylesspalace.tusky.viewdata.AttachmentViewData -import com.uber.autodispose.android.lifecycle.autoDispose -import io.reactivex.SingleObserver -import io.reactivex.android.schedulers.AndroidSchedulers -import io.reactivex.disposables.Disposable +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.SingleObserver +import io.reactivex.rxjava3.disposables.Disposable import retrofit2.Response import java.io.IOException -import java.util.* +import java.util.Random import javax.inject.Inject /** @@ -156,18 +156,17 @@ class AccountMediaFragment : Fragment(R.layout.fragment_timeline), RefreshableFr override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - isSwipeToRefreshEnabled = arguments?.getBoolean(ARG_ENABLE_SWIPE_TO_REFRESH,true) == true - accountId = arguments?.getString(ACCOUNT_ID_ARG)!! + isSwipeToRefreshEnabled = arguments?.getBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true) == true + accountId = arguments?.getString(ACCOUNT_ID_ARG)!! } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - val columnCount = view.context.resources.getInteger(R.integer.profile_media_column_count) val layoutManager = GridLayoutManager(view.context, columnCount) - adapter.baseItemColor = ThemeUtils.getColor(view.context, android.R.attr.windowBackground) + adapter.baseItemColor = ThemeUtils.getColor(view.context, android.R.attr.windowBackground) binding.recyclerView.layoutManager = layoutManager binding.recyclerView.adapter = adapter @@ -188,12 +187,12 @@ class AccountMediaFragment : Fragment(R.layout.fragment_timeline), RefreshableFr val lastItem = layoutManager.findLastCompletelyVisibleItemPosition() if (itemCount <= lastItem + 3 && fetchingStatus == FetchingStatus.NOT_FETCHING) { statuses.lastOrNull()?.let { (id) -> - Log.d(TAG, "Requesting statuses with max_id: ${id}, (bottom)") + Log.d(TAG, "Requesting statuses with max_id: $id, (bottom)") fetchingStatus = FetchingStatus.FETCHING_BOTTOM api.accountStatuses(accountId, id, null, null, null, true, null) - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(this@AccountMediaFragment, Lifecycle.Event.ON_DESTROY) - .subscribe(bottomCallback) + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(this@AccountMediaFragment, Lifecycle.Event.ON_DESTROY) + .subscribe(bottomCallback) } } } @@ -213,8 +212,8 @@ class AccountMediaFragment : Fragment(R.layout.fragment_timeline), RefreshableFr fetchingStatus = FetchingStatus.REFRESHING api.accountStatuses(accountId, null, statuses[0].id, null, null, true, null) }.observeOn(AndroidSchedulers.mainThread()) - .autoDispose(this, Lifecycle.Event.ON_DESTROY) - .subscribe(callback) + .autoDispose(this, Lifecycle.Event.ON_DESTROY) + .subscribe(callback) if (!isSwipeToRefreshEnabled) binding.topProgressBar.show() @@ -227,11 +226,10 @@ class AccountMediaFragment : Fragment(R.layout.fragment_timeline), RefreshableFr if (fetchingStatus == FetchingStatus.NOT_FETCHING && statuses.isEmpty()) { fetchingStatus = FetchingStatus.INITIAL_FETCHING api.accountStatuses(accountId, null, null, null, null, true, null) - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(this@AccountMediaFragment, Lifecycle.Event.ON_DESTROY) - .subscribe(callback) - } - else if (needToRefresh) + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(this@AccountMediaFragment, Lifecycle.Event.ON_DESTROY) + .subscribe(callback) + } else if (needToRefresh) refresh() needToRefresh = false } @@ -264,7 +262,7 @@ class AccountMediaFragment : Fragment(R.layout.fragment_timeline), RefreshableFr } inner class MediaGridAdapter : - RecyclerView.Adapter() { + RecyclerView.Adapter() { var baseItemColor = Color.BLACK @@ -305,15 +303,14 @@ class AccountMediaFragment : Fragment(R.layout.fragment_timeline), RefreshableFr val item = items[position] Glide.with(holder.imageView) - .load(item.attachment.previewUrl) - .centerInside() - .into(holder.imageView) + .load(item.attachment.previewUrl) + .centerInside() + .into(holder.imageView) } - - inner class MediaViewHolder(val imageView: ImageView) - : RecyclerView.ViewHolder(imageView), - View.OnClickListener { + inner class MediaViewHolder(val imageView: ImageView) : + RecyclerView.ViewHolder(imageView), + View.OnClickListener { init { itemView.setOnClickListener(this) } @@ -334,11 +331,11 @@ class AccountMediaFragment : Fragment(R.layout.fragment_timeline), RefreshableFr companion object { @JvmStatic - fun newInstance(accountId: String, enableSwipeToRefresh:Boolean=true): AccountMediaFragment { + fun newInstance(accountId: String, enableSwipeToRefresh: Boolean = true): AccountMediaFragment { val fragment = AccountMediaFragment() val args = Bundle() args.putString(ACCOUNT_ID_ARG, accountId) - args.putBoolean(ARG_ENABLE_SWIPE_TO_REFRESH,enableSwipeToRefresh) + args.putBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, enableSwipeToRefresh) fragment.arguments = args return fragment } @@ -347,4 +344,4 @@ class AccountMediaFragment : Fragment(R.layout.fragment_timeline), RefreshableFr private const val TAG = "AccountMediaFragment" private const val ARG_ENABLE_SWIPE_TO_REFRESH = "arg.enable.swipe.to.refresh" } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java index d42a45649..89e5e2797 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java @@ -58,6 +58,7 @@ import com.keylesspalace.tusky.appstore.BlockEvent; import com.keylesspalace.tusky.appstore.BookmarkEvent; import com.keylesspalace.tusky.appstore.EventHub; import com.keylesspalace.tusky.appstore.FavoriteEvent; +import com.keylesspalace.tusky.appstore.PinEvent; import com.keylesspalace.tusky.appstore.PreferenceChangedEvent; import com.keylesspalace.tusky.appstore.QuickReplyEvent; import com.keylesspalace.tusky.appstore.ReblogEvent; @@ -85,6 +86,7 @@ import com.keylesspalace.tusky.util.StatusDisplayOptions; import com.keylesspalace.tusky.util.ViewDataUtils; import com.keylesspalace.tusky.view.BackgroundMessageView; import com.keylesspalace.tusky.view.EndlessOnScrollListener; +import com.keylesspalace.tusky.viewdata.AttachmentViewData; import com.keylesspalace.tusky.viewdata.NotificationViewData; import com.keylesspalace.tusky.viewdata.StatusViewData; @@ -95,6 +97,7 @@ import java.util.Collections; import java.util.HashSet; import java.util.Iterator; import java.util.List; +import java.util.Locale; import java.util.Objects; import java.util.Set; import java.util.concurrent.TimeUnit; @@ -102,18 +105,18 @@ import java.util.concurrent.TimeUnit; import javax.inject.Inject; import at.connyduck.sparkbutton.helpers.Utils; -import io.reactivex.Observable; -import io.reactivex.Single; -import io.reactivex.android.schedulers.AndroidSchedulers; -import io.reactivex.disposables.CompositeDisposable; -import io.reactivex.disposables.Disposable; +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; +import io.reactivex.rxjava3.core.Observable; +import io.reactivex.rxjava3.core.Single; +import io.reactivex.rxjava3.disposables.CompositeDisposable; +import io.reactivex.rxjava3.disposables.Disposable; import kotlin.Unit; import kotlin.collections.CollectionsKt; import kotlin.jvm.functions.Function1; +import static autodispose2.AutoDispose.autoDisposable; +import static autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from; import static com.keylesspalace.tusky.util.StringUtils.isLessThan; -import static com.uber.autodispose.AutoDispose.autoDisposable; -import static com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from; public class NotificationsFragment extends SFragment implements SwipeRefreshLayout.OnRefreshListener, @@ -310,40 +313,11 @@ public class NotificationsFragment extends SFragment implements private void confirmClearNotifications() { new AlertDialog.Builder(getContext()) .setMessage(R.string.notification_clear_text) - .setPositiveButton(android.R.string.yes, (DialogInterface dia, int which) -> clearNotifications()) - .setNegativeButton(android.R.string.no, null) + .setPositiveButton(android.R.string.ok, (DialogInterface dia, int which) -> clearNotifications()) + .setNegativeButton(android.R.string.cancel, null) .show(); } - private void handleFavEvent(FavoriteEvent event) { - Pair posAndNotification = - findReplyPosition(event.getStatusId()); - if (posAndNotification == null) return; - //noinspection ConstantConditions - setFavouriteForStatus(posAndNotification.first, - posAndNotification.second.getStatus(), - event.getFavourite()); - } - - private void handleBookmarkEvent(BookmarkEvent event) { - Pair posAndNotification = - findReplyPosition(event.getStatusId()); - if (posAndNotification == null) return; - //noinspection ConstantConditions - setBookmarkForStatus(posAndNotification.first, - posAndNotification.second.getStatus(), - event.getBookmark()); - } - - private void handleReblogEvent(ReblogEvent event) { - Pair posAndNotification = findReplyPosition(event.getStatusId()); - if (posAndNotification == null) return; - //noinspection ConstantConditions - setReblogForStatus(posAndNotification.first, - posAndNotification.second.getStatus(), - event.getReblog()); - } - @Override public void onActivityCreated(@Nullable Bundle savedInstanceState) { super.onActivityCreated(savedInstanceState); @@ -387,14 +361,16 @@ public class NotificationsFragment extends SFragment implements eventHub.getEvents() .observeOn(AndroidSchedulers.mainThread()) - .as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) + .to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) .subscribe(event -> { if (event instanceof FavoriteEvent) { - handleFavEvent((FavoriteEvent) event); + setFavouriteForStatus(((FavoriteEvent) event).getStatusId(), ((FavoriteEvent) event).getFavourite()); } else if (event instanceof BookmarkEvent) { - handleBookmarkEvent((BookmarkEvent) event); + setBookmarkForStatus(((BookmarkEvent) event).getStatusId(), ((BookmarkEvent) event).getBookmark()); } else if (event instanceof ReblogEvent) { - handleReblogEvent((ReblogEvent) event); + setReblogForStatus(((ReblogEvent) event).getStatusId(), ((ReblogEvent) event).getReblog()); + } else if (event instanceof PinEvent) { + setPinForStatus(((PinEvent) event).getStatusId(), ((PinEvent) event).getPinned()); } else if (event instanceof BlockEvent) { removeAllByAccountId(((BlockEvent) event).getAccountId()); } else if (event instanceof PreferenceChangedEvent) { @@ -427,34 +403,21 @@ public class NotificationsFragment extends SFragment implements final Notification notification = notifications.get(position).asRight(); final Status status = notification.getStatus(); Objects.requireNonNull(status, "Reblog on notification without status"); - timelineCases.reblog(status, reblog) + timelineCases.reblog(status.getId(), reblog) .observeOn(AndroidSchedulers.mainThread()) - .as(autoDisposable(from(this))) + .to(autoDisposable(from(this))) .subscribe( - (newStatus) -> setReblogForStatus(position, status, reblog), + (newStatus) -> setReblogForStatus(status.getId(), reblog), (t) -> Log.d(getClass().getSimpleName(), "Failed to reblog status: " + status.getId(), t) ); } - private void setReblogForStatus(int position, Status status, boolean reblog) { - status.setReblogged(reblog); - - if (status.getReblog() != null) { - status.getReblog().setReblogged(reblog); - } - - NotificationViewData.Concrete viewdata = (NotificationViewData.Concrete) notifications.getPairedItem(position); - - StatusViewData.Builder viewDataBuilder = new StatusViewData.Builder(viewdata.getStatusViewData()); - viewDataBuilder.setReblogged(reblog); - - NotificationViewData.Concrete newViewData = new NotificationViewData.Concrete( - viewdata.getType(), viewdata.getId(), viewdata.getAccount(), - viewDataBuilder.createStatusViewData()); - - notifications.setPairedItem(position, newViewData); - updateAdapter(); + private void setReblogForStatus(String statusId, boolean reblog) { + updateStatus(statusId, (s) -> { + s.setReblogged(reblog); + return s; + }); } @Override @@ -462,34 +425,21 @@ public class NotificationsFragment extends SFragment implements final Notification notification = notifications.get(position).asRight(); final Status status = notification.getStatus(); - timelineCases.favourite(status, favourite) + timelineCases.favourite(status.getId(), favourite) .observeOn(AndroidSchedulers.mainThread()) - .as(autoDisposable(from(this))) + .to(autoDisposable(from(this))) .subscribe( - (newStatus) -> setFavouriteForStatus(position, status, favourite), + (newStatus) -> setFavouriteForStatus(status.getId(), favourite), (t) -> Log.d(getClass().getSimpleName(), "Failed to favourite status: " + status.getId(), t) ); } - private void setFavouriteForStatus(int position, Status status, boolean favourite) { - status.setFavourited(favourite); - - if (status.getReblog() != null) { - status.getReblog().setFavourited(favourite); - } - - NotificationViewData.Concrete viewdata = (NotificationViewData.Concrete) notifications.getPairedItem(position); - - StatusViewData.Builder viewDataBuilder = new StatusViewData.Builder(viewdata.getStatusViewData()); - viewDataBuilder.setFavourited(favourite); - - NotificationViewData.Concrete newViewData = new NotificationViewData.Concrete( - viewdata.getType(), viewdata.getId(), viewdata.getAccount(), - viewDataBuilder.createStatusViewData()); - - notifications.setPairedItem(position, newViewData); - updateAdapter(); + private void setFavouriteForStatus(String statusId, boolean favourite) { + updateStatus(statusId, (s) -> { + s.setFavourited(favourite); + return s; + }); } @Override @@ -497,34 +447,21 @@ public class NotificationsFragment extends SFragment implements final Notification notification = notifications.get(position).asRight(); final Status status = notification.getStatus(); - timelineCases.bookmark(status, bookmark) + timelineCases.bookmark(status.getActionableId(), bookmark) .observeOn(AndroidSchedulers.mainThread()) - .as(autoDisposable(from(this))) + .to(autoDisposable(from(this))) .subscribe( - (newStatus) -> setBookmarkForStatus(position, status, bookmark), + (newStatus) -> setBookmarkForStatus(status.getId(), bookmark), (t) -> Log.d(getClass().getSimpleName(), "Failed to bookmark status: " + status.getId(), t) ); } - private void setBookmarkForStatus(int position, Status status, boolean bookmark) { - status.setBookmarked(bookmark); - - if (status.getReblog() != null) { - status.getReblog().setBookmarked(bookmark); - } - - NotificationViewData.Concrete viewdata = (NotificationViewData.Concrete) notifications.getPairedItem(position); - - StatusViewData.Builder viewDataBuilder = new StatusViewData.Builder(viewdata.getStatusViewData()); - viewDataBuilder.setBookmarked(bookmark); - - NotificationViewData.Concrete newViewData = new NotificationViewData.Concrete( - viewdata.getType(), viewdata.getId(), viewdata.getAccount(), - viewDataBuilder.createStatusViewData()); - - notifications.setPairedItem(position, newViewData); - updateAdapter(); + private void setBookmarkForStatus(String statusId, boolean bookmark) { + updateStatus(statusId, (s) -> { + s.setBookmarked(bookmark); + return s; + }); } @Override @@ -534,31 +471,19 @@ public class NotificationsFragment extends SFragment implements public void onVoteInPoll(int position, @NonNull List choices) { final Notification notification = notifications.get(position).asRight(); - final Status status = notification.getStatus(); - - timelineCases.voteInPoll(status, choices) + final Status status = notification.getStatus().getActionableStatus(); + timelineCases.voteInPoll(status.getId(), status.getPoll().getId(), choices) .observeOn(AndroidSchedulers.mainThread()) - .as(autoDisposable(from(this))) + .to(autoDisposable(from(this))) .subscribe( - (newPoll) -> setVoteForPoll(position, newPoll), + (newPoll) -> setVoteForPoll(status, newPoll), (t) -> Log.d(TAG, "Failed to vote in poll: " + status.getId(), t) ); } - private void setVoteForPoll(int position, Poll poll) { - - NotificationViewData.Concrete viewdata = (NotificationViewData.Concrete) notifications.getPairedItem(position); - - StatusViewData.Builder viewDataBuilder = new StatusViewData.Builder(viewdata.getStatusViewData()); - viewDataBuilder.setPoll(poll); - - NotificationViewData.Concrete newViewData = new NotificationViewData.Concrete( - viewdata.getType(), viewdata.getId(), viewdata.getAccount(), - viewDataBuilder.createStatusViewData()); - - notifications.setPairedItem(position, newViewData); - updateAdapter(); + private void setVoteForPoll(Status status, Poll poll) { + updateStatus(status.getId(), (s) -> s.copyWithPoll(poll)); } @Override @@ -571,13 +496,17 @@ public class NotificationsFragment extends SFragment implements public void onViewMedia(int position, int attachmentIndex, @Nullable View view) { Notification notification = notifications.get(position).asRightOrNull(); if (notification == null || notification.getStatus() == null) return; - super.viewMedia(attachmentIndex, notification.getStatus(), view); + Status status = notification.getStatus(); + super.viewMedia(attachmentIndex, AttachmentViewData.list(status), view); } @Override public void onViewThread(int position) { Notification notification = notifications.get(position).asRight(); - super.viewThread(notification.getStatus()); + Status status = notification.getStatus(); + if (status == null) return; + ; + super.viewThread(status.getActionableId(), status.getActionableStatus().getUrl()); } @Override @@ -588,30 +517,19 @@ public class NotificationsFragment extends SFragment implements @Override public void onExpandedChange(boolean expanded, int position) { - NotificationViewData.Concrete old = - (NotificationViewData.Concrete) notifications.getPairedItem(position); - StatusViewData.Concrete statusViewData = - new StatusViewData.Builder(old.getStatusViewData()) - .setIsExpanded(expanded) - .createStatusViewData(); - NotificationViewData notificationViewData = new NotificationViewData.Concrete(old.getType(), - old.getId(), old.getAccount(), statusViewData); - notifications.setPairedItem(position, notificationViewData); - updateAdapter(); + updateViewDataAt(position, (vd) -> vd.copyWithExpanded(expanded)); } @Override public void onContentHiddenChange(boolean isShowing, int position) { - NotificationViewData.Concrete old = - (NotificationViewData.Concrete) notifications.getPairedItem(position); - StatusViewData.Concrete statusViewData = - new StatusViewData.Builder(old.getStatusViewData()) - .setIsShowingSensitiveContent(isShowing) - .createStatusViewData(); - NotificationViewData notificationViewData = new NotificationViewData.Concrete(old.getType(), - old.getId(), old.getAccount(), statusViewData); - notifications.setPairedItem(position, notificationViewData); - updateAdapter(); + updateViewDataAt(position, (vd) -> vd.copyWithShowingContent(isShowing)); + } + + private void setPinForStatus(String statusId, boolean pinned) { + updateStatus(statusId, status -> { + status.copyWithPinned(pinned); + return status; + }); } @Override @@ -637,42 +555,74 @@ public class NotificationsFragment extends SFragment implements @Override public void onContentCollapsedChange(boolean isCollapsed, int position) { - if (position < 0 || position >= notifications.size()) { - Log.e(TAG, String.format("Tried to access out of bounds status position: %d of %d", position, notifications.size() - 1)); - return; - } + updateViewDataAt(position, (vd) -> vd.copyWIthCollapsed(isCollapsed)); + ; + } - NotificationViewData notification = notifications.getPairedItem(position); - if (!(notification instanceof NotificationViewData.Concrete)) { - Log.e(TAG, String.format( - "Expected NotificationViewData.Concrete, got %s instead at position: %d of %d", - notification == null ? "null" : notification.getClass().getSimpleName(), + private void updateStatus(String statusId, Function mapper) { + int index = CollectionsKt.indexOfFirst(this.notifications, (s) -> s.isRight() && + s.asRight().getStatus() != null && + s.asRight().getStatus().getId().equals(statusId)); + if (index == -1) return; + + // We have quite some graph here: + // + // Notification --------> Status + // ^ + // | + // StatusViewData + // ^ + // | + // NotificationViewData -----+ + // + // So if we have "new" status we need to update all references to be sure that data is + // up-to-date: + // 1. update status + // 2. update notification + // 3. update statusViewData + // 4. update notificationViewData + + Status oldStatus = notifications.get(index).asRight().getStatus(); + NotificationViewData.Concrete oldViewData = + (NotificationViewData.Concrete) this.notifications.getPairedItem(index); + Status newStatus = mapper.apply(oldStatus); + Notification newNotification = this.notifications.get(index).asRight() + .copyWithStatus(newStatus); + StatusViewData.Concrete newStatusViewData = + Objects.requireNonNull(oldViewData.getStatusViewData()).copyWithStatus(newStatus); + NotificationViewData.Concrete newViewData = oldViewData.copyWithStatus(newStatusViewData); + + notifications.set(index, new Either.Right<>(newNotification)); + notifications.setPairedItem(index, newViewData); + + updateAdapter(); + } + + private void updateViewDataAt(int position, + Function mapper) { + if (position < 0 || position >= notifications.size()) { + String message = String.format( + Locale.getDefault(), + "Tried to access out of bounds status position: %d of %d", position, notifications.size() - 1 - )); + ); + Log.e(TAG, message); return; } + NotificationViewData someViewData = this.notifications.getPairedItem(position); + if (!(someViewData instanceof NotificationViewData.Concrete)) { + return; + } + NotificationViewData.Concrete oldViewData = (NotificationViewData.Concrete) someViewData; + StatusViewData.Concrete oldStatusViewData = oldViewData.getStatusViewData(); + if (oldStatusViewData == null) return; - StatusViewData.Concrete status = ((NotificationViewData.Concrete) notification).getStatusViewData(); - StatusViewData.Concrete updatedStatus = new StatusViewData.Builder(status) - .setCollapsed(isCollapsed) - .createStatusViewData(); + NotificationViewData.Concrete newViewData = + oldViewData.copyWithStatus(mapper.apply(oldStatusViewData)); + notifications.setPairedItem(position, newViewData); - NotificationViewData.Concrete concreteNotification = (NotificationViewData.Concrete) notification; - NotificationViewData updatedNotification = new NotificationViewData.Concrete( - concreteNotification.getType(), - concreteNotification.getId(), - concreteNotification.getAccount(), - updatedStatus - ); - notifications.setPairedItem(position, updatedNotification); updateAdapter(); - - // Since we cannot notify to the RecyclerView right away because it may be scrolling - // we run this when the RecyclerView is done doing measurements and other calculations. - // To test this is not bs: try getting a notification while scrolling, without wrapping - // notifyItemChanged in a .post() call. App will crash. - recyclerView.post(() -> adapter.notifyItemChanged(position, notification)); } @Override @@ -696,7 +646,7 @@ public class NotificationsFragment extends SFragment implements //Execute clear notifications request mastodonApi.clearNotifications() .observeOn(AndroidSchedulers.mainThread()) - .as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) + .to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) .subscribe( response -> { // nothing to do @@ -841,7 +791,7 @@ public class NotificationsFragment extends SFragment implements mastodonApi.authorizeFollowRequest(id) : mastodonApi.rejectFollowRequest(id); request.observeOn(AndroidSchedulers.mainThread()) - .as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) + .to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) .subscribe( (relationship) -> fullyRefreshWithProgressBar(true), (error) -> Log.e(TAG, String.format("Failed to %s account id %s", accept ? "accept" : "reject", id)) @@ -853,8 +803,11 @@ public class NotificationsFragment extends SFragment implements for (Either either : notifications) { Notification notification = either.asRightOrNull(); if (notification != null && notification.getId().equals(notificationId)) { - super.viewThread(notification.getStatus()); - return; + Status status = notification.getStatus(); + if (status != null) { + super.viewThread(status.getActionableId(), status.getActionableStatus().getUrl()); + return; + } } } Log.w(TAG, "Didn't find a notification for ID: " + notificationId); @@ -960,8 +913,8 @@ public class NotificationsFragment extends SFragment implements } Disposable notificationCall = mastodonApi.notifications(fromId, uptoId, LOAD_AT_ONCE, showNotificationsFilter ? notificationFilter : null) - .observeOn(AndroidSchedulers.mainThread()) - .as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) + .observeOn(AndroidSchedulers.mainThread()) + .to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) .subscribe( response -> { if (response.isSuccessful()) { @@ -1293,7 +1246,7 @@ public class NotificationsFragment extends SFragment implements if (!useAbsoluteTime) { Observable.interval(1, TimeUnit.MINUTES) .observeOn(AndroidSchedulers.mainThread()) - .as(autoDisposable(from(this, Lifecycle.Event.ON_PAUSE))) + .to(autoDisposable(from(this, Lifecycle.Event.ON_PAUSE))) .subscribe( interval -> updateAdapter() ); @@ -1305,9 +1258,4 @@ public class NotificationsFragment extends SFragment implements public void onReselect() { jumpToTop(); } - - @Override - public void onReset() { - fullyRefresh(); - } } diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java index 9279babb1..53bb6b158 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java @@ -24,7 +24,6 @@ import android.content.Intent; import android.content.pm.PackageManager; import android.net.Uri; import android.os.Environment; -import android.text.TextUtils; import android.util.Log; import android.view.Menu; import android.view.MenuItem; @@ -33,7 +32,6 @@ import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.annotation.VisibleForTesting; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.widget.PopupMenu; import androidx.core.app.ActivityOptionsCompat; @@ -55,8 +53,6 @@ import com.keylesspalace.tusky.db.AccountEntity; import com.keylesspalace.tusky.db.AccountManager; import com.keylesspalace.tusky.di.Injectable; import com.keylesspalace.tusky.entity.Attachment; -import com.keylesspalace.tusky.entity.Filter; -import com.keylesspalace.tusky.entity.PollOption; import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.network.MastodonApi; import com.keylesspalace.tusky.network.TimelineCases; @@ -64,23 +60,17 @@ import com.keylesspalace.tusky.util.LinkHelper; import com.keylesspalace.tusky.view.MuteAccountDialog; import com.keylesspalace.tusky.viewdata.AttachmentViewData; -import java.util.ArrayList; import java.util.LinkedHashSet; import java.util.List; import java.util.Set; -import java.util.regex.Matcher; -import java.util.regex.Pattern; import javax.inject.Inject; -import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import kotlin.Unit; -import retrofit2.Call; -import retrofit2.Callback; -import retrofit2.Response; -import static com.uber.autodispose.AutoDispose.autoDisposable; -import static com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from; +import static autodispose2.AutoDispose.autoDisposable; +import static autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from; /* Note from Andrew on Jan. 22, 2017: This class is a design problem for me, so I left it with an * awkward name. TimelineFragment and NotificationFragment have significant overlap but the nature @@ -96,11 +86,6 @@ public abstract class SFragment extends Fragment implements Injectable { private BottomSheetActivity bottomSheetActivity; - private static List filters; - private boolean filterRemoveRegex; - private Matcher filterRemoveRegexMatcher; - private static final Matcher alphanumeric = Pattern.compile("^\\w+$").matcher(""); - @Inject public MastodonApi mastodonApi; @Inject @@ -131,9 +116,8 @@ public abstract class SFragment extends Fragment implements Injectable { bottomSheetActivity.viewAccount(status.getAccount().getId()); } - protected void viewThread(Status status) { - Status actionableStatus = status.getActionableStatus(); - bottomSheetActivity.viewThread(actionableStatus.getId(), actionableStatus.getUrl()); + protected void viewThread(String statusId, @Nullable String statusUrl) { + bottomSheetActivity.viewThread(statusId, statusUrl); } protected void viewAccount(String accountId) { @@ -149,7 +133,7 @@ public abstract class SFragment extends Fragment implements Injectable { Status actionableStatus = status.getActionableStatus(); Status.Visibility replyVisibility = actionableStatus.getVisibility(); String contentWarning = actionableStatus.getSpoilerText(); - Status.Mention[] mentions = actionableStatus.getMentions(); + List mentions = actionableStatus.getMentions(); Set mentionedUsernames = new LinkedHashSet<>(); mentionedUsernames.add(actionableStatus.getAccount().getUsername()); String loggedInUsername = null; @@ -177,7 +161,7 @@ public abstract class SFragment extends Fragment implements Injectable { String id = status.getActionableId(); Status actionableStatus = status.getActionableStatus(); Status.Visibility visibility = actionableStatus.getVisibility(); - Status.Mention[] mentions = actionableStatus.getMentions(); + List mentions = actionableStatus.getMentions(); Set mentionedUsernames = new LinkedHashSet<>(); mentionedUsernames.add(actionableStatus.getAccount().getUsername()); String loggedInUsername = null; @@ -343,14 +327,14 @@ public abstract class SFragment extends Fragment implements Injectable { return true; } case R.id.pin: { - timelineCases.pin(status, !status.isPinned()); + timelineCases.pin(status.getId(), !status.isPinned()); return true; } case R.id.status_mute_conversation: { - timelineCases.muteConversation(status, status.getMuted() == null || !status.getMuted()) + timelineCases.muteConversation(status.getId(), status.getMuted() == null || !status.getMuted()) .onErrorReturnItem(status) .observeOn(AndroidSchedulers.mainThread()) - .as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) + .to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) .subscribe(); return true; } @@ -362,12 +346,12 @@ public abstract class SFragment extends Fragment implements Injectable { private void onMute(String accountId, String accountUsername) { MuteAccountDialog.showMuteAccountDialog( - this.getActivity(), - accountUsername, - (notifications, duration) -> { - timelineCases.mute(accountId, notifications, duration); - return Unit.INSTANCE; - } + this.getActivity(), + accountUsername, + (notifications, duration) -> { + timelineCases.mute(accountId, notifications, duration); + return Unit.INSTANCE; + } ); } @@ -379,7 +363,7 @@ public abstract class SFragment extends Fragment implements Injectable { .show(); } - private static boolean accountIsInMentions(AccountEntity account, Status.Mention[] mentions) { + private static boolean accountIsInMentions(AccountEntity account, List mentions) { if (account == null) { return false; } @@ -395,20 +379,18 @@ public abstract class SFragment extends Fragment implements Injectable { return false; } - protected void viewMedia(int urlIndex, Status status, @Nullable View view) { - final Status actionable = status.getActionableStatus(); - final Attachment active = actionable.getAttachments().get(urlIndex); - Attachment.Type type = active.getType(); + protected void viewMedia(int urlIndex, List attachments, @Nullable View view) { + final AttachmentViewData active = attachments.get(urlIndex); + Attachment.Type type = active.getAttachment().getType(); switch (type) { case GIFV: case VIDEO: case IMAGE: case AUDIO: { - final List attachments = AttachmentViewData.list(actionable); final Intent intent = ViewMediaActivity.newIntent(getContext(), attachments, urlIndex); if (view != null) { - String url = active.getUrl(); + String url = active.getAttachment().getUrl(); ViewCompat.setTransitionName(view, url); ActivityOptionsCompat options = ActivityOptionsCompat.makeSceneTransitionAnimation(getActivity(), @@ -421,7 +403,7 @@ public abstract class SFragment extends Fragment implements Injectable { } default: case UNKNOWN: { - LinkHelper.openLink(active.getUrl(), getContext()); + LinkHelper.openLink(active.getAttachment().getUrl(), getContext()); break; } } @@ -443,7 +425,7 @@ public abstract class SFragment extends Fragment implements Injectable { .setPositiveButton(android.R.string.ok, (dialogInterface, i) -> { timelineCases.delete(id) .observeOn(AndroidSchedulers.mainThread()) - .as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) + .to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) .subscribe( deletedStatus -> { }, @@ -466,7 +448,7 @@ public abstract class SFragment extends Fragment implements Injectable { .setPositiveButton(android.R.string.ok, (dialogInterface, i) -> { timelineCases.delete(id) .observeOn(AndroidSchedulers.mainThread()) - .as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) + .to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) .subscribe(deletedStatus -> { removeItem(position); @@ -537,83 +519,4 @@ public abstract class SFragment extends Fragment implements Injectable { } }); } - - @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) - public void reloadFilters(boolean forceRefresh) { - if (filters != null && !forceRefresh) { - applyFilters(forceRefresh); - return; - } - - mastodonApi.getFilters().enqueue(new Callback>() { - @Override - public void onResponse(@NonNull Call> call, @NonNull Response> response) { - filters = response.body(); - if (response.isSuccessful() && filters != null) { - applyFilters(forceRefresh); - } else { - Log.e(TAG, "Error getting filters from server"); - } - } - - @Override - public void onFailure(@NonNull Call> call, @NonNull Throwable t) { - Log.e(TAG, "Error getting filters from server", t); - } - }); - } - - protected boolean filterIsRelevant(@NonNull Filter filter) { - // Called when building local filter expression - // Override to select relevant filters for your fragment - return false; - } - - protected void refreshAfterApplyingFilters() { - // Called after filters are updated - // Override to refresh your fragment - } - - @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) - public boolean shouldFilterStatus(Status status) { - - if (filterRemoveRegex && status.getPoll() != null) { - for (PollOption option : status.getPoll().getOptions()) { - if (filterRemoveRegexMatcher.reset(option.getTitle()).find()) { - return true; - } - } - } - - return (filterRemoveRegex && (filterRemoveRegexMatcher.reset(status.getActionableStatus().getContent()).find() - || (!status.getSpoilerText().isEmpty() && filterRemoveRegexMatcher.reset(status.getActionableStatus().getSpoilerText()).find()))); - } - - private void applyFilters(boolean refresh) { - List tokens = new ArrayList<>(); - for (Filter filter : filters) { - if (filterIsRelevant(filter)) { - tokens.add(filterToRegexToken(filter)); - } - } - filterRemoveRegex = !tokens.isEmpty(); - if (filterRemoveRegex) { - filterRemoveRegexMatcher = Pattern.compile(TextUtils.join("|", tokens), Pattern.CASE_INSENSITIVE).matcher(""); - } - if (refresh) { - refreshAfterApplyingFilters(); - } - } - - private static String filterToRegexToken(Filter filter) { - String phrase = filter.getPhrase(); - String quotedPhrase = Pattern.quote(phrase); - return (filter.getWholeWord() && alphanumeric.reset(phrase).matches()) ? // "whole word" should only apply to alphanumeric filters, #1543 - String.format("(^|\\W)%s($|\\W)", quotedPhrase) : - quotedPhrase; - } - - public static void flushFilters() { - filters = null; - } } diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.kt deleted file mode 100644 index a3c3494ff..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.kt +++ /dev/null @@ -1,1411 +0,0 @@ -/* 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 . */ - -package com.keylesspalace.tusky.fragment - -import android.content.Context.CONNECTIVITY_SERVICE -import android.content.SharedPreferences -import android.net.ConnectivityManager -import android.os.Bundle -import android.util.Log -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.accessibility.AccessibilityManager -import androidx.core.content.ContextCompat -import androidx.core.util.Pair -import androidx.lifecycle.Lifecycle -import androidx.preference.PreferenceManager -import androidx.recyclerview.widget.* -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener -import at.connyduck.sparkbutton.helpers.Utils -import com.keylesspalace.tusky.AccountListActivity -import com.keylesspalace.tusky.AccountListActivity.Companion.newIntent -import com.keylesspalace.tusky.BaseActivity -import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.adapter.StatusBaseViewHolder -import com.keylesspalace.tusky.adapter.TimelineAdapter -import com.keylesspalace.tusky.appstore.* -import com.keylesspalace.tusky.components.compose.CAN_USE_QUOTE_ID -import com.keylesspalace.tusky.databinding.FragmentTimelineBinding -import com.keylesspalace.tusky.db.AccountManager -import com.keylesspalace.tusky.di.Injectable -import com.keylesspalace.tusky.entity.Filter -import com.keylesspalace.tusky.entity.Poll -import com.keylesspalace.tusky.entity.Status -import com.keylesspalace.tusky.interfaces.ActionButtonActivity -import com.keylesspalace.tusky.interfaces.RefreshableFragment -import com.keylesspalace.tusky.interfaces.ReselectableFragment -import com.keylesspalace.tusky.interfaces.StatusActionListener -import com.keylesspalace.tusky.repository.Placeholder -import com.keylesspalace.tusky.repository.TimelineRepository -import com.keylesspalace.tusky.repository.TimelineRequestMode -import com.keylesspalace.tusky.settings.PrefKeys -import com.keylesspalace.tusky.util.* -import com.keylesspalace.tusky.util.Either.Left -import com.keylesspalace.tusky.util.Either.Right -import com.keylesspalace.tusky.view.EndlessOnScrollListener -import com.keylesspalace.tusky.viewdata.StatusViewData -import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from -import com.uber.autodispose.autoDispose -import io.reactivex.Observable -import io.reactivex.Single -import io.reactivex.android.schedulers.AndroidSchedulers -import net.accelf.yuito.TimelineStreamingListener -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.WebSocket -import retrofit2.Response -import java.io.IOException -import java.util.* -import java.util.concurrent.TimeUnit -import javax.inject.Inject -import kotlin.collections.ArrayList - -class TimelineFragment : SFragment(), OnRefreshListener, StatusActionListener, Injectable, ReselectableFragment, RefreshableFragment { - - @Inject - lateinit var eventHub: EventHub - - @Inject - lateinit var timelineRepo: TimelineRepository - - @Inject - lateinit var accountManager: AccountManager - - private val binding by viewBinding(FragmentTimelineBinding::bind) - - private var kind: Kind? = null - private var id: String? = null - private var tags: List = emptyList() - - private lateinit var adapter: TimelineAdapter - - private var isSwipeToRefreshEnabled = true - private var isNeedRefresh = false - - var isStreamingEnabled = false - set(value) { - field = value - when (value) { - true -> startStreaming() - false -> stopStreaming() - } - } - - private var webSocket: WebSocket? = null - - private var eventRegistered = false - - /** - * For some timeline kinds we must use LINK headers and not just status ids. - */ - private var nextId: String? = null - private var layoutManager: LinearLayoutManager? = null - private var scrollListener: EndlessOnScrollListener? = null - private var filterRemoveReplies = false - private var filterRemoveReblogs = false - private var hideFab = false - private var bottomLoading = false - private var didLoadEverythingBottom = false - private var alwaysShowSensitiveMedia = false - private var alwaysOpenSpoiler = false - private var initialUpdateFailed = false - - private var reduceTimelineLoading = false - private var checkMobileNetwork = true - - private val statuses = PairedList, StatusViewData> { input -> - val status = input.asRightOrNull() - if (status != null) { - ViewDataUtils.statusToViewData( - status, - alwaysShowSensitiveMedia, - alwaysOpenSpoiler - ) - } else { - val (id1) = input.asLeft() - StatusViewData.Placeholder(id1, false) - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - val arguments = requireArguments() - kind = Kind.valueOf(arguments.getString(KIND_ARG)!!) - if (kind == Kind.USER || kind == Kind.USER_PINNED || kind == Kind.USER_WITH_REPLIES || kind == Kind.LIST) { - id = arguments.getString(ID_ARG)!! - } - if (kind == Kind.TAG) { - tags = arguments.getStringArrayList(HASHTAGS_ARG)!! - } - - isSwipeToRefreshEnabled = arguments.getBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true) - isStreamingEnabled = arguments.getBoolean(ARG_ENABLE_STREAMING, false) - - val preferences = PreferenceManager.getDefaultSharedPreferences(activity) - val statusDisplayOptions = StatusDisplayOptions( - animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), - mediaPreviewEnabled = accountManager.activeAccount!!.mediaPreviewEnabled, - useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false), - showBotOverlay = preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true), - useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true), - cardViewMode = if (preferences.getBoolean(PrefKeys.SHOW_CARDS_IN_TIMELINES, false)) CardViewMode.INDENTED else CardViewMode.NONE, - confirmReblogs = preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true), - hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), - animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), - quoteEnabled = CAN_USE_QUOTE_ID.contains(accountManager.activeAccount?.domain), - ) - adapter = TimelineAdapter(dataSource, statusDisplayOptions, this) - } - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - return inflater.inflate(R.layout.fragment_timeline, container, false) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - setupSwipeRefreshLayout() - setupRecyclerView() - updateAdapter() - setupTimelinePreferences() - if (statuses.isEmpty()) { - binding.progressBar.show() - bottomLoading = true - sendInitialRequest() - } else { - binding.progressBar.hide() - if (isNeedRefresh) { - onRefresh() - } - } - } - - override fun onStart() { - super.onStart() - - if (isStreamingEnabled) { - startStreaming() - } - } - - override fun onStop() { - super.onStop() - - stopStreaming() - } - - private fun startStreaming() { - accountManager.activeAccount?.let { activeAccount -> - val params = when (kind) { - Kind.HOME -> { - "user" - } - Kind.PUBLIC_FEDERATED -> { - "public" - } - Kind.PUBLIC_LOCAL -> { - "public:local" - } - Kind.LIST -> { - "list&list=$id" - } - else -> { - return - } - } - val endpoint = ("wss://${activeAccount.domain}/api/v1/streaming/?access_token=${activeAccount.accessToken}&stream=${params}") - if (webSocket != null) { - stopStreaming() - } - val request: Request = Request.Builder().url(endpoint).build() - val client: OkHttpClient = OkHttpClient.Builder().build() - webSocket = client.newWebSocket(request, TimelineStreamingListener(eventHub, kind!!, id)) - } - } - - private fun stopStreaming() { - webSocket?.close(1000, null) - webSocket = null - } - - private fun sendInitialRequest() { - if (kind == Kind.HOME) { - tryCache() - } else { - sendFetchTimelineRequest(null, null, null, FetchEnd.BOTTOM, -1) - } - } - - private fun tryCache() { - // Request timeline from disk to make it quick, then replace it with timeline from - // the server to update it - timelineRepo.getStatuses(null, null, null, LOAD_AT_ONCE, TimelineRequestMode.DISK) - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(from(this)) - .subscribe { statuses: List> -> - val mutableStatusResponse = statuses.toMutableList() - filterStatuses(mutableStatusResponse) - if (statuses.size > 1) { - clearPlaceholdersForResponse(mutableStatusResponse) - this.statuses.clear() - this.statuses.addAll(statuses) - updateAdapter() - binding.progressBar.hide() - // Request statuses including current top to refresh all of them - } - updateCurrent() - loadAbove() - } - } - - private fun updateCurrent() { - if (statuses.isEmpty()) { - return - } - val topId = statuses.first { status -> status.isRight() }!!.asRight().id - timelineRepo.getStatuses(topId, null, null, LOAD_AT_ONCE, - TimelineRequestMode.NETWORK) - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(from(this)) - .subscribe( - { statuses: List> -> - - initialUpdateFailed = false - // When cached timeline is too old, we would replace it with nothing - if (statuses.isNotEmpty()) { - val mutableStatuses = statuses.toMutableList() - filterStatuses(mutableStatuses) - if (!this.statuses.isEmpty()) { - // clear old cached statuses - val iterator = this.statuses.iterator() - while (iterator.hasNext()) { - val item = iterator.next() - if (item.isRight()) { - val (id1) = item.asRight() - if (id1.length < topId.length || id1 < topId) { - iterator.remove() - } - } else { - val (id1) = item.asLeft() - if (id1.length < topId.length || id1 < topId) { - iterator.remove() - } - } - } - } - this.statuses.addAll(mutableStatuses) - updateAdapter() - } - bottomLoading = false - }, - { t: Throwable? -> - Log.d(TAG, "Failed updating timeline", t) - initialUpdateFailed = true - // Indicate that we are not loading anymore - binding.progressBar.hide() - binding.swipeRefreshLayout.isRefreshing = false - }) - } - - private fun setupTimelinePreferences() { - alwaysShowSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia - alwaysOpenSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler - val preferences = PreferenceManager.getDefaultSharedPreferences(context) - if (kind == Kind.HOME) { - filterRemoveReplies = !preferences.getBoolean("tabFilterHomeReplies", true) - filterRemoveReblogs = !preferences.getBoolean("tabFilterHomeBoosts", true) - } - reloadFilters(false) - updateLimitedBandwidthStatus(preferences) - } - - override fun filterIsRelevant(filter: Filter): Boolean { - return filterContextMatchesKind(kind, filter.context) - } - - override fun refreshAfterApplyingFilters() { - fullyRefresh() - } - - private fun setupSwipeRefreshLayout() { - binding.swipeRefreshLayout.isEnabled = isSwipeToRefreshEnabled - binding.swipeRefreshLayout.setOnRefreshListener(this) - binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue) - } - - private fun setupRecyclerView() { - binding.recyclerView.setAccessibilityDelegateCompat( - ListStatusAccessibilityDelegate(binding.recyclerView, this) - { pos -> statuses.getPairedItemOrNull(pos) } - ) - binding.recyclerView.setHasFixedSize(true) - layoutManager = LinearLayoutManager(context) - binding.recyclerView.layoutManager = layoutManager - val divider = DividerItemDecoration(context, RecyclerView.VERTICAL) - binding.recyclerView.addItemDecoration(divider) - - // CWs are expanded without animation, buttons animate itself, we don't need it basically - (binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false - binding.recyclerView.adapter = adapter - } - - private fun deleteStatusById(id: String) { - for (i in statuses.indices) { - val either = statuses[i] - if (either.isRight() && id == either.asRight().id) { - statuses.remove(either) - updateAdapter() - break - } - } - if (statuses.isEmpty()) { - showEmptyView() - } - } - - private fun showEmptyView() { - binding.statusView.show() - binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null) - } - - override fun onActivityCreated(savedInstanceState: Bundle?) { - super.onActivityCreated(savedInstanceState) - - /* This is delayed until onActivityCreated solely because MainActivity.composeButton isn't - * guaranteed to be set until then. */ - scrollListener = if (actionButtonPresent()) { - /* Use a modified scroll listener that both loads more statuses as it goes, and hides - * the follow button on down-scroll. */ - val preferences = PreferenceManager.getDefaultSharedPreferences(context) - hideFab = preferences.getBoolean("fabHide", false) - object : EndlessOnScrollListener(layoutManager) { - override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) { - super.onScrolled(view, dx, dy) - val composeButton = (activity as ActionButtonActivity).actionButton - if (composeButton != null) { - if (hideFab) { - if (dy > 0 && composeButton.isShown) { - composeButton.hide() // hides the button if we're scrolling down - } else if (dy < 0 && !composeButton.isShown) { - composeButton.show() // shows it if we are scrolling up - } - } else if (!composeButton.isShown) { - composeButton.show() - } - } - } - - override fun onLoadMore(totalItemsCount: Int, view: RecyclerView) { - this@TimelineFragment.onLoadMore() - } - } - } else { - // Just use the basic scroll listener to load more statuses. - object : EndlessOnScrollListener(layoutManager) { - override fun onLoadMore(totalItemsCount: Int, view: RecyclerView) { - this@TimelineFragment.onLoadMore() - } - } - }.also { - binding.recyclerView.addOnScrollListener(it) - } - - if (!eventRegistered) { - eventHub.events - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(from(this, Lifecycle.Event.ON_DESTROY)) - .subscribe { event: Event? -> - when (event) { - is FavoriteEvent -> handleFavEvent(event) - is ReblogEvent -> handleReblogEvent(event) - is BookmarkEvent -> handleBookmarkEvent(event) - is MuteConversationEvent -> fullyRefresh() - is UnfollowEvent -> { - if (kind == Kind.HOME) { - val id = event.accountId - removeAllByAccountId(id) - } - } - is BlockEvent -> { - if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { - val id = event.accountId - removeAllByAccountId(id) - } - } - is MuteEvent -> { - if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { - val id = event.accountId - removeAllByAccountId(id) - } - } - is DomainMuteEvent -> { - if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { - val instance = event.instance - removeAllByInstance(instance) - } - } - is StatusDeletedEvent -> { - if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { - val id = event.statusId - deleteStatusById(id) - } - } - is StatusComposedEvent -> { - val status = event.status - handleStatusComposeEvent(status) - } - is PreferenceChangedEvent -> { - onPreferenceChanged(event.preferenceKey) - } - is StreamUpdateEvent -> { - if (isStreamingEnabled) { - handleStreamUpdateEvent(event) - } - } - } - } - eventRegistered = true - } - } - - override fun onRefresh() { - binding.swipeRefreshLayout.isEnabled = isSwipeToRefreshEnabled - binding.statusView.hide() - isNeedRefresh = false - if (initialUpdateFailed) { - updateCurrent() - } - loadAbove() - } - - private fun loadAbove() { - var firstOrNull: String? = null - var secondOrNull: String? = null - for (i in statuses.indices) { - val status = statuses[i] - if (status.isRight()) { - firstOrNull = status.asRight().id - if (i + 1 < statuses.size && statuses[i + 1].isRight()) { - secondOrNull = statuses[i + 1].asRight().id - } - break - } - } - if (firstOrNull != null) { - sendFetchTimelineRequest(null, firstOrNull, secondOrNull, FetchEnd.TOP, -1) - } else { - sendFetchTimelineRequest(null, null, null, FetchEnd.BOTTOM, -1) - } - } - - override fun onReply(position: Int) { - when (kind) { - Kind.HOME, - Kind.PUBLIC_LOCAL, - Kind.PUBLIC_FEDERATED, - Kind.TAG, - Kind.FAVOURITES, - Kind.LIST -> { - eventHub.dispatch(QuickReplyEvent(statuses[position].asRight().actionableStatus)) - } - Kind.BOOKMARKS, - Kind.USER, - Kind.USER_PINNED, - Kind.USER_WITH_REPLIES -> { - super.reply(statuses[position].asRight()) - } - } - } - - override fun onReblog(reblog: Boolean, position: Int) { - val status = statuses[position].asRight() - timelineCases.reblog(status, reblog) - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(from(this)) - .subscribe( - { newStatus: Status -> setRebloggedForStatus(position, newStatus, reblog) } - ) { t: Throwable? -> Log.d(TAG, "Failed to reblog status " + status.id, t) } - } - - private fun setRebloggedForStatus(position: Int, status: Status, reblog: Boolean) { - status.reblogged = reblog - if (status.reblog != null) { - status.reblog.reblogged = reblog - } - val actual = findStatusAndPosition(position, status) ?: return - val newViewData: StatusViewData = StatusViewData.Builder(actual.first) - .setReblogged(reblog) - .createStatusViewData() - statuses.setPairedItem(actual.second!!, newViewData) - updateAdapter() - } - - override fun onFavourite(favourite: Boolean, position: Int) { - val status = statuses[position].asRight() - timelineCases.favourite(status, favourite) - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(from(this)) - .subscribe( - { newStatus: Status -> setFavouriteForStatus(position, newStatus, favourite) }, - { t: Throwable? -> Log.d(TAG, "Failed to favourite status " + status.id, t) } - ) - } - - private fun setFavouriteForStatus(position: Int, status: Status, favourite: Boolean) { - status.favourited = favourite - if (status.reblog != null) { - status.reblog.favourited = favourite - } - val actual = findStatusAndPosition(position, status) ?: return - val newViewData: StatusViewData = StatusViewData.Builder(actual.first) - .setFavourited(favourite) - .createStatusViewData() - statuses.setPairedItem(actual.second!!, newViewData) - updateAdapter() - } - - override fun onQuote(position: Int) { - super.quote(statuses[position].asRight()) - } - - override fun onBookmark(bookmark: Boolean, position: Int) { - val status = statuses[position].asRight() - timelineCases.bookmark(status, bookmark) - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(from(this)) - .subscribe( - { newStatus: Status -> setBookmarkForStatus(position, newStatus, bookmark) }, - { t: Throwable? -> Log.d(TAG, "Failed to favourite status " + status.id, t) } - ) - } - - private fun setBookmarkForStatus(position: Int, status: Status, bookmark: Boolean) { - status.bookmarked = bookmark - if (status.reblog != null) { - status.reblog.bookmarked = bookmark - } - val actual = findStatusAndPosition(position, status) ?: return - val newViewData: StatusViewData = StatusViewData.Builder(actual.first) - .setBookmarked(bookmark) - .createStatusViewData() - statuses.setPairedItem(actual.second!!, newViewData) - updateAdapter() - } - - override fun onVoteInPoll(position: Int, choices: List) { - val status = statuses[position].asRight() - val votedPoll = status.actionableStatus.poll!!.votedCopy(choices) - setVoteForPoll(position, status, votedPoll) - timelineCases.voteInPoll(status, choices) - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(from(this)) - .subscribe( - { newPoll: Poll -> setVoteForPoll(position, status, newPoll) }, - { t: Throwable? -> Log.d(TAG, "Failed to vote in poll: " + status.id, t) } - ) - } - - private fun setVoteForPoll(position: Int, status: Status, newPoll: Poll) { - val actual = findStatusAndPosition(position, status) ?: return - val newViewData: StatusViewData = StatusViewData.Builder(actual.first) - .setPoll(newPoll) - .createStatusViewData() - statuses.setPairedItem(actual.second!!, newViewData) - updateAdapter() - } - - override fun onMore(view: View, position: Int) { - super.more(statuses[position].asRight(), view, position) - } - - override fun onOpenReblog(position: Int) { - super.openReblog(statuses[position].asRight()) - } - - override fun onExpandedChange(expanded: Boolean, position: Int) { - val newViewData: StatusViewData = StatusViewData.Builder( - statuses.getPairedItem(position) as StatusViewData.Concrete) - .setIsExpanded(expanded).createStatusViewData() - statuses.setPairedItem(position, newViewData) - updateAdapter() - } - - override fun onContentHiddenChange(isShowing: Boolean, position: Int) { - val newViewData: StatusViewData = StatusViewData.Builder( - statuses.getPairedItem(position) as StatusViewData.Concrete) - .setIsShowingSensitiveContent(isShowing).createStatusViewData() - statuses.setPairedItem(position, newViewData) - updateAdapter() - } - - override fun onShowReblogs(position: Int) { - val statusId = statuses[position].asRight().id - val intent = newIntent(requireContext(), AccountListActivity.Type.REBLOGGED, statusId) - (activity as BaseActivity).startActivityWithSlideInAnimation(intent) - } - - override fun onShowFavs(position: Int) { - val statusId = statuses[position].asRight().id - val intent = newIntent(requireContext(), AccountListActivity.Type.FAVOURITED, statusId) - (activity as BaseActivity).startActivityWithSlideInAnimation(intent) - } - - override fun onLoadMore(position: Int) { - //check bounds before accessing list, - if (statuses.size >= position && position > 0) { - val fromStatus = statuses[position - 1].asRightOrNull() - val toStatus = statuses[position + 1].asRightOrNull() - val maxMinusOne = if (statuses.size > position + 1 && statuses[position + 2].isRight()) statuses[position + 1].asRight().id else null - if (fromStatus == null || toStatus == null) { - Log.e(TAG, "Failed to load more at $position, wrong placeholder position") - return - } - sendFetchTimelineRequest(fromStatus.id, toStatus.id, maxMinusOne, - FetchEnd.MIDDLE, position) - val (id1) = statuses[position].asLeft() - val newViewData: StatusViewData = StatusViewData.Placeholder(id1, true) - statuses.setPairedItem(position, newViewData) - updateAdapter() - } else { - Log.e(TAG, "error loading more") - } - } - - override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { - if (position < 0 || position >= statuses.size) { - Log.e(TAG, String.format("Tried to access out of bounds status position: %d of %d", position, statuses.size - 1)) - return - } - val status = statuses.getPairedItem(position) - if (status !is StatusViewData.Concrete) { - // Statuses PairedList contains a base type of StatusViewData.Concrete and also doesn't - // check for null values when adding values to it although this doesn't seem to be an issue. - Log.e(TAG, String.format( - "Expected StatusViewData.Concrete, got %s instead at position: %d of %d", - status?.javaClass?.simpleName ?: "", - position, - statuses.size - 1 - )) - return - } - val updatedStatus: StatusViewData = StatusViewData.Builder(status) - .setCollapsed(isCollapsed) - .createStatusViewData() - statuses.setPairedItem(position, updatedStatus) - updateAdapter() - } - - override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { - val status = statuses.getOrNull(position)?.asRightOrNull() ?: return - super.viewMedia(attachmentIndex, status, view) - } - - override fun onViewThread(position: Int) { - super.viewThread(statuses[position].asRight()) - } - - override fun onViewTag(tag: String) { - if (kind == Kind.TAG && tags.size == 1 && tags.contains(tag)) { - // If already viewing a tag page, then ignore any request to view that tag again. - return - } - super.viewTag(tag) - } - - override fun onViewAccount(id: String) { - if ((kind == Kind.USER || kind == Kind.USER_WITH_REPLIES) && this.id == id) { - /* If already viewing an account page, then any requests to view that account page - * should be ignored. */ - return - } - super.viewAccount(id) - } - - private fun onPreferenceChanged(key: String) { - val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) - when (key) { - PrefKeys.FAB_HIDE -> { - hideFab = sharedPreferences.getBoolean(PrefKeys.FAB_HIDE, false) - } - PrefKeys.MEDIA_PREVIEW_ENABLED -> { - val enabled = accountManager.activeAccount!!.mediaPreviewEnabled - val oldMediaPreviewEnabled = adapter.mediaPreviewEnabled - if (enabled != oldMediaPreviewEnabled) { - adapter.mediaPreviewEnabled = enabled - fullyRefresh() - } - } - PrefKeys.TAB_FILTER_HOME_REPLIES -> { - val filter = sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_REPLIES, true) - val oldRemoveReplies = filterRemoveReplies - filterRemoveReplies = kind == Kind.HOME && !filter - if (adapter.itemCount > 1 && oldRemoveReplies != filterRemoveReplies) { - fullyRefresh() - } - } - PrefKeys.TAB_FILTER_HOME_BOOSTS -> { - val filter = sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_BOOSTS, true) - val oldRemoveReblogs = filterRemoveReblogs - filterRemoveReblogs = kind == Kind.HOME && !filter - if (adapter.itemCount > 1 && oldRemoveReblogs != filterRemoveReblogs) { - fullyRefresh() - } - } - Filter.HOME, Filter.NOTIFICATIONS, Filter.THREAD, Filter.PUBLIC, Filter.ACCOUNT -> { - if (filterContextMatchesKind(kind, listOf(key))) { - reloadFilters(true) - } - } - PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA -> { - //it is ok if only newly loaded statuses are affected, no need to fully refresh - alwaysShowSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia - } - PrefKeys.LIMITED_BANDWIDTH_ACTIVE, - PrefKeys.LIMITED_BANDWIDTH_TIMELINE_LOADING, - PrefKeys.LIMITED_BANDWIDTH_ONLY_MOBILE_NETWORK -> { - updateLimitedBandwidthStatus(sharedPreferences) - } - } - } - - private fun updateLimitedBandwidthStatus(sharedPreferences: SharedPreferences) { - reduceTimelineLoading = sharedPreferences.getBoolean(PrefKeys.LIMITED_BANDWIDTH_ACTIVE, false) - && sharedPreferences.getBoolean(PrefKeys.LIMITED_BANDWIDTH_TIMELINE_LOADING, true) - checkMobileNetwork = sharedPreferences.getBoolean(PrefKeys.LIMITED_BANDWIDTH_ONLY_MOBILE_NETWORK, true) - } - - public override fun removeItem(position: Int) { - statuses.removeAt(position) - updateAdapter() - } - - private fun removeAllByAccountId(accountId: String) { - // using iterator to safely remove items while iterating - val iterator = statuses.iterator() - while (iterator.hasNext()) { - val status = iterator.next().asRightOrNull() - if (status != null && - (status.account.id == accountId || status.actionableStatus.account.id == accountId)) { - iterator.remove() - } - } - updateAdapter() - } - - private fun removeAllByInstance(instance: String) { - // using iterator to safely remove items while iterating - val iterator = statuses.iterator() - while (iterator.hasNext()) { - val status = iterator.next().asRightOrNull() - if (status != null && LinkHelper.getDomain(status.account.url) == instance) { - iterator.remove() - } - } - updateAdapter() - } - - private fun onLoadMore() { - if (didLoadEverythingBottom || bottomLoading) { - return - } - if (statuses.isEmpty()) { - sendInitialRequest() - return - } - bottomLoading = true - val last = statuses[statuses.size - 1] - val placeholder: Placeholder - if (last!!.isRight()) { - val placeholderId = last.asRight().id.dec() - placeholder = Placeholder(placeholderId) - statuses.add(Left(placeholder)) - } else { - placeholder = last.asLeft() - } - statuses.setPairedItem(statuses.size - 1, - StatusViewData.Placeholder(placeholder.id, true)) - updateAdapter() - - val bottomId: String? = if (kind == Kind.FAVOURITES || kind == Kind.BOOKMARKS) { - nextId - } else { - statuses.lastOrNull { it.isRight() }?.asRight()?.id - } - - sendFetchTimelineRequest(bottomId, null, null, FetchEnd.BOTTOM, -1) - } - - private fun fullyRefresh() { - statuses.clear() - updateAdapter() - bottomLoading = true - sendFetchTimelineRequest(null, null, null, FetchEnd.BOTTOM, -1) - } - - private fun actionButtonPresent(): Boolean { - return kind != Kind.TAG && kind != Kind.FAVOURITES && kind != Kind.BOOKMARKS && - activity is ActionButtonActivity - } - - private fun getFetchCallByTimelineType(fromId: String?, uptoId: String?): Single>> { - val api = mastodonApi - return when (kind) { - Kind.HOME -> api.homeTimeline(fromId, uptoId, LOAD_AT_ONCE) - Kind.PUBLIC_FEDERATED -> api.publicTimeline(null, fromId, uptoId, LOAD_AT_ONCE) - Kind.PUBLIC_LOCAL -> api.publicTimeline(true, fromId, uptoId, LOAD_AT_ONCE) - Kind.TAG -> { - val firstHashtag = tags[0] - val additionalHashtags = tags.subList(1, tags.size) - api.hashtagTimeline(firstHashtag, additionalHashtags, null, fromId, uptoId, LOAD_AT_ONCE) - } - Kind.USER -> api.accountStatuses(id!!, fromId, uptoId, LOAD_AT_ONCE, true, null, null) - Kind.USER_PINNED -> api.accountStatuses(id!!, fromId, uptoId, LOAD_AT_ONCE, null, null, true) - Kind.USER_WITH_REPLIES -> api.accountStatuses(id!!, fromId, uptoId, LOAD_AT_ONCE, null, null, null) - Kind.FAVOURITES -> api.favourites(fromId, uptoId, LOAD_AT_ONCE) - Kind.BOOKMARKS -> api.bookmarks(fromId, uptoId, LOAD_AT_ONCE) - Kind.LIST -> api.listTimeline(id!!, fromId, uptoId, LOAD_AT_ONCE) - else -> api.homeTimeline(fromId, uptoId, LOAD_AT_ONCE) - } - } - - private fun sendFetchTimelineRequest(maxId: String?, sinceId: String?, - sinceIdMinusOne: String?, - fetchEnd: FetchEnd, pos: Int) { - if (isAdded && (fetchEnd == FetchEnd.TOP || fetchEnd == FetchEnd.BOTTOM && maxId == null && binding.progressBar.visibility != View.VISIBLE) && !isSwipeToRefreshEnabled) { - binding.topProgressBar.show() - } - if (kind == Kind.HOME) { - // allow getting old statuses/fallbacks for network only for for bottom loading - val mode = if (fetchEnd == FetchEnd.BOTTOM) { - TimelineRequestMode.ANY - } else { - TimelineRequestMode.NETWORK - } - timelineRepo.getStatuses(maxId, sinceId, sinceIdMinusOne, LOAD_AT_ONCE, mode) - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(from(this)) - .subscribe( - { result: List> -> onFetchTimelineSuccess(result.toMutableList(), fetchEnd, pos) }, - { t: Throwable -> onFetchTimelineFailure(t, fetchEnd, pos) } - ) - } else { - getFetchCallByTimelineType(maxId, sinceId) - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(from(this)) - .subscribe( - { response: Response> -> - if (response.isSuccessful) { - val newNextId = extractNextId(response) - if (newNextId != null) { - // when we reach the bottom of the list, we won't have a new link. If - // we blindly write `null` here we will start loading from the top - // again. - nextId = newNextId - } - onFetchTimelineSuccess(liftStatusList(response.body()!!).toMutableList(), fetchEnd, pos) - } else { - onFetchTimelineFailure(Exception(response.message()), fetchEnd, pos) - } - } - ) { t: Throwable -> onFetchTimelineFailure(t, fetchEnd, pos) } - } - } - - private fun extractNextId(response: Response<*>): String? { - val linkHeader = response.headers()["Link"] ?: return null - val links = HttpHeaderLink.parse(linkHeader) - val nextHeader = HttpHeaderLink.findByRelationType(links, "next") ?: return null - val nextLink = nextHeader.uri ?: return null - return nextLink.getQueryParameter("max_id") - } - - private fun onFetchTimelineSuccess(statuses: MutableList>, - fetchEnd: FetchEnd, pos: Int) { - - // We filled the hole (or reached the end) if the server returned less statuses than we - // we asked for. - val fullFetch = statuses.size >= LOAD_AT_ONCE - filterStatuses(statuses) - when (fetchEnd) { - FetchEnd.TOP -> { - updateStatuses(statuses, fullFetch) - } - FetchEnd.MIDDLE -> { - replacePlaceholderWithStatuses(statuses, fullFetch, pos) - } - FetchEnd.BOTTOM -> { - if (!this.statuses.isEmpty() - && !this.statuses[this.statuses.size - 1].isRight()) { - this.statuses.removeAt(this.statuses.size - 1) - updateAdapter() - } - if (statuses.isNotEmpty() && !statuses[statuses.size - 1].isRight()) { - // Removing placeholder if it's the last one from the cache - statuses.removeAt(statuses.size - 1) - } - val oldSize = this.statuses.size - if (this.statuses.size > 1) { - addItems(statuses) - } else { - updateStatuses(statuses, fullFetch) - } - if (this.statuses.size == oldSize) { - // This may be a brittle check but seems like it works - // Can we check it using headers somehow? Do all server support them? - didLoadEverythingBottom = true - } - } - } - if (isAdded) { - binding.topProgressBar.hide() - updateBottomLoadingState(fetchEnd) - binding.progressBar.hide() - binding.swipeRefreshLayout.isRefreshing = false - binding.swipeRefreshLayout.isEnabled = true - if (this.statuses.size == 0) { - showEmptyView() - } else { - binding.statusView.hide() - } - } - } - - private fun onFetchTimelineFailure(throwable: Throwable, fetchEnd: FetchEnd, position: Int) { - if (isAdded) { - binding.swipeRefreshLayout.isRefreshing = false - binding.topProgressBar.hide() - if (fetchEnd == FetchEnd.MIDDLE && !statuses[position].isRight()) { - var placeholder = statuses[position].asLeftOrNull() - val newViewData: StatusViewData - if (placeholder == null) { - val (id1) = statuses[position - 1].asRight() - val newId = id1.dec() - placeholder = Placeholder(newId) - } - newViewData = StatusViewData.Placeholder(placeholder.id, false) - statuses.setPairedItem(position, newViewData) - updateAdapter() - } else if (statuses.isEmpty()) { - binding.swipeRefreshLayout.isEnabled = false - binding.statusView.visibility = View.VISIBLE - if (throwable is IOException) { - binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) { - binding.progressBar.visibility = View.VISIBLE - onRefresh() - } - } else { - binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic) { - binding.progressBar.visibility = View.VISIBLE - onRefresh() - } - } - } - Log.e(TAG, "Fetch Failure: " + throwable.message) - updateBottomLoadingState(fetchEnd) - binding.progressBar.hide() - } - } - - private fun updateBottomLoadingState(fetchEnd: FetchEnd) { - if (fetchEnd == FetchEnd.BOTTOM) { - bottomLoading = false - } - } - - private fun filterStatuses(statuses: MutableList>) { - val it = statuses.iterator() - while (it.hasNext()) { - val status = it.next().asRightOrNull() - if (status != null - && (status.inReplyToId != null && filterRemoveReplies - || status.reblog != null && filterRemoveReblogs - || shouldFilterStatus(status.actionableStatus))) { - it.remove() - } - } - } - - private fun updateStatuses(newStatuses: MutableList>, fullFetch: Boolean) { - if (newStatuses.isEmpty()) { - updateAdapter() - return - } - if (statuses.isEmpty()) { - statuses.addAll(newStatuses) - } else { - val lastOfNew = newStatuses[newStatuses.size - 1] - val index = statuses.indexOf(lastOfNew) - if (index >= 0) { - statuses.subList(0, index).clear() - } - val newIndex = newStatuses.indexOf(statuses[0]) - if (newIndex == -1) { - if (index == -1 && fullFetch) { - val placeholderId = newStatuses.last { status -> status.isRight() }.asRight().id.inc() - newStatuses.add(Left(Placeholder(placeholderId))) - } - statuses.addAll(0, newStatuses) - } else { - statuses.addAll(0, newStatuses.subList(0, newIndex)) - } - } - // Remove all consecutive placeholders - removeConsecutivePlaceholders() - updateAdapter() - } - - private fun removeConsecutivePlaceholders() { - for (i in 0 until statuses.size - 1) { - if (statuses[i].isLeft() && statuses[i + 1].isLeft()) { - statuses.removeAt(i) - } - } - } - - private fun addItems(newStatuses: List?>) { - if (newStatuses.isEmpty()) { - return - } - val last = statuses.last { status -> - status.isRight() - } - - // I was about to replace findStatus with indexOf but it is incorrect to compare value - // types by ID anyway and we should change equals() for Status, I think, so this makes sense - if (last != null && !newStatuses.contains(last)) { - statuses.addAll(newStatuses) - removeConsecutivePlaceholders() - updateAdapter() - } - } - - private fun addStatus(item: Either) { - if (item.isRight()) { - val status = item.asRight() - if (!(status.inReplyToId != null && filterRemoveReplies - || status.reblog != null && filterRemoveReblogs - || shouldFilterStatus(status))) { - if (findStatusOrReblogPositionById(status.id) < 0) { - statuses.add(0, item) - updateAdapter() - if (kind == Kind.HOME) { - timelineRepo.addSingleStatusToDb(status) - } - } - } - } else { - statuses.add(0, item) - updateAdapter() - } - } - - /** - * For certain requests we don't want to see placeholders, they will be removed some other way - */ - private fun clearPlaceholdersForResponse(statuses: MutableList>) { - statuses.removeAll { status -> status.isLeft() } - } - - private fun replacePlaceholderWithStatuses(newStatuses: MutableList>, - fullFetch: Boolean, pos: Int) { - val placeholder = statuses[pos] - if (placeholder.isLeft()) { - statuses.removeAt(pos) - } - if (newStatuses.isEmpty()) { - updateAdapter() - return - } - if (fullFetch) { - newStatuses.add(placeholder) - } - statuses.addAll(pos, newStatuses) - removeConsecutivePlaceholders() - updateAdapter() - } - - private fun findStatusOrReblogPositionById(statusId: String): Int { - return statuses.indexOfFirst { either -> - val status = either.asRightOrNull() - status != null && - (statusId == status.id || - (status.reblog != null && statusId == status.reblog.id)) - } - } - - private val statusLifter: Function1> = { value -> Right(value) } - - private fun findStatusAndPosition(position: Int, status: Status): Pair? { - val statusToUpdate: StatusViewData.Concrete - val positionToUpdate: Int - val someOldViewData = statuses.getPairedItem(position) - - // Unlikely, but data could change between the request and response - if (someOldViewData is StatusViewData.Placeholder || - (someOldViewData as StatusViewData.Concrete).id != status.id) { - // try to find the status we need to update - val foundPos = statuses.indexOf(Right(status)) - if (foundPos < 0) return null // okay, it's hopeless, give up - statusToUpdate = statuses.getPairedItem(foundPos) as StatusViewData.Concrete - positionToUpdate = position - } else { - statusToUpdate = someOldViewData - positionToUpdate = position - } - return Pair(statusToUpdate, positionToUpdate) - } - - private fun handleReblogEvent(reblogEvent: ReblogEvent) { - val pos = findStatusOrReblogPositionById(reblogEvent.statusId) - if (pos < 0) return - val status = statuses[pos].asRight() - setRebloggedForStatus(pos, status, reblogEvent.reblog) - } - - private fun handleFavEvent(favEvent: FavoriteEvent) { - val pos = findStatusOrReblogPositionById(favEvent.statusId) - if (pos < 0) return - val status = statuses[pos].asRight() - setFavouriteForStatus(pos, status, favEvent.favourite) - } - - private fun handleBookmarkEvent(bookmarkEvent: BookmarkEvent) { - val pos = findStatusOrReblogPositionById(bookmarkEvent.statusId) - if (pos < 0) return - val status = statuses[pos].asRight() - setBookmarkForStatus(pos, status, bookmarkEvent.bookmark) - } - - private fun handleStatusComposeEvent(status: Status) { - if (isStreamingEnabled) { - return - } - - var reload = when (kind) { - Kind.HOME, Kind.PUBLIC_FEDERATED, Kind.PUBLIC_LOCAL -> true - Kind.USER, Kind.USER_WITH_REPLIES -> status.account.id == id - Kind.TAG, Kind.FAVOURITES, Kind.LIST, Kind.BOOKMARKS, Kind.USER_PINNED -> false - else -> false - } - - if (!reload) { - return - } - - if (reduceTimelineLoading) { - reload = false - if (checkMobileNetwork - && activity?.let { - (activity?.getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager).isActiveNetworkMetered - } == false) { - reload = true - } - } - - if (reload) { - onRefresh() - } - } - - private fun handleStreamUpdateEvent(event: StreamUpdateEvent) { - if (event.targetKind != kind || event.targetIdentifier != null && event.targetIdentifier != id) { - return - } - - val status = event.status - if (event.first && statuses[0].isRight()) { - val placeholder = Placeholder(statuses[0].asRight().id + 1) - updateStatuses(mutableListOf(Right(status), Left(placeholder)), false) - } else { - addStatus(Right(status)) - } - } - - private fun liftStatusList(list: List): List> { - return list.map(statusLifter) - } - - private fun updateAdapter() { - differ.submitList(statuses.pairedCopy) - } - - private val listUpdateCallback: ListUpdateCallback = object : ListUpdateCallback { - override fun onInserted(position: Int, count: Int) { - if (isAdded) { - adapter.notifyItemRangeInserted(position, count) - val context = context - // scroll up when new items at the top are loaded while being in the first position - // https://github.com/tuskyapp/Tusky/pull/1905#issuecomment-677819724 - if (position == 0 && context != null && layoutManager?.findFirstVisibleItemPosition() == 0 && adapter.itemCount != count) { - when { - count == 1 -> { - layoutManager?.scrollToPosition(0) - binding.recyclerView.stopScroll() - scrollListener?.reset() - } - isSwipeToRefreshEnabled -> { - binding.recyclerView.scrollBy(0, Utils.dpToPx(context, -30)) - } - else -> binding.recyclerView.scrollToPosition(0) - } - } - } - } - - override fun onRemoved(position: Int, count: Int) { - adapter.notifyItemRangeRemoved(position, count) - } - - override fun onMoved(fromPosition: Int, toPosition: Int) { - adapter.notifyItemMoved(fromPosition, toPosition) - } - - override fun onChanged(position: Int, count: Int, payload: Any?) { - adapter.notifyItemRangeChanged(position, count, payload) - } - } - private val differ = AsyncListDiffer(listUpdateCallback, - AsyncDifferConfig.Builder(diffCallback).build()) - - private val dataSource: TimelineAdapter.AdapterDataSource = object : TimelineAdapter.AdapterDataSource { - override fun getItemCount(): Int { - return differ.currentList.size - } - - override fun getItemAt(pos: Int): StatusViewData { - return differ.currentList[pos] - } - } - - private var talkBackWasEnabled = false - - override fun onResume() { - super.onResume() - val a11yManager = ContextCompat.getSystemService(requireContext(), AccessibilityManager::class.java) - - val wasEnabled = talkBackWasEnabled - talkBackWasEnabled = a11yManager?.isEnabled == true - Log.d(TAG, "talkback was enabled: $wasEnabled, now $talkBackWasEnabled") - if (talkBackWasEnabled && !wasEnabled) { - adapter.notifyDataSetChanged() - } - startUpdateTimestamp() - } - - /** - * Start to update adapter every minute to refresh timestamp - * If setting absoluteTimeView is false - * Auto dispose observable on pause - */ - private fun startUpdateTimestamp() { - val preferences = PreferenceManager.getDefaultSharedPreferences(activity) - val useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false) - if (!useAbsoluteTime) { - Observable.interval(1, TimeUnit.MINUTES) - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(from(this, Lifecycle.Event.ON_PAUSE)) - .subscribe { updateAdapter() } - } - } - - override fun onReselect() { - if (isAdded) { - layoutManager!!.scrollToPosition(0) - binding.recyclerView.stopScroll() - scrollListener!!.reset() - } - } - - override fun onReset() { - fullyRefresh() - } - - override fun refreshContent() { - if (isAdded) { - onRefresh() - } else { - isNeedRefresh = true - } - } - - enum class Kind { - HOME, PUBLIC_LOCAL, PUBLIC_FEDERATED, TAG, USER, USER_PINNED, USER_WITH_REPLIES, FAVOURITES, LIST, BOOKMARKS - } - - private enum class FetchEnd { - TOP, BOTTOM, MIDDLE - } - - companion object { - private const val TAG = "TimelineF" // logging tag - private const val KIND_ARG = "kind" - private const val ID_ARG = "id" - private const val HASHTAGS_ARG = "hashtags" - private const val ARG_ENABLE_SWIPE_TO_REFRESH = "enableSwipeToRefresh" - private const val ARG_ENABLE_STREAMING = "enableStreaming" - private const val LOAD_AT_ONCE = 30 - - fun newInstance(kind: Kind, hashtagOrId: String? = null, enableSwipeToRefresh: Boolean = true, enableStreaming: Boolean = false): TimelineFragment { - val fragment = TimelineFragment() - val arguments = Bundle(3) - arguments.putString(KIND_ARG, kind.name) - arguments.putString(ID_ARG, hashtagOrId) - arguments.putBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, enableSwipeToRefresh) - arguments.putBoolean(ARG_ENABLE_STREAMING, enableStreaming) - fragment.arguments = arguments - return fragment - } - - @JvmStatic - fun newHashtagInstance(hashtags: List): TimelineFragment { - val fragment = TimelineFragment() - val arguments = Bundle(3) - arguments.putString(KIND_ARG, Kind.TAG.name) - arguments.putStringArrayList(HASHTAGS_ARG, ArrayList(hashtags)) - arguments.putBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true) - fragment.arguments = arguments - return fragment - } - - private fun filterContextMatchesKind(kind: Kind?, filterContext: List): Boolean { - // home, notifications, public, thread - return when (kind) { - Kind.HOME, Kind.LIST -> filterContext.contains(Filter.HOME) - Kind.PUBLIC_FEDERATED, Kind.PUBLIC_LOCAL, Kind.TAG -> filterContext.contains(Filter.PUBLIC) - Kind.FAVOURITES -> filterContext.contains(Filter.PUBLIC) || filterContext.contains(Filter.NOTIFICATIONS) - Kind.USER, Kind.USER_WITH_REPLIES, Kind.USER_PINNED -> filterContext.contains(Filter.ACCOUNT) - else -> false - } - } - - private val diffCallback: DiffUtil.ItemCallback = object : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: StatusViewData, newItem: StatusViewData): Boolean { - return oldItem.viewDataId == newItem.viewDataId - } - - override fun areContentsTheSame(oldItem: StatusViewData, newItem: StatusViewData): Boolean { - return false // Items are different always. It allows to refresh timestamp on every view holder update - } - - override fun getChangePayload(oldItem: StatusViewData, newItem: StatusViewData): Any? { - return if (oldItem.deepEquals(newItem)) { - // If items are equal - update timestamp only - listOf(StatusBaseViewHolder.Key.KEY_CREATED) - } else // If items are different - update the whole view holder - null - } - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewImageFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewImageFragment.kt index c68cfb5f5..0362da9c7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewImageFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewImageFragment.kt @@ -37,7 +37,7 @@ import com.keylesspalace.tusky.databinding.FragmentViewImageBinding import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.visible -import io.reactivex.subjects.BehaviorSubject +import io.reactivex.rxjava3.subjects.BehaviorSubject import kotlin.math.abs class ViewImageFragment : ViewMediaFragment() { @@ -66,12 +66,11 @@ class ViewImageFragment : ViewMediaFragment() { photoActionsListener = context as PhotoActionsListener } - override fun setupMediaView( - url: String, - previewUrl: String?, - description: String?, - showingDescription: Boolean + url: String, + previewUrl: String?, + description: String?, + showingDescription: Boolean ) { binding.photoView.transitionName = url binding.mediaDescription.text = description @@ -136,9 +135,9 @@ class ViewImageFragment : ViewMediaFragment() { if (event.action == MotionEvent.ACTION_DOWN) { lastY = event.rawY - } else if (event.pointerCount == 1 - && attacher.scale == 1f - && event.action == MotionEvent.ACTION_MOVE + } else if (event.pointerCount == 1 && + attacher.scale == 1f && + event.action == MotionEvent.ACTION_MOVE ) { val diff = event.rawY - lastY // This code is to prevent transformations during page scrolling @@ -176,21 +175,21 @@ class ViewImageFragment : ViewMediaFragment() { } override fun onToolbarVisibilityChange(visible: Boolean) { - if (_binding == null || !userVisibleHint ) { + if (_binding == null || !userVisibleHint) { return } isDescriptionVisible = showingDescription && visible val alpha = if (isDescriptionVisible) 1.0f else 0.0f binding.captionSheet.animate().alpha(alpha) - .setListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator) { - if (_binding != null) { - binding.captionSheet.visible(isDescriptionVisible) - } - animation.removeListener(this) + .setListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + if (_binding != null) { + binding.captionSheet.visible(isDescriptionVisible) } - }) - .start() + animation.removeListener(this) + } + }) + .start() } override fun onDestroyView() { @@ -204,27 +203,30 @@ class ViewImageFragment : ViewMediaFragment() { val glide = Glide.with(this) // Request image from the any cache glide - .load(url) - .dontAnimate() - .onlyRetrieveFromCache(true) - .let { - if (previewUrl != null) - it.thumbnail(glide - .load(previewUrl) - .dontAnimate() - .onlyRetrieveFromCache(true) - .centerInside() - .addListener(ImageRequestListener(true, isThumnailRequest = true))) - else it - } - //Request image from the network on fail load image from cache - .error(glide.load(url) - .centerInside() - .addListener(ImageRequestListener(false, isThumnailRequest = false)) - ) - .centerInside() - .addListener(ImageRequestListener(true, isThumnailRequest = false)) - .into(photoView) + .load(url) + .dontAnimate() + .onlyRetrieveFromCache(true) + .let { + if (previewUrl != null) + it.thumbnail( + glide + .load(previewUrl) + .dontAnimate() + .onlyRetrieveFromCache(true) + .centerInside() + .addListener(ImageRequestListener(true, isThumnailRequest = true)) + ) + else it + } + // Request image from the network on fail load image from cache + .error( + glide.load(url) + .centerInside() + .addListener(ImageRequestListener(false, isThumnailRequest = false)) + ) + .centerInside() + .addListener(ImageRequestListener(true, isThumnailRequest = false)) + .into(photoView) } /** @@ -248,14 +250,20 @@ class ViewImageFragment : ViewMediaFragment() { * @param isCacheRequest - is this listener for request image from cache or from the network */ private inner class ImageRequestListener( - private val isCacheRequest: Boolean, - private val isThumnailRequest: Boolean) : RequestListener { + private val isCacheRequest: Boolean, + private val isThumnailRequest: Boolean + ) : RequestListener { - override fun onLoadFailed(e: GlideException?, model: Any, target: Target, - isFirstResource: Boolean): Boolean { + override fun onLoadFailed( + e: GlideException?, + model: Any, + target: Target, + isFirstResource: Boolean + ): Boolean { // If cache for full image failed complete transition - if (isCacheRequest && !isThumnailRequest && shouldStartTransition - && !startedTransition) { + if (isCacheRequest && !isThumnailRequest && shouldStartTransition && + !startedTransition + ) { photoActionsListener.onBringUp() } // Hide progress bar only on fail request from internet @@ -265,8 +273,13 @@ class ViewImageFragment : ViewMediaFragment() { } @SuppressLint("CheckResult") - override fun onResourceReady(resource: Drawable, model: Any, target: Target, - dataSource: DataSource, isFirstResource: Boolean): Boolean { + override fun onResourceReady( + resource: Drawable, + model: Any, + target: Target, + dataSource: DataSource, + isFirstResource: Boolean + ): Boolean { if (_binding != null) { binding.progressBar.hide() // Always hide the progress bar on success } @@ -284,14 +297,14 @@ class ViewImageFragment : ViewMediaFragment() { // This wait for transition. If there's no transition then we should hit // another branch. take() will unsubscribe after we have it to not leak menmory transition - .take(1) - .subscribe { - target.onResourceReady(resource, null) - // It's needed. Don't ask why, I don't know, setImageDrawable() should - // do it by itself but somehow it doesn't work automatically. - // Just do it. If you don't, image will jump around when touched. - attacher.update() - } + .take(1) + .subscribe { + target.onResourceReady(resource, null) + // It's needed. Don't ask why, I don't know, setImageDrawable() should + // do it by itself but somehow it doesn't work automatically. + // Just do it. If you don't, image will jump around when touched. + attacher.update() + } } return true } diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewMediaFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewMediaFragment.kt index b25fec26f..89c65e10a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewMediaFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewMediaFragment.kt @@ -25,10 +25,10 @@ abstract class ViewMediaFragment : Fragment() { private var toolbarVisibiltyDisposable: Function0? = null abstract fun setupMediaView( - url: String, - previewUrl: String?, - description: String?, - showingDescription: Boolean + url: String, + previewUrl: String?, + description: String?, + showingDescription: Boolean ) abstract fun onToolbarVisibilityChange(visible: Boolean) @@ -56,7 +56,7 @@ abstract class ViewMediaFragment : Fragment() { Attachment.Type.VIDEO, Attachment.Type.GIFV, Attachment.Type.AUDIO -> ViewVideoFragment() - else -> ViewImageFragment() // it probably won't show anything, but its better than crashing + else -> ViewImageFragment() // it probably won't show anything, but its better than crashing } fragment.arguments = arguments return fragment @@ -84,9 +84,9 @@ abstract class ViewMediaFragment : Fragment() { setupMediaView(url, previewUrl, description, showingDescription && mediaActivity.isToolbarVisible) toolbarVisibiltyDisposable = (activity as ViewMediaActivity) - .addToolbarVisibilityListener { isVisible -> - onToolbarVisibilityChange(isVisible) - } + .addToolbarVisibilityListener { isVisible -> + onToolbarVisibilityChange(isVisible) + } } override fun onDestroyView() { diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java index 35931009c..400cb8c23 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java @@ -28,7 +28,6 @@ import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.arch.core.util.Function; -import androidx.core.util.Pair; import androidx.lifecycle.Lifecycle; import androidx.preference.PreferenceManager; import androidx.recyclerview.widget.DividerItemDecoration; @@ -48,6 +47,7 @@ import com.keylesspalace.tusky.appstore.BlockEvent; import com.keylesspalace.tusky.appstore.BookmarkEvent; import com.keylesspalace.tusky.appstore.EventHub; import com.keylesspalace.tusky.appstore.FavoriteEvent; +import com.keylesspalace.tusky.appstore.PinEvent; import com.keylesspalace.tusky.appstore.ReblogEvent; import com.keylesspalace.tusky.appstore.StatusComposedEvent; import com.keylesspalace.tusky.appstore.StatusDeletedEvent; @@ -57,6 +57,7 @@ import com.keylesspalace.tusky.entity.Filter; import com.keylesspalace.tusky.entity.Poll; import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.interfaces.StatusActionListener; +import com.keylesspalace.tusky.network.FilterModel; import com.keylesspalace.tusky.network.MastodonApi; import com.keylesspalace.tusky.settings.PrefKeys; import com.keylesspalace.tusky.util.CardViewMode; @@ -65,6 +66,7 @@ import com.keylesspalace.tusky.util.PairedList; import com.keylesspalace.tusky.util.StatusDisplayOptions; import com.keylesspalace.tusky.util.ViewDataUtils; import com.keylesspalace.tusky.view.ConversationLineItemDecoration; +import com.keylesspalace.tusky.viewdata.AttachmentViewData; import com.keylesspalace.tusky.viewdata.StatusViewData; import java.util.ArrayList; @@ -75,10 +77,12 @@ import java.util.Locale; import javax.inject.Inject; -import io.reactivex.android.schedulers.AndroidSchedulers; +import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider; +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; +import kotlin.collections.CollectionsKt; -import static com.uber.autodispose.AutoDispose.autoDisposable; -import static com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from; +import static autodispose2.AutoDispose.autoDisposable; +import static autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from; public final class ViewThreadFragment extends SFragment implements SwipeRefreshLayout.OnRefreshListener, StatusActionListener, Injectable { @@ -88,6 +92,8 @@ public final class ViewThreadFragment extends SFragment implements public MastodonApi mastodonApi; @Inject public EventHub eventHub; + @Inject + public FilterModel filterModel; private SwipeRefreshLayout swipeRefreshLayout; private RecyclerView recyclerView; @@ -166,7 +172,7 @@ public final class ViewThreadFragment extends SFragment implements recyclerView.addItemDecoration(new ConversationLineItemDecoration(context)); alwaysShowSensitiveMedia = accountManager.getActiveAccount().getAlwaysShowSensitiveMedia(); alwaysOpenSpoiler = accountManager.getActiveAccount().getAlwaysOpenSpoiler(); - reloadFilters(false); + reloadFilters(); recyclerView.setAdapter(adapter); @@ -185,7 +191,7 @@ public final class ViewThreadFragment extends SFragment implements eventHub.getEvents() .observeOn(AndroidSchedulers.mainThread()) - .as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) + .to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) .subscribe(event -> { if (event instanceof FavoriteEvent) { handleFavEvent((FavoriteEvent) event); @@ -193,6 +199,8 @@ public final class ViewThreadFragment extends SFragment implements handleReblogEvent((ReblogEvent) event); } else if (event instanceof BookmarkEvent) { handleBookmarkEvent((BookmarkEvent) event); + } else if (event instanceof PinEvent) { + handlePinEvent(((PinEvent) event)); } else if (event instanceof BlockEvent) { removeAllByAccountId(((BlockEvent) event).getAccountId()); } else if (event instanceof StatusComposedEvent) { @@ -206,13 +214,8 @@ public final class ViewThreadFragment extends SFragment implements public void onRevealPressed() { boolean allExpanded = allExpanded(); for (int i = 0; i < statuses.size(); i++) { - StatusViewData.Concrete newViewData = - new StatusViewData.Concrete.Builder(statuses.getPairedItem(i)) - .setIsExpanded(!allExpanded) - .createStatusViewData(); - statuses.setPairedItem(i, newViewData); + updateViewData(i, statuses.getPairedItem(i).copyWithExpanded(!allExpanded)); } - adapter.setStatuses(statuses.getPairedCopy()); updateRevealIcon(); } @@ -242,11 +245,11 @@ public final class ViewThreadFragment extends SFragment implements public void onReblog(final boolean reblog, final int position) { final Status status = statuses.get(position); - timelineCases.reblog(statuses.get(position), reblog) + timelineCases.reblog(statuses.get(position).getId(), reblog) .observeOn(AndroidSchedulers.mainThread()) - .as(autoDisposable(from(this))) + .to(autoDisposable(from(this))) .subscribe( - (newStatus) -> updateStatus(position, newStatus), + this::replaceStatus, (t) -> Log.d(TAG, "Failed to reblog status: " + status.getId(), t) ); @@ -256,11 +259,11 @@ public final class ViewThreadFragment extends SFragment implements public void onFavourite(final boolean favourite, final int position) { final Status status = statuses.get(position); - timelineCases.favourite(statuses.get(position), favourite) + timelineCases.favourite(statuses.get(position).getId(), favourite) .observeOn(AndroidSchedulers.mainThread()) - .as(autoDisposable(from(this))) + .to(autoDisposable(from(this))) .subscribe( - (newStatus) -> updateStatus(position, newStatus), + this::replaceStatus, (t) -> Log.d(TAG, "Failed to favourite status: " + status.getId(), t) ); @@ -275,32 +278,29 @@ public final class ViewThreadFragment extends SFragment implements public void onBookmark(final boolean bookmark, final int position) { final Status status = statuses.get(position); - timelineCases.bookmark(statuses.get(position), bookmark) + timelineCases.bookmark(statuses.get(position).getId(), bookmark) .observeOn(AndroidSchedulers.mainThread()) - .as(autoDisposable(from(this))) + .to(autoDisposable(from(this))) .subscribe( - (newStatus) -> updateStatus(position, newStatus), + this::replaceStatus, (t) -> Log.d(TAG, "Failed to bookmark status: " + status.getId(), t) ); } - private void updateStatus(int position, Status status) { + private void replaceStatus(Status status) { + updateStatus(status.getId(), (__) -> status); + } + + private void updateStatus(String statusId, Function mapper) { + int position = indexOfStatus(statusId); + if (position >= 0 && position < statuses.size()) { - - Status actionableStatus = status.getActionableStatus(); - - StatusViewData.Concrete viewData = new StatusViewData.Builder(statuses.getPairedItem(position)) - .setReblogged(actionableStatus.getReblogged()) - .setReblogsCount(actionableStatus.getReblogsCount()) - .setFavourited(actionableStatus.getFavourited()) - .setBookmarked(actionableStatus.getBookmarked()) - .setFavouritesCount(actionableStatus.getFavouritesCount()) - .createStatusViewData(); - statuses.setPairedItem(position, viewData); - - adapter.setItem(position, viewData, true); - + Status oldStatus = statuses.get(position); + Status newStatus = mapper.apply(oldStatus); + StatusViewData.Concrete oldViewData = statuses.getPairedItem(position); + statuses.set(position, newStatus); + updateViewData(position, oldViewData.copyWithStatus(newStatus)); } } @@ -312,7 +312,7 @@ public final class ViewThreadFragment extends SFragment implements @Override public void onViewMedia(int position, int attachmentIndex, @NonNull View view) { Status status = statuses.get(position); - super.viewMedia(attachmentIndex, status, view); + super.viewMedia(attachmentIndex, AttachmentViewData.list(status), view); } @Override @@ -322,7 +322,7 @@ public final class ViewThreadFragment extends SFragment implements // If already viewing this thread, don't reopen it. return; } - super.viewThread(status); + super.viewThread(status.getActionableId(), status.getActionableStatus().getUrl()); } @Override @@ -333,21 +333,22 @@ public final class ViewThreadFragment extends SFragment implements @Override public void onExpandedChange(boolean expanded, int position) { - StatusViewData.Concrete newViewData = - new StatusViewData.Builder(statuses.getPairedItem(position)) - .setIsExpanded(expanded) - .createStatusViewData(); - statuses.setPairedItem(position, newViewData); - adapter.setItem(position, newViewData, true); + updateViewData( + position, + statuses.getPairedItem(position).copyWithExpanded(expanded) + ); updateRevealIcon(); } @Override public void onContentHiddenChange(boolean isShowing, int position) { - StatusViewData.Concrete newViewData = - new StatusViewData.Builder(statuses.getPairedItem(position)) - .setIsShowingSensitiveContent(isShowing) - .createStatusViewData(); + updateViewData( + position, + statuses.getPairedItem(position).copyWithShowingContent(isShowing) + ); + } + + private void updateViewData(int position, StatusViewData.Concrete newViewData) { statuses.setPairedItem(position, newViewData); adapter.setItem(position, newViewData, true); } @@ -373,28 +374,11 @@ public final class ViewThreadFragment extends SFragment implements @Override public void onContentCollapsedChange(boolean isCollapsed, int position) { - if (position < 0 || position >= statuses.size()) { - Log.e(TAG, String.format("Tried to access out of bounds status position: %d of %d", position, statuses.size() - 1)); - return; - } - - StatusViewData.Concrete status = statuses.getPairedItem(position); - if (status == null) { - // Statuses PairedList contains a base type of StatusViewData.Concrete and also doesn't - // check for null values when adding values to it although this doesn't seem to be an issue. - Log.e(TAG, String.format( - "Expected StatusViewData.Concrete, got null instead at position: %d of %d", - position, - statuses.size() - 1 - )); - return; - } - - StatusViewData.Concrete updatedStatus = new StatusViewData.Builder(status) - .setCollapsed(isCollapsed) - .createStatusViewData(); - statuses.setPairedItem(position, updatedStatus); - recyclerView.post(() -> adapter.setItem(position, updatedStatus, true)); + adapter.setItem( + position, + statuses.getPairedItem(position).copyWIthCollapsed(isCollapsed), + true + ); } @Override @@ -420,28 +404,21 @@ public final class ViewThreadFragment extends SFragment implements public void onVoteInPoll(int position, @NonNull List choices) { final Status status = statuses.get(position).getActionableStatus(); - setVoteForPoll(position, status.getPoll().votedCopy(choices)); + setVoteForPoll(status.getId(), status.getPoll().votedCopy(choices)); - timelineCases.voteInPoll(status, choices) + timelineCases.voteInPoll(status.getId(), status.getPoll().getId(), choices) .observeOn(AndroidSchedulers.mainThread()) - .as(autoDisposable(from(this))) + .to(autoDisposable(from(this))) .subscribe( - (newPoll) -> setVoteForPoll(position, newPoll), + (newPoll) -> setVoteForPoll(status.getId(), newPoll), (t) -> Log.d(TAG, "Failed to vote in poll: " + status.getId(), t) ); } - private void setVoteForPoll(int position, Poll newPoll) { - - StatusViewData.Concrete viewData = statuses.getPairedItem(position); - - StatusViewData.Concrete newViewData = new StatusViewData.Builder(viewData) - .setPoll(newPoll) - .createStatusViewData(); - statuses.setPairedItem(position, newViewData); - adapter.setItem(position, newViewData, true); + private void setVoteForPoll(String statusId, Poll newPoll) { + updateStatus(statusId, s -> s.copyWithPoll(newPoll)); } private void removeAllByAccountId(String accountId) { @@ -470,7 +447,7 @@ public final class ViewThreadFragment extends SFragment implements private void sendStatusRequest(final String id) { mastodonApi.status(id) .observeOn(AndroidSchedulers.mainThread()) - .as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) + .to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) .subscribe( status -> { int position = setStatus(status); @@ -483,7 +460,7 @@ public final class ViewThreadFragment extends SFragment implements private void sendThreadRequest(final String id) { mastodonApi.statusContext(id) .observeOn(AndroidSchedulers.mainThread()) - .as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) + .to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) .subscribe( context -> { swipeRefreshLayout.setRefreshing(false); @@ -538,7 +515,7 @@ public final class ViewThreadFragment extends SFragment implements ArrayList ancestors = new ArrayList<>(); for (Status status : unfilteredAncestors) - if (!shouldFilterStatus(status)) + if (!filterModel.shouldFilterStatus(status)) ancestors.add(status); // Insert newly fetched ancestors @@ -568,7 +545,7 @@ public final class ViewThreadFragment extends SFragment implements ArrayList descendants = new ArrayList<>(); for (Status status : unfilteredDescendants) - if (!shouldFilterStatus(status)) + if (!filterModel.shouldFilterStatus(status)) descendants.add(status); // Insert newly fetched descendants @@ -589,71 +566,31 @@ public final class ViewThreadFragment extends SFragment implements } private void handleFavEvent(FavoriteEvent event) { - Pair posAndStatus = findStatusAndPos(event.getStatusId()); - if (posAndStatus == null) return; - - boolean favourite = event.getFavourite(); - posAndStatus.second.setFavourited(favourite); - - if (posAndStatus.second.getReblog() != null) { - posAndStatus.second.getReblog().setFavourited(favourite); - } - - StatusViewData.Concrete viewdata = statuses.getPairedItem(posAndStatus.first); - - StatusViewData.Builder viewDataBuilder = new StatusViewData.Builder((viewdata)); - viewDataBuilder.setFavourited(favourite); - - StatusViewData.Concrete newViewData = viewDataBuilder.createStatusViewData(); - - statuses.setPairedItem(posAndStatus.first, newViewData); - adapter.setItem(posAndStatus.first, newViewData, true); + updateStatus(event.getStatusId(), (s) -> { + s.setFavourited(event.getFavourite()); + return s; + }); } private void handleReblogEvent(ReblogEvent event) { - Pair posAndStatus = findStatusAndPos(event.getStatusId()); - if (posAndStatus == null) return; - - boolean reblog = event.getReblog(); - posAndStatus.second.setReblogged(reblog); - - if (posAndStatus.second.getReblog() != null) { - posAndStatus.second.getReblog().setReblogged(reblog); - } - - StatusViewData.Concrete viewdata = statuses.getPairedItem(posAndStatus.first); - - StatusViewData.Builder viewDataBuilder = new StatusViewData.Builder((viewdata)); - viewDataBuilder.setReblogged(reblog); - - StatusViewData.Concrete newViewData = viewDataBuilder.createStatusViewData(); - - statuses.setPairedItem(posAndStatus.first, newViewData); - adapter.setItem(posAndStatus.first, newViewData, true); + updateStatus(event.getStatusId(), (s) -> { + s.setReblogged(event.getReblog()); + return s; + }); } private void handleBookmarkEvent(BookmarkEvent event) { - Pair posAndStatus = findStatusAndPos(event.getStatusId()); - if (posAndStatus == null) return; - - boolean bookmark = event.getBookmark(); - posAndStatus.second.setBookmarked(bookmark); - - if (posAndStatus.second.getReblog() != null) { - posAndStatus.second.getReblog().setBookmarked(bookmark); - } - - StatusViewData.Concrete viewdata = statuses.getPairedItem(posAndStatus.first); - - StatusViewData.Builder viewDataBuilder = new StatusViewData.Builder((viewdata)); - viewDataBuilder.setBookmarked(bookmark); - - StatusViewData.Concrete newViewData = viewDataBuilder.createStatusViewData(); - - statuses.setPairedItem(posAndStatus.first, newViewData); - adapter.setItem(posAndStatus.first, newViewData, true); + updateStatus(event.getStatusId(), (s) -> { + s.setBookmarked(event.getBookmark()); + return s; + }); } + private void handlePinEvent(PinEvent event) { + updateStatus(event.getStatusId(), (s) -> s.copyWithPinned(event.getPinned())); + } + + private void handleStatusComposedEvent(StatusComposedEvent event) { Status eventStatus = event.getStatus(); if (eventStatus.getInReplyToId() == null) return; @@ -679,23 +616,16 @@ public final class ViewThreadFragment extends SFragment implements } private void handleStatusDeletedEvent(StatusDeletedEvent event) { - Pair posAndStatus = findStatusAndPos(event.getStatusId()); - if (posAndStatus == null) return; - - @SuppressWarnings("ConstantConditions") - int pos = posAndStatus.first; - statuses.remove(pos); - adapter.removeItem(pos); + int index = this.indexOfStatus(event.getStatusId()); + if (index != -1) { + statuses.remove(index); + adapter.removeItem(index); + } } - @Nullable - private Pair findStatusAndPos(@NonNull String statusId) { - for (int i = 0; i < statuses.size(); i++) { - if (statusId.equals(statuses.get(i).getId())) { - return new Pair<>(i, statuses.get(i)); - } - } - return null; + + private int indexOfStatus(String statusId) { + return CollectionsKt.indexOfFirst(this.statuses, (s) -> s.getId().equals(statusId)); } private void updateRevealIcon() { @@ -718,13 +648,25 @@ public final class ViewThreadFragment extends SFragment implements ViewThreadActivity.REVEAL_BUTTON_REVEAL); } - @Override - protected boolean filterIsRelevant(@NonNull Filter filter) { - return filter.getContext().contains(Filter.THREAD); + private void reloadFilters() { + mastodonApi.getFilters() + .to(autoDisposable(AndroidLifecycleScopeProvider.from(this))) + .subscribe( + (filters) -> { + List relevantFilters = CollectionsKt.filter( + filters, + (f) -> f.getContext().contains(Filter.THREAD) + ); + filterModel.initWithFilters(relevantFilters); + + recyclerView.post(this::applyFilters); + }, + (t) -> Log.e(TAG, "Failed to load filters", t) + ); } - @Override - protected void refreshAfterApplyingFilters() { - onRefresh(); + private void applyFilters() { + CollectionsKt.removeAll(this.statuses, filterModel::shouldFilterStatus); + adapter.setStatuses(this.statuses.getPairedCopy()); } } diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt index a0912837d..35b98ef71 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt @@ -48,7 +48,7 @@ class ViewVideoFragment : ViewMediaFragment() { } private lateinit var mediaActivity: ViewMediaActivity private val TOOLBAR_HIDE_DELAY_MS = 3000L - private lateinit var mediaController : MediaController + private lateinit var mediaController: MediaController private var isAudio = false override fun setUserVisibleHint(isVisibleToUser: Boolean) { @@ -72,10 +72,10 @@ class ViewVideoFragment : ViewMediaFragment() { @SuppressLint("ClickableViewAccessibility") override fun setupMediaView( - url: String, - previewUrl: String?, - description: String?, - showingDescription: Boolean + url: String, + previewUrl: String?, + description: String?, + showingDescription: Boolean ) { binding.mediaDescription.text = description binding.mediaDescription.visible(showingDescription) @@ -105,7 +105,7 @@ class ViewVideoFragment : ViewMediaFragment() { mediaController.setMediaPlayer(binding.videoView) binding.videoView.setMediaController(mediaController) binding.videoView.requestFocus() - binding.videoView.setPlayPauseListener(object: ExposedPlayPauseVideoView.PlayPauseListener { + binding.videoView.setPlayPauseListener(object : ExposedPlayPauseVideoView.PlayPauseListener { override fun onPause() { handler.removeCallbacks(hideToolbar) } @@ -125,7 +125,7 @@ class ViewVideoFragment : ViewMediaFragment() { val videoWidth = mp.videoWidth.toFloat() val videoHeight = mp.videoHeight.toFloat() - if(containerWidth/containerHeight > videoWidth/videoHeight) { + if (containerWidth / containerHeight > videoWidth / videoHeight) { binding.videoView.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT binding.videoView.layoutParams.width = ViewGroup.LayoutParams.WRAP_CONTENT } else { @@ -190,15 +190,15 @@ class ViewVideoFragment : ViewMediaFragment() { } binding.mediaDescription.animate().alpha(alpha) - .setListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator) { - if (_binding != null) { - binding.mediaDescription.visible(isDescriptionVisible) - } - animation.removeListener(this) + .setListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + if (_binding != null) { + binding.mediaDescription.visible(isDescriptionVisible) } - }) - .start() + animation.removeListener(this) + } + }) + .start() if (visible && binding.videoView.isPlaying && !isAudio) { hideToolbarAfterDelay(TOOLBAR_HIDE_DELAY_MS) diff --git a/app/src/main/java/com/keylesspalace/tusky/interfaces/AccountSelectionListener.kt b/app/src/main/java/com/keylesspalace/tusky/interfaces/AccountSelectionListener.kt index 04b1ebd2a..b86c55c76 100644 --- a/app/src/main/java/com/keylesspalace/tusky/interfaces/AccountSelectionListener.kt +++ b/app/src/main/java/com/keylesspalace/tusky/interfaces/AccountSelectionListener.kt @@ -19,4 +19,4 @@ import com.keylesspalace.tusky.db.AccountEntity interface AccountSelectionListener { fun onAccountSelected(account: AccountEntity) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/interfaces/RefreshableFragment.kt b/app/src/main/java/com/keylesspalace/tusky/interfaces/RefreshableFragment.kt index 5032774f4..83fc20c93 100644 --- a/app/src/main/java/com/keylesspalace/tusky/interfaces/RefreshableFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/interfaces/RefreshableFragment.kt @@ -8,4 +8,4 @@ interface RefreshableFragment { * Call this method to refresh fragment content */ fun refreshContent() -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/interfaces/ReselectableFragment.kt b/app/src/main/java/com/keylesspalace/tusky/interfaces/ReselectableFragment.kt index 88350b94e..598894f6b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/interfaces/ReselectableFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/interfaces/ReselectableFragment.kt @@ -8,5 +8,4 @@ interface ReselectableFragment { * Call this method when tab reselected */ fun onReselect() - fun onReset() -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/json/SpannedTypeAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/json/SpannedTypeAdapter.kt index b5238abc0..dadc9137a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/json/SpannedTypeAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/json/SpannedTypeAdapter.kt @@ -18,7 +18,13 @@ package com.keylesspalace.tusky.json import android.text.Spanned import androidx.core.text.HtmlCompat import androidx.core.text.parseAsHtml -import com.google.gson.* +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import com.google.gson.JsonParseException +import com.google.gson.JsonPrimitive +import com.google.gson.JsonSerializationContext +import com.google.gson.JsonSerializer import com.keylesspalace.tusky.util.trimTrailingWhitespace import org.jsoup.Jsoup import java.lang.reflect.Type diff --git a/app/src/main/java/com/keylesspalace/tusky/network/FilterModel.kt b/app/src/main/java/com/keylesspalace/tusky/network/FilterModel.kt new file mode 100644 index 000000000..d4c7464f1 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/network/FilterModel.kt @@ -0,0 +1,58 @@ +package com.keylesspalace.tusky.network + +import android.text.TextUtils +import com.keylesspalace.tusky.entity.Filter +import com.keylesspalace.tusky.entity.Status +import java.util.regex.Pattern +import javax.inject.Inject + +/** + * One-stop for status filtering logic using Mastodon's filters. + * + * 1. You init with [initWithFilters], this compiles regex pattern. + * 2. You call [shouldFilterStatus] to figure out what to display when you load statuses. + */ +class FilterModel @Inject constructor() { + private var pattern: Pattern? = null + + fun initWithFilters(filters: List) { + this.pattern = makeFilter(filters) + } + + fun shouldFilterStatus(status: Status): Boolean { + // Patterns are expensive and thread-safe, matchers are neither. + val matcher = pattern?.matcher("") ?: return false + + if (status.poll != null) { + val pollMatches = status.poll.options.any { matcher.reset(it.title).find() } + if (pollMatches) return true + } + + val spoilerText = status.actionableStatus.spoilerText + return ( + matcher.reset(status.actionableStatus.content).find() || + spoilerText.isNotEmpty() && matcher.reset(spoilerText).find() + ) + } + + private fun filterToRegexToken(filter: Filter): String? { + val phrase = filter.phrase + val quotedPhrase = Pattern.quote(phrase) + return if (filter.wholeWord && ALPHANUMERIC.matcher(phrase).matches()) { + String.format("(^|\\W)%s($|\\W)", quotedPhrase) + } else { + quotedPhrase + } + } + + private fun makeFilter(filters: List): Pattern? { + if (filters.isEmpty()) return null + val tokens = filters.map { filterToRegexToken(it) } + + return Pattern.compile(TextUtils.join("|", tokens), Pattern.CASE_INSENSITIVE) + } + + companion object { + private val ALPHANUMERIC = Pattern.compile("^\\w+$") + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt index 28ac77b6a..dc5fd8f38 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -15,16 +15,48 @@ package com.keylesspalace.tusky.network -import com.keylesspalace.tusky.entity.* -import io.reactivex.Completable -import io.reactivex.Single +import com.keylesspalace.tusky.entity.AccessToken +import com.keylesspalace.tusky.entity.Account +import com.keylesspalace.tusky.entity.Announcement +import com.keylesspalace.tusky.entity.AppCredentials +import com.keylesspalace.tusky.entity.Attachment +import com.keylesspalace.tusky.entity.Conversation +import com.keylesspalace.tusky.entity.DeletedStatus +import com.keylesspalace.tusky.entity.Emoji +import com.keylesspalace.tusky.entity.Filter +import com.keylesspalace.tusky.entity.IdentityProof +import com.keylesspalace.tusky.entity.Instance +import com.keylesspalace.tusky.entity.Marker +import com.keylesspalace.tusky.entity.MastoList +import com.keylesspalace.tusky.entity.NewStatus +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.entity.Poll +import com.keylesspalace.tusky.entity.Relationship +import com.keylesspalace.tusky.entity.ScheduledStatus +import com.keylesspalace.tusky.entity.SearchResult +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.entity.StatusContext +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.core.Single import okhttp3.MultipartBody import okhttp3.RequestBody import okhttp3.ResponseBody import retrofit2.Call import retrofit2.Response -import retrofit2.http.* +import retrofit2.http.Body +import retrofit2.http.DELETE import retrofit2.http.Field +import retrofit2.http.FormUrlEncoded +import retrofit2.http.GET +import retrofit2.http.HTTP +import retrofit2.http.Header +import retrofit2.http.Multipart +import retrofit2.http.PATCH +import retrofit2.http.POST +import retrofit2.http.PUT +import retrofit2.http.Part +import retrofit2.http.Path +import retrofit2.http.Query /** * for documentation of the Mastodon REST API see https://docs.joinmastodon.org/api/ @@ -49,61 +81,61 @@ interface MastodonApi { fun getInstance(): Single @GET("api/v1/filters") - fun getFilters(): Call> + fun getFilters(): Single> @GET("api/v1/timelines/home") fun homeTimeline( - @Query("max_id") maxId: String?, - @Query("since_id") sinceId: String?, - @Query("limit") limit: Int? + @Query("max_id") maxId: String?, + @Query("since_id") sinceId: String?, + @Query("limit") limit: Int? ): Single>> @GET("api/v1/timelines/public") fun publicTimeline( - @Query("local") local: Boolean?, - @Query("max_id") maxId: String?, - @Query("since_id") sinceId: String?, - @Query("limit") limit: Int? + @Query("local") local: Boolean?, + @Query("max_id") maxId: String?, + @Query("since_id") sinceId: String?, + @Query("limit") limit: Int? ): Single>> @GET("api/v1/timelines/tag/{hashtag}") fun hashtagTimeline( - @Path("hashtag") hashtag: String, - @Query("any[]") any: List?, - @Query("local") local: Boolean?, - @Query("max_id") maxId: String?, - @Query("since_id") sinceId: String?, - @Query("limit") limit: Int? + @Path("hashtag") hashtag: String, + @Query("any[]") any: List?, + @Query("local") local: Boolean?, + @Query("max_id") maxId: String?, + @Query("since_id") sinceId: String?, + @Query("limit") limit: Int? ): Single>> @GET("api/v1/timelines/list/{listId}") fun listTimeline( - @Path("listId") listId: String, - @Query("max_id") maxId: String?, - @Query("since_id") sinceId: String?, - @Query("limit") limit: Int? + @Path("listId") listId: String, + @Query("max_id") maxId: String?, + @Query("since_id") sinceId: String?, + @Query("limit") limit: Int? ): Single>> @GET("api/v1/notifications") fun notifications( - @Query("max_id") maxId: String?, - @Query("since_id") sinceId: String?, - @Query("limit") limit: Int?, - @Query("exclude_types[]") excludes: Set? + @Query("max_id") maxId: String?, + @Query("since_id") sinceId: String?, + @Query("limit") limit: Int?, + @Query("exclude_types[]") excludes: Set? ): Single>> @GET("api/v1/markers") fun markersWithAuth( - @Header("Authorization") auth: String, - @Header(DOMAIN_HEADER) domain: String, - @Query("timeline[]") timelines: List + @Header("Authorization") auth: String, + @Header(DOMAIN_HEADER) domain: String, + @Query("timeline[]") timelines: List ): Single> @GET("api/v1/notifications") fun notificationsWithAuth( - @Header("Authorization") auth: String, - @Header(DOMAIN_HEADER) domain: String, - @Query("since_id") sinceId: String? + @Header("Authorization") auth: String, + @Header(DOMAIN_HEADER) domain: String, + @Query("since_id") sinceId: String? ): Single> @POST("api/v1/notifications/clear") @@ -112,111 +144,111 @@ interface MastodonApi { @Multipart @POST("api/v1/media") fun uploadMedia( - @Part file: MultipartBody.Part, - @Part description: MultipartBody.Part? = null + @Part file: MultipartBody.Part, + @Part description: MultipartBody.Part? = null ): Single @FormUrlEncoded @PUT("api/v1/media/{mediaId}") fun updateMedia( - @Path("mediaId") mediaId: String, - @Field("description") description: String + @Path("mediaId") mediaId: String, + @Field("description") description: String ): Single @POST("api/v1/statuses") fun createStatus( - @Header("Authorization") auth: String, - @Header(DOMAIN_HEADER) domain: String, - @Header("Idempotency-Key") idempotencyKey: String, - @Body status: NewStatus + @Header("Authorization") auth: String, + @Header(DOMAIN_HEADER) domain: String, + @Header("Idempotency-Key") idempotencyKey: String, + @Body status: NewStatus ): Call @GET("api/v1/statuses/{id}") fun status( - @Path("id") statusId: String + @Path("id") statusId: String ): Single @GET("api/v1/statuses/{id}/context") fun statusContext( - @Path("id") statusId: String + @Path("id") statusId: String ): Single @GET("api/v1/statuses/{id}/reblogged_by") fun statusRebloggedBy( - @Path("id") statusId: String, - @Query("max_id") maxId: String? + @Path("id") statusId: String, + @Query("max_id") maxId: String? ): Single>> @GET("api/v1/statuses/{id}/favourited_by") fun statusFavouritedBy( - @Path("id") statusId: String, - @Query("max_id") maxId: String? + @Path("id") statusId: String, + @Query("max_id") maxId: String? ): Single>> @DELETE("api/v1/statuses/{id}") fun deleteStatus( - @Path("id") statusId: String + @Path("id") statusId: String ): Single @POST("api/v1/statuses/{id}/reblog") fun reblogStatus( - @Path("id") statusId: String + @Path("id") statusId: String ): Single @POST("api/v1/statuses/{id}/unreblog") fun unreblogStatus( - @Path("id") statusId: String + @Path("id") statusId: String ): Single @POST("api/v1/statuses/{id}/favourite") fun favouriteStatus( - @Path("id") statusId: String + @Path("id") statusId: String ): Single @POST("api/v1/statuses/{id}/unfavourite") fun unfavouriteStatus( - @Path("id") statusId: String + @Path("id") statusId: String ): Single @POST("api/v1/statuses/{id}/bookmark") fun bookmarkStatus( - @Path("id") statusId: String + @Path("id") statusId: String ): Single @POST("api/v1/statuses/{id}/unbookmark") fun unbookmarkStatus( - @Path("id") statusId: String + @Path("id") statusId: String ): Single @POST("api/v1/statuses/{id}/pin") fun pinStatus( - @Path("id") statusId: String + @Path("id") statusId: String ): Single @POST("api/v1/statuses/{id}/unpin") fun unpinStatus( - @Path("id") statusId: String + @Path("id") statusId: String ): Single @POST("api/v1/statuses/{id}/mute") fun muteConversation( - @Path("id") statusId: String + @Path("id") statusId: String ): Single @POST("api/v1/statuses/{id}/unmute") fun unmuteConversation( - @Path("id") statusId: String + @Path("id") statusId: String ): Single @GET("api/v1/scheduled_statuses") fun scheduledStatuses( - @Query("limit") limit: Int? = null, - @Query("max_id") maxId: String? = null + @Query("limit") limit: Int? = null, + @Query("max_id") maxId: String? = null ): Single> @DELETE("api/v1/scheduled_statuses/{id}") fun deleteScheduledStatus( - @Path("id") scheduledStatusId: String + @Path("id") scheduledStatusId: String ): Single @GET("api/v1/accounts/verify_credentials") @@ -225,39 +257,39 @@ interface MastodonApi { @FormUrlEncoded @PATCH("api/v1/accounts/update_credentials") fun accountUpdateSource( - @Field("source[privacy]") privacy: String?, - @Field("source[sensitive]") sensitive: Boolean? + @Field("source[privacy]") privacy: String?, + @Field("source[sensitive]") sensitive: Boolean? ): Call @Multipart @PATCH("api/v1/accounts/update_credentials") fun accountUpdateCredentials( - @Part(value = "display_name") displayName: RequestBody?, - @Part(value = "note") note: RequestBody?, - @Part(value = "locked") locked: RequestBody?, - @Part avatar: MultipartBody.Part?, - @Part header: MultipartBody.Part?, - @Part(value = "fields_attributes[0][name]") fieldName0: RequestBody?, - @Part(value = "fields_attributes[0][value]") fieldValue0: RequestBody?, - @Part(value = "fields_attributes[1][name]") fieldName1: RequestBody?, - @Part(value = "fields_attributes[1][value]") fieldValue1: RequestBody?, - @Part(value = "fields_attributes[2][name]") fieldName2: RequestBody?, - @Part(value = "fields_attributes[2][value]") fieldValue2: RequestBody?, - @Part(value = "fields_attributes[3][name]") fieldName3: RequestBody?, - @Part(value = "fields_attributes[3][value]") fieldValue3: RequestBody? + @Part(value = "display_name") displayName: RequestBody?, + @Part(value = "note") note: RequestBody?, + @Part(value = "locked") locked: RequestBody?, + @Part avatar: MultipartBody.Part?, + @Part header: MultipartBody.Part?, + @Part(value = "fields_attributes[0][name]") fieldName0: RequestBody?, + @Part(value = "fields_attributes[0][value]") fieldValue0: RequestBody?, + @Part(value = "fields_attributes[1][name]") fieldName1: RequestBody?, + @Part(value = "fields_attributes[1][value]") fieldValue1: RequestBody?, + @Part(value = "fields_attributes[2][name]") fieldName2: RequestBody?, + @Part(value = "fields_attributes[2][value]") fieldValue2: RequestBody?, + @Part(value = "fields_attributes[3][name]") fieldName3: RequestBody?, + @Part(value = "fields_attributes[3][value]") fieldValue3: RequestBody? ): Call @GET("api/v1/accounts/search") fun searchAccounts( - @Query("q") query: String, - @Query("resolve") resolve: Boolean? = null, - @Query("limit") limit: Int? = null, - @Query("following") following: Boolean? = null + @Query("q") query: String, + @Query("resolve") resolve: Boolean? = null, + @Query("limit") limit: Int? = null, + @Query("following") following: Boolean? = null ): Single> @GET("api/v1/accounts/{id}") fun account( - @Path("id") accountId: String + @Path("id") accountId: String ): Single /** @@ -271,71 +303,71 @@ interface MastodonApi { */ @GET("api/v1/accounts/{id}/statuses") fun accountStatuses( - @Path("id") accountId: String, - @Query("max_id") maxId: String?, - @Query("since_id") sinceId: String?, - @Query("limit") limit: Int?, - @Query("exclude_replies") excludeReplies: Boolean?, - @Query("only_media") onlyMedia: Boolean?, - @Query("pinned") pinned: Boolean? + @Path("id") accountId: String, + @Query("max_id") maxId: String?, + @Query("since_id") sinceId: String?, + @Query("limit") limit: Int?, + @Query("exclude_replies") excludeReplies: Boolean?, + @Query("only_media") onlyMedia: Boolean?, + @Query("pinned") pinned: Boolean? ): Single>> @GET("api/v1/accounts/{id}/followers") fun accountFollowers( - @Path("id") accountId: String, - @Query("max_id") maxId: String? + @Path("id") accountId: String, + @Query("max_id") maxId: String? ): Single>> @GET("api/v1/accounts/{id}/following") fun accountFollowing( - @Path("id") accountId: String, - @Query("max_id") maxId: String? + @Path("id") accountId: String, + @Query("max_id") maxId: String? ): Single>> @FormUrlEncoded @POST("api/v1/accounts/{id}/follow") fun followAccount( - @Path("id") accountId: String, - @Field("reblogs") showReblogs: Boolean? = null, - @Field("notify") notify: Boolean? = null + @Path("id") accountId: String, + @Field("reblogs") showReblogs: Boolean? = null, + @Field("notify") notify: Boolean? = null ): Single @POST("api/v1/accounts/{id}/unfollow") fun unfollowAccount( - @Path("id") accountId: String + @Path("id") accountId: String ): Single @POST("api/v1/accounts/{id}/block") fun blockAccount( - @Path("id") accountId: String + @Path("id") accountId: String ): Single @POST("api/v1/accounts/{id}/unblock") fun unblockAccount( - @Path("id") accountId: String + @Path("id") accountId: String ): Single @FormUrlEncoded @POST("api/v1/accounts/{id}/mute") fun muteAccount( - @Path("id") accountId: String, - @Field("notifications") notifications: Boolean? = null, - @Field("duration") duration: Int? = null + @Path("id") accountId: String, + @Field("notifications") notifications: Boolean? = null, + @Field("duration") duration: Int? = null ): Single @POST("api/v1/accounts/{id}/unmute") fun unmuteAccount( - @Path("id") accountId: String + @Path("id") accountId: String ): Single @GET("api/v1/accounts/relationships") fun relationships( - @Query("id[]") accountIds: List + @Query("id[]") accountIds: List ): Single> @GET("api/v1/accounts/{id}/identity_proofs") fun identityProofs( - @Path("id") accountId: String + @Path("id") accountId: String ): Single> @POST("api/v1/pleroma/accounts/{id}/subscribe") @@ -350,25 +382,25 @@ interface MastodonApi { @GET("api/v1/blocks") fun blocks( - @Query("max_id") maxId: String? + @Query("max_id") maxId: String? ): Single>> @GET("api/v1/mutes") fun mutes( - @Query("max_id") maxId: String? + @Query("max_id") maxId: String? ): Single>> @GET("api/v1/domain_blocks") fun domainBlocks( - @Query("max_id") maxId: String? = null, - @Query("since_id") sinceId: String? = null, - @Query("limit") limit: Int? = null + @Query("max_id") maxId: String? = null, + @Query("since_id") sinceId: String? = null, + @Query("limit") limit: Int? = null ): Single>> @FormUrlEncoded @POST("api/v1/domain_blocks") fun blockDomain( - @Field("domain") domain: String + @Field("domain") domain: String ): Call @FormUrlEncoded @@ -378,192 +410,197 @@ interface MastodonApi { @GET("api/v1/favourites") fun favourites( - @Query("max_id") maxId: String?, - @Query("since_id") sinceId: String?, - @Query("limit") limit: Int? + @Query("max_id") maxId: String?, + @Query("since_id") sinceId: String?, + @Query("limit") limit: Int? ): Single>> @GET("api/v1/bookmarks") fun bookmarks( - @Query("max_id") maxId: String?, - @Query("since_id") sinceId: String?, - @Query("limit") limit: Int? + @Query("max_id") maxId: String?, + @Query("since_id") sinceId: String?, + @Query("limit") limit: Int? ): Single>> @GET("api/v1/follow_requests") fun followRequests( - @Query("max_id") maxId: String? + @Query("max_id") maxId: String? ): Single>> @POST("api/v1/follow_requests/{id}/authorize") fun authorizeFollowRequest( - @Path("id") accountId: String + @Path("id") accountId: String ): Single @POST("api/v1/follow_requests/{id}/reject") fun rejectFollowRequest( - @Path("id") accountId: String + @Path("id") accountId: String ): Single @FormUrlEncoded @POST("api/v1/apps") fun authenticateApp( - @Header(DOMAIN_HEADER) domain: String, - @Field("client_name") clientName: String, - @Field("redirect_uris") redirectUris: String, - @Field("scopes") scopes: String, - @Field("website") website: String + @Header(DOMAIN_HEADER) domain: String, + @Field("client_name") clientName: String, + @Field("redirect_uris") redirectUris: String, + @Field("scopes") scopes: String, + @Field("website") website: String ): Call @FormUrlEncoded @POST("oauth/token") fun fetchOAuthToken( - @Header(DOMAIN_HEADER) domain: String, - @Field("client_id") clientId: String, - @Field("client_secret") clientSecret: String, - @Field("redirect_uri") redirectUri: String, - @Field("code") code: String, - @Field("grant_type") grantType: String + @Header(DOMAIN_HEADER) domain: String, + @Field("client_id") clientId: String, + @Field("client_secret") clientSecret: String, + @Field("redirect_uri") redirectUri: String, + @Field("code") code: String, + @Field("grant_type") grantType: String ): Call @FormUrlEncoded @POST("api/v1/lists") fun createList( - @Field("title") title: String + @Field("title") title: String ): Single @FormUrlEncoded @PUT("api/v1/lists/{listId}") fun updateList( - @Path("listId") listId: String, - @Field("title") title: String + @Path("listId") listId: String, + @Field("title") title: String ): Single @DELETE("api/v1/lists/{listId}") fun deleteList( - @Path("listId") listId: String + @Path("listId") listId: String ): Completable @GET("api/v1/lists/{listId}/accounts") fun getAccountsInList( - @Path("listId") listId: String, - @Query("limit") limit: Int + @Path("listId") listId: String, + @Query("limit") limit: Int ): Single> @FormUrlEncoded // @DELETE doesn't support fields @HTTP(method = "DELETE", path = "api/v1/lists/{listId}/accounts", hasBody = true) fun deleteAccountFromList( - @Path("listId") listId: String, - @Field("account_ids[]") accountIds: List + @Path("listId") listId: String, + @Field("account_ids[]") accountIds: List ): Completable @FormUrlEncoded @POST("api/v1/lists/{listId}/accounts") fun addCountToList( - @Path("listId") listId: String, - @Field("account_ids[]") accountIds: List + @Path("listId") listId: String, + @Field("account_ids[]") accountIds: List ): Completable @GET("/api/v1/conversations") - fun getConversations( - @Query("max_id") maxId: String? = null, - @Query("limit") limit: Int - ): Call> + suspend fun getConversations( + @Query("max_id") maxId: String? = null, + @Query("limit") limit: Int + ): List + + @DELETE("/api/v1/conversations/{id}") + suspend fun deleteConversation( + @Path("id") conversationId: String + ) @FormUrlEncoded @POST("api/v1/filters") fun createFilter( - @Field("phrase") phrase: String, - @Field("context[]") context: List, - @Field("irreversible") irreversible: Boolean?, - @Field("whole_word") wholeWord: Boolean?, - @Field("expires_in") expiresIn: String? + @Field("phrase") phrase: String, + @Field("context[]") context: List, + @Field("irreversible") irreversible: Boolean?, + @Field("whole_word") wholeWord: Boolean?, + @Field("expires_in") expiresIn: String? ): Call @FormUrlEncoded @PUT("api/v1/filters/{id}") fun updateFilter( - @Path("id") id: String, - @Field("phrase") phrase: String, - @Field("context[]") context: List, - @Field("irreversible") irreversible: Boolean?, - @Field("whole_word") wholeWord: Boolean?, - @Field("expires_in") expiresIn: String? + @Path("id") id: String, + @Field("phrase") phrase: String, + @Field("context[]") context: List, + @Field("irreversible") irreversible: Boolean?, + @Field("whole_word") wholeWord: Boolean?, + @Field("expires_in") expiresIn: String? ): Call @DELETE("api/v1/filters/{id}") fun deleteFilter( - @Path("id") id: String + @Path("id") id: String ): Call @FormUrlEncoded @POST("api/v1/polls/{id}/votes") fun voteInPoll( - @Path("id") id: String, - @Field("choices[]") choices: List + @Path("id") id: String, + @Field("choices[]") choices: List ): Single @GET("api/v1/announcements") fun listAnnouncements( - @Query("with_dismissed") withDismissed: Boolean = true + @Query("with_dismissed") withDismissed: Boolean = true ): Single> @POST("api/v1/announcements/{id}/dismiss") fun dismissAnnouncement( - @Path("id") announcementId: String + @Path("id") announcementId: String ): Single @PUT("api/v1/announcements/{id}/reactions/{name}") fun addAnnouncementReaction( - @Path("id") announcementId: String, - @Path("name") name: String + @Path("id") announcementId: String, + @Path("name") name: String ): Single @DELETE("api/v1/announcements/{id}/reactions/{name}") fun removeAnnouncementReaction( - @Path("id") announcementId: String, - @Path("name") name: String + @Path("id") announcementId: String, + @Path("name") name: String ): Single @FormUrlEncoded @POST("api/v1/reports") fun reportObservable( - @Field("account_id") accountId: String, - @Field("status_ids[]") statusIds: List, - @Field("comment") comment: String, - @Field("forward") isNotifyRemote: Boolean? + @Field("account_id") accountId: String, + @Field("status_ids[]") statusIds: List, + @Field("comment") comment: String, + @Field("forward") isNotifyRemote: Boolean? ): Single @GET("api/v1/accounts/{id}/statuses") fun accountStatusesObservable( - @Path("id") accountId: String, - @Query("max_id") maxId: String?, - @Query("since_id") sinceId: String?, - @Query("limit") limit: Int?, - @Query("exclude_reblogs") excludeReblogs: Boolean? + @Path("id") accountId: String, + @Query("max_id") maxId: String?, + @Query("since_id") sinceId: String?, + @Query("min_id") minId: String?, + @Query("limit") limit: Int?, + @Query("exclude_reblogs") excludeReblogs: Boolean? ): Single> @GET("api/v1/statuses/{id}") fun statusObservable( - @Path("id") statusId: String + @Path("id") statusId: String ): Single @GET("api/v2/search") fun searchObservable( - @Query("q") query: String?, - @Query("type") type: String? = null, - @Query("resolve") resolve: Boolean? = null, - @Query("limit") limit: Int? = null, - @Query("offset") offset: Int? = null, - @Query("following") following: Boolean? = null + @Query("q") query: String?, + @Query("type") type: String? = null, + @Query("resolve") resolve: Boolean? = null, + @Query("limit") limit: Int? = null, + @Query("offset") offset: Int? = null, + @Query("following") following: Boolean? = null ): Single @FormUrlEncoded @POST("api/v1/accounts/{id}/note") fun updateAccountNote( - @Path("id") accountId: String, - @Field("comment") note: String + @Path("id") accountId: String, + @Field("comment") note: String ): Single - } diff --git a/app/src/main/java/com/keylesspalace/tusky/network/NotestockApi.kt b/app/src/main/java/com/keylesspalace/tusky/network/NotestockApi.kt index 6e9781b03..c0aad9904 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/NotestockApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/NotestockApi.kt @@ -1,7 +1,7 @@ package com.keylesspalace.tusky.network import com.keylesspalace.tusky.entity.SearchResult -import io.reactivex.Single +import io.reactivex.rxjava3.core.Single import retrofit2.http.GET import retrofit2.http.Query diff --git a/app/src/main/java/com/keylesspalace/tusky/network/TimelineCases.kt b/app/src/main/java/com/keylesspalace/tusky/network/TimelineCases.kt index 8cf2b688d..ea51d8c3e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/TimelineCases.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/TimelineCases.kt @@ -16,34 +16,42 @@ package com.keylesspalace.tusky.network import android.util.Log -import com.keylesspalace.tusky.appstore.* +import com.keylesspalace.tusky.appstore.BlockEvent +import com.keylesspalace.tusky.appstore.BookmarkEvent +import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.appstore.FavoriteEvent +import com.keylesspalace.tusky.appstore.MuteConversationEvent +import com.keylesspalace.tusky.appstore.MuteEvent +import com.keylesspalace.tusky.appstore.PinEvent +import com.keylesspalace.tusky.appstore.PollVoteEvent +import com.keylesspalace.tusky.appstore.ReblogEvent +import com.keylesspalace.tusky.appstore.StatusDeletedEvent import com.keylesspalace.tusky.entity.DeletedStatus import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Status -import io.reactivex.Single -import io.reactivex.disposables.CompositeDisposable -import io.reactivex.rxkotlin.addTo -import java.lang.IllegalStateException +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.kotlin.addTo /** * Created by charlag on 3/24/18. */ interface TimelineCases { - fun reblog(status: Status, reblog: Boolean): Single - fun favourite(status: Status, favourite: Boolean): Single - fun bookmark(status: Status, bookmark: Boolean): Single - fun mute(id: String, notifications: Boolean, duration: Int) - fun block(id: String) - fun delete(id: String): Single - fun pin(status: Status, pin: Boolean) - fun voteInPoll(status: Status, choices: List): Single - fun muteConversation(status: Status, mute: Boolean): Single + fun reblog(statusId: String, reblog: Boolean): Single + fun favourite(statusId: String, favourite: Boolean): Single + fun bookmark(statusId: String, bookmark: Boolean): Single + fun mute(statusId: String, notifications: Boolean, duration: Int?) + fun block(statusId: String) + fun delete(statusId: String): Single + fun pin(statusId: String, pin: Boolean): Single + fun voteInPoll(statusId: String, pollId: String, choices: List): Single + fun muteConversation(statusId: String, mute: Boolean): Single } class TimelineCasesImpl( - private val mastodonApi: MastodonApi, - private val eventHub: EventHub + private val mastodonApi: MastodonApi, + private val eventHub: EventHub ) : TimelineCases { /** @@ -52,104 +60,98 @@ class TimelineCasesImpl( */ private val cancelDisposable = CompositeDisposable() - override fun reblog(status: Status, reblog: Boolean): Single { - val id = status.actionableId - + override fun reblog(statusId: String, reblog: Boolean): Single { val call = if (reblog) { - mastodonApi.reblogStatus(id) + mastodonApi.reblogStatus(statusId) } else { - mastodonApi.unreblogStatus(id) + mastodonApi.unreblogStatus(statusId) } return call.doAfterSuccess { - eventHub.dispatch(ReblogEvent(status.id, reblog)) + eventHub.dispatch(ReblogEvent(statusId, reblog)) } } - override fun favourite(status: Status, favourite: Boolean): Single { - val id = status.actionableId - + override fun favourite(statusId: String, favourite: Boolean): Single { val call = if (favourite) { - mastodonApi.favouriteStatus(id) + mastodonApi.favouriteStatus(statusId) } else { - mastodonApi.unfavouriteStatus(id) + mastodonApi.unfavouriteStatus(statusId) } return call.doAfterSuccess { - eventHub.dispatch(FavoriteEvent(status.id, favourite)) + eventHub.dispatch(FavoriteEvent(statusId, favourite)) } } - override fun bookmark(status: Status, bookmark: Boolean): Single { - val id = status.actionableId - + override fun bookmark(statusId: String, bookmark: Boolean): Single { val call = if (bookmark) { - mastodonApi.bookmarkStatus(id) + mastodonApi.bookmarkStatus(statusId) } else { - mastodonApi.unbookmarkStatus(id) + mastodonApi.unbookmarkStatus(statusId) } return call.doAfterSuccess { - eventHub.dispatch(BookmarkEvent(status.id, bookmark)) + eventHub.dispatch(BookmarkEvent(statusId, bookmark)) } } - override fun muteConversation(status: Status, mute: Boolean): Single { - val id = status.actionableId - + override fun muteConversation(statusId: String, mute: Boolean): Single { val call = if (mute) { - mastodonApi.muteConversation(id) + mastodonApi.muteConversation(statusId) } else { - mastodonApi.unmuteConversation(id) + mastodonApi.unmuteConversation(statusId) } return call.doAfterSuccess { - eventHub.dispatch(MuteConversationEvent(status.id, mute)) + eventHub.dispatch(MuteConversationEvent(statusId, mute)) } } - override fun mute(id: String, notifications: Boolean, duration: Int) { - mastodonApi.muteAccount(id, notifications, duration) - .subscribe({ - eventHub.dispatch(MuteEvent(id)) - }, { t -> + override fun mute(statusId: String, notifications: Boolean, duration: Int?) { + mastodonApi.muteAccount(statusId, notifications, duration) + .subscribe( + { + eventHub.dispatch(MuteEvent(statusId)) + }, + { t -> Log.w("Failed to mute account", t) - }) - .addTo(cancelDisposable) - } - - override fun block(id: String) { - mastodonApi.blockAccount(id) - .subscribe({ - eventHub.dispatch(BlockEvent(id)) - }, { t -> - Log.w("Failed to block account", t) - }) - .addTo(cancelDisposable) - } - - override fun delete(id: String): Single { - return mastodonApi.deleteStatus(id) - .doAfterSuccess { - eventHub.dispatch(StatusDeletedEvent(id)) } + ) + .addTo(cancelDisposable) } - override fun pin(status: Status, pin: Boolean) { + override fun block(statusId: String) { + mastodonApi.blockAccount(statusId) + .subscribe( + { + eventHub.dispatch(BlockEvent(statusId)) + }, + { t -> + Log.w("Failed to block account", t) + } + ) + .addTo(cancelDisposable) + } + + override fun delete(statusId: String): Single { + return mastodonApi.deleteStatus(statusId) + .doAfterSuccess { + eventHub.dispatch(StatusDeletedEvent(statusId)) + } + } + + override fun pin(statusId: String, pin: Boolean): Single { // Replace with extension method if we use RxKotlin - (if (pin) mastodonApi.pinStatus(status.id) else mastodonApi.unpinStatus(status.id)) - .subscribe({ updatedStatus -> - status.pinned = updatedStatus.pinned - }, {}) - .addTo(this.cancelDisposable) + return (if (pin) mastodonApi.pinStatus(statusId) else mastodonApi.unpinStatus(statusId)) + .doAfterSuccess { + eventHub.dispatch(PinEvent(statusId, pin)) + } } - override fun voteInPoll(status: Status, choices: List): Single { - val pollId = status.actionableStatus.poll?.id - - if(pollId == null || choices.isEmpty()) { + override fun voteInPoll(statusId: String, pollId: String, choices: List): Single { + if (choices.isEmpty()) { return Single.error(IllegalStateException()) } return mastodonApi.voteInPoll(pollId, choices).doAfterSuccess { - eventHub.dispatch(PollVoteEvent(status.id, it)) + eventHub.dispatch(PollVoteEvent(statusId, it)) } } - -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/pager/AccountPagerAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/pager/AccountPagerAdapter.kt index f8c026e05..2d01a32db 100644 --- a/app/src/main/java/com/keylesspalace/tusky/pager/AccountPagerAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/pager/AccountPagerAdapter.kt @@ -15,26 +15,26 @@ package com.keylesspalace.tusky.pager -import androidx.fragment.app.* - +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import com.keylesspalace.tusky.components.timeline.TimelineFragment +import com.keylesspalace.tusky.components.timeline.TimelineViewModel import com.keylesspalace.tusky.fragment.AccountMediaFragment -import com.keylesspalace.tusky.fragment.TimelineFragment import com.keylesspalace.tusky.interfaces.RefreshableFragment - import com.keylesspalace.tusky.util.CustomFragmentStateAdapter class AccountPagerAdapter( - activity: FragmentActivity, - private val accountId: String + activity: FragmentActivity, + private val accountId: String ) : CustomFragmentStateAdapter(activity) { override fun getItemCount() = TAB_COUNT override fun createFragment(position: Int): Fragment { return when (position) { - 0 -> TimelineFragment.newInstance(TimelineFragment.Kind.USER, accountId, false) - 1 -> TimelineFragment.newInstance(TimelineFragment.Kind.USER_WITH_REPLIES, accountId, false) - 2 -> TimelineFragment.newInstance(TimelineFragment.Kind.USER_PINNED, accountId, false) + 0 -> TimelineFragment.newInstance(TimelineViewModel.Kind.USER, accountId, false) + 1 -> TimelineFragment.newInstance(TimelineViewModel.Kind.USER_WITH_REPLIES, accountId, false) + 2 -> TimelineFragment.newInstance(TimelineViewModel.Kind.USER_PINNED, accountId, false) 3 -> AccountMediaFragment.newInstance(accountId, false) else -> throw AssertionError("Page $position is out of AccountPagerAdapter bounds") } diff --git a/app/src/main/java/com/keylesspalace/tusky/pager/ImagePagerAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/pager/ImagePagerAdapter.kt index 4f813d8b1..26c5fc05a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/pager/ImagePagerAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/pager/ImagePagerAdapter.kt @@ -8,9 +8,9 @@ import com.keylesspalace.tusky.fragment.ViewMediaFragment import java.lang.ref.WeakReference class ImagePagerAdapter( - activity: FragmentActivity, - private val attachments: List, - private val initialPosition: Int + activity: FragmentActivity, + private val attachments: List, + private val initialPosition: Int ) : ViewMediaAdapter(activity) { private var didTransition = false @@ -25,8 +25,8 @@ class ImagePagerAdapter( // forth photo and then back to the first. The first fragment will try to start the // transition and wait until it's over and it will never take place. val fragment = ViewMediaFragment.newInstance( - attachment = attachments[position], - shouldStartPostponedTransition = !didTransition && position == initialPosition + attachment = attachments[position], + shouldStartPostponedTransition = !didTransition && position == initialPosition ) fragments[position] = WeakReference(fragment) return fragment @@ -35,7 +35,7 @@ class ImagePagerAdapter( } } - override fun onTransitionEnd(position: Int) { + override fun onTransitionEnd(position: Int) { this.didTransition = true fragments[position]?.get()?.onTransitionEnd() } diff --git a/app/src/main/java/com/keylesspalace/tusky/pager/MainPagerAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/pager/MainPagerAdapter.kt index 1e1029410..4fe92660c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/pager/MainPagerAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/pager/MainPagerAdapter.kt @@ -28,5 +28,4 @@ class MainPagerAdapter(val tabs: List, activity: FragmentActivity) : Cu } override fun getItemCount() = tabs.size - } diff --git a/app/src/main/java/com/keylesspalace/tusky/pager/SingleImagePagerAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/pager/SingleImagePagerAdapter.kt index c8306f70e..c1f5342a3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/pager/SingleImagePagerAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/pager/SingleImagePagerAdapter.kt @@ -6,8 +6,8 @@ import com.keylesspalace.tusky.ViewMediaAdapter import com.keylesspalace.tusky.fragment.ViewMediaFragment class SingleImagePagerAdapter( - activity: FragmentActivity, - private val imageUrl: String + activity: FragmentActivity, + private val imageUrl: String ) : ViewMediaAdapter(activity) { override fun createFragment(position: Int): Fragment { diff --git a/app/src/main/java/com/keylesspalace/tusky/receiver/NotificationClearBroadcastReceiver.kt b/app/src/main/java/com/keylesspalace/tusky/receiver/NotificationClearBroadcastReceiver.kt index d9b94857c..6d4e97193 100644 --- a/app/src/main/java/com/keylesspalace/tusky/receiver/NotificationClearBroadcastReceiver.kt +++ b/app/src/main/java/com/keylesspalace/tusky/receiver/NotificationClearBroadcastReceiver.kt @@ -18,9 +18,8 @@ package com.keylesspalace.tusky.receiver import android.content.BroadcastReceiver import android.content.Context import android.content.Intent - -import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.components.notifications.NotificationHelper +import com.keylesspalace.tusky.db.AccountManager import dagger.android.AndroidInjection import javax.inject.Inject @@ -40,5 +39,4 @@ class NotificationClearBroadcastReceiver : BroadcastReceiver() { accountManager.saveAccount(account) } } - } diff --git a/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt b/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt index ce689e5cf..999b5290a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt +++ b/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt @@ -26,11 +26,11 @@ import androidx.core.content.ContextCompat import com.keylesspalace.tusky.R import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeOptions +import com.keylesspalace.tusky.components.notifications.NotificationHelper import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.service.SendTootService import com.keylesspalace.tusky.service.TootToSend -import com.keylesspalace.tusky.components.notifications.NotificationHelper import com.keylesspalace.tusky.util.randomAlphanumericString import dagger.android.AndroidInjection import javax.inject.Inject @@ -51,8 +51,8 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() { val senderFullName = intent.getStringExtra(NotificationHelper.KEY_SENDER_ACCOUNT_FULL_NAME) val citedStatusId = intent.getStringExtra(NotificationHelper.KEY_CITED_STATUS_ID) val visibility = intent.getSerializableExtra(NotificationHelper.KEY_VISIBILITY) as Status.Visibility - val spoiler = intent.getStringExtra(NotificationHelper.KEY_SPOILER) - val mentions = intent.getStringArrayExtra(NotificationHelper.KEY_MENTIONS) + val spoiler = intent.getStringExtra(NotificationHelper.KEY_SPOILER) ?: "" + val mentions = intent.getStringArrayExtra(NotificationHelper.KEY_MENTIONS) ?: emptyArray() val citedText = intent.getStringExtra(NotificationHelper.KEY_CITED_TEXT) val localAuthorId = intent.getStringExtra(NotificationHelper.KEY_CITED_AUTHOR_LOCAL) @@ -68,10 +68,10 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() { Log.w(TAG, "Account \"$senderId\" not found in database. Aborting quick reply!") val builder = NotificationCompat.Builder(context, NotificationHelper.CHANNEL_MENTION + senderIdentifier) - .setSmallIcon(R.drawable.ic_notify) - .setColor(ContextCompat.getColor(context, (R.color.tusky_blue))) - .setGroup(senderFullName) - .setDefaults(0) // So it doesn't ring twice, notify only in Target callback + .setSmallIcon(R.drawable.ic_notify) + .setColor(ContextCompat.getColor(context, R.color.tusky_blue)) + .setGroup(senderFullName) + .setDefaults(0) // So it doesn't ring twice, notify only in Target callback builder.setContentTitle(context.getString(R.string.error_generic)) builder.setContentText(context.getString(R.string.error_sender_account_gone)) @@ -86,36 +86,35 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() { val text = mentions.joinToString(" ", postfix = " ") { "@$it" } + message.toString() val sendIntent = SendTootService.sendTootIntent( - context, - TootToSend( - text = text, - warningText = spoiler, - visibility = visibility.serverString(), - sensitive = false, - mediaIds = emptyList(), - mediaUris = emptyList(), - mediaDescriptions = emptyList(), - scheduledAt = null, - inReplyToId = citedStatusId, - poll = null, - replyingStatusContent = null, - replyingStatusAuthorUsername = null, - quoteId = null, - accountId = account.id, - savedTootUid = -1, - draftId = -1, - idempotencyKey = randomAlphanumericString(16), - retries = 0 - ) + context, + TootToSend( + text = text, + warningText = spoiler, + visibility = visibility.serverString(), + sensitive = false, + mediaIds = emptyList(), + mediaUris = emptyList(), + mediaDescriptions = emptyList(), + scheduledAt = null, + inReplyToId = citedStatusId, + poll = null, + replyingStatusContent = null, + replyingStatusAuthorUsername = null, + quoteId = null, + accountId = account.id, + draftId = -1, + idempotencyKey = randomAlphanumericString(16), + retries = 0 + ) ) context.startService(sendIntent) val builder = NotificationCompat.Builder(context, NotificationHelper.CHANNEL_MENTION + senderIdentifier) - .setSmallIcon(R.drawable.ic_notify) - .setColor(ContextCompat.getColor(context, (R.color.tusky_blue))) - .setGroup(senderFullName) - .setDefaults(0) // So it doesn't ring twice, notify only in Target callback + .setSmallIcon(R.drawable.ic_notify) + .setColor(ContextCompat.getColor(context, (R.color.tusky_blue))) + .setGroup(senderFullName) + .setDefaults(0) // So it doesn't ring twice, notify only in Target callback builder.setContentTitle(context.getString(R.string.status_sent)) builder.setContentText(context.getString(R.string.status_sent_long)) @@ -135,14 +134,17 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() { accountManager.setActiveAccount(senderId) - val composeIntent = ComposeActivity.startIntent(context, ComposeOptions( + val composeIntent = ComposeActivity.startIntent( + context, + ComposeOptions( inReplyToId = citedStatusId, replyVisibility = visibility, contentWarning = spoiler, mentionedUsernames = mentions.toSet(), replyingStatusAuthor = localAuthorId, replyingStatusContent = citedText - )) + ) + ) composeIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) @@ -155,5 +157,4 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() { return remoteInput.getCharSequence(NotificationHelper.KEY_REPLY, "") } - } diff --git a/app/src/main/java/com/keylesspalace/tusky/repository/TimelineRepository.kt b/app/src/main/java/com/keylesspalace/tusky/repository/TimelineRepository.kt deleted file mode 100644 index 3af6ce40e..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/repository/TimelineRepository.kt +++ /dev/null @@ -1,407 +0,0 @@ -package com.keylesspalace.tusky.repository - -import android.text.SpannedString -import androidx.core.text.parseAsHtml -import androidx.core.text.toHtml -import com.google.gson.Gson -import com.google.gson.reflect.TypeToken -import com.keylesspalace.tusky.db.* -import com.keylesspalace.tusky.entity.* -import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.repository.TimelineRequestMode.DISK -import com.keylesspalace.tusky.repository.TimelineRequestMode.NETWORK -import com.keylesspalace.tusky.util.Either -import com.keylesspalace.tusky.util.dec -import com.keylesspalace.tusky.util.inc -import com.keylesspalace.tusky.util.trimTrailingWhitespace -import io.reactivex.Single -import io.reactivex.schedulers.Schedulers -import java.io.IOException -import java.util.* -import java.util.concurrent.TimeUnit -import kotlin.collections.ArrayList - -data class Placeholder(val id: String) - -typealias TimelineStatus = Either - -enum class TimelineRequestMode { - DISK, NETWORK, ANY -} - -interface TimelineRepository { - fun getStatuses(maxId: String?, sinceId: String?, sincedIdMinusOne: String?, limit: Int, - requestMode: TimelineRequestMode): Single> - fun addSingleStatusToDb(status: Status) - - companion object { - val CLEANUP_INTERVAL = TimeUnit.DAYS.toMillis(14) - } -} - -class TimelineRepositoryImpl( - private val timelineDao: TimelineDao, - private val mastodonApi: MastodonApi, - private val accountManager: AccountManager, - private val gson: Gson -) : TimelineRepository { - - init { - this.cleanup() - } - - override fun getStatuses(maxId: String?, sinceId: String?, sincedIdMinusOne: String?, - limit: Int, requestMode: TimelineRequestMode - ): Single> { - val acc = accountManager.activeAccount ?: throw IllegalStateException() - val accountId = acc.id - - return if (requestMode == DISK) { - this.getStatusesFromDb(accountId, maxId, sinceId, limit) - } else { - getStatusesFromNetwork(maxId, sinceId, sincedIdMinusOne, limit, accountId, requestMode) - } - } - - override fun addSingleStatusToDb(status: Status) { - val acc = accountManager.activeAccount ?: throw IllegalStateException() - val accountId = acc.id - - timelineDao.insertInTransaction( - status.toEntity(accountId, gson), - status.account.toEntity(accountId, gson), - status.reblog?.account?.toEntity(accountId, gson) - ) - } - - private fun getStatusesFromNetwork(maxId: String?, sinceId: String?, - sinceIdMinusOne: String?, limit: Int, - accountId: Long, requestMode: TimelineRequestMode - ): Single> { - return mastodonApi.homeTimeline(maxId, sinceIdMinusOne, limit + 1) - .map { response -> - this.saveStatusesToDb(accountId, response.body().orEmpty(), maxId, sinceId) - } - .flatMap { statuses -> - this.addFromDbIfNeeded(accountId, statuses, maxId, sinceId, limit, requestMode) - } - .onErrorResumeNext { error -> - if (error is IOException && requestMode != NETWORK) { - this.getStatusesFromDb(accountId, maxId, sinceId, limit) - } else { - Single.error(error) - } - } - } - - private fun addFromDbIfNeeded(accountId: Long, statuses: List>, - maxId: String?, sinceId: String?, limit: Int, - requestMode: TimelineRequestMode - ): Single> { - return if (requestMode != NETWORK && statuses.size < 2) { - val newMaxID = if (statuses.isEmpty()) { - maxId - } else { - statuses.last { it.isRight() }.asRight().id - } - this.getStatusesFromDb(accountId, newMaxID, sinceId, limit) - .map { fromDb -> - // If it's just placeholders and less than limit (so we exhausted both - // db and server at this point) - if (fromDb.size < limit && fromDb.all { !it.isRight() }) { - statuses - } else { - statuses + fromDb - } - } - } else { - Single.just(statuses) - } - } - - private fun getStatusesFromDb(accountId: Long, maxId: String?, sinceId: String?, - limit: Int): Single> { - return timelineDao.getStatusesForAccount(accountId, maxId, sinceId, limit) - .subscribeOn(Schedulers.io()) - .map { statuses -> - statuses.map { it.toStatus() } - } - } - - private fun saveStatusesToDb(accountId: Long, statuses: List, - maxId: String?, sinceId: String? - ): List> { - var placeholderToInsert: Placeholder? = null - - // Look for overlap - val resultStatuses = if (statuses.isNotEmpty() && sinceId != null) { - val indexOfSince = statuses.indexOfLast { it.id == sinceId } - if (indexOfSince == -1) { - // We didn't find the status which must be there. Add a placeholder - placeholderToInsert = Placeholder(sinceId.inc()) - statuses.mapTo(mutableListOf(), Status::lift) - .apply { - add(Either.Left(placeholderToInsert)) - } - } else { - // There was an overlap. Remove all overlapped statuses. No need for a placeholder. - statuses.mapTo(mutableListOf(), Status::lift) - .apply { - subList(indexOfSince, size).clear() - } - } - } else { - // Just a normal case. - statuses.map(Status::lift) - } - - Single.fromCallable { - - if(statuses.isNotEmpty()) { - timelineDao.deleteRange(accountId, statuses.last().id, statuses.first().id) - } - - for (status in statuses) { - timelineDao.insertInTransaction( - status.toEntity(accountId, gson), - status.account.toEntity(accountId, gson), - status.reblog?.account?.toEntity(accountId, gson) - ) - } - - placeholderToInsert?.let { - timelineDao.insertStatusIfNotThere(placeholderToInsert.toEntity(accountId)) - } - - // If we're loading in the bottom insert placeholder after every load - // (for requests on next launches) but not return it. - if (sinceId == null && statuses.isNotEmpty()) { - timelineDao.insertStatusIfNotThere( - Placeholder(statuses.last().id.dec()).toEntity(accountId)) - } - - // There may be placeholders which we thought could be from our TL but they are not - if (statuses.size > 2) { - timelineDao.removeAllPlaceholdersBetween(accountId, statuses.first().id, - statuses.last().id) - } else if (placeholderToInsert == null && maxId != null && sinceId != null) { - timelineDao.removeAllPlaceholdersBetween(accountId, maxId, sinceId) - } - } - .subscribeOn(Schedulers.io()) - .subscribe() - - return resultStatuses - } - - private fun cleanup() { - Schedulers.io().scheduleDirect { - val olderThan = System.currentTimeMillis() - TimelineRepository.CLEANUP_INTERVAL - timelineDao.cleanup(olderThan) - } - } - - private fun TimelineStatusWithAccount.toStatus(): TimelineStatus { - if (this.status.authorServerId == null) { - return Either.Left(Placeholder(this.status.serverId)) - } - - val attachments: ArrayList = gson.fromJson(status.attachments, - object : TypeToken>() {}.type) ?: ArrayList() - val mentions: Array = gson.fromJson(status.mentions, - Array::class.java) ?: arrayOf() - val application = gson.fromJson(status.application, Status.Application::class.java) - val emojis: List = gson.fromJson(status.emojis, - object : TypeToken>() {}.type) ?: listOf() - val poll: Poll? = gson.fromJson(status.poll, Poll::class.java) - - val reblog = status.reblogServerId?.let { id -> - Status( - id = id, - url = status.url, - account = account.toAccount(gson), - inReplyToId = status.inReplyToId, - inReplyToAccountId = status.inReplyToAccountId, - reblog = null, - content = status.content?.parseAsHtml()?.trimTrailingWhitespace() ?: SpannedString(""), - createdAt = Date(status.createdAt), - emojis = emojis, - reblogsCount = status.reblogsCount, - favouritesCount = status.favouritesCount, - reblogged = status.reblogged, - favourited = status.favourited, - bookmarked = status.bookmarked, - sensitive = status.sensitive, - spoilerText = status.spoilerText!!, - visibility = status.visibility!!, - attachments = attachments, - mentions = mentions, - application = application, - pinned = false, - muted = status.muted, - poll = poll, - card = null, - quote = null - ) - } - val status = if (reblog != null) { - Status( - id = status.serverId, - url = null, // no url for reblogs - account = this.reblogAccount!!.toAccount(gson), - inReplyToId = null, - inReplyToAccountId = null, - reblog = reblog, - content = SpannedString(""), - createdAt = Date(status.createdAt), // lie but whatever? - emojis = listOf(), - reblogsCount = 0, - favouritesCount = 0, - reblogged = false, - favourited = false, - bookmarked = false, - sensitive = false, - spoilerText = "", - visibility = status.visibility!!, - attachments = ArrayList(), - mentions = arrayOf(), - application = null, - pinned = false, - muted = status.muted, - poll = null, - card = null, - quote = null - ) - } else { - Status( - id = status.serverId, - url = status.url, - account = account.toAccount(gson), - inReplyToId = status.inReplyToId, - inReplyToAccountId = status.inReplyToAccountId, - reblog = null, - content = status.content?.parseAsHtml()?.trimTrailingWhitespace() ?: SpannedString(""), - createdAt = Date(status.createdAt), - emojis = emojis, - reblogsCount = status.reblogsCount, - favouritesCount = status.favouritesCount, - reblogged = status.reblogged, - favourited = status.favourited, - bookmarked = status.bookmarked, - sensitive = status.sensitive, - spoilerText = status.spoilerText!!, - visibility = status.visibility!!, - attachments = attachments, - mentions = mentions, - application = application, - pinned = false, - muted = status.muted, - poll = poll, - card = null, - quote = null - ) - } - return Either.Right(status) - } -} - -private val emojisListTypeToken = object : TypeToken>() {} - -fun Account.toEntity(accountId: Long, gson: Gson): TimelineAccountEntity { - return TimelineAccountEntity( - serverId = id, - timelineUserId = accountId, - localUsername = localUsername, - username = username, - displayName = name, - url = url, - avatar = avatar, - emojis = gson.toJson(emojis), - bot = bot - ) -} - -fun TimelineAccountEntity.toAccount(gson: Gson): Account { - return Account( - id = serverId, - localUsername = localUsername, - username = username, - displayName = displayName, - note = SpannedString(""), - url = url, - avatar = avatar, - header = "", - locked = false, - followingCount = 0, - followersCount = 0, - statusesCount = 0, - source = null, - bot = bot, - emojis = gson.fromJson(this.emojis, emojisListTypeToken.type), - fields = null, - moved = null - ) -} - - -fun Placeholder.toEntity(timelineUserId: Long): TimelineStatusEntity { - return TimelineStatusEntity( - serverId = this.id, - url = null, - timelineUserId = timelineUserId, - authorServerId = null, - inReplyToId = null, - inReplyToAccountId = null, - content = null, - createdAt = 0L, - emojis = null, - reblogsCount = 0, - favouritesCount = 0, - reblogged = false, - favourited = false, - bookmarked = false, - sensitive = false, - spoilerText = null, - visibility = null, - attachments = null, - mentions = null, - application = null, - reblogServerId = null, - reblogAccountId = null, - poll = null, - muted = false - ) -} - -fun Status.toEntity(timelineUserId: Long, - gson: Gson): TimelineStatusEntity { - val actionable = actionableStatus - return TimelineStatusEntity( - serverId = this.id, - url = actionable.url!!, - timelineUserId = timelineUserId, - authorServerId = actionable.account.id, - inReplyToId = actionable.inReplyToId, - inReplyToAccountId = actionable.inReplyToAccountId, - content = actionable.content.toHtml(), - createdAt = actionable.createdAt.time, - emojis = actionable.emojis.let(gson::toJson), - reblogsCount = actionable.reblogsCount, - favouritesCount = actionable.favouritesCount, - reblogged = actionable.reblogged, - favourited = actionable.favourited, - bookmarked = actionable.bookmarked, - sensitive = actionable.sensitive, - spoilerText = actionable.spoilerText, - visibility = actionable.visibility, - attachments = actionable.attachments.let(gson::toJson), - mentions = actionable.mentions.let(gson::toJson), - application = actionable.application.let(gson::toJson), - reblogServerId = reblog?.id, - reblogAccountId = reblog?.let { this.account.id }, - poll = actionable.poll.let(gson::toJson), - muted = actionable.muted - ) -} - -fun Status.lift(): Either = Either.Right(this) diff --git a/app/src/main/java/com/keylesspalace/tusky/service/SendTootService.kt b/app/src/main/java/com/keylesspalace/tusky/service/SendTootService.kt index 88682e214..473eaa363 100644 --- a/app/src/main/java/com/keylesspalace/tusky/service/SendTootService.kt +++ b/app/src/main/java/com/keylesspalace/tusky/service/SendTootService.kt @@ -26,13 +26,17 @@ import com.keylesspalace.tusky.entity.NewPoll import com.keylesspalace.tusky.entity.NewStatus import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.util.SaveTootHelper import dagger.android.AndroidInjection +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize import retrofit2.Call import retrofit2.Callback import retrofit2.Response -import java.util.* +import java.util.Timer +import java.util.TimerTask import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit import javax.inject.Inject @@ -49,8 +53,9 @@ class SendTootService : Service(), Injectable { lateinit var database: AppDatabase @Inject lateinit var draftHelper: DraftHelper - @Inject - lateinit var saveTootHelper: SaveTootHelper + + private val supervisorJob = SupervisorJob() + private val serviceScope = CoroutineScope(Dispatchers.Main + supervisorJob) private val tootsToSend = ConcurrentHashMap() private val sendCalls = ConcurrentHashMap>() @@ -72,12 +77,11 @@ class SendTootService : Service(), Injectable { if (intent.hasExtra(KEY_TOOT)) { val tootToSend = intent.getParcelableExtra(KEY_TOOT) - ?: throw IllegalStateException("SendTootService started without $KEY_TOOT extra") + ?: throw IllegalStateException("SendTootService started without $KEY_TOOT extra") if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val channel = NotificationChannel(CHANNEL_ID, getString(R.string.send_toot_notification_channel_name), NotificationManager.IMPORTANCE_LOW) notificationManager.createNotificationChannel(channel) - } var notificationText = tootToSend.warningText @@ -86,13 +90,13 @@ class SendTootService : Service(), Injectable { } val builder = NotificationCompat.Builder(this, CHANNEL_ID) - .setSmallIcon(R.drawable.ic_notify) - .setContentTitle(getString(R.string.send_toot_notification_title)) - .setContentText(notificationText) - .setProgress(1, 0, true) - .setOngoing(true) - .setColor(ContextCompat.getColor(this, R.color.tusky_blue)) - .addAction(0, getString(android.R.string.cancel), cancelSendingIntent(sendingNotificationId)) + .setSmallIcon(R.drawable.ic_notify) + .setContentTitle(getString(R.string.send_toot_notification_title)) + .setContentText(notificationText) + .setProgress(1, 0, true) + .setOngoing(true) + .setColor(ContextCompat.getColor(this, R.color.tusky_blue)) + .addAction(0, getString(android.R.string.cancel), cancelSendingIntent(sendingNotificationId)) if (tootsToSend.size == 0 || Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_DETACH) @@ -103,17 +107,14 @@ class SendTootService : Service(), Injectable { tootsToSend[sendingNotificationId] = tootToSend sendToot(sendingNotificationId--) - } else { if (intent.hasExtra(KEY_CANCEL)) { cancelSending(intent.getIntExtra(KEY_CANCEL, 0)) } - } return START_NOT_STICKY - } private fun sendToot(tootId: Int) { @@ -142,17 +143,16 @@ class SendTootService : Service(), Injectable { tootToSend.mediaIds, tootToSend.scheduledAt, tootToSend.poll, - tootToSend.quoteId + tootToSend.quoteId, ) val sendCall = mastodonApi.createStatus( - "Bearer " + account.accessToken, - account.domain, - tootToSend.idempotencyKey, - newStatus + "Bearer " + account.accessToken, + account.domain, + tootToSend.idempotencyKey, + newStatus ) - sendCalls[tootId] = sendCall val callback = object : Callback { @@ -163,12 +163,10 @@ class SendTootService : Service(), Injectable { if (response.isSuccessful) { // If the status was loaded from a draft, delete the draft and associated media files. - if (tootToSend.savedTootUid != 0) { - saveTootHelper.deleteDraft(tootToSend.savedTootUid) - } if (tootToSend.draftId != 0) { - draftHelper.deleteDraftAndAttachments(tootToSend.draftId) - .subscribe() + serviceScope.launch { + draftHelper.deleteDraftAndAttachments(tootToSend.draftId) + } } if (scheduled) { @@ -178,24 +176,21 @@ class SendTootService : Service(), Injectable { } notificationManager.cancel(tootId) - } else { // the server refused to accept the toot, save toot & show error message saveTootToDrafts(tootToSend) val builder = NotificationCompat.Builder(this@SendTootService, CHANNEL_ID) - .setSmallIcon(R.drawable.ic_notify) - .setContentTitle(getString(R.string.send_toot_notification_error_title)) - .setContentText(getString(R.string.send_toot_notification_saved_content)) - .setColor(ContextCompat.getColor(this@SendTootService, R.color.tusky_blue)) + .setSmallIcon(R.drawable.ic_notify) + .setContentTitle(getString(R.string.send_toot_notification_error_title)) + .setContentText(getString(R.string.send_toot_notification_saved_content)) + .setColor(ContextCompat.getColor(this@SendTootService, R.color.tusky_blue)) notificationManager.cancel(tootId) notificationManager.notify(errorNotificationId--, builder.build()) - } stopSelfWhenDone() - } override fun onFailure(call: Call, t: Throwable) { @@ -204,16 +199,18 @@ class SendTootService : Service(), Injectable { backoff = MAX_RETRY_INTERVAL } - timer.schedule(object : TimerTask() { - override fun run() { - sendToot(tootId) - } - }, backoff) + timer.schedule( + object : TimerTask() { + override fun run() { + sendToot(tootId) + } + }, + backoff + ) } } sendCall.enqueue(callback) - } private fun stopSelfWhenDone() { @@ -233,26 +230,28 @@ class SendTootService : Service(), Injectable { saveTootToDrafts(tootToCancel) val builder = NotificationCompat.Builder(this@SendTootService, CHANNEL_ID) - .setSmallIcon(R.drawable.ic_notify) - .setContentTitle(getString(R.string.send_toot_notification_cancel_title)) - .setContentText(getString(R.string.send_toot_notification_saved_content)) - .setColor(ContextCompat.getColor(this@SendTootService, R.color.tusky_blue)) + .setSmallIcon(R.drawable.ic_notify) + .setContentTitle(getString(R.string.send_toot_notification_cancel_title)) + .setContentText(getString(R.string.send_toot_notification_saved_content)) + .setColor(ContextCompat.getColor(this@SendTootService, R.color.tusky_blue)) notificationManager.notify(tootId, builder.build()) - timer.schedule(object : TimerTask() { - override fun run() { - notificationManager.cancel(tootId) - stopSelfWhenDone() - } - }, 5000) - + timer.schedule( + object : TimerTask() { + override fun run() { + notificationManager.cancel(tootId) + stopSelfWhenDone() + } + }, + 5000 + ) } } private fun saveTootToDrafts(toot: TootToSend) { - - draftHelper.saveDraft( + serviceScope.launch { + draftHelper.saveDraft( draftId = toot.draftId, accountId = toot.accountId, inReplyToId = toot.inReplyToId, @@ -264,7 +263,8 @@ class SendTootService : Service(), Injectable { mediaDescriptions = toot.mediaDescriptions, poll = toot.poll, failedToSend = true - ).subscribe() + ) + } } private fun cancelSendingIntent(tootId: Int): PendingIntent { @@ -276,6 +276,10 @@ class SendTootService : Service(), Injectable { return PendingIntent.getService(this, tootId, intent, PendingIntent.FLAG_UPDATE_CURRENT) } + override fun onDestroy() { + super.onDestroy() + supervisorJob.cancel() + } companion object { @@ -289,8 +293,9 @@ class SendTootService : Service(), Injectable { private var errorNotificationId = Int.MIN_VALUE // use even more negative ids to not clash with other notis @JvmStatic - fun sendTootIntent(context: Context, - tootToSend: TootToSend + fun sendTootIntent( + context: Context, + tootToSend: TootToSend ): Intent { val intent = Intent(context, SendTootService::class.java) intent.putExtra(KEY_TOOT, tootToSend) @@ -299,43 +304,40 @@ class SendTootService : Service(), Injectable { // forward uri permissions intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) val uriClip = ClipData( - ClipDescription("Toot Media", arrayOf("image/*", "video/*")), - ClipData.Item(tootToSend.mediaUris[0]) + ClipDescription("Toot Media", arrayOf("image/*", "video/*")), + ClipData.Item(tootToSend.mediaUris[0]) ) tootToSend.mediaUris - .drop(1) - .forEach { mediaUri -> - uriClip.addItem(ClipData.Item(mediaUri)) - } + .drop(1) + .forEach { mediaUri -> + uriClip.addItem(ClipData.Item(mediaUri)) + } intent.clipData = uriClip - } return intent } - } } @Parcelize data class TootToSend( - val text: String, - val warningText: String, - val visibility: String, - val sensitive: Boolean, - val mediaIds: List, - val mediaUris: List, - val mediaDescriptions: List, - val scheduledAt: String?, - val inReplyToId: String?, - val poll: NewPoll?, - val replyingStatusContent: String?, - val replyingStatusAuthorUsername: String?, - val quoteId: String?, - val accountId: Long, - val savedTootUid: Int, - val draftId: Int, - val idempotencyKey: String, - var retries: Int + val text: String, + val warningText: String, + val visibility: String, + val sensitive: Boolean, + val mediaIds: List, + val mediaUris: List, + val mediaDescriptions: List, + val scheduledAt: String?, + val inReplyToId: String?, + val poll: NewPoll?, + val replyingStatusContent: String?, + val replyingStatusAuthorUsername: String?, + val quoteId: String?, + val accountId: Long, + val draftId: Int, + val idempotencyKey: String, + var retries: Int ) : Parcelable diff --git a/app/src/main/java/com/keylesspalace/tusky/service/ServiceClient.kt b/app/src/main/java/com/keylesspalace/tusky/service/ServiceClient.kt index b60377f52..5b9e3298d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/service/ServiceClient.kt +++ b/app/src/main/java/com/keylesspalace/tusky/service/ServiceClient.kt @@ -31,4 +31,4 @@ class ServiceClientImpl(private val context: Context) : ServiceClient { context.startService(intent) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsDSL.kt b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsDSL.kt index 82dfa14e2..1569cb151 100644 --- a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsDSL.kt +++ b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsDSL.kt @@ -2,13 +2,20 @@ package com.keylesspalace.tusky.settings import android.content.Context import androidx.annotation.StringRes -import androidx.preference.* +import androidx.preference.CheckBoxPreference +import androidx.preference.EditTextPreference +import androidx.preference.ListPreference +import androidx.preference.Preference +import androidx.preference.PreferenceCategory +import androidx.preference.PreferenceFragmentCompat +import androidx.preference.PreferenceScreen +import androidx.preference.SwitchPreference import com.keylesspalace.tusky.components.preference.EmojiPreference import okhttp3.OkHttpClient class PreferenceParent( - val context: Context, - val addPref: (pref: Preference) -> Unit + val context: Context, + val addPref: (pref: Preference) -> Unit ) inline fun PreferenceParent.preference(builder: Preference.() -> Unit): Preference { @@ -33,7 +40,7 @@ inline fun PreferenceParent.emojiPreference(okHttpClient: OkHttpClient, builder: } inline fun PreferenceParent.switchPreference( - builder: SwitchPreference.() -> Unit + builder: SwitchPreference.() -> Unit ): SwitchPreference { val pref = SwitchPreference(context) builder(pref) @@ -42,7 +49,7 @@ inline fun PreferenceParent.switchPreference( } inline fun PreferenceParent.editTextPreference( - builder: EditTextPreference.() -> Unit + builder: EditTextPreference.() -> Unit ): EditTextPreference { val pref = EditTextPreference(context) builder(pref) @@ -51,7 +58,7 @@ inline fun PreferenceParent.editTextPreference( } inline fun PreferenceParent.checkBoxPreference( - builder: CheckBoxPreference.() -> Unit + builder: CheckBoxPreference.() -> Unit ): CheckBoxPreference { val pref = CheckBoxPreference(context) builder(pref) @@ -60,8 +67,8 @@ inline fun PreferenceParent.checkBoxPreference( } inline fun PreferenceParent.preferenceCategory( - @StringRes title: Int, - builder: PreferenceParent.(PreferenceCategory) -> Unit + @StringRes title: Int, + builder: PreferenceParent.(PreferenceCategory) -> Unit ) { val category = PreferenceCategory(context) addPref(category) @@ -71,7 +78,7 @@ inline fun PreferenceParent.preferenceCategory( } inline fun PreferenceFragmentCompat.makePreferenceScreen( - builder: PreferenceParent.() -> Unit + builder: PreferenceParent.() -> Unit ): PreferenceScreen { val context = requireContext() val screen = preferenceManager.createPreferenceScreen(context) @@ -81,4 +88,4 @@ inline fun PreferenceFragmentCompat.makePreferenceScreen( preferenceScreen = screen builder(parent) return screen -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/BiListing.kt b/app/src/main/java/com/keylesspalace/tusky/util/BiListing.kt deleted file mode 100644 index dad6d552d..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/util/BiListing.kt +++ /dev/null @@ -1,38 +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 BiListing( - // the LiveData of paged lists for the UI to observe - val pagedList: LiveData>, - // represents the network request status for load data before first to show to the user - val networkStateBefore: LiveData, - // represents the network request status for load data after last to show to the user - val networkStateAfter: LiveData, - // represents the refresh status to show to the user. Separate from networkState, this - // value is importantly only when refresh is requested. - val refreshState: LiveData, - // refreshes the whole data and fetches it from scratch. - val refresh: () -> Unit, - // retries any failed requests. - val retry: () -> Unit) \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/util/BindingHolder.kt b/app/src/main/java/com/keylesspalace/tusky/util/BindingHolder.kt index a7a4c9720..62167ee6a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/BindingHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/BindingHolder.kt @@ -4,5 +4,5 @@ import androidx.recyclerview.widget.RecyclerView import androidx.viewbinding.ViewBinding class BindingHolder( - val binding: T + val binding: T ) : RecyclerView.ViewHolder(binding.root) diff --git a/app/src/main/java/com/keylesspalace/tusky/util/BlurHashDecoder.kt b/app/src/main/java/com/keylesspalace/tusky/util/BlurHashDecoder.kt index bd5f9007c..117f59c09 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/BlurHashDecoder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/BlurHashDecoder.kt @@ -74,18 +74,20 @@ object BlurHashDecoder { val g = (value / 19) % 19 val b = value % 19 return floatArrayOf( - signedPow2((r - 9) / 9.0f) * maxAc, - signedPow2((g - 9) / 9.0f) * maxAc, - signedPow2((b - 9) / 9.0f) * maxAc + signedPow2((r - 9) / 9.0f) * maxAc, + signedPow2((g - 9) / 9.0f) * maxAc, + signedPow2((b - 9) / 9.0f) * maxAc ) } private fun signedPow2(value: Float) = value.pow(2f).withSign(value) private fun composeBitmap( - width: Int, height: Int, - numCompX: Int, numCompY: Int, - colors: Array + width: Int, + height: Int, + numCompX: Int, + numCompY: Int, + colors: Array ): Bitmap { val imageArray = IntArray(width * height) for (y in 0 until height) { @@ -118,13 +120,12 @@ object BlurHashDecoder { } private val charMap = listOf( - '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', 'G', - 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', - 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', - 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '#', '$', '%', '*', '+', ',', - '-', '.', ':', ';', '=', '?', '@', '[', ']', '^', '_', '{', '|', '}', '~' + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', 'G', + 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', + 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', + 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '#', '$', '%', '*', '+', ',', + '-', '.', ':', ';', '=', '?', '@', '[', ']', '^', '_', '{', '|', '}', '~' ) - .mapIndexed { i, c -> c to i } - .toMap() - + .mapIndexed { i, c -> c to i } + .toMap() } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/CardViewMode.kt b/app/src/main/java/com/keylesspalace/tusky/util/CardViewMode.kt index 2cf2348cd..81c2216b0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/CardViewMode.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/CardViewMode.kt @@ -4,4 +4,4 @@ enum class CardViewMode { NONE, FULL_WIDTH, INDENTED -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ClickableSpanNoUnderline.kt b/app/src/main/java/com/keylesspalace/tusky/util/ClickableSpanNoUnderline.kt deleted file mode 100644 index a9e7ba89a..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/util/ClickableSpanNoUnderline.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.keylesspalace.tusky.util - -import android.text.TextPaint -import android.text.style.ClickableSpan - -abstract class ClickableSpanNoUnderline : ClickableSpan() { - override fun updateDrawState(ds: TextPaint) { - super.updateDrawState(ds) - ds.isUnderlineText = false - } -} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ComposeTokenizer.kt b/app/src/main/java/com/keylesspalace/tusky/util/ComposeTokenizer.kt index c0da4275e..6fee42edf 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ComposeTokenizer.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ComposeTokenizer.kt @@ -22,10 +22,10 @@ import android.widget.MultiAutoCompleteTextView class ComposeTokenizer : MultiAutoCompleteTextView.Tokenizer { - private fun isMentionOrHashtagAllowedCharacter(character: Char) : Boolean { - return Character.isLetterOrDigit(character) || character == '_' // simple usernames - || character == '-' // extended usernames - || character == '.' // domain dot + private fun isMentionOrHashtagAllowedCharacter(character: Char): Boolean { + return Character.isLetterOrDigit(character) || character == '_' || // simple usernames + character == '-' || // extended usernames + character == '.' // domain dot } override fun findTokenStart(text: CharSequence, cursor: Int): Int { @@ -36,8 +36,8 @@ class ComposeTokenizer : MultiAutoCompleteTextView.Tokenizer { var character = text[i - 1] // go up to first illegal character or character we're looking for (@, # or :) - while(i > 0 && !(character == '@' || character == '#' || character == ':')) { - if(!isMentionOrHashtagAllowedCharacter(character)) { + while (i > 0 && !(character == '@' || character == '#' || character == ':')) { + if (!isMentionOrHashtagAllowedCharacter(character)) { return cursor } @@ -46,13 +46,13 @@ class ComposeTokenizer : MultiAutoCompleteTextView.Tokenizer { } // maybe caught domain name? try search username - if(i > 2 && character == '@') { + if (i > 2 && character == '@') { var j = i - 1 var character2 = text[i - 2] // again go up to first illegal character or tag "@" - while(j > 0 && character2 != '@') { - if(!isMentionOrHashtagAllowedCharacter(character2)) { + while (j > 0 && character2 != '@') { + if (!isMentionOrHashtagAllowedCharacter(character2)) { break } @@ -61,15 +61,16 @@ class ComposeTokenizer : MultiAutoCompleteTextView.Tokenizer { } // found mention symbol, override cursor - if(character2 == '@') { + if (character2 == '@') { i = j character = character2 } } - if (i < 1 - || (character != '@' && character != '#' && character != ':') - || i > 1 && !Character.isWhitespace(text[i - 2])) { + if (i < 1 || + (character != '@' && character != '#' && character != ':') || + i > 1 && !Character.isWhitespace(text[i - 2]) + ) { return cursor } return i - 1 diff --git a/app/src/main/java/com/keylesspalace/tusky/util/CustomEmojiHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/CustomEmojiHelper.kt index 7521afe40..2aee7384b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/CustomEmojiHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/CustomEmojiHelper.kt @@ -18,21 +18,18 @@ package com.keylesspalace.tusky.util import android.graphics.Canvas import android.graphics.Paint -import android.graphics.drawable.* +import android.graphics.drawable.Animatable +import android.graphics.drawable.Drawable import android.text.SpannableStringBuilder import android.text.style.ReplacementSpan import android.view.View - import com.bumptech.glide.Glide import com.bumptech.glide.request.target.CustomTarget import com.bumptech.glide.request.target.Target import com.bumptech.glide.request.transition.Transition import com.keylesspalace.tusky.entity.Emoji - import java.lang.ref.WeakReference import java.util.regex.Pattern -import androidx.preference.PreferenceManager -import com.keylesspalace.tusky.settings.PrefKeys /** * replaces emoji shortcodes in a text with EmojiSpans @@ -41,8 +38,8 @@ import com.keylesspalace.tusky.settings.PrefKeys * @param view a reference to the a view the emojis will be shown in (should be the TextView, but parents of the TextView are also acceptable) * @return the text with the shortcodes replaced by EmojiSpans */ -fun CharSequence.emojify(emojis: List?, view: View, animate: Boolean) : CharSequence { - if(emojis.isNullOrEmpty()) +fun CharSequence.emojify(emojis: List?, view: View, animate: Boolean): CharSequence { + if (emojis.isNullOrEmpty()) return this val builder = SpannableStringBuilder.valueOf(this) @@ -51,7 +48,7 @@ fun CharSequence.emojify(emojis: List?, view: View, animate: Boolean) : C val matcher = Pattern.compile(":$shortcode:", Pattern.LITERAL) .matcher(this) - while(matcher.find()) { + while (matcher.find()) { val span = EmojiSpan(WeakReference(view)) builder.setSpan(span, matcher.start(), matcher.end(), 0) @@ -66,8 +63,8 @@ fun CharSequence.emojify(emojis: List?, view: View, animate: Boolean) : C class EmojiSpan(val viewWeakReference: WeakReference) : ReplacementSpan() { var imageDrawable: Drawable? = null - - override fun getSize(paint: Paint, text: CharSequence, start: Int, end: Int, fm: Paint.FontMetricsInt?) : Int { + + override fun getSize(paint: Paint, text: CharSequence, start: Int, end: Int, fm: Paint.FontMetricsInt?): Int { if (fm != null) { /* update FontMetricsInt or otherwise span does not get drawn when * it covers the whole text */ @@ -77,10 +74,10 @@ class EmojiSpan(val viewWeakReference: WeakReference) : ReplacementSpan() fm.descent = metrics.descent fm.bottom = metrics.bottom } - + return (paint.textSize * 1.2).toInt() } - + override fun draw(canvas: Canvas, text: CharSequence, start: Int, end: Int, x: Float, top: Int, y: Int, bottom: Int, paint: Paint) { imageDrawable?.let { drawable -> canvas.save() @@ -96,15 +93,15 @@ class EmojiSpan(val viewWeakReference: WeakReference) : ReplacementSpan() canvas.restore() } } - - fun getTarget(animate : Boolean): Target { + + fun getTarget(animate: Boolean): Target { return object : CustomTarget() { override fun onResourceReady(resource: Drawable, transition: Transition?) { viewWeakReference.get()?.let { view -> - if(animate && resource is Animatable) { + if (animate && resource is Animatable) { val callback = resource.callback - resource.callback = object: Drawable.Callback { + resource.callback = object : Drawable.Callback { override fun unscheduleDrawable(p0: Drawable, p1: Runnable) { callback?.unscheduleDrawable(p0, p1) } @@ -123,7 +120,7 @@ class EmojiSpan(val viewWeakReference: WeakReference) : ReplacementSpan() view.invalidate() } } - + override fun onLoadCleared(placeholder: Drawable?) {} } } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/CustomFragmentStateAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/util/CustomFragmentStateAdapter.kt index bda206144..eb31032f5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/CustomFragmentStateAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/CustomFragmentStateAdapter.kt @@ -20,9 +20,9 @@ import androidx.fragment.app.FragmentActivity import androidx.viewpager2.adapter.FragmentStateAdapter abstract class CustomFragmentStateAdapter( - private val activity: FragmentActivity -): FragmentStateAdapter(activity) { + private val activity: FragmentActivity +) : FragmentStateAdapter(activity) { - fun getFragment(position: Int): Fragment? - = activity.supportFragmentManager.findFragmentByTag("f" + getItemId(position)) + fun getFragment(position: Int): Fragment? = + activity.supportFragmentManager.findFragmentByTag("f" + getItemId(position)) } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/CustomURLSpan.java b/app/src/main/java/com/keylesspalace/tusky/util/CustomURLSpan.java deleted file mode 100644 index e772162e5..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/util/CustomURLSpan.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.keylesspalace.tusky.util; - -import android.os.Parcel; -import android.os.Parcelable; -import android.text.TextPaint; -import android.text.style.URLSpan; -import android.view.View; - -public class CustomURLSpan extends URLSpan { - public CustomURLSpan(String url) { - super(url); - } - - private CustomURLSpan(Parcel src) { - super(src); - } - - public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { - - @Override - public CustomURLSpan createFromParcel(Parcel source) { - return new CustomURLSpan(source); - } - - @Override - public CustomURLSpan[] newArray(int size) { - return new CustomURLSpan[size]; - } - - }; - - @Override - public void onClick(View view) { - LinkHelper.openLink(getURL(), view.getContext()); - } - - @Override public void updateDrawState(TextPaint ds) { - super.updateDrawState(ds); - ds.setUnderlineText(false); - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/Either.kt b/app/src/main/java/com/keylesspalace/tusky/util/Either.kt index f0955cfa8..728ccd0e0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/Either.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/Either.kt @@ -44,4 +44,4 @@ sealed class Either { Right(mapper(this.asRight())) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/EmojiCompatFont.kt b/app/src/main/java/com/keylesspalace/tusky/util/EmojiCompatFont.kt index d0a0e443e..f513feeef 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/EmojiCompatFont.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/EmojiCompatFont.kt @@ -8,9 +8,9 @@ import androidx.annotation.StringRes import androidx.annotation.VisibleForTesting import com.keylesspalace.tusky.R import de.c1710.filemojicompat.FileEmojiCompatConfig -import io.reactivex.Observable -import io.reactivex.ObservableEmitter -import io.reactivex.schedulers.Schedulers +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.core.ObservableEmitter +import io.reactivex.rxjava3.schedulers.Schedulers import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response @@ -29,13 +29,14 @@ import kotlin.math.max * This class bundles information about an emoji font as well as many convenient actions. */ class EmojiCompatFont( - val name: String, - private val display: String, - @StringRes val caption: Int, - @DrawableRes val img: Int, - val url: String, - // The version is stored as a String in the x.xx.xx format (to be able to compare versions) - val version: String) { + val name: String, + private val display: String, + @StringRes val caption: Int, + @DrawableRes val img: Int, + val url: String, + // The version is stored as a String in the x.xx.xx format (to be able to compare versions) + val version: String +) { private val versionCode = getVersionCode(version) @@ -102,8 +103,13 @@ class EmojiCompatFont( if (compareVersions(fileExists.second, versionCode) < 0) { val file = fileExists.first // Uses side effects! - Log.d(TAG, String.format("Deleted %s successfully: %s", file.absolutePath, - file.delete())) + Log.d( + TAG, + String.format( + "Deleted %s successfully: %s", file.absolutePath, + file.delete() + ) + ) } } } @@ -131,8 +137,13 @@ class EmojiCompatFont( val fontRegex = "$name(\\d+(\\.\\d+)*)?\\.ttf".toPattern() val ttfFilter = FilenameFilter { _, name: String -> name.endsWith(".ttf") } val foundFontFiles = directory.listFiles(ttfFilter).orEmpty() - Log.d(TAG, String.format("loadExistingFontFiles: %d other font files found", - foundFontFiles.size)) + Log.d( + TAG, + String.format( + "loadExistingFontFiles: %d other font files found", + foundFontFiles.size + ) + ) return foundFontFiles.map { file -> val matcher = fontRegex.matcher(file.name) @@ -170,8 +181,10 @@ class EmojiCompatFont( } } - fun downloadFontFile(context: Context, - okHttpClient: OkHttpClient): Observable { + fun downloadFontFile( + context: Context, + okHttpClient: OkHttpClient + ): Observable { return Observable.create { emitter: ObservableEmitter -> // It is possible (and very likely) that the file does not exist yet val downloadFile = getFontFile(context)!! @@ -180,7 +193,7 @@ class EmojiCompatFont( downloadFile.createNewFile() } val request = Request.Builder().url(url) - .build() + .build() val sink = downloadFile.sink().buffer() var source: Source? = null @@ -197,7 +210,7 @@ class EmojiCompatFont( while (!emitter.isDisposed) { sink.write(source, CHUNK_SIZE) progress += CHUNK_SIZE.toFloat() - if(size > 0) { + if (size > 0) { emitter.onNext(progress / size) } else { emitter.onNext(-1f) @@ -213,7 +226,6 @@ class EmojiCompatFont( Log.e(TAG, "Downloading $url failed. Status code: ${response.code}") emitter.tryOnError(Exception()) } - } catch (ex: IOException) { Log.e(TAG, "Downloading $url failed.", ex) downloadFile.deleteIfExists() @@ -228,10 +240,8 @@ class EmojiCompatFont( emitter.onComplete() } } - } - .subscribeOn(Schedulers.io()) - + .subscribeOn(Schedulers.io()) } /** @@ -256,32 +266,37 @@ class EmojiCompatFont( private const val CHUNK_SIZE = 4096L // The system font gets some special behavior... - val SYSTEM_DEFAULT = EmojiCompatFont("system-default", - "System Default", - R.string.caption_systememoji, - R.drawable.ic_emoji_34dp, - "", - "0") - val BLOBMOJI = EmojiCompatFont("Blobmoji", - "Blobmoji", - R.string.caption_blobmoji, - R.drawable.ic_blobmoji, - "https://tusky.app/hosted/emoji/BlobmojiCompat.ttf", - "12.0.0" + val SYSTEM_DEFAULT = EmojiCompatFont( + "system-default", + "System Default", + R.string.caption_systememoji, + R.drawable.ic_emoji_34dp, + "", + "0" ) - val TWEMOJI = EmojiCompatFont("Twemoji", - "Twemoji", - R.string.caption_twemoji, - R.drawable.ic_twemoji, - "https://tusky.app/hosted/emoji/TwemojiCompat.ttf", - "12.0.0" + val BLOBMOJI = EmojiCompatFont( + "Blobmoji", + "Blobmoji", + R.string.caption_blobmoji, + R.drawable.ic_blobmoji, + "https://tusky.app/hosted/emoji/BlobmojiCompat.ttf", + "12.0.0" ) - val NOTOEMOJI = EmojiCompatFont("NotoEmoji", - "Noto Emoji", - R.string.caption_notoemoji, - R.drawable.ic_notoemoji, - "https://tusky.app/hosted/emoji/NotoEmojiCompat.ttf", - "11.0.0" + val TWEMOJI = EmojiCompatFont( + "Twemoji", + "Twemoji", + R.string.caption_twemoji, + R.drawable.ic_twemoji, + "https://tusky.app/hosted/emoji/TwemojiCompat.ttf", + "12.0.0" + ) + val NOTOEMOJI = EmojiCompatFont( + "NotoEmoji", + "Noto Emoji", + R.string.caption_notoemoji, + R.drawable.ic_notoemoji, + "https://tusky.app/hosted/emoji/NotoEmojiCompat.ttf", + "11.0.0" ) /** @@ -341,11 +356,9 @@ class EmojiCompatFont( } private fun File.deleteIfExists() { - if(exists() && !delete()) { + if (exists() && !delete()) { Log.e(TAG, "Could not delete file $this") } } - } - -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/FocalPointUtil.kt b/app/src/main/java/com/keylesspalace/tusky/util/FocalPointUtil.kt index 6f2542b50..41d1034c7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/FocalPointUtil.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/FocalPointUtil.kt @@ -16,7 +16,6 @@ package com.keylesspalace.tusky.util import android.graphics.Matrix - import com.keylesspalace.tusky.entity.Attachment.Focus /** @@ -54,12 +53,14 @@ object FocalPointUtil { * * @return The matrix which correctly crops the image */ - fun updateFocalPointMatrix(viewWidth: Float, - viewHeight: Float, - imageWidth: Float, - imageHeight: Float, - focus: Focus, - mat: Matrix) { + fun updateFocalPointMatrix( + viewWidth: Float, + viewHeight: Float, + imageWidth: Float, + imageHeight: Float, + focus: Focus, + mat: Matrix + ) { // Reset the cached matrix: mat.reset() @@ -84,11 +85,15 @@ object FocalPointUtil { * * The scaling used depends on if we need a vertical of horizontal crop. */ - fun calculateScaling(viewWidth: Float, viewHeight: Float, - imageWidth: Float, imageHeight: Float): Float { + fun calculateScaling( + viewWidth: Float, + viewHeight: Float, + imageWidth: Float, + imageHeight: Float + ): Float { return if (isVerticalCrop(viewWidth, viewHeight, imageWidth, imageHeight)) { viewWidth / imageWidth - } else { // horizontal crop: + } else { // horizontal crop: viewHeight / imageHeight } } @@ -96,8 +101,12 @@ object FocalPointUtil { /** * Return true if we need a vertical crop, false for a horizontal crop. */ - fun isVerticalCrop(viewWidth: Float, viewHeight: Float, - imageWidth: Float, imageHeight: Float): Boolean { + fun isVerticalCrop( + viewWidth: Float, + viewHeight: Float, + imageWidth: Float, + imageHeight: Float + ): Boolean { val viewRatio = viewWidth / viewHeight val imageRatio = imageWidth / imageHeight @@ -135,8 +144,12 @@ object FocalPointUtil { * the image. So it won't put the very edge of the image in center, because that would * leave part of the view empty. */ - fun focalOffset(view: Float, image: Float, - scale: Float, focal: Float): Float { + fun focalOffset( + view: Float, + image: Float, + scale: Float, + focal: Float + ): Float { // The fraction of the image that will be in view: val inView = view / (scale * image) var offset = 0f diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ImageLoadingHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/ImageLoadingHelper.kt index 9daf16f80..1cd9b99ac 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ImageLoadingHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ImageLoadingHelper.kt @@ -11,41 +11,38 @@ import com.bumptech.glide.load.resource.bitmap.CenterCrop import com.bumptech.glide.load.resource.bitmap.RoundedCorners import com.keylesspalace.tusky.R - private val centerCropTransformation = CenterCrop() fun loadAvatar(url: String?, imageView: ImageView, @Px radius: Int, animate: Boolean) { if (url.isNullOrBlank()) { Glide.with(imageView) - .load(R.drawable.avatar_default) - .into(imageView) + .load(R.drawable.avatar_default) + .into(imageView) } else { if (animate) { Glide.with(imageView) - .load(url) - .transform( - centerCropTransformation, - RoundedCorners(radius) - ) - .placeholder(R.drawable.avatar_default) - .into(imageView) - + .load(url) + .transform( + centerCropTransformation, + RoundedCorners(radius) + ) + .placeholder(R.drawable.avatar_default) + .into(imageView) } else { Glide.with(imageView) - .asBitmap() - .load(url) - .transform( - centerCropTransformation, - RoundedCorners(radius) - ) - .placeholder(R.drawable.avatar_default) - .into(imageView) + .asBitmap() + .load(url) + .transform( + centerCropTransformation, + RoundedCorners(radius) + ) + .placeholder(R.drawable.avatar_default) + .into(imageView) } - } } fun decodeBlurHash(context: Context, blurhash: String): BitmapDrawable { return BitmapDrawable(context.resources, BlurHashDecoder.decode(blurhash, 32, 32, 1f)) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.java b/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.java index fab45d0d8..b262f1aea 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.java +++ b/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.java @@ -40,6 +40,7 @@ import com.keylesspalace.tusky.interfaces.LinkListener; import java.net.URI; import java.net.URISyntaxException; +import java.util.List; public class LinkHelper { public static String getDomain(String urlString) { @@ -69,7 +70,7 @@ public class LinkHelper { * @param listener to notify about particular spans that are clicked */ public static void setClickableText(TextView view, CharSequence content, - @Nullable Status.Mention[] mentions, final LinkListener listener) { + @Nullable List mentions, final LinkListener listener) { SpannableStringBuilder builder = SpannableStringBuilder.valueOf(content); URLSpan[] urlSpans = builder.getSpans(0, content.length(), URLSpan.class); for (URLSpan span : urlSpans) { @@ -81,11 +82,11 @@ public class LinkHelper { if (text.charAt(0) == '#') { final String tag = text.subSequence(1, text.length()).toString(); - customSpan = new ClickableSpanNoUnderline() { + customSpan = new NoUnderlineURLSpan(span.getURL()) { @Override public void onClick(@NonNull View widget) { listener.onViewTag(tag); } }; - } else if (text.charAt(0) == '@' && mentions != null && mentions.length > 0) { + } else if (text.charAt(0) == '@' && mentions != null && mentions.size() > 0) { String accountUsername = text.subSequence(1, text.length()).toString(); /* There may be multiple matches for users on different instances with the same * username. If a match has the same domain we know it's for sure the same, but if @@ -101,7 +102,7 @@ public class LinkHelper { } if (id != null) { final String accountId = id; - customSpan = new ClickableSpanNoUnderline() { + customSpan = new NoUnderlineURLSpan(span.getURL()) { @Override public void onClick(@NonNull View widget) { listener.onViewAccount(accountId); } }; @@ -109,9 +110,9 @@ public class LinkHelper { } if (customSpan == null) { - customSpan = new CustomURLSpan(span.getURL()) { + customSpan = new NoUnderlineURLSpan(span.getURL()) { @Override - public void onClick(View widget) { + public void onClick(@NonNull View widget) { listener.onViewUrl(getURL(), text.toString()); } }; @@ -141,8 +142,8 @@ public class LinkHelper { * @param listener to notify about particular spans that are clicked */ public static void setClickableMentions( - TextView view, @Nullable Status.Mention[] mentions, final LinkListener listener) { - if (mentions == null || mentions.length == 0) { + TextView view, @Nullable List mentions, final LinkListener listener) { + if (mentions == null || mentions.size() == 0) { view.setText(null); return; } @@ -154,7 +155,7 @@ public class LinkHelper { for (Status.Mention mention : mentions) { String accountUsername = mention.getLocalUsername(); final String accountId = mention.getId(); - ClickableSpan customSpan = new ClickableSpanNoUnderline() { + ClickableSpan customSpan = new NoUnderlineURLSpan(mention.getUrl()) { @Override public void onClick(@NonNull View widget) { listener.onViewAccount(accountId); } }; @@ -180,7 +181,7 @@ public class LinkHelper { } public static CharSequence createClickableText(String text, String link) { - URLSpan span = new CustomURLSpan(link); + URLSpan span = new NoUnderlineURLSpan(link); SpannableStringBuilder clickableText = new SpannableStringBuilder(text); clickableText.setSpan(span, 0, text.length(), Spanned.SPAN_INCLUSIVE_EXCLUSIVE); diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt b/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt index 859162da3..879fccc30 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt @@ -27,20 +27,22 @@ fun interface StatusProvider { } class ListStatusAccessibilityDelegate( - private val recyclerView: RecyclerView, - private val statusActionListener: StatusActionListener, - private val statusProvider: StatusProvider + private val recyclerView: RecyclerView, + private val statusActionListener: StatusActionListener, + private val statusProvider: StatusProvider ) : RecyclerViewAccessibilityDelegate(recyclerView) { private val a11yManager = context.getSystemService(Context.ACCESSIBILITY_SERVICE) - as AccessibilityManager + as AccessibilityManager override fun getItemDelegate(): AccessibilityDelegateCompat = itemDelegate private val context: Context get() = recyclerView.context private val itemDelegate = object : RecyclerViewAccessibilityDelegate.ItemDelegate(this) { - override fun onInitializeAccessibilityNodeInfo(host: View, - info: AccessibilityNodeInfoCompat) { + override fun onInitializeAccessibilityNodeInfo( + host: View, + info: AccessibilityNodeInfoCompat + ) { super.onInitializeAccessibilityNodeInfo(host, info) val pos = recyclerView.getChildAdapterPosition(host) @@ -52,44 +54,51 @@ class ListStatusAccessibilityDelegate( info.addAction(replyAction) - if (status.rebloggingEnabled) { - info.addAction(if (status.isReblogged) unreblogAction else reblogAction) + val actionable = status.actionable + if (actionable.rebloggingAllowed()) { + info.addAction(if (actionable.reblogged) unreblogAction else reblogAction) } - info.addAction(if (status.isFavourited) unfavouriteAction else favouriteAction) - info.addAction(if (status.isBookmarked) unbookmarkAction else bookmarkAction) + info.addAction(if (actionable.favourited) unfavouriteAction else favouriteAction) + info.addAction(if (actionable.bookmarked) unbookmarkAction else bookmarkAction) val mediaActions = intArrayOf( - R.id.action_open_media_1, - R.id.action_open_media_2, - R.id.action_open_media_3, - R.id.action_open_media_4) - val attachmentCount = min(status.attachments.size, MAX_MEDIA_ATTACHMENTS) + R.id.action_open_media_1, + R.id.action_open_media_2, + R.id.action_open_media_3, + R.id.action_open_media_4 + ) + val attachmentCount = min(actionable.attachments.size, MAX_MEDIA_ATTACHMENTS) for (i in 0 until attachmentCount) { - info.addAction(AccessibilityActionCompat( + info.addAction( + AccessibilityActionCompat( mediaActions[i], - context.getString(R.string.action_open_media_n, i + 1))) + context.getString(R.string.action_open_media_n, i + 1) + ) + ) } info.addAction(openProfileAction) if (getLinks(status).any()) info.addAction(linksAction) - val mentions = status.mentions - if (mentions != null && mentions.isNotEmpty()) info.addAction(mentionsAction) + val mentions = actionable.mentions + if (mentions.isNotEmpty()) info.addAction(mentionsAction) if (getHashtags(status).any()) info.addAction(hashtagsAction) - if (!status.rebloggedByUsername.isNullOrEmpty()) { + if (!status.status.reblog?.account?.username.isNullOrEmpty()) { info.addAction(openRebloggerAction) } - if (status.reblogsCount > 0) info.addAction(openRebloggedByAction) - if (status.favouritesCount > 0) info.addAction(openFavsAction) + if (actionable.reblogsCount > 0) info.addAction(openRebloggedByAction) + if (actionable.favouritesCount > 0) info.addAction(openFavsAction) info.addAction(moreAction) } - } - override fun performAccessibilityAction(host: View, action: Int, - args: Bundle?): Boolean { + override fun performAccessibilityAction( + host: View, + action: Int, + args: Bundle? + ): Boolean { val pos = recyclerView.getChildAdapterPosition(host) when (action) { R.id.action_reply -> { @@ -105,7 +114,8 @@ class ListStatusAccessibilityDelegate( R.id.action_open_profile -> { interrupt() statusActionListener.onViewAccount( - (statusProvider.getStatus(pos) as StatusViewData.Concrete).senderId) + (statusProvider.getStatus(pos) as StatusViewData.Concrete).actionable.account.id + ) } R.id.action_open_media_1 -> { interrupt() @@ -160,49 +170,56 @@ class ListStatusAccessibilityDelegate( return true } - private fun showLinksDialog(host: View) { val status = getStatus(host) as? StatusViewData.Concrete ?: return val links = getLinks(status).toList() val textLinks = links.map { item -> item.link } AlertDialog.Builder(host.context) - .setTitle(R.string.title_links_dialog) - .setAdapter(ArrayAdapter( - host.context, - android.R.layout.simple_list_item_1, - textLinks) - ) { _, which -> LinkHelper.openLink(links[which].link, host.context) } - .show() - .let { forceFocus(it.listView) } + .setTitle(R.string.title_links_dialog) + .setAdapter( + ArrayAdapter( + host.context, + android.R.layout.simple_list_item_1, + textLinks + ) + ) { _, which -> LinkHelper.openLink(links[which].link, host.context) } + .show() + .let { forceFocus(it.listView) } } private fun showMentionsDialog(host: View) { val status = getStatus(host) as? StatusViewData.Concrete ?: return - val mentions = status.mentions ?: return + val mentions = status.actionable.mentions val stringMentions = mentions.map { it.username } AlertDialog.Builder(host.context) - .setTitle(R.string.title_mentions_dialog) - .setAdapter(ArrayAdapter(host.context, - android.R.layout.simple_list_item_1, stringMentions) - ) { _, which -> - statusActionListener.onViewAccount(mentions[which].id) - } - .show() - .let { forceFocus(it.listView) } + .setTitle(R.string.title_mentions_dialog) + .setAdapter( + ArrayAdapter( + host.context, + android.R.layout.simple_list_item_1, stringMentions + ) + ) { _, which -> + statusActionListener.onViewAccount(mentions[which].id) + } + .show() + .let { forceFocus(it.listView) } } private fun showHashtagsDialog(host: View) { val status = getStatus(host) as? StatusViewData.Concrete ?: return val tags = getHashtags(status).map { it.subSequence(1, it.length) }.toList() AlertDialog.Builder(host.context) - .setTitle(R.string.title_hashtags_dialog) - .setAdapter(ArrayAdapter(host.context, - android.R.layout.simple_list_item_1, tags) - ) { _, which -> - statusActionListener.onViewTag(tags[which].toString()) - } - .show() - .let { forceFocus(it.listView) } + .setTitle(R.string.title_hashtags_dialog) + .setAdapter( + ArrayAdapter( + host.context, + android.R.layout.simple_list_item_1, tags + ) + ) { _, which -> + statusActionListener.onViewTag(tags[which].toString()) + } + .show() + .let { forceFocus(it.listView) } } private fun getStatus(childView: View): StatusViewData { @@ -210,19 +227,19 @@ class ListStatusAccessibilityDelegate( } } - private fun getLinks(status: StatusViewData.Concrete): Sequence { val content = status.content return if (content is Spannable) { content.getSpans(0, content.length, URLSpan::class.java) - .asSequence() - .map { span -> - val text = content.subSequence( - content.getSpanStart(span), - content.getSpanEnd(span)) - if (isHashtag(text)) null else LinkSpanInfo(text.toString(), span.url) - } - .filterNotNull() + .asSequence() + .map { span -> + val text = content.subSequence( + content.getSpanStart(span), + content.getSpanEnd(span) + ) + if (isHashtag(text)) null else LinkSpanInfo(text.toString(), span.url) + } + .filterNotNull() } else { emptySequence() } @@ -231,11 +248,11 @@ class ListStatusAccessibilityDelegate( private fun getHashtags(status: StatusViewData.Concrete): Sequence { val content = status.content return content.getSpans(0, content.length, Object::class.java) - .asSequence() - .map { span -> - content.subSequence(content.getSpanStart(span), content.getSpanEnd(span)) - } - .filter(this::isHashtag) + .asSequence() + .map { span -> + content.subSequence(content.getSpanStart(span), content.getSpanEnd(span)) + } + .filter(this::isHashtag) } private fun forceFocus(host: View) { @@ -249,77 +266,92 @@ class ListStatusAccessibilityDelegate( a11yManager.interrupt() } - private fun isHashtag(text: CharSequence) = text.startsWith("#") private val collapseCwAction = AccessibilityActionCompat( - R.id.action_collapse_cw, - context.getString(R.string.status_content_warning_show_less)) + R.id.action_collapse_cw, + context.getString(R.string.status_content_warning_show_less) + ) private val expandCwAction = AccessibilityActionCompat( - R.id.action_expand_cw, - context.getString(R.string.status_content_warning_show_more)) + R.id.action_expand_cw, + context.getString(R.string.status_content_warning_show_more) + ) private val replyAction = AccessibilityActionCompat( - R.id.action_reply, - context.getString(R.string.action_reply)) + R.id.action_reply, + context.getString(R.string.action_reply) + ) private val unreblogAction = AccessibilityActionCompat( - R.id.action_unreblog, - context.getString(R.string.action_unreblog)) + R.id.action_unreblog, + context.getString(R.string.action_unreblog) + ) private val reblogAction = AccessibilityActionCompat( - R.id.action_reblog, - context.getString(R.string.action_reblog)) + R.id.action_reblog, + context.getString(R.string.action_reblog) + ) private val unfavouriteAction = AccessibilityActionCompat( - R.id.action_unfavourite, - context.getString(R.string.action_unfavourite)) + R.id.action_unfavourite, + context.getString(R.string.action_unfavourite) + ) private val favouriteAction = AccessibilityActionCompat( - R.id.action_favourite, - context.getString(R.string.action_favourite)) + R.id.action_favourite, + context.getString(R.string.action_favourite) + ) private val bookmarkAction = AccessibilityActionCompat( - R.id.action_bookmark, - context.getString(R.string.action_bookmark)) + R.id.action_bookmark, + context.getString(R.string.action_bookmark) + ) private val unbookmarkAction = AccessibilityActionCompat( - R.id.action_unbookmark, - context.getString(R.string.action_bookmark)) + R.id.action_unbookmark, + context.getString(R.string.action_bookmark) + ) private val openProfileAction = AccessibilityActionCompat( - R.id.action_open_profile, - context.getString(R.string.action_view_profile)) + R.id.action_open_profile, + context.getString(R.string.action_view_profile) + ) private val linksAction = AccessibilityActionCompat( - R.id.action_links, - context.getString(R.string.action_links)) + R.id.action_links, + context.getString(R.string.action_links) + ) private val mentionsAction = AccessibilityActionCompat( - R.id.action_mentions, - context.getString(R.string.action_mentions)) + R.id.action_mentions, + context.getString(R.string.action_mentions) + ) private val hashtagsAction = AccessibilityActionCompat( - R.id.action_hashtags, - context.getString(R.string.action_hashtags)) + R.id.action_hashtags, + context.getString(R.string.action_hashtags) + ) private val openRebloggerAction = AccessibilityActionCompat( - R.id.action_open_reblogger, - context.getString(R.string.action_open_reblogger)) + R.id.action_open_reblogger, + context.getString(R.string.action_open_reblogger) + ) private val openRebloggedByAction = AccessibilityActionCompat( - R.id.action_open_reblogged_by, - context.getString(R.string.action_open_reblogged_by)) + R.id.action_open_reblogged_by, + context.getString(R.string.action_open_reblogged_by) + ) private val openFavsAction = AccessibilityActionCompat( - R.id.action_open_faved_by, - context.getString(R.string.action_open_faved_by)) + R.id.action_open_faved_by, + context.getString(R.string.action_open_faved_by) + ) private val moreAction = AccessibilityActionCompat( - R.id.action_more, - context.getString(R.string.action_more) + R.id.action_more, + context.getString(R.string.action_more) ) private data class LinkSpanInfo(val text: String, val link: String) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ListUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/ListUtils.kt index 8a5223ce1..7cdc12e83 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ListUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ListUtils.kt @@ -17,9 +17,8 @@ package com.keylesspalace.tusky.util -import java.util.LinkedHashSet import java.util.ArrayList - +import java.util.LinkedHashSet /** * @return true if list is null or else return list.isEmpty() @@ -52,4 +51,8 @@ inline fun List.replacedFirstWhich(replacement: T, predicate: (T) -> Bool newList[index] = replacement } return newList -} \ No newline at end of file +} + +inline fun Iterable<*>.firstIsInstanceOrNull(): R? { + return firstOrNull { it is R }?.let { it as R } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/Listing.kt b/app/src/main/java/com/keylesspalace/tusky/util/Listing.kt deleted file mode 100644 index 3d4234c59..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/util/Listing.kt +++ /dev/null @@ -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( - // the LiveData of paged lists for the UI to observe - val pagedList: LiveData>, - // represents the network request status to show to the user - val networkState: LiveData, - // represents the refresh status to show to the user. Separate from networkState, this - // value is importantly only when refresh is requested. - val refreshState: LiveData, - // refreshes the whole data and fetches it from scratch. - val refresh: () -> Unit, - // retries any failed requests. - val retry: () -> Unit) \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/util/LiveDataUtil.kt b/app/src/main/java/com/keylesspalace/tusky/util/LiveDataUtil.kt index b0048aefb..21c4307c6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/LiveDataUtil.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/LiveDataUtil.kt @@ -15,16 +15,21 @@ package com.keylesspalace.tusky.util -import androidx.lifecycle.* -import io.reactivex.BackpressureStrategy -import io.reactivex.Observable -import io.reactivex.Single +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LiveData +import androidx.lifecycle.LiveDataReactiveStreams +import androidx.lifecycle.MediatorLiveData +import androidx.lifecycle.Observer +import androidx.lifecycle.Transformations +import io.reactivex.rxjava3.core.BackpressureStrategy +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.core.Single inline fun LiveData.map(crossinline mapFunction: (X) -> Y): LiveData = - Transformations.map(this) { input -> mapFunction(input) } + Transformations.map(this) { input -> mapFunction(input) } inline fun LiveData.switchMap( - crossinline switchMapFunction: (X) -> LiveData + crossinline switchMapFunction: (X) -> LiveData ): LiveData = Transformations.switchMap(this) { input -> switchMapFunction(input) } inline fun LiveData.filter(crossinline predicate: (X) -> Boolean): LiveData { @@ -38,17 +43,17 @@ inline fun LiveData.filter(crossinline predicate: (X) -> Boolean): LiveDa } fun LifecycleOwner.withLifecycleContext(body: LifecycleContext.() -> Unit) = - LifecycleContext(this).apply(body) + LifecycleContext(this).apply(body) class LifecycleContext(val lifecycleOwner: LifecycleOwner) { inline fun LiveData.observe(crossinline observer: (T) -> Unit) = - this.observe(lifecycleOwner, Observer { observer(it) }) + this.observe(lifecycleOwner, Observer { observer(it) }) /** * Just hold a subscription, */ fun LiveData.subscribe() = - this.observe(lifecycleOwner, Observer { }) + this.observe(lifecycleOwner, Observer { }) } /** @@ -89,5 +94,5 @@ fun combineOptionalLiveData(a: LiveData, b: LiveData, combiner: fun Single.toLiveData() = LiveDataReactiveStreams.fromPublisher(this.toFlowable()) fun Observable.toLiveData( - backpressureStrategy: BackpressureStrategy = BackpressureStrategy.LATEST -) = LiveDataReactiveStreams.fromPublisher(this.toFlowable(BackpressureStrategy.LATEST)) \ No newline at end of file + backpressureStrategy: BackpressureStrategy = BackpressureStrategy.LATEST +) = LiveDataReactiveStreams.fromPublisher(this.toFlowable(BackpressureStrategy.LATEST)) diff --git a/app/src/main/java/com/keylesspalace/tusky/util/LocaleManager.kt b/app/src/main/java/com/keylesspalace/tusky/util/LocaleManager.kt index 4a80bca20..45f3ab371 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/LocaleManager.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/LocaleManager.kt @@ -19,7 +19,7 @@ import android.content.Context import android.content.SharedPreferences import android.content.res.Configuration import androidx.preference.PreferenceManager -import java.util.* +import java.util.Locale class LocaleManager(context: Context) { diff --git a/app/src/main/java/com/keylesspalace/tusky/util/MediaUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/MediaUtils.kt index 43f05e9cb..5482b292b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/MediaUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/MediaUtils.kt @@ -22,11 +22,13 @@ import android.graphics.BitmapFactory import android.graphics.Matrix import android.net.Uri import android.provider.OpenableColumns +import android.util.Log import androidx.annotation.Px import androidx.exifinterface.media.ExifInterface -import android.util.Log -import java.io.* - +import java.io.File +import java.io.FileNotFoundException +import java.io.IOException +import java.io.InputStream import java.text.SimpleDateFormat import java.util.Calendar import java.util.Date @@ -46,7 +48,7 @@ const val MEDIA_SIZE_UNKNOWN = -1L * @return the size of the media in bytes or {@link MediaUtils#MEDIA_SIZE_UNKNOWN} */ fun getMediaSize(contentResolver: ContentResolver, uri: Uri?): Long { - if(uri == null) { + if (uri == null) { return MEDIA_SIZE_UNKNOWN } @@ -165,8 +167,10 @@ fun reorientBitmap(bitmap: Bitmap?, orientation: Int): Bitmap? { } return try { - val result = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, - bitmap.height, matrix, true) + val result = Bitmap.createBitmap( + bitmap, 0, 0, bitmap.width, + bitmap.height, matrix, true + ) if (!bitmap.sameAs(result)) { bitmap.recycle() } @@ -210,7 +214,7 @@ fun deleteStaleCachedMedia(mediaDirectory: File?) { twentyfourHoursAgo.add(Calendar.HOUR, -24) val unixTime = twentyfourHoursAgo.timeInMillis - val files = mediaDirectory.listFiles{ file -> unixTime > file.lastModified() && file.name.contains(MEDIA_TEMP_PREFIX) } + val files = mediaDirectory.listFiles { file -> unixTime > file.lastModified() && file.name.contains(MEDIA_TEMP_PREFIX) } if (files == null || files.isEmpty()) { // Nothing to do return diff --git a/app/src/main/java/com/keylesspalace/tusky/util/NetworkState.kt b/app/src/main/java/com/keylesspalace/tusky/util/NetworkState.kt index 09a00339a..c7eea3a65 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/NetworkState.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/NetworkState.kt @@ -24,11 +24,12 @@ enum class Status { @Suppress("DataClassPrivateConstructor") data class NetworkState private constructor( - val status: Status, - val msg: String? = null) { + val status: Status, + val msg: String? = null +) { companion object { val LOADED = NetworkState(Status.SUCCESS) val LOADING = NetworkState(Status.RUNNING) fun error(msg: String?) = NetworkState(Status.FAILED, msg) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/db/TootDao.java b/app/src/main/java/com/keylesspalace/tusky/util/NoUnderlineURLSpan.kt similarity index 50% rename from app/src/main/java/com/keylesspalace/tusky/db/TootDao.java rename to app/src/main/java/com/keylesspalace/tusky/util/NoUnderlineURLSpan.kt index f46c2753a..a9b56b894 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/TootDao.java +++ b/app/src/main/java/com/keylesspalace/tusky/util/NoUnderlineURLSpan.kt @@ -1,4 +1,4 @@ -/* Copyright 2017 Andrew Dawson +/* Copyright 2021 Tusky Contributors * * This file is a part of Tusky. * @@ -13,33 +13,22 @@ * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ -package com.keylesspalace.tusky.db; +package com.keylesspalace.tusky.util -import androidx.room.Dao; -import androidx.room.Query; +import android.text.TextPaint +import android.text.style.URLSpan +import android.view.View -import java.util.List; +open class NoUnderlineURLSpan( + url: String +) : URLSpan(url) { -import io.reactivex.Observable; + override fun updateDrawState(ds: TextPaint) { + super.updateDrawState(ds) + ds.isUnderlineText = false + } -/** - * Created by cto3543 on 28/06/2017. - * - * DAO to fetch and update toots in the DB. - */ - -@Dao -public interface TootDao { - - @Query("SELECT * FROM TootEntity ORDER BY uid DESC") - List loadAll(); - - @Query("DELETE FROM TootEntity WHERE uid = :uid") - int delete(int uid); - - @Query("SELECT * FROM TootEntity WHERE uid = :uid") - TootEntity find(int uid); - - @Query("SELECT COUNT(*) FROM TootEntity") - Observable savedTootCount(); -} \ No newline at end of file + override fun onClick(view: View) { + LinkHelper.openLink(url, view.context) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/NotificationTypeConverter.kt b/app/src/main/java/com/keylesspalace/tusky/util/NotificationTypeConverter.kt index 65c8f6c08..34e8924f8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/NotificationTypeConverter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/NotificationTypeConverter.kt @@ -42,4 +42,4 @@ fun deserialize(data: String?): Set { } } return ret -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/PagingRequestHelper.java b/app/src/main/java/com/keylesspalace/tusky/util/PagingRequestHelper.java deleted file mode 100644 index 4f7d3effb..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/util/PagingRequestHelper.java +++ /dev/null @@ -1,491 +0,0 @@ -/* - * Copyright 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.annotation.AnyThread; -import androidx.annotation.GuardedBy; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.VisibleForTesting; -import java.util.Arrays; -import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.Executor; -import java.util.concurrent.atomic.AtomicBoolean; -/** - * A helper class for {@link androidx.paging.PagedList.BoundaryCallback BoundaryCallback}s and - * {@link androidx.paging.DataSource}s to help with tracking network requests. - *

- * It is designed to support 3 types of requests, {@link RequestType#INITIAL INITIAL}, - * {@link RequestType#BEFORE BEFORE} and {@link RequestType#AFTER AFTER} and runs only 1 request - * for each of them via {@link #runIfNotRunning(RequestType, Request)}. - *

- * It tracks a {@link Status} and an {@code error} for each {@link RequestType}. - *

- * A sample usage of this class to limit requests looks like this: - *

- * class PagingBoundaryCallback extends PagedList.BoundaryCallback<MyItem> {
- *     // TODO replace with an executor from your application
- *     Executor executor = Executors.newSingleThreadExecutor();
- *     PagingRequestHelper helper = new PagingRequestHelper(executor);
- *     // imaginary API service, using Retrofit
- *     MyApi api;
- *
- *     {@literal @}Override
- *     public void onItemAtFrontLoaded({@literal @}NonNull MyItem itemAtFront) {
- *         helper.runIfNotRunning(PagingRequestHelper.RequestType.BEFORE,
- *                 helperCallback -> api.getTopBefore(itemAtFront.getName(), 10).enqueue(
- *                         new Callback<ApiResponse>() {
- *                             {@literal @}Override
- *                             public void onResponse(Call<ApiResponse> call,
- *                                     Response<ApiResponse> response) {
- *                                 // TODO insert new records into database
- *                                 helperCallback.recordSuccess();
- *                             }
- *
- *                             {@literal @}Override
- *                             public void onFailure(Call<ApiResponse> call, Throwable t) {
- *                                 helperCallback.recordFailure(t);
- *                             }
- *                         }));
- *     }
- *
- *     {@literal @}Override
- *     public void onItemAtEndLoaded({@literal @}NonNull MyItem itemAtEnd) {
- *         helper.runIfNotRunning(PagingRequestHelper.RequestType.AFTER,
- *                 helperCallback -> api.getTopBefore(itemAtEnd.getName(), 10).enqueue(
- *                         new Callback<ApiResponse>() {
- *                             {@literal @}Override
- *                             public void onResponse(Call<ApiResponse> call,
- *                                     Response<ApiResponse> response) {
- *                                 // TODO insert new records into database
- *                                 helperCallback.recordSuccess();
- *                             }
- *
- *                             {@literal @}Override
- *                             public void onFailure(Call<ApiResponse> call, Throwable t) {
- *                                 helperCallback.recordFailure(t);
- *                             }
- *                         }));
- *     }
- * }
- * 
- *

- * The helper provides an API to observe combined request status, which can be reported back to the - * application based on your business rules. - *

- * MutableLiveData<PagingRequestHelper.Status> combined = new MutableLiveData<>();
- * helper.addListener(status -> {
- *     // merge multiple states per request type into one, or dispatch separately depending on
- *     // your application logic.
- *     if (status.hasRunning()) {
- *         combined.postValue(PagingRequestHelper.Status.RUNNING);
- *     } else if (status.hasError()) {
- *         // can also obtain the error via {@link StatusReport#getErrorFor(RequestType)}
- *         combined.postValue(PagingRequestHelper.Status.FAILED);
- *     } else {
- *         combined.postValue(PagingRequestHelper.Status.SUCCESS);
- *     }
- * });
- * 
- */ -// THIS class is likely to be moved into the library in a future release. Feel free to copy it -// from this sample. -public class PagingRequestHelper { - private final Object mLock = new Object(); - private final Executor mRetryService; - @GuardedBy("mLock") - private final RequestQueue[] mRequestQueues = new RequestQueue[] - {new RequestQueue(RequestType.INITIAL), - new RequestQueue(RequestType.BEFORE), - new RequestQueue(RequestType.AFTER)}; - @NonNull - final CopyOnWriteArrayList mListeners = new CopyOnWriteArrayList<>(); - /** - * Creates a new PagingRequestHelper with the given {@link Executor} which is used to run - * retry actions. - * - * @param retryService The {@link Executor} that can run the retry actions. - */ - public PagingRequestHelper(@NonNull Executor retryService) { - mRetryService = retryService; - } - /** - * Adds a new listener that will be notified when any request changes {@link Status state}. - * - * @param listener The listener that will be notified each time a request's status changes. - * @return True if it is added, false otherwise (e.g. it already exists in the list). - */ - @AnyThread - public boolean addListener(@NonNull Listener listener) { - return mListeners.add(listener); - } - /** - * Removes the given listener from the listeners list. - * - * @param listener The listener that will be removed. - * @return True if the listener is removed, false otherwise (e.g. it never existed) - */ - public boolean removeListener(@NonNull Listener listener) { - return mListeners.remove(listener); - } - /** - * Runs the given {@link Request} if no other requests in the given request type is already - * running. - *

- * If run, the request will be run in the current thread. - * - * @param type The type of the request. - * @param request The request to run. - * @return True if the request is run, false otherwise. - */ - @SuppressWarnings("WeakerAccess") - @AnyThread - public boolean runIfNotRunning(@NonNull RequestType type, @NonNull Request request) { - boolean hasListeners = !mListeners.isEmpty(); - StatusReport report = null; - synchronized (mLock) { - RequestQueue queue = mRequestQueues[type.ordinal()]; - if (queue.mRunning != null) { - return false; - } - queue.mRunning = request; - queue.mStatus = Status.RUNNING; - queue.mFailed = null; - queue.mLastError = null; - if (hasListeners) { - report = prepareStatusReportLocked(); - } - } - if (report != null) { - dispatchReport(report); - } - final RequestWrapper wrapper = new RequestWrapper(request, this, type); - wrapper.run(); - return true; - } - @GuardedBy("mLock") - private StatusReport prepareStatusReportLocked() { - Throwable[] errors = new Throwable[]{ - mRequestQueues[0].mLastError, - mRequestQueues[1].mLastError, - mRequestQueues[2].mLastError - }; - return new StatusReport( - getStatusForLocked(RequestType.INITIAL), - getStatusForLocked(RequestType.BEFORE), - getStatusForLocked(RequestType.AFTER), - errors - ); - } - @GuardedBy("mLock") - private Status getStatusForLocked(RequestType type) { - return mRequestQueues[type.ordinal()].mStatus; - } - @AnyThread - @VisibleForTesting - void recordResult(@NonNull RequestWrapper wrapper, @Nullable Throwable throwable) { - StatusReport report = null; - final boolean success = throwable == null; - boolean hasListeners = !mListeners.isEmpty(); - synchronized (mLock) { - RequestQueue queue = mRequestQueues[wrapper.mType.ordinal()]; - queue.mRunning = null; - queue.mLastError = throwable; - if (success) { - queue.mFailed = null; - queue.mStatus = Status.SUCCESS; - } else { - queue.mFailed = wrapper; - queue.mStatus = Status.FAILED; - } - if (hasListeners) { - report = prepareStatusReportLocked(); - } - } - if (report != null) { - dispatchReport(report); - } - } - private void dispatchReport(StatusReport report) { - for (Listener listener : mListeners) { - listener.onStatusChange(report); - } - } - /** - * Retries all failed requests. - * - * @return True if any request is retried, false otherwise. - */ - public boolean retryAllFailed() { - final RequestWrapper[] toBeRetried = new RequestWrapper[RequestType.values().length]; - boolean retried = false; - synchronized (mLock) { - for (int i = 0; i < RequestType.values().length; i++) { - toBeRetried[i] = mRequestQueues[i].mFailed; - mRequestQueues[i].mFailed = null; - } - } - for (RequestWrapper failed : toBeRetried) { - if (failed != null) { - failed.retry(mRetryService); - retried = true; - } - } - return retried; - } - static class RequestWrapper implements Runnable { - @NonNull - final Request mRequest; - @NonNull - final PagingRequestHelper mHelper; - @NonNull - final RequestType mType; - RequestWrapper(@NonNull Request request, @NonNull PagingRequestHelper helper, - @NonNull RequestType type) { - mRequest = request; - mHelper = helper; - mType = type; - } - @Override - public void run() { - mRequest.run(new Request.Callback(this, mHelper)); - } - void retry(Executor service) { - service.execute(new Runnable() { - @Override - public void run() { - mHelper.runIfNotRunning(mType, mRequest); - } - }); - } - } - /** - * Runner class that runs a request tracked by the {@link PagingRequestHelper}. - *

- * When a request is invoked, it must call one of {@link Callback#recordFailure(Throwable)} - * or {@link Callback#recordSuccess()} once and only once. This call - * can be made any time. Until that method call is made, {@link PagingRequestHelper} will - * consider the request is running. - */ - @FunctionalInterface - public interface Request { - /** - * Should run the request and call the given {@link Callback} with the result of the - * request. - * - * @param callback The callback that should be invoked with the result. - */ - void run(Callback callback); - /** - * Callback class provided to the {@link #run(Callback)} method to report the result. - */ - class Callback { - private final AtomicBoolean mCalled = new AtomicBoolean(); - private final RequestWrapper mWrapper; - private final PagingRequestHelper mHelper; - Callback(RequestWrapper wrapper, PagingRequestHelper helper) { - mWrapper = wrapper; - mHelper = helper; - } - /** - * Call this method when the request succeeds and new data is fetched. - */ - @SuppressWarnings("unused") - public final void recordSuccess() { - if (mCalled.compareAndSet(false, true)) { - mHelper.recordResult(mWrapper, null); - } else { - throw new IllegalStateException( - "already called recordSuccess or recordFailure"); - } - } - /** - * Call this method with the failure message and the request can be retried via - * {@link #retryAllFailed()}. - * - * @param throwable The error that occured while carrying out the request. - */ - @SuppressWarnings("unused") - public final void recordFailure(@NonNull Throwable throwable) { - //noinspection ConstantConditions - if (throwable == null) { - throw new IllegalArgumentException("You must provide a throwable describing" - + " the error to record the failure"); - } - if (mCalled.compareAndSet(false, true)) { - mHelper.recordResult(mWrapper, throwable); - } else { - throw new IllegalStateException( - "already called recordSuccess or recordFailure"); - } - } - } - } - /** - * Data class that holds the information about the current status of the ongoing requests - * using this helper. - */ - public static final class StatusReport { - /** - * Status of the latest request that were submitted with {@link RequestType#INITIAL}. - */ - @NonNull - public final Status initial; - /** - * Status of the latest request that were submitted with {@link RequestType#BEFORE}. - */ - @NonNull - public final Status before; - /** - * Status of the latest request that were submitted with {@link RequestType#AFTER}. - */ - @NonNull - public final Status after; - @NonNull - private final Throwable[] mErrors; - StatusReport(@NonNull Status initial, @NonNull Status before, @NonNull Status after, - @NonNull Throwable[] errors) { - this.initial = initial; - this.before = before; - this.after = after; - this.mErrors = errors; - } - /** - * Convenience method to check if there are any running requests. - * - * @return True if there are any running requests, false otherwise. - */ - public boolean hasRunning() { - return initial == Status.RUNNING - || before == Status.RUNNING - || after == Status.RUNNING; - } - /** - * Convenience method to check if there are any requests that resulted in an error. - * - * @return True if there are any requests that finished with error, false otherwise. - */ - public boolean hasError() { - return initial == Status.FAILED - || before == Status.FAILED - || after == Status.FAILED; - } - /** - * Returns the error for the given request type. - * - * @param type The request type for which the error should be returned. - * @return The {@link Throwable} returned by the failing request with the given type or - * {@code null} if the request for the given type did not fail. - */ - @Nullable - public Throwable getErrorFor(@NonNull RequestType type) { - return mErrors[type.ordinal()]; - } - @Override - public String toString() { - return "StatusReport{" - + "initial=" + initial - + ", before=" + before - + ", after=" + after - + ", mErrors=" + Arrays.toString(mErrors) - + '}'; - } - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - StatusReport that = (StatusReport) o; - if (initial != that.initial) return false; - if (before != that.before) return false; - if (after != that.after) return false; - // Probably incorrect - comparing Object[] arrays with Arrays.equals - return Arrays.equals(mErrors, that.mErrors); - } - @Override - public int hashCode() { - int result = initial.hashCode(); - result = 31 * result + before.hashCode(); - result = 31 * result + after.hashCode(); - result = 31 * result + Arrays.hashCode(mErrors); - return result; - } - } - /** - * Listener interface to get notified by request status changes. - */ - public interface Listener { - /** - * Called when the status for any of the requests has changed. - * - * @param report The current status report that has all the information about the requests. - */ - void onStatusChange(@NonNull StatusReport report); - } - /** - * Represents the status of a Request for each {@link RequestType}. - */ - public enum Status { - /** - * There is current a running request. - */ - RUNNING, - /** - * The last request has succeeded or no such requests have ever been run. - */ - SUCCESS, - /** - * The last request has failed. - */ - FAILED - } - /** - * Available request types. - */ - public enum RequestType { - /** - * Corresponds to an initial request made to a {@link androidx.paging.DataSource} or the empty state for - * a {@link androidx.paging.PagedList.BoundaryCallback BoundaryCallback}. - */ - INITIAL, - /** - * Corresponds to the {@code loadBefore} calls in {@link androidx.paging.DataSource} or - * {@code onItemAtFrontLoaded} in - * {@link androidx.paging.PagedList.BoundaryCallback BoundaryCallback}. - */ - BEFORE, - /** - * Corresponds to the {@code loadAfter} calls in {@link androidx.paging.DataSource} or - * {@code onItemAtEndLoaded} in - * {@link androidx.paging.PagedList.BoundaryCallback BoundaryCallback}. - */ - AFTER - } - class RequestQueue { - @NonNull - final RequestType mRequestType; - @Nullable - RequestWrapper mFailed; - @Nullable - Request mRunning; - @Nullable - Throwable mLastError; - @NonNull - Status mStatus = Status.SUCCESS; - RequestQueue(@NonNull RequestType requestType) { - mRequestType = requestType; - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/util/PickMediaFiles.kt b/app/src/main/java/com/keylesspalace/tusky/util/PickMediaFiles.kt new file mode 100644 index 000000000..4d3fcd5b4 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/PickMediaFiles.kt @@ -0,0 +1,52 @@ +/* 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 . */ + +package com.keylesspalace.tusky.util + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.activity.result.contract.ActivityResultContract + +class PickMediaFiles : ActivityResultContract>() { + override fun createIntent(context: Context, allowMultiple: Boolean): Intent { + return Intent(Intent.ACTION_GET_CONTENT) + .addCategory(Intent.CATEGORY_OPENABLE) + .setType("*/*") + .apply { + putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("image/*", "video/*", "audio/*")) + putExtra(Intent.EXTRA_ALLOW_MULTIPLE, allowMultiple) + } + } + + override fun parseResult(resultCode: Int, intent: Intent?): List { + if (resultCode == Activity.RESULT_OK) { + val intentData = intent?.data + val clipData = intent?.clipData + if (intentData != null) { + // Single media, upload it and done. + return listOf(intentData) + } else if (clipData != null) { + val result: MutableList = mutableListOf() + for (i in 0 until clipData.itemCount) { + result.add(clipData.getItemAt(i).uri) + } + return result + } + } + return emptyList() + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/Resource.kt b/app/src/main/java/com/keylesspalace/tusky/util/Resource.kt index 1f9f35d20..ddc88f45d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/Resource.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/Resource.kt @@ -6,8 +6,9 @@ class Loading (override val data: T? = null) : Resource(data) class Success (override val data: T? = null) : Resource(data) -class Error (override val data: T? = null, - val errorMessage: String? = null, - var consumed: Boolean = false, - val cause: Throwable? = null -): Resource(data) \ No newline at end of file +class Error ( + override val data: T? = null, + val errorMessage: String? = null, + var consumed: Boolean = false, + val cause: Throwable? = null +) : Resource(data) diff --git a/app/src/main/java/com/keylesspalace/tusky/util/RxAwareViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/util/RxAwareViewModel.kt index c78b0f787..0f3267436 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/RxAwareViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/RxAwareViewModel.kt @@ -2,8 +2,8 @@ package com.keylesspalace.tusky.util import androidx.annotation.CallSuper import androidx.lifecycle.ViewModel -import io.reactivex.disposables.CompositeDisposable -import io.reactivex.disposables.Disposable +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.disposables.Disposable open class RxAwareViewModel : ViewModel() { val disposables = CompositeDisposable() @@ -15,4 +15,4 @@ open class RxAwareViewModel : ViewModel() { super.onCleared() disposables.clear() } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/SaveTootHelper.java b/app/src/main/java/com/keylesspalace/tusky/util/SaveTootHelper.java deleted file mode 100644 index 29693550d..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/util/SaveTootHelper.java +++ /dev/null @@ -1,57 +0,0 @@ -package com.keylesspalace.tusky.util; - -import android.content.Context; -import android.net.Uri; -import android.util.Log; - -import androidx.annotation.NonNull; - -import com.google.gson.Gson; -import com.google.gson.reflect.TypeToken; -import com.keylesspalace.tusky.db.AppDatabase; -import com.keylesspalace.tusky.db.TootDao; -import com.keylesspalace.tusky.db.TootEntity; - -import java.util.ArrayList; - -import javax.inject.Inject; - -public final class SaveTootHelper { - - private static final String TAG = "SaveTootHelper"; - - private TootDao tootDao; - private Context context; - private Gson gson = new Gson(); - - @Inject - public SaveTootHelper(@NonNull AppDatabase appDatabase, @NonNull Context context) { - this.tootDao = appDatabase.tootDao(); - this.context = context; - } - - public void deleteDraft(int tootId) { - TootEntity item = tootDao.find(tootId); - if (item != null) { - deleteDraft(item); - } - } - - public void deleteDraft(@NonNull TootEntity item) { - // Delete any media files associated with the status. - ArrayList uris = gson.fromJson(item.getUrls(), - new TypeToken>() { - }.getType()); - if (uris != null) { - for (String uriString : uris) { - Uri uri = Uri.parse(uriString); - if (context.getContentResolver().delete(uri, null, null) == 0) { - Log.e(TAG, String.format("Did not delete file %s.", uriString)); - } - } - } - // update DB - tootDao.delete(item.getUid()); - } - -} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ShareShortcutHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/ShareShortcutHelper.kt index 1acf22605..ee6874964 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ShareShortcutHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ShareShortcutHelper.kt @@ -31,8 +31,8 @@ import com.keylesspalace.tusky.MainActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.components.notifications.NotificationHelper import com.keylesspalace.tusky.db.AccountEntity -import io.reactivex.Single -import io.reactivex.schedulers.Schedulers +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers fun updateShortcut(context: Context, account: AccountEntity) { @@ -43,17 +43,17 @@ fun updateShortcut(context: Context, account: AccountEntity) { val bmp = if (TextUtils.isEmpty(account.profilePictureUrl)) { Glide.with(context) - .asBitmap() - .load(R.drawable.avatar_default) - .submit(innerSize, innerSize) - .get() + .asBitmap() + .load(R.drawable.avatar_default) + .submit(innerSize, innerSize) + .get() } else { Glide.with(context) - .asBitmap() - .load(account.profilePictureUrl) - .error(R.drawable.avatar_default) - .submit(innerSize, innerSize) - .get() + .asBitmap() + .load(account.profilePictureUrl) + .error(R.drawable.avatar_default) + .submit(innerSize, innerSize) + .get() } // inset the loaded bitmap inside a 108dp transparent canvas so it looks good as adaptive icon @@ -65,10 +65,10 @@ fun updateShortcut(context: Context, account: AccountEntity) { val icon = IconCompat.createWithAdaptiveBitmap(outBmp) val person = Person.Builder() - .setIcon(icon) - .setName(account.displayName) - .setKey(account.identifier) - .build() + .setIcon(icon) + .setName(account.displayName) + .setKey(account.identifier) + .build() // This intent will be sent when the user clicks on one of the launcher shortcuts. Intent from share sheet will be different val intent = Intent(context, MainActivity::class.java).apply { @@ -78,26 +78,22 @@ fun updateShortcut(context: Context, account: AccountEntity) { } val shortcutInfo = ShortcutInfoCompat.Builder(context, account.id.toString()) - .setIntent(intent) - .setCategories(setOf("com.keylesspalace.tusky.Share")) - .setShortLabel(account.displayName) - .setPerson(person) - .setLongLived(true) - .setIcon(icon) - .build() + .setIntent(intent) + .setCategories(setOf("com.keylesspalace.tusky.Share")) + .setShortLabel(account.displayName) + .setPerson(person) + .setLongLived(true) + .setIcon(icon) + .build() ShortcutManagerCompat.addDynamicShortcuts(context, listOf(shortcutInfo)) - } - .subscribeOn(Schedulers.io()) - .onErrorReturnItem(false) - .subscribe() - - + .subscribeOn(Schedulers.io()) + .onErrorReturnItem(false) + .subscribe() } fun removeShortcut(context: Context, account: AccountEntity) { ShortcutManagerCompat.removeDynamicShortcuts(context, listOf(account.id.toString())) - -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/SmartLengthInputFilter.kt b/app/src/main/java/com/keylesspalace/tusky/util/SmartLengthInputFilter.kt index ba9c42039..078639acd 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/SmartLengthInputFilter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/SmartLengthInputFilter.kt @@ -35,10 +35,10 @@ private const val LENGTH_DEFAULT = 500 * be hidden will not be enough to justify the operation. * * @param message The message to trim. - * @return Whether the message should be trimmed or not. + * @return Whether the message should be trimmed or not. */ fun shouldTrimStatus(message: Spanned): Boolean { - return message.isNotEmpty() && LENGTH_DEFAULT.toFloat() / message.length < 0.75 + return message.isNotEmpty() && LENGTH_DEFAULT.toFloat() / message.length < 0.75 } /** @@ -53,59 +53,59 @@ fun shouldTrimStatus(message: Spanned): Boolean { * */ object SmartLengthInputFilter : InputFilter { - /** {@inheritDoc} */ - override fun filter(source: CharSequence, start: Int, end: Int, dest: Spanned, dstart: Int, dend: Int): CharSequence? { - // Code originally imported from InputFilter.LengthFilter but heavily customized and converted to Kotlin. - // https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/text/InputFilter.java#175 + /** {@inheritDoc} */ + override fun filter(source: CharSequence, start: Int, end: Int, dest: Spanned, dstart: Int, dend: Int): CharSequence? { + // Code originally imported from InputFilter.LengthFilter but heavily customized and converted to Kotlin. + // https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/text/InputFilter.java#175 - val sourceLength = source.length - var keep = LENGTH_DEFAULT - (dest.length - (dend - dstart)) - if (keep <= 0) return "" - if (keep >= end - start) return null // Keep original + val sourceLength = source.length + var keep = LENGTH_DEFAULT - (dest.length - (dend - dstart)) + if (keep <= 0) return "" + if (keep >= end - start) return null // Keep original - keep += start + keep += start - // Skip trimming if the ratio doesn't warrant it - if (keep.toDouble() / sourceLength > 0.75) return null + // Skip trimming if the ratio doesn't warrant it + if (keep.toDouble() / sourceLength > 0.75) return null - // Enable trimming at the end of the closest word if possible - if (source[keep].isLetterOrDigit()) { - var boundary: Int + // Enable trimming at the end of the closest word if possible + if (source[keep].isLetterOrDigit()) { + var boundary: Int - // Android N+ offer a clone of the ICU APIs in Java for better internationalization and - // unicode support. Using the ICU version of BreakIterator grants better support for - // those without having to add the ICU4J library at a minimum Api trade-off. - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) { - val iterator = android.icu.text.BreakIterator.getWordInstance() - iterator.setText(source.toString()) - boundary = iterator.following(keep) - if (keep - boundary > RUNWAY) boundary = iterator.preceding(keep) - } else { - val iterator = java.text.BreakIterator.getWordInstance() - iterator.setText(source.toString()) - boundary = iterator.following(keep) - if (keep - boundary > RUNWAY) boundary = iterator.preceding(keep) - } + // Android N+ offer a clone of the ICU APIs in Java for better internationalization and + // unicode support. Using the ICU version of BreakIterator grants better support for + // those without having to add the ICU4J library at a minimum Api trade-off. + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) { + val iterator = android.icu.text.BreakIterator.getWordInstance() + iterator.setText(source.toString()) + boundary = iterator.following(keep) + if (keep - boundary > RUNWAY) boundary = iterator.preceding(keep) + } else { + val iterator = java.text.BreakIterator.getWordInstance() + iterator.setText(source.toString()) + boundary = iterator.following(keep) + if (keep - boundary > RUNWAY) boundary = iterator.preceding(keep) + } - keep = boundary - } else { + keep = boundary + } else { - // If no runway is allowed simply remove whitespaces if present - while(source[keep - 1].isWhitespace()) { - --keep - if (keep == start) return "" - } - } + // If no runway is allowed simply remove whitespaces if present + while (source[keep - 1].isWhitespace()) { + --keep + if (keep == start) return "" + } + } - if (source[keep - 1].isHighSurrogate()) { - --keep - if (keep == start) return "" - } + if (source[keep - 1].isHighSurrogate()) { + --keep + if (keep == start) return "" + } - return if (source is Spanned) { - SpannableStringBuilder(source, start, keep).append("…") - } else { - "${source.subSequence(start, keep)}…" - } - } -} \ No newline at end of file + return if (source is Spanned) { + SpannableStringBuilder(source, start, keep).append("…") + } else { + "${source.subSequence(start, keep)}…" + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/SpanUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/SpanUtils.kt index 307fbeae7..7734d9d7f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/SpanUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/SpanUtils.kt @@ -49,13 +49,17 @@ private class FindCharsResult { var end: Int = -1 } -private class PatternFinder(val searchCharacter: Char, regex: String, val searchPrefixWidth: Int, - val prefixValidator: (Int) -> Boolean) { +private class PatternFinder( + val searchCharacter: Char, + regex: String, + val searchPrefixWidth: Int, + val prefixValidator: (Int) -> Boolean +) { val pattern: Pattern = Pattern.compile(regex, Pattern.CASE_INSENSITIVE) } private fun clearSpans(text: Spannable, spanClass: Class) { - for(span in text.getSpans(0, text.length, spanClass)) { + for (span in text.getSpans(0, text.length, spanClass)) { text.removeSpan(span) } } @@ -66,14 +70,18 @@ private fun findPattern(string: String, fromIndex: Int): FindCharsResult { val c = string[i] for (matchType in FoundMatchType.values()) { val finder = finders[matchType] - if (finder!!.searchCharacter == c - && ((i - fromIndex) < finder.searchPrefixWidth || - finder.prefixValidator(string.codePointAt(i - finder.searchPrefixWidth)))) { + if (finder!!.searchCharacter == c && + ( + (i - fromIndex) < finder.searchPrefixWidth || + finder.prefixValidator(string.codePointAt(i - finder.searchPrefixWidth)) + ) + ) { result.matchType = matchType result.start = max(0, i - finder.searchPrefixWidth) findEndOfPattern(string, result, finder.pattern) if (result.start + finder.searchPrefixWidth <= i + 1 && // The found result is actually triggered by the correct search character - result.end >= result.start) { // ...and we actually found a valid result + result.end >= result.start + ) { // ...and we actually found a valid result return result } } @@ -92,7 +100,8 @@ private fun findEndOfPattern(string: String, result: FindCharsResult, pattern: P FoundMatchType.TAG -> { if (isValidForTagPrefix(string.codePointAt(result.start))) { if (string[result.start] != '#' || - (string[result.start] == '#' && string[result.start + 1] == '#')) { + (string[result.start] == '#' && string[result.start + 1] == '#') + ) { ++result.start } } @@ -116,9 +125,9 @@ private fun findEndOfPattern(string: String, result: FindCharsResult, pattern: P } private fun getSpan(matchType: FoundMatchType, string: String, colour: Int, start: Int, end: Int): CharacterStyle { - return when(matchType) { - FoundMatchType.HTTP_URL -> CustomURLSpan(string.substring(start, end)) - FoundMatchType.HTTPS_URL -> CustomURLSpan(string.substring(start, end)) + return when (matchType) { + FoundMatchType.HTTP_URL -> NoUnderlineURLSpan(string.substring(start, end)) + FoundMatchType.HTTPS_URL -> NoUnderlineURLSpan(string.substring(start, end)) else -> ForegroundColorSpan(colour) } } @@ -149,13 +158,15 @@ fun highlightSpans(text: Spannable, colour: Int) { private fun isWordCharacters(codePoint: Int): Boolean { return (codePoint in 0x30..0x39) || // [0-9] - (codePoint in 0x41..0x5a) || // [A-Z] - (codePoint == 0x5f) || // _ - (codePoint in 0x61..0x7a) // [a-z] + (codePoint in 0x41..0x5a) || // [A-Z] + (codePoint == 0x5f) || // _ + (codePoint in 0x61..0x7a) // [a-z] } private fun isValidForTagPrefix(codePoint: Int): Boolean { - return !(isWordCharacters(codePoint) || // \w + return !( + isWordCharacters(codePoint) || // \w (codePoint == 0x2f) || // / - (codePoint == 0x29)) // ) + (codePoint == 0x29) + ) // ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt b/app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt index bfec20e2e..932f9db44 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt @@ -1,24 +1,24 @@ package com.keylesspalace.tusky.util data class StatusDisplayOptions( - @get:JvmName("animateAvatars") - val animateAvatars: Boolean, - @get:JvmName("mediaPreviewEnabled") - val mediaPreviewEnabled: Boolean, - @get:JvmName("useAbsoluteTime") - val useAbsoluteTime: Boolean, - @get:JvmName("showBotOverlay") - val showBotOverlay: Boolean, - @get:JvmName("useBlurhash") - val useBlurhash: Boolean, - @get:JvmName("cardViewMode") - val cardViewMode: CardViewMode, - @get:JvmName("confirmReblogs") - val confirmReblogs: Boolean, - @get:JvmName("hideStats") - val hideStats: Boolean, - @get:JvmName("animateEmojis") - val animateEmojis: Boolean, - @get:JvmName("quoteEnabled") - val quoteEnabled: Boolean + @get:JvmName("animateAvatars") + val animateAvatars: Boolean, + @get:JvmName("mediaPreviewEnabled") + val mediaPreviewEnabled: Boolean, + @get:JvmName("useAbsoluteTime") + val useAbsoluteTime: Boolean, + @get:JvmName("showBotOverlay") + val showBotOverlay: Boolean, + @get:JvmName("useBlurhash") + val useBlurhash: Boolean, + @get:JvmName("cardViewMode") + val cardViewMode: CardViewMode, + @get:JvmName("confirmReblogs") + val confirmReblogs: Boolean, + @get:JvmName("hideStats") + val hideStats: Boolean, + @get:JvmName("animateEmojis") + val animateEmojis: Boolean, + @get:JvmName("quoteEnabled") + val quoteEnabled: Boolean ) diff --git a/app/src/main/java/com/keylesspalace/tusky/util/StatusViewHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/StatusViewHelper.kt index 822100290..00f6699da 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/StatusViewHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/StatusViewHelper.kt @@ -34,7 +34,8 @@ import com.keylesspalace.tusky.viewdata.buildDescription import com.keylesspalace.tusky.viewdata.calculatePercent import java.text.NumberFormat import java.text.SimpleDateFormat -import java.util.* +import java.util.Date +import java.util.Locale import kotlin.math.min class StatusViewHelper(private val itemView: View) { @@ -47,25 +48,28 @@ class StatusViewHelper(private val itemView: View) { private val longSdf = SimpleDateFormat("MM/dd HH:mm:ss", Locale.getDefault()) fun setMediasPreview( - statusDisplayOptions: StatusDisplayOptions, - attachments: List, - sensitive: Boolean, - previewListener: MediaPreviewListener, - showingContent: Boolean, - mediaPreviewHeight: Int) { + statusDisplayOptions: StatusDisplayOptions, + attachments: List, + sensitive: Boolean, + previewListener: MediaPreviewListener, + showingContent: Boolean, + mediaPreviewHeight: Int + ) { val context = itemView.context val mediaPreviews = arrayOf( - itemView.findViewById(R.id.status_media_preview_0), - itemView.findViewById(R.id.status_media_preview_1), - itemView.findViewById(R.id.status_media_preview_2), - itemView.findViewById(R.id.status_media_preview_3)) + itemView.findViewById(R.id.status_media_preview_0), + itemView.findViewById(R.id.status_media_preview_1), + itemView.findViewById(R.id.status_media_preview_2), + itemView.findViewById(R.id.status_media_preview_3) + ) val mediaOverlays = arrayOf( - itemView.findViewById(R.id.status_media_overlay_0), - itemView.findViewById(R.id.status_media_overlay_1), - itemView.findViewById(R.id.status_media_overlay_2), - itemView.findViewById(R.id.status_media_overlay_3)) + itemView.findViewById(R.id.status_media_overlay_0), + itemView.findViewById(R.id.status_media_overlay_1), + itemView.findViewById(R.id.status_media_overlay_2), + itemView.findViewById(R.id.status_media_overlay_3) + ) val sensitiveMediaWarning = itemView.findViewById(R.id.status_sensitive_media_warning) val sensitiveMediaShow = itemView.findViewById(R.id.status_sensitive_media_button) @@ -85,7 +89,6 @@ class StatusViewHelper(private val itemView: View) { return } - val mediaPreviewUnloaded = ColorDrawable(ThemeUtils.getColor(context, R.attr.colorBackgroundAccent)) val n = min(attachments.size, Status.MAX_MEDIA_ATTACHMENTS) @@ -105,9 +108,9 @@ class StatusViewHelper(private val itemView: View) { if (TextUtils.isEmpty(previewUrl)) { Glide.with(mediaPreviews[i]) - .load(mediaPreviewUnloaded) - .centerInside() - .into(mediaPreviews[i]) + .load(mediaPreviewUnloaded) + .centerInside() + .into(mediaPreviews[i]) } else { val placeholder = if (attachment.blurhash != null) decodeBlurHash(context, attachment.blurhash) @@ -119,19 +122,19 @@ class StatusViewHelper(private val itemView: View) { mediaPreviews[i].setFocalPoint(focus) Glide.with(mediaPreviews[i]) - .load(previewUrl) - .placeholder(placeholder) - .centerInside() - .addListener(mediaPreviews[i]) - .into(mediaPreviews[i]) + .load(previewUrl) + .placeholder(placeholder) + .centerInside() + .addListener(mediaPreviews[i]) + .into(mediaPreviews[i]) } else { mediaPreviews[i].removeFocalPoint() Glide.with(mediaPreviews[i]) - .load(previewUrl) - .placeholder(placeholder) - .centerInside() - .into(mediaPreviews[i]) + .load(previewUrl) + .placeholder(placeholder) + .centerInside() + .into(mediaPreviews[i]) } } else { mediaPreviews[i].removeFocalPoint() @@ -145,8 +148,9 @@ class StatusViewHelper(private val itemView: View) { } val type = attachment.type - if (showingContent - && (type === Attachment.Type.VIDEO) or (type === Attachment.Type.GIFV)) { + if (showingContent && + (type === Attachment.Type.VIDEO) or (type === Attachment.Type.GIFV) + ) { mediaOverlays[i].visibility = View.VISIBLE } else { mediaOverlays[i].visibility = View.GONE @@ -170,7 +174,7 @@ class StatusViewHelper(private val itemView: View) { sensitiveMediaWarning.visibility = View.GONE sensitiveMediaShow.visibility = View.GONE } else { - sensitiveMediaWarning.text = if (sensitive) { + sensitiveMediaWarning.text = if (sensitive) { context.getString(R.string.status_sensitive_media_title) } else { context.getString(R.string.status_media_hidden_title) @@ -182,15 +186,19 @@ class StatusViewHelper(private val itemView: View) { previewListener.onContentHiddenChange(false) v.visibility = View.GONE sensitiveMediaWarning.visibility = View.VISIBLE - setMediasPreview(statusDisplayOptions, attachments, sensitive, previewListener, - false, mediaPreviewHeight) + setMediasPreview( + statusDisplayOptions, attachments, sensitive, previewListener, + false, mediaPreviewHeight + ) } sensitiveMediaWarning.setOnClickListener { v -> previewListener.onContentHiddenChange(true) v.visibility = View.GONE sensitiveMediaShow.visibility = View.VISIBLE - setMediasPreview(statusDisplayOptions, attachments, sensitive, previewListener, - true, mediaPreviewHeight) + setMediasPreview( + statusDisplayOptions, attachments, sensitive, previewListener, + true, mediaPreviewHeight + ) } } @@ -200,8 +208,12 @@ class StatusViewHelper(private val itemView: View) { } } - private fun setMediaLabel(mediaLabel: TextView, attachments: List, sensitive: Boolean, - listener: MediaPreviewListener) { + private fun setMediaLabel( + mediaLabel: TextView, + attachments: List, + sensitive: Boolean, + listener: MediaPreviewListener + ) { if (attachments.isEmpty()) { mediaLabel.visibility = View.GONE return @@ -245,10 +257,11 @@ class StatusViewHelper(private val itemView: View) { fun setupPollReadonly(poll: PollViewData?, emojis: List, statusDisplayOptions: StatusDisplayOptions) { val pollResults = listOf( - itemView.findViewById(R.id.status_poll_option_result_0), - itemView.findViewById(R.id.status_poll_option_result_1), - itemView.findViewById(R.id.status_poll_option_result_2), - itemView.findViewById(R.id.status_poll_option_result_3)) + itemView.findViewById(R.id.status_poll_option_result_0), + itemView.findViewById(R.id.status_poll_option_result_1), + itemView.findViewById(R.id.status_poll_option_result_2), + itemView.findViewById(R.id.status_poll_option_result_3) + ) val pollDescription = itemView.findViewById(R.id.status_poll_description) @@ -260,7 +273,6 @@ class StatusViewHelper(private val itemView: View) { } else { val timestamp = System.currentTimeMillis() - setupPollResult(poll, emojis, pollResults, statusDisplayOptions.animateEmojis) pollDescription.visibility = View.VISIBLE @@ -271,7 +283,7 @@ class StatusViewHelper(private val itemView: View) { private fun getPollInfoText(timestamp: Long, poll: PollViewData, pollDescription: TextView, useAbsoluteTime: Boolean): CharSequence { val context = pollDescription.context - val votesText = if(poll.votersCount == null) { + val votesText = if (poll.votersCount == null) { val votes = NumberFormat.getNumberInstance().format(poll.votesCount.toLong()) context.resources.getQuantityString(R.plurals.poll_info_votes, poll.votesCount, votes) } else { @@ -291,7 +303,6 @@ class StatusViewHelper(private val itemView: View) { return context.getString(R.string.poll_info_format, votesText, pollDurationInfo) } - private fun setupPollResult(poll: PollViewData, emojis: List, pollResults: List, animateEmojis: Boolean) { val options = poll.options @@ -306,7 +317,6 @@ class StatusViewHelper(private val itemView: View) { val level = percent * 100 pollResults[i].background.level = level - } else { pollResults[i].visibility = View.GONE } @@ -329,4 +339,4 @@ class StatusViewHelper(private val itemView: View) { val COLLAPSE_INPUT_FILTER = arrayOf(SmartLengthInputFilter) val NO_INPUT_FILTER = arrayOfNulls(0) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/StringUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/StringUtils.kt index 83eaeafad..57b87f92c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/StringUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/StringUtils.kt @@ -3,8 +3,7 @@ package com.keylesspalace.tusky.util import android.text.Spanned -import java.util.* - +import java.util.Random private const val POSSIBLE_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" @@ -30,7 +29,6 @@ fun String.inc(): String { return String(builder) } - /** * "Decrement" string so that during sorting it's smaller than [this]. */ @@ -73,6 +71,15 @@ fun String.isLessThan(other: String): Boolean { } } +fun String.idCompareTo(other: String): Int { + return when { + this === other -> 0 + this.length < other.length -> -1 + this.length > other.length -> 1 + else -> this.compareTo(other) + } +} + fun Spanned.trimTrailingWhitespace(): Spanned { var i = length do { @@ -88,4 +95,4 @@ fun Spanned.trimTrailingWhitespace(): Spanned { */ fun CharSequence.unicodeWrap(): String { return "\u2068${this}\u2069" -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ViewBindingExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/ViewBindingExtensions.kt index 5fa80fcd4..e2db79c6e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ViewBindingExtensions.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ViewBindingExtensions.kt @@ -16,35 +16,35 @@ import kotlin.reflect.KProperty */ inline fun AppCompatActivity.viewBinding( - crossinline bindingInflater: (LayoutInflater) -> T + crossinline bindingInflater: (LayoutInflater) -> T ) = lazy(LazyThreadSafetyMode.NONE) { bindingInflater(layoutInflater) } class FragmentViewBindingDelegate( - val fragment: Fragment, - val viewBindingFactory: (View) -> T + val fragment: Fragment, + val viewBindingFactory: (View) -> T ) : ReadOnlyProperty { private var binding: T? = null init { fragment.lifecycle.addObserver( - object : DefaultLifecycleObserver { - override fun onCreate(owner: LifecycleOwner) { - fragment.viewLifecycleOwnerLiveData.observe( - fragment, - { t -> - t?.lifecycle?.addObserver( - object : DefaultLifecycleObserver { - override fun onDestroy(owner: LifecycleOwner) { - binding = null - } - } - ) + object : DefaultLifecycleObserver { + override fun onCreate(owner: LifecycleOwner) { + fragment.viewLifecycleOwnerLiveData.observe( + fragment, + { t -> + t?.lifecycle?.addObserver( + object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + binding = null + } } - ) - } + ) + } + ) } + } ) } @@ -64,4 +64,4 @@ class FragmentViewBindingDelegate( } fun Fragment.viewBinding(viewBindingFactory: (View) -> T) = - FragmentViewBindingDelegate(this, viewBindingFactory) + FragmentViewBindingDelegate(this, viewBindingFactory) diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.java b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.java deleted file mode 100644 index 4516d360b..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.java +++ /dev/null @@ -1,88 +0,0 @@ -/* Copyright 2017 Andrew Dawson - * - * 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 . */ - -package com.keylesspalace.tusky.util; - -import androidx.annotation.Nullable; - -import com.keylesspalace.tusky.entity.Notification; -import com.keylesspalace.tusky.entity.Status; -import com.keylesspalace.tusky.viewdata.NotificationViewData; -import com.keylesspalace.tusky.viewdata.StatusViewData; - -/** - * Created by charlag on 12/07/2017. - */ - -public final class ViewDataUtils { - @Nullable - public static StatusViewData.Concrete statusToViewData(@Nullable Status status, - boolean alwaysShowSensitiveMedia, - boolean alwaysOpenSpoiler) { - if (status == null) return null; - Status visibleStatus = status.getReblog() == null ? status : status.getReblog(); - return new StatusViewData.Builder().setId(status.getId()) - .setAttachments(visibleStatus.getAttachments()) - .setAvatar(visibleStatus.getAccount().getAvatar()) - .setContent(visibleStatus.getContent()) - .setCreatedAt(visibleStatus.getCreatedAt()) - .setReblogsCount(visibleStatus.getReblogsCount()) - .setFavouritesCount(visibleStatus.getFavouritesCount()) - .setInReplyToId(visibleStatus.getInReplyToId()) - .setFavourited(visibleStatus.getFavourited()) - .setBookmarked(visibleStatus.getBookmarked()) - .setReblogged(visibleStatus.getReblogged()) - .setIsExpanded(alwaysOpenSpoiler) - .setIsShowingSensitiveContent(false) - .setMentions(visibleStatus.getMentions()) - .setNickname(visibleStatus.getAccount().getUsername()) - .setRebloggedAvatar(status.getReblog() == null ? null : status.getAccount().getAvatar()) - .setSensitive(visibleStatus.getSensitive()) - .setIsShowingSensitiveContent(alwaysShowSensitiveMedia || !visibleStatus.getSensitive()) - .setSpoilerText(visibleStatus.getSpoilerText()) - .setRebloggedByUsername(status.getReblog() == null ? null : status.getAccount().getName()) - .setUserFullName(visibleStatus.getAccount().getName()) - .setVisibility(visibleStatus.getVisibility()) - .setSenderId(visibleStatus.getAccount().getId()) - .setRebloggingEnabled(visibleStatus.rebloggingAllowed()) - .setApplication(visibleStatus.getApplication()) - .setStatusEmojis(visibleStatus.getEmojis()) - .setAccountEmojis(visibleStatus.getAccount().getEmojis()) - .setRebloggedByEmojis(status.getReblog() == null ? null : status.getAccount().getEmojis()) - .setCollapsible(SmartLengthInputFilterKt.shouldTrimStatus(visibleStatus.getContent())) - .setCollapsed(true) - .setPoll(visibleStatus.getPoll()) - .setCard(visibleStatus.getCard()) - .setIsBot(visibleStatus.getAccount().getBot()) - .setQuote(visibleStatus.getQuote()) - .setIsNotestock(visibleStatus.isNotestock()) - .createStatusViewData(); - } - - public static NotificationViewData.Concrete notificationToViewData(Notification notification, - boolean alwaysShowSensitiveData, - boolean alwaysOpenSpoiler) { - return new NotificationViewData.Concrete( - notification.getType(), - notification.getId(), - notification.getAccount(), - statusToViewData( - notification.getStatus(), - alwaysShowSensitiveData, - alwaysOpenSpoiler - ) - ); - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt new file mode 100644 index 000000000..9b86db6d7 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt @@ -0,0 +1,51 @@ +@file:JvmName("ViewDataUtils") + +/* Copyright 2017 Andrew Dawson + * + * 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 . */ +package com.keylesspalace.tusky.util + +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.viewdata.NotificationViewData +import com.keylesspalace.tusky.viewdata.StatusViewData + +@JvmName("statusToViewData") +fun Status.toViewData( + alwaysShowSensitiveMedia: Boolean, + alwaysOpenSpoiler: Boolean +): StatusViewData.Concrete { + val visibleStatus = this.reblog ?: this + + return StatusViewData.Concrete( + status = this, + isShowingContent = alwaysShowSensitiveMedia || !visibleStatus.sensitive, + isCollapsible = shouldTrimStatus(visibleStatus.content), + isCollapsed = false, + isExpanded = alwaysOpenSpoiler, + ) +} + +@JvmName("notificationToViewData") +fun Notification.toViewData( + alwaysShowSensitiveData: Boolean, + alwaysOpenSpoiler: Boolean +): NotificationViewData.Concrete { + return NotificationViewData.Concrete( + this.type, + this.id, + this.account, + this.status?.toViewData(alwaysShowSensitiveData, alwaysOpenSpoiler) + ) +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ViewExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/ViewExtensions.kt index 389995ae2..07a9539f5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ViewExtensions.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ViewExtensions.kt @@ -45,7 +45,8 @@ open class DefaultTextWatcher : TextWatcher { } inline fun EditText.onTextChanged( - crossinline callback: (s: CharSequence, start: Int, before: Int, count: Int) -> Unit) { + crossinline callback: (s: CharSequence, start: Int, before: Int, count: Int) -> Unit +) { addTextChangedListener(object : DefaultTextWatcher() { override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { callback(s, start, before, count) @@ -54,10 +55,11 @@ inline fun EditText.onTextChanged( } inline fun EditText.afterTextChanged( - crossinline callback: (s: Editable) -> Unit) { + crossinline callback: (s: Editable) -> Unit +) { addTextChangedListener(object : DefaultTextWatcher() { override fun afterTextChanged(s: Editable) { callback(s) } }) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/getErrorMessage.kt b/app/src/main/java/com/keylesspalace/tusky/util/getErrorMessage.kt deleted file mode 100644 index b003cb2d5..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/util/getErrorMessage.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.keylesspalace.tusky.util - -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData - -private fun getErrorMessage(report: PagingRequestHelper.StatusReport): String { - return PagingRequestHelper.RequestType.values().mapNotNull { - report.getErrorFor(it)?.message - }.first() -} - -fun PagingRequestHelper.createStatusLiveData(): LiveData { - val liveData = MutableLiveData() - addListener { report -> - when { - report.hasRunning() -> liveData.postValue(NetworkState.LOADING) - report.hasError() -> liveData.postValue( - NetworkState.error(getErrorMessage(report))) - else -> liveData.postValue(NetworkState.LOADED) - } - } - return liveData -} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/view/BackgroundMessageView.kt b/app/src/main/java/com/keylesspalace/tusky/view/BackgroundMessageView.kt index 32a7d6b31..82860f9fc 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/BackgroundMessageView.kt +++ b/app/src/main/java/com/keylesspalace/tusky/view/BackgroundMessageView.kt @@ -17,9 +17,9 @@ import com.keylesspalace.tusky.util.visible * Can show an image, text and button below them. */ class BackgroundMessageView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0 + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 ) : LinearLayout(context, attrs, defStyleAttr) { private val binding = ViewBackgroundMessageBinding.inflate(LayoutInflater.from(context), this) @@ -38,13 +38,13 @@ class BackgroundMessageView @JvmOverloads constructor( * If [clickListener] is `null` then the button will be hidden. */ fun setup( - @DrawableRes imageRes: Int, - @StringRes messageRes: Int, - clickListener: ((v: View) -> Unit)? = null + @DrawableRes imageRes: Int, + @StringRes messageRes: Int, + clickListener: ((v: View) -> Unit)? = null ) { binding.messageTextView.setText(messageRes) binding.imageView.setImageResource(imageRes) binding.button.setOnClickListener(clickListener) binding.button.visible(clickListener != null) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/view/ConversationLineItemDecoration.kt b/app/src/main/java/com/keylesspalace/tusky/view/ConversationLineItemDecoration.kt index 4011d69d3..c291bd019 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/ConversationLineItemDecoration.kt +++ b/app/src/main/java/com/keylesspalace/tusky/view/ConversationLineItemDecoration.kt @@ -18,10 +18,9 @@ package com.keylesspalace.tusky.view import android.content.Context import android.graphics.Canvas import android.graphics.drawable.Drawable -import androidx.recyclerview.widget.RecyclerView import android.view.View import androidx.core.content.ContextCompat - +import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R import com.keylesspalace.tusky.adapter.ThreadAdapter @@ -47,14 +46,15 @@ class ConversationLineItemDecoration(private val context: Context) : RecyclerVie val dividerBottom: Int if (current != null) { val above = adapter.getItem(position - 1) - dividerTop = if (above != null && above.id == current.inReplyToId) { + dividerTop = if (above != null && above.id == current.status.inReplyToId) { child.top } else { child.top + avatarMargin } val below = adapter.getItem(position + 1) - dividerBottom = if (below != null && current.id == below.inReplyToId && - adapter.detailedStatusPosition != position) { + dividerBottom = if (below != null && current.id == below.status.inReplyToId && + adapter.detailedStatusPosition != position + ) { child.bottom } else { child.top + avatarMargin @@ -66,7 +66,6 @@ class ConversationLineItemDecoration(private val context: Context) : RecyclerVie divider.setBounds(canvas.width - dividerEnd, dividerTop, canvas.width - dividerStart, dividerBottom) } divider.draw(canvas) - } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/view/EmojiPicker.kt b/app/src/main/java/com/keylesspalace/tusky/view/EmojiPicker.kt index 09e648adf..0a9bfbf7e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/EmojiPicker.kt +++ b/app/src/main/java/com/keylesspalace/tusky/view/EmojiPicker.kt @@ -6,8 +6,8 @@ import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView class EmojiPicker @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null + context: Context, + attrs: AttributeSet? = null ) : RecyclerView(context, attrs) { init { diff --git a/app/src/main/java/com/keylesspalace/tusky/view/ExposedPlayPauseVideoView.kt b/app/src/main/java/com/keylesspalace/tusky/view/ExposedPlayPauseVideoView.kt index ec748e040..444e71dc1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/ExposedPlayPauseVideoView.kt +++ b/app/src/main/java/com/keylesspalace/tusky/view/ExposedPlayPauseVideoView.kt @@ -5,10 +5,11 @@ import android.util.AttributeSet import android.widget.VideoView class ExposedPlayPauseVideoView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0) - : VideoView(context, attrs, defStyleAttr) { + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : + VideoView(context, attrs, defStyleAttr) { private var listener: PlayPauseListener? = null @@ -30,4 +31,4 @@ class ExposedPlayPauseVideoView @JvmOverloads constructor( fun onPlay() fun onPause() } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/view/LicenseCard.kt b/app/src/main/java/com/keylesspalace/tusky/view/LicenseCard.kt index ad9ae52c2..116f01703 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/LicenseCard.kt +++ b/app/src/main/java/com/keylesspalace/tusky/view/LicenseCard.kt @@ -27,9 +27,9 @@ import com.keylesspalace.tusky.util.hide class LicenseCard @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0 + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 ) : MaterialCardView(context, attrs, defStyleAttr) { init { @@ -46,14 +46,11 @@ class LicenseCard binding.licenseCardName.text = name binding.licenseCardLicense.text = license - if(link.isNullOrBlank()) { + if (link.isNullOrBlank()) { binding.licenseCardLink.hide() } else { binding.licenseCardLink.text = link setOnClickListener { LinkHelper.openLink(link, context) } } - } - } - diff --git a/app/src/main/java/com/keylesspalace/tusky/view/MediaPreviewImageView.kt b/app/src/main/java/com/keylesspalace/tusky/view/MediaPreviewImageView.kt index 42bfc276c..8922fafd5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/MediaPreviewImageView.kt +++ b/app/src/main/java/com/keylesspalace/tusky/view/MediaPreviewImageView.kt @@ -24,7 +24,6 @@ import com.bumptech.glide.load.engine.GlideException import com.bumptech.glide.request.RequestListener import com.bumptech.glide.request.target.Target import com.keylesspalace.tusky.entity.Attachment - import com.keylesspalace.tusky.util.FocalPointUtil /** @@ -40,10 +39,10 @@ import com.keylesspalace.tusky.util.FocalPointUtil */ class MediaPreviewImageView @JvmOverloads constructor( -context: Context, -attrs: AttributeSet? = null, -defStyleAttr: Int = 0 -) : AppCompatImageView(context, attrs, defStyleAttr),RequestListener { + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : AppCompatImageView(context, attrs, defStyleAttr), RequestListener { private var focus: Attachment.Focus? = null private var focalMatrix: Matrix? = null @@ -106,7 +105,6 @@ defStyleAttr: Int = 0 return false } - /** * Called when the size of the view changes, it calls the FocalPointUtil to update the * matrix if we have a set focal point. It then reassigns the matrix to this imageView. @@ -120,9 +118,11 @@ defStyleAttr: Int = 0 private fun recalculateMatrix(width: Int, height: Int, drawable: Drawable?) { if (drawable != null && focus != null && focalMatrix != null) { scaleType = ScaleType.MATRIX - FocalPointUtil.updateFocalPointMatrix(width.toFloat(), height.toFloat(), - drawable.intrinsicWidth.toFloat(), drawable.intrinsicHeight.toFloat(), - focus as Attachment.Focus, focalMatrix as Matrix) + FocalPointUtil.updateFocalPointMatrix( + width.toFloat(), height.toFloat(), + drawable.intrinsicWidth.toFloat(), drawable.intrinsicHeight.toFloat(), + focus as Attachment.Focus, focalMatrix as Matrix + ) imageMatrix = focalMatrix } } diff --git a/app/src/main/java/com/keylesspalace/tusky/view/MuteAccountDialog.kt b/app/src/main/java/com/keylesspalace/tusky/view/MuteAccountDialog.kt index da5e79874..715fa6033 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/MuteAccountDialog.kt +++ b/app/src/main/java/com/keylesspalace/tusky/view/MuteAccountDialog.kt @@ -10,18 +10,27 @@ import com.keylesspalace.tusky.databinding.DialogMuteAccountBinding fun showMuteAccountDialog( activity: Activity, accountUsername: String, - onOk: (notifications: Boolean, duration: Int) -> Unit + onOk: (notifications: Boolean, duration: Int?) -> Unit ) { val binding = DialogMuteAccountBinding.inflate(activity.layoutInflater) binding.warning.text = activity.getString(R.string.dialog_mute_warning, accountUsername) binding.checkbox.isChecked = true AlertDialog.Builder(activity) - .setView(binding.root) - .setPositiveButton(android.R.string.ok) { _, _ -> - val durationValues = activity.resources.getIntArray(R.array.mute_duration_values) - onOk(binding.checkbox.isChecked, durationValues[binding.duration.selectedItemPosition]) + .setView(binding.root) + .setPositiveButton(android.R.string.ok) { _, _ -> + val durationValues = activity.resources.getIntArray(R.array.mute_duration_values) + + // workaround to make indefinite muting work with Mastodon 3.3.0 + // https://github.com/tuskyapp/Tusky/issues/2107 + val duration = if (binding.duration.selectedItemPosition == 0) { + null + } else { + durationValues[binding.duration.selectedItemPosition] } - .setNegativeButton(android.R.string.cancel, null) - .show() -} \ No newline at end of file + + onOk(binding.checkbox.isChecked, duration) + } + .setNegativeButton(android.R.string.cancel, null) + .show() +} diff --git a/app/src/main/java/com/keylesspalace/tusky/view/SquareImageView.kt b/app/src/main/java/com/keylesspalace/tusky/view/SquareImageView.kt index d0e730522..d7e753bbb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/SquareImageView.kt +++ b/app/src/main/java/com/keylesspalace/tusky/view/SquareImageView.kt @@ -1,8 +1,8 @@ package com.keylesspalace.tusky.view import android.content.Context -import androidx.appcompat.widget.AppCompatImageView import android.util.AttributeSet +import androidx.appcompat.widget.AppCompatImageView /** * Created by charlag on 26/10/2017. @@ -13,12 +13,12 @@ class SquareImageView : AppCompatImageView { constructor(context: Context, attributes: AttributeSet) : super(context, attributes) - constructor(context: Context, attributes: AttributeSet, defStyleAttr: Int) - : super(context, attributes, defStyleAttr) + constructor(context: Context, attributes: AttributeSet, defStyleAttr: Int) : + super(context, attributes, defStyleAttr) override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { super.onMeasure(widthMeasureSpec, heightMeasureSpec) val width = measuredWidth setMeasuredDimension(width, width) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/AttachmentViewData.kt b/app/src/main/java/com/keylesspalace/tusky/viewdata/AttachmentViewData.kt index a7b2bffc7..b0a8062f6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/AttachmentViewData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/AttachmentViewData.kt @@ -7,9 +7,9 @@ import kotlinx.parcelize.Parcelize @Parcelize data class AttachmentViewData( - val attachment: Attachment, - val statusId: String, - val statusUrl: String + val attachment: Attachment, + val statusId: String, + val statusUrl: String ) : Parcelable { companion object { @JvmStatic @@ -19,12 +19,5 @@ data class AttachmentViewData( AttachmentViewData(it, actionable.id, actionable.url!!) } } - - fun list(attachments: List): List { - return attachments.map { - AttachmentViewData(it, it.id, it.url) - } - } - } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java b/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java index 6438a25de..409b858d2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java @@ -15,13 +15,13 @@ package com.keylesspalace.tusky.viewdata; +import androidx.annotation.Nullable; + import com.keylesspalace.tusky.entity.Account; import com.keylesspalace.tusky.entity.Notification; import java.util.Objects; -import io.reactivex.annotations.Nullable; - /** * Created by charlag on 12/07/2017. *

@@ -86,9 +86,7 @@ public abstract class NotificationViewData { return type == concrete.type && Objects.equals(id, concrete.id) && account.getId().equals(concrete.account.getId()) && - (statusViewData == concrete.statusViewData || - statusViewData != null && - statusViewData.deepEquals(concrete.statusViewData)); + (Objects.equals(statusViewData, concrete.statusViewData)); } @Override @@ -96,6 +94,10 @@ public abstract class NotificationViewData { return Objects.hash(type, id, account, statusViewData); } + + public Concrete copyWithStatus(@Nullable StatusViewData.Concrete statusViewData) { + return new Concrete(type, id, account, statusViewData); + } } public static final class Placeholder extends NotificationViewData { diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/PollViewData.kt b/app/src/main/java/com/keylesspalace/tusky/viewdata/PollViewData.kt index b6eefd713..0cd73bc98 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/PollViewData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/PollViewData.kt @@ -22,24 +22,24 @@ import androidx.core.text.parseAsHtml import com.keylesspalace.tusky.R import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.PollOption -import java.util.* +import java.util.Date import kotlin.math.roundToInt data class PollViewData( - val id: String, - val expiresAt: Date?, - val expired: Boolean, - val multiple: Boolean, - val votesCount: Int, - val votersCount: Int?, - val options: List, - var voted: Boolean + val id: String, + val expiresAt: Date?, + val expired: Boolean, + val multiple: Boolean, + val votesCount: Int, + val votersCount: Int?, + val options: List, + var voted: Boolean ) data class PollOptionViewData( - val title: String, - var votesCount: Int, - var selected: Boolean + val title: String, + var votesCount: Int, + var selected: Boolean ) fun calculatePercent(fraction: Int, totalVoters: Int?, totalVotes: Int): Int { @@ -60,21 +60,21 @@ fun buildDescription(title: String, percent: Int, context: Context): Spanned { fun Poll?.toViewData(): PollViewData? { if (this == null) return null return PollViewData( - id = id, - expiresAt = expiresAt, - expired = expired, - multiple = multiple, - votesCount = votesCount, - votersCount = votersCount, - options = options.map { it.toViewData() }, - voted = voted + id = id, + expiresAt = expiresAt, + expired = expired, + multiple = multiple, + votesCount = votesCount, + votersCount = votersCount, + options = options.map { it.toViewData() }, + voted = voted ) } fun PollOption.toViewData(): PollOptionViewData { return PollOptionViewData( - title = title, - votesCount = votesCount, - selected = false + title = title, + votesCount = votesCount, + selected = false ) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.java b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.java deleted file mode 100644 index 335259b63..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.java +++ /dev/null @@ -1,712 +0,0 @@ -/* Copyright 2017 Andrew Dawson - * - * 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 . */ - -package com.keylesspalace.tusky.viewdata; - -import android.os.Build; -import android.text.SpannableStringBuilder; -import android.text.Spanned; - -import androidx.annotation.Nullable; - -import com.keylesspalace.tusky.entity.Attachment; -import com.keylesspalace.tusky.entity.Card; -import com.keylesspalace.tusky.entity.Emoji; -import com.keylesspalace.tusky.entity.Poll; -import com.keylesspalace.tusky.entity.Status; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.Date; -import java.util.List; -import java.util.Objects; - -/** - * Created by charlag on 11/07/2017. - *

- * Class to represent data required to display either a notification or a placeholder. - * It is either a {@link StatusViewData.Concrete} or a {@link StatusViewData.Placeholder}. - */ - -public abstract class StatusViewData { - - private StatusViewData() { } - - public abstract long getViewDataId(); - - public abstract boolean deepEquals(StatusViewData other); - - public static final class Concrete extends StatusViewData { - private static final char SOFT_HYPHEN = '\u00ad'; - private static final char ASCII_HYPHEN = '-'; - - private final String id; - private final Spanned content; - final boolean reblogged; - final boolean favourited; - final boolean bookmarked; - private final boolean muted; - @Nullable - private final String spoilerText; - private final Status.Visibility visibility; - private final List attachments; - @Nullable - private final String rebloggedByUsername; - @Nullable - private final String rebloggedAvatar; - private final boolean isSensitive; - final boolean isExpanded; - private final boolean isShowingContent; - private final String userFullName; - private final String nickname; - private final String avatar; - private final Date createdAt; - private final int reblogsCount; - private final int favouritesCount; - @Nullable - private final String inReplyToId; - // I would rather have something else but it would be too much of a rewrite - @Nullable - private final Status.Mention[] mentions; - private final String senderId; - private final boolean rebloggingEnabled; - private final Status.Application application; - private final List statusEmojis; - private final List accountEmojis; - private final List rebloggedByAccountEmojis; - @Nullable - private final Card card; - private final boolean isCollapsible; /** Whether the status meets the requirement to be collapse */ - final boolean isCollapsed; /** Whether the status is shown partially or fully */ - @Nullable - private final PollViewData poll; - private final boolean isBot; - - private final Status quote; - private final boolean isNotestock; - - public Concrete(String id, Spanned content, boolean reblogged, boolean favourited, boolean bookmarked, boolean muted, - @Nullable String spoilerText, Status.Visibility visibility, List attachments, - @Nullable String rebloggedByUsername, @Nullable String rebloggedAvatar, boolean sensitive, boolean isExpanded, - boolean isShowingContent, String userFullName, String nickname, String avatar, - Date createdAt, int reblogsCount, int favouritesCount, @Nullable String inReplyToId, - @Nullable Status.Mention[] mentions, String senderId, boolean rebloggingEnabled, - Status.Application application, List statusEmojis, List accountEmojis, List rebloggedByAccountEmojis, @Nullable Card card, - boolean isCollapsible, boolean isCollapsed, @Nullable PollViewData poll, boolean isBot, Status quote, boolean isNotestock) { - - this.id = id; - if (Build.VERSION.SDK_INT == 23) { - // https://github.com/tuskyapp/Tusky/issues/563 - this.content = replaceCrashingCharacters(content); - this.spoilerText = spoilerText == null ? null : replaceCrashingCharacters(spoilerText).toString(); - this.nickname = replaceCrashingCharacters(nickname).toString(); - } else { - this.content = content; - this.spoilerText = spoilerText; - this.nickname = nickname; - } - this.reblogged = reblogged; - this.favourited = favourited; - this.bookmarked = bookmarked; - this.muted = muted; - this.visibility = visibility; - this.attachments = attachments; - this.rebloggedByUsername = rebloggedByUsername; - this.rebloggedAvatar = rebloggedAvatar; - this.isSensitive = sensitive; - this.isExpanded = isExpanded; - this.isShowingContent = isShowingContent; - this.userFullName = userFullName; - this.avatar = avatar; - this.createdAt = createdAt; - this.reblogsCount = reblogsCount; - this.favouritesCount = favouritesCount; - this.inReplyToId = inReplyToId; - this.mentions = mentions; - this.senderId = senderId; - this.rebloggingEnabled = rebloggingEnabled; - this.application = application; - this.statusEmojis = statusEmojis; - this.accountEmojis = accountEmojis; - this.rebloggedByAccountEmojis = rebloggedByAccountEmojis; - this.card = card; - this.isCollapsible = isCollapsible; - this.isCollapsed = isCollapsed; - this.poll = poll; - this.isBot = isBot; - this.quote = quote; - this.isNotestock = isNotestock; - } - - public String getId() { - return id; - } - - public Spanned getContent() { - return content; - } - - public boolean isReblogged() { - return reblogged; - } - - public boolean isFavourited() { - return favourited; - } - - public boolean isBookmarked() { - return bookmarked; - } - - public boolean isMuted() { - return muted; - } - - @Nullable - public String getSpoilerText() { - return spoilerText; - } - - public Status.Visibility getVisibility() { - if (visibility == null) { - return Status.Visibility.UNKNOWN; - } - return visibility; - } - - public List getAttachments() { - return attachments; - } - - @Nullable - public String getRebloggedByUsername() { - return rebloggedByUsername; - } - - public boolean isSensitive() { - return isSensitive; - } - - public boolean isExpanded() { - return isExpanded; - } - - public boolean isShowingContent() { - return isShowingContent; - } - - public boolean isBot(){ return isBot; } - - @Nullable - public String getRebloggedAvatar() { - return rebloggedAvatar; - } - - public String getUserFullName() { - return userFullName; - } - - public String getNickname() { - return nickname; - } - - public String getAvatar() { - return avatar; - } - - public Date getCreatedAt() { - return createdAt; - } - - public int getReblogsCount() { - return reblogsCount; - } - - public int getFavouritesCount() { - return favouritesCount; - } - - @Nullable - public String getInReplyToId() { - return inReplyToId; - } - - public String getSenderId() { - return senderId; - } - - public Boolean getRebloggingEnabled() { - return rebloggingEnabled; - } - - @Nullable - public Status.Mention[] getMentions() { - return mentions; - } - - public Status.Application getApplication() { - return application; - } - - public List getStatusEmojis() { - return statusEmojis; - } - - public List getAccountEmojis() { - return accountEmojis; - } - - public List getRebloggedByAccountEmojis() { - return rebloggedByAccountEmojis; - } - - @Nullable - public Card getCard() { - return card; - } - - /** - * Specifies whether the content of this post is allowed to be collapsed or if it should show - * all content regardless. - * - * @return Whether the post is collapsible or never collapsed. - */ - public boolean isCollapsible() { - return isCollapsible; - } - - /** - * Specifies whether the content of this post is currently limited in visibility to the first - * 500 characters or not. - * - * @return Whether the post is collapsed or fully expanded. - */ - public boolean isCollapsed() { - return isCollapsed; - } - - @Nullable - public PollViewData getPoll() { - return poll; - } - - public Status getQuote() { - return quote; - } - - public boolean isNotestock() { - return isNotestock; - } - - @Override public long getViewDataId() { - // Chance of collision is super low and impact of mistake is low as well - return id.hashCode(); - } - - public boolean deepEquals(StatusViewData o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - Concrete concrete = (Concrete) o; - return reblogged == concrete.reblogged && - favourited == concrete.favourited && - bookmarked == concrete.bookmarked && - isSensitive == concrete.isSensitive && - isExpanded == concrete.isExpanded && - isShowingContent == concrete.isShowingContent && - isBot == concrete.isBot && - reblogsCount == concrete.reblogsCount && - favouritesCount == concrete.favouritesCount && - rebloggingEnabled == concrete.rebloggingEnabled && - Objects.equals(id, concrete.id) && - Objects.equals(content, concrete.content) && - Objects.equals(spoilerText, concrete.spoilerText) && - visibility == concrete.visibility && - Objects.equals(attachments, concrete.attachments) && - Objects.equals(rebloggedByUsername, concrete.rebloggedByUsername) && - Objects.equals(rebloggedAvatar, concrete.rebloggedAvatar) && - Objects.equals(userFullName, concrete.userFullName) && - Objects.equals(nickname, concrete.nickname) && - Objects.equals(avatar, concrete.avatar) && - Objects.equals(createdAt, concrete.createdAt) && - Objects.equals(inReplyToId, concrete.inReplyToId) && - Arrays.equals(mentions, concrete.mentions) && - Objects.equals(senderId, concrete.senderId) && - Objects.equals(application, concrete.application) && - Objects.equals(statusEmojis, concrete.statusEmojis) && - Objects.equals(accountEmojis, concrete.accountEmojis) && - Objects.equals(rebloggedByAccountEmojis, concrete.rebloggedByAccountEmojis) && - Objects.equals(card, concrete.card) && - Objects.equals(poll, concrete.poll) && - isCollapsed == concrete.isCollapsed && - Objects.equals(quote, concrete.quote); - } - - static Spanned replaceCrashingCharacters(Spanned content) { - return (Spanned) replaceCrashingCharacters((CharSequence) content); - } - - static CharSequence replaceCrashingCharacters(CharSequence content) { - boolean replacing = false; - SpannableStringBuilder builder = null; - int length = content.length(); - - for (int index = 0; index < length; ++index) { - char character = content.charAt(index); - - // If there are more than one or two, switch to a map - if (character == SOFT_HYPHEN) { - if (!replacing) { - replacing = true; - builder = new SpannableStringBuilder(content, 0, index); - } - builder.append(ASCII_HYPHEN); - } else if (replacing) { - builder.append(character); - } - } - - return replacing ? builder : content; - } - } - - public static final class Placeholder extends StatusViewData { - private final boolean isLoading; - private final String id; - - public Placeholder(String id, boolean isLoading) { - this.id = id; - this.isLoading = isLoading; - } - - public boolean isLoading() { - return isLoading; - } - - public String getId() { - return id; - } - - @Override public long getViewDataId() { - return id.hashCode(); - } - - @Override public boolean deepEquals(StatusViewData other) { - if (!(other instanceof Placeholder)) return false; - Placeholder that = (Placeholder) other; - return isLoading == that.isLoading && id.equals(that.id); - } - - @Override public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - - Placeholder that = (Placeholder) o; - - return deepEquals(that); - } - - @Override - public int hashCode() { - int result = (isLoading ? 1 : 0); - result = 31 * result + id.hashCode(); - return result; - } - } - - public static class Builder { - private String id; - private Spanned content; - private boolean reblogged; - private boolean favourited; - private boolean bookmarked; - private boolean muted; - private String spoilerText; - private Status.Visibility visibility; - private List attachments; - private String rebloggedByUsername; - private String rebloggedAvatar; - private boolean isSensitive; - private boolean isExpanded; - private boolean isShowingContent; - private String userFullName; - private String nickname; - private String avatar; - private Date createdAt; - private int reblogsCount; - private int favouritesCount; - private String inReplyToId; - private Status.Mention[] mentions; - private String senderId; - private boolean rebloggingEnabled; - private Status.Application application; - private List statusEmojis; - private List accountEmojis; - private List rebloggedByAccountEmojis; - private Card card; - private boolean isCollapsible; /** Whether the status meets the requirement to be collapsed */ - private boolean isCollapsed; /** Whether the status is shown partially or fully */ - private PollViewData poll; - private boolean isBot; - private Status quote; - private boolean isNotestock; - - public Builder() { - } - - public Builder(final StatusViewData.Concrete viewData) { - id = viewData.id; - content = viewData.content; - reblogged = viewData.reblogged; - favourited = viewData.favourited; - bookmarked = viewData.bookmarked; - muted = viewData.muted; - spoilerText = viewData.spoilerText; - visibility = viewData.visibility; - attachments = viewData.attachments == null ? null : new ArrayList<>(viewData.attachments); - rebloggedByUsername = viewData.rebloggedByUsername; - rebloggedAvatar = viewData.rebloggedAvatar; - isSensitive = viewData.isSensitive; - isExpanded = viewData.isExpanded; - isShowingContent = viewData.isShowingContent; - userFullName = viewData.userFullName; - nickname = viewData.nickname; - avatar = viewData.avatar; - createdAt = new Date(viewData.createdAt.getTime()); - reblogsCount = viewData.reblogsCount; - favouritesCount = viewData.favouritesCount; - inReplyToId = viewData.inReplyToId; - mentions = viewData.mentions == null ? null : viewData.mentions.clone(); - senderId = viewData.senderId; - rebloggingEnabled = viewData.rebloggingEnabled; - application = viewData.application; - statusEmojis = viewData.getStatusEmojis(); - accountEmojis = viewData.getAccountEmojis(); - rebloggedByAccountEmojis = viewData.getRebloggedByAccountEmojis(); - card = viewData.getCard(); - isCollapsible = viewData.isCollapsible(); - isCollapsed = viewData.isCollapsed(); - poll = viewData.poll; - isBot = viewData.isBot(); - quote = viewData.getQuote(); - isNotestock = viewData.isNotestock; - } - - public Builder setId(String id) { - this.id = id; - return this; - } - - public Builder setContent(Spanned content) { - this.content = content; - return this; - } - - public Builder setReblogged(boolean reblogged) { - this.reblogged = reblogged; - return this; - } - - public Builder setFavourited(boolean favourited) { - this.favourited = favourited; - return this; - } - - public Builder setBookmarked(boolean bookmarked) { - this.bookmarked = bookmarked; - return this; - } - - public Builder setMuted(boolean muted) { - this.muted = muted; - return this; - } - - public Builder setSpoilerText(String spoilerText) { - this.spoilerText = spoilerText; - return this; - } - - public Builder setVisibility(Status.Visibility visibility) { - this.visibility = visibility; - return this; - } - - public Builder setAttachments(List attachments) { - if (attachments == null) { - this.attachments = new ArrayList<>(); - return this; - } - this.attachments = attachments; - return this; - } - - public Builder setRebloggedByUsername(String rebloggedByUsername) { - this.rebloggedByUsername = rebloggedByUsername; - return this; - } - - public Builder setRebloggedAvatar(String rebloggedAvatar) { - this.rebloggedAvatar = rebloggedAvatar; - return this; - } - - public Builder setSensitive(boolean sensitive) { - this.isSensitive = sensitive; - return this; - } - - public Builder setIsExpanded(boolean isExpanded) { - this.isExpanded = isExpanded; - return this; - } - - public Builder setIsShowingSensitiveContent(boolean isShowingSensitiveContent) { - this.isShowingContent = isShowingSensitiveContent; - return this; - } - - public Builder setIsBot(boolean isBot) { - this.isBot = isBot; - return this; - } - - public Builder setUserFullName(String userFullName) { - this.userFullName = userFullName; - return this; - } - - public Builder setNickname(String nickname) { - this.nickname = nickname; - return this; - } - - public Builder setAvatar(String avatar) { - this.avatar = avatar; - return this; - } - - public Builder setCreatedAt(Date createdAt) { - this.createdAt = createdAt; - return this; - } - - public Builder setReblogsCount(int reblogsCount) { - this.reblogsCount = reblogsCount; - return this; - } - - public Builder setFavouritesCount(int favouritesCount) { - this.favouritesCount = favouritesCount; - return this; - } - - public Builder setInReplyToId(String inReplyToId) { - this.inReplyToId = inReplyToId; - return this; - } - - public Builder setMentions(Status.Mention[] mentions) { - this.mentions = mentions; - return this; - } - - public Builder setSenderId(String senderId) { - this.senderId = senderId; - return this; - } - - public Builder setRebloggingEnabled(boolean rebloggingEnabled) { - this.rebloggingEnabled = rebloggingEnabled; - return this; - } - - public Builder setApplication(Status.Application application) { - this.application = application; - return this; - } - - public Builder setStatusEmojis(List emojis) { - this.statusEmojis = emojis; - return this; - } - - public Builder setAccountEmojis(List emojis) { - this.accountEmojis = emojis; - return this; - } - - public Builder setRebloggedByEmojis(List emojis) { - this.rebloggedByAccountEmojis = emojis; - return this; - } - - public Builder setCard(Card card) { - this.card = card; - return this; - } - - /** - * Configure the {@link com.keylesspalace.tusky.viewdata.StatusViewData} to support collapsing - * its content limiting the visible length when collapsed at 500 characters, - * - * @param collapsible Whether the status should support being collapsed or not. - * @return This {@link com.keylesspalace.tusky.viewdata.StatusViewData.Builder} instance. - */ - public Builder setCollapsible(boolean collapsible) { - isCollapsible = collapsible; - return this; - } - - /** - * Configure the {@link com.keylesspalace.tusky.viewdata.StatusViewData} to start in a collapsed - * state, hiding partially the content of the post if it exceeds a certain amount of characters. - * - * @param collapsed Whether to show the full content of the status or not. - * @return This {@link com.keylesspalace.tusky.viewdata.StatusViewData.Builder} instance. - */ - public Builder setCollapsed(boolean collapsed) { - isCollapsed = collapsed; - return this; - } - - public Builder setPoll(Poll poll) { - this.poll = PollViewDataKt.toViewData(poll); - return this; - } - - public Builder setQuote(Status quote){ - this.quote = quote; - return this; - } - - public Builder setIsNotestock(boolean isNotestock){ - this.isNotestock = isNotestock; - return this; - } - - public StatusViewData.Concrete createStatusViewData() { - if (this.statusEmojis == null) statusEmojis = Collections.emptyList(); - if (this.accountEmojis == null) accountEmojis = Collections.emptyList(); - if (this.createdAt == null) createdAt = new Date(); - - return new StatusViewData.Concrete(id, content, reblogged, favourited, bookmarked, muted, spoilerText, - visibility, attachments, rebloggedByUsername, rebloggedAvatar, isSensitive, isExpanded, - isShowingContent, userFullName, nickname, avatar, createdAt, reblogsCount, - favouritesCount, inReplyToId, mentions, senderId, rebloggingEnabled, application, - statusEmojis, accountEmojis, rebloggedByAccountEmojis, card, isCollapsible, isCollapsed, poll, isBot, quote, isNotestock); - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt new file mode 100644 index 000000000..92675eb61 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt @@ -0,0 +1,151 @@ +/* Copyright 2017 Andrew Dawson + * + * 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 . */ +package com.keylesspalace.tusky.viewdata + +import android.os.Build +import android.text.SpannableStringBuilder +import android.text.Spanned +import com.keylesspalace.tusky.entity.Status + +/** + * Created by charlag on 11/07/2017. + * + * + * Class to represent data required to display either a notification or a placeholder. + * It is either a [StatusViewData.Concrete] or a [StatusViewData.Placeholder]. + */ +sealed class StatusViewData private constructor() { + abstract val viewDataId: Long + + data class Concrete( + val status: Status, + val isExpanded: Boolean, + val isShowingContent: Boolean, + /** + * Specifies whether the content of this post is allowed to be collapsed or if it should show + * all content regardless. + * + * @return Whether the post is collapsible or never collapsed. + */ + val isCollapsible: Boolean, + /** + * Specifies whether the content of this post is currently limited in visibility to the first + * 500 characters or not. + * + * @return Whether the post is collapsed or fully expanded. + */ + /** Whether the status meets the requirement to be collapse */ + val isCollapsed: Boolean, + ) : StatusViewData() { + override val viewDataId: Long + get() = status.id.hashCode().toLong() + + val content: Spanned + val spoilerText: String + val username: String + + val actionable: Status + get() = status.actionableStatus + + val actionableId: String + get() = status.actionableStatus.id + + val rebloggedAvatar: String? + get() = if (status.reblog != null) { + status.account.avatar + } else { + null + } + + val rebloggingStatus: Status? + get() = if (status.reblog != null) status else null + + init { + if (Build.VERSION.SDK_INT == 23) { + // https://github.com/tuskyapp/Tusky/issues/563 + this.content = replaceCrashingCharacters(status.actionableStatus.content) + this.spoilerText = + replaceCrashingCharacters(status.actionableStatus.spoilerText).toString() + this.username = + replaceCrashingCharacters(status.actionableStatus.account.username).toString() + } else { + this.content = status.actionableStatus.content + this.spoilerText = status.actionableStatus.spoilerText + this.username = status.actionableStatus.account.username + } + } + + companion object { + private const val SOFT_HYPHEN = '\u00ad' + private const val ASCII_HYPHEN = '-' + fun replaceCrashingCharacters(content: Spanned): Spanned { + return replaceCrashingCharacters(content as CharSequence) as Spanned + } + + fun replaceCrashingCharacters(content: CharSequence): CharSequence? { + var replacing = false + var builder: SpannableStringBuilder? = null + val length = content.length + for (index in 0 until length) { + val character = content[index] + + // If there are more than one or two, switch to a map + if (character == SOFT_HYPHEN) { + if (!replacing) { + replacing = true + builder = SpannableStringBuilder(content, 0, index) + } + builder!!.append(ASCII_HYPHEN) + } else if (replacing) { + builder!!.append(character) + } + } + return if (replacing) builder else content + } + } + + val id: String + get() = status.id + + /** Helper for Java */ + fun copyWithStatus(status: Status): Concrete { + return copy(status = status) + } + + /** Helper for Java */ + fun copyWithExpanded(isExpanded: Boolean): Concrete { + return copy(isExpanded = isExpanded) + } + + /** Helper for Java */ + fun copyWithShowingContent(isShowingContent: Boolean): Concrete { + return copy(isShowingContent = isShowingContent) + } + + /** Helper for Java */ + fun copyWIthCollapsed(isCollapsed: Boolean): Concrete { + return copy(isCollapsed = isCollapsed) + } + } + + data class Placeholder(val id: String, val isLoading: Boolean) : StatusViewData() { + override val viewDataId: Long + get() = id.hashCode().toLong() + } + + fun asStatusOrNull() = this as? Concrete + + fun asPlaceholderOrNull() = this as? Placeholder +} diff --git a/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountViewModel.kt index a0f0ed680..5d8fcd32b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountViewModel.kt @@ -2,16 +2,27 @@ package com.keylesspalace.tusky.viewmodel import android.util.Log import androidx.lifecycle.MutableLiveData -import com.keylesspalace.tusky.appstore.* +import com.keylesspalace.tusky.appstore.BlockEvent +import com.keylesspalace.tusky.appstore.DomainMuteEvent +import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.appstore.MuteEvent +import com.keylesspalace.tusky.appstore.ProfileEditedEvent +import com.keylesspalace.tusky.appstore.UnfollowEvent import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Field import com.keylesspalace.tusky.entity.IdentityProof import com.keylesspalace.tusky.entity.Relationship import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.util.* -import io.reactivex.Single -import io.reactivex.disposables.Disposable +import com.keylesspalace.tusky.util.Either +import com.keylesspalace.tusky.util.Error +import com.keylesspalace.tusky.util.Loading +import com.keylesspalace.tusky.util.Resource +import com.keylesspalace.tusky.util.RxAwareViewModel +import com.keylesspalace.tusky.util.Success +import com.keylesspalace.tusky.util.combineOptionalLiveData +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.disposables.Disposable import retrofit2.Call import retrofit2.Callback import retrofit2.Response @@ -19,9 +30,9 @@ import java.util.concurrent.TimeUnit import javax.inject.Inject class AccountViewModel @Inject constructor( - private val mastodonApi: MastodonApi, - private val eventHub: EventHub, - private val accountManager: AccountManager + private val mastodonApi: MastodonApi, + private val eventHub: EventHub, + private val accountManager: AccountManager ) : RxAwareViewModel() { val accountData = MutableLiveData>() @@ -33,7 +44,7 @@ class AccountViewModel @Inject constructor( val accountFieldData = combineOptionalLiveData(accountData, identityProofData) { accountRes, identityProofs -> identityProofs.orEmpty().map { Either.Left(it) } - .plus(accountRes?.data?.fields.orEmpty().map { Either.Right(it) }) + .plus(accountRes?.data?.fields.orEmpty().map { Either.Right(it) }) } val isRefreshing = MutableLiveData().apply { value = false } @@ -46,11 +57,11 @@ class AccountViewModel @Inject constructor( init { eventHub.events - .subscribe { event -> - if (event is ProfileEditedEvent && event.newProfileData.id == accountData.value?.data?.id) { - accountData.postValue(Success(event.newProfileData)) - } - }.autoDispose() + .subscribe { event -> + if (event is ProfileEditedEvent && event.newProfileData.id == accountData.value?.data?.id) { + accountData.postValue(Success(event.newProfileData)) + } + }.autoDispose() } private fun obtainAccount(reload: Boolean = false) { @@ -59,17 +70,20 @@ class AccountViewModel @Inject constructor( accountData.postValue(Loading()) mastodonApi.account(accountId) - .subscribe({ account -> + .subscribe( + { account -> accountData.postValue(Success(account)) isDataLoading = false isRefreshing.postValue(false) - }, {t -> + }, + { t -> Log.w(TAG, "failed obtaining account", t) accountData.postValue(Error()) isDataLoading = false isRefreshing.postValue(false) - }) - .autoDispose() + } + ) + .autoDispose() } } @@ -79,13 +93,16 @@ class AccountViewModel @Inject constructor( relationshipData.postValue(Loading()) mastodonApi.relationships(listOf(accountId)) - .subscribe({ relationships -> + .subscribe( + { relationships -> relationshipData.postValue(Success(relationships[0])) - }, { t -> + }, + { t -> Log.w(TAG, "failed obtaining relationships", t) relationshipData.postValue(Error()) - }) - .autoDispose() + } + ) + .autoDispose() } } @@ -93,12 +110,15 @@ class AccountViewModel @Inject constructor( if (identityProofData.value == null || reload) { mastodonApi.identityProofs(accountId) - .subscribe({ proofs -> + .subscribe( + { proofs -> identityProofData.postValue(proofs) - }, { t -> + }, + { t -> Log.w(TAG, "failed obtaining identity proofs", t) - }) - .autoDispose() + } + ) + .autoDispose() } } @@ -119,18 +139,19 @@ class AccountViewModel @Inject constructor( } } - fun muteAccount(notifications: Boolean, duration: Int) { + fun muteAccount(notifications: Boolean, duration: Int?) { changeRelationship(RelationShipAction.MUTE, notifications, duration) } fun unmuteAccount() { changeRelationship(RelationShipAction.UNMUTE) } - + fun changeSubscribingState() { val relationship = relationshipData.value?.data - if(relationship?.notifying == true /* Mastodon 3.3.0rc1 */ - || relationship?.subscribing == true /* Pleroma */ ) { + if (relationship?.notifying == true || /* Mastodon 3.3.0rc1 */ + relationship?.subscribing == true /* Pleroma */ + ) { changeRelationship(RelationShipAction.UNSUBSCRIBE) } else { changeRelationship(RelationShipAction.SUBSCRIBE) @@ -138,12 +159,12 @@ class AccountViewModel @Inject constructor( } fun blockDomain(instance: String) { - mastodonApi.blockDomain(instance).enqueue(object: Callback { + mastodonApi.blockDomain(instance).enqueue(object : Callback { override fun onResponse(call: Call, response: Response) { if (response.isSuccessful) { eventHub.dispatch(DomainMuteEvent(instance)) val relation = relationshipData.value?.data - if(relation != null) { + if (relation != null) { relationshipData.postValue(Success(relation.copy(blockingDomain = true))) } } else { @@ -158,11 +179,11 @@ class AccountViewModel @Inject constructor( } fun unblockDomain(instance: String) { - mastodonApi.unblockDomain(instance).enqueue(object: Callback { + mastodonApi.unblockDomain(instance).enqueue(object : Callback { override fun onResponse(call: Call, response: Response) { if (response.isSuccessful) { val relation = relationshipData.value?.data - if(relation != null) { + if (relation != null) { relationshipData.postValue(Success(relation.copy(blockingDomain = false))) } } else { @@ -209,12 +230,12 @@ class AccountViewModel @Inject constructor( RelationShipAction.MUTE -> relation.copy(muting = true) RelationShipAction.UNMUTE -> relation.copy(muting = false) RelationShipAction.SUBSCRIBE -> { - if(isMastodon) + if (isMastodon) relation.copy(notifying = true) else relation.copy(subscribing = true) } RelationShipAction.UNSUBSCRIBE -> { - if(isMastodon) + if (isMastodon) relation.copy(notifying = false) else relation.copy(subscribing = false) } @@ -230,50 +251,53 @@ class AccountViewModel @Inject constructor( RelationShipAction.MUTE -> mastodonApi.muteAccount(accountId, parameter ?: true, duration) RelationShipAction.UNMUTE -> mastodonApi.unmuteAccount(accountId) RelationShipAction.SUBSCRIBE -> { - if(isMastodon) + if (isMastodon) mastodonApi.followAccount(accountId, notify = true) else mastodonApi.subscribeAccount(accountId) } RelationShipAction.UNSUBSCRIBE -> { - if(isMastodon) + if (isMastodon) mastodonApi.followAccount(accountId, notify = false) else mastodonApi.unsubscribeAccount(accountId) } }.subscribe( - { relationship -> - relationshipData.postValue(Success(relationship)) + { relationship -> + relationshipData.postValue(Success(relationship)) - when (relationshipAction) { - RelationShipAction.UNFOLLOW -> eventHub.dispatch(UnfollowEvent(accountId)) - RelationShipAction.BLOCK -> eventHub.dispatch(BlockEvent(accountId)) - RelationShipAction.MUTE -> eventHub.dispatch(MuteEvent(accountId)) - else -> { - } + when (relationshipAction) { + RelationShipAction.UNFOLLOW -> eventHub.dispatch(UnfollowEvent(accountId)) + RelationShipAction.BLOCK -> eventHub.dispatch(BlockEvent(accountId)) + RelationShipAction.MUTE -> eventHub.dispatch(MuteEvent(accountId)) + else -> { } - }, - { - relationshipData.postValue(Error(relation)) } + }, + { + relationshipData.postValue(Error(relation)) + } ) - .autoDispose() + .autoDispose() } fun noteChanged(newNote: String) { noteSaved.postValue(false) noteDisposable?.dispose() noteDisposable = Single.timer(1500, TimeUnit.MILLISECONDS) - .flatMap { - mastodonApi.updateAccountNote(accountId, newNote) - } - .doOnSuccess { - noteSaved.postValue(true) - } - .delay(4, TimeUnit.SECONDS) - .subscribe({ + .flatMap { + mastodonApi.updateAccountNote(accountId, newNote) + } + .doOnSuccess { + noteSaved.postValue(true) + } + .delay(4, TimeUnit.SECONDS) + .subscribe( + { noteSaved.postValue(false) - }, { + }, + { Log.e(TAG, "Error updating note", it) - }) + } + ) } override fun onCleared() { diff --git a/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountsInListViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountsInListViewModel.kt index 1dc412283..b02c2ac09 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountsInListViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountsInListViewModel.kt @@ -24,8 +24,8 @@ import com.keylesspalace.tusky.util.Either.Left import com.keylesspalace.tusky.util.Either.Right import com.keylesspalace.tusky.util.RxAwareViewModel import com.keylesspalace.tusky.util.withoutFirstWhich -import io.reactivex.Observable -import io.reactivex.subjects.BehaviorSubject +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.subjects.BehaviorSubject import javax.inject.Inject data class State(val accounts: Either>, val searchResult: List?) @@ -38,39 +38,52 @@ class AccountsInListViewModel @Inject constructor(private val api: MastodonApi) fun load(listId: String) { val state = _state.value!! if (state.accounts.isLeft() || state.accounts.asRight().isEmpty()) { - api.getAccountsInList(listId, 0).subscribe({ accounts -> - updateState { copy(accounts = Right(accounts)) } - }, { e -> - updateState { copy(accounts = Left(e)) } - }).autoDispose() + api.getAccountsInList(listId, 0).subscribe( + { accounts -> + updateState { copy(accounts = Right(accounts)) } + }, + { e -> + updateState { copy(accounts = Left(e)) } + } + ).autoDispose() } } fun addAccountToList(listId: String, account: Account) { api.addCountToList(listId, listOf(account.id)) - .subscribe({ + .subscribe( + { updateState { copy(accounts = accounts.map { it + account }) } - }, { - Log.i(javaClass.simpleName, - "Failed to add account to the list: ${account.username}") - }) - .autoDispose() + }, + { + Log.i( + javaClass.simpleName, + "Failed to add account to the list: ${account.username}" + ) + } + ) + .autoDispose() } fun deleteAccountFromList(listId: String, accountId: String) { api.deleteAccountFromList(listId, listOf(accountId)) - .subscribe({ + .subscribe( + { updateState { - copy(accounts = accounts.map { accounts -> - accounts.withoutFirstWhich { it.id == accountId } - }) + copy( + accounts = accounts.map { accounts -> + accounts.withoutFirstWhich { it.id == accountId } + } + ) } - }, { + }, + { Log.i(javaClass.simpleName, "Failed to remove account from thelist: $accountId") - }) - .autoDispose() + } + ) + .autoDispose() } fun search(query: String) { @@ -78,15 +91,18 @@ class AccountsInListViewModel @Inject constructor(private val api: MastodonApi) query.isEmpty() -> updateState { copy(searchResult = null) } query.isBlank() -> updateState { copy(searchResult = listOf()) } else -> api.searchAccounts(query, null, 10, true) - .subscribe({ result -> + .subscribe( + { result -> updateState { copy(searchResult = result) } - }, { + }, + { updateState { copy(searchResult = listOf()) } - }).autoDispose() + } + ).autoDispose() } } private inline fun updateState(crossinline fn: State.() -> State) { _state.onNext(fn(_state.value!!)) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt index f5184b5fd..cc2f46e21 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt @@ -30,11 +30,17 @@ import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Instance import com.keylesspalace.tusky.entity.StringField import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.util.* -import io.reactivex.Single -import io.reactivex.disposables.CompositeDisposable -import io.reactivex.rxkotlin.addTo -import io.reactivex.schedulers.Schedulers +import com.keylesspalace.tusky.util.Error +import com.keylesspalace.tusky.util.IOUtils +import com.keylesspalace.tusky.util.Loading +import com.keylesspalace.tusky.util.Resource +import com.keylesspalace.tusky.util.Success +import com.keylesspalace.tusky.util.getSampledBitmap +import com.keylesspalace.tusky.util.randomAlphanumericString +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.kotlin.addTo +import io.reactivex.rxjava3.schedulers.Schedulers import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MultipartBody import okhttp3.RequestBody @@ -56,10 +62,10 @@ private const val AVATAR_FILE_NAME = "avatar.png" private const val TAG = "EditProfileViewModel" -class EditProfileViewModel @Inject constructor( - private val mastodonApi: MastodonApi, - private val eventHub: EventHub -): ViewModel() { +class EditProfileViewModel @Inject constructor( + private val mastodonApi: MastodonApi, + private val eventHub: EventHub +) : ViewModel() { val profileData = MutableLiveData>() val avatarData = MutableLiveData>() @@ -72,21 +78,21 @@ class EditProfileViewModel @Inject constructor( private val disposeables = CompositeDisposable() fun obtainProfile() { - if(profileData.value == null || profileData.value is Error) { + if (profileData.value == null || profileData.value is Error) { profileData.postValue(Loading()) mastodonApi.accountVerifyCredentials() - .subscribe( - {profile -> - oldProfileData = profile - profileData.postValue(Success(profile)) - }, - { - profileData.postValue(Error()) - }) - .addTo(disposeables) - + .subscribe( + { profile -> + oldProfileData = profile + profileData.postValue(Success(profile)) + }, + { + profileData.postValue(Error()) + } + ) + .addTo(disposeables) } } @@ -102,12 +108,14 @@ class EditProfileViewModel @Inject constructor( resizeImage(uri, context, HEADER_WIDTH, HEADER_HEIGHT, cacheFile, headerData) } - private fun resizeImage(uri: Uri, - context: Context, - resizeWidth: Int, - resizeHeight: Int, - cacheFile: File, - imageLiveData: MutableLiveData>) { + private fun resizeImage( + uri: Uri, + context: Context, + resizeWidth: Int, + resizeHeight: Int, + cacheFile: File, + imageLiveData: MutableLiveData> + ) { Single.fromCallable { val contentResolver = context.contentResolver @@ -117,13 +125,13 @@ class EditProfileViewModel @Inject constructor( throw Exception() } - //dont upscale image if its smaller than the desired size + // dont upscale image if its smaller than the desired size val bitmap = - if (sourceBitmap.width <= resizeWidth && sourceBitmap.height <= resizeHeight) { - sourceBitmap - } else { - Bitmap.createScaledBitmap(sourceBitmap, resizeWidth, resizeHeight, true) - } + if (sourceBitmap.width <= resizeWidth && sourceBitmap.height <= resizeHeight) { + sourceBitmap + } else { + Bitmap.createScaledBitmap(sourceBitmap, resizeWidth, resizeHeight, true) + } if (!saveBitmapToFile(bitmap, cacheFile)) { throw Exception() @@ -131,17 +139,20 @@ class EditProfileViewModel @Inject constructor( bitmap }.subscribeOn(Schedulers.io()) - .subscribe({ + .subscribe( + { imageLiveData.postValue(Success(it)) - }, { + }, + { imageLiveData.postValue(Error()) - }) - .addTo(disposeables) + } + ) + .addTo(disposeables) } fun save(newDisplayName: String, newNote: String, newLocked: Boolean, newFields: List, context: Context) { - if(saveData.value is Loading || profileData.value !is Success) { + if (saveData.value is Loading || profileData.value !is Success) { return } @@ -184,21 +195,23 @@ class EditProfileViewModel @Inject constructor( val field3 = calculateFieldToUpdate(newFields.getOrNull(2), fieldsUnchanged) val field4 = calculateFieldToUpdate(newFields.getOrNull(3), fieldsUnchanged) - if (displayName == null && note == null && locked == null && avatar == null && header == null - && field1 == null && field2 == null && field3 == null && field4 == null) { + if (displayName == null && note == null && locked == null && avatar == null && header == null && + field1 == null && field2 == null && field3 == null && field4 == null + ) { /** if nothing has changed, there is no need to make a network request */ saveData.postValue(Success()) return } - mastodonApi.accountUpdateCredentials(displayName, note, locked, avatar, header, - field1?.first, field1?.second, field2?.first, field2?.second, field3?.first, field3?.second, field4?.first, field4?.second + mastodonApi.accountUpdateCredentials( + displayName, note, locked, avatar, header, + field1?.first, field1?.second, field2?.first, field2?.second, field3?.first, field3?.second, field4?.first, field4?.second ).enqueue(object : Callback { override fun onResponse(call: Call, response: Response) { val newProfileData = response.body() if (!response.isSuccessful || newProfileData == null) { val errorResponse = response.errorBody()?.string() - val errorMsg = if(!errorResponse.isNullOrBlank()) { + val errorMsg = if (!errorResponse.isNullOrBlank()) { try { JSONObject(errorResponse).optString("error", null) } catch (e: JSONException) { @@ -218,29 +231,28 @@ class EditProfileViewModel @Inject constructor( saveData.postValue(Error()) } }) - } // cache activity state for rotation change fun updateProfile(newDisplayName: String, newNote: String, newLocked: Boolean, newFields: List) { - if(profileData.value is Success) { + if (profileData.value is Success) { val newProfileSource = profileData.value?.data?.source?.copy(note = newNote, fields = newFields) - val newProfile = profileData.value?.data?.copy(displayName = newDisplayName, - locked = newLocked, source = newProfileSource) + val newProfile = profileData.value?.data?.copy( + displayName = newDisplayName, + locked = newLocked, source = newProfileSource + ) profileData.postValue(Success(newProfile)) } - } - private fun calculateFieldToUpdate(newField: StringField?, fieldsUnchanged: Boolean): Pair? { - if(fieldsUnchanged || newField == null) { + if (fieldsUnchanged || newField == null) { return null } return Pair( - newField.name.toRequestBody(MultipartBody.FORM), - newField.value.toRequestBody(MultipartBody.FORM) + newField.name.toRequestBody(MultipartBody.FORM), + newField.value.toRequestBody(MultipartBody.FORM) ) } @@ -270,19 +282,18 @@ class EditProfileViewModel @Inject constructor( } fun obtainInstance() { - if(instanceData.value == null || instanceData.value is Error) { + if (instanceData.value == null || instanceData.value is Error) { instanceData.postValue(Loading()) mastodonApi.getInstance().subscribe( - { instance -> - instanceData.postValue(Success(instance)) - }, - { - instanceData.postValue(Error()) - }) - .addTo(disposeables) + { instance -> + instanceData.postValue(Success(instance)) + }, + { + instanceData.postValue(Error()) + } + ) + .addTo(disposeables) } } - - } diff --git a/app/src/main/java/com/keylesspalace/tusky/viewmodel/ListsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/viewmodel/ListsViewModel.kt index 22f509b59..682631555 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewmodel/ListsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewmodel/ListsViewModel.kt @@ -21,14 +21,13 @@ import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.RxAwareViewModel import com.keylesspalace.tusky.util.replacedFirstWhich import com.keylesspalace.tusky.util.withoutFirstWhich -import io.reactivex.Observable -import io.reactivex.subjects.BehaviorSubject -import io.reactivex.subjects.PublishSubject +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.subjects.BehaviorSubject +import io.reactivex.rxjava3.subjects.PublishSubject import java.io.IOException import java.net.ConnectException import javax.inject.Inject - internal class ListsViewModel @Inject constructor(private val api: MastodonApi) : RxAwareViewModel() { enum class LoadingState { INITIAL, LOADING, LOADED, ERROR_NETWORK, ERROR_OTHER @@ -56,49 +55,63 @@ internal class ListsViewModel @Inject constructor(private val api: MastodonApi) copy(loadingState = LoadingState.LOADING) } - api.getLists().subscribe({ lists -> - updateState { - copy( + api.getLists().subscribe( + { lists -> + updateState { + copy( lists = lists, loadingState = LoadingState.LOADED - ) + ) + } + }, + { err -> + updateState { + copy( + loadingState = if (err is IOException || err is ConnectException) + LoadingState.ERROR_NETWORK else LoadingState.ERROR_OTHER + ) + } } - }, { err -> - updateState { - copy(loadingState = if (err is IOException || err is ConnectException) - LoadingState.ERROR_NETWORK else LoadingState.ERROR_OTHER) - } - }).autoDispose() + ).autoDispose() } fun createNewList(listName: String) { - api.createList(listName).subscribe({ list -> - updateState { - copy(lists = lists + list) + api.createList(listName).subscribe( + { list -> + updateState { + copy(lists = lists + list) + } + }, + { + sendEvent(Event.CREATE_ERROR) } - }, { - sendEvent(Event.CREATE_ERROR) - }).autoDispose() + ).autoDispose() } fun renameList(listId: String, listName: String) { - api.updateList(listId, listName).subscribe({ list -> - updateState { - copy(lists = lists.replacedFirstWhich(list) { it.id == listId }) + api.updateList(listId, listName).subscribe( + { list -> + updateState { + copy(lists = lists.replacedFirstWhich(list) { it.id == listId }) + } + }, + { + sendEvent(Event.RENAME_ERROR) } - }, { - sendEvent(Event.RENAME_ERROR) - }).autoDispose() + ).autoDispose() } fun deleteList(listId: String) { - api.deleteList(listId).subscribe({ - updateState { - copy(lists = lists.withoutFirstWhich { it.id == listId }) + api.deleteList(listId).subscribe( + { + updateState { + copy(lists = lists.withoutFirstWhich { it.id == listId }) + } + }, + { + sendEvent(Event.DELETE_ERROR) } - }, { - sendEvent(Event.DELETE_ERROR) - }).autoDispose() + ).autoDispose() } private inline fun updateState(crossinline fn: State.() -> State) { diff --git a/app/src/main/java/net/accelf/yuito/FooterDrawerItem.kt b/app/src/main/java/net/accelf/yuito/FooterDrawerItem.kt index 4f20406e5..4dfb713a8 100644 --- a/app/src/main/java/net/accelf/yuito/FooterDrawerItem.kt +++ b/app/src/main/java/net/accelf/yuito/FooterDrawerItem.kt @@ -4,12 +4,12 @@ import android.content.Context import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import autodispose2.SingleSubscribeProxy import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ItemDrawerFooterBinding import com.keylesspalace.tusky.entity.Instance import com.keylesspalace.tusky.util.BindingHolder import com.mikepenz.materialdrawer.model.AbstractDrawerItem -import com.uber.autodispose.SingleSubscribeProxy class FooterDrawerItem : AbstractDrawerItem>() { diff --git a/app/src/main/java/net/accelf/yuito/HttpToastInterceptor.java b/app/src/main/java/net/accelf/yuito/HttpToastInterceptor.java index 047ec04c1..fb30e13e0 100644 --- a/app/src/main/java/net/accelf/yuito/HttpToastInterceptor.java +++ b/app/src/main/java/net/accelf/yuito/HttpToastInterceptor.java @@ -34,7 +34,7 @@ public final class HttpToastInterceptor implements Interceptor { } int code = response.code(); - if (code == 200) { + if (code == 101 || code == 200) { return response; } diff --git a/app/src/main/java/net/accelf/yuito/QuoteInlineHelper.java b/app/src/main/java/net/accelf/yuito/QuoteInlineHelper.java index b268bc17a..7c398914f 100644 --- a/app/src/main/java/net/accelf/yuito/QuoteInlineHelper.java +++ b/app/src/main/java/net/accelf/yuito/QuoteInlineHelper.java @@ -66,7 +66,7 @@ public class QuoteInlineHelper { quoteUsername.setText(usernameText); } - private void setContent(Spanned content, Status.Mention[] mentions, List emojis, + private void setContent(Spanned content, List mentions, List emojis, LinkListener listener) { Spanned singleLineText = SpannedTextHelper.replaceSpanned(content); CharSequence emojifiedText = CustomEmojiHelper.emojify(singleLineText, emojis, quoteContent, statusDisplayOptions.animateEmojis()); diff --git a/app/src/main/java/net/accelf/yuito/TimelineStreamingListener.kt b/app/src/main/java/net/accelf/yuito/TimelineStreamingListener.kt index 3482185cc..1f3f4e343 100644 --- a/app/src/main/java/net/accelf/yuito/TimelineStreamingListener.kt +++ b/app/src/main/java/net/accelf/yuito/TimelineStreamingListener.kt @@ -1,29 +1,27 @@ package net.accelf.yuito -import android.text.Spanned import android.util.Log import com.google.gson.Gson -import com.google.gson.GsonBuilder import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.appstore.StatusDeletedEvent import com.keylesspalace.tusky.appstore.StreamUpdateEvent +import com.keylesspalace.tusky.components.timeline.TimelineViewModel import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.StreamEvent -import com.keylesspalace.tusky.fragment.TimelineFragment -import com.keylesspalace.tusky.json.SpannedTypeAdapter import okhttp3.Response import okhttp3.WebSocket import okhttp3.WebSocketListener -class TimelineStreamingListener(private val eventHub: EventHub, - private val kind: TimelineFragment.Kind, - private val identifier: String? = null) : WebSocketListener() { +class TimelineStreamingListener( + private val eventHub: EventHub, + private val gson: Gson, + private val kind: TimelineViewModel.Kind, + private val identifier: String? = null, +) : WebSocketListener() { - private val gson = buildGson() private val target = if (identifier == null) { kind.name } else { kind.name + ":" + identifier } - private var isFirstStatus = true override fun onOpen(webSocket: WebSocket, response: Response) { Log.d(TAG, "Stream connected to: $target") @@ -35,10 +33,7 @@ class TimelineStreamingListener(private val eventHub: EventHub, when (event.event) { StreamEvent.EventType.UPDATE -> { val status = gson.fromJson(payload, Status::class.java) - eventHub.dispatch(StreamUpdateEvent(status, kind, identifier, isFirstStatus)) - if (isFirstStatus) { - isFirstStatus = false - } + eventHub.dispatch(StreamUpdateEvent(status, kind, identifier)) } StreamEvent.EventType.DELETE -> eventHub.dispatch(StatusDeletedEvent(payload)) StreamEvent.EventType.FILTERS_CHANGED -> eventHub.dispatch(PreferenceChangedEvent(Filter.HOME)) // It may be not a home but it doesn't matter @@ -52,12 +47,5 @@ class TimelineStreamingListener(private val eventHub: EventHub, companion object { private const val TAG = "StreamingListener" - - private fun buildGson(): Gson { - return GsonBuilder() - .registerTypeAdapter(Spanned::class.java, SpannedTypeAdapter()) - .create() - } } - } diff --git a/app/src/main/res/layout/activity_license.xml b/app/src/main/res/layout/activity_license.xml index 42060f663..2d9c24d2f 100644 --- a/app/src/main/res/layout/activity_license.xml +++ b/app/src/main/res/layout/activity_license.xml @@ -171,7 +171,7 @@ android:layout_marginStart="12dp" android:layout_marginTop="12dp" license:license="@string/license_apache_2" - license:link="https://github.com/ArthurHub/Android-Image-Cropper" + license:link="https://github.com/CanHub/Android-Image-Cropper" license:name="Android Image Cropper" /> - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/item_saved_toot.xml b/app/src/main/res/layout/item_saved_toot.xml deleted file mode 100644 index c95473f27..000000000 --- a/app/src/main/res/layout/item_saved_toot.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/menu/conversation_more.xml b/app/src/main/res/menu/conversation_more.xml new file mode 100644 index 000000000..2f5dedd93 --- /dev/null +++ b/app/src/main/res/menu/conversation_more.xml @@ -0,0 +1,13 @@ + +

+ + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/drafts.xml b/app/src/main/res/menu/drafts.xml deleted file mode 100644 index bbc9202f4..000000000 --- a/app/src/main/res/menu/drafts.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/menu/view_tab_action.xml b/app/src/main/res/menu/view_tab_action.xml index cdbe535ea..37d8c89b5 100644 --- a/app/src/main/res/menu/view_tab_action.xml +++ b/app/src/main/res/menu/view_tab_action.xml @@ -5,10 +5,6 @@ android:icon="@drawable/ic_arrow_upward" android:title="@string/action_tab_jump_to_top" /> - - موافقة
رفض البحث - المسودات + المسودات كيفية عرض التبويق تحذير عن المحتوى لوحة مفاتيح الإيموجي @@ -473,7 +473,7 @@ أضيف إلى الفواصل المرجعية اختر قائمة القائمة - ليس لديك أية مسودات. + ليس لديك أية مسودات. ليس لديك أية منشورات مُبرمَجة للنشر. يجب أن يكون حجم الملفات الصوتية أقل مِن 40 ميغابايت. تُقدّر أدنى فترة لبرمجة النشر في ماستدون بـ 5 دقائق. diff --git a/app/src/main/res/values-ber/strings.xml b/app/src/main/res/values-ber/strings.xml index 9726d4ff0..b6d317b5a 100644 --- a/app/src/main/res/values-ber/strings.xml +++ b/app/src/main/res/values-ber/strings.xml @@ -28,7 +28,7 @@ ⴸ ⴰⵛⵓ ⵓⴸ ⵜⵜⵓⵎⵎⴰⵏⵜ\? ⵉⵙⵎⴻⵏⵢⵉⴼⴻⵏ ⴽⴽⴻⵙ - ⵉⵔⴻⵡⵡⴰⵢⴻⵏ + ⵉⵔⴻⵡⵡⴰⵢⴻⵏ ⵉⵎⵙⴻⵇⴷⴰⵛⴻⵏ ⵜⵙⵡⴰⵃⵍⴻⵎ ⵉⵛⵛⴰⵔⴻⵏ ⴰⵏⵜⴰ ⵝⵓⵎⵎⴰⵏⵜ\? diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index 0056bfdb7..a3db937bc 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -320,7 +320,7 @@ Предупреждение за съдържание Видимост на публикация Планирани публикации - Чернови + Чернови Търсене Отхвърляне Приемане @@ -442,9 +442,6 @@ Възникна грешка. Черновата е изтрита Неуспешно зареждане на информация за отговор - Стари чернови - Функцията за чернови в Tusky е напълно преработена, за да бъде по-бърза, по-лесна за ползване и по-малко бъгава. -\n Все още можете да осъществите достъп до старите си чернови чрез бутон на екрана за нови чернови, но те ще бъдат премахнати при бъдеща актуализация! Тази публикация не успя да се изпрати! Наистина ли искате да изтриете списъка %s\? @@ -470,7 +467,7 @@ Mastodon има минимален интервал за планиране от 5 минути. Няма оповестявания. Нямате планирани състояния. - Нямате чернови. + Нямате чернови. Грешка при търсенето на публикация %s Редакция Избор %d diff --git a/app/src/main/res/values-bn-rBD/strings.xml b/app/src/main/res/values-bn-rBD/strings.xml index c547ee143..a8be9e394 100644 --- a/app/src/main/res/values-bn-rBD/strings.xml +++ b/app/src/main/res/values-bn-rBD/strings.xml @@ -13,7 +13,8 @@ সতর্কবার্তা: %s মিডিয়া: %s - সর্বাধিক %1$d টি ট্যাব পৌঁছেছে + সর্বোচ্চ %1$dটি ট্যাব পৌঁছেছে + সর্বোচ্চ %1$dটি ট্যাব পৌঁছেছে দ্বারা পছন্দ দ্বারা সর্মথন @@ -235,7 +236,7 @@ ইমোজি কীবোর্ড সতর্কবার্তা টুট দৃশ্যমানতা - ড্রাফটগুলি + ড্রাফটগুলি অনুসন্ধান প্রত্যাখ্যান গ্রহণ @@ -296,7 +297,7 @@ মিডিয়া লুকানো সংবেদনশীল কন্টেন্ট লাইসেন্সগুলি - খসড়াগুলো + খসড়া আপনার প্রোফাইল সম্পাদনা করুন অনুরোধ অনুসরণ করুন অবরুদ্ধ ব্যবহারকারী @@ -330,7 +331,7 @@ আলাপ বন্ধ করো মাস্টোডনের সর্বনিম্ন ৫ মিনিটের সময়সূচীর বিরতি আছে। অডিও ফাইলগুলি অবশ্যই ৪০MB এর চেয়ে কম হওয়া উচিত। - তোমার কোনো খসড়া নেই। + তোমার কোনো খসড়া নেই। তোমার কোনো সময়সূচীত স্ট্যাটাস নেই। তালিকা তালিকা নির্বাচন করো @@ -439,4 +440,79 @@ %s তোমার টুট বুস্ট করেছে %s তোমার টুট বুস্ট করেছে ঘোষণা + %1$s,%2$s,%3$s এবং %4$d অন্যরা + যখন আমার সদস্যতা নেওয়া কেউ টুট দেয় তখন বিজ্ঞপ্তি দিবে + নতুন টুট + বিশেষ আবেগ বানাও + সদস্যতা আছে এমন একজন টুট দিয়েছে + কোনো ঘোষণা নেই। + যদিও তোমার অ্যাকাউন্ট রুদ্ধকৃত না, %1$s রা ভেবেছে এই অ্যাকাউন্টগুলোর অনুসরণ অনুরোধ তোমার পরীক্ষা করা উচিত। + যে টুটের উত্তর খসড়া করেছিলে তা মুছে ফেলা হয়েছে + এই তালিকাটা আসলেই মুছতে চাও\? + + %1$d টার বেশি সংযুক্তি পাঠানো যাবে না। + %1$d টার বেশি সংযুক্তি পাঠানো যাবে না। + + টুট পাঠাতে ব্যর্থ! + উত্তরের তথ্য আনতে ব্যর্থ + সংরক্ষিত! + অবতারে পরিসংখ্যান লুকাও + ছাপার পরিসংখ্যান লুকাও + সময়কাল বিজ্ঞপ্তি সীমাবদ্ধ করো + তোমার মানসিক স্বাস্থে নেতিবাচক প্রভাব ফেলতে পারে এমন জিনিসগুলো লুকানো আছে। যেমন: +\n +\n - পছন্দ/বুস্ট/অনুসরণ বিজ্ঞপ্তি +\n - টুটে পছন্দ/বুস্ট সংখ্যা +\n - অবতারে অনুসরণকারী/পরিসংখ্যান +\n +\nপুশ-বিজ্ঞপ্তিতে প্রভাব পরবে না, কিন্তু বিজ্ঞপ্তি পছন্দ পাল্টাতে পারবে। + এই অ্যাকাউন্ট নিয়ে তোমার ব্যক্তিগত লেখা + শীর্ষস্থানীয় সরঞ্জামের শিরোনামটি লুকাও + খসড়া মুছো হয়েছে + বিজ্ঞপ্তি + সুস্থতা + সময়হীন + সময়কাল + + %d সেকেন্ড বাকি + %d সেকেন্ড বাকি + + + %d মিনিট বাকি + %d মিনিট বাকি + + + %d ঘন্টা বাকি + %d ঘন্টা বাকি + + + %d দিন বাকি + %d দিন বাকি + + + %s জন + %s জন + + + %sটি ভোট + %sটি ভোট + + %1$s, %2$s এবং %3$d আরো অন্য জন + %1$s এবং %2$s + সদস্যতা + সদস্যতা বাতিল + + %s বুস্ট + %s বুস্ট + + %1$s স্থানান্তরিত হয়েছে এখানে: + সংযুক্তি + শব্দ + + %dটি নতুন ক্রিয়া + %dটি নতুন ক্রিয়া + + %1$s আর %2$s + %1$s, %2$s, আর %3$s + %s তোমাকে উল্লেখ করেছে \ No newline at end of file diff --git a/app/src/main/res/values-bn-rIN/strings.xml b/app/src/main/res/values-bn-rIN/strings.xml index 389f6569c..7c96ad083 100644 --- a/app/src/main/res/values-bn-rIN/strings.xml +++ b/app/src/main/res/values-bn-rIN/strings.xml @@ -102,7 +102,7 @@ গ্রহণ প্রত্যাখ্যান অনুসন্ধান - খসড়াগুলো + খসড়াগুলো টুট দৃশ্যমানতা সতর্কবার্তা ইমোজি কীবোর্ড @@ -473,7 +473,7 @@ ট্যাবের মাঝে সোয়াইপ সংকেত চালু করো টাইমলাইনে লিঙ্ক প্রিভিউ দেখাও তোমার কোনো সময়সূচীত স্ট্যাটাস নেই। - তোমার কোনো খসড়া নেই। + তোমার কোনো খসড়া নেই। মাস্টোডনের সর্বনিম্ন ৫ মিনিটের সময়সূচীর বিরতি আছে। শীর্ষস্থানীয় সরঞ্জামদণ্ডের শিরোনামটি লুকান \ No newline at end of file diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 168f2e121..06b52799d 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -80,7 +80,7 @@ D\'acord Rebutja Cerca - Esborranys + Esborranys S\'està baixant %1$s Copia l\'enllaç Comparteix l\'URL del toot a… @@ -433,7 +433,7 @@ S\'ha produït un error en cercar la publicació %s No tens cap estat planificat. Els fitxers d\'àudio han de ser de mida menor de 40MB. - No teniu cap esborrany. + No teniu cap esborrany. L\'interval mínim de planificació a Mastodon és de 5 minuts. Peticions de seguiment Mostra el diàleg de confirmació abans de promoure @@ -496,7 +496,6 @@ S\'ha esborrat el tut del qual en vau fer un esborrany de resposta S\'ha eliminat l\'esborrany No s\'ha pogut carregar la informació de la resposta - Esborranys antics No s\'ha pogut enviar aquest tut! Segur que voleu esborrar la llista %s\? diff --git a/app/src/main/res/values-ckb/strings.xml b/app/src/main/res/values-ckb/strings.xml index 5fb07bed5..c5702f28c 100644 --- a/app/src/main/res/values-ckb/strings.xml +++ b/app/src/main/res/values-ckb/strings.xml @@ -34,7 +34,7 @@ ئاگاداری ناوەڕۆک بینینی توت توتی خشتەکراو - ڕەشنووسەکان + ڕەشنووسەکان گەڕان ڕەتکردنەوە ڕازیبون @@ -257,7 +257,7 @@ ماستۆدۆن کەمترین ماوەی خشتەی هەیە لە ٥ خولەک. هیچ ڕاگه یه نراوێک له بەرده رنه کەون. هیچ بارێکی خشتەکراوت نیە. - هیچ ڕەشنووسێکت نییە. + هیچ ڕەشنووسێکت نییە. هەڵە لە گەڕان بەدوای بابەت %s دەستکاریکردن هەڵبژاردنی %d diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 3f3d6d690..d51b5cd8c 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -101,7 +101,7 @@ Přijmout Zamítnout Hledat - Koncepty + Koncepty Viditelnost tootu Varování o obsahu Klávesnice s emoji @@ -465,7 +465,7 @@ Ukazovat náhledy k odkazům Mastodon neumožňuje pracovat s intervalem menším než 5 minut. Zatím zde nemáte žádné naplánované statusy. - Zatím zde nejsou žádné koncepty. + Zatím zde nejsou žádné koncepty. Možnost přetahování prstem pro přechod mezi kartami Seznam Přidat hashtag @@ -481,7 +481,8 @@ Odkrýt oznámení od %s Odkrýt %s Ztišit @%s\? - %s požádal/a aby vás mohl/a sledovat Zobrazit dialogové okno s potvrzením při boostování + %s právě vydal + Oznámení \ No newline at end of file diff --git a/app/src/main/res/values-cy/strings.xml b/app/src/main/res/values-cy/strings.xml index 9d350b40a..b016d709d 100644 --- a/app/src/main/res/values-cy/strings.xml +++ b/app/src/main/res/values-cy/strings.xml @@ -92,7 +92,7 @@ Derbyn Gwrthod Chwilio - Drafftiau + Drafftiau Pwy all weld Tŵt Rhybudd cynnwys Bysellfwrdd emoji diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index bb5cf232a..de47f4bdd 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -101,7 +101,7 @@ Akzeptieren Ablehnen Suche - Entwürfe + Entwürfe Beitragssichtbarkeit Inhaltswarnung Emoji @@ -294,7 +294,7 @@ Die Blob–Emojis aus Android 4.4–7.1 Die Standard-Emojis von Mastodon Die aktuellen Emojis von Google - Download fehlgeschlagen. + Download fehlgeschlagen Bot %1$s ist umgezogen auf: An ursprüngliches Publikum teilen @@ -357,7 +357,7 @@ Beitrag erstellen Bot-Hinweis anzeigen Bist du dir sicher, dass du alle deine Benachrichtigungen dauerhaft löschen möchtest\? - " %1$s • %2$s" + %1$s • %2$s %s Stimme %s Stimmen @@ -365,8 +365,8 @@ endet um %s Geschlossen Abstimmen - Eine Umfrage in der du abgestimmt hast ist vorbei - Eine Umfrage die du erstellt hast ist vorbei + Eine Umfrage, in der du abgestimmt hast, ist vorbei + Eine Umfrage, die du erstellt hast, ist vorbei %d Tag verbleibend %d Tage verbleibend @@ -436,7 +436,7 @@ Liste auswählen Liste Fehler beim Nachschlagen von Post %s - Du hast keine Entwürfe. + Du hast keine Entwürfe. Du hast keine geplanten Beiträge. Das Datum des geplanten Toots muss mindestens 5 Minuten in der Zukunft liegen. Benachrichtigungen über neue Folgeanfragen @@ -480,9 +480,6 @@ Ankündigungen Der Beitrag auf den du antworten willst wurde gelöscht Entwurf gelöscht - Alte Entwürfe - Das \"Entwürfe\"-Feature in Tusky wurde komplett neu gestaltet um schneller und benutzerfreundlicher zu sein. -\nDu kannst deine alten Entwürfe noch hinter einem Button bei den neuen Entwürfen finden, aber sie werden mit einem zukünftigen Update gelöscht! Dieser Beitrag konnte nicht gesendet werden! Willst du die Liste %s wirklich löschen\? @@ -499,7 +496,7 @@ GIF-Emojis animieren Jemand, den ich abonniert habe, etwas Neues veröffentlicht %s hat gerade etwas gepostet - %dm + %d Min. Benachrichtigungen überprüfen Informationen, die dein Wohlbefinden beeinflussen könnten, werden versteckt. Das beinhaltet \n @@ -514,4 +511,10 @@ Timeline-Benachrichtigungen einschränken Abonnieren nicht mehr abonnieren + in %d M. + in %d St. + Antwortinformationen konnten nicht geladen werden + %d T. + in %d J. + in %d Sek. diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml new file mode 100644 index 000000000..4734fea97 --- /dev/null +++ b/app/src/main/res/values-el/strings.xml @@ -0,0 +1,6 @@ + + + Αυτό δεν μπορεί να είναι κενό. + Προέκυψε σφάλμα δικτύου! Παρακαλώ ελέγξτε τη σύνδεσή σας και προσπαθήστε ξανά! + Προέκυψε ένα σφάλμα. + \ No newline at end of file diff --git a/app/src/main/res/values-eo/strings.xml b/app/src/main/res/values-eo/strings.xml index 53ffd6ee7..3f5c5aeec 100644 --- a/app/src/main/res/values-eo/strings.xml +++ b/app/src/main/res/values-eo/strings.xml @@ -101,7 +101,7 @@ Rajtigi Rifuzi Serĉi - Malnetoj + Malnetoj Videblo de la mesaĝo Enhava averto Klavaro de emoĝioj @@ -438,7 +438,7 @@ Listo Eraro dum elserĉo de la mesaĝo %s Aŭdia dosiero devas esti malpli ol 40MB. - Vi ne havas iun ajn malneton. + Vi ne havas iun ajn malneton. Vi ne havas iun ajn planitan mesaĝon. Petoj de sekvado Kradvortoj diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 9e8d1a56f..0df2d7f7f 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -99,7 +99,7 @@ Aceptar Rechazar Buscar - Borradores + Borradores Visibilidad del estado Aviso de contenido Teclado de emojis @@ -451,7 +451,7 @@ Seleccionar lista Lista Los ficheros de audio deben ser menores de 40MB. - No tienes ningún borrador. + No tienes ningún borrador. No tienes ningún estado programado. Mastodon tiene un intervalo de programación mínimo de 5 minutos. Solicitudes @@ -506,9 +506,6 @@ El toot al que redactaste una respuesta ha sido eliminado Borrador eliminado Error al cargar la información de respuesta - Borradores antiguos - La función de borrador en Tusky se ha rediseñado por completo para que sea más rápida, más fácil de usar y con menos errores. -\nAún puede acceder a sus borradores antiguos a través de un botón en la pantalla de borradores nuevos, ¡pero se eliminarán en una actualización futura! ¡Este toot no se pudo enviar! ¿Realmente quieres eliminar la lista %s\? Indefinido diff --git a/app/src/main/res/values-eu/strings.xml b/app/src/main/res/values-eu/strings.xml index 15e4c8991..ff0685594 100644 --- a/app/src/main/res/values-eu/strings.xml +++ b/app/src/main/res/values-eu/strings.xml @@ -93,7 +93,7 @@ Onartu Ukatu Bilatu - Zirriborroak + Zirriborroak Tutaren ikusgarritasuna Edukiaren abisua Emoji teklatua @@ -444,7 +444,7 @@ Audioak 40MB baino gutxiago izan behar ditu. Aukeratu zerrenda Zerrenda - Ez duzu zirriborrorik. + Ez duzu zirriborrorik. Ez duzu tut programaturik. Mastodonek gutxienez 5 minutuko programazio-tartea du. Eskakizunak diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index b7f9240b0..5a5088644 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -93,7 +93,7 @@ پذیرش رد جست‌وجو - پیش‌نویس‌ها + پیش‌نویس‌ها نمایانی بوق هشدار محتوا صفحه‌کلید اموجی @@ -439,7 +439,7 @@ نشان‌شده گزینش فهرست فهرست - هیچ پیش‌نویسی ندارید. + هیچ پیش‌نویسی ندارید. هیچ وضعیت زمان‌بسته‌ای ندارید. ماستودون، بازهٔ زمان‌بندی‌ای با کمینهٔ ۵ دقیقه دارد. نمایش گفت‌وگوی تأیید، پیش از تقویت @@ -485,7 +485,6 @@ عدم اشتراک اشتراک پیش‌نویس حذف شد - پیش‌نویس‌های قدیمی فرستادن این بوق شکست خورد! نهفتن آمار کمی روی نمایه‌ها نهفتن آمار کمی روی فرسته‌ها @@ -509,8 +508,6 @@ \n - آمار پی‌گیر و فرسته روی نمایه‌ها \n \n فرستادن آگاهی‌ها تأثیر نمی‌پذیرد، ولی می‌توانید ترجیحات آگاهیتان را به صورت دستی بازبینی کنید. - ویژگی پیش‌نویس در تاسکی به صورت کامل بازطرّاحی شده تا سریع‌تر، کاربرپسندتر و کم‌مشکل‌تر باشد. -\n همجنان می‌توانید از طریق دکمه‌ای دز صفحهٔ پیش‌نویس‌های جدید، به پیش‌نویس‌های قدیمیتان دسترسی داشته باشید، ولی در به‌روز رسانی آینده برداشته خواهند شد! واقعاً می‌خواهید فهرست %s را حذف کنید؟ نمی‌توانید بیش از %1$d رسانه بارگذارید. diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml new file mode 100644 index 000000000..3638aeafc --- /dev/null +++ b/app/src/main/res/values-fi/strings.xml @@ -0,0 +1,170 @@ + + + Animoi mukautetut emojit + Animoi GIF-avatarit + Seuraa laitteen teemaa + Lopeta tilin seuraaminen\? + Poista tuuttaus\? + Mikä on instanssi\? + Kopioi linkki + Avaa selaimessa + Kirjaudu Mastodonilla + Muokkaa profiilia + Luonnos poistettu + Vaihtoehto %d + Monivalinta + Lisää vaihtoehto + 7 päivää + 3 päivää + 1 päivä + 6 tuntia + 1 tunti + 30 minuuttia + 5 minuuttia + Lisää hashtag + Ei kuvausta + CC-BY-SA 4.0 + CC-BY 4.0 + Lataus epäonnistui + Avaa tuuttaus + Järjestelmän oletus + Emojien tyyli + Lähetetään tuuttausta… + Tallennetaanko luonnoksena\? + Lukitse tili + Lisää tili + Seuraa sinua + Tuskyn profiili + Tusky %s + Lukittu tili + Uusia tuuttauksia + Seuraamispyynnöt + Uusia seuraajia + Uusia mainintoja + HTTP-välityspalvelin + Näytä vastaukset + Teema + Ei tuloksia + Sisältövaroitus + Mitä tapahtuu\? + Mikä instanssi\? + Ladataan mediaa + Lataa media + Näytä suosikit + Lisää välilehti + Ajasta tuuttaus + Emoji-näppäimistö + Sisältövaroitus + Ajastetut tuuttaukset + Muokkaa profiilia + Piilota media + Ota kuva + Lisää kysely + Lisää media + Seuraamispyynnöt + Estetyt tilit + Tiliasetukset + Kirjaudu ulos + Näytä vähemmän + Näytä lisää + Media piilotettu + Seuraamispyynnöt + Estetyt tilit + Mykistetyt tilit + Viestit + Tallennettu! + Muokkaa + Kysely + Tilit + Valmis + Takaisin + Jatka + Äänestä + suljettu + Suodatin + Lista + Hastagit + Julkinen + Kiinnitä + Poista kiinnitys + Botti + Poista + Listat + Listat + Päivitä + Poista + Audio + Video + Kuvat + Tietoja + Välityspalvelin + Vain seuraajat + Julkinen + Välilehdet + Kieli + Selain + Musta + Vaalea + Tumma + Suodattimet + Aikajanat + seurasi + mainitsi + Ilmoitukset + Ilmoitukset + Lataa + Profiilikuva + Vastaa… + Hae… + Kuvaus + Linkit + Maininnat + Hastagit + Hastagit + Maininnat + Linkit + Nollaa + Luonnokset + Hae + Älä hyväksy + Hyväksy + Peruuta + Muokkaa + Tallenna + Mainitse + Poista mykistys + Mykistä + Jaa + Media + Kirjanmerkit + Suosikit + Asetukset + Profiili + Sulje + Yritä uudelleen + TUUTTAUS + Poista + Muokkaa + Ilmianna + Poista esto + Estä + Seurataan + Seuraa + TUUTAA! + Tuuttaus + Ajastetut tuuttaukset + Vastaa + \@%s + Lisenssit + Luonnokset + Suosikit + Kirjanmerkit + Seuraajat + Seurataan + Kiinnitetty + Julkaisut + Välilehdet + Paikallinen + Ilmoitukset + Koti + \ No newline at end of file diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index a71ea8707..7248a77f4 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -101,7 +101,7 @@ Accepter Refuser Rechercher - Brouillons + Brouillons Visibilité du pouet Contenu sensible Clavier d’émojis @@ -456,7 +456,7 @@ Sélectionner la liste Liste Les fichiers audio doivent avoir moins de 40 Mo. - Vous n’avez aucun brouillon. + Vous n’avez aucun brouillon. Vous n’avez aucun pouet planifié. L’intervalle minimum de planification sur Mastodon est de 5 minutes. Demandes d\'abonnement diff --git a/app/src/main/res/values-ga/strings.xml b/app/src/main/res/values-ga/strings.xml index 18261a959..7ebc79bac 100644 --- a/app/src/main/res/values-ga/strings.xml +++ b/app/src/main/res/values-ga/strings.xml @@ -109,7 +109,7 @@ Rabhadh ábhair Infheictheacht tút Tútanna sceidealta - Dréachtaí + Dréachtaí Diúltaigh Glac Cealaigh @@ -463,7 +463,7 @@ Rogha %d Cuir in Eagar Earráid agus an post á lorg %s - Níl aon dréachtaí agat. + Níl aon dréachtaí agat. Níl aon stádas sceidealta agat. Tá eatramh sceidealaithe íosta 5 nóiméad ag Mastodon. Taispeáin réamhamhairc nasc in amlínte diff --git a/app/src/main/res/values-gd/strings.xml b/app/src/main/res/values-gd/strings.xml index c9d0d5fc0..ff23b95df 100644 --- a/app/src/main/res/values-gd/strings.xml +++ b/app/src/main/res/values-gd/strings.xml @@ -26,14 +26,14 @@ Freagair… Lorg… Clàraich a-steach le Mastodon - DÙD! + POSTAICH! Meur-chlàr Emoji Na lean tuilleadh Lean Barrachd Feuch ris a-rithist Dùin - DÙD + POSTAICH Sguab às Sguab às is dèan dreachd ùr air Dèan gearan @@ -47,7 +47,7 @@ Sgrìobh Seall na brosnachaidhean Seall na brosnachaidhean - Dreachdan + Dreachdan Annsachdan Brathan Brathan @@ -55,12 +55,9 @@ Cuir crìoch air an fho-sgrìobhadh Fo-sgrìobh Beòthaich na h-Emojis gnàthaichte - Bha againn ris an dùd a bha thu airson freagairt dha a thoirt air falbh + Bha againn ris a’ phost a bha thu airson freagairt dha a thoirt air falbh Chaidh an dreach a sguabadh às Cha deach leinn fiosrachadh na freagairte a luchdadh - Seann-dreachdan - Chaidh dealbhadh gu tur ùr a chur air gleus nan dreachdan aig Tusky ach am biodh e nas luaithe, nas fhasa cleachdadh is nas lugha de bhugaichean ann. -\n Gheibh thu grèim air na seann-dreachdan agad fhathast le putan air sgrìn ùr nan dreachdan ach thèid an toirt air falbh le ùrachadh ri teachd! Cha b’ urrainn dhuinn an dùd a chur! Ceanglachain Fuaim @@ -87,14 +84,14 @@ Thèid cuid a dh’fhiosrachadh a dh’fhaodadh droch-bhuaidh a thoirt air d’ shlàinte-inntinn fhalach. Tha seo a’ gabhail a-staigh: \n \n - Brathan air annsachdan/brosnachaidhean/leantainn -\n - Cunntas nan annsachdan/brosnachaidhean air dùdan +\n - Cunntas nan annsachdan/brosnachaidhean air postaichean \n - Stadastaireachd an luchd-leantainn/nam postaichean air pròifilean \n \n Cha doir seo buaidh air na brathan-putaidh ach ’s urrainn dhut roghainnean nam brathan agad atharrachadh a làimh. Slàinte-inntinn - Brathan nuair a dh’fhoillsich cuideigin air a rinn mi fo-sgrìobhadh dùd ùr - Dùdan ùra - dh’fhoillsich cuideigin air a rinn mi fo-sgrìobhadh dùd ùr + Brathan nuair a dh’fhoillsich cuideigin air a rinn mi fo-sgrìobhadh post ùr + Postaichean ùra + dh’fhoillsich cuideigin air a rinn mi fo-sgrìobhadh post ùr Tha %s air rud a phostadh Chan eil brath-fios ann. Brathan-fios @@ -108,7 +105,7 @@ Seall ro-sheallaidhean air ceanglaichean sna loidhnichean-ama Feumaidh co-dhiù 5 mionaidean a bhith eadar staidean sgeidealaichte air Mastodon. Chan eil staid sam bith air an sgeideal agad. - Chan eil dreachd sam bith agad. + Chan eil dreachd sam bith agad. Thachair mearachd le lorg a’ phuist %s Roghainn %d Iomadh roghainn @@ -172,7 +169,7 @@ %1$s • %2$s Gnìomhan dhan dealbh %s A bheil thu cinnteach gu bheil thu airson na brathan uile agad fhalamhachadh gu buan\? - Sgrìobh dùd + Sgrìobh post Cuir an sàs Criathraich Falamhaich @@ -236,7 +233,7 @@ Uaireigin eile Feumaidh tu Tusky ath-thòiseachadh gus na roghainnean seo a chur an sàs Feumaidh tu an aplacaid ath-thòiseachadh - Fosgail an dùd + Fosgail am post Leudaich/Co-theannaich gach staid ’Ga lorg… Feumaidh tu na seataichean seo de dh’Emojis a luchdadh a-nuas an toiseach @@ -244,11 +241,11 @@ Stoidhle nan Emojis Chaidh lethbhreac dheth a chur air an stòr-bhòrd Chan eil Emojis gnàthaichte aig an ionstans %s agad - Chaidh lethbhreac dhen dùd agad a shàbhaladh ’na dhreachd + Chaidh lethbhreac dhen phost agad a shàbhaladh ’na dhreachd Chaidh sgur dhen chur - A’ cur nan dùd - Mearachd a’ cur an dùid - A’ cur an dùid… + A’ cur nam post + Mearachd a’ cur a’ phuist + A’ cur a’ phuist… A bheil thu airson a shàbhaladh ’na dhreachd\? Feumaidh tu gabhail ri luchd-leantainn ùr a làimh Glais an cunntas @@ -273,14 +270,14 @@ Cuir cunntas ris An abairt ri chriathradh Mur eil ach litrichean is àireamhan san fhacal-luirg, cha dèid a chur an sàs ach ma bhios e a’ maidseadh an fhacail shlàin - Leudaich dùdan ris a bheil rabhadh susbainte an-còmhnaidh - Co-roinn ceangal dhan dùd - Co-roinn susbaint an dùid + Leudaich postaichean ris a bheil rabhadh susbainte an-còmhnaidh + Co-roinn ceangal dhan phost + Co-roinn susbaint a’ phuist ’S e bathar-bog saor le bun-tùs fosgailte a th’ ann an Tusky. Tha e fo cheadachas GNU General Public License tionndadh 3. Chì thu an ceadachas an-seo: https://www.gnu.org/licenses/gpl-3.0.en.html - Brathan nuair a thèid dùd agad a chomharrachadh ’na annsachd - Brathan nuair a thèid dùd agad brosnachadh - A bheil thu airson an dùd seo a sguabadh às is dreachd ùr a dhèanamh air\? - A bheil thu airson an dùd seo a sguabadh às\? + Brathan nuair a thèid post agad a chomharrachadh ’na annsachd + Brathan nuair a thèid post agad brosnachadh + A bheil thu airson am post seo a sguabadh às is dreachd ùr a dhèanamh air\? + A bheil thu airson am post seo a sguabadh às\? ’S urrainn dhut seòladh no àrainn-lìn aig ionstans sam bith a chur a-steach an-seo, can mastodon.social, icosahedron.website, social.tchncs.de agus a bharrachd! \n \nMur eil cunntas agad fhathast, cuir a-steach ainm an ionstans sa bheil thu airson ballrachd fhaighinn airson cunntas a chruthachadh ann. @@ -288,16 +285,16 @@ \n’S e an t-aon àite far an cruthaich thu cunntas a th’ ann an ionstans ud ’s a nì an t-òstadh dhan chunntas agad. Gidheadh, ’s urrainn dhut conaltradh le daoine a tha air ionstans eile agus leantainn orra mar gun robh sibh air an aon làrach. \n \nGheibh thu barrachd fiosrachaidh air joinmastodon.org.
- Co-roinn an dùd le… - Co-roinn URL an dùid le… - Cuir dùd air an sgeideal - Faicsinneachd an dùid - Dùdan air an sgeideal - Chuir %s an dùd agad ris na h-annsachdan - Bhrosnaich %s an dùd agad - Dùdan air an sgeideal - Dùd - Mearachd a’ cur an dùd. + Co-roinn am post le… + Co-roinn URL a’ phuist le… + Cuir post air an sgeideal + Faicsinneachd a’ phuist + Postaichean air an sgeideal + Chuir %s am post agad ris na h-annsachdan + Bhrosnaich %s am post agad + Postaichean air an sgeideal + Post + Mearachd a’ cur a’ phuist. Dì-mhùch %s Tagaichean hais Luchd-leantainn @@ -495,7 +492,7 @@ Briog air gus a shealltainn Meadhanan falaichte Susbaint fhrionasach - Bhrosnaich %s + ’Ga bhrosnachadh le %s \@%s Ceadachasan Iarrtasan leantainn @@ -532,4 +529,5 @@ Chan fhaod seo a bhith falamh. Thachair mearachd leis an lìonra! Thoir sùil air a’ cheangal agad is feuch ris a-rithist! Thachair mearachd. + Ged nach eil an cunntas agad glaiste, tha sgioba %1$s dhen bheachd gum b’ fheàirrde thu lèirmheas a dhèanamh air na h-iarrtasan leantainn o na cunntasan seo a làimh. \ No newline at end of file diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index bbba46cfe..d63b34135 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -3,7 +3,7 @@ Aviso sobre o contido Visibilidade do toot Toots programados - Borradores + Borradores Buscar Rexeitar Aceptar @@ -438,9 +438,6 @@ Que contas\? Borrador eliminado Fallou a carga da información da Resposta - Borradores antigos - Os borradores en Tusky foron redeseñados para ser máis rápidos, amigables para a usuaria e con menos fallos. -\nAínda podes acceder aos antigos borradores a través do botón na pantalla de novos borradores, pero eliminarémolo en futuras actualizacións! Fallou o envío do toot! Tes a certeza de querer eliminar a listaxe %s\? @@ -467,7 +464,7 @@ Mastodon ten un intervalo mínimo de 5 minutos para as programacións. Non hai anuncios. Non tes estados programados. - Non tes borradores. + Non tes borradores. Erro ao buscar publicación %s Editar Opción %d @@ -511,4 +508,6 @@ Programar Toot Teclado Emoji Aínda que a túa conta non está bloqueada, a administración de %1$s opina que debes revisar manualmente as peticións de seguimento destas contas. + Eliminar esta conversa\? + Eliminar conversa \ No newline at end of file diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index dd1a60e86..ddd50890a 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -152,7 +152,7 @@ टूट दृश्यता अनुसूचित टूट स्वीकार करें - ड्राफ्ट + ड्राफ्ट अस्वीकार करें पूर्ववत करें संपादित करें @@ -257,7 +257,7 @@ और लोड करें टाइमलाइन में लिंक प्रीव्यू दिखाएं मास्टोडन का न्यूनतम शेड्यूलिंग अंतराल 5 मिनट है। - आपके पास कोई ड्राफ्ट नहीं है। + आपके पास कोई ड्राफ्ट नहीं है। %s पोस्ट खोजने में त्रुटि टैब के बीच स्विच करने के लिए स्वाइप जेस्चर को सक्षम करें सूचना फ़िल्टर दिखाएं diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 39d9bb7a7..ef4d62b90 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -60,7 +60,7 @@ Kedvencnek jelölés Több Szerkesztés - Bejelentkezés Mastodon-nal + Bejelentkezés Mastodonnal Kijelentkezés Biztosan ki szeretnél jelentkezni a következőből: %1$s? Követés @@ -99,7 +99,7 @@ Elfogadás Elutasítás Keresés - Piszkozatok + Piszkozatok Tülkök láthatósága Tartalom figyelmeztetés Emoji billentyűzet @@ -182,7 +182,7 @@ HTTP proxy szerver HTTP Proxy port Tülkök alapértelmezett láthatósága - Minden média szenzitívnek jelölése + Minden média kényesnek jelölése A beállítások szinkronizálása nem sikerült Nyilvános Listázatlan @@ -231,11 +231,11 @@ Követés kérelmezve Követ téged - Mindig mutassa a szenzitív tartalmat + Mindig mutassa a kényes tartalmat Média több betöltése Fiók hozzáadása - Új Mastodon fiók hozzáadása + Új Mastodon-fiók hozzáadása Listák Listák Törlés @@ -255,12 +255,12 @@ Keresés… Tülk megnyitása Az app újraindítása szükséges - A beállítások érvényesítéséhez újra kell indítani a Tusky-t + A beállítások érvényesítéséhez újra kell indítani a Tuskyt Később Újraindítás - Az eszközöd alapértelmezett emoji készlete - Az Android 4.4-7.1 Blob emoji-jai - Mastodon alapértelmezett emoji készlet + Az eszközöd alapértelmezett emodzsi készlete + Az Android 4.4–7.1 Blob emodzsijai + A Mastodon alapértelmezett emodzsi készlete Letöltés sikertelen Bot %1$s elköltözött: @@ -351,7 +351,7 @@ Cím beállítása Minden követődet külön engedélyezned kell Minden tülk kibontása/összecsukása - Google jelenlegi emoji készlete + A Google jelenlegi emodzsi készlete Megtolás az eredeti közönségnek Megtolás visszavonása Apache licensz alatt @@ -379,10 +379,10 @@ Törlés Szűrés Alkalmaz - Tülk Szerkesztése + Tülk szerkesztése Szerkesztés Biztos, hogy minden értesítésedet véglegesen törlöd\? - Műveletek a %s képpel + Műveletek a(z) %s képpel %1$s • %2$s %s szavazat @@ -450,13 +450,13 @@ Lista kiválasztása Lista A hangfájloknak kisebbnek kell lenniük, mint 40 MB. - Nincs egy piszkozatod sem. + Nincs egy piszkozatod sem. Nincs egy ütemezett tülköd sem. A Mastodonban a legrövidebb ütemezhető időintervallum 5 perc. Követési kérelmek Jóváhagyó ablak mutatása megtolás előtt Hivatkozás előnézetének mutatása idővonalakon - Tabok közötti váltás engedélyezése csúsztatással + Lapok közötti váltás engedélyezése csúsztatással %s személy %s személy @@ -484,12 +484,9 @@ Saját, mások számára nem látható megjegyzés erről a fiókról Nincsenek közlemények. Közlemények - A Tülköt, melyre válaszul piszkozatot készítettél törölték + A tülköt, melyre válaszul piszkozatot készítettél törölték Piszkozat törölve Nem sikerült a Válasz információit betölteni - Régi Piszkozatok - A Tusky piszkozat funkcióját teljesen újraterveztük, hogy gyorsabb, felhasználóbarátabb és hibamentesebb legyen. -\nTovábbra is elérheted a régi piszkozataidat egy gombbal az új piszkozatok képernyőjén, de ezeket egy későbbi frissítésben el fogjuk törölni! Ez a tülk nem küldődött el! Tényleg le akarod törölni a %s listát\? @@ -519,4 +516,5 @@ Egyedi emojik animálása Leiratkozás Feliratkozás + Bár a fiókod nincs zárolva, a %1$s csapata úgy gondolta, hogy ezen fiókok követési kérelmeit átnéznéd. diff --git a/app/src/main/res/values-is/strings.xml b/app/src/main/res/values-is/strings.xml index d73446e2f..af8b146e6 100644 --- a/app/src/main/res/values-is/strings.xml +++ b/app/src/main/res/values-is/strings.xml @@ -115,7 +115,7 @@ Afturkalla Samþykkja Hafna - Drög + Drög Áætluð tíst Sýnileiki tísts Aðvörun vegna efnis @@ -244,6 +244,7 @@ %1$s, %2$s og %3$s %1$s og %2$s + %d ný aðgerð %d nýjar aðgerðir Læstur notandaaðgangur @@ -354,6 +355,7 @@ %1$s og %2$s %1$s, %2$s og %3$d til viðbótar + hámarksfjölda %1$d flipa náð hámarksfjölda %1$d flipa náð Myndefni: %s @@ -414,7 +416,7 @@ Valkostur %d Breyta Villa við að fletta upp færslunni %s - Þú ert ekki með nein drög. + Þú ert ekki með nein drög. Þú ert ekki með neinar áætlaðar stöðufærslur. Hljóðskrár verða að vera minni en 40MB. Mastodon er með 5 mínútna lágmarksbil fyrir áætlaðar aðgerðir. @@ -473,8 +475,6 @@ Afþagga %s %s bað um að fylgjast með þér Tilkynningar - Gerð draga í Tusky hefur verið endurhönnuð til að verða fljótlegri, notendavænni og gallalaus. -\n Þú getur áfram nýtt eldri drög í gegnum sérstakan hnapp í glugganum fyrir drög, en sá eiginleiki verður fjarlægður í framtíðaruppfærslu! Sumar upplýsingar sem gætu haft áhrif á andlega vellíðan þína verða faldar. Þetta hefur áhrif á: \n \n - Eftirlæti/Endurbirtingar/Tilkynningar um fylgjendabeiðnir @@ -488,7 +488,6 @@ Tístið sem þú gerðir drög að svari við hefur veriið fjarlægt Eyddi drögum Mistókst að hlaða inn svarupplýsingum - Eldri drög Mistókst að senda þetta tíst! Viðhengi Hljóð @@ -496,6 +495,7 @@ Ótiltekið Tímalengd + Þú getur ekki sent inn fleiri en %1$d myndefnisviðhengi. Þú getur ekki sent inn fleiri en %1$d myndefnisviðhengi. Fela magntölfræði notendasniða @@ -507,4 +507,5 @@ Ný tíst einhver sem ég er áskrifandi að birti nýtt tíst %s sendi inn rétt í þessu + Jafnvel þótt aðgangurinn þinn sé ekki læstur, fannst starfsfólki %1$s að þú gætir viljað yfirfara handvirkt fylgjendabeiðnir frá þessum aðgöngum. \ No newline at end of file diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 3fccd40f1..76520ddc3 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -101,7 +101,7 @@ Accetta Rifiuta Cerca - Bozze + Bozze Visibilità dei toot Avviso per il contenuto Tastiera emoji @@ -217,6 +217,7 @@ %1$s, %2$s e %3$s %1$s e %2$s + %d nuova interazione %d nuove interazioni Account bloccato @@ -341,6 +342,7 @@ %1$s e %2$s %1$s, %2$s ed altri %3$d + limite massimo di %1$d tab raggiunto limite massimo di %1$d tab raggiunto Media: %s @@ -454,7 +456,7 @@ Programma un toot RIpristina %1$s • %2$s - Non hai bozze. + Non hai bozze. %s persona %s persone @@ -499,6 +501,16 @@ qualcuno a cui sono iscritto ha pubblicato un nuovo toot %s appena pubblicato + Non puoi caricare più di %1$d allegato multimediale. Non puoi caricare più di %1$d allegati multimediali. + Il toot a cui hai scritto una risposta è stato rimosso + Bozza cancellata + L\'invio di questo toot è fallito! + Sei sicuro di voler cancellare la lista %s\? + Indefinita + Durata + Allegati + Audio + Mostra le animazioni delle emojis personalizzate \ No newline at end of file diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 9918d567b..2430f27f4 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -100,7 +100,7 @@ 許可 拒否 検索 - 下書き + 下書き トゥートの公開範囲 注意書き 絵文字キーボード @@ -421,7 +421,7 @@ %sさんがあなたにフォローリクエストしました \@%sさんを通報しました 予約した投稿はありません。 - 下書きはありません。 + 下書きはありません。 項目 %d このアカウントは外部のサーバーにあります。匿名化された通報の複製をそちらにも送信しますか? 通報をサーバーのモデレーターに送信します。以下にこのアカウントを通報理由を入力できます: @@ -474,4 +474,8 @@ Mastodonにおける予約までの最小間隔は5分です。 %sさんがトゥートしました お知らせ + 本当に %s のすべてをブロックするのですか? そのドメインからのコンテンツは、公開タイムラインや通知に表示されなくなります。また、そのドメインのフォロワーは削除されます。 + 音声 + ドメイン全体を非表示 + Tuskyによって提供されています diff --git a/app/src/main/res/values-kab/strings.xml b/app/src/main/res/values-kab/strings.xml index ad5a99d92..d63c92792 100644 --- a/app/src/main/res/values-kab/strings.xml +++ b/app/src/main/res/values-kab/strings.xml @@ -40,14 +40,14 @@ Ldi deg uminig Bḍu Sgugem - Irewwayen + Irewwayen Sken-d ismenyifen Ismenyifen %1$s n usmenyaf %1$s n ismenyifen - Ur tesɛiḍ ara irewwayen. + Ur tesɛiḍ ara irewwayen. Tella-d tucḍa. Tilɣa D acu i ttummant\? diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index c955454a1..b4e2bf624 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -105,7 +105,7 @@ 수락 거절 검색 - 임시 저장 + 임시 저장 공개 범위 열람 주의 이모지 추가 diff --git a/app/src/main/res/values-ml/strings.xml b/app/src/main/res/values-ml/strings.xml index 4f4a7868b..81a89d731 100644 --- a/app/src/main/res/values-ml/strings.xml +++ b/app/src/main/res/values-ml/strings.xml @@ -106,7 +106,7 @@ പിന്തുടാനുള്ള അപേക്ഷകൾ ബൂട്ട്‌സ് കാണിക്കുക മുന്‍നിശ്ചയിച്ച ടൂറ്റ്‌സ് - കരടുകൾ + കരടുകൾ തിരുത്ത് അറിയിപ്പുകൾ ടാബുകൾ diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index c28dac9b0..0cd05164e 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -18,7 +18,7 @@ Er is toestemming nodig om media op te slaan. Afbeeldingen en video\'s kunnen niet allebei aan dezelfde toot worden toegevoegd. Uploaden mislukt. - Fout tijdens verzenden toot, + Fout tijdens verzenden toot. Start Meldingen Lokaal @@ -53,7 +53,7 @@ %s markeerde jouw toot als favoriet %s volgt jou Rapporteer @%s - Extra opmerkingen + Extra opmerkingen\? Snelle reactie Reageren Boosten @@ -101,7 +101,7 @@ Goedkeuren Afwijzen Zoeken - Concepten + Concepten Zichtbaarheid toot Tekstwaarschuwing Emojis @@ -216,6 +216,7 @@ %1$s, %2$s en %3$s %1$s en %2$s + %d nieuwe interactie %d nieuwe interacties Besloten account @@ -321,6 +322,7 @@ %1$s en %2$s %1$s, %2$s en %3$d meer + maximum van %1$d tab bereikt maximum van %1$d tabs bereikt Media: %s @@ -396,16 +398,16 @@ Poll met keuzes: %s, %s, %s, %s; %s Acties voor afbeelding %s - %d dag over - %d dagen over + %d dag te gaan + %d dagen te gaan - %d uur over - %d uur over + %d uur te gaan + %d uur te gaan - %d minuut over - %d minuten over + %d minuut te gaan + %d minuten te gaan %d seconde over @@ -454,9 +456,65 @@ Zoeken mislukt Poll Fout tijdens opzoeken toot %s - Je hebt nog geen concepten + Je hebt nog geen concepten Je hebt nog geen ingeplande toots Om in te plannen moet je in Mastodon een minimum interval van 5 minuten gebruiken. Volgverzoeken Hashtags + Bijlagen + volgverzoek verstuurd + Afmelden + Abonneren + De toot waarvoor jij een reactie had opgesteld, is verwijderd + Het versturen van deze toot is mislukt! + Weet je zeker dat je de lijst %s wilt verwijderen\? + + Je kan niet meer dan %1$d mediabijlage uploaden. + Je kan niet meer dan %1$d mediabijlagen uploaden. + + Meldingen op tijdlijn beperken + Opgeslagen! + Jouw eigen opmerking over dit account + Welzijn + De titel van de bovenste statusbalk verbergen + Vraag voor het boosten van een toot een bevestiging + Linkpreviews in tijdlijnen weergeven + Er zijn geen aankondigingen. + Oneindig + Looptijd + Swipebewegingen om tussen tabs te schakelen inschakelen + + %s persoon + %s personen + + Hashtag toevoegen + Geluid + Meldingen wanneer iemand waar je op bent geabonneerd een nieuwe toot plaatst + Nieuwe toots + Meldingen over volgverzoeken + Onder + Boven + Lokale emojis animeren + Kleurverloop weergeven voor verborgen media + iemand waar ik op ben geabonneerd heeft een nieuwe toot geplaatst + Meldingen verbergen + \@%s negeren\? + \@%s blokkeren\? + Gesprek niet meer negeren + Gesprek negeren + %s niet meer negeren + Meldingen van %s negeren + Meldingen van %s niet meer negeren + %s niet meer negeren + %s heeft net een toot geplaatst + %s verzoekt u te volgen + Aankondigingen + Meldingen beoordelen + Concept verwijderd + Kwantitatieve statistieken voor toots verbergen + Laden van reactie-informatie mislukt + Kwantitatieve statistieken in profielen verbergen + Hoofd navigatiepositie + Dit gesprek verwijderen\? + Gesprek verwijderen \ No newline at end of file diff --git a/app/src/main/res/values-no-rNB/strings.xml b/app/src/main/res/values-no-rNB/strings.xml index 6e91f029a..bb2b4fad0 100644 --- a/app/src/main/res/values-no-rNB/strings.xml +++ b/app/src/main/res/values-no-rNB/strings.xml @@ -101,7 +101,7 @@ Aksepter Avvis Søk - Kladder + Kladder Toot-synlighet Innholdsadvarsel Emoji-tastatur @@ -441,7 +441,7 @@ Velg liste Liste Du har ingen planlagte statuser. - Du har ikke lagret noen kladder. + Du har ikke lagret noen kladder. Lydfiler må være mindre enn 40MB. Mastodon har et minimums planleggingsinterval på 5 minutter. Vis forhåndsvisning av linker i tidslinjer @@ -503,12 +503,11 @@ Tootet du kladdet et svar til har blitt fjernet Kladd slettet Lasting av svarinformasjon feilet - Gamle kladder - KladdfunksjonaLiteten i Tusky er skrevet om og er nå kjappere, mer brukervennlig, og med færre feil. -\nGamle kladder er fortsatt tilgjengelige via en knapp på den nye kladdskjermen, men de vil bli fjernet i en fremtidig oppdatering! Sending av toot feilet! Animer egendefinerte emojis Avslutt abonnementet Abonner Selv om kontoen din ikke er låst, har %1$s administratorer markert disse følgeforespørsler for manuell godkjenning. + Slette denne samtalen\? + Slett samtale \ No newline at end of file diff --git a/app/src/main/res/values-oc/strings.xml b/app/src/main/res/values-oc/strings.xml index bc9b2a65f..e76066db5 100644 --- a/app/src/main/res/values-oc/strings.xml +++ b/app/src/main/res/values-oc/strings.xml @@ -91,7 +91,7 @@ Acceptar Regetar Cercar - Borrolhons + Borrolhons Visibilitat del tut Avis de contengut Clavièr Emoji @@ -450,7 +450,7 @@ Seleccionar la list Lista Los fichièrs àudio devon èsser inferiors a 40 Mo. - Avètz pas cap de borrolhon. + Avètz pas cap de borrolhon. Avètz pas cap de tut planificat. L’interval minimum de planificacion sus Mastodon e de 5 minutas. Demandas d’abonament diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 01232072b..b3613b889 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -89,7 +89,7 @@ Akceptuj Odrzuć Szukaj - Szkice + Szkice Widoczność wpisu Ostrzeżenie o zawartości Klawiatura emoji @@ -461,7 +461,7 @@ Wybierz listę Lista Pliki audio muszą być mniejsze niż 40MB. - Nie masz żadnych szkiców. + Nie masz żadnych szkiców. Nie masz żadnych zaplanowanych wpisów. Mastodon umożliwia wysłanie minimalnie 5 minut od zaplanowania. Prośby o możliwość śledzenia diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 02db8f4d4..6b5033d1c 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -58,7 +58,7 @@ Compor Entrar com Mastodon Sair - Você tem certeza de que deseja sair da conta %1$s\? + Tem certeza de que deseja sair da conta %1$s\? Seguir Deixar de seguir Bloquear @@ -94,7 +94,7 @@ Aceitar Rejeitar Pesquisar - Rascunhos + Rascunhos Privacidade do toot Aviso de Conteúdo Teclado de emojis @@ -131,9 +131,9 @@ Conectando… O domínio de qualquer instância pode ser inserido aqui, como mastodon.social, masto.donte.com.br, colorid.es ou qualquer outro! \n -\n Se você não tem uma conta ainda, você pode inserir o nome da instância a qual você gostaria de participar e criar uma conta lá. +\n Se não tem uma conta ainda, insira o nome da instância que gostaria de participar e crie uma conta lá. \n -\n Uma instância é um lugar onde sua conta é hospedada, mas você pode facilmente se comunicar e seguir pessoas de outras instâncias como se vocês estivessem no mesmo site. +\n Uma instância é um lugar onde sua conta é hospedada, mas é fácil se comunicar e seguir pessoas de outras instâncias como se todos estivessem no mesmo site. \n \n Mais informações podem ser encontradas em joinmastodon.org. Envio de mídia terminando @@ -203,14 +203,13 @@ %1$s, %2$s, e %3$s %1$s e %2$s + %d nova interação %d novas interações - Conta trancada + Perfil trancado Sobre Yuito %s - Yuito é um software livre e de código aberto. - Ele é licenciado sob a versão 3 da Licença Pública Geral GNU. - Você pode ler a licença aqui: https://www.gnu.org/licenses/gpl-3.0.pt-br.html + Yuito é um software livre e de código aberto. Ele é licenciado sob a versão 3 da Licença Pública Geral GNU. Leia a licença aqui: https://www.gnu.org/licenses/gpl-3.0.pt-br.html %1$s • %2$s @@ -382,19 +382,19 @@ Sua enquete terminou %d dia restante - %d dias restante + %d dias restantes %d hora restante - %d horas restante + %d horas restantes %d minuto restante - %d minutos restante + %d minutos restantes %d segundo restante - %d segundos restante + %d segundos restantes Reproduzir GIFs Enquete com as opções: %1$s, %2$s, %3$s, %4$s; %5$s @@ -407,13 +407,13 @@ Encaminhar para %s Erro ao denunciar Erro ao carregar toots - A denúncia será enviada aos moderadores da instância. Você pode explicar por que você denunciou a conta: + A denúncia será enviada aos moderadores da instância. Explique por que denunciou a conta: A conta está em outra instância. Enviar uma cópia anônima da denúncia para lá\? Instâncias bloqueadas Instâncias bloqueadas Bloquear %s %s desbloqueada - Você tem certeza de que deseja bloquear tudo de %s\? Você não verá mais o conteúdo desta instância em nenhuma linha do tempo pública ou nas suas notificações. Seus seguidores desta instância serão removidos. + Tem certeza de que deseja bloquear tudo de %s\? Você não verá mais o conteúdo desta instância em nenhuma linha do tempo pública ou nas suas notificações. Seus seguidores desta instância serão removidos. Bloquear instância Mostrar filtro de notificações Toda palavra @@ -449,7 +449,7 @@ Lista Sem toots agendados. O áudio deve ser menor que 40MB. - Sem rascunhos. + Sem rascunhos. Mastodon possui um intervalo mínimo de 5 minutos para agendar. Seguidores pendentes %s quer te seguir @@ -484,10 +484,8 @@ Erro ao enviar o toot! O toot em que se rascunhou uma resposta foi excluído Rascunho excluído - A função de rascunhos no Tusky foi totalmente redesenhada para ser mais rápida, mais fácil e com menos erros. -\nÉ possível acessar rascunhos antigos através de um botão na tela de novos rascunhos, mas serão removidos numa futura atualização! - Rascunhos antigos + Não é possível anexar mais de %1$d arquivo de mídia. Não é possível anexar mais de %1$d arquivos de mídia. Ocultar status dos perfis @@ -502,7 +500,7 @@ \n \nNotificações push não serão afetadas, mas é possível revisar sua preferência manualmente. Salvo! - Nota pessoal sobre esta conta aqui + Nota pessoal sobre este perfil aqui Bem-estar Sem comunicados. Indefinido @@ -512,4 +510,11 @@ Novos toots %s recém tootou Comunicados + Apesar do seu perfil não ser trancado, %1$s exige que você revise a solicitação para te seguir destes perfis manualmente. + Cancelar + Notificar + Animar emojis personalizados + Excluir esta conversa\? + Excluir conversa + Deseja excluir a lista %s\? diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 469474ad6..d4698f210 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -16,7 +16,7 @@ Файл не может быть открыт. Необходимо разрешение на чтение медиаконтента. Необходимо разрешение для хранения медиаконтента. - Изображения и видео не могут быть прикрекплены к статусу одновременно. + Изображения и видео не могут быть прикреплены к статусу одновременно. Загрузка не удалась. Ошибка при отправке поста. Главная @@ -43,14 +43,14 @@ Чувствительный контент Медиа скрыто Нажмите для просмотра - Развернуть - Свернуть + Показать больше + Показать меньше Развернуть Свернуть Ничего нет. Ничего нет. Потяните вниз, чтобы обновить! - %s продвинул(а) ваш статус - %s понравился ваш статус + %s продвинул(а) вашу запись + %s понравился ваша запись %s подписался(-лась) на вас Пожаловаться на @%s Дополнительные комментарии? @@ -59,8 +59,8 @@ Продвинуть Убрать продвижение Нравится - Не нравится - Развернуть + Убрать из избранного + Больше Написать Войти Выйти @@ -70,7 +70,7 @@ Заблокировать Разблокировать Скрыть продвижения - Показывать продвижения + Показать продвижения Пожаловаться Удалить Удалить и исправить @@ -94,15 +94,15 @@ Отменить глушение Упомянуть Скрыть медиаконтент - Нарисовать + Открыть drawer Сохранить Редактировать профиль - Изменить + Редактировать Отменить Принять Отклонить Поиск - Черновики + Черновики Видимость поста Предупреждение о контенте Эмодзи-клавиатура @@ -112,7 +112,7 @@ Хэштеги Перейти к автору Показывать продвижения - Показать, кому нравится + Показать избранное Хэштеги Упоминания Ссылки @@ -123,12 +123,12 @@ Поделиться как … Скачать медиафайл Скачивание медиафайла - Поделиться ссылкой на статус… - Поделиться статусом… + Поделиться ссылкой на запись… + Поделиться записью… Поделиться медифайлом… Отправить! Пользователь разблокирован - Глушение снято + Пользователь разглушен Отправлено! Ответ успешно отправлен. Какой узел? @@ -143,18 +143,20 @@ Заголовок Что такое узел? Соединение… - Здесь можно ввести адрес или домен любого узла, например, mastodon.social, icosahedron.website, social.tchncs.de и других!\n\nЕсли у вас еще нет аккаунта, введите адрес узла, на котором хотите зарегистрироваться, и создайте аккаунт.\n\n - Узел - это то место, где размещен ваш аккаунт, но вы можете взаимодействовать с пользователями других узлов, как будто вы находитесь на одном сайте.\n - \n - Чтобы получить больше информации посетите joinmastodon.org. - + Здесь можно ввести адрес или домен любого узла, например, mastodon.social, icosahedron.website, social.tchncs.de и других! +\n +\nЕсли у вас ещё нет аккаунта, введите адрес узла, на котором хотите зарегистрироваться, и создайте аккаунт. +\n +\n Узел - это то место, где размещён ваш аккаунт, но вы можете взаимодействовать с пользователями других узлов, как будто вы находитесь на одном сайте. +\n +\n Чтобы получить больше информации посетите joinmastodon.org. Завершается загрузка медиаконтента Загружается… Скачать Отменить запрос на подписку? Отписаться от этого аккаунта? - Удалить статус? - Удалить статус и превратить его в черновик? + Удалить запись\? + Удалить запись и превратить её в черновик\? Публичный: Показать в публичных лентах Скрытый: Не показывать в лентах Приватный: Показать только подписчикам @@ -162,10 +164,10 @@ Push-уведомления Push-уведомления Предупреждения - Звуковые уведомления - Использовать вибрацию - Световые уведомления - Уведомлять когда… + Уведомлять звуком + Уведомлять вибрацией + Уведомлять светом + Уведомлять когда упомянули подписались мои посты продвинули @@ -176,7 +178,7 @@ Фильтры Тёмная Светлая - Черная + Чёрная Автоматическая (по времени) Как в системе Браузер @@ -221,6 +223,9 @@ %1$s, %2$s, и %3$s %1$s и %2$s + Новое событие: %d + Новые события: %d + Новых событий: %d Новых событий: %d Закрытый аккаунт @@ -354,7 +359,7 @@ Открепить Закрепить - %1$s Понравилось + %1$s Понравился %1$s Понравилось %1$s Понравилось %1$s Понравилось @@ -371,6 +376,9 @@ %1$s и %2$s %1$s, %2$s и ещё %3$d + достигнут лимит в %1$d вкладку + достигнут лимит в %1$d вкладок + достигнут лимит в %1$d вкладок достигнут лимит в %1$d вкладок @@ -429,9 +437,9 @@ Скрытые домены Заглушить %s %s показывается - Вы уверены, что хотите заблокировать %s целиком\? Вы перестанете видеть посты с того узла во всех публичных лентах и уведомлениях. Все ваши подписчики с того домена будут удалены. + Вы уверены, что хотите заблокировать %s целиком\? Вы перестанете видеть посты из того домена во всех публичных лентах и уведомлениях. Все ваши подписчики из того домена будут удалены. Скрыть узел целиком - завершившиеся опросы + опросы завершились Анимировать GIF-аватары Слово целиком Если слово или фраза состоит только из букв и цифр, будет учитываться полное совпадение @@ -461,10 +469,10 @@ Множественный выбор Вариант %d Изменить - Отложенные записи + Запланированные записи Редактировать - Отложенные записи - Отложить запись + Запланированные записи + Запланировать запись Сброс Закладки Добавить в закладки @@ -475,11 +483,11 @@ Список Аудиофайлы должны быть меньше 40МБ. Ошибка поиска поста %s - У вас нет черновиков. + У вас нет черновиков. У вас нет запланированный постов. Минимальный интервал планирования в Mastodon составляет 5 минут. - Показвать диалог подтверждения перед продвижением - Показывать предосмотр ссылок в лентах + Показывать диалог подтверждения перед продвижением + Показывать предпросмотр ссылок в лентах Включить переключение между вкладками смахиванием %s человек @@ -493,7 +501,7 @@ Заглушить @%s\? Заблокировать @%s\? Показать обсуждение - Скрыть обсуждение + Заглушить обсуждение запрос на подписку от %s Тэги Добавить тэг @@ -503,24 +511,47 @@ Расположение панели навигации Отменить глушение %s Скрыть уведомления - Заблокировать уведомления от %s + Заглушить уведомления от %s Получать уведомления от %s Разблокировать %s Сохранено! Ваша личная заметка об этой учётной записи - Скрыть заголовок в верхней панели + Скрыть заголовок верхней панели Объявлений нет. Объявления - "Некоторая информация, которая может повлиять на ваше психическое благополучие, будет скрыта. Это включает в себя: + "Информация, могущая повлиять на ваше психическое благополучие, будет скрыта. Она включает: \n \n - Избранное/Продвижение/Уведомления подписок -\n - Избранное/Продвижение счета на тутах -\n - Статистика подписчиков/публикаций в профилях -\n +\n - Избранное/Счётчики продвижения постов +\n - Статистика подписчиков/постов в профилях +\n \n На push-уведомления это не повлияет, но вы можете просмотреть настройки уведомлений вручную." Благосостояние Неопределённая Продолжительность Вложения Аудио + %s только что опубликовал(а) + Ваша учётная запись не заблокирована, но персонал %1$s подумал, что вы можете захотеть вручную просмотреть запросы на отслеживание от этих учётных записей. + + Вы не можете загрузить более %1$d мультимедийного вложения. + Вы не можете загрузить более %1$d мультимедийных вложений. + Вы не можете загрузить более %1$d мультимедийных вложений. + Вы не можете загрузить более %1$d мультимедийных вложений. + + Скрыть количественную статистику по сообщениям + Отписаться + Подписаться + Пост, на который вы написали ответ, был удалён + Не удалось загрузить информацию об ответе + Черновик удалён + Этот пост не удалось отправить! + Вы действительно хотите удалить список %s\? + Скрыть количественную статистику по сообщениям + Ограничить уведомления на временной шкале + Просмотр уведомлений + Уведомления, когда кто-то, на кого вы подписаны, опубликовал новую запись + Новые записи + Анимировать собственные эмодзи + кто-то, на кого я подписан, опубликовал новую запись \ No newline at end of file diff --git a/app/src/main/res/values-sa/strings.xml b/app/src/main/res/values-sa/strings.xml index 168b1baf1..29994122c 100644 --- a/app/src/main/res/values-sa/strings.xml +++ b/app/src/main/res/values-sa/strings.xml @@ -131,7 +131,7 @@ विषयप्रत्यादेशः दौत्यसुदर्शता कालबद्धदौत्यानि - लेखविकर्षाः + लेखविकर्षाः अन्विष्यताम् अस्वीक्रियताम् स्वीक्रियताम् @@ -366,7 +366,7 @@ प्रकाशनंं नश्यताम् मूलदर्शकेभ्यः प्रकाश्यताम् %1$s मित्रमत्र प्रस्थितम्: - न लेखविकर्षास्ते सन्ति । + न लेखविकर्षास्ते सन्ति । %1$s उच्चैःस्थितायाः साधनशालकायाः शीर्षकं छाद्यताम् प्रकाशनात् प्राक् पुष्टिसंवादमञ्जूषा दर्शनीया diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index 14af6584d..5b026442f 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -124,7 +124,7 @@ %1$s a %2$s Upraviť Hashtagy - Koncepty + Koncepty Upraviť Oznámenia Oznámenia diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml index 7db86c46f..e848ac44b 100644 --- a/app/src/main/res/values-sl/strings.xml +++ b/app/src/main/res/values-sl/strings.xml @@ -96,7 +96,7 @@ Sprejmi Zavrni Iskanje - Osnutki + Osnutki Vidljivost tuta Opozorilo o vsebini Tipkovnica z emotikoni diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 585cb16b3..7bcddcfc6 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -101,7 +101,7 @@ Acceptera Avvisa Sök - Utkast + Utkast Toot synlighet Innehållsvarning Emoji-tangentbord @@ -457,7 +457,7 @@ Lista Du har inga schemalagda statusar. Ljudfiler måste vara mindre än 40MB. - Du har inga utkast. + Du har inga utkast. Mastodon har ett minimalt schemaläggningsintervall på 5 minuter. Tysta konversation Visa bekräftelse innan knuff diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml index 9d5ea9ff4..6d1091299 100644 --- a/app/src/main/res/values-ta/strings.xml +++ b/app/src/main/res/values-ta/strings.xml @@ -84,7 +84,7 @@ ஏற்கவும் நிராகரி தேடு - வரைவுகள் + வரைவுகள் Toot புலப்படும் தன்மை உள்ளடக்க எச்சரிக்கை Emoji விசைபலகை diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index f0554fe76..9d188dce4 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -321,7 +321,7 @@ เตือนเนื้อหา การมองเห็น Toot Toot แบบตั้งเวลา - ฉบับร่าง + ฉบับร่าง ปฏิเสธ ยอมรับ ยกเลิก @@ -447,7 +447,7 @@ แสดงตัวอย่างลิงก์ในไทม์ไลน์ Mastodon กำหนดเวลาขั้นต่ำ 5 นาที ไม่มีสถานะแบบตั้งเวลาใด ๆ - ไม่มีฉบับร่างใด ๆ + ไม่มีฉบับร่างใด ๆ การค้นหาโพสต์ %s เกิดข้อผิดผลาด แก้ไข ตัวเลือกที่ %d @@ -472,8 +472,6 @@ แจ้งเตือน Limit timeline แจ้งเตือน Review ใครบางคนที่ฉันได้ติดตาม ได้เผยแพร่โพสต์ใหม่ - ฟีเจอร์ฉบับร่างใน Tusky ได้รับการออกแบบใหม่ทั้งหมดเพื่อให้เร็วขึ้นเป็นมิตรกับผู้ใช้มากขึ้นและบั๊กน้อยลง -\n คุณยังสามารถเข้าถึงฉบับร่างเก่าผ่านปุ่มในหน้าฉบับร่างใหม่ แต่จะถูกลบออกในการอัปเดตในอนาคต! ซ่อนสถิติเชิงปริมาณในโปรไฟล์ ซ่อนสถิติเชิงปริมาณของโพสต์ สุขภาวะ @@ -482,7 +480,6 @@ โพสต์ที่คุณได้ร่างตอบไว้ ถูกลบแลัว ลบฉบับร่างแล้ว ล้มเหลวในการโหลดข้อมูลตอบกลับ - ฉบับร่างเก่า คุณต้องการลบลิสต์ %s ใช่ไหม\? คุณไม่สามารถอัปโหลดไฟล์แนบมากกว่า %1$d ได้ diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 2ee321b09..71ebaf7ed 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -99,7 +99,7 @@ Kabul et Reddet Ara - Taslaklar + Taslaklar Toot görünürlüğü İçerik uyarı İfade klavyesi @@ -443,7 +443,7 @@ Bildirilemedi Seçenek %d %s gönderisi aranırken hata oluştu - Hiç taslağınız yok. + Hiç taslağınız yok. Zamanlanmış durumunuz yok. Kendi kitlenize yükseltin Hashtags\'ler diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 73882f1ea..37f2ab257 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -58,7 +58,7 @@ Посилання Попередження про вміст Заплановані дмухи - Чернетки + Чернетки Відхилити Прийняти Скасувати @@ -88,7 +88,7 @@ Заблокувати Відписатися Підписатися - Ви дійсно хочете вийти з облікового запису %1$s\? + Ви впевнені, що хочете вийти з облікового запису %1$s\? Написати Не подобається Додати в закладки @@ -458,9 +458,6 @@ Дмух, для якого ви створили чернетку відповіді, вилучено Чернетку видалено Не вдалося завантажити дані відповіді - Старі чернетки - Функція чернетки в Tusky була повністю перероблена, щоб бути швидшою, зручнішою для користувачів і з меншою кількістю вад. -\n Ви все ще можете отримати доступ до своїх старих чернеток за допомогою кнопки на екрані нових чернеток, але вони будуть вилучені в майбутньому оновленні! Не вдалося надіслати цей дмух! Ви дійсно хочете видалити список %s\? @@ -481,7 +478,7 @@ Найкоротший час планування Mastodon становить 5 хвилин. Оголошень немає. Черга статусів порожня. - У вас немає чернеток. + У вас немає чернеток. Помилка пошуку допису %s Увімкнути перемикання між вкладками жестом проведення пальцем Показати фільтр сповіщень @@ -532,4 +529,7 @@ Не вдалося завантажити Поточний набір емодзі Google Стандартний набір емодзі Mastodon + Навіть попри те, що ваш обліковий запис загальнодоступний, співробітники %1$s вважають, що ви, можливо, захочете переглянути запити від цих облікових записів власноруч. + Видалити цю бесіду\? + Видалити бесіду \ No newline at end of file diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 7f8bfd51e..b18635412 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -117,7 +117,7 @@ Nội dung nhạy cảm Công khai Tút đã lên lịch - Nháp + Nháp Từ chối Đồng ý Trở về @@ -247,7 +247,7 @@ Yêu cầu theo dõi Thông báo về người theo dõi mới Người theo dõi mới - Thông báo về lược nhắc tới + Thông báo về lượt nhắc tới To nhất To Trung bình @@ -269,7 +269,7 @@ Bật proxy Dùng proxy Vượt tường lửa - Thông báo khi của bạn được chia sẻ + Thông báo khi tút của bạn được chia sẻ Chia sẻ Thông báo về lượt yêu cầu theo dõi Báo lỗi và đề xuất tính năng @@ -291,20 +291,20 @@ Cộng đồng xem thêm Trả lời @%s - Album + Media Luôn hiện nội dung bị ẩn Luôn hiện nội dung nhạy cảm Đang theo dõi bạn %ds - %dm - %dh + %d phút + %d giờ %d ngày %d năm %ds - %dm - %dh - %dd - %dy + %d phút + %d giờ + %d ngày + %d năm Yêu cầu theo dõi Video Hình ảnh @@ -315,7 +315,7 @@ Hiện xem trước của link Mastodon giới hạn tối thiểu 5 phút. Bạn không có tút đã lên lịch. - Bạn không có bản nháp nào. + Bạn không có bản nháp nào. Sửa Lựa chọn %d Cho phép chọn nhiều lựa chọn @@ -383,7 +383,7 @@ Đã lưu Đã thích Đã chia sẻ - Âm thanh + Không có mô tả Nội dung nhạy cảm: %s tối đa %1$d tab @@ -490,11 +490,8 @@ Đính kèm Âm thanh Tút bạn lên lịch đã bị hủy bỏ - Tút lên lịch cũ Tút lên lịch đã xóa Chưa tải được bình luận - Tính năng lên lịch đăng tút của Tusky được thiết kế lại hoàn toàn để nhanh hơn, thân thiện hơn và ít lỗi hơn. -\nBạn vẫn có thể xem lại bản nháp cũ nhưng chúng sẽ bị xóa bỏ trong bản cập nhật tương lai! Đăng tút không thành công! Emoji động Ngưng nhận thông báo diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index f54306d39..d3c9a29b9 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -102,7 +102,7 @@ 接受 拒绝 搜索 - 草稿 + 草稿 设置嘟文可见范围 设置内容提醒 插入表情符号 @@ -454,7 +454,7 @@ 选择 %d 编辑 查找嘟文时出错 %s - 您没有草稿。 + 您没有草稿。 您没有任何定时嘟文。 Mastodon的最小预订时间为5分钟。 关注请求 @@ -492,9 +492,6 @@ 该草稿回复的原嘟文已被删除 草稿已删除 加载回复信息失败 - 旧草稿 - Tusky 的草稿功能已被重新设计,现在它更快、更友好,Bug也更少。 -\n 旧草稿依然可以通过新草稿页面的按钮查看,但他们将在未来版本中移除! 嘟文发送失败! 确认删除列表 %s? @@ -524,4 +521,5 @@ 显示动态自定义Emoji 关注的人发送了新嘟文 %s 发送了新嘟文 + 即使您的账号未上锁,管理员 %1$s 认为您可能需要手动处理来自这些账号的关注请求。 diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index acc684c65..b7bb0646e 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -102,7 +102,7 @@ 接受 拒絕 搜尋 - 草稿 + 草稿 設定嘟文可見範圍 設定敏感內容警告 插入表情符號 @@ -451,7 +451,6 @@ 你的草稿欲回覆的原嘟文已被刪除 草稿已刪除 載入回覆資訊失敗 - 舊的草稿 這條嘟文發送失敗! 你確定要刪除列表 %s? @@ -465,7 +464,7 @@ Mastodon 的最短發文間隔限制為 5 分鐘。 沒有公告。 你沒有任何已排程的嘟文。 - 你沒有任何草稿。 + 你沒有任何草稿。 尋找嘟文時發生錯誤 %s 選項 %d 多個選項 diff --git a/app/src/main/res/values-zh-rMO/strings.xml b/app/src/main/res/values-zh-rMO/strings.xml index 51da49cd3..8ebc4f783 100644 --- a/app/src/main/res/values-zh-rMO/strings.xml +++ b/app/src/main/res/values-zh-rMO/strings.xml @@ -102,7 +102,7 @@ 接受 拒絕 搜尋 - 草稿 + 草稿 設定嘟文可見範圍 敏感內容警告 插入表情符號 diff --git a/app/src/main/res/values-zh-rSG/strings.xml b/app/src/main/res/values-zh-rSG/strings.xml index af01e1a7d..641593f3e 100644 --- a/app/src/main/res/values-zh-rSG/strings.xml +++ b/app/src/main/res/values-zh-rSG/strings.xml @@ -102,7 +102,7 @@ 接受 拒绝 搜索 - 草稿 + 草稿 设置嘟文可见范围 设置内容提醒信息 插入表情符号 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 3fe605bb6..531a522a8 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -102,7 +102,7 @@ 接受 拒絕 搜尋 - 草稿 + 草稿 設定嘟文可見範圍 敏感內容警告 插入表情符號 @@ -440,8 +440,6 @@ 編輯 書籤 音檔必需小於40MB。 - Tusky 的草稿功能已重新設計,更快、更好用、更少問題。 -\n 你還是可以在草稿頁面中查看你的先前的舊草稿,但它們在未來的某次更新中將會被移除! 隱藏個人頁面中的狀態數量資訊 隱藏貼文上的狀態數量資訊 限制時間軸通知 @@ -463,7 +461,6 @@ 你的草稿欲回覆的原嘟文已被刪除 草稿已刪除 載入回覆資訊失敗 - 舊的草稿 這條嘟文發送失敗! 附件 錄音 @@ -508,7 +505,7 @@ 取消靜音對話 靜音對話 Mastodon 的最短發文間隔限制為 5 分鐘。 - 你沒有任何草稿。 + 你沒有任何草稿。 你沒有任何已排程的嘟文。 列表 選擇列表 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6d6c4891f..6046da189 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -93,6 +93,7 @@ Report Edit Delete + Delete conversation Delete and re-draft TOOT TOOT! @@ -132,7 +133,7 @@ Accept Reject Search - Drafts + Drafts Scheduled toots Toot visibility Content warning @@ -149,7 +150,6 @@ Quote Authorize Now! Jump to top - Reset tab Edit this list Use streaming in this tab Toggle notifications filter @@ -215,6 +215,7 @@ Unfollow this account? Delete this toot? Delete and re-draft this toot? + Delete this conversation? 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. Hide entire domain Block @%s? @@ -614,7 +615,7 @@ Edit Error looking up post %s - You don\'t have any drafts. + You don\'t have any drafts. You don\'t have any scheduled statuses. There are no announcements. Mastodon has a minimum scheduling interval of 5 minutes. @@ -642,13 +643,6 @@ Do you really want to delete the list %s? This toot failed to send! - - - The draft feature in Tusky has been completely redesigned to be faster, more user friendly and less buggy.\n - You can still access your old drafts via a button on the new drafts screen, - but they will be removed in a future update! - - Old Drafts Failed loading Reply information Draft deleted The Toot you drafted a reply to has been removed diff --git a/app/src/test/java/android/text/FakeSpannableString.kt b/app/src/test/java/android/text/SpannableString.kt similarity index 99% rename from app/src/test/java/android/text/FakeSpannableString.kt rename to app/src/test/java/android/text/SpannableString.kt index c4e4e4ccd..dc8cd831f 100644 --- a/app/src/test/java/android/text/FakeSpannableString.kt +++ b/app/src/test/java/android/text/SpannableString.kt @@ -23,7 +23,6 @@ class SpannableString(private val text: CharSequence) : Spannable { override val length: Int get() = text.length - override fun nextSpanTransition(start: Int, limit: Int, type: Class<*>?): Int { throw NotImplementedError() } @@ -47,4 +46,4 @@ class SpannableString(private val text: CharSequence) : Spannable { override fun getSpanStart(tag: Any?): Int { throw NotImplementedError() } -} \ No newline at end of file +} diff --git a/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt b/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt index 91ffe1167..458fc2884 100644 --- a/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt @@ -17,28 +17,37 @@ package com.keylesspalace.tusky import android.text.SpannedString import android.widget.LinearLayout +import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.google.android.material.bottomsheet.BottomSheetBehavior import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.SearchResult import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi -import io.reactivex.Single -import io.reactivex.android.plugins.RxAndroidPlugins -import io.reactivex.plugins.RxJavaPlugins -import io.reactivex.schedulers.TestScheduler +import io.reactivex.rxjava3.android.plugins.RxAndroidPlugins +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.plugins.RxJavaPlugins +import io.reactivex.rxjava3.schedulers.TestScheduler import org.junit.Assert import org.junit.Before +import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.junit.runners.Parameterized import org.mockito.ArgumentMatchers -import org.mockito.Mockito.* -import java.util.* +import org.mockito.Mockito.`when` +import org.mockito.Mockito.eq +import org.mockito.Mockito.mock +import java.util.ArrayList +import java.util.Collections +import java.util.Date import java.util.concurrent.TimeUnit - class BottomSheetActivityTest { - private lateinit var activity : FakeBottomSheetActivity + + @get:Rule + val instantTaskExecutorRule: InstantTaskExecutorRule = InstantTaskExecutorRule() + + private lateinit var activity: FakeBottomSheetActivity private lateinit var apiMock: MastodonApi private val accountQuery = "http://mastodon.foo.bar/@User" private val statusQuery = "http://mastodon.foo.bar/@User/345678" @@ -46,52 +55,52 @@ class BottomSheetActivityTest { private val emptyCallback = Single.just(SearchResult(emptyList(), emptyList(), emptyList())) private val testScheduler = TestScheduler() - private val account = Account ( - "1", - "admin", - "admin", - "Ad Min", - SpannedString(""), - "http://mastodon.foo.bar", - "", - "", - false, - 0, - 0, - 0, - null, - false, - emptyList(), - emptyList() + private val account = Account( + "1", + "admin", + "admin", + "Ad Min", + SpannedString(""), + "http://mastodon.foo.bar", + "", + "", + false, + 0, + 0, + 0, + null, + false, + emptyList(), + emptyList() ) private val accountSingle = Single.just(SearchResult(listOf(account), emptyList(), emptyList())) private val status = Status( - "1", - statusQuery, - account, - null, - null, - null, - SpannedString("omgwat"), - Date(), - Collections.emptyList(), - 0, - 0, - false, - false, - false, - false, - "", - Status.Visibility.PUBLIC, - ArrayList(), - arrayOf(), - null, - pinned = false, - muted = false, - poll = null, - card = null, - quote = null + "1", + statusQuery, + account, + null, + null, + null, + SpannedString("omgwat"), + Date(), + Collections.emptyList(), + 0, + 0, + false, + false, + false, + false, + "", + Status.Visibility.PUBLIC, + ArrayList(), + listOf(), + null, + pinned = false, + muted = false, + poll = null, + card = null, + quote = null ) private val statusSingle = Single.just(SearchResult(emptyList(), listOf(status), emptyList())) @@ -114,7 +123,7 @@ class BottomSheetActivityTest { companion object { @Parameterized.Parameters(name = "match_{0}") @JvmStatic - fun data() : Iterable { + fun data(): Iterable { return listOf( arrayOf("https://mastodon.foo.bar/@User", true), arrayOf("http://mastodon.foo.bar/@abc123", true), diff --git a/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt b/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt index bd9be3b21..156a6b413 100644 --- a/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt @@ -25,17 +25,22 @@ import com.keylesspalace.tusky.components.compose.ComposeViewModel import com.keylesspalace.tusky.components.compose.DEFAULT_CHARACTER_LIMIT import com.keylesspalace.tusky.components.compose.MediaUploader import com.keylesspalace.tusky.components.drafts.DraftHelper -import com.keylesspalace.tusky.db.* +import com.keylesspalace.tusky.db.AccountEntity +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.db.InstanceDao +import com.keylesspalace.tusky.db.InstanceEntity import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Instance import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.service.ServiceClient -import com.keylesspalace.tusky.util.SaveTootHelper import com.nhaarman.mockitokotlin2.any -import io.reactivex.Single -import io.reactivex.SingleObserver -import org.junit.Assert.* +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.core.SingleObserver +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -60,25 +65,25 @@ class ComposeActivityTest { private val instanceDomain = "example.domain" private val account = AccountEntity( - id = 1, - domain = instanceDomain, - accessToken = "token", - isActive = true, - accountId = "1", - username = "username", - displayName = "Display Name", - profilePictureUrl = "", - notificationsEnabled = true, - notificationsMentioned = true, - notificationsFollowed = true, - notificationsFollowRequested = false, - notificationsReblogged = true, - notificationsFavorited = true, - notificationSound = true, - notificationVibration = true, - notificationLight = true + id = 1, + domain = instanceDomain, + accessToken = "token", + isActive = true, + accountId = "1", + username = "username", + displayName = "Display Name", + profilePictureUrl = "", + notificationsEnabled = true, + notificationsMentioned = true, + notificationsFollowed = true, + notificationsFollowRequested = false, + notificationsReblogged = true, + notificationsFavorited = true, + notificationSound = true, + notificationVibration = true, + notificationLight = true ) - private var instanceResponseCallback: (()->Instance)? = null + private var instanceResponseCallback: (() -> Instance)? = null private var composeOptions: ComposeActivity.ComposeOptions? = null @Before @@ -91,7 +96,7 @@ class ComposeActivityTest { apiMock = mock(MastodonApi::class.java) `when`(apiMock.getCustomEmojis()).thenReturn(Single.just(emptyList())) - `when`(apiMock.getInstance()).thenReturn(object: Single() { + `when`(apiMock.getInstance()).thenReturn(object : Single() { override fun subscribeActual(observer: SingleObserver) { val instance = instanceResponseCallback?.invoke() if (instance == null) { @@ -104,20 +109,19 @@ class ComposeActivityTest { val instanceDaoMock = mock(InstanceDao::class.java) `when`(instanceDaoMock.loadMetadataForInstance(any())).thenReturn( - Single.just(InstanceEntity(instanceDomain, emptyList(),null, null, null, null)) + Single.just(InstanceEntity(instanceDomain, emptyList(), null, null, null, null)) ) val dbMock = mock(AppDatabase::class.java) `when`(dbMock.instanceDao()).thenReturn(instanceDaoMock) val viewModel = ComposeViewModel( - apiMock, - accountManagerMock, - mock(MediaUploader::class.java), - mock(ServiceClient::class.java), - mock(DraftHelper::class.java), - mock(SaveTootHelper::class.java), - dbMock + apiMock, + accountManagerMock, + mock(MediaUploader::class.java), + mock(ServiceClient::class.java), + mock(DraftHelper::class.java), + dbMock ) activity.intent = Intent(activity, ComposeActivity::class.java).apply { putExtra(ComposeActivity.COMPOSE_OPTIONS_EXTRA, composeOptions) @@ -383,41 +387,38 @@ class ComposeActivityTest { activity.findViewById(R.id.composeEditField).setText(text ?: "Some text") } - private fun getInstanceWithMaximumTootCharacters(maximumTootCharacters: Int?): Instance - { + private fun getInstanceWithMaximumTootCharacters(maximumTootCharacters: Int?): Instance { return Instance( + "https://example.token", + "Example dot Token", + "Example instance for testing", + "admin@example.token", + "2.6.3", + HashMap(), + null, + null, + listOf("en"), + Account( + "1", + "admin", + "admin", + "admin", + SpannedString(""), "https://example.token", - "Example dot Token", - "Example instance for testing", - "admin@example.token", - "2.6.3", - HashMap(), + "", + "", + false, + 0, + 0, + 0, null, - null, - listOf("en"), - Account( - "1", - "admin", - "admin", - "admin", - SpannedString(""), - "https://example.token", - "", - "", - false, - 0, - 0, - 0, - null, - false, - emptyList(), - emptyList() - ), - maximumTootCharacters, - null, - null + false, + emptyList(), + emptyList() + ), + maximumTootCharacters, + null, + null ) } - } - diff --git a/app/src/test/java/com/keylesspalace/tusky/ComposeTokenizerTest.kt b/app/src/test/java/com/keylesspalace/tusky/ComposeTokenizerTest.kt index b603a4a7c..e203dde27 100644 --- a/app/src/test/java/com/keylesspalace/tusky/ComposeTokenizerTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/ComposeTokenizerTest.kt @@ -22,64 +22,66 @@ import org.junit.runner.RunWith import org.junit.runners.Parameterized @RunWith(Parameterized::class) -class ComposeTokenizerTest(private val text: CharSequence, - private val expectedStartIndex: Int, - private val expectedEndIndex: Int) { +class ComposeTokenizerTest( + private val text: CharSequence, + private val expectedStartIndex: Int, + private val expectedEndIndex: Int +) { companion object { @Parameterized.Parameters(name = "{0}") @JvmStatic fun data(): Iterable { return listOf( - arrayOf("@mention", 0, 8), - arrayOf("@ment10n", 0, 8), - arrayOf("@ment10n_", 0, 9), - arrayOf("@ment10n_n", 0, 10), - arrayOf("@ment10n_9", 0, 10), - arrayOf(" @mention", 1, 9), - arrayOf(" @ment10n", 1, 9), - arrayOf(" @ment10n_", 1, 10), - arrayOf(" @ment10n_ @", 11, 12), - arrayOf(" @ment10n_ @ment20n", 11, 19), - arrayOf(" @ment10n_ @ment20n_", 11, 20), - arrayOf(" @ment10n_ @ment20n_n", 11, 21), - arrayOf(" @ment10n_ @ment20n_9", 11, 21), - arrayOf(" @ment10n-", 1, 10), - arrayOf(" @ment10n- @", 11, 12), - arrayOf(" @ment10n- @ment20n", 11, 19), - arrayOf(" @ment10n- @ment20n-", 11, 20), - arrayOf(" @ment10n- @ment20n-n", 11, 21), - arrayOf(" @ment10n- @ment20n-9", 11, 21), - arrayOf("@ment10n@l0calhost", 0, 18), - arrayOf(" @ment10n@l0calhost", 1, 19), - arrayOf(" @ment10n_@l0calhost", 1, 20), - arrayOf(" @ment10n-@l0calhost", 1, 20), - arrayOf(" @ment10n_@l0calhost @ment20n@husky", 21, 35), - arrayOf(" @ment10n_@l0calhost @ment20n_@husky", 21, 36), - arrayOf(" @ment10n-@l0calhost @ment20n-@husky", 21, 36), - arrayOf(" @m@localhost", 1, 13), - arrayOf(" @m@localhost @a@localhost", 14, 26), - arrayOf("@m@", 0, 3), - arrayOf(" @m@ @a@asdf", 5, 12), - arrayOf(" @m@ @a@", 5, 8), - arrayOf(" @m@ @a@a", 5, 9), - arrayOf(" @m@a @a@m", 6, 10), - arrayOf("@m@m@", 5, 5), - arrayOf("#tusky@husky", 12, 12), - arrayOf(":tusky@husky", 12, 12), - arrayOf("mention", 7, 7), - arrayOf("ment10n", 7, 7), - arrayOf("mentio_", 7, 7), - arrayOf("#tusky", 0, 6), - arrayOf("#@tusky", 7, 7), - arrayOf("@#tusky", 7, 7), - arrayOf(" @#tusky", 8, 8), - arrayOf(":mastodon", 0, 9), - arrayOf(":@mastodon", 10, 10), - arrayOf("@:mastodon", 10, 10), - arrayOf(" @:mastodon", 11, 11), - arrayOf("#@:mastodon", 11, 11), - arrayOf(" #@:mastodon", 12, 12) + arrayOf("@mention", 0, 8), + arrayOf("@ment10n", 0, 8), + arrayOf("@ment10n_", 0, 9), + arrayOf("@ment10n_n", 0, 10), + arrayOf("@ment10n_9", 0, 10), + arrayOf(" @mention", 1, 9), + arrayOf(" @ment10n", 1, 9), + arrayOf(" @ment10n_", 1, 10), + arrayOf(" @ment10n_ @", 11, 12), + arrayOf(" @ment10n_ @ment20n", 11, 19), + arrayOf(" @ment10n_ @ment20n_", 11, 20), + arrayOf(" @ment10n_ @ment20n_n", 11, 21), + arrayOf(" @ment10n_ @ment20n_9", 11, 21), + arrayOf(" @ment10n-", 1, 10), + arrayOf(" @ment10n- @", 11, 12), + arrayOf(" @ment10n- @ment20n", 11, 19), + arrayOf(" @ment10n- @ment20n-", 11, 20), + arrayOf(" @ment10n- @ment20n-n", 11, 21), + arrayOf(" @ment10n- @ment20n-9", 11, 21), + arrayOf("@ment10n@l0calhost", 0, 18), + arrayOf(" @ment10n@l0calhost", 1, 19), + arrayOf(" @ment10n_@l0calhost", 1, 20), + arrayOf(" @ment10n-@l0calhost", 1, 20), + arrayOf(" @ment10n_@l0calhost @ment20n@husky", 21, 35), + arrayOf(" @ment10n_@l0calhost @ment20n_@husky", 21, 36), + arrayOf(" @ment10n-@l0calhost @ment20n-@husky", 21, 36), + arrayOf(" @m@localhost", 1, 13), + arrayOf(" @m@localhost @a@localhost", 14, 26), + arrayOf("@m@", 0, 3), + arrayOf(" @m@ @a@asdf", 5, 12), + arrayOf(" @m@ @a@", 5, 8), + arrayOf(" @m@ @a@a", 5, 9), + arrayOf(" @m@a @a@m", 6, 10), + arrayOf("@m@m@", 5, 5), + arrayOf("#tusky@husky", 12, 12), + arrayOf(":tusky@husky", 12, 12), + arrayOf("mention", 7, 7), + arrayOf("ment10n", 7, 7), + arrayOf("mentio_", 7, 7), + arrayOf("#tusky", 0, 6), + arrayOf("#@tusky", 7, 7), + arrayOf("@#tusky", 7, 7), + arrayOf(" @#tusky", 8, 8), + arrayOf(":mastodon", 0, 9), + arrayOf(":@mastodon", 10, 10), + arrayOf("@:mastodon", 10, 10), + arrayOf(" @:mastodon", 11, 11), + arrayOf("#@:mastodon", 11, 11), + arrayOf(" #@:mastodon", 12, 12) ) } } @@ -91,4 +93,4 @@ class ComposeTokenizerTest(private val text: CharSequence, Assert.assertEquals(expectedStartIndex, tokenizer.findTokenStart(text, text.length)) Assert.assertEquals(expectedEndIndex, tokenizer.findTokenEnd(text, text.length)) } -} \ No newline at end of file +} diff --git a/app/src/test/java/com/keylesspalace/tusky/FilterTest.kt b/app/src/test/java/com/keylesspalace/tusky/FilterTest.kt index 20da48707..ca9ee8bd4 100644 --- a/app/src/test/java/com/keylesspalace/tusky/FilterTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/FilterTest.kt @@ -1,261 +1,183 @@ package com.keylesspalace.tusky -import android.os.Bundle import android.text.SpannedString import androidx.test.ext.junit.runners.AndroidJUnit4 import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.PollOption import com.keylesspalace.tusky.entity.Status -import com.keylesspalace.tusky.fragment.SFragment -import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.network.FilterModel import com.nhaarman.mockitokotlin2.mock -import okhttp3.Request -import okio.Timeout import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith -import org.mockito.Mockito -import org.robolectric.Robolectric import org.robolectric.annotation.Config -import retrofit2.Call -import retrofit2.Callback -import retrofit2.Response -import java.util.* +import java.util.Date @Config(sdk = [28]) @RunWith(AndroidJUnit4::class) class FilterTest { - private val fragment = FakeFragment() + lateinit var filterModel: FilterModel @Before fun setup() { + filterModel = FilterModel() + val filters = listOf( + Filter( + id = "123", + phrase = "badWord", + context = listOf(Filter.HOME), + expiresAt = null, + irreversible = false, + wholeWord = false + ), + Filter( + id = "123", + phrase = "badWholeWord", + context = listOf(Filter.HOME, Filter.PUBLIC), + expiresAt = null, + irreversible = false, + wholeWord = true + ), + Filter( + id = "123", + phrase = "@twitter.com", + context = listOf(Filter.HOME), + expiresAt = null, + irreversible = false, + wholeWord = true + ) + ) - val controller = Robolectric.buildActivity(FakeActivity::class.java) - val activity = controller.get() - - activity.accountManager = mock() - val apiMock = Mockito.mock(MastodonApi::class.java) - Mockito.`when`(apiMock.getFilters()).thenReturn(object: Call> { - override fun isExecuted(): Boolean { - return false - } - override fun clone(): Call> { - throw Error("not implemented") - } - override fun isCanceled(): Boolean { - throw Error("not implemented") - } - override fun cancel() { - throw Error("not implemented") - } - override fun execute(): Response> { - throw Error("not implemented") - } - override fun request(): Request { - throw Error("not implemented") - } - - override fun enqueue(callback: Callback>) { - callback.onResponse( - this, - Response.success( - listOf( - Filter( - id = "123", - phrase = "badWord", - context = listOf(Filter.HOME), - expiresAt = null, - irreversible = false, - wholeWord = false - ), - Filter( - id = "123", - phrase = "badWholeWord", - context = listOf(Filter.HOME, Filter.PUBLIC), - expiresAt = null, - irreversible = false, - wholeWord = true - ), - Filter( - id = "123", - phrase = "wrongContext", - context = listOf(Filter.PUBLIC), - expiresAt = null, - irreversible = false, - wholeWord = true - ), - Filter( - id = "123", - phrase = "@twitter.com", - context = listOf(Filter.HOME), - expiresAt = null, - irreversible = false, - wholeWord = true - ) - ) - ) - ) - } - - override fun timeout(): Timeout { - throw Error("not implemented") - } - }) - - activity.mastodonApi = apiMock - - - controller.create().start() - - fragment.mastodonApi = apiMock - - - activity.supportFragmentManager.beginTransaction() - .replace(R.id.mainDrawerLayout, fragment, "fragment") - .commit() - - fragment.reloadFilters(false) - + filterModel.initWithFilters(filters) } @Test fun shouldNotFilter() { - assertFalse(fragment.shouldFilterStatus( + assertFalse( + filterModel.shouldFilterStatus( mockStatus(content = "should not be filtered") - )) - } - - @Test - fun shouldNotFilter_whenContextDoesNotMatch() { - assertFalse(fragment.shouldFilterStatus( - mockStatus(content = "one two wrongContext three") - )) + ) + ) } @Test fun shouldFilter_whenContentMatchesBadWord() { - assertTrue(fragment.shouldFilterStatus( + assertTrue( + filterModel.shouldFilterStatus( mockStatus(content = "one two badWord three") - )) + ) + ) } @Test fun shouldFilter_whenContentMatchesBadWordPart() { - assertTrue(fragment.shouldFilterStatus( + assertTrue( + filterModel.shouldFilterStatus( mockStatus(content = "one two badWordPart three") - )) + ) + ) } @Test fun shouldFilter_whenContentMatchesBadWholeWord() { - assertTrue(fragment.shouldFilterStatus( + assertTrue( + filterModel.shouldFilterStatus( mockStatus(content = "one two badWholeWord three") - )) + ) + ) } @Test fun shouldNotFilter_whenContentDoesNotMatchWholeWord() { - assertFalse(fragment.shouldFilterStatus( + assertFalse( + filterModel.shouldFilterStatus( mockStatus(content = "one two badWholeWordTest three") - )) + ) + ) } @Test fun shouldFilter_whenSpoilerTextDoesMatch() { - assertTrue(fragment.shouldFilterStatus( + assertTrue( + filterModel.shouldFilterStatus( mockStatus( - content = "should not be filtered", - spoilerText = "badWord should be filtered" + content = "should not be filtered", + spoilerText = "badWord should be filtered" ) - )) + ) + ) } @Test fun shouldFilter_whenPollTextDoesMatch() { - assertTrue(fragment.shouldFilterStatus( + assertTrue( + filterModel.shouldFilterStatus( mockStatus( - content = "should not be filtered", - spoilerText = "should not be filtered", - pollOptions = listOf("should not be filtered", "badWord") + content = "should not be filtered", + spoilerText = "should not be filtered", + pollOptions = listOf("should not be filtered", "badWord") ) - )) + ) + ) } @Test fun shouldFilterPartialWord_whenWholeWordFilterContainsNonAlphanumericCharacters() { - assertTrue(fragment.shouldFilterStatus( + assertTrue( + filterModel.shouldFilterStatus( mockStatus(content = "one two someone@twitter.com three") - )) - } - - private fun mockStatus( - content: String = "", - spoilerText: String = "", - pollOptions: List? = null - ): Status { - return Status( - id = "123", - url = "https://mastodon.social/@Tusky/100571663297225812", - account = mock(), - inReplyToId = null, - inReplyToAccountId = null, - reblog = null, - content = SpannedString(content), - createdAt = Date(), - emojis = emptyList(), - reblogsCount = 0, - favouritesCount = 0, - reblogged = false, - favourited = false, - bookmarked = false, - sensitive = false, - spoilerText = spoilerText, - visibility = Status.Visibility.PUBLIC, - attachments = arrayListOf(), - mentions = emptyArray(), - application = null, - pinned = false, - muted = false, - poll = if (pollOptions != null) { - Poll( - id = "1234", - expiresAt = null, - expired = false, - multiple = false, - votesCount = 0, - votersCount = 0, - options = pollOptions.map { - PollOption(it, 0) - }, - voted = false - ) - } else null, - card = null, - quote = null + ) ) } -} - -class FakeActivity: BottomSheetActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.activity_main) + private fun mockStatus( + content: String = "", + spoilerText: String = "", + pollOptions: List? = null + ): Status { + return Status( + id = "123", + url = "https://mastodon.social/@Tusky/100571663297225812", + account = mock(), + inReplyToId = null, + inReplyToAccountId = null, + reblog = null, + content = SpannedString(content), + createdAt = Date(), + emojis = emptyList(), + reblogsCount = 0, + favouritesCount = 0, + reblogged = false, + favourited = false, + bookmarked = false, + sensitive = false, + spoilerText = spoilerText, + visibility = Status.Visibility.PUBLIC, + attachments = arrayListOf(), + mentions = listOf(), + application = null, + pinned = false, + muted = false, + poll = if (pollOptions != null) { + Poll( + id = "1234", + expiresAt = null, + expired = false, + multiple = false, + votesCount = 0, + votersCount = 0, + options = pollOptions.map { + PollOption(it, 0) + }, + voted = false + ) + } else null, + card = null, + quote = null + ) } } - -class FakeFragment: SFragment() { - override fun removeItem(position: Int) { - } - - override fun onReblog(reblog: Boolean, position: Int) { - } - - override fun filterIsRelevant(filter: Filter): Boolean { - return filter.context.contains(Filter.HOME) - } -} \ No newline at end of file diff --git a/app/src/test/java/com/keylesspalace/tusky/FocalPointUtilTest.kt b/app/src/test/java/com/keylesspalace/tusky/FocalPointUtilTest.kt index f3094ee93..444da0619 100644 --- a/app/src/test/java/com/keylesspalace/tusky/FocalPointUtilTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/FocalPointUtilTest.kt @@ -17,8 +17,8 @@ package com.keylesspalace.tusky import com.keylesspalace.tusky.util.FocalPointUtil import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Test class FocalPointUtilTest { @@ -45,66 +45,112 @@ class FocalPointUtilTest { // isVerticalCrop tests @Test fun isVerticalCropTest() { - assertTrue(FocalPointUtil.isVerticalCrop(2f, 1f, - 1f, 2f)) + assertTrue( + FocalPointUtil.isVerticalCrop( + 2f, 1f, + 1f, 2f + ) + ) } @Test fun isHorizontalCropTest() { - assertFalse(FocalPointUtil.isVerticalCrop(1f, 2f, - 2f,1f)) + assertFalse( + FocalPointUtil.isVerticalCrop( + 1f, 2f, + 2f, 1f + ) + ) } @Test fun isPerfectFitTest() { // Doesn't matter what it returns, just check it doesn't crash - FocalPointUtil.isVerticalCrop(3f, 1f, - 6f, 2f) + FocalPointUtil.isVerticalCrop( + 3f, 1f, + 6f, 2f + ) } // calculateScaling tests @Test fun perfectFitScaleDownTest() { - assertEquals(FocalPointUtil.calculateScaling(2f, 5f, - 5f, 12.5f), 0.4f, eps) + assertEquals( + FocalPointUtil.calculateScaling( + 2f, 5f, + 5f, 12.5f + ), + 0.4f, eps + ) } @Test fun perfectFitScaleUpTest() { - assertEquals(FocalPointUtil.calculateScaling(2f, 5f, - 1f, 2.5f), 2f, eps) + assertEquals( + FocalPointUtil.calculateScaling( + 2f, 5f, + 1f, 2.5f + ), + 2f, eps + ) } @Test fun verticalCropScaleUpTest() { - assertEquals(FocalPointUtil.calculateScaling(2f, 1f, - 1f, 2f), 2f, eps) + assertEquals( + FocalPointUtil.calculateScaling( + 2f, 1f, + 1f, 2f + ), + 2f, eps + ) } @Test fun verticalCropScaleDownTest() { - assertEquals(FocalPointUtil.calculateScaling(4f, 3f, - 8f, 24f), 0.5f, eps) + assertEquals( + FocalPointUtil.calculateScaling( + 4f, 3f, + 8f, 24f + ), + 0.5f, eps + ) } @Test fun horizontalCropScaleUpTest() { - assertEquals(FocalPointUtil.calculateScaling(1f, 2f, - 2f, 1f), 2f, eps) + assertEquals( + FocalPointUtil.calculateScaling( + 1f, 2f, + 2f, 1f + ), + 2f, eps + ) } @Test fun horizontalCropScaleDownTest() { - assertEquals(FocalPointUtil.calculateScaling(3f, 4f, - 24f, 8f), 0.5f, eps) + assertEquals( + FocalPointUtil.calculateScaling( + 3f, 4f, + 24f, 8f + ), + 0.5f, eps + ) } // focalOffset tests @Test fun toLowFocalOffsetTest() { - assertEquals(FocalPointUtil.focalOffset(2f, 8f, 1f, 0.05f), - 0f, eps) + assertEquals( + FocalPointUtil.focalOffset(2f, 8f, 1f, 0.05f), + 0f, eps + ) } @Test fun toHighFocalOffsetTest() { - assertEquals(FocalPointUtil.focalOffset(2f, 4f, 2f,0.95f), - -6f, eps) + assertEquals( + FocalPointUtil.focalOffset(2f, 4f, 2f, 0.95f), + -6f, eps + ) } @Test fun possibleFocalOffsetTest() { - assertEquals(FocalPointUtil.focalOffset(2f, 4f, 2f,0.7f), - -4.6f, eps) + assertEquals( + FocalPointUtil.focalOffset(2f, 4f, 2f, 0.7f), + -4.6f, eps + ) } -} \ No newline at end of file +} diff --git a/app/src/test/java/com/keylesspalace/tusky/SpanUtilsTest.kt b/app/src/test/java/com/keylesspalace/tusky/SpanUtilsTest.kt index 68bdaaa97..213405603 100644 --- a/app/src/test/java/com/keylesspalace/tusky/SpanUtilsTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/SpanUtilsTest.kt @@ -36,10 +36,10 @@ class SpanUtilsTest { @JvmStatic fun data(): Iterable { return listOf( - "@mention", - "#tag", - "https://thr.ee/meh?foo=bar&wat=@at#hmm", - "http://thr.ee/meh?foo=bar&wat=@at#hmm" + "@mention", + "#tag", + "https://thr.ee/meh?foo=bar&wat=@at#hmm", + "http://thr.ee/meh?foo=bar&wat=@at#hmm" ) } } @@ -94,21 +94,23 @@ class SpanUtilsTest { } @RunWith(Parameterized::class) - class HighlightingTestsForTag(private val text: String, - private val expectedStartIndex: Int, - private val expectedEndIndex: Int) { + class HighlightingTestsForTag( + private val text: String, + private val expectedStartIndex: Int, + private val expectedEndIndex: Int + ) { companion object { @Parameterized.Parameters(name = "{0}") @JvmStatic fun data(): Iterable { return listOf( - arrayOf("#test", 0, 5), - arrayOf(" #AfterSpace", 1, 12), - arrayOf("#BeforeSpace ", 0, 12), - arrayOf("@#after_at", 1, 10), - arrayOf("あいうえお#after_hiragana", 5, 20), - arrayOf("##DoubleHash", 1, 12), - arrayOf("###TripleHash", 2, 13) + arrayOf("#test", 0, 5), + arrayOf(" #AfterSpace", 1, 12), + arrayOf("#BeforeSpace ", 0, 12), + arrayOf("@#after_at", 1, 10), + arrayOf("あいうえお#after_hiragana", 5, 20), + arrayOf("##DoubleHash", 1, 12), + arrayOf("###TripleHash", 2, 13) ) } } @@ -133,13 +135,13 @@ class SpanUtilsTest { } override fun getSpans(start: Int, end: Int, type: Class): Array { - return spans.filter { it.start >= start && it.end <= end && type.isInstance(it)} - .map { it.span } - .toTypedArray() as Array + return spans.filter { it.start >= start && it.end <= end && type.isInstance(it) } + .map { it.span } + .toTypedArray() as Array } override fun removeSpan(what: Any?) { - spans.removeIf { span -> span.span == what} + spans.removeIf { span -> span.span == what } } override fun toString(): String { @@ -175,4 +177,4 @@ class SpanUtilsTest { throw NotImplementedError() } } -} \ No newline at end of file +} diff --git a/app/src/test/java/com/keylesspalace/tusky/StringUtilsTest.kt b/app/src/test/java/com/keylesspalace/tusky/StringUtilsTest.kt index 7b23297e8..5966cc39e 100644 --- a/app/src/test/java/com/keylesspalace/tusky/StringUtilsTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/StringUtilsTest.kt @@ -3,21 +3,23 @@ package com.keylesspalace.tusky import com.keylesspalace.tusky.util.dec import com.keylesspalace.tusky.util.inc import com.keylesspalace.tusky.util.isLessThan -import org.junit.Assert.* +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Test class StringUtilsTest { @Test fun isLessThan() { val lessList = listOf( - "abc" to "bcd", - "ab" to "abc", - "cb" to "abc", - "1" to "2" + "abc" to "bcd", + "ab" to "abc", + "cb" to "abc", + "1" to "2" ) lessList.forEach { (l, r) -> assertTrue("$l < $r", l.isLessThan(r)) } val notLessList = lessList.map { (l, r) -> r to l } + listOf( - "abc" to "abc" + "abc" to "abc" ) notLessList.forEach { (l, r) -> assertFalse("not $l < $r", l.isLessThan(r)) } } @@ -25,22 +27,22 @@ class StringUtilsTest { @Test fun inc() { listOf( - "122" to "123", - "12A" to "12B", - "1" to "2" + "122" to "123", + "12A" to "12B", + "1" to "2" ).forEach { (l, r) -> assertEquals("$l + 1 = $r", r, l.inc()) } } @Test fun dec() { listOf( - "123" to "122", - "12B" to "12A", - "120" to "11z", - "100" to "zz", - "0" to "", - "" to "", - "2" to "1" + "123" to "122", + "12B" to "12A", + "120" to "11z", + "100" to "zz", + "0" to "", + "" to "", + "2" to "1" ).forEach { (l, r) -> assertEquals("$l - 1 = $r", r, l.dec()) } } -} \ No newline at end of file +} diff --git a/app/src/test/java/com/keylesspalace/tusky/TuskyApplication.kt b/app/src/test/java/com/keylesspalace/tusky/TuskyApplication.kt index 7f82e2495..7724ba768 100644 --- a/app/src/test/java/com/keylesspalace/tusky/TuskyApplication.kt +++ b/app/src/test/java/com/keylesspalace/tusky/TuskyApplication.kt @@ -44,4 +44,4 @@ class TuskyApplication : Application() { @JvmStatic lateinit var localeManager: LocaleManager } -} \ No newline at end of file +} diff --git a/app/src/test/java/com/keylesspalace/tusky/fragment/TimelineRepositoryTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/TimelineRepositoryTest.kt similarity index 57% rename from app/src/test/java/com/keylesspalace/tusky/fragment/TimelineRepositoryTest.kt rename to app/src/test/java/com/keylesspalace/tusky/components/timeline/TimelineRepositoryTest.kt index 1d2ce9bec..2177f249c 100644 --- a/app/src/test/java/com/keylesspalace/tusky/fragment/TimelineRepositoryTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/TimelineRepositoryTest.kt @@ -1,4 +1,4 @@ -package com.keylesspalace.tusky.fragment +package com.keylesspalace.tusky.components.timeline import android.text.SpannableString import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -10,28 +10,28 @@ import com.keylesspalace.tusky.db.TimelineStatusWithAccount import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.repository.* import com.keylesspalace.tusky.util.Either import com.nhaarman.mockitokotlin2.isNull import com.nhaarman.mockitokotlin2.verify import com.nhaarman.mockitokotlin2.verifyNoMoreInteractions import com.nhaarman.mockitokotlin2.whenever -import io.reactivex.Single -import io.reactivex.plugins.RxJavaPlugins -import io.reactivex.schedulers.Schedulers -import io.reactivex.schedulers.TestScheduler +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.plugins.RxJavaPlugins +import io.reactivex.rxjava3.schedulers.Schedulers +import io.reactivex.rxjava3.schedulers.TestScheduler import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test import org.junit.runner.RunWith -import org.mockito.ArgumentMatchers.* +import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.ArgumentMatchers.anyLong import org.mockito.Mock import org.mockito.MockitoAnnotations import org.robolectric.annotation.Config import retrofit2.Response -import java.util.* +import java.util.Date import java.util.concurrent.TimeUnit -import kotlin.collections.ArrayList @Config(sdk = [28]) @RunWith(AndroidJUnit4::class) @@ -51,13 +51,12 @@ class TimelineRepositoryTest { private lateinit var testScheduler: TestScheduler - private val limit = 30 private val account = AccountEntity( - id = 2, - accessToken = "token", - domain = "domain.com", - isActive = true + id = 2, + accessToken = "token", + domain = "domain.com", + isActive = true ) @Before @@ -74,13 +73,13 @@ class TimelineRepositoryTest { @Test fun testNetworkUnbounded() { val statuses = listOf( - makeStatus("3"), - makeStatus("2") + makeStatus("3"), + makeStatus("2") ) whenever(mastodonApi.homeTimeline(isNull(), isNull(), anyInt())) - .thenReturn(Single.just(Response.success(statuses))) + .thenReturn(Single.just(Response.success(statuses))) val result = subject.getStatuses(null, null, null, limit, TimelineRequestMode.NETWORK) - .blockingGet() + .blockingGet() assertEquals(statuses.map(Status::lift), result) testScheduler.advanceTimeBy(100, TimeUnit.SECONDS) @@ -90,9 +89,9 @@ class TimelineRepositoryTest { verify(timelineDao).insertStatusIfNotThere(Placeholder("1").toEntity(account.id)) for (status in statuses) { verify(timelineDao).insertInTransaction( - status.toEntity(account.id, gson), - status.account.toEntity(account.id, gson), - null + status.toEntity(account.id, gson), + status.account.toEntity(account.id, gson), + null ) } verify(timelineDao).cleanup(anyLong()) @@ -102,34 +101,38 @@ class TimelineRepositoryTest { @Test fun testNetworkLoadingTopNoGap() { val response = listOf( - makeStatus("4"), - makeStatus("3"), - makeStatus("2") + makeStatus("4"), + makeStatus("3"), + makeStatus("2") ) val sinceId = "2" val sinceIdMinusOne = "1" whenever(mastodonApi.homeTimeline(null, sinceIdMinusOne, limit + 1)) - .thenReturn(Single.just(Response.success(response))) - val result = subject.getStatuses(null, sinceId, sinceIdMinusOne, limit, - TimelineRequestMode.NETWORK) - .blockingGet() + .thenReturn(Single.just(Response.success(response))) + val result = subject.getStatuses( + null, sinceId, sinceIdMinusOne, limit, + TimelineRequestMode.NETWORK + ) + .blockingGet() assertEquals( - response.subList(0, 2).map(Status::lift), - result + response.subList(0, 2).map(Status::lift), + result ) testScheduler.advanceTimeBy(100, TimeUnit.SECONDS) verify(timelineDao).deleteRange(account.id, response.last().id, response.first().id) // We assume for now that overlapped one is inserted but it's not that important for (status in response) { verify(timelineDao).insertInTransaction( - status.toEntity(account.id, gson), - status.account.toEntity(account.id, gson), - null + status.toEntity(account.id, gson), + status.account.toEntity(account.id, gson), + null ) } - verify(timelineDao).removeAllPlaceholdersBetween(account.id, response.first().id, - response.last().id) + verify(timelineDao).removeAllPlaceholdersBetween( + account.id, response.first().id, + response.last().id + ) verify(timelineDao).cleanup(anyLong()) verifyNoMoreInteractions(timelineDao) } @@ -137,16 +140,18 @@ class TimelineRepositoryTest { @Test fun testNetworkLoadingTopWithGap() { val response = listOf( - makeStatus("5"), - makeStatus("4") + makeStatus("5"), + makeStatus("4") ) val sinceId = "2" val sinceIdMinusOne = "1" whenever(mastodonApi.homeTimeline(null, sinceIdMinusOne, limit + 1)) - .thenReturn(Single.just(Response.success(response))) - val result = subject.getStatuses(null, sinceId, sinceIdMinusOne, limit, - TimelineRequestMode.NETWORK) - .blockingGet() + .thenReturn(Single.just(Response.success(response))) + val result = subject.getStatuses( + null, sinceId, sinceIdMinusOne, limit, + TimelineRequestMode.NETWORK + ) + .blockingGet() val placeholder = Placeholder("3") assertEquals(response.map(Status::lift) + Either.Left(placeholder), result) @@ -154,9 +159,9 @@ class TimelineRepositoryTest { verify(timelineDao).deleteRange(account.id, response.last().id, response.first().id) for (status in response) { verify(timelineDao).insertInTransaction( - status.toEntity(account.id, gson), - status.account.toEntity(account.id, gson), - null + status.toEntity(account.id, gson), + status.account.toEntity(account.id, gson), + null ) } verify(timelineDao).insertStatusIfNotThere(placeholder.toEntity(account.id)) @@ -174,36 +179,40 @@ class TimelineRepositoryTest { // 1 val response = listOf( - makeStatus("5"), - makeStatus("4"), - makeStatus("3"), - makeStatus("2") + makeStatus("5"), + makeStatus("4"), + makeStatus("3"), + makeStatus("2") ) val sinceId = "2" val sinceIdMinusOne = "1" val maxId = "3" whenever(mastodonApi.homeTimeline(maxId, sinceIdMinusOne, limit + 1)) - .thenReturn(Single.just(Response.success(response))) - val result = subject.getStatuses(maxId, sinceId, sinceIdMinusOne, limit, - TimelineRequestMode.NETWORK) - .blockingGet() + .thenReturn(Single.just(Response.success(response))) + val result = subject.getStatuses( + maxId, sinceId, sinceIdMinusOne, limit, + TimelineRequestMode.NETWORK + ) + .blockingGet() assertEquals( - response.subList(0, response.lastIndex).map(Status::lift), - result + response.subList(0, response.lastIndex).map(Status::lift), + result ) testScheduler.advanceTimeBy(100, TimeUnit.SECONDS) verify(timelineDao).deleteRange(account.id, response.last().id, response.first().id) // We assume for now that overlapped one is inserted but it's not that important for (status in response) { verify(timelineDao).insertInTransaction( - status.toEntity(account.id, gson), - status.account.toEntity(account.id, gson), - null + status.toEntity(account.id, gson), + status.account.toEntity(account.id, gson), + null ) } - verify(timelineDao).removeAllPlaceholdersBetween(account.id, response.first().id, - response.last().id) + verify(timelineDao).removeAllPlaceholdersBetween( + account.id, response.first().id, + response.last().id + ) verify(timelineDao).cleanup(anyLong()) verifyNoMoreInteractions(timelineDao) } @@ -218,23 +227,25 @@ class TimelineRepositoryTest { // 1 val response = listOf( - makeStatus("6"), - makeStatus("5"), - makeStatus("4") + makeStatus("6"), + makeStatus("5"), + makeStatus("4") ) val sinceId = "2" val sinceIdMinusOne = "1" val maxId = "4" whenever(mastodonApi.homeTimeline(maxId, sinceIdMinusOne, limit + 1)) - .thenReturn(Single.just(Response.success(response))) - val result = subject.getStatuses(maxId, sinceId, sinceIdMinusOne, limit, - TimelineRequestMode.NETWORK) - .blockingGet() + .thenReturn(Single.just(Response.success(response))) + val result = subject.getStatuses( + maxId, sinceId, sinceIdMinusOne, limit, + TimelineRequestMode.NETWORK + ) + .blockingGet() val placeholder = Placeholder("3") assertEquals( - response.map(Status::lift) + Either.Left(placeholder), - result + response.map(Status::lift) + Either.Left(placeholder), + result ) testScheduler.advanceTimeBy(100, TimeUnit.SECONDS) // We assume for now that overlapped one is inserted but it's not that important @@ -243,13 +254,15 @@ class TimelineRepositoryTest { for (status in response) { verify(timelineDao).insertInTransaction( - status.toEntity(account.id, gson), - status.account.toEntity(account.id, gson), - null + status.toEntity(account.id, gson), + status.account.toEntity(account.id, gson), + null ) } - verify(timelineDao).removeAllPlaceholdersBetween(account.id, response.first().id, - response.last().id) + verify(timelineDao).removeAllPlaceholdersBetween( + account.id, response.first().id, + response.last().id + ) verify(timelineDao).insertStatusIfNotThere(placeholder.toEntity(account.id)) verify(timelineDao).cleanup(anyLong()) verifyNoMoreInteractions(timelineDao) @@ -265,11 +278,11 @@ class TimelineRepositoryTest { dbResult.account = status.account.toEntity(account.id, gson) whenever(mastodonApi.homeTimeline(any(), any(), any())) - .thenReturn(Single.just(Response.success((listOf(status))))) + .thenReturn(Single.just(Response.success((listOf(status))))) whenever(timelineDao.getStatusesForAccount(account.id, status.id, null, 30)) - .thenReturn(Single.just(listOf(dbResult))) + .thenReturn(Single.just(listOf(dbResult))) val result = subject.getStatuses(null, null, null, limit, TimelineRequestMode.ANY) - .blockingGet() + .blockingGet() assertEquals(listOf(status, dbStatus).map(Status::lift), result) } @@ -283,61 +296,61 @@ class TimelineRepositoryTest { dbResult2.status = Placeholder("1").toEntity(account.id) whenever(mastodonApi.homeTimeline(any(), any(), any())) - .thenReturn(Single.just(Response.success(listOf(status)))) + .thenReturn(Single.just(Response.success(listOf(status)))) whenever(timelineDao.getStatusesForAccount(account.id, status.id, null, 30)) - .thenReturn(Single.just(listOf(dbResult, dbResult2))) + .thenReturn(Single.just(listOf(dbResult, dbResult2))) val result = subject.getStatuses(null, null, null, limit, TimelineRequestMode.ANY) - .blockingGet() + .blockingGet() assertEquals(listOf(status).map(Status::lift), result) } +} - private fun makeStatus(id: String, account: Account = makeAccount(id)): Status { - return Status( - id = id, - account = account, - content = SpannableString("hello$id"), - createdAt = Date(), - emojis = listOf(), - reblogsCount = 3, - favouritesCount = 5, - sensitive = false, - visibility = Status.Visibility.PUBLIC, - spoilerText = "", - reblogged = true, - favourited = false, - bookmarked = false, - attachments = ArrayList(), - mentions = arrayOf(), - application = null, - inReplyToAccountId = null, - inReplyToId = null, - pinned = false, - muted = false, - reblog = null, - url = "http://example.com/statuses/$id", - poll = null, - card = null, - quote = null - ) - } +fun makeAccount(id: String): Account { + return Account( + id = id, + localUsername = "test$id", + username = "test$id@example.com", + displayName = "Example Account $id", + note = SpannableString("Note! $id"), + url = "https://example.com/@test$id", + avatar = "avatar$id", + header = "Header$id", + followersCount = 300, + followingCount = 400, + statusesCount = 1000, + bot = false, + emojis = listOf(), + fields = null, + source = null + ) +} - private fun makeAccount(id: String): Account { - return Account( - id = id, - localUsername = "test$id", - username = "test$id@example.com", - displayName = "Example Account $id", - note = SpannableString("Note! $id"), - url = "https://example.com/@test$id", - avatar = "avatar$id", - header = "Header$id", - followersCount = 300, - followingCount = 400, - statusesCount = 1000, - bot = false, - emojis = listOf(), - fields = null, - source = null - ) - } -} \ No newline at end of file +fun makeStatus(id: String, account: Account = makeAccount(id)): Status { + return Status( + id = id, + account = account, + content = SpannableString("hello$id"), + createdAt = Date(), + emojis = listOf(), + reblogsCount = 3, + favouritesCount = 5, + sensitive = false, + visibility = Status.Visibility.PUBLIC, + spoilerText = "", + reblogged = true, + favourited = false, + bookmarked = false, + attachments = ArrayList(), + mentions = listOf(), + application = null, + inReplyToAccountId = null, + inReplyToId = null, + pinned = false, + muted = false, + reblog = null, + url = "http://example.com/statuses/$id", + poll = null, + card = null, + quote = null + ) +} diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/TimelineViewModelTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/TimelineViewModelTest.kt new file mode 100644 index 000000000..a5665de92 --- /dev/null +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/TimelineViewModelTest.kt @@ -0,0 +1,791 @@ +package com.keylesspalace.tusky.components.timeline + +import android.content.SharedPreferences +import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.components.timeline.TimelineViewModel.Companion.LOAD_AT_ONCE +import com.keylesspalace.tusky.db.AccountEntity +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.entity.Poll +import com.keylesspalace.tusky.entity.PollOption +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.network.FilterModel +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.network.TimelineCases +import com.keylesspalace.tusky.util.Either +import com.keylesspalace.tusky.util.toViewData +import com.keylesspalace.tusky.viewdata.StatusViewData +import com.nhaarman.mockitokotlin2.clearInvocations +import com.nhaarman.mockitokotlin2.doReturn +import com.nhaarman.mockitokotlin2.eq +import com.nhaarman.mockitokotlin2.isNull +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.times +import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.whenever +import io.reactivex.rxjava3.annotations.NonNull +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.observers.TestObserver +import io.reactivex.rxjava3.subjects.PublishSubject +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowLog +import retrofit2.Response +import java.io.IOException + +@Config(sdk = [29]) +class TimelineViewModelTest { + lateinit var timelineRepository: TimelineRepository + lateinit var timelineCases: TimelineCases + lateinit var mastodonApi: MastodonApi + lateinit var eventHub: EventHub + lateinit var viewModel: TimelineViewModel + lateinit var accountManager: AccountManager + lateinit var sharedPreference: SharedPreferences + + @Before + fun setup() { + ShadowLog.stream = System.out + timelineRepository = mock() + timelineCases = mock() + mastodonApi = mock() + eventHub = mock { + on { events } doReturn Observable.never() + } + val account = AccountEntity( + 0, + "domain", + "accessToken", + isActive = true, + ) + + accountManager = mock { + on { activeAccount } doReturn account + } + sharedPreference = mock() + viewModel = TimelineViewModel( + timelineRepository, + timelineCases, + mastodonApi, + eventHub, + accountManager, + sharedPreference, + FilterModel() + ) + } + + @Test + fun `loadInitial, home, without cache, empty response`() { + val initialResponse = listOf() + setCachedResponse(initialResponse) + + // loadAbove -> loadBelow + whenever( + timelineRepository.getStatuses( + maxId = null, + sinceId = null, + sincedIdMinusOne = null, + requestMode = TimelineRequestMode.ANY, + limit = LOAD_AT_ONCE + ) + ).thenReturn(Single.just(listOf())) + + runBlocking { + viewModel.loadInitial() + } + + verify(timelineRepository).getStatuses( + null, + null, + null, + LOAD_AT_ONCE, + TimelineRequestMode.ANY + ) + } + + @Test + fun `loadInitial, home, without cache, single item in response`() { + setCachedResponse(listOf()) + + val status = makeStatus("1") + whenever( + timelineRepository.getStatuses( + isNull(), + isNull(), + isNull(), + eq(LOAD_AT_ONCE), + eq(TimelineRequestMode.ANY) + ) + ).thenReturn( + Single.just( + listOf( + Either.Right(status) + ) + ) + ) + + val updates = viewModel.viewUpdates.test() + + runBlocking { + viewModel.loadInitial() + } + + verify(timelineRepository).getStatuses( + isNull(), + isNull(), + isNull(), + eq(LOAD_AT_ONCE), + eq(TimelineRequestMode.ANY) + ) + + assertViewUpdated(updates) + + assertHasList(listOf(status).toViewData()) + } + + @Test + fun `loadInitial, list`() { + val listId = "listId" + viewModel.init(TimelineViewModel.Kind.LIST, listId, listOf()) + val status = makeStatus("1") + + whenever( + mastodonApi.listTimeline( + listId, + null, + null, + LOAD_AT_ONCE, + ) + ).thenReturn( + Single.just( + Response.success( + listOf( + status + ) + ) + ) + ) + + val updates = viewModel.viewUpdates.test() + + runBlocking { + viewModel.loadInitial().join() + } + assertViewUpdated(updates) + + assertHasList(listOf(status).toViewData()) + assertFalse("loading", viewModel.isLoadingInitially) + } + + @Test + fun `loadInitial, home, without cache, error on load`() { + setCachedResponse(listOf()) + + whenever( + timelineRepository.getStatuses( + maxId = null, + sinceId = null, + sincedIdMinusOne = null, + limit = LOAD_AT_ONCE, + TimelineRequestMode.ANY, + ) + ).thenReturn(Single.error(IOException("test"))) + + val updates = viewModel.viewUpdates.test() + + runBlocking { + viewModel.loadInitial() + } + + verify(timelineRepository).getStatuses( + isNull(), + isNull(), + isNull(), + eq(LOAD_AT_ONCE), + eq(TimelineRequestMode.ANY) + ) + + assertViewUpdated(updates) + + assertHasList(listOf()) + assertEquals(TimelineViewModel.FailureReason.NETWORK, viewModel.failure) + } + + @Test + fun `loadInitial, home, with cache, error on load above`() { + val statuses = (5 downTo 1).map { makeStatus(it.toString()) } + setCachedResponse(statuses) + setInitialRefresh("6", statuses) + + whenever( + timelineRepository.getStatuses( + maxId = null, + sinceId = "5", + sincedIdMinusOne = "4", + limit = LOAD_AT_ONCE, + TimelineRequestMode.NETWORK, + ) + ).thenReturn(Single.error(IOException("test"))) + + val updates = viewModel.viewUpdates.test() + + runBlocking { + viewModel.loadInitial() + } + + assertViewUpdated(updates) + + assertHasList(statuses.toViewData()) + // No failure set since we had statuses + assertNull(viewModel.failure) + } + + @Test + fun `loadInitial, home, with cache, error on refresh`() { + val statuses = (5 downTo 2).map { makeStatus(it.toString()) } + setCachedResponse(statuses) + + // Error on refreshing cached + whenever( + timelineRepository.getStatuses( + maxId = "6", + sinceId = null, + sincedIdMinusOne = null, + limit = LOAD_AT_ONCE, + TimelineRequestMode.NETWORK, + ) + ).thenReturn(Single.error(IOException("test"))) + + // Empty on loading above + setLoadAbove("5", "4", listOf()) + + val updates = viewModel.viewUpdates.test() + + runBlocking { + viewModel.loadInitial() + } + + assertViewUpdated(updates) + + assertHasList(statuses.toViewData()) + assertNull(viewModel.failure) + } + + @Test + fun `loads above cached`() { + val cachedStatuses = (5 downTo 1).map { makeStatus(it.toString()) } + setCachedResponse(cachedStatuses) + setInitialRefresh("6", cachedStatuses) + + val additionalStatuses = (10 downTo 6) + .map { makeStatus(it.toString()) } + + whenever( + timelineRepository.getStatuses( + null, + "5", + "4", + LOAD_AT_ONCE, + TimelineRequestMode.NETWORK + ) + ).thenReturn(Single.just(additionalStatuses.toEitherList())) + + runBlocking { + viewModel.loadInitial() + } + + // We could also check refresh progress here but it's a bit cumbersome + + assertHasList(additionalStatuses.plus(cachedStatuses).toViewData()) + } + + @Test + fun refresh() { + val cachedStatuses = (5 downTo 1).map { makeStatus(it.toString()) } + setCachedResponse(cachedStatuses) + setInitialRefresh("6", cachedStatuses) + + val additionalStatuses = listOf(makeStatus("6")) + + whenever( + timelineRepository.getStatuses( + null, + "5", + "4", + LOAD_AT_ONCE, + TimelineRequestMode.NETWORK + ) + ).thenReturn(Single.just(additionalStatuses.toEitherList())) + + runBlocking { + viewModel.loadInitial() + } + + clearInvocations(timelineRepository) + + val newStatuses = (8 downTo 7).map { makeStatus(it.toString()) } + + // Loading above the cached manually + whenever( + timelineRepository.getStatuses( + null, + "6", + "5", + LOAD_AT_ONCE, + TimelineRequestMode.NETWORK + ) + ).thenReturn(Single.just(newStatuses.toEitherList())) + + runBlocking { + viewModel.refresh() + } + + val allStatuses = newStatuses + additionalStatuses + cachedStatuses + assertHasList(allStatuses.toViewData()) + } + + @Test + fun `refresh failed`() { + val cachedStatuses = (5 downTo 1).map { makeStatus(it.toString()) } + setCachedResponse(cachedStatuses) + setInitialRefresh("6", cachedStatuses) + setLoadAbove("5", "4", listOf()) + + runBlocking { + viewModel.loadInitial() + } + + clearInvocations(timelineRepository) + + // Loading above the cached manually + whenever( + timelineRepository.getStatuses( + null, + "6", + "5", + LOAD_AT_ONCE, + TimelineRequestMode.NETWORK + ) + ).thenReturn(Single.error(IOException("test"))) + + runBlocking { + viewModel.refresh().join() + } + + assertHasList(cachedStatuses.map { it.toViewData(false, false) }) + assertFalse("refreshing", viewModel.isRefreshing) + assertNull("failure is not set", viewModel.failure) + } + + @Test + fun loadMore() { + val cachedStatuses = (10 downTo 5).map { makeStatus(it.toString()) } + setCachedResponse(cachedStatuses) + setInitialRefresh("11", cachedStatuses) + + // Nothing above + setLoadAbove("10", "9", listOf()) + + runBlocking { + viewModel.loadInitial().join() + } + + clearInvocations(timelineRepository) + + val oldStatuses = (4 downTo 1).map { makeStatus(it.toString()) } + + // Loading below the cached + whenever( + timelineRepository.getStatuses( + "5", + null, + null, + LOAD_AT_ONCE, + TimelineRequestMode.ANY + ) + ).thenReturn(Single.just(oldStatuses.toEitherList())) + + runBlocking { + viewModel.loadMore().join() + } + + val allStatuses = cachedStatuses + oldStatuses + assertHasList(allStatuses.toViewData()) + } + + @Test + fun `loadMore parallel`() { + val cachedStatuses = (10 downTo 5).map { makeStatus(it.toString()) } + setCachedResponse(cachedStatuses) + setInitialRefresh("11", cachedStatuses) + + // Nothing above + setLoadAbove("10", "9", listOf()) + + runBlocking { + viewModel.loadInitial().join() + } + + clearInvocations(timelineRepository) + + val oldStatuses = (4 downTo 1).map { makeStatus(it.toString()) } + + val responseSubject = PublishSubject.create>() + // Loading below the cached + whenever( + timelineRepository.getStatuses( + "5", + null, + null, + LOAD_AT_ONCE, + TimelineRequestMode.ANY + ) + ).thenReturn(responseSubject.firstOrError()) + + clearInvocations(timelineRepository) + + runBlocking { + // Trigger them in parallel + val job1 = viewModel.loadMore() + val job2 = viewModel.loadMore() + // Send the response + responseSubject.onNext(oldStatuses.toEitherList()) + // Wait for both + job1.join() + job2.join() + } + + val allStatuses = cachedStatuses + oldStatuses + assertHasList(allStatuses.toViewData()) + + verify(timelineRepository, times(1)).getStatuses( + "5", + null, + null, + LOAD_AT_ONCE, + TimelineRequestMode.ANY + ) + } + + @Test + fun `loadMore failed`() { + val cachedStatuses = (10 downTo 5).map { makeStatus(it.toString()) } + setCachedResponse(cachedStatuses) + setInitialRefresh("11", cachedStatuses) + + // Nothing above + setLoadAbove("10", "9", listOf()) + + runBlocking { + viewModel.loadInitial().join() + } + + clearInvocations(timelineRepository) + + // Loading below the cached + whenever( + timelineRepository.getStatuses( + "5", + null, + null, + LOAD_AT_ONCE, + TimelineRequestMode.ANY + ) + ).thenReturn(Single.error(IOException("test"))) + + runBlocking { + viewModel.loadMore().join() + } + + assertHasList(cachedStatuses.toViewData()) + + // Check that we can still load after that + + val oldStatuses = listOf(makeStatus("4")) + whenever( + timelineRepository.getStatuses( + "5", + null, + null, + LOAD_AT_ONCE, + TimelineRequestMode.ANY + ) + ).thenReturn(Single.just(oldStatuses.toEitherList())) + + runBlocking { + viewModel.loadMore().join() + } + assertHasList((cachedStatuses + oldStatuses).toViewData()) + } + + @Test + fun loadGap() { + val status5 = makeStatus("5") + val status4 = makeStatus("4") + val status3 = makeStatus("3") + val status1 = makeStatus("1") + + val cachedStatuses: List = listOf( + Either.Right(status5), + Either.Left(Placeholder("4")), + Either.Right(status1) + ) + val laterFetchedStatuses = listOf( + Either.Right(status4), + Either.Right(status3), + ) + + setCachedResponseWithGaps(cachedStatuses) + setInitialRefreshWithGaps("6", cachedStatuses) + + // Nothing above + setLoadAbove("5", items = listOf()) + + whenever( + timelineRepository.getStatuses( + "5", + "1", + null, + LOAD_AT_ONCE, + TimelineRequestMode.NETWORK + ) + ).thenReturn(Single.just(laterFetchedStatuses)) + + runBlocking { + viewModel.loadInitial().join() + + viewModel.loadGap(1).join() + } + + assertHasList( + listOf( + status5, + status4, + status3, + status1 + ).toViewData() + ) + } + + @Test + fun `loadGap failed`() { + val status5 = makeStatus("5") + val status1 = makeStatus("1") + + val cachedStatuses: List = listOf( + Either.Right(status5), + Either.Left(Placeholder("4")), + Either.Right(status1) + ) + setCachedResponseWithGaps(cachedStatuses) + setInitialRefreshWithGaps("6", cachedStatuses) + + setLoadAbove("5", items = listOf()) + + whenever( + timelineRepository.getStatuses( + "5", + "1", + null, + LOAD_AT_ONCE, + TimelineRequestMode.NETWORK + ) + ).thenReturn(Single.error(IOException("test"))) + + runBlocking { + viewModel.loadInitial().join() + + viewModel.loadGap(1).join() + } + + assertHasList( + listOf( + status5.toViewData(false, false), + StatusViewData.Placeholder("4", false), + status1.toViewData(false, false), + ) + ) + } + + @Test + fun favorite() { + val status5 = makeStatus("5") + val status4 = makeStatus("4") + val status3 = makeStatus("3") + val statuses = listOf(status5, status4, status3) + setCachedResponse(statuses) + setInitialRefresh("6", statuses) + setLoadAbove("5", "4", listOf()) + + runBlocking { viewModel.loadInitial() } + + whenever(timelineCases.favourite("4", true)) + .thenReturn(Single.just(status4.copy(favourited = true))) + + runBlocking { + viewModel.favorite(true, 1).join() + } + + verify(timelineCases).favourite("4", true) + + assertHasList(listOf(status5, status4.copy(favourited = true), status3).toViewData()) + } + + @Test + fun reblog() { + val status5 = makeStatus("5") + val status4 = makeStatus("4") + val status3 = makeStatus("3") + val statuses = listOf(status5, status4, status3) + setCachedResponse(statuses) + setInitialRefresh("6", statuses) + setLoadAbove("5", "4", listOf()) + + runBlocking { viewModel.loadInitial() } + + whenever(timelineCases.reblog("4", true)) + .thenReturn(Single.just(status4.copy(reblogged = true))) + + runBlocking { + viewModel.reblog(true, 1).join() + } + + verify(timelineCases).reblog("4", true) + + assertHasList(listOf(status5, status4.copy(reblogged = true), status3).toViewData()) + } + + @Test + fun bookmark() { + val status5 = makeStatus("5") + val status4 = makeStatus("4") + val status3 = makeStatus("3") + val statuses = listOf(status5, status4, status3) + setCachedResponse(statuses) + setInitialRefresh("6", statuses) + setLoadAbove("5", "4", listOf()) + + runBlocking { viewModel.loadInitial() } + + whenever(timelineCases.bookmark("4", true)) + .thenReturn(Single.just(status4.copy(bookmarked = true))) + + runBlocking { + viewModel.bookmark(true, 1).join() + } + + verify(timelineCases).bookmark("4", true) + + assertHasList(listOf(status5, status4.copy(bookmarked = true), status3).toViewData()) + } + + @Test + fun voteInPoll() { + val status5 = makeStatus("5") + val poll = Poll( + "1", + expiresAt = null, + expired = false, + multiple = false, + votersCount = 1, + votesCount = 1, + voted = false, + options = listOf(PollOption("1", 1), PollOption("2", 2)), + ) + val status4 = makeStatus("4").copy(poll = poll) + val status3 = makeStatus("3") + val statuses = listOf(status5, status4, status3) + setCachedResponse(statuses) + setInitialRefresh("6", statuses) + setLoadAbove("5", "4", listOf()) + + runBlocking { viewModel.loadInitial() } + + val votedPoll = poll.votedCopy(listOf(0)) + whenever(timelineCases.voteInPoll("4", poll.id, listOf(0))) + .thenReturn(Single.just(votedPoll)) + + runBlocking { + viewModel.voteInPoll(1, listOf(0)).join() + } + + verify(timelineCases).voteInPoll("4", poll.id, listOf(0)) + + assertHasList(listOf(status5, status4.copy(poll = votedPoll), status3).toViewData()) + } + + private fun setLoadAbove( + above: String, + aboveMinusOne: String? = null, + items: List + ) { + whenever( + timelineRepository.getStatuses( + null, + above, + aboveMinusOne, + LOAD_AT_ONCE, + TimelineRequestMode.NETWORK + ) + ).thenReturn(Single.just(items)) + } + + private fun assertHasList(aList: List) { + assertEquals( + aList, + viewModel.statuses.toList() + ) + } + + private fun assertViewUpdated(updates: @NonNull TestObserver) { + assertTrue("There were view updates", updates.values().isNotEmpty()) + } + + private fun setInitialRefresh(maxId: String?, statuses: List) { + setInitialRefreshWithGaps(maxId, statuses.toEitherList()) + } + + private fun setCachedResponse(initialResponse: List) { + setCachedResponseWithGaps(initialResponse.toEitherList()) + } + + private fun setCachedResponseWithGaps(initialResponse: List) { + whenever( + timelineRepository.getStatuses( + isNull(), + isNull(), + isNull(), + eq(LOAD_AT_ONCE), + eq(TimelineRequestMode.DISK) + ) + ) + .thenReturn(Single.just(initialResponse)) + } + + private fun setInitialRefreshWithGaps(maxId: String?, statuses: List) { + whenever( + timelineRepository.getStatuses( + maxId, + null, + null, + LOAD_AT_ONCE, + TimelineRequestMode.NETWORK + ) + ).thenReturn(Single.just(statuses)) + } + + private fun List.toViewData(): List = map { + it.toViewData( + alwaysShowSensitiveMedia = false, + alwaysOpenSpoiler = false + ) + } + + private fun List.toEitherList() = map { Either.Right(it) } +} diff --git a/app/src/test/java/com/keylesspalace/tusky/util/EmojiCompatFontTest.kt b/app/src/test/java/com/keylesspalace/tusky/util/EmojiCompatFontTest.kt index badaa709a..5dd5ea84f 100644 --- a/app/src/test/java/com/keylesspalace/tusky/util/EmojiCompatFontTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/util/EmojiCompatFontTest.kt @@ -1,6 +1,6 @@ package com.keylesspalace.tusky.util -import org.junit.Assert.* +import org.junit.Assert.assertEquals import org.junit.Test class EmojiCompatFontTest { @@ -9,39 +9,39 @@ class EmojiCompatFontTest { fun testCompareVersions() { assertEquals( - -1, - EmojiCompatFont.compareVersions( - listOf(0), - listOf(1, 2, 3) - ) + -1, + EmojiCompatFont.compareVersions( + listOf(0), + listOf(1, 2, 3) + ) ) assertEquals( - 1, - EmojiCompatFont.compareVersions( - listOf(1, 2, 3), - listOf(0, 0, 0) - ) + 1, + EmojiCompatFont.compareVersions( + listOf(1, 2, 3), + listOf(0, 0, 0) + ) ) assertEquals( - -1, - EmojiCompatFont.compareVersions( - listOf(1, 0, 1), - listOf(1, 1, 0) - ) + -1, + EmojiCompatFont.compareVersions( + listOf(1, 0, 1), + listOf(1, 1, 0) + ) ) assertEquals( - 0, - EmojiCompatFont.compareVersions( - listOf(4, 5, 6), - listOf(4, 5, 6) - ) + 0, + EmojiCompatFont.compareVersions( + listOf(4, 5, 6), + listOf(4, 5, 6) + ) ) assertEquals( - 0, - EmojiCompatFont.compareVersions( - listOf(0, 0), - listOf(0) - ) + 0, + EmojiCompatFont.compareVersions( + listOf(0, 0), + listOf(0) + ) ) } -} \ No newline at end of file +} diff --git a/app/src/test/java/com/keylesspalace/tusky/util/SmartLengthInputFilterTest.kt b/app/src/test/java/com/keylesspalace/tusky/util/SmartLengthInputFilterTest.kt index b85d60a1f..5b6f417b7 100644 --- a/app/src/test/java/com/keylesspalace/tusky/util/SmartLengthInputFilterTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/util/SmartLengthInputFilterTest.kt @@ -12,7 +12,6 @@ import org.robolectric.annotation.Config @RunWith(AndroidJUnit4::class) class SmartLengthInputFilterTest { - @Test fun shouldNotTrimStatusWithLength0() { assertFalse(shouldTrimStatus(SpannableStringBuilder(""))) @@ -25,56 +24,80 @@ class SmartLengthInputFilterTest { @Test fun shouldNotTrimStatusWithLength500() { - assertFalse(shouldTrimStatus(SpannableStringBuilder("u1Pc5TbDVYFnzIdqlQkb3xuZ2S61fFD1K4u" + - "cb3q40dnELjAsWxnSH59jqly249Spr0Vod029zfwFHYQ0PkBCNQ7tuk90h6aY661RFC7vhIKJna4yDYOBFj" + - "RR9u0CsUa6vlgEE5yUrk5LKn3bmnnzRCXmU6HyT2bFu256qoUWbmMQ6GFXUXjO28tru8Q3UiXKLgrotKdSH" + - "mmqPwQgtatbMykTW4RZdKTE46nzlbD3mXHdWQkf4uVPYhVT1CMvVbCPMaimfQ0xuU8CpxyVqA8a6lCL3YX9" + - "pNnZjD7DoCg2FCejANnjXsTF6vuqPSHjQZDjy696nSAFy95p9kBeJkc70fHzX5TcfUqSaNtvx3LUtpIkwh4" + - "q2EYmKISPsxlANaspEMPuX6r9fSACiEwmHsitZkp4RMKZq5NqRsGPCiAXcNIN3jj9fCYVGxUwVxVeCescDG" + - "5naEEszIR3FT1RO4MSn9c2ZZi0UdLizd8ciJAIuwwmcVyYyyM4"))) + assertFalse( + shouldTrimStatus( + SpannableStringBuilder( + "u1Pc5TbDVYFnzIdqlQkb3xuZ2S61fFD1K4u" + + "cb3q40dnELjAsWxnSH59jqly249Spr0Vod029zfwFHYQ0PkBCNQ7tuk90h6aY661RFC7vhIKJna4yDYOBFj" + + "RR9u0CsUa6vlgEE5yUrk5LKn3bmnnzRCXmU6HyT2bFu256qoUWbmMQ6GFXUXjO28tru8Q3UiXKLgrotKdSH" + + "mmqPwQgtatbMykTW4RZdKTE46nzlbD3mXHdWQkf4uVPYhVT1CMvVbCPMaimfQ0xuU8CpxyVqA8a6lCL3YX9" + + "pNnZjD7DoCg2FCejANnjXsTF6vuqPSHjQZDjy696nSAFy95p9kBeJkc70fHzX5TcfUqSaNtvx3LUtpIkwh4" + + "q2EYmKISPsxlANaspEMPuX6r9fSACiEwmHsitZkp4RMKZq5NqRsGPCiAXcNIN3jj9fCYVGxUwVxVeCescDG" + + "5naEEszIR3FT1RO4MSn9c2ZZi0UdLizd8ciJAIuwwmcVyYyyM4" + ) + ) + ) } @Test fun shouldNotTrimStatusWithLength666() { - assertFalse(shouldTrimStatus(SpannableStringBuilder("hIAXqY7DYynQGcr3zxcjCjNZFcdwAzwnWv" + - "NHONtT55rO3r2faeMRZLTG3JlOshq8M1mtLRn0Ca8M9w82nIjJDm1jspxhFc4uLFpOjb9Gm2BokgRftA8ih" + - "pv6wvMwF5Fg8V4qa8GcXcqt1q7S9g09S3PszCXG4wnrR6dp8GGc9TqVArgmoLSc9EVREIRcLPdzkhV1WWM9" + - "ZWw7josT27BfBdMWk0ckQkClHAyqLtlKZ84WamxK2q3NtHR5gr7ohIjU8CZoKDjv1bA8ZI8wBesyOhqbmHf" + - "0Ltypq39WKZ63VTGSf5Dd9kuTEjlXJtxZD1DXH4FFplY45DH5WuQ61Ih5dGx0WFEEVb1L3aku3Ht8rKG7YU" + - "bOPeanGMBmeI9YRdiD4MmuTUkJfVLkA9rrpRtiEYw8RS3Jf9iqDkTpES9aLQODMip5xTsT4liIcUbLo0Z1d" + - "NhHk7YKubigNQIm1mmh2iU3Q0ZEm8TraDpKu2o27gIwSKbAnTllrOokprPxWQWDVrN9bIliwGHzgTKPI5z8" + - "gUybaqewxUYe12GvxnzqpfPFvvHricyZAC9i6Fkil5VmFdae75tLFWRBfE8Wfep0dSjL751m2yzvzZTc6uZ" + - "RTcUiipvl42DaY8Z5eG2b6xPVhvXshMORvHzwhJhPkHSbnwXX5K"))) + assertFalse( + shouldTrimStatus( + SpannableStringBuilder( + "hIAXqY7DYynQGcr3zxcjCjNZFcdwAzwnWv" + + "NHONtT55rO3r2faeMRZLTG3JlOshq8M1mtLRn0Ca8M9w82nIjJDm1jspxhFc4uLFpOjb9Gm2BokgRftA8ih" + + "pv6wvMwF5Fg8V4qa8GcXcqt1q7S9g09S3PszCXG4wnrR6dp8GGc9TqVArgmoLSc9EVREIRcLPdzkhV1WWM9" + + "ZWw7josT27BfBdMWk0ckQkClHAyqLtlKZ84WamxK2q3NtHR5gr7ohIjU8CZoKDjv1bA8ZI8wBesyOhqbmHf" + + "0Ltypq39WKZ63VTGSf5Dd9kuTEjlXJtxZD1DXH4FFplY45DH5WuQ61Ih5dGx0WFEEVb1L3aku3Ht8rKG7YU" + + "bOPeanGMBmeI9YRdiD4MmuTUkJfVLkA9rrpRtiEYw8RS3Jf9iqDkTpES9aLQODMip5xTsT4liIcUbLo0Z1d" + + "NhHk7YKubigNQIm1mmh2iU3Q0ZEm8TraDpKu2o27gIwSKbAnTllrOokprPxWQWDVrN9bIliwGHzgTKPI5z8" + + "gUybaqewxUYe12GvxnzqpfPFvvHricyZAC9i6Fkil5VmFdae75tLFWRBfE8Wfep0dSjL751m2yzvzZTc6uZ" + + "RTcUiipvl42DaY8Z5eG2b6xPVhvXshMORvHzwhJhPkHSbnwXX5K" + ) + ) + ) } @Test fun shouldTrimStatusWithLength667() { - assertTrue(shouldTrimStatus(SpannableStringBuilder("hIAXqY7DYynQGcr3zxcjCjNZFcdwAzwnWv" + - "NHONtT55rO3r2faeMRZLTG3JlOshq8M1mtLRn0Ca8M9w82nIjJDm1jspxhFc4uLFpOjb9Gm2BokgRftA8ih" + - "pv6wvMwF5Fg8V4qa8GcXcqt1q7S9g09S3PszCXG4wnrR6dp8GGc9TqVArgmoLSc9EVREIRcLPdzkhV1WWM9" + - "ZWw7josT27BfBdMWk0ckQkClHAyqLtlKZ84WamxK2q3NtHR5gr7ohIjU8CZoKDjv1bA8ZI8wBesyOhqbmHf" + - "0Ltypq39WKZ63VTGSf5Dd9kuTEjlXJtxZD1DXH4FFplY45DH5WuQ61Ih5dGx0WFEEVb1L3aku3Ht8rKG7YU" + - "bOPeanGMBmeI9YRdiD4MmuTUkJfVLkA9rrpRtiEYw8RS3Jf9iqDkTpES9aLQODMip5xTsT4liIcUbLo0Z1d" + - "NhHk7YKubigNQIm1mmh2iU3Q0ZEm8TraDpKu2o27gIwSKbAnTllrOokprPxWQWDVrN9bIliwGHzgTKPI5z8" + - "gUybaqewxUYe12GvxnzqpfPFvvHricyZAC9i6Fkil5VmFdae75tLFWRBfE8Wfep0dSjL751m2yzvzZTc6uZ" + - "RTcUiipvl42DaY8Z5eG2b6xPVhvXshMORvHzwhJhPkHSbnwXX5K1"))) + assertTrue( + shouldTrimStatus( + SpannableStringBuilder( + "hIAXqY7DYynQGcr3zxcjCjNZFcdwAzwnWv" + + "NHONtT55rO3r2faeMRZLTG3JlOshq8M1mtLRn0Ca8M9w82nIjJDm1jspxhFc4uLFpOjb9Gm2BokgRftA8ih" + + "pv6wvMwF5Fg8V4qa8GcXcqt1q7S9g09S3PszCXG4wnrR6dp8GGc9TqVArgmoLSc9EVREIRcLPdzkhV1WWM9" + + "ZWw7josT27BfBdMWk0ckQkClHAyqLtlKZ84WamxK2q3NtHR5gr7ohIjU8CZoKDjv1bA8ZI8wBesyOhqbmHf" + + "0Ltypq39WKZ63VTGSf5Dd9kuTEjlXJtxZD1DXH4FFplY45DH5WuQ61Ih5dGx0WFEEVb1L3aku3Ht8rKG7YU" + + "bOPeanGMBmeI9YRdiD4MmuTUkJfVLkA9rrpRtiEYw8RS3Jf9iqDkTpES9aLQODMip5xTsT4liIcUbLo0Z1d" + + "NhHk7YKubigNQIm1mmh2iU3Q0ZEm8TraDpKu2o27gIwSKbAnTllrOokprPxWQWDVrN9bIliwGHzgTKPI5z8" + + "gUybaqewxUYe12GvxnzqpfPFvvHricyZAC9i6Fkil5VmFdae75tLFWRBfE8Wfep0dSjL751m2yzvzZTc6uZ" + + "RTcUiipvl42DaY8Z5eG2b6xPVhvXshMORvHzwhJhPkHSbnwXX5K1" + ) + ) + ) } @Test fun shouldTrimStatusWithLength1000() { - assertTrue(shouldTrimStatus(SpannableStringBuilder("u1Pc5TbDVYFnzIdqlQkb3xuZ2S61fFD1K4u" + - "cb3q40dnELjAsWxnSH59jqly249Spr0Vod029zfwFHYQ0PkBCNQ7tuk90h6aY661RFC7vhIKJna4yDYOBFj" + - "RR9u0CsUa6vlgEE5yUrk5LKn3bmnnzRCXmU6HyT2bFu256qoUWbmMQ6GFXUXjO28tru8Q3UiXKLgrotKdSH" + - "mmqPwQgtatbMykTW4RZdKTE46nzlbD3mXHdWQkf4uVPYhVT1CMvVbCPMaimfQ0xuU8CpxyVqA8a6lCL3YX9" + - "pNnZjD7DoCg2FCejANnjXsTF6vuqPSHjQZDjy696nSAFy95p9kBeJkc70fHzX5TcfUqSaNtvx3LUtpIkwh4" + - "q2EYmKISPsxlANaspEMPuX6r9fSACiEwmHsitZkp4RMKZq5NqRsGPCiAXcNIN3jj9fCYVGxUwVxVeCescDG" + - "5naEEszIR3FT1RO4MSn9c2ZZi0UdLizd8ciJAIuwwmcVyYyyM4"+ - "u1Pc5TbDVYFnzIdqlQkb3xuZ2S61fFD1K4u" + - "cb3q40dnELjAsWxnSH59jqly249Spr0Vod029zfwFHYQ0PkBCNQ7tuk90h6aY661RFC7vhIKJna4yDYOBFj" + - "RR9u0CsUa6vlgEE5yUrk5LKn3bmnnzRCXmU6HyT2bFu256qoUWbmMQ6GFXUXjO28tru8Q3UiXKLgrotKdSH" + - "mmqPwQgtatbMykTW4RZdKTE46nzlbD3mXHdWQkf4uVPYhVT1CMvVbCPMaimfQ0xuU8CpxyVqA8a6lCL3YX9" + - "pNnZjD7DoCg2FCejANnjXsTF6vuqPSHjQZDjy696nSAFy95p9kBeJkc70fHzX5TcfUqSaNtvx3LUtpIkwh4" + - "q2EYmKISPsxlANaspEMPuX6r9fSACiEwmHsitZkp4RMKZq5NqRsGPCiAXcNIN3jj9fCYVGxUwVxVeCescDG" + - "5naEEszIR3FT1RO4MSn9c2ZZi0UdLizd8ciJAIuwwmcVyYyyM4"))) + assertTrue( + shouldTrimStatus( + SpannableStringBuilder( + "u1Pc5TbDVYFnzIdqlQkb3xuZ2S61fFD1K4u" + + "cb3q40dnELjAsWxnSH59jqly249Spr0Vod029zfwFHYQ0PkBCNQ7tuk90h6aY661RFC7vhIKJna4yDYOBFj" + + "RR9u0CsUa6vlgEE5yUrk5LKn3bmnnzRCXmU6HyT2bFu256qoUWbmMQ6GFXUXjO28tru8Q3UiXKLgrotKdSH" + + "mmqPwQgtatbMykTW4RZdKTE46nzlbD3mXHdWQkf4uVPYhVT1CMvVbCPMaimfQ0xuU8CpxyVqA8a6lCL3YX9" + + "pNnZjD7DoCg2FCejANnjXsTF6vuqPSHjQZDjy696nSAFy95p9kBeJkc70fHzX5TcfUqSaNtvx3LUtpIkwh4" + + "q2EYmKISPsxlANaspEMPuX6r9fSACiEwmHsitZkp4RMKZq5NqRsGPCiAXcNIN3jj9fCYVGxUwVxVeCescDG" + + "5naEEszIR3FT1RO4MSn9c2ZZi0UdLizd8ciJAIuwwmcVyYyyM4" + + "u1Pc5TbDVYFnzIdqlQkb3xuZ2S61fFD1K4u" + + "cb3q40dnELjAsWxnSH59jqly249Spr0Vod029zfwFHYQ0PkBCNQ7tuk90h6aY661RFC7vhIKJna4yDYOBFj" + + "RR9u0CsUa6vlgEE5yUrk5LKn3bmnnzRCXmU6HyT2bFu256qoUWbmMQ6GFXUXjO28tru8Q3UiXKLgrotKdSH" + + "mmqPwQgtatbMykTW4RZdKTE46nzlbD3mXHdWQkf4uVPYhVT1CMvVbCPMaimfQ0xuU8CpxyVqA8a6lCL3YX9" + + "pNnZjD7DoCg2FCejANnjXsTF6vuqPSHjQZDjy696nSAFy95p9kBeJkc70fHzX5TcfUqSaNtvx3LUtpIkwh4" + + "q2EYmKISPsxlANaspEMPuX6r9fSACiEwmHsitZkp4RMKZq5NqRsGPCiAXcNIN3jj9fCYVGxUwVxVeCescDG" + + "5naEEszIR3FT1RO4MSn9c2ZZi0UdLizd8ciJAIuwwmcVyYyyM4" + ) + ) + ) } } diff --git a/app/src/test/java/com/keylesspalace/tusky/util/VersionUtilsTest.kt b/app/src/test/java/com/keylesspalace/tusky/util/VersionUtilsTest.kt index 03ab3d947..2731228a0 100644 --- a/app/src/test/java/com/keylesspalace/tusky/util/VersionUtilsTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/util/VersionUtilsTest.kt @@ -7,24 +7,24 @@ import org.junit.runners.Parameterized @RunWith(Parameterized::class) class VersionUtilsTest( - private val versionString: String, - private val supportsScheduledToots: Boolean + private val versionString: String, + private val supportsScheduledToots: Boolean ) { companion object { @JvmStatic @Parameterized.Parameters fun data() = listOf( - arrayOf("2.0.0", false), - arrayOf("2a9a0", false), - arrayOf("1.0", false), - arrayOf("error", false), - arrayOf("", false), - arrayOf("2.6.9", false), - arrayOf("2.7.0", true), - arrayOf("2.00008.0", true), - arrayOf("2.7.2 (compatible; Pleroma 1.0.0-1168-ge18c7866-pleroma-dot-site)", true), - arrayOf("3.0.1", true) + arrayOf("2.0.0", false), + arrayOf("2a9a0", false), + arrayOf("1.0", false), + arrayOf("error", false), + arrayOf("", false), + arrayOf("2.6.9", false), + arrayOf("2.7.0", true), + arrayOf("2.00008.0", true), + arrayOf("2.7.2 (compatible; Pleroma 1.0.0-1168-ge18c7866-pleroma-dot-site)", true), + arrayOf("3.0.1", true) ) } @@ -32,5 +32,4 @@ class VersionUtilsTest( fun testVersionUtils() { assertEquals(VersionUtils(versionString).supportsScheduledToots(), supportsScheduledToots) } - -} \ No newline at end of file +} diff --git a/build.gradle b/build.gradle index d6ef9eec0..ccc0c7faa 100644 --- a/build.gradle +++ b/build.gradle @@ -1,19 +1,25 @@ buildscript { - ext.kotlin_version = '1.4.31' + ext.kotlin_version = '1.5.10' repositories { google() - jcenter() + mavenCentral() + gradlePluginPortal() } dependencies { - classpath 'com.android.tools.build:gradle:4.1.2' + classpath 'com.android.tools.build:gradle:4.2.1' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + classpath "org.jlleitschuh.gradle:ktlint-gradle:10.1.0" } } +plugins { + id "org.jlleitschuh.gradle.ktlint" version "10.1.0" +} allprojects { + apply plugin: "org.jlleitschuh.gradle.ktlint" repositories { google() - jcenter() + mavenCentral() maven { url "https://jitpack.io" } } } diff --git a/gradle.properties b/gradle.properties index dab205f58..ff715c0e9 100644 --- a/gradle.properties +++ b/gradle.properties @@ -14,8 +14,6 @@ org.gradle.jvmargs=-Xmx4096m # use parallel execution org.gradle.parallel=true -# enable file system watching -org.gradle.vfs.watch=true android.enableR8.fullMode=true android.enableJetifier=true diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 1c4bcc29e..29e413457 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.8.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-all.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists