diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0e4f81ab..20abafe6 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 Java. + +### 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: @@ -31,15 +37,15 @@ git commit -m "Describe the changes in this commit here." ``` ## Submitting Your Changes -1. Make sure your branch is up-to-date with the ```master``` branch. Run: +1. Make sure your branch is up-to-date with the ```develop``` branch. Run: ``` git fetch -git rebase origin/master +git rebase origin/develop ``` -It may refuse to start the rebase if there's changes that haven't been committed, so make sure you've added and committed everything. If there were changes on master to any of the parts of files you worked on, a conflict will arise when you rebase. [Resolving a merge conflict](https://help.github.com/articles/resolving-a-merge-conflict-using-the-command-line) is a good guide to help with this. After committing the resolution, you can run ```git rebase --continue``` to finish the rebase. If you want to cancel, like if you make some mistake in resolving the conflict, you can always do ```git rebase --abort```. +It may refuse to start the rebase if there's changes that haven't been committed, so make sure you've added and committed everything. If there were changes on develop to any of the parts of files you worked on, a conflict will arise when you rebase. [Resolving a merge conflict](https://help.github.com/articles/resolving-a-merge-conflict-using-the-command-line) is a good guide to help with this. After committing the resolution, you can run ```git rebase --continue``` to finish the rebase. If you want to cancel, like if you make some mistake in resolving the conflict, you can always do ```git rebase --abort```. 2. Push your local branch to your fork on GitHub by running ```git push origin your-change-name```. -3. Then, go to the original project page and make a pull request. Select your fork/branch and use ```master``` as the base branch. +3. Then, go to the original project page and make a pull request. Select your fork/branch and use ```develop``` as the base branch. 4. Wait for feedback on your pull request and be ready to make some changes If you have any questions, don't hesitate to open an issue or contact [Tusky@mastodon.social](https://mastodon.social/@Tusky). Please also ask before you start implementing a new big feature. diff --git a/app/build.gradle b/app/build.gradle index fbc6d6f5..36d9e011 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -15,13 +15,13 @@ def getGitSha = { } android { - compileSdkVersion 29 + compileSdkVersion 30 defaultConfig { applicationId APP_ID minSdkVersion 21 - targetSdkVersion 29 - versionCode 81 - versionName "15.1-CW1" + targetSdkVersion 30 + versionCode 87 + versionName "16.0-CW1" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true @@ -35,7 +35,6 @@ android { kapt { arguments { arg("room.schemaLocation", "$projectDir/schemas") - arg("room.incremental", "true") } } } @@ -61,10 +60,6 @@ android { lintOptions { disable 'MissingTranslation' } - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } buildFeatures { viewBinding true } @@ -89,70 +84,76 @@ android { enableSplit = false } } -} - -project.tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all { kotlinOptions { - jvmTarget = "1.8" + freeCompilerArgs += "-opt-in=kotlin.RequiresOptIn" } } -ext.lifecycleVersion = "2.2.0" +ext.coroutinesVersion = "1.6.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.3' +ext.glideVersion = '4.12.0' +ext.daggerVersion = '2.40.5' +ext.materialdrawerVersion = '8.4.5' // if libraries are changed here, they should also be changed in LicenseActivity 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:$coroutinesVersion" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-rx3:$coroutinesVersion" + + 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.exifinterface:exifinterface:1.3.2" + implementation "androidx.recyclerview:recyclerview:1.2.1" + implementation "androidx.exifinterface:exifinterface:1.3.3" 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" implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion" 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.constraintlayout:constraintlayout: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.google.android.material:material:1.4.0" + + implementation "com.google.code.gson:gson:2.8.9" 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" + kapt "com.github.bumptech.glide:compiler:$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 "com.github.penfeizhou.android.animation:glide-plugin:2.17.0" - implementation "com.uber.autodispose:autodispose-android-archcomponents:1.4.0" - implementation "com.uber.autodispose:autodispose:1.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.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" @@ -166,18 +167,21 @@ dependencies { implementation "com.mikepenz:materialdrawer:$materialdrawerVersion" implementation "com.mikepenz:materialdrawer-iconics:$materialdrawerVersion" - implementation 'com.mikepenz:google-material-typeface:3.0.1.4.original-kotlin@aar' + implementation 'com.mikepenz:google-material-typeface:4.0.0.2-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 "androidx.test.ext:junit:1.1.3" testImplementation "org.robolectric:robolectric:4.4" testImplementation "org.mockito:mockito-inline:3.6.28" testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0" - androidTestImplementation "androidx.test.espresso:espresso-core:3.3.0" + androidTestImplementation "androidx.test.espresso:espresso-core:3.4.0" androidTestImplementation "androidx.room:room-testing:$roomVersion" - androidTestImplementation "androidx.test.ext:junit:1.1.2" + androidTestImplementation "androidx.test.ext:junit:1.1.3" + testImplementation "androidx.arch.core:core-testing:2.1.0" + + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion" } diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index a05994a1..b7de4270 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -33,6 +33,10 @@ public static final ** CREATOR; } +-keepclassmembers class **.R$* { + public static ; +} + # TUSKY SPECIFIC OPTIONS # keep members of our model classes, they are used in json de/serialization @@ -43,10 +47,37 @@ public *; } +-keepclassmembers class com.keylesspalace.tusky.components.conversation.ConversationAccountEntity { *; } +-keepclassmembers class com.keylesspalace.tusky.db.DraftAttachment { *; } + -keep enum com.keylesspalace.tusky.db.DraftAttachment$Type { public *; } +# https://github.com/google/gson/blob/master/examples/android-proguard-example/proguard.cfg + +# Prevent proguard from stripping interface information from TypeAdapter, TypeAdapterFactory, +# JsonSerializer, JsonDeserializer instances (so they can be used in @JsonAdapter) +-keep class * extends com.google.gson.TypeAdapter +-keep class * implements com.google.gson.TypeAdapterFactory +-keep class * implements com.google.gson.JsonSerializer +-keep class * implements com.google.gson.JsonDeserializer + +# Retain generic signatures of TypeToken and its subclasses with R8 version 3.0 and higher. +-keep,allowobfuscation,allowshrinking class com.google.gson.reflect.TypeToken +-keep,allowobfuscation,allowshrinking class * extends com.google.gson.reflect.TypeToken + +# Retain generic signatures of classes used in MastodonApi so Retrofit works +-keep,allowobfuscation,allowshrinking class io.reactivex.rxjava3.core.Single +-keep,allowobfuscation,allowshrinking class retrofit2.Response +-keep,allowobfuscation,allowshrinking class kotlin.collections.List +-keep,allowobfuscation,allowshrinking class kotlin.collections.Map +-keep,allowobfuscation,allowshrinking class retrofit2.Call + +# https://r8.googlesource.com/r8/+/refs/heads/master/compatibility-faq.md#retrofit +-keepattributes Signature +-keep class kotlin.coroutines.Continuation + # preserve line numbers for crash reporting -keepattributes SourceFile,LineNumberTable -renamesourcefileattribute SourceFile @@ -65,7 +96,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 00000000..bd82fd4f --- /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 00000000..c8396309 --- /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/schemas/com.keylesspalace.tusky.db.AppDatabase/28.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/28.json new file mode 100644 index 00000000..c9e9b370 --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/28.json @@ -0,0 +1,777 @@ +{ + "formatVersion": 1, + "database": { + "version": 28, + "identityHash": "867026e095d84652026e902709389c00", + "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 NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT, `mentions` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, 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": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "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 + }, + { + "fieldPath": "expanded", + "columnName": "expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentCollapsed", + "columnName": "contentCollapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentShowing", + "columnName": "contentShowing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pinned", + "columnName": "pinned", + "affinity": "INTEGER", + "notNull": true + } + ], + "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, '867026e095d84652026e902709389c00')" + ] + } +} \ 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 9c65aebf..69641cc4 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 deleted file mode 100644 index da55b08b..00000000 --- a/app/src/androidTest/java/com/keylesspalace/tusky/TimelineDAOTest.kt +++ /dev/null @@ -1,249 +0,0 @@ -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.entity.Status -import com.keylesspalace.tusky.repository.TimelineRepository -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class TimelineDAOTest { - private lateinit var timelineDao: TimelineDao - private lateinit var db: AppDatabase - - @Before - fun createDb() { - val context = InstrumentationRegistry.getInstrumentation().targetContext - db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).build() - timelineDao = db.timelineDao() - } - - @After - fun closeDb() { - db.close() - } - - @Test - fun insertGetStatus() { - val setOne = makeStatus(statusId = 3) - val setTwo = makeStatus(statusId = 20, reblog = true) - val ignoredOne = makeStatus(statusId = 1) - val ignoredTwo = makeStatus(accountId = 2) - - for ((status, author, reblogger) in listOf(setOne, setTwo, ignoredOne, ignoredTwo)) { - timelineDao.insertInTransaction(status, author, reblogger) - } - - 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)) { - val (status, author, reblogger) = set - assertEquals(status, fromDb.status) - assertEquals(author, fromDb.account) - assertEquals(reblogger, fromDb.reblogAccount) - } - } - - @Test - fun doNotOverwrite() { - val (status, author) = makeStatus() - timelineDao.insertInTransaction(status, author, null) - - val placeholder = createPlaceholder(status.serverId, status.timelineUserId) - - timelineDao.insertStatusIfNotThere(placeholder) - - val fromDb = timelineDao.getStatusesForAccount(status.timelineUserId, null, null, 10) - .blockingGet() - val result = fromDb.first() - - assertEquals(1, fromDb.size) - assertEquals(author, result.account) - assertEquals(status, result.status) - assertNull(result.reblogAccount) - - } - - @Test - fun cleanup() { - val now = System.currentTimeMillis() - val oldDate = now - TimelineRepository.CLEANUP_INTERVAL - 20_000 - val oldThisAccount = makeStatus( - statusId = 5, - createdAt = oldDate - ) - val oldAnotherAccount = makeStatus( - statusId = 10, - createdAt = oldDate, - accountId = 2 - ) - val recentThisAccount = makeStatus( - statusId = 30, - createdAt = System.currentTimeMillis() - ) - val recentAnotherAccount = makeStatus( - statusId = 60, - createdAt = System.currentTimeMillis(), - accountId = 2 - ) - - for ((status, author, reblogAuthor) in listOf(oldThisAccount, oldAnotherAccount, recentThisAccount, recentAnotherAccount)) { - timelineDao.insertInTransaction(status, author, reblogAuthor) - } - - timelineDao.cleanup(now - TimelineRepository.CLEANUP_INTERVAL) - - assertEquals( - 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() } - ) - } - - @Test - fun overwriteDeletedStatus() { - - val oldStatuses = listOf( - makeStatus(statusId = 3), - makeStatus(statusId = 2), - makeStatus(statusId = 1) - ) - - timelineDao.deleteRange(1, oldStatuses.last().first.serverId, oldStatuses.first().first.serverId) - - for ((status, author, reblogAuthor) in oldStatuses) { - timelineDao.insertInTransaction(status, author, reblogAuthor) - } - - // status 2 gets deleted, newly loaded status contain only 1 + 3 - val newStatuses = listOf( - makeStatus(statusId = 3), - makeStatus(statusId = 1) - ) - - timelineDao.deleteRange(1, newStatuses.last().first.serverId, newStatuses.first().first.serverId) - - for ((status, author, reblogAuthor) in newStatuses) { - timelineDao.insertInTransaction(status, author, reblogAuthor) - } - - //make sure status 2 is no longer in db - - assertEquals( - 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" - ): Triple { - val author = TimelineAccountEntity( - 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 - ) - } 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 - ) - 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 - ) - } - - 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 1302a031..30751a31 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -34,9 +34,6 @@ android:resource="@xml/share_shortcuts" /> - @@ -114,7 +111,7 @@ android:name=".ViewMediaActivity" android:theme="@style/TuskyBaseTheme" /> @@ -123,7 +120,7 @@ - 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 92994f16..e348f036 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 add21063..599322a0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt @@ -22,12 +22,13 @@ 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.components.account.AccountActivity 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 @@ -60,7 +61,6 @@ abstract class BottomSheetActivity : BaseActivity() { override fun onSlide(bottomSheet: View, slideOffset: Float) {} }) - } open fun viewUrl(url: String, lookupFallbackBehavior: PostLookupFallbackBehavior = PostLookupFallbackBehavior.OPEN_IN_BROWSER) { @@ -70,11 +70,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 } @@ -90,12 +91,14 @@ abstract class BottomSheetActivity : BaseActivity() { } performUrlFallbackAction(url, lookupFallbackBehavior) - }, { + }, + { if (!getCancelSearchRequested(url)) { onEndSearch(url) performUrlFallbackAction(url, lookupFallbackBehavior) } - }) + } + ) onBeginSearch(url) } @@ -177,6 +180,8 @@ abstract class BottomSheetActivity : BaseActivity() { // https://friendica.foo.bar/profile/user // https://friendica.foo.bar/display/d4643c42-3ae0-4b73-b8b0-c725f5819207 // https://misskey.foo.bar/notes/83w6r388br (always lowercase) +// https://pixelfed.social/p/connyduck/391263492998670833 +// https://pixelfed.social/connyduck fun looksLikeMastodonUrl(urlString: String): Boolean { val uri: URI try { @@ -186,20 +191,23 @@ 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("^/@[^/]+/\\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("^/p/\\w+/\\d+$".toRegex()) || + path.matches("^/\\w+$".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 3d7e0380..ef0e3e98 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,19 +164,17 @@ class EditProfileActivity : BaseActivity(), Injectable { viewModel.obtainProfile() } snackbar.show() - } + is Loading -> { } } } viewModel.obtainInstance() viewModel.instanceData.observe(this) { result -> - when (result) { - is Success -> { - val instance = result.data - if (instance?.maxBioChars != null && instance.maxBioChars > 0) { - binding.noteEditTextLayout.counterMaxLength = instance.maxBioChars - } + if (result is Success) { + val instance = result.data + if (instance?.maxBioChars != null && instance.maxBioChars > 0) { + binding.noteEditTextLayout.counterMaxLength = instance.maxBioChars } } } @@ -179,20 +182,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 +207,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 +272,12 @@ class EditProfileActivity : BaseActivity(), Injectable { } } - override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, - grantResults: IntArray) { + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) 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 7e91db07..d6de5d8e 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 406a4aaf..ed3aed3a 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 @@ -41,7 +41,6 @@ class LicenseActivity : BaseActivity() { setTitle(R.string.title_licenses) loadFileIntoTextView(R.raw.apache, binding.licenseApacheTextView) - } 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 be995e9e..3bd0d498 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.viewmodel.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 1acf7dbd..859bac61 100644 --- a/app/src/main/java/com/keylesspalace/tusky/LoginActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/LoginActivity.kt @@ -34,7 +34,11 @@ 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.rickRoll +import com.keylesspalace.tusky.util.shouldRickRoll +import com.keylesspalace.tusky.util.viewBinding import okhttp3.HttpUrl import retrofit2.Call import retrofit2.Callback @@ -62,29 +66,30 @@ 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.registerButton.setOnClickListener { onRegisterClick() } 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() } @@ -96,7 +101,6 @@ class LoginActivity : BaseActivity(), Injectable { } else { binding.toolbar.visibility = View.GONE } - } override fun requiresLogin(): Boolean { @@ -105,7 +109,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) } } @@ -150,8 +154,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) @@ -164,10 +170,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) } @@ -181,11 +187,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) { @@ -193,10 +200,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) @@ -240,31 +247,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) @@ -356,14 +359,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 1628d41d..2290e7d7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt @@ -19,7 +19,10 @@ import android.content.Context import android.content.DialogInterface import android.content.Intent import android.content.res.ColorStateList +import android.graphics.Bitmap import android.graphics.Color +import android.graphics.drawable.Animatable +import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.Drawable import android.net.Uri import android.os.Bundle @@ -31,24 +34,30 @@ import android.widget.ImageView import androidx.appcompat.app.AlertDialog 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 -import com.keylesspalace.tusky.appstore.* +import com.keylesspalace.tusky.appstore.AnnouncementReadEvent +import com.keylesspalace.tusky.appstore.CacheUpdater +import com.keylesspalace.tusky.appstore.Event +import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.appstore.MainTabsChangedEvent +import com.keylesspalace.tusky.appstore.ProfileEditedEvent +import com.keylesspalace.tusky.components.account.AccountActivity import com.keylesspalace.tusky.components.announcements.AnnouncementsActivity import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.components.compose.ComposeActivity.Companion.canHandleMimeType @@ -61,15 +70,20 @@ import com.keylesspalace.tusky.components.scheduled.ScheduledTootActivity import com.keylesspalace.tusky.components.search.SearchActivity import com.keylesspalace.tusky.databinding.ActivityMainBinding import com.keylesspalace.tusky.db.AccountEntity -import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.entity.Account -import com.keylesspalace.tusky.fragment.SFragment 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 @@ -78,15 +92,30 @@ 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.util.* +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.AbstractDrawerImageLoader +import com.mikepenz.materialdrawer.util.DrawerImageLoader +import com.mikepenz.materialdrawer.util.addItems +import com.mikepenz.materialdrawer.util.addItemsAtPosition +import com.mikepenz.materialdrawer.util.updateBadge import com.mikepenz.materialdrawer.widget.AccountHeaderView -import com.uber.autodispose.android.lifecycle.autoDispose import dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector -import io.reactivex.android.schedulers.AndroidSchedulers -import io.reactivex.schedulers.Schedulers +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.schedulers.Schedulers +import kotlinx.coroutines.launch import javax.inject.Inject class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInjector { @@ -102,9 +131,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje @Inject lateinit var conversationRepository: ConversationsRepository - @Inject - lateinit var appDb: AppDatabase - @Inject lateinit var draftHelper: DraftHelper @@ -135,10 +161,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: @@ -163,19 +187,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 @@ -226,24 +253,23 @@ 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() } } + } Schedulers.io().scheduleDirect { // Flush old media that was cached for sharing deleteStaleCachedMedia(applicationContext.getExternalFilesDir("Tusky")) } - draftWarning() } override fun onResume() { @@ -331,12 +357,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 @@ -350,13 +379,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) } } @@ -376,114 +405,116 @@ 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 + } ) 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) @@ -491,11 +522,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) + } ) } EmojiCompat.get().registerInitCallback(emojiInitCallback) @@ -528,7 +559,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje activeTabLayout.removeAllTabs() 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 { @@ -580,31 +611,29 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje binding.mainToolbar.setOnClickListener { (adapter.getFragment(activeTabLayout.selectedTabPosition) as? ReselectableFragment)?.onReselect() } - } 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) val intent = Intent(this, MainActivity::class.java) intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK @@ -621,49 +650,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) @@ -679,49 +714,85 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje private fun loadDrawerAvatar(avatarUrl: String, showPlaceholder: Boolean) { val navIconSize = resources.getDimensionPixelSize(R.dimen.avatar_toolbar_nav_icon_size) - glide.asDrawable() - .load(avatarUrl) - .transform( + val animateAvatars = preferences.getBoolean("animateGifAvatars", false) + + if (animateAvatars) { + glide.asDrawable() + .load(avatarUrl) + .transform( RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp)) - ) - .apply { - if (showPlaceholder) { - placeholder(R.drawable.avatar_default) - } - } - .into(object : CustomTarget(navIconSize, navIconSize) { - - override fun onLoadStarted(placeholder: Drawable?) { - if (placeholder != null) { - binding.mainToolbar.navigationIcon = FixedSizeDrawable(placeholder, navIconSize, navIconSize) + ) + .apply { + if (showPlaceholder) { + placeholder(R.drawable.avatar_default) } } + .into(object : CustomTarget(navIconSize, navIconSize) { - override fun onResourceReady(resource: Drawable, transition: Transition?) { - binding.mainToolbar.navigationIcon = FixedSizeDrawable(resource, navIconSize, navIconSize) - } + override fun onLoadStarted(placeholder: Drawable?) { + if (placeholder != null) { + binding.mainToolbar.navigationIcon = FixedSizeDrawable(placeholder, navIconSize, navIconSize) + } + } - override fun onLoadCleared(placeholder: Drawable?) { - if (placeholder != null) { - binding.mainToolbar.navigationIcon = FixedSizeDrawable(placeholder, navIconSize, navIconSize) + override fun onResourceReady(resource: Drawable, transition: Transition?) { + if (resource is Animatable) { + resource.start() + } + binding.mainToolbar.navigationIcon = FixedSizeDrawable(resource, navIconSize, navIconSize) + } + + override fun onLoadCleared(placeholder: Drawable?) { + if (placeholder != null) { + binding.mainToolbar.navigationIcon = FixedSizeDrawable(placeholder, navIconSize, navIconSize) + } + } + }) + } else { + glide.asBitmap() + .load(avatarUrl) + .transform( + RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp)) + ) + .apply { + if (showPlaceholder) { + placeholder(R.drawable.avatar_default) } } - }) + .into(object : CustomTarget(navIconSize, navIconSize) { + + override fun onLoadStarted(placeholder: Drawable?) { + if (placeholder != null) { + binding.mainToolbar.navigationIcon = FixedSizeDrawable(placeholder, navIconSize, navIconSize) + } + } + + override fun onResourceReady(resource: Bitmap, transition: Transition?) { + binding.mainToolbar.navigationIcon = FixedSizeDrawable(BitmapDrawable(resources, resource), navIconSize, navIconSize) + } + + override fun onLoadCleared(placeholder: Drawable?) { + if (placeholder != null) { + binding.mainToolbar.navigationIcon = FixedSizeDrawable(placeholder, navIconSize, navIconSize) + } + } + }) + } } 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() { @@ -755,30 +826,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 @@ -792,20 +840,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 64c22917..044349b9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ModalTimelineActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/ModalTimelineActivity.kt @@ -4,8 +4,9 @@ import android.content.Context import android.content.Intent import android.os.Bundle import com.google.android.material.floatingactionbutton.FloatingActionButton +import com.keylesspalace.tusky.components.timeline.TimelineFragment +import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel import com.keylesspalace.tusky.databinding.ActivityModalTimelineBinding -import com.keylesspalace.tusky.fragment.TimelineFragment import com.keylesspalace.tusky.interfaces.ActionButtonActivity import dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector @@ -29,12 +30,12 @@ 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() } } @@ -47,13 +48,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 63a32b17..00000000 --- a/app/src/main/java/com/keylesspalace/tusky/SavedTootActivity.java +++ /dev/null @@ -1,209 +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(), - /*replyVisibility*/null, - item.getVisibility(), - item.getContentWarning(), - item.getInReplyToUsername(), - item.getInReplyToText(), - /*mediaAttachments*/null, - /*draftAttachments*/null, - /*scheduledAt*/null, - /*sensitive*/null, - /*poll*/null, - /* modifiedInitialState */ true - ); - 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 07c54e97..1b7e2994 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 javax.inject.Inject class SplashActivity : AppCompatActivity(), Injectable { @@ -46,5 +45,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 b2691ee9..ebe6c63e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt @@ -19,15 +19,12 @@ import android.content.Context import android.content.Intent import android.os.Bundle import androidx.fragment.app.commit +import com.keylesspalace.tusky.components.timeline.TimelineFragment +import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel.Kind import com.keylesspalace.tusky.databinding.ActivityStatuslistBinding - -import com.keylesspalace.tusky.fragment.TimelineFragment -import com.keylesspalace.tusky.fragment.TimelineFragment.Kind - -import javax.inject.Inject - import dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector +import javax.inject.Inject class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { @@ -44,7 +41,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 @@ -60,7 +57,6 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { val fragment = TimelineFragment.newInstance(kind) replace(R.id.fragment_container, fragment) } - } override fun androidInjector() = dispatchingAndroidInjector @@ -71,15 +67,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 4bf123b8..de75b7c7 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.viewmodel.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 */ @@ -33,71 +34,72 @@ const val DIRECT = "Direct" const val HASHTAG = "Hashtag" const val LIST = "List" -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)} - ) +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) } +) fun createTabDataFromId(id: String, arguments: List = emptyList()): TabData { return when (id) { HOME -> TabData( - HOME, - R.string.title_home, - R.drawable.ic_home_24dp, - { TimelineFragment.newInstance(TimelineFragment.Kind.HOME) } + HOME, + R.string.title_home, + R.drawable.ic_home_24dp, + { TimelineFragment.newInstance(TimelineViewModel.Kind.HOME) } ) 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) } + LOCAL, + R.string.title_public_local, + R.drawable.ic_local_24dp, + { TimelineFragment.newInstance(TimelineViewModel.Kind.PUBLIC_LOCAL) } ) FEDERATED -> TabData( - FEDERATED, - R.string.title_public_federated, - R.drawable.ic_public_24dp, - { TimelineFragment.newInstance(TimelineFragment.Kind.PUBLIC_FEDERATED) } + FEDERATED, + R.string.title_public_federated, + R.drawable.ic_public_24dp, + { TimelineFragment.newInstance(TimelineViewModel.Kind.PUBLIC_FEDERATED) } ) 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()) }, - arguments, - { arguments.getOrNull(1).orEmpty() } - ) + LIST, + R.string.list, + R.drawable.ic_list, + { args -> TimelineFragment.newInstance(TimelineViewModel.Kind.LIST, args.getOrNull(0).orEmpty()) }, + arguments, + { arguments.getOrNull(1).orEmpty() } + ) else -> throw IllegalArgumentException("unknown tab type") } } fun defaultTabs(): List { return listOf( - createTabDataFromId(HOME), - createTabDataFromId(NOTIFICATIONS), - createTabDataFromId(LOCAL), - createTabDataFromId(FEDERATED) + createTabDataFromId(HOME), + createTabDataFromId(NOTIFICATIONS), + createTabDataFromId(LOCAL), + createTabDataFromId(FEDERATED) ) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt b/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt index 67cd9cb6..720664bd 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 = 5 } - } diff --git a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt index 562f644e..0339a7bc 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 86205b29..2598b5e2 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 0ff6ff56..0071924b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ViewTagActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/ViewTagActivity.java @@ -25,7 +25,7 @@ import androidx.appcompat.widget.Toolbar; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentTransaction; -import com.keylesspalace.tusky.fragment.TimelineFragment; +import com.keylesspalace.tusky.components.timeline.TimelineFragment; import java.util.Collections; 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 24430dce..00000000 --- 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 00000000..320f8126 --- /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/AccountFieldEditAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldEditAdapter.kt index f7f4553a..7ba5537b 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 57cc9035..00000000 --- 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 00000000..33a23605 --- /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 2640caac..dc9ec70d 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 98cb9e4d..00000000 --- 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 00000000..672f1fca --- /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 a7e92743..2be8b762 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 ef14618e..00000000 --- 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 00000000..9b0a5dd9 --- /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 60ab4008..2480086e 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 ebff5c5f..6d5ddbd8 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 f63af6ca..00000000 --- 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 00000000..9fca33e8 --- /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 b45ca95f..cf755990 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 9f212ccc..5613d139 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. * @@ -43,6 +43,7 @@ import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding; import com.keylesspalace.tusky.entity.Account; import com.keylesspalace.tusky.entity.Emoji; import com.keylesspalace.tusky.entity.Notification; +import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.interfaces.AccountActionListener; import com.keylesspalace.tusky.interfaces.LinkListener; import com.keylesspalace.tusky.interfaces.StatusActionListener; @@ -195,14 +196,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().getDisplayName(), 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()); } } @@ -215,7 +217,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()); } } } @@ -256,6 +258,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter { statusDisplayOptions.useBlurhash(), CardViewMode.NONE, statusDisplayOptions.confirmReblogs(), + statusDisplayOptions.confirmFavourites(), statusDisplayOptions.hideStats(), statusDisplayOptions.animateEmojis() ); @@ -386,7 +389,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter { private StatusViewData.Concrete statusViewData; private SimpleDateFormat shortSdf; private SimpleDateFormat longSdf; - + private int avatarRadius48dp; private int avatarRadius36dp; private int avatarRadius24dp; @@ -415,7 +418,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter { statusContent.setOnClickListener(this); shortSdf = new SimpleDateFormat("HH:mm:ss", Locale.getDefault()); longSdf = new SimpleDateFormat("MM/dd HH:mm:ss", Locale.getDefault()); - + this.avatarRadius48dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_48dp); this.avatarRadius36dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_36dp); this.avatarRadius24dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_24dp); @@ -531,7 +534,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()) { @@ -586,7 +589,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter { notificationAvatar.setVisibility(View.VISIBLE); ImageLoadingHelper.loadAvatar(notificationAvatarUrl, notificationAvatar, - avatarRadius24dp, statusDisplayOptions.animateAvatars()); + avatarRadius24dp, statusDisplayOptions.animateAvatars()); } @Override @@ -607,7 +610,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 { @@ -615,7 +618,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 -> { @@ -641,14 +644,19 @@ 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); } 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 f8f1a0b5..00000000 --- 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 00000000..e80e3746 --- /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 1f57cc4e..1a60d860 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/PollAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/PollAdapter.kt @@ -18,8 +18,10 @@ package com.keylesspalace.tusky.adapter import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.core.content.ContextCompat import androidx.emoji.text.EmojiCompat import androidx.recyclerview.widget.RecyclerView +import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ItemPollBinding import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.util.BindingHolder @@ -29,7 +31,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 +42,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 +60,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,16 +84,22 @@ 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) + val emojifiedPollOptionText = buildDescription(option.title, percent, option.voted, resultTextView.context) + .emojify(emojis, resultTextView, animateEmojis) + resultTextView.text = EmojiCompat.get().process(emojifiedPollOptionText) val level = percent * 100 + val optionColor = if (option.voted) { + R.color.colorBackgroundHighlight + } else { + R.color.colorBackgroundAccent + } resultTextView.background.level = level + resultTextView.background.setTint(ContextCompat.getColor(resultTextView.context, optionColor)) resultTextView.setOnClickListener(resultClickListener) } SINGLE -> { @@ -114,7 +122,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 4206f7cf..6b59672d 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 af9c31d5..00000000 --- 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 d6cee626..f45667eb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java @@ -201,7 +201,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, @@ -243,7 +243,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, @@ -651,11 +651,19 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } favouriteButton.setEventListener((button, buttonState) -> { + // return true to play animaion int position = getBindingAdapterPosition(); if (position != RecyclerView.NO_POSITION) { - listener.onFavourite(!buttonState, position); + if (statusDisplayOptions.confirmFavourites()) { + showConfirmFavouriteDialog(listener, statusContent, buttonState, position); + return false; + } else { + listener.onFavourite(!buttonState, position); + return true; + } + } else { + return true; } - return true; }); bookmarkButton.setEventListener((button, buttonState) -> { @@ -703,26 +711,45 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { .show(); } + private void showConfirmFavouriteDialog(StatusActionListener listener, + String statusContent, + boolean buttonState, + int position) { + int okButtonTextId = buttonState ? R.string.action_unfavourite : R.string.action_favourite; + new AlertDialog.Builder(favouriteButton.getContext()) + .setMessage(statusContent) + .setPositiveButton(okButtonTextId, (__, ___) -> { + listener.onFavourite(!buttonState, position); + if (!buttonState) { + // Play animation only when it's favourite, not unfavourite + favouriteButton.playAnimation(); + } + }) + .show(); + } + public void setupWithStatus(StatusViewData.Concrete status, final StatusActionListener listener, StatusDisplayOptions statusDisplayOptions) { 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); - setIsReply(status.getInReplyToId() != null); - setAvatar(status.getAvatar(), status.getRebloggedAvatar(), status.isBot(), statusDisplayOptions); - setReblogged(status.isReblogged()); - setFavourited(status.isFavourited()); - setBookmarked(status.isBookmarked()); - List attachments = status.getAttachments(); - boolean sensitive = status.isSensitive(); + Status actionable = status.getActionable(); + setDisplayName(actionable.getAccount().getDisplayName(), actionable.getAccount().getEmojis(), statusDisplayOptions); + setUsername(status.getUsername()); + setCreatedAt(actionable.getCreatedAt(), statusDisplayOptions); + setIsReply(actionable.getInReplyToId() != null); + setAvatar(actionable.getAccount().getAvatar(), status.getRebloggedAvatar(), + actionable.getAccount().getBot(), statusDisplayOptions); + setReblogged(actionable.getReblogged()); + setFavourited(actionable.getFavourited()); + setBookmarked(actionable.getBookmarked()); + List attachments = actionable.getAttachments(); + boolean sensitive = actionable.getSensitive(); if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) { setMediaPreviews(attachments, sensitive, listener, status.isShowingContent(), statusDisplayOptions.useBlurhash()); @@ -744,14 +771,17 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } if (cardView != null) { - setupCard(status, statusDisplayOptions.cardViewMode(), statusDisplayOptions); + setupCard(status, statusDisplayOptions.cardViewMode(), statusDisplayOptions, listener); } - setupButtons(listener, status.getSenderId(), status.getContent().toString(), + setupButtons(listener, actionable.getAccount().getId(), status.getContent().toString(), statusDisplayOptions); - setRebloggingEnabled(status.getRebloggingEnabled(), status.getVisibility()); + setRebloggingEnabled(actionable.rebloggingAllowed(), 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); @@ -765,7 +795,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); } } @@ -784,21 +814,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().getDisplayName(), 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); @@ -806,10 +837,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 ""; } @@ -817,11 +848,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) { @@ -874,7 +905,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 { @@ -883,7 +914,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { for (int i = 0; i < args.length; i++) { if (i < options.size()) { int percent = PollViewDataKt.calculatePercent(options.get(i).getVotesCount(), poll.getVotersCount(), poll.getVotesCount()); - args[i] = buildDescription(options.get(i).getTitle(), percent, context); + args[i] = buildDescription(options.get(i).getTitle(), percent, options.get(i).getVoted(), context); } else { args[i] = ""; } @@ -980,7 +1011,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 { @@ -1003,13 +1034,18 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { return pollDescription.getContext().getString(R.string.poll_info_format, votesText, pollDurationInfo); } - protected void setupCard(StatusViewData.Concrete status, CardViewMode cardViewMode, StatusDisplayOptions statusDisplayOptions) { + protected void setupCard( + StatusViewData.Concrete status, + CardViewMode cardViewMode, + StatusDisplayOptions statusDisplayOptions, + final StatusActionListener listener + ) { + 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())) { @@ -1028,7 +1064,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; @@ -1094,7 +1130,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { cardImage.setImageResource(R.drawable.card_image_placeholder); } - View.OnClickListener visitLink = v -> LinkHelper.openLink(card.getUrl(), v.getContext()); + View.OnClickListener visitLink = v -> listener.onViewUrl(card.getUrl()); View.OnClickListener openImage = v -> cardView.getContext().startActivity(ViewMediaActivity.newSingleImageIntent(cardView.getContext(), card.getEmbed_url())); cardInfo.setOnClickListener(visitLink); 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 abb8ca85..56adfcad 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java @@ -101,21 +101,22 @@ 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) { super.setupWithStatus(status, listener, statusDisplayOptions, payloads); - setupCard(status, CardViewMode.FULL_WIDTH, statusDisplayOptions); // Always show card for detailed status + setupCard(status, CardViewMode.FULL_WIDTH, statusDisplayOptions, listener); // Always show card for detailed status 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; @@ -130,7 +131,7 @@ class StatusDetailedViewHolder extends StatusBaseViewHolder { content.setOnLongClickListener(longClickListener); contentWarningDescription.setOnLongClickListener(longClickListener); - setStatusVisibility(status.getVisibility()); + setStatusVisibility(status.getActionable().getVisibility()); } } 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 68d64a69..68415989 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().getDisplayName(); + 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 bec07f06..994630a1 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 0143cb43..00000000 --- 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 00000000..8abbbd5f --- /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/adapter/TimelineAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/TimelineAdapter.java deleted file mode 100644 index 4be922d6..00000000 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/TimelineAdapter.java +++ /dev/null @@ -1,135 +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.List; - -public final class TimelineAdapter extends RecyclerView.Adapter { - - public interface AdapterDataSource { - int getItemCount(); - - T getItemAt(int pos); - } - - private static final int VIEW_TYPE_STATUS = 0; - private static final int VIEW_TYPE_PLACEHOLDER = 2; - - private final AdapterDataSource dataSource; - private StatusDisplayOptions statusDisplayOptions; - private final StatusActionListener statusListener; - - public TimelineAdapter(AdapterDataSource dataSource, - StatusDisplayOptions statusDisplayOptions, - StatusActionListener statusListener) { - this.dataSource = dataSource; - this.statusDisplayOptions = statusDisplayOptions; - this.statusListener = statusListener; - } - - public boolean getMediaPreviewEnabled() { - return statusDisplayOptions.mediaPreviewEnabled(); - } - - public void setMediaPreviewEnabled(boolean mediaPreviewEnabled) { - this.statusDisplayOptions = statusDisplayOptions.copy( - statusDisplayOptions.animateAvatars(), - mediaPreviewEnabled, - statusDisplayOptions.useAbsoluteTime(), - statusDisplayOptions.showBotOverlay(), - statusDisplayOptions.useBlurhash(), - statusDisplayOptions.cardViewMode(), - statusDisplayOptions.confirmReblogs(), - statusDisplayOptions.hideStats(), - statusDisplayOptions.animateEmojis() - ); - } - - @NonNull - @Override - public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int viewType) { - switch (viewType) { - default: - case VIEW_TYPE_STATUS: { - View view = LayoutInflater.from(viewGroup.getContext()) - .inflate(R.layout.item_status, viewGroup, false); - return new StatusViewHolder(view); - } - case VIEW_TYPE_PLACEHOLDER: { - View view = LayoutInflater.from(viewGroup.getContext()) - .inflate(R.layout.item_status_placeholder, viewGroup, false); - return new PlaceholderViewHolder(view); - } - } - } - - @Override - public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) { - bindViewHolder(viewHolder, position, null); - } - - - @Override - public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position, @NonNull List payloads) { - bindViewHolder(viewHolder, position, payloads); - } - - private void bindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position, @Nullable List payloads) { - StatusViewData status = dataSource.getItemAt(position); - if (status instanceof StatusViewData.Placeholder) { - PlaceholderViewHolder holder = (PlaceholderViewHolder) viewHolder; - holder.setup(statusListener, ((StatusViewData.Placeholder) status).isLoading()); - } else if (status instanceof StatusViewData.Concrete) { - StatusViewHolder holder = (StatusViewHolder) viewHolder; - holder.setupWithStatus((StatusViewData.Concrete) status, - statusListener, - statusDisplayOptions, - payloads != null && !payloads.isEmpty() ? payloads.get(0) : null); - } - } - - @Override - public int getItemCount() { - return dataSource.getItemCount(); - } - - @Override - public int getItemViewType(int position) { - if (dataSource.getItemAt(position) instanceof StatusViewData.Placeholder) { - return VIEW_TYPE_PLACEHOLDER; - } else { - return VIEW_TYPE_STATUS; - } - } - - @Override - public long getItemId(int position) { - return dataSource.getItemAt(position).getViewDataId(); - } -} 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 6404de5c..50f96b26 100644 --- a/app/src/main/java/com/keylesspalace/tusky/appstore/CacheUpdater.kt +++ b/app/src/main/java/com/keylesspalace/tusky/appstore/CacheUpdater.kt @@ -3,22 +3,23 @@ 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, + private val accountManager: AccountManager, + private val appDatabase: AppDatabase, + gson: Gson ) { private val disposable: Disposable init { val timelineDao = appDatabase.timelineDao() + disposable = eventHub.events.subscribe { event -> val accountId = accountManager.activeAccount?.id ?: return@subscribe when (event) { @@ -27,7 +28,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 -> @@ -36,6 +37,8 @@ class CacheUpdater @Inject constructor( val pollString = gson.toJson(event.poll) timelineDao.setVoted(accountId, event.statusId, pollString) } + is PinEvent -> + timelineDao.setPinned(accountId, event.statusId, event.pinned) } } } @@ -49,7 +52,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 288de430..aef4525c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt +++ b/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt @@ -19,5 +19,6 @@ 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 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 ceaf5133..7fb1f05b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/appstore/EventsHub.kt +++ b/app/src/main/java/com/keylesspalace/tusky/appstore/EventsHub.kt @@ -1,22 +1,20 @@ 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 +import javax.inject.Inject +import javax.inject.Singleton interface Event interface Dispatchable : Event -interface EventHub { - val events: Observable - fun dispatch(event: Dispatchable) -} - -object EventHubImpl : EventHub { +@Singleton +class EventHub @Inject constructor() { private val eventsSubject = PublishSubject.create() - override val events: Observable = eventsSubject + val events: Observable = eventsSubject - override fun dispatch(event: Dispatchable) { + fun dispatch(event: Dispatchable) { eventsSubject.onNext(event) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/AccountActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt similarity index 86% rename from app/src/main/java/com/keylesspalace/tusky/AccountActivity.kt rename to app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt index 8f2f7188..ffcad8cb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/AccountActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt @@ -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 +package com.keylesspalace.tusky.components.account import android.animation.ArgbEvaluator import android.content.Context @@ -34,20 +34,29 @@ import androidx.annotation.Px import androidx.appcompat.app.AlertDialog import androidx.core.app.ActivityOptionsCompat import androidx.core.content.ContextCompat +import androidx.core.view.ViewCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsCompat.Type.systemBars +import androidx.core.view.updatePadding import androidx.emoji.text.EmojiCompat import androidx.preference.PreferenceManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.viewpager2.widget.MarginPageTransformer import com.bumptech.glide.Glide import com.google.android.material.appbar.AppBarLayout -import com.google.android.material.appbar.CollapsingToolbarLayout import com.google.android.material.floatingactionbutton.FloatingActionButton import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.shape.ShapeAppearanceModel import com.google.android.material.snackbar.Snackbar import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayoutMediator -import com.keylesspalace.tusky.adapter.AccountFieldAdapter +import com.keylesspalace.tusky.AccountListActivity +import com.keylesspalace.tusky.BottomSheetActivity +import com.keylesspalace.tusky.EditProfileActivity +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.ViewMediaActivity +import com.keylesspalace.tusky.ViewTagActivity import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.components.report.ReportActivity import com.keylesspalace.tusky.databinding.ActivityAccountBinding @@ -57,11 +66,20 @@ import com.keylesspalace.tusky.entity.Relationship import com.keylesspalace.tusky.interfaces.ActionButtonActivity import com.keylesspalace.tusky.interfaces.LinkListener import com.keylesspalace.tusky.interfaces.ReselectableFragment -import com.keylesspalace.tusky.pager.AccountPagerAdapter import com.keylesspalace.tusky.settings.PrefKeys -import com.keylesspalace.tusky.util.* +import com.keylesspalace.tusky.util.DefaultTextWatcher +import com.keylesspalace.tusky.util.Error +import com.keylesspalace.tusky.util.LinkHelper +import com.keylesspalace.tusky.util.Loading +import com.keylesspalace.tusky.util.Success +import com.keylesspalace.tusky.util.ThemeUtils +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.util.visible import com.keylesspalace.tusky.view.showMuteAccountDialog -import com.keylesspalace.tusky.viewmodel.AccountViewModel import dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector import java.text.NumberFormat @@ -79,7 +97,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI private val binding: ActivityAccountBinding by viewBinding(ActivityAccountBinding::inflate) - private lateinit var accountFieldAdapter : AccountFieldAdapter + private lateinit var accountFieldAdapter: AccountFieldAdapter private var followState: FollowState = FollowState.NOT_FOLLOWING private var blocking: Boolean = false @@ -129,6 +147,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI animateEmojis = sharedPrefs.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) hideFab = sharedPrefs.getBoolean("fabHide", false) + handleWindowInsets() setupToolbar() setupTabs() setupAccountViews() @@ -170,7 +189,6 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI binding.accountFieldList.layoutManager = LinearLayoutManager(this) binding.accountFieldList.adapter = accountFieldAdapter - val accountListClickListener = { v: View -> val type = when (v.id) { R.id.accountFollowers -> AccountListActivity.Type.FOLLOWERS @@ -231,21 +249,25 @@ 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 + private fun handleWindowInsets() { + ViewCompat.setOnApplyWindowInsetsListener(binding.accountCoordinatorLayout) { _, insets -> + val top = insets.getInsets(systemBars()).top + val toolbarParams = binding.accountToolbar.layoutParams as ViewGroup.MarginLayoutParams toolbarParams.topMargin = top - insets.consumeSystemWindowInsets() - } + val right = insets.getInsets(systemBars()).right + val bottom = insets.getInsets(systemBars()).bottom + val left = insets.getInsets(systemBars()).left + binding.accountCoordinatorLayout.updatePadding(right = right, bottom = bottom, left = left) + WindowInsetsCompat.CONSUMED + } + } + + private fun setupToolbar() { // Setup the toolbar. setSupportActionBar(binding.accountToolbar) supportActionBar?.run { @@ -266,8 +288,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 @@ -314,12 +336,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 } @@ -332,9 +352,10 @@ 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() } + is Loading -> { } } } viewModel.relationshipData.observe(this) { @@ -345,15 +366,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) } @@ -367,9 +390,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.chinwag_green) } @@ -383,7 +409,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI val emojifiedNote = account.note.emojify(account.emojis, binding.accountNoteTextView, animateEmojis) LinkHelper.setClickableText(binding.accountNoteTextView, emojifiedNote, null, this) - // accountFieldAdapter.fields = account.fields ?: emptyList() + // accountFieldAdapter.fields = account.fields ?: emptyList() accountFieldAdapter.emojis = account.emojis ?: emptyList() accountFieldAdapter.notifyDataSetChanged() @@ -410,21 +436,21 @@ 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) + val intent = + ViewMediaActivity.newSingleImageIntent(avatarView.context, account.avatar) avatarView.transitionName = account.avatar val options = ActivityOptionsCompat.makeSceneTransitionAnimation(this, avatarView, account.avatar) @@ -479,7 +505,6 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI binding.accountMovedText.setCompoundDrawablesRelativeWithIntrinsicBounds(movedIcon, null, null, null) } - } /** @@ -555,15 +580,16 @@ 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() } - if(relation.notifying != null) + if (relation.notifying != null) subscribing = relation.notifying - else if(relation.subscribing != null) + else if (relation.subscribing != null) subscribing = relation.subscribing } @@ -578,7 +604,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI updateButtons() } - private val noteWatcher = object: DefaultTextWatcher() { + private val noteWatcher = object : DefaultTextWatcher() { override fun afterTextChanged(s: Editable) { viewModel.noteChanged(s.toString()) } @@ -616,11 +642,11 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI } private fun updateSubscribeButton() { - if(followState != FollowState.FOLLOWING) { + if (followState != FollowState.FOLLOWING) { binding.accountSubscribeButton.hide() } - if(subscribing) { + if (subscribing) { binding.accountSubscribeButton.setIconResource(R.drawable.ic_notifications_active_24dp) binding.accountSubscribeButton.contentDescription = getString(R.string.action_unsubscribe_account) } else { @@ -649,7 +675,6 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI binding.accountMuteButton.hide() updateMuteButton() } - } else { binding.accountFloatingActionButton.hide() binding.accountFollowButton.hide() @@ -699,11 +724,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) @@ -718,39 +741,39 @@ 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) { - if(blockingDomain) { + if (blockingDomain) { 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() } @@ -760,8 +783,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) } @@ -773,8 +796,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) } } @@ -850,5 +875,4 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI return intent } } - } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountFieldAdapter.kt similarity index 84% rename from app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldAdapter.kt rename to app/src/main/java/com/keylesspalace/tusky/components/account/AccountFieldAdapter.kt index fe3b15f8..4f4e204c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountFieldAdapter.kt @@ -13,23 +13,26 @@ * 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.account import android.text.method.LinkMovementMethod -import androidx.recyclerview.widget.RecyclerView import android.view.LayoutInflater import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ItemAccountFieldBinding 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.* +import com.keylesspalace.tusky.util.BindingHolder +import com.keylesspalace.tusky.util.Either +import com.keylesspalace.tusky.util.LinkHelper +import com.keylesspalace.tusky.util.emojify class AccountFieldAdapter( - private val linkListener: LinkListener, - private val animateEmojis: Boolean + private val linkListener: LinkListener, + private val animateEmojis: Boolean ) : RecyclerView.Adapter>() { var emojis: List = emptyList() @@ -47,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 @@ -55,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) @@ -64,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/pager/AccountPagerAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountPagerAdapter.kt similarity index 68% rename from app/src/main/java/com/keylesspalace/tusky/pager/AccountPagerAdapter.kt rename to app/src/main/java/com/keylesspalace/tusky/components/account/AccountPagerAdapter.kt index f8c026e0..760db829 100644 --- a/app/src/main/java/com/keylesspalace/tusky/pager/AccountPagerAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountPagerAdapter.kt @@ -13,28 +13,28 @@ * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ -package com.keylesspalace.tusky.pager +package com.keylesspalace.tusky.components.account -import androidx.fragment.app.* - -import com.keylesspalace.tusky.fragment.AccountMediaFragment -import com.keylesspalace.tusky.fragment.TimelineFragment +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import com.keylesspalace.tusky.components.account.media.AccountMediaFragment +import com.keylesspalace.tusky.components.timeline.TimelineFragment +import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel 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/viewmodel/AccountViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountViewModel.kt similarity index 74% rename from app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountViewModel.kt rename to app/src/main/java/com/keylesspalace/tusky/components/account/AccountViewModel.kt index 1837652e..6fa988ac 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountViewModel.kt @@ -1,17 +1,28 @@ -package com.keylesspalace.tusky.viewmodel +package com.keylesspalace.tusky.components.account 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() } } @@ -126,11 +146,12 @@ class AccountViewModel @Inject constructor( 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/fragment/AccountMediaFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaFragment.kt similarity index 89% rename from app/src/main/java/com/keylesspalace/tusky/fragment/AccountMediaFragment.kt rename to app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaFragment.kt index 54c455b4..0d2dcc77 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/AccountMediaFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaFragment.kt @@ -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.fragment +package com.keylesspalace.tusky.components.account.media import android.graphics.Color import android.os.Bundle @@ -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/components/announcements/AnnouncementAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementAdapter.kt index 5014b52e..27284faf 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 @@ -15,33 +15,37 @@ package com.keylesspalace.tusky.components.announcements +import android.os.Build +import android.text.SpannableStringBuilder import android.view.ContextThemeWrapper import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.view.size import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide import com.google.android.material.chip.Chip import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ItemAnnouncementBinding import com.keylesspalace.tusky.entity.Announcement -import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.interfaces.LinkListener import com.keylesspalace.tusky.util.BindingHolder +import com.keylesspalace.tusky.util.EmojiSpan import com.keylesspalace.tusky.util.LinkHelper import com.keylesspalace.tusky.util.emojify +import java.lang.ref.WeakReference -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 { @@ -56,7 +60,9 @@ class AnnouncementAdapter( val chips = holder.binding.chipGroup val addReactionChip = holder.binding.addReactionChip - LinkHelper.setClickableText(text, item.content, null, listener) + val emojifiedText: CharSequence = item.content.emojify(item.emojis, text, animateEmojis) + + LinkHelper.setClickableText(text, emojifiedText, item.mentions, listener) // If wellbeing mode is enabled, announcement badge counts should not be shown. if (wellbeingEnabled) { @@ -67,40 +73,43 @@ class AnnouncementAdapter( } item.reactions.forEachIndexed { i, reaction -> - (chips.getChildAt(i)?.takeUnless { it.id == R.id.addReactionChip } as Chip? + ( + 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 - } else { - context.getString(R.string.emoji_shortcode_format, reaction.name) + } + ) + .apply { + if (reaction.url == null) { + this.text = "${reaction.name} ${reaction.count}" + } else { + // we set the EmojiSpan on a space, because otherwise the Chip won't have the right size + // https://github.com/tuskyapp/Tusky/issues/2308 + val spanBuilder = SpannableStringBuilder(" ${reaction.count}") + val span = EmojiSpan(WeakReference(this)) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + span.contentDescription = reaction.name } - this.text = ("$emojiText ${reaction.count}") - .emojify( - listOf(Emoji( - reaction.name, - reaction.url ?: "", - reaction.staticUrl ?: "", - null - )), - this, - animateEmojis - ) + spanBuilder.setSpan(span, 0, 1, 0) + Glide.with(this) + .asDrawable() + .load(if (animateEmojis) { reaction.url } else { reaction.staticUrl }) + .into(span.getTarget(animateEmojis)) + this.text = spanBuilder + } - isChecked = reaction.me + isChecked = reaction.me - setOnClickListener { - if (reaction.me) { - listener.removeReaction(item.id, reaction.name) - } else { - listener.addReaction(item.id, reaction.name) - } + setOnClickListener { + if (reaction.me) { + listener.removeReaction(item.id, reaction.name) + } else { + listener.addReaction(item.id, reaction.name) } } + } } while (chips.size - 1 > item.reactions.size) { 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 ada2769c..3a9956af 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 964cc739..fef8f932 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 81f6c565..dbb45d30 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 @@ -28,13 +27,16 @@ 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 @@ -71,7 +73,20 @@ import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.NewPoll import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.settings.PrefKeys -import com.keylesspalace.tusky.util.* +import com.keylesspalace.tusky.util.ComposeTokenizer +import com.keylesspalace.tusky.util.PickMediaFiles +import com.keylesspalace.tusky.util.ThemeUtils +import com.keylesspalace.tusky.util.afterTextChanged +import com.keylesspalace.tusky.util.combineLiveData +import com.keylesspalace.tusky.util.combineOptionalLiveData +import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.highlightSpans +import com.keylesspalace.tusky.util.loadAvatar +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.util.withLifecycleContext import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.utils.colorInt @@ -84,13 +99,14 @@ 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 @@ -114,6 +130,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) @@ -130,16 +161,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 @@ -255,11 +286,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()) @@ -274,8 +305,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) } } @@ -316,9 +348,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() @@ -379,7 +411,6 @@ class ComposeActivity : BaseActivity(), setDisplayShowHomeEnabled(true) setHomeAsUpIndicator(R.drawable.ic_close_24dp) } - } private fun setupAvatar(preferences: SharedPreferences, activeAccount: AccountEntity) { @@ -390,13 +421,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) { @@ -454,7 +487,6 @@ class ComposeActivity : BaseActivity(), } } - private fun atButtonClicked() { prependSelectedWordsWith("@") } @@ -470,7 +502,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() } @@ -488,7 +520,6 @@ class ComposeActivity : BaseActivity(), binding.composeHideMediaButton.setImageResource(R.drawable.ic_hide_media_24dp) binding.composeHideMediaButton.isClickable = false ContextCompat.getColor(this, R.color.transparent_chinwag_green) - } else { binding.composeHideMediaButton.isClickable = true if (markMediaSensitive) { @@ -597,15 +628,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) } } } @@ -619,8 +652,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() { @@ -740,35 +775,40 @@ class ComposeActivity : BaseActivity(), } else if (characterCount <= maximumTootCharacters) { 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() } } } @@ -776,50 +816,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) } @@ -828,31 +856,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 -> @@ -876,7 +879,6 @@ class ComposeActivity : BaseActivity(), } displayTransientError(errorId) } - } } } @@ -908,9 +910,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 @@ -945,12 +948,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() } @@ -982,13 +985,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; @@ -1011,32 +1014,29 @@ class ComposeActivity : BaseActivity(), @Parcelize 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, - var mediaDescriptions: List? = null, - var mentionedUsernames: Set? = null, - var inReplyToId: String? = null, - var replyVisibility: Status.Visibility? = null, - var visibility: Status.Visibility? = null, - var contentWarning: String? = null, - var replyingStatusAuthor: String? = null, - var replyingStatusContent: String? = null, - var mediaAttachments: List? = null, - var draftAttachments: List? = null, - var scheduledAt: String? = null, - var sensitive: Boolean? = null, - var poll: NewPoll? = null, - var modifiedInitialState: Boolean? = null + // Let's keep fields var until all consumers are Kotlin + var scheduledTootId: String? = null, + var draftId: Int? = null, + var tootText: String? = null, + var mediaUrls: List? = null, + var mediaDescriptions: List? = null, + var mentionedUsernames: Set? = null, + var inReplyToId: String? = null, + var replyVisibility: Status.Visibility? = null, + var visibility: Status.Visibility? = null, + var contentWarning: String? = null, + var replyingStatusAuthor: String? = null, + var replyingStatusContent: String? = null, + var mediaAttachments: List? = null, + var draftAttachments: List? = null, + var scheduledAt: String? = null, + var sensitive: Boolean? = null, + var poll: NewPoll? = null, + var modifiedInitialState: Boolean? = null ) : Parcelable 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 71293511..7391ab1c 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 = "" @@ -66,15 +76,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) @@ -91,30 +101,36 @@ class ComposeViewModel @Inject constructor( init { - Singles.zip(api.getCustomEmojis(), api.getInstance()) { emojis, instance -> - InstanceEntity( + Single.zip( + api.getCustomEmojis(), api.getInstance(), + { emojis, instance -> + InstanceEntity( instance = accountManager.activeAccount?.domain!!, emojiList = emojis, maximumTootCharacters = instance.maxTootChars, maxPollOptions = instance.pollLimits?.maxOptions, maxPollOptionLength = instance.pollLimits?.maxOptionChars, version = instance.version - ) - } - .doOnSuccess { - db.instanceDao().insertOrReplace(it) - } - .onErrorResumeNext( - db.instanceDao().loadMetadataForInstance(accountManager.activeAccount?.domain!!) ) - .subscribe({ instanceEntity -> + } + ) + .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() + } + ) + .autoDispose() } fun pickMedia(uri: Uri, description: String? = null): LiveData> { @@ -122,44 +138,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) @@ -169,16 +190,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 } @@ -198,12 +223,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 @@ -216,25 +243,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, @@ -246,7 +271,8 @@ class ComposeViewModel @Inject constructor( mediaDescriptions = mediaDescriptions, poll = poll.value, failedToSend = false - ).subscribe() + ) + } } /** @@ -255,51 +281,50 @@ 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 ?: "") - } - - val tootToSend = TootToSend( - text = content, - warningText = spoilerText, - visibility = statusVisibility.value!!.serverString(), - sensitive = mediaUris.isNotEmpty() && (markMediaAsSensitive.value!! || showContentWarning.value!!), - mediaIds = mediaIds, - mediaUris = mediaUris.map { it.toString() }, - mediaDescriptions = mediaDescriptions, - scheduledAt = scheduledAt.value, - inReplyToId = inReplyToId, - poll = poll.value, - replyingStatusContent = null, - replyingStatusAuthorUsername = null, - accountId = accountManager.activeAccount!!.id, - savedTootUid = savedTootUid, - draftId = draftId, - idempotencyKey = randomAlphanumericString(16), - retries = 0 - ) - - serviceClient.sendToot(tootToSend) + .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, + warningText = spoilerText, + visibility = statusVisibility.value!!.serverString(), + sensitive = mediaUris.isNotEmpty() && (markMediaAsSensitive.value!! || showContentWarning.value!!), + mediaIds = mediaIds, + mediaUris = mediaUris.map { it.toString() }, + mediaDescriptions = mediaDescriptions, + scheduledAt = scheduledAt.value, + inReplyToId = inReplyToId, + poll = poll.value, + replyingStatusContent = null, + replyingStatusAuthorUsername = null, + accountId = accountManager.activeAccount!!.id, + draftId = draftId, + idempotencyKey = randomAlphanumericString(16), + retries = 0 + ) + + serviceClient.sendToot(tootToSend) + } + return combineLiveData(deletionObservable, sendObservable) { _, _ -> } } @@ -318,12 +343,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) } } @@ -336,8 +364,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() @@ -346,9 +374,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() @@ -357,11 +385,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) { @@ -391,7 +419,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 @@ -406,20 +435,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 -> @@ -432,7 +449,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 @@ -483,20 +499,19 @@ class ComposeViewModel @Inject constructor( private companion object { const val TAG = "ComposeViewModel" } - } fun mutableLiveData(default: T) = MutableLiveData().apply { value = default } const val DEFAULT_CHARACTER_LIMIT = 500 private const val DEFAULT_MAX_OPTION_COUNT = 4 -private const val DEFAULT_MAX_OPTION_LENGTH = 25 +private const val DEFAULT_MAX_OPTION_LENGTH = 50 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 a08aebc0..0b1fa8c4 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 8ff7dcf3..44d1e8ed 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,20 @@ 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 +import javax.inject.Inject sealed class UploadEvent { data class ProgressEvent(val percentage: Int) : UploadEvent() @@ -50,40 +54,35 @@ 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 */ ) } data class PreparedMedia(val type: QueuedMedia.Type, val uri: Uri, val size: Long) -interface MediaUploader { - fun prepareMedia(inUri: Uri): Single - fun uploadMedia(media: QueuedMedia): Observable -} - class AudioSizeException : Exception() class VideoSizeException : Exception() class MediaTypeException : Exception() class CouldNotOpenFileException : Exception() -class MediaUploaderImpl( - private val context: Context, - private val mastodonApi: MastodonApi -) : MediaUploader { - override fun uploadMedia(media: QueuedMedia): Observable { +class MediaUploader @Inject constructor( + private val context: Context, + private val mastodonApi: MastodonApi +) { + 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 { + fun prepareMedia(inUri: Uri): Single { return Single.fromCallable { var mediaSize = getMediaSize(contentResolver, inUri) var uri = inUri @@ -101,12 +100,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 +151,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,12 +182,15 @@ class MediaUploaderImpl( } val uploadDisposable = mastodonApi.uploadMedia(body, description) - .subscribe({ attachment -> + .subscribe( + { attachment -> emitter.onNext(UploadEvent.FinishedEvent(attachment)) emitter.onComplete() - }, { e -> + }, + { e -> emitter.onError(e) - }) + } + ) // Cancel the request when our observable is cancelled emitter.setDisposable(uploadDisposable) @@ -194,15 +199,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 { @@ -211,6 +217,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 6ace77bc..7a4f7389 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 6a0b6a87..3640ffa9 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 adc72cd3..0c15eff0 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 @@ -41,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) @@ -54,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 @@ -64,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)) @@ -79,41 +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. Glide.with(this) - .load(previewUri) - .downsample(DownsampleStrategy.CENTER_INSIDE) - .into(object : CustomTarget(4096, 4096) { - override fun onLoadCleared(placeholder: Drawable?) { - imageView.setImageDrawable(placeholder) - } + .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 8f80c76d..02ec9a9b 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 @@ -57,12 +57,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 0a5e1c33..a8403c95 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 1126047d..c55e8fce 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 f7ba7ee6..24f4130b 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 -> { @@ -68,8 +68,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 376d3cd5..89c1ad0f 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 e35d460d..0a469822 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,71 +131,85 @@ 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) + id = id, + url = url, + account = account.toAccount(), + inReplyToId = inReplyToId, + inReplyToAccountId = inReplyToAccountId, + content = content, + reblog = null, + createdAt = createdAt, + emojis = emojis, + reblogsCount = 0, + favouritesCount = favouritesCount, + reblogged = false, + favourited = favourited, + bookmarked = bookmarked, + sensitive = sensitive, + spoilerText = spoilerText, + visibility = Status.Visibility.DIRECT, + attachments = attachments, + mentions = mentions, + application = null, + pinned = false, + muted = muted, + poll = poll, + card = null + ) } } fun Account.toEntity() = - ConversationAccountEntity( - id, - username, - name, - avatar, - emojis ?: emptyList() - ) + ConversationAccountEntity( + id = id, + username = username, + displayName = name, + avatar = avatar, + emojis = emojis ?: emptyList() + ) fun Status.toEntity() = - ConversationStatusEntity( - id, url, inReplyToId, inReplyToAccountId, account.toEntity(), content, - createdAt, emojis, favouritesCount, favourited, bookmarked, sensitive, - spoilerText, attachments, mentions, - false, - false, - shouldTrimStatus(content), - true, - poll - ) - + ConversationStatusEntity( + id = id, + url = url, + inReplyToId = inReplyToId, + inReplyToAccountId = inReplyToAccountId, + account = account.toEntity(), + content = content, + createdAt = createdAt, + emojis = emojis, + favouritesCount = favouritesCount, + favourited = favourited, + bookmarked = bookmarked, + sensitive = sensitive, + spoilerText = spoilerText, + attachments = attachments, + mentions = mentions, + showingHiddenContent = false, + expanded = false, + collapsible = shouldTrimStatus(content), + collapsed = true, + muted = muted ?: false, + poll = poll + ) fun Conversation.toEntity(accountId: Long) = - ConversationEntity( - accountId, - id, - accounts.map { it.toEntity() }, - unread, - lastStatus!!.toEntity() - ) + ConversationEntity( + accountId = accountId, + id = id, + accounts = accounts.map { it.toEntity() }, + unread = unread, + lastStatus = lastStatus!!.toEntity() + ) 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 00000000..c7224c4d --- /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 5d359015..00000000 --- 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 b24ff232..1e233d38 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,14 +20,19 @@ 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 import androidx.recyclerview.widget.SimpleItemAnimator -import com.keylesspalace.tusky.AccountActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.ViewTagActivity +import com.keylesspalace.tusky.components.account.AccountActivity import com.keylesspalace.tusky.databinding.FragmentTimelineBinding import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.ViewModelFactory @@ -36,12 +41,18 @@ 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 +@OptIn(ExperimentalPagingApi::class) class ConversationsFragment : SFragment(), StatusActionListener, Injectable, ReselectableFragment { @Inject @@ -52,9 +63,12 @@ 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) } @@ -63,23 +77,25 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res val preferences = PreferenceManager.getDefaultSharedPreferences(view.context) val statusDisplayOptions = StatusDisplayOptions( - animateAvatars = preferences.getBoolean("animateGifAvatars", false), - mediaPreviewEnabled = accountManager.activeAccount?.mediaPreviewEnabled ?: true, - useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false), - showBotOverlay = preferences.getBoolean("showBotOverlay", true), - useBlurhash = preferences.getBoolean("useBlurhash", true), - cardViewMode = CardViewMode.NONE, - confirmReblogs = preferences.getBoolean("confirmReblogs", true), - hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), - animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) + animateAvatars = preferences.getBoolean("animateGifAvatars", false), + mediaPreviewEnabled = accountManager.activeAccount?.mediaPreviewEnabled ?: true, + useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false), + showBotOverlay = preferences.getBoolean("showBotOverlay", true), + useBlurhash = preferences.getBoolean("useBlurhash", true), + cardViewMode = CardViewMode.NONE, + confirmReblogs = preferences.getBoolean("confirmReblogs", true), + confirmFavourites = preferences.getBoolean("confirmFavourites", false), + hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), + animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) ) - adapter = ConversationAdapter(statusDisplayOptions, this, ::onTopLoaded, viewModel::retry) + adapter = ConversationAdapter(statusDisplayOptions, this) + loadStateAdapter = ConversationLoadStateAdapter(adapter::retry) binding.recyclerView.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL)) layoutManager = LinearLayoutManager(view.context) binding.recyclerView.layoutManager = layoutManager - binding.recyclerView.adapter = adapter + binding.recyclerView.adapter = adapter.withLoadStateFooter(loadStateAdapter) (binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false binding.progressBar.hide() @@ -87,58 +103,101 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res initSwipeToRefresh() - viewModel.conversations.observe(viewLifecycleOwner) { - adapter.submitList(it) - } - viewModel.networkState.observe(viewLifecycleOwner) { - adapter.setNetworkState(it) + lifecycleScope.launch { + viewModel.conversationFlow.collectLatest { pagingData -> + adapter.submitData(pagingData) + } } - viewModel.load() + adapter.addLoadStateListener { loadStates -> + loadStates.refresh.let { refreshState -> + if (refreshState is LoadState.Error) { + binding.statusView.show() + if (refreshState.error is IOException) { + binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) { + adapter.refresh() + } + } else { + binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic) { + adapter.refresh() + } + } + } else { + binding.statusView.hide() + } + + binding.progressBar.visible(refreshState == LoadState.Loading && adapter.itemCount == 0) + + if (refreshState is LoadState.NotLoading && !initialRefreshDone) { + // jump to top after the initial refresh finished + binding.recyclerView.scrollToPosition(0) + initialRefreshDone = true + } + + if (refreshState != LoadState.Loading) { + binding.swipeRefreshLayout.isRefreshing = false + } + } + } } private fun initSwipeToRefresh() { - viewModel.refreshState.observe(viewLifecycleOwner) { - binding.swipeRefreshLayout.isRefreshing = it == NetworkState.LOADING - } binding.swipeRefreshLayout.setOnRefreshListener { - viewModel.refresh() + adapter.refresh() } binding.swipeRefreshLayout.setColorSchemeResources(R.color.chinwag_green) } - private fun onTopLoaded() { - binding.recyclerView.scrollToPosition(0) - } - override fun onReblog(reblog: Boolean, position: Int) { // its impossible to reblog private messages } override fun onFavourite(favourite: Boolean, position: Int) { - viewModel.favourite(favourite, position) + adapter.item(position)?.let { conversation -> + viewModel.favourite(favourite, conversation) + } } override fun onBookmark(favourite: Boolean, position: Int) { - viewModel.bookmark(favourite, position) + adapter.item(position)?.let { conversation -> + viewModel.bookmark(favourite, conversation) + } } override fun onMore(view: View, position: Int) { - viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let { - more(it.toStatus(), view, position) + adapter.item(position)?.let { conversation -> + + val popup = PopupMenu(requireContext(), view) + popup.inflate(R.menu.conversation_more) + + if (conversation.lastStatus.muted) { + popup.menu.removeItem(R.id.status_mute_conversation) + } else { + popup.menu.removeItem(R.id.status_unmute_conversation) + } + + popup.setOnMenuItemClickListener { item -> + when (item.itemId) { + R.id.status_mute_conversation -> viewModel.muteConversation(conversation) + R.id.status_unmute_conversation -> viewModel.muteConversation(conversation) + R.id.conversation_delete -> deleteConversation(conversation) + } + true + } + popup.show() } } override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { - viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let { - viewMedia(attachmentIndex, 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) } } @@ -147,11 +206,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) { @@ -159,7 +222,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) { @@ -174,15 +239,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) @@ -195,7 +270,9 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res } 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 00000000..26984c8e --- /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 + +@OptIn(ExperimentalPagingApi::class) +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 3cb4745a..12c5eb0b 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 c6fa84b4..396f8e48 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>() + @OptIn(ExperimentalPagingApi::class) + 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 5038ac00..7511dc3c 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 69403fdb..acee683b 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 ddf8a838..ce004801 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 5ba3716e..18621fd3 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 f928b6d0..78853d1e 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 7253112a..667360c5 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 @@ -17,7 +17,7 @@ class InstanceListActivity: BaseActivity(), HasAndroidInjector { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val binding = ActivityAccountListBinding.inflate(layoutInflater) - setContentView(R.layout.activity_account_list) + setContentView(binding.root) setSupportActionBar(binding.includedToolbar.toolbar) supportActionBar?.apply { @@ -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 f475f394..509c9561 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 005432d8..ccfe52b3 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 97d59cc9..9b88ad96 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 394a6846..1af9b3d7 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 @@ -1,5 +1,6 @@ package com.keylesspalace.tusky.components.notifications +import android.content.Context import android.util.Log import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountManager @@ -10,9 +11,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 context: Context ) { fun fetchAndShow() { for (account in accountManager.getAllAccountsOrderedByActive()) { @@ -20,7 +21,7 @@ class NotificationFetcher @Inject constructor( try { val notifications = fetchNotifications(account) notifications.forEachIndexed { index, notification -> - notifier.show(notification, account, index == 0) + NotificationHelper.make(context, notification, account, index == 0) } accountManager.saveAccount(account) } catch (e: Exception) { @@ -39,9 +40,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 +64,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 +80,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 16499757..4b7d3dc9 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++) { @@ -660,9 +659,12 @@ public class NotificationHelper { StringBuilder builder = new StringBuilder(notification.getStatus().getContent()); builder.append('\n'); Poll poll = notification.getStatus().getPoll(); - for(PollOption option: poll.getOptions()) { + List options = poll.getOptions(); + for(int i = 0; i < options.size(); ++i) { + PollOption option = options.get(i); builder.append(buildDescription(option.getTitle(), PollViewDataKt.calculatePercent(option.getVotesCount(), poll.getVotersCount(), poll.getVotesCount()), + poll.getOwnVotes() != null && poll.getOwnVotes().contains(i), context)); builder.append('\n'); } 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 ae7d4d3f..42b9c869 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 deleted file mode 100644 index 35c33a9b..00000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/Notifier.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.keylesspalace.tusky.components.notifications - -import android.content.Context -import com.keylesspalace.tusky.db.AccountEntity -import com.keylesspalace.tusky.entity.Notification - -/** - * Shows notifications. - */ -interface Notifier { - fun show(notification: Notification, account: AccountEntity, isFirstInBatch: Boolean) -} - -class SystemNotifier( - 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 286b49b5..e6bf83fb 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 2d32723f..e793f17f 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 1e90abc8..4d8ba84f 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 f1a07615..ee79a624 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,10 +123,10 @@ class PreferencesActivity : BaseActivity(), SharedPreferences.OnSharedPreference restartActivitiesOnExit = true this.restartCurrentActivity() - } - "statusTextSize", "absoluteTimeView", "showBotOverlay", "animateGifAvatars", - "useBlurhash", "showCardsInTimelines", "confirmReblogs", "enableSwipeForTabs", "mainNavPosition", PrefKeys.HIDE_TOP_TOOLBAR -> { + "statusTextSize", "absoluteTimeView", "showBotOverlay", "animateGifAvatars", "useBlurhash", + "showCardsInTimelines", "confirmReblogs", "confirmFavourites", + "enableSwipeForTabs", "mainNavPosition", PrefKeys.HIDE_TOP_TOOLBAR -> { restartActivitiesOnExit = true } "language" -> { @@ -179,5 +180,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 2c794f70..3aaca40f 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 @@ -22,7 +22,14 @@ import com.keylesspalace.tusky.R 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 @@ -122,7 +129,6 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable { setTitle(R.string.pref_title_bot_overlay) isSingleLineTitle = false setIcon(R.drawable.ic_bot_24dp) - } switchPreference { @@ -151,14 +157,6 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable { key = PrefKeys.SHOW_NOTIFICATIONS_FILTER setTitle(R.string.pref_title_show_notifications_filter) isSingleLineTitle = false - setOnPreferenceClickListener { - activity?.let { activity -> - val intent = PreferencesActivity.newIntent(activity, PreferencesActivity.TAB_FILTER_PREFERENCES) - activity.startActivity(intent) - activity.overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left) - } - true - } } switchPreference { @@ -168,6 +166,13 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable { isSingleLineTitle = false } + switchPreference { + setDefaultValue(false) + key = PrefKeys.CONFIRM_FAVOURITES + setTitle(R.string.pref_title_confirm_favourites) + isSingleLineTitle = false + } + switchPreference { setDefaultValue(true) key = PrefKeys.ENABLE_SWIPE_FOR_TABS @@ -267,7 +272,6 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable { sizePx = iconSize colorInt = ThemeUtils.getColor(context, R.attr.iconColor) } - } override fun onResume() { @@ -282,7 +286,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 922d5a7a..322b0c1d 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 57c9214c..82526706 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 fdc73163..f8991282 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 643c46c1..fb0b15ca 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 957d5b32..fd150f91 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 506d99af..fa5acc2d 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 90579a92..41486506 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) @@ -109,18 +120,20 @@ 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) { + private fun setTextVisible( + expanded: Boolean, + content: Spanned, + mentions: List?, + emojis: List, + listener: LinkListener + ) { if (expanded) { val emojifiedText = content.emojify(emojis, binding.statusContent, statusDisplayOptions.animateEmojis) LinkHelper.setClickableText(binding.statusContent, emojifiedText, mentions, listener) @@ -152,7 +165,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() @@ -174,4 +187,4 @@ class StatusViewHolder( } private fun status() = getStatusForPosition(bindingAdapterPosition) -} \ No newline at end of file +} 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 b66ac4f3..76ed2ebe 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 10635dda..00000000 --- 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 4cf8ff1c..00000000 --- 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 00000000..c007239d --- /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 cea3080e..00000000 --- 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 794cb287..0f806577 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 b47b586a..56f812a0 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 25728a70..65f58902 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,15 +21,17 @@ 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 import androidx.recyclerview.widget.SimpleItemAnimator import com.google.android.material.snackbar.Snackbar -import com.keylesspalace.tusky.AccountActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.ViewMediaActivity import com.keylesspalace.tusky.ViewTagActivity +import com.keylesspalace.tusky.components.account.AccountActivity import com.keylesspalace.tusky.components.report.ReportViewModel import com.keylesspalace.tusky.components.report.Screen import com.keylesspalace.tusky.components.report.adapter.AdapterHandler @@ -43,10 +45,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 { @@ -70,13 +73,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) @@ -85,7 +86,6 @@ class ReportStatusesFragment : Fragment(R.layout.fragment_report_statuses), Inje Attachment.Type.UNKNOWN -> { } } - } } @@ -100,80 +100,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) + 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), + confirmFavourites = preferences.getBoolean("confirmFavourites", false), + hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), + animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) ) - 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 664ddc6a..2bcade2f 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 d0fbb08c..68cd715a 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 LoadState.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 414130dd..75b83e5d 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 6c9ba31b..00000000 --- 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 00000000..c4994cef --- /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 3584168e..14f012ba 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 7208b038..2326bf17 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 @@ -76,7 +76,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 5df65744..235f8ce0 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 57c78ef8..e87c0c41 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,26 +1,44 @@ +/* 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 com.keylesspalace.tusky.components.search.adapter.SearchRepository +import androidx.lifecycle.viewModelScope +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.cachedIn +import com.keylesspalace.tusky.components.search.adapter.SearchPagingSourceFactory import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountManager -import com.keylesspalace.tusky.entity.* +import com.keylesspalace.tusky.entity.DeletedStatus +import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.TimelineCases -import com.keylesspalace.tusky.util.* +import com.keylesspalace.tusky.util.RxAwareViewModel +import com.keylesspalace.tusky.util.toViewData import com.keylesspalace.tusky.viewdata.StatusViewData -import io.reactivex.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, - private val timelineCases: TimelineCases, - private val accountManager: AccountManager + mastodonApi: MastodonApi, + private val timelineCases: TimelineCases, + private val accountManager: AccountManager ) : RxAwareViewModel() { var currentQuery: String = "" @@ -35,158 +53,145 @@ class SearchViewModel @Inject constructor( val alwaysShowSensitiveMedia = activeAccount?.alwaysShowSensitiveMedia ?: false val alwaysOpenSpoiler = activeAccount?.alwaysOpenSpoiler ?: false - private val statusesRepository = SearchRepository>(mastodonApi) - private val accountsRepository = SearchRepository(mastodonApi) - private val hashtagsRepository = SearchRepository(mastodonApi) + private val loadedStatuses: 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 -> + val statusViewData = status.toViewData( + isShowingContent = alwaysShowSensitiveMedia || !status.actionableStatus.sensitive, + isExpanded = alwaysOpenSpoiler, + isCollapsed = true + ) - 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 } + Pair(status, statusViewData) + }.apply { + loadedStatuses.addAll(this) + } + } + private val accountsPagingSourceFactory = SearchPagingSourceFactory(mastodonApi, SearchType.Account) { + it.accounts + } + private val hashtagsPagingSourceFactory = SearchPagingSourceFactory(mastodonApi, SearchType.Hashtag) { + it.hashtags + } - 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 statusesFlow = Pager( + config = PagingConfig(pageSize = DEFAULT_LOAD_SIZE, initialLoadSize = DEFAULT_LOAD_SIZE), + pagingSourceFactory = statusesPagingSourceFactory + ).flow + .cachedIn(viewModelScope) + + val accountsFlow = Pager( + config = PagingConfig(pageSize = DEFAULT_LOAD_SIZE, initialLoadSize = DEFAULT_LOAD_SIZE), + pagingSourceFactory = accountsPagingSourceFactory + ).flow + .cachedIn(viewModelScope) + + val hashtagsFlow = Pager( + config = PagingConfig(pageSize = DEFAULT_LOAD_SIZE, initialLoadSize = DEFAULT_LOAD_SIZE), + pagingSourceFactory = hashtagsPagingSourceFactory + ).flow + .cachedIn(viewModelScope) - private val loadedStatuses = 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() - } - + statusesPagingSourceFactory.newSearch(query) + accountsPagingSourceFactory.newSearch(query) + hashtagsPagingSourceFactory.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 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 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 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 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 { @@ -198,7 +203,7 @@ class SearchViewModel @Inject constructor( } fun pinAccount(status: Status, isPin: Boolean) { - timelineCases.pin(status, isPin) + timelineCases.pin(status.id, isPin) } fun blockAccount(accountId: String) { @@ -209,24 +214,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 b6bc9568..71d58268 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 2b706288..00000000 --- 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 ebc02160..50bd0f93 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/SearchPagerAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchPagerAdapter.kt index 845abaf8..9f30f9c5 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 @@ -34,5 +34,4 @@ class SearchPagerAdapter(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/search/adapter/SearchPagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchPagingSource.kt new file mode 100644 index 00000000..5ced4403 --- /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 b47da701..f995d029 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 28d9564d..00000000 --- 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 0fcee37d..d1ad3586 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 8715e1ab..d5e2a7ab 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 a1ca40c7..8a4c47be 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,28 +4,36 @@ 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 import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import com.google.android.material.snackbar.Snackbar -import com.keylesspalace.tusky.AccountActivity import com.keylesspalace.tusky.BottomSheetActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.ViewTagActivity +import com.keylesspalace.tusky.components.account.AccountActivity import com.keylesspalace.tusky.components.search.SearchViewModel 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 15310d3c..d0b7e8fa 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/SearchStatusesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt index f2ea85c0..4b5f6717 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,39 +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) + 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), + confirmFavourites = preferences.getBoolean("confirmFavourites", false), + hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), + animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) ) binding.searchRecyclerView.addItemDecoration(DividerItemDecoration(binding.searchRecyclerView.context, DividerItemDecoration.VERTICAL)) @@ -96,47 +92,51 @@ 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 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) @@ -146,26 +146,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) } } @@ -175,25 +173,25 @@ class SearchStatusesFragment : SearchFragment) { - searchAdapter.getItem(position)?.let { + searchAdapter.item(position)?.let { viewModel.voteInPoll(it, choices) } } private fun removeItem(position: Int) { - searchAdapter.getItem(position)?.let { + searchAdapter.item(position)?.let { viewModel.removeItem(it) } } override fun onReblog(reblog: Boolean, position: Int) { - searchAdapter.getItem(position)?.let { status -> + searchAdapter.item(position)?.let { status -> viewModel.reblog(status, reblog) } } @@ -205,20 +203,23 @@ class SearchStatusesFragment : SearchFragment { - } //Ignore + } // Ignore } } else { popup.inflate(R.menu.status_more) @@ -278,11 +279,12 @@ class SearchStatusesFragment : SearchFragment @@ -294,8 +296,8 @@ class SearchStatusesFragment : SearchFragment { - searchAdapter.getItem(position)?.let { foundStatus -> + searchAdapter.item(position)?.let { foundStatus -> viewModel.muteConversation(foundStatus, status.muted != true) } return@setOnMenuItemClickListener true @@ -368,10 +370,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) { @@ -383,18 +385,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) { @@ -437,51 +442,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/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt new file mode 100644 index 00000000..9a932e4c --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -0,0 +1,532 @@ +/* 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.lifecycle.Lifecycle +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope +import androidx.paging.LoadState +import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +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.appstore.EventHub +import com.keylesspalace.tusky.appstore.PreferenceChangedEvent +import com.keylesspalace.tusky.appstore.StatusComposedEvent +import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineViewModel +import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel +import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel +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.entity.Status +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.viewdata.AttachmentViewData +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Observable +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import java.io.IOException +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 lazy { + if (kind == TimelineViewModel.Kind.HOME) { + ViewModelProvider(this, viewModelFactory).get(CachedTimelineViewModel::class.java) + } else { + ViewModelProvider(this, viewModelFactory).get(NetworkTimelineViewModel::class.java) + } + } + + private val binding by viewBinding(FragmentTimelineBinding::bind) + + private lateinit var kind: TimelineViewModel.Kind + + private lateinit var adapter: TimelinePagingAdapter + + private var isSwipeToRefreshEnabled = true + + private var eventRegistered = false + + private var layoutManager: LinearLayoutManager? = null + private var scrollListener: RecyclerView.OnScrollListener? = null + private var hideFab = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val arguments = requireArguments() + 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() + } + viewModel.init( + kind, + id, + tags, + ) + + 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), + confirmFavourites = preferences.getBoolean(PrefKeys.CONFIRM_FAVOURITES, false), + hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), + animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) + ) + adapter = TimelinePagingAdapter( + 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() + + adapter.addLoadStateListener { loadState -> + if (loadState.refresh != LoadState.Loading) { + binding.swipeRefreshLayout.isRefreshing = false + } + + binding.statusView.hide() + binding.progressBar.hide() + + if (adapter.itemCount == 0) { + when (loadState.refresh) { + is LoadState.NotLoading -> { + if (loadState.append is LoadState.NotLoading) { + binding.statusView.show() + binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null) + } + } + is LoadState.Error -> { + binding.statusView.show() + + if ((loadState.refresh as LoadState.Error).error is IOException) { + binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network, null) + } else { + binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic, null) + } + } + is LoadState.Loading -> { + binding.progressBar.show() + } + } + } + } + + adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { + override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { + if (positionStart == 0 && adapter.itemCount != itemCount) { + binding.recyclerView.post { + if (getView() != null) { + if (isSwipeToRefreshEnabled) { + binding.recyclerView.scrollBy(0, Utils.dpToPx(requireContext(), -30)) + } else binding.recyclerView.scrollToPosition(0) + } + } + } + } + }) + + lifecycleScope.launch { + viewModel.statuses.collectLatest { pagingData -> + adapter.submitData(pagingData) + } + } + } + + 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 -> + adapter.peek(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 + } + + 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. */ + if (actionButtonPresent()) { + val preferences = PreferenceManager.getDefaultSharedPreferences(context) + hideFab = preferences.getBoolean("fabHide", false) + scrollListener = object : RecyclerView.OnScrollListener() { + override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) { + 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() + } + } + } + }.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) + } + is StatusComposedEvent -> { + val status = event.status + handleStatusComposeEvent(status) + } + } + } + eventRegistered = true + } + } + + override fun onRefresh() { + binding.statusView.hide() + + adapter.refresh() + } + + override fun onReply(position: Int) { + val status = adapter.peek(position)?.asStatusOrNull() ?: return + super.reply(status.status) + } + + override fun onReblog(reblog: Boolean, position: Int) { + val status = adapter.peek(position)?.asStatusOrNull() ?: return + viewModel.reblog(reblog, status) + } + + override fun onFavourite(favourite: Boolean, position: Int) { + val status = adapter.peek(position)?.asStatusOrNull() ?: return + viewModel.favorite(favourite, status) + } + + override fun onBookmark(bookmark: Boolean, position: Int) { + val status = adapter.peek(position)?.asStatusOrNull() ?: return + viewModel.bookmark(bookmark, status) + } + + override fun onVoteInPoll(position: Int, choices: List) { + val status = adapter.peek(position)?.asStatusOrNull() ?: return + viewModel.voteInPoll(choices, status) + } + + override fun onMore(view: View, position: Int) { + val status = adapter.peek(position)?.asStatusOrNull() ?: return + super.more(status.status, view, position) + } + + override fun onOpenReblog(position: Int) { + val status = adapter.peek(position)?.asStatusOrNull() ?: return + super.openReblog(status.status) + } + + override fun onExpandedChange(expanded: Boolean, position: Int) { + val status = adapter.peek(position)?.asStatusOrNull() ?: return + viewModel.changeExpanded(expanded, status) + } + + override fun onContentHiddenChange(isShowing: Boolean, position: Int) { + val status = adapter.peek(position)?.asStatusOrNull() ?: return + viewModel.changeContentShowing(isShowing, status) + } + + override fun onShowReblogs(position: Int) { + val statusId = adapter.peek(position)?.asStatusOrNull()?.id ?: return + val intent = newIntent(requireContext(), AccountListActivity.Type.REBLOGGED, statusId) + (activity as BaseActivity).startActivityWithSlideInAnimation(intent) + } + + override fun onShowFavs(position: Int) { + val statusId = adapter.peek(position)?.asStatusOrNull()?.id ?: return + val intent = newIntent(requireContext(), AccountListActivity.Type.FAVOURITED, statusId) + (activity as BaseActivity).startActivityWithSlideInAnimation(intent) + } + + override fun onLoadMore(position: Int) { + val placeholder = adapter.peek(position)?.asPlaceholderOrNull() ?: return + viewModel.loadMore(placeholder.id) + } + + override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { + val status = adapter.peek(position)?.asStatusOrNull() ?: return + viewModel.changeContentCollapsed(isCollapsed, status) + } + + override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { + val status = adapter.peek(position)?.asStatusOrNull() ?: return + super.viewMedia( + attachmentIndex, + AttachmentViewData.list(status.actionable), + view + ) + } + + override fun onViewThread(position: Int) { + val status = adapter.peek(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 + adapter.notifyDataSetChanged() + } + } + } + } + + private fun handleStatusComposeEvent(status: Status) { + when (kind) { + TimelineViewModel.Kind.HOME, + TimelineViewModel.Kind.PUBLIC_FEDERATED, + TimelineViewModel.Kind.PUBLIC_LOCAL -> adapter.refresh() + TimelineViewModel.Kind.USER, + TimelineViewModel.Kind.USER_WITH_REPLIES -> if (status.account.id == viewModel.id) { + adapter.refresh() + } + TimelineViewModel.Kind.TAG, + TimelineViewModel.Kind.FAVOURITES, + TimelineViewModel.Kind.LIST, + TimelineViewModel.Kind.BOOKMARKS, + TimelineViewModel.Kind.USER_PINNED -> return + } + } + + public override fun removeItem(position: Int) { + val status = adapter.peek(position)?.asStatusOrNull() ?: return + viewModel.removeStatusWithId(status.id) + } + + private fun actionButtonPresent(): Boolean { + return viewModel.kind != TimelineViewModel.Kind.TAG && + viewModel.kind != TimelineViewModel.Kind.FAVOURITES && + viewModel.kind != TimelineViewModel.Kind.BOOKMARKS && + activity is ActionButtonActivity + } + + 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 { + adapter.notifyDataSetChanged() + } + } + } + + override fun onReselect() { + if (isAdded) { + layoutManager!!.scrollToPosition(0) + binding.recyclerView.stopScroll() + } + } + + 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" + + fun newInstance( + kind: TimelineViewModel.Kind, + hashtagOrId: String? = null, + enableSwipeToRefresh: Boolean = true + ): 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) + 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 + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt new file mode 100644 index 00000000..e8b23c61 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt @@ -0,0 +1,135 @@ +/* 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.view.LayoutInflater +import android.view.ViewGroup +import androidx.paging.PagingDataAdapter +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.adapter.PlaceholderViewHolder +import com.keylesspalace.tusky.adapter.StatusBaseViewHolder +import com.keylesspalace.tusky.adapter.StatusViewHolder +import com.keylesspalace.tusky.interfaces.StatusActionListener +import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.viewdata.StatusViewData + +class TimelinePagingAdapter( + private var statusDisplayOptions: StatusDisplayOptions, + private val statusListener: StatusActionListener +) : PagingDataAdapter(TimelineDifferCallback) { + + var mediaPreviewEnabled: Boolean + get() = statusDisplayOptions.mediaPreviewEnabled + set(mediaPreviewEnabled) { + statusDisplayOptions = statusDisplayOptions.copy( + mediaPreviewEnabled = mediaPreviewEnabled + ) + } + + override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return when (viewType) { + VIEW_TYPE_STATUS -> { + val view = LayoutInflater.from(viewGroup.context) + .inflate(R.layout.item_status, viewGroup, false) + StatusViewHolder(view) + } + VIEW_TYPE_PLACEHOLDER -> { + val view = LayoutInflater.from(viewGroup.context) + .inflate(R.layout.item_status_placeholder, viewGroup, false) + PlaceholderViewHolder(view) + } + else -> { + val view = LayoutInflater.from(viewGroup.context) + .inflate(R.layout.item_status, viewGroup, false) + StatusViewHolder(view) + } + } + } + + override fun onBindViewHolder(viewHolder: RecyclerView.ViewHolder, position: Int) { + bindViewHolder(viewHolder, position, null) + } + + override fun onBindViewHolder( + viewHolder: RecyclerView.ViewHolder, + position: Int, + payloads: List<*> + ) { + bindViewHolder(viewHolder, position, payloads) + } + + private fun bindViewHolder( + viewHolder: RecyclerView.ViewHolder, + position: Int, + payloads: List<*>? + ) { + val status = getItem(position) + if (status is StatusViewData.Placeholder) { + val holder = viewHolder as PlaceholderViewHolder + holder.setup(statusListener, status.isLoading) + } else if (status is StatusViewData.Concrete) { + val holder = viewHolder as StatusViewHolder + holder.setupWithStatus( + status, + statusListener, + statusDisplayOptions, + if (payloads != null && payloads.isNotEmpty()) payloads[0] else null + ) + } + } + + override fun getItemViewType(position: Int): Int { + return if (getItem(position) is StatusViewData.Placeholder) { + VIEW_TYPE_PLACEHOLDER + } else { + VIEW_TYPE_STATUS + } + } + + companion object { + private const val VIEW_TYPE_STATUS = 0 + private const val VIEW_TYPE_PLACEHOLDER = 2 + + val TimelineDifferCallback = 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/TimelineTypeMappers.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt new file mode 100644 index 00000000..1f3810f9 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt @@ -0,0 +1,256 @@ +/* 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.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.TimelineAccountEntity +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.util.shouldTrimStatus +import com.keylesspalace.tusky.util.trimTrailingWhitespace +import com.keylesspalace.tusky.viewdata.StatusViewData +import java.util.Date + +data class Placeholder( + val id: String, + val loading: Boolean +) + +private val attachmentArrayListType = object : TypeToken>() {}.type +private val emojisListType = object : TypeToken>() {}.type +private val mentionListType = object : TypeToken>() {}.type + +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(emojis, emojisListType), + 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 = "", + visibility = Status.Visibility.UNKNOWN, + attachments = null, + mentions = null, + application = null, + reblogServerId = null, + reblogAccountId = null, + poll = null, + muted = false, + expanded = loading, + contentCollapsed = false, + contentShowing = false, + pinned = false + ) +} + +fun Status.toEntity( + timelineUserId: Long, + gson: Gson, + expanded: Boolean, + contentShowing: Boolean, + contentCollapsed: Boolean +): TimelineStatusEntity { + return TimelineStatusEntity( + serverId = this.id, + url = actionableStatus.url, + timelineUserId = timelineUserId, + authorServerId = actionableStatus.account.id, + inReplyToId = actionableStatus.inReplyToId, + inReplyToAccountId = actionableStatus.inReplyToAccountId, + content = actionableStatus.content.toHtml(), + createdAt = actionableStatus.createdAt.time, + emojis = actionableStatus.emojis.let(gson::toJson), + reblogsCount = actionableStatus.reblogsCount, + favouritesCount = actionableStatus.favouritesCount, + reblogged = actionableStatus.reblogged, + favourited = actionableStatus.favourited, + bookmarked = actionableStatus.bookmarked, + sensitive = actionableStatus.sensitive, + spoilerText = actionableStatus.spoilerText, + visibility = actionableStatus.visibility, + attachments = actionableStatus.attachments.let(gson::toJson), + mentions = actionableStatus.mentions.let(gson::toJson), + application = actionableStatus.application.let(gson::toJson), + reblogServerId = reblog?.id, + reblogAccountId = reblog?.let { this.account.id }, + poll = actionableStatus.poll.let(gson::toJson), + muted = actionableStatus.muted, + expanded = expanded, + contentShowing = contentShowing, + contentCollapsed = contentCollapsed, + pinned = actionableStatus.pinned == true + ) +} + +fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData { + if (this.status.authorServerId == null) { + return StatusViewData.Placeholder(this.status.serverId, this.status.expanded) + } + + val attachments: ArrayList = gson.fromJson(status.attachments, attachmentArrayListType) ?: arrayListOf() + val mentions: List = gson.fromJson(status.mentions, mentionListType) ?: emptyList() + val application = gson.fromJson(status.application, Status.Application::class.java) + val emojis: List = gson.fromJson(status.emojis, emojisListType) ?: emptyList() + 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 + ) + } + 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 = status.pinned, + muted = status.muted, + poll = null, + card = 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 = status.pinned, + muted = status.muted, + poll = poll, + card = null + ) + } + return StatusViewData.Concrete( + status = status, + isExpanded = this.status.expanded, + isShowingContent = this.status.contentShowing, + isCollapsible = shouldTrimStatus(status.content), + isCollapsed = this.status.contentCollapsed + ) +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt new file mode 100644 index 00000000..e9d81e59 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt @@ -0,0 +1,162 @@ +/* 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.viewmodel + +import androidx.paging.ExperimentalPagingApi +import androidx.paging.LoadType +import androidx.paging.PagingState +import androidx.paging.RemoteMediator +import androidx.room.withTransaction +import com.google.gson.Gson +import com.keylesspalace.tusky.components.timeline.Placeholder +import com.keylesspalace.tusky.components.timeline.toEntity +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.db.TimelineStatusEntity +import com.keylesspalace.tusky.db.TimelineStatusWithAccount +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.dec +import kotlinx.coroutines.rx3.await +import retrofit2.HttpException + +@OptIn(ExperimentalPagingApi::class) +class CachedTimelineRemoteMediator( + accountManager: AccountManager, + private val api: MastodonApi, + private val db: AppDatabase, + private val gson: Gson +) : RemoteMediator() { + + private var initialRefresh = false + + private val timelineDao = db.timelineDao() + private val activeAccount = accountManager.activeAccount!! + + override suspend fun load( + loadType: LoadType, + state: PagingState + ): MediatorResult { + + try { + var dbEmpty = false + + val topPlaceholderId = if (loadType == LoadType.REFRESH) { + timelineDao.getTopPlaceholderId(activeAccount.id) + } else { + null // don't execute the query if it is not needed + } + + if (!initialRefresh && loadType == LoadType.REFRESH) { + val topId = timelineDao.getTopId(activeAccount.id) + topId?.let { cachedTopId -> + val statusResponse = api.homeTimeline( + maxId = cachedTopId, + sinceId = topPlaceholderId, // so already existing placeholders don't get accidentally overwritten + limit = state.config.pageSize + ).await() + + val statuses = statusResponse.body() + if (statusResponse.isSuccessful && statuses != null) { + db.withTransaction { + replaceStatusRange(statuses, state) + } + } + } + initialRefresh = true + dbEmpty = topId == null + } + + val statusResponse = when (loadType) { + LoadType.REFRESH -> { + api.homeTimeline(sinceId = topPlaceholderId, limit = state.config.pageSize).await() + } + LoadType.PREPEND -> { + return MediatorResult.Success(endOfPaginationReached = true) + } + LoadType.APPEND -> { + val maxId = state.pages.findLast { it.data.isNotEmpty() }?.data?.lastOrNull()?.status?.serverId + api.homeTimeline(maxId = maxId, limit = state.config.pageSize).await() + } + } + + val statuses = statusResponse.body() + if (!statusResponse.isSuccessful || statuses == null) { + return MediatorResult.Error(HttpException(statusResponse)) + } + + db.withTransaction { + val overlappedStatuses = replaceStatusRange(statuses, state) + + if (loadType == LoadType.REFRESH && overlappedStatuses == 0 && statuses.isNotEmpty() && !dbEmpty) { + timelineDao.insertStatus( + Placeholder(statuses.last().id.dec(), loading = false).toEntity(activeAccount.id) + ) + } + } + return MediatorResult.Success(endOfPaginationReached = statuses.isEmpty()) + } catch (e: Exception) { + return MediatorResult.Error(e) + } + } + + /** + * Deletes all statuses in a given range and inserts new statuses. + * This is necessary so statuses that have been deleted on the server are cleaned up. + * Should be run in a transaction as it executes multiple db updates + * @param statuses the new statuses + * @return the number of old statuses that have been cleared from the database + */ + private suspend fun replaceStatusRange(statuses: List, state: PagingState): Int { + val overlappedStatuses = if (statuses.isNotEmpty()) { + timelineDao.deleteRange(activeAccount.id, statuses.last().id, statuses.first().id) + } else { + 0 + } + + for (status in statuses) { + timelineDao.insertAccount(status.account.toEntity(activeAccount.id, gson)) + status.reblog?.account?.toEntity(activeAccount.id, gson)?.let { rebloggedAccount -> + timelineDao.insertAccount(rebloggedAccount) + } + + // check if we already have one of the newly loaded statuses cached locally + // in case we do, copy the local state (expanded, contentShowing, contentCollapsed) over so it doesn't get lost + var oldStatus: TimelineStatusEntity? = null + for (page in state.pages) { + oldStatus = page.data.find { s -> + s.status.serverId == status.id + }?.status + if (oldStatus != null) break + } + + val expanded = oldStatus?.expanded ?: activeAccount.alwaysOpenSpoiler + val contentShowing = oldStatus?.contentShowing ?: activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive + val contentCollapsed = oldStatus?.contentCollapsed ?: true + + timelineDao.insertStatus( + status.toEntity( + timelineUserId = activeAccount.id, + gson = gson, + expanded = expanded, + contentShowing = contentShowing, + contentCollapsed = contentCollapsed + ) + ) + } + return overlappedStatuses + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt new file mode 100644 index 00000000..1da37bf4 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt @@ -0,0 +1,227 @@ +/* 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.viewmodel + +import android.content.SharedPreferences +import android.util.Log +import androidx.lifecycle.viewModelScope +import androidx.paging.ExperimentalPagingApi +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.cachedIn +import androidx.paging.filter +import androidx.paging.map +import androidx.room.withTransaction +import com.google.gson.Gson +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.components.timeline.Placeholder +import com.keylesspalace.tusky.components.timeline.toEntity +import com.keylesspalace.tusky.components.timeline.toViewData +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.entity.Poll +import com.keylesspalace.tusky.network.FilterModel +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.network.TimelineCases +import com.keylesspalace.tusky.util.dec +import com.keylesspalace.tusky.util.inc +import com.keylesspalace.tusky.viewdata.StatusViewData +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.rx3.await +import retrofit2.HttpException +import javax.inject.Inject +import kotlin.time.DurationUnit +import kotlin.time.toDuration + +/** + * TimelineViewModel that caches all statuses in a local database + */ +class CachedTimelineViewModel @Inject constructor( + timelineCases: TimelineCases, + private val api: MastodonApi, + eventHub: EventHub, + accountManager: AccountManager, + sharedPreferences: SharedPreferences, + filterModel: FilterModel, + private val db: AppDatabase, + private val gson: Gson +) : TimelineViewModel(timelineCases, api, eventHub, accountManager, sharedPreferences, filterModel) { + + @OptIn(ExperimentalPagingApi::class) + override val statuses = Pager( + config = PagingConfig(pageSize = LOAD_AT_ONCE), + remoteMediator = CachedTimelineRemoteMediator(accountManager, api, db, gson), + pagingSourceFactory = { db.timelineDao().getStatusesForAccount(accountManager.activeAccount!!.id) } + ).flow + .map { pagingData -> + pagingData.map { timelineStatus -> + timelineStatus.toViewData(gson) + } + } + .map { pagingData -> + pagingData.filter { statusViewData -> + !shouldFilterStatus(statusViewData) + } + } + .cachedIn(viewModelScope) + + init { + viewModelScope.launch { + delay(5.toDuration(DurationUnit.SECONDS)) // delay so the db is not locked during initial ui refresh + accountManager.activeAccount?.id?.let { accountId -> + db.timelineDao().cleanup(accountId, MAX_STATUSES_IN_CACHE) + db.timelineDao().cleanupAccounts(accountId) + } + } + } + + override fun updatePoll(newPoll: Poll, status: StatusViewData.Concrete) { + // handled by CacheUpdater + } + + override fun changeExpanded(expanded: Boolean, status: StatusViewData.Concrete) { + viewModelScope.launch { + db.timelineDao().setExpanded(accountManager.activeAccount!!.id, status.id, expanded) + } + } + + override fun changeContentShowing(isShowing: Boolean, status: StatusViewData.Concrete) { + viewModelScope.launch { + db.timelineDao().setContentShowing(accountManager.activeAccount!!.id, status.id, isShowing) + } + } + + override fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete) { + viewModelScope.launch { + db.timelineDao().setContentCollapsed(accountManager.activeAccount!!.id, status.id, isCollapsed) + } + } + + override fun removeAllByAccountId(accountId: String) { + viewModelScope.launch { + db.timelineDao().removeAllByUser(accountManager.activeAccount!!.id, accountId) + } + } + + override fun removeAllByInstance(instance: String) { + viewModelScope.launch { + db.timelineDao().deleteAllFromInstance(accountManager.activeAccount!!.id, instance) + } + } + + override fun removeStatusWithId(id: String) { + // handled by CacheUpdater + } + + override fun loadMore(placeholderId: String) { + viewModelScope.launch { + try { + val timelineDao = db.timelineDao() + + val activeAccount = accountManager.activeAccount!! + + timelineDao.insertStatus(Placeholder(placeholderId, loading = true).toEntity(activeAccount.id)) + + val nextPlaceholderId = timelineDao.getNextPlaceholderIdAfter(activeAccount.id, placeholderId) + + val response = api.homeTimeline(maxId = placeholderId.inc(), sinceId = nextPlaceholderId, limit = 20).await() + + val statuses = response.body() + if (!response.isSuccessful || statuses == null) { + loadMoreFailed(placeholderId, HttpException(response)) + return@launch + } + + db.withTransaction { + + timelineDao.delete(activeAccount.id, placeholderId) + + val overlappedStatuses = if (statuses.isNotEmpty()) { + timelineDao.deleteRange(activeAccount.id, statuses.last().id, statuses.first().id) + } else { + 0 + } + + for (status in statuses) { + timelineDao.insertAccount(status.account.toEntity(activeAccount.id, gson)) + status.reblog?.account?.toEntity(activeAccount.id, gson)?.let { rebloggedAccount -> + timelineDao.insertAccount(rebloggedAccount) + } + timelineDao.insertStatus( + status.toEntity( + timelineUserId = activeAccount.id, + gson = gson, + expanded = activeAccount.alwaysOpenSpoiler, + contentShowing = activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive, + contentCollapsed = true + ) + ) + } + + if (overlappedStatuses == 0 && statuses.isNotEmpty()) { + timelineDao.insertStatus( + Placeholder(statuses.last().id.dec(), loading = false).toEntity(activeAccount.id) + ) + } + } + } catch (e: Exception) { + loadMoreFailed(placeholderId, e) + } + } + } + + private suspend fun loadMoreFailed(placeholderId: String, e: Exception) { + Log.w("CachedTimelineVM", "failed loading statuses", e) + val activeAccount = accountManager.activeAccount!! + db.timelineDao().insertStatus(Placeholder(placeholderId, loading = false).toEntity(activeAccount.id)) + } + + override fun handleReblogEvent(reblogEvent: ReblogEvent) { + // handled by CacheUpdater + } + + override fun handleFavEvent(favEvent: FavoriteEvent) { + // handled by CacheUpdater + } + + override fun handleBookmarkEvent(bookmarkEvent: BookmarkEvent) { + // handled by CacheUpdater + } + + override fun handlePinEvent(pinEvent: PinEvent) { + // handled by CacheUpdater + } + + override fun fullReload() { + viewModelScope.launch { + val activeAccount = accountManager.activeAccount!! + db.runInTransaction { + db.timelineDao().removeAllForAccount(activeAccount.id) + db.timelineDao().removeAllUsersForAccount(activeAccount.id) + } + } + } + + companion object { + private const val MAX_STATUSES_IN_CACHE = 1000 + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt new file mode 100644 index 00000000..56236ecf --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt @@ -0,0 +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.timeline.viewmodel + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.keylesspalace.tusky.viewdata.StatusViewData + +class NetworkTimelinePagingSource( + private val viewModel: NetworkTimelineViewModel +) : PagingSource() { + + override fun getRefreshKey(state: PagingState): String? = null + + override suspend fun load(params: LoadParams): LoadResult { + + return if (params is LoadParams.Refresh) { + val list = viewModel.statusData.toList() + LoadResult.Page(list, null, viewModel.nextKey) + } else { + LoadResult.Page(emptyList(), null, null) + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt new file mode 100644 index 00000000..114faa92 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt @@ -0,0 +1,113 @@ +/* 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.viewmodel + +import androidx.paging.ExperimentalPagingApi +import androidx.paging.LoadType +import androidx.paging.PagingState +import androidx.paging.RemoteMediator +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.util.HttpHeaderLink +import com.keylesspalace.tusky.util.dec +import com.keylesspalace.tusky.util.toViewData +import com.keylesspalace.tusky.viewdata.StatusViewData +import retrofit2.HttpException + +@OptIn(ExperimentalPagingApi::class) +class NetworkTimelineRemoteMediator( + private val accountManager: AccountManager, + private val viewModel: NetworkTimelineViewModel +) : RemoteMediator() { + + override suspend fun load( + loadType: LoadType, + state: PagingState + ): MediatorResult { + + try { + val statusResponse = when (loadType) { + LoadType.REFRESH -> { + viewModel.fetchStatusesForKind(null, null, limit = state.config.pageSize) + } + LoadType.PREPEND -> { + return MediatorResult.Success(endOfPaginationReached = true) + } + LoadType.APPEND -> { + val maxId = viewModel.nextKey + if (maxId != null) { + viewModel.fetchStatusesForKind(maxId, null, limit = state.config.pageSize) + } else { + return MediatorResult.Success(endOfPaginationReached = true) + } + } + } + + val statuses = statusResponse.body() + if (!statusResponse.isSuccessful || statuses == null) { + return MediatorResult.Error(HttpException(statusResponse)) + } + + val activeAccount = accountManager.activeAccount!! + + val data = statuses.map { status -> + + val oldStatus = viewModel.statusData.find { s -> + s.asStatusOrNull()?.id == status.id + }?.asStatusOrNull() + + val contentShowing = oldStatus?.isShowingContent ?: activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive + val expanded = oldStatus?.isExpanded ?: activeAccount.alwaysOpenSpoiler + val contentCollapsed = oldStatus?.isCollapsed ?: true + + status.toViewData( + isShowingContent = contentShowing, + isExpanded = expanded, + isCollapsed = contentCollapsed + ) + } + + if (loadType == LoadType.REFRESH && viewModel.statusData.isNotEmpty()) { + + val insertPlaceholder = if (statuses.isNotEmpty()) { + !viewModel.statusData.removeAll { statusViewData -> + statuses.any { status -> status.id == statusViewData.asStatusOrNull()?.id } + } + } else { + false + } + + viewModel.statusData.addAll(0, data) + + if (insertPlaceholder) { + viewModel.statusData.add(statuses.size, StatusViewData.Placeholder(statuses.last().id.dec(), false)) + } + } else { + val linkHeader = statusResponse.headers()["Link"] + val links = HttpHeaderLink.parse(linkHeader) + val nextId = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter("max_id") + + viewModel.nextKey = nextId + + viewModel.statusData.addAll(data) + } + + viewModel.currentSource?.invalidate() + return MediatorResult.Success(endOfPaginationReached = statuses.isEmpty()) + } catch (e: Exception) { + return MediatorResult.Error(e) + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt new file mode 100644 index 00000000..5815662d --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt @@ -0,0 +1,302 @@ +/* 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.viewmodel + +import android.content.SharedPreferences +import android.util.Log +import androidx.lifecycle.viewModelScope +import androidx.paging.ExperimentalPagingApi +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.cachedIn +import androidx.paging.filter +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.db.AccountManager +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.util.LinkHelper +import com.keylesspalace.tusky.util.inc +import com.keylesspalace.tusky.util.toViewData +import com.keylesspalace.tusky.viewdata.StatusViewData +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.rx3.await +import retrofit2.HttpException +import retrofit2.Response +import javax.inject.Inject + +/** + * TimelineViewModel that caches all statuses in an in-memory list + */ +class NetworkTimelineViewModel @Inject constructor( + timelineCases: TimelineCases, + private val api: MastodonApi, + eventHub: EventHub, + accountManager: AccountManager, + sharedPreferences: SharedPreferences, + filterModel: FilterModel +) : TimelineViewModel(timelineCases, api, eventHub, accountManager, sharedPreferences, filterModel) { + + var currentSource: NetworkTimelinePagingSource? = null + + val statusData: MutableList = mutableListOf() + + var nextKey: String? = null + + @OptIn(ExperimentalPagingApi::class) + override val statuses = Pager( + config = PagingConfig(pageSize = LOAD_AT_ONCE), + pagingSourceFactory = { + NetworkTimelinePagingSource( + viewModel = this + ).also { source -> + currentSource = source + } + }, + remoteMediator = NetworkTimelineRemoteMediator(accountManager, this) + ).flow + .map { pagingData -> + pagingData.filter { statusViewData -> + !shouldFilterStatus(statusViewData) + } + } + .cachedIn(viewModelScope) + + override fun updatePoll(newPoll: Poll, status: StatusViewData.Concrete) { + status.copy( + status = status.status.copy(poll = newPoll) + ).update() + } + + override fun changeExpanded(expanded: Boolean, status: StatusViewData.Concrete) { + status.copy( + isExpanded = expanded + ).update() + } + + override fun changeContentShowing(isShowing: Boolean, status: StatusViewData.Concrete) { + status.copy( + isShowingContent = isShowing + ).update() + } + + override fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete) { + status.copy( + isCollapsed = isCollapsed + ).update() + } + + override fun removeAllByAccountId(accountId: String) { + statusData.removeAll { vd -> + val status = vd.asStatusOrNull()?.status ?: return@removeAll false + status.account.id == accountId || status.actionableStatus.account.id == accountId + } + currentSource?.invalidate() + } + + override fun removeAllByInstance(instance: String) { + statusData.removeAll { vd -> + val status = vd.asStatusOrNull()?.status ?: return@removeAll false + LinkHelper.getDomain(status.account.url) == instance + } + currentSource?.invalidate() + } + + override fun removeStatusWithId(id: String) { + statusData.removeAll { vd -> + val status = vd.asStatusOrNull()?.status ?: return@removeAll false + status.id == id || status.reblog?.id == id + } + currentSource?.invalidate() + } + + override fun loadMore(placeholderId: String) { + viewModelScope.launch { + try { + val statusResponse = fetchStatusesForKind( + fromId = placeholderId.inc(), + uptoId = null, + limit = 20 + ) + + val statuses = statusResponse.body() + if (!statusResponse.isSuccessful || statuses == null) { + loadMoreFailed(placeholderId, HttpException(statusResponse)) + return@launch + } + + val activeAccount = accountManager.activeAccount!! + + val data = statuses.map { status -> + val oldStatus = statusData.find { s -> + s.asStatusOrNull()?.id == status.id + }?.asStatusOrNull() + + val contentShowing = oldStatus?.isShowingContent ?: activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive + val expanded = oldStatus?.isExpanded ?: activeAccount.alwaysOpenSpoiler + val contentCollapsed = oldStatus?.isCollapsed ?: true + + status.toViewData( + isShowingContent = contentShowing, + isExpanded = expanded, + isCollapsed = contentCollapsed + ) + } + + val index = + statusData.indexOfFirst { it is StatusViewData.Placeholder && it.id == placeholderId } + statusData.removeAt(index) + statusData.addAll(index, data) + + currentSource?.invalidate() + } catch (e: Exception) { + loadMoreFailed(placeholderId, e) + } + } + } + + private fun loadMoreFailed(placeholderId: String, e: Exception) { + Log.w("NetworkTimelineVM", "failed loading statuses", e) + + val index = + statusData.indexOfFirst { it is StatusViewData.Placeholder && it.id == placeholderId } + statusData[index] = StatusViewData.Placeholder(placeholderId, isLoading = false) + + currentSource?.invalidate() + } + + override fun handleReblogEvent(reblogEvent: ReblogEvent) { + updateStatusById(reblogEvent.statusId) { + it.copy(status = it.status.copy(reblogged = reblogEvent.reblog)) + } + } + + override fun handleFavEvent(favEvent: FavoriteEvent) { + updateActionableStatusById(favEvent.statusId) { + it.copy(favourited = favEvent.favourite) + } + } + + override fun handleBookmarkEvent(bookmarkEvent: BookmarkEvent) { + updateActionableStatusById(bookmarkEvent.statusId) { + it.copy(bookmarked = bookmarkEvent.bookmark) + } + } + + override fun handlePinEvent(pinEvent: PinEvent) { + updateActionableStatusById(pinEvent.statusId) { + it.copy(pinned = pinEvent.pinned) + } + } + + override fun fullReload() { + statusData.clear() + currentSource?.invalidate() + } + + suspend fun fetchStatusesForKind( + fromId: String?, + uptoId: String?, + limit: Int + ): Response> { + 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) + }.await() + } + + private fun StatusViewData.Concrete.update() { + val position = statusData.indexOfFirst { viewData -> viewData.asStatusOrNull()?.id == this.id } + statusData[position] = this + currentSource?.invalidate() + } + + private inline fun updateStatusById( + id: String, + updater: (StatusViewData.Concrete) -> StatusViewData.Concrete + ) { + val pos = statusData.indexOfFirst { it.asStatusOrNull()?.id == id } + if (pos == -1) return + updateViewDataAt(pos, updater) + } + + private inline fun updateActionableStatusById( + id: String, + updater: (Status) -> Status + ) { + val pos = statusData.indexOfFirst { it.asStatusOrNull()?.id == id } + if (pos == -1) return + updateViewDataAt(pos) { vd -> + if (vd.status.reblog != null) { + vd.copy(status = vd.status.copy(reblog = updater(vd.status.reblog))) + } else { + vd.copy(status = updater(vd.status)) + } + } + } + + private inline fun updateViewDataAt( + position: Int, + updater: (StatusViewData.Concrete) -> StatusViewData.Concrete + ) { + val status = statusData.getOrNull(position)?.asStatusOrNull() ?: return + statusData[position] = updater(status) + currentSource?.invalidate() + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt new file mode 100644 index 00000000..c7c95636 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt @@ -0,0 +1,315 @@ +/* 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.viewmodel + +import android.content.SharedPreferences +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import com.keylesspalace.tusky.appstore.BlockEvent +import com.keylesspalace.tusky.appstore.BookmarkEvent +import com.keylesspalace.tusky.appstore.DomainMuteEvent +import com.keylesspalace.tusky.appstore.Event +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.PreferenceChangedEvent +import com.keylesspalace.tusky.appstore.ReblogEvent +import com.keylesspalace.tusky.appstore.StatusDeletedEvent +import com.keylesspalace.tusky.appstore.UnfollowEvent +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.entity.Filter +import com.keylesspalace.tusky.entity.Poll +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.viewdata.StatusViewData +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.rx3.asFlow +import kotlinx.coroutines.rx3.await +import retrofit2.HttpException +import java.io.IOException + +abstract class TimelineViewModel( + private val timelineCases: TimelineCases, + private val api: MastodonApi, + private val eventHub: EventHub, + protected val accountManager: AccountManager, + private val sharedPreferences: SharedPreferences, + private val filterModel: FilterModel +) : ViewModel() { + + abstract val statuses: Flow> + + var kind: Kind = Kind.HOME + private set + var id: String? = null + private set + var tags: List = emptyList() + private set + + protected var alwaysShowSensitiveMedia = false + protected var alwaysOpenSpoilers = false + private var filterRemoveReplies = false + private var filterRemoveReblogs = false + + fun init( + kind: Kind, + id: String?, + tags: List + ) { + this.kind = kind + this.id = id + this.tags = tags + + 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() + } + + fun reblog(reblog: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.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, status: StatusViewData.Concrete): Job = viewModelScope.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, status: StatusViewData.Concrete): Job = viewModelScope.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(choices: List, status: StatusViewData.Concrete): Job = viewModelScope.launch { + val poll = status.status.actionableStatus.poll ?: run { + Log.w(TAG, "No poll on status ${status.id}") + return@launch + } + + val votedPoll = poll.votedCopy(choices) + updatePoll(votedPoll, status) + + 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) + } + } + } + + abstract fun updatePoll(newPoll: Poll, status: StatusViewData.Concrete) + + abstract fun changeExpanded(expanded: Boolean, status: StatusViewData.Concrete) + + abstract fun changeContentShowing(isShowing: Boolean, status: StatusViewData.Concrete) + + abstract fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete) + + abstract fun removeAllByAccountId(accountId: String) + + abstract fun removeAllByInstance(instance: String) + + abstract fun removeStatusWithId(id: String) + + abstract fun loadMore(placeholderId: String) + + abstract fun handleReblogEvent(reblogEvent: ReblogEvent) + + abstract fun handleFavEvent(favEvent: FavoriteEvent) + + abstract fun handleBookmarkEvent(bookmarkEvent: BookmarkEvent) + + abstract fun handlePinEvent(pinEvent: PinEvent) + + abstract fun fullReload() + + protected fun shouldFilterStatus(statusViewData: StatusViewData): Boolean { + val status = statusViewData.asStatusOrNull()?.status ?: return false + return status.inReplyToId != null && filterRemoveReplies || + status.reblog != null && filterRemoveReblogs || + filterModel.shouldFilterStatus(status.actionableStatus) + } + + 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 (oldRemoveReplies != filterRemoveReplies) { + fullReload() + } + } + PrefKeys.TAB_FILTER_HOME_BOOSTS -> { + val filter = sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_BOOSTS, true) + val oldRemoveReblogs = filterRemoveReblogs + filterRemoveReblogs = kind == Kind.HOME && !filter + if (oldRemoveReblogs != filterRemoveReblogs) { + fullReload() + } + } + 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 + } + } + } + + 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 fun handleEvent(event: Event) { + when (event) { + is FavoriteEvent -> handleFavEvent(event) + is ReblogEvent -> handleReblogEvent(event) + is BookmarkEvent -> handleBookmarkEvent(event) + is PinEvent -> handlePinEvent(event) + is MuteConversationEvent -> fullReload() + 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) { + removeStatusWithId(event.statusId) + } + } + is PreferenceChangedEvent -> { + onPreferenceChanged(event.preferenceKey) + } + } + } + + 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) + } + ) + } + } + + private fun isExpectedRequestException(t: Exception) = t is IOException || t is HttpException + + 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 e1c64e28..218c9b8f 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 ab6dbb7e..0c25cbbc 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 fc10adb6..3de34f55 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 d35fd389..50e13ca2 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 = 28) 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,95 @@ 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"); + } + }; + + public static final Migration MIGRATION_27_28 = new Migration(27, 28) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + + database.execSQL("DROP TABLE IF EXISTS `TimelineAccountEntity`"); + database.execSQL("DROP TABLE IF EXISTS `TimelineStatusEntity`"); + + database.execSQL("CREATE TABLE IF NOT EXISTS `TimelineAccountEntity` (" + + "`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`) )"); + + database.execSQL("CREATE TABLE IF NOT EXISTS `TimelineStatusEntity` (" + + "`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 NOT NULL," + + "`visibility` INTEGER NOT NULL," + + "`attachments` TEXT," + + "`mentions` TEXT," + + "`application` TEXT," + + "`reblogServerId` TEXT," + + "`reblogAccountId` TEXT," + + "`poll` TEXT," + + "`muted` INTEGER," + + "`expanded` INTEGER NOT NULL," + + "`contentCollapsed` INTEGER NOT NULL," + + "`contentShowing` INTEGER NOT NULL," + + "`pinned` INTEGER NOT NULL," + + "PRIMARY KEY(`serverId`, `timelineUserId`)," + + "FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`)" + + "ON UPDATE NO ACTION ON DELETE NO ACTION )"); + + database.execSQL("CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId`" + + "ON `TimelineStatusEntity` (`authorServerId`, `timelineUserId`)"); + } + }; } 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 00f32f53..393a2392 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 b81ad63e..a59133de 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt @@ -25,18 +25,23 @@ import com.google.gson.reflect.TypeToken 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 @@ -62,10 +67,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 @@ -105,13 +110,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 @@ -126,7 +131,7 @@ class Converters @Inject constructor ( @TypeConverter fun spannedToString(spanned: Spanned?): String? { - if(spanned == null) { + if (spanned == null) { return null } return spanned.toHtml() @@ -134,7 +139,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 065af1ae..8029dd23 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 184ff2c3..a1e19c75 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/DraftEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/DraftEntity.kt @@ -21,6 +21,7 @@ import androidx.core.net.toUri import androidx.room.Entity import androidx.room.PrimaryKey import androidx.room.TypeConverters +import com.google.gson.annotations.SerializedName import com.keylesspalace.tusky.entity.NewPoll import com.keylesspalace.tusky.entity.Status import kotlinx.parcelize.Parcelize @@ -28,24 +29,29 @@ 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 ) +/** + * The alternate names are here because we accidentally published versions were DraftAttachment was minified + * Tusky 15: uriString = e, description = f, type = g + * Tusky 16 beta: uriString = i, description = j, type = k + */ @Parcelize data class DraftAttachment( - val uriString: String, - val description: String?, - val type: Type -): Parcelable { + @SerializedName(value = "uriString", alternate = ["e", "i"]) val uriString: String, + @SerializedName(value = "description", alternate = ["f", "j"]) val description: String?, + @SerializedName(value = "type", alternate = ["g", "k"]) 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 0c78349e..52fc3aa8 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 1e2adaf0..ac4464f2 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 9c50ad03..6952ca86 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt @@ -1,32 +1,42 @@ +/* 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.db +import androidx.paging.PagingSource import androidx.room.Dao import androidx.room.Insert -import androidx.room.OnConflictStrategy.IGNORE import androidx.room.OnConflictStrategy.REPLACE import androidx.room.Query -import androidx.room.Transaction -import io.reactivex.Single @Dao abstract class TimelineDao { @Insert(onConflict = REPLACE) - abstract fun insertAccount(timelineAccountEntity: TimelineAccountEntity): Long + abstract suspend fun insertAccount(timelineAccountEntity: TimelineAccountEntity): Long @Insert(onConflict = REPLACE) - abstract fun insertStatus(timelineAccountEntity: TimelineStatusEntity): Long + abstract suspend fun insertStatus(timelineStatusEntity: 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, s.spoilerText, s.visibility, s.mentions, s.application, s.reblogServerId,s.reblogAccountId, -s.content, s.attachments, s.poll, s.muted, +s.content, s.attachments, s.poll, s.muted, s.expanded, s.contentShowing, s.contentCollapsed, s.pinned, a.serverId as 'a_serverId', a.timelineUserId as 'a_timelineUserId', a.localUsername as 'a_localUsername', a.username as 'a_username', a.displayName as 'a_displayName', a.url as 'a_url', a.avatar as 'a_avatar', @@ -34,59 +44,46 @@ a.emojis as 'a_emojis', a.bot as 'a_bot', rb.serverId as 'rb_serverId', rb.timelineUserId 'rb_timelineUserId', rb.localUsername as 'rb_localUsername', rb.username as 'rb_username', rb.displayName as 'rb_displayName', rb.url as 'rb_url', rb.avatar as 'rb_avatar', -rb.emojis as'rb_emojis', rb.bot as 'rb_bot' +rb.emojis as 'rb_emojis', rb.bot as 'rb_bot' FROM TimelineStatusEntity s LEFT JOIN TimelineAccountEntity a ON (s.timelineUserId = a.timelineUserId AND s.authorServerId = a.serverId) LEFT JOIN TimelineAccountEntity rb ON (s.timelineUserId = rb.timelineUserId AND s.reblogAccountId = rb.serverId) WHERE s.timelineUserId = :account -AND (CASE WHEN :maxId IS NOT NULL THEN -(LENGTH(s.serverId) < LENGTH(:maxId) OR LENGTH(s.serverId) == LENGTH(:maxId) AND s.serverId < :maxId) -ELSE 1 END) -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""") - abstract fun getStatusesForAccount(account: Long, maxId: String?, sinceId: String?, limit: Int): Single> +ORDER BY LENGTH(s.serverId) DESC, s.serverId DESC""" + ) + abstract fun getStatusesForAccount(account: Long): PagingSource - - @Transaction - 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 - (LENGTH(serverId) < LENGTH(:maxId) OR LENGTH(serverId) == LENGTH(:maxId) AND serverId < :maxId) + @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) +(LENGTH(serverId) > LENGTH(:minId) OR LENGTH(serverId) == LENGTH(:minId) AND serverId >= :minId) + """ + ) + abstract suspend fun deleteRange(accountId: Long, minId: String, maxId: String): Int - @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 +92,81 @@ 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) + /** + * Cleans the TimelineStatusEntity table from old status entries. + * @param accountId id of the account for which to clean statuses + * @param limit how many statuses to keep + */ + @Query( + """DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND serverId NOT IN + (SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT :limit) + """ + ) + abstract suspend fun cleanup(accountId: Long, limit: Int) - @Query("""UPDATE TimelineStatusEntity SET poll = :poll -WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""") + /** + * Cleans the TimelineAccountEntity table from accounts that are no longer referenced in the TimelineStatusEntity table + * @param accountId id of the user account for which to clean timeline accounts + */ + @Query( + """DELETE FROM TimelineAccountEntity WHERE timelineUserId = :accountId AND serverId NOT IN + (SELECT authorServerId FROM TimelineStatusEntity WHERE timelineUserId = :accountId) + AND serverId NOT IN + (SELECT reblogAccountId FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND reblogAccountId IS NOT NULL)""" + ) + abstract suspend fun cleanupAccounts(accountId: Long) + + @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 + + @Query( + """UPDATE TimelineStatusEntity SET expanded = :expanded +WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""" + ) + abstract fun setExpanded(accountId: Long, statusId: String, expanded: Boolean) + + @Query( + """UPDATE TimelineStatusEntity SET contentShowing = :contentShowing +WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""" + ) + abstract fun setContentShowing(accountId: Long, statusId: String, contentShowing: Boolean) + + @Query( + """UPDATE TimelineStatusEntity SET contentCollapsed = :contentCollapsed +WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""" + ) + abstract fun setContentCollapsed(accountId: Long, statusId: String, contentCollapsed: Boolean) + + @Query( + """UPDATE TimelineStatusEntity SET pinned = :pinned +WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""" + ) + abstract fun setPinned(accountId: Long, statusId: String, pinned: Boolean) + + @Query( + """DELETE FROM TimelineStatusEntity +WHERE timelineUserId = :accountId AND authorServerId IN ( +SELECT serverId FROM TimelineAccountEntity WHERE username LIKE '%@' || :instanceDomain +AND timelineUserId = :accountId +)""" + ) + abstract suspend fun deleteAllFromInstance(accountId: Long, instanceDomain: String) + + @Query("SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT 1") + abstract suspend fun getTopId(accountId: Long): String? + + @Query("SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND authorServerId IS NULL ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT 1") + abstract suspend fun getTopPlaceholderId(accountId: Long): String? + + @Query("SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND authorServerId IS NULL AND (LENGTH(:serverId) > LENGTH(serverId) OR (LENGTH(:serverId) = LENGTH(serverId) AND :serverId > serverId)) ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT 1") + abstract suspend fun getNextPlaceholderIdAfter(accountId: Long, serverId: String): String? +} 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 296111d3..ce816959 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,25 @@ +/* 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.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 +34,67 @@ 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?, + val expanded: Boolean, // used as the "loading" attribute when this TimelineStatusEntity is a placeholder + val contentCollapsed: Boolean, + val contentShowing: Boolean, + val pinned: 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 +102,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 b4b258d5..00000000 --- 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 2e82d640..6a440d26 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.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.account.AccountActivity import com.keylesspalace.tusky.components.announcements.AnnouncementsActivity import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.components.drafts.DraftsActivity @@ -79,9 +95,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 ff3d0266..2cf48046 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,22 @@ 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 + ] +) interface AppComponent { @Component.Builder interface Builder { @@ -48,4 +47,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 bd06bfc1..6446a735 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 13266851..7699ba69 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt @@ -13,25 +13,16 @@ * 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 androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.preference.PreferenceManager import androidx.room.Room import com.keylesspalace.tusky.TuskyApplication -import com.keylesspalace.tusky.appstore.EventHub -import com.keylesspalace.tusky.appstore.EventHubImpl -import com.keylesspalace.tusky.components.notifications.Notifier -import com.keylesspalace.tusky.components.notifications.SystemNotifier import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.Converters -import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.network.TimelineCases -import com.keylesspalace.tusky.network.TimelineCasesImpl import dagger.Module import dagger.Provides import javax.inject.Singleton @@ -54,40 +45,24 @@ class AppModule { return PreferenceManager.getDefaultSharedPreferences(app) } - @Provides - fun providesBroadcastManager(app: Application): LocalBroadcastManager { - return LocalBroadcastManager.getInstance(app) - } - - @Provides - fun providesTimelineUseCases(api: MastodonApi, - eventHub: EventHub): TimelineCases { - return TimelineCasesImpl(api, eventHub) - } - - @Provides - @Singleton - fun providesEventHub(): EventHub = EventHubImpl - @Provides @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.Migration25_26(appContext.getExternalFilesDir("Tusky")), + AppDatabase.MIGRATION_26_27, AppDatabase.MIGRATION_27_28 + ) + .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 edf95341..b7213fa6 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 a1a8c8fe..b3c5eaf8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt @@ -13,22 +13,25 @@ * 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 +import com.keylesspalace.tusky.components.account.media.AccountMediaFragment import com.keylesspalace.tusky.components.conversation.ConversationsFragment import com.keylesspalace.tusky.components.instancemute.fragment.InstanceListFragment -import com.keylesspalace.tusky.fragment.* import com.keylesspalace.tusky.components.preference.AccountPreferencesFragment import com.keylesspalace.tusky.components.preference.NotificationPreferencesFragment +import com.keylesspalace.tusky.components.preference.PreferencesFragment import com.keylesspalace.tusky.components.report.fragments.ReportDoneFragment import com.keylesspalace.tusky.components.report.fragments.ReportNoteFragment import com.keylesspalace.tusky.components.report.fragments.ReportStatusesFragment import com.keylesspalace.tusky.components.search.fragments.SearchAccountsFragment import com.keylesspalace.tusky.components.search.fragments.SearchHashtagsFragment import com.keylesspalace.tusky.components.search.fragments.SearchStatusesFragment -import com.keylesspalace.tusky.components.preference.PreferencesFragment +import com.keylesspalace.tusky.components.timeline.TimelineFragment +import com.keylesspalace.tusky.fragment.AccountListFragment +import com.keylesspalace.tusky.fragment.NotificationsFragment +import com.keylesspalace.tusky.fragment.ViewThreadFragment import dagger.Module import dagger.android.ContributesAndroidInjector @@ -53,9 +56,6 @@ abstract class FragmentBuildersModule { @ContributesAndroidInjector abstract fun notificationsFragment(): NotificationsFragment - @ContributesAndroidInjector - abstract fun searchFragment(): SearchStatusesFragment - @ContributesAndroidInjector abstract fun notificationPreferencesFragment(): NotificationPreferencesFragment @@ -63,7 +63,7 @@ abstract class FragmentBuildersModule { abstract fun accountPreferencesFragment(): AccountPreferencesFragment @ContributesAndroidInjector - abstract fun directMessagesPreferencesFragment(): ConversationsFragment + abstract fun conversationsFragment(): ConversationsFragment @ContributesAndroidInjector abstract fun accountInListsFragment(): AccountsInListFragment @@ -80,6 +80,9 @@ abstract class FragmentBuildersModule { @ContributesAndroidInjector abstract fun instanceListFragment(): InstanceListFragment + @ContributesAndroidInjector + abstract fun searchStatusesFragment(): SearchStatusesFragment + @ContributesAndroidInjector abstract fun searchAccountFragment(): SearchAccountsFragment @@ -88,5 +91,4 @@ abstract class FragmentBuildersModule { @ContributesAndroidInjector abstract fun preferencesFragment(): PreferencesFragment - } 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 1df715e7..f3b4e810 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/NetworkModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt index fb232a64..7bda6ef7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt @@ -34,7 +34,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 @@ -53,16 +53,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", "") @@ -92,30 +92,29 @@ 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 }) - } + .apply { + addInterceptor(InstanceSwitchAuthInterceptor(accountManager)) + if (BuildConfig.DEBUG) { + addInterceptor(HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC }) } - .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 @Singleton fun providesApi(retrofit: Retrofit): MastodonApi = retrofit.create() -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/di/RepositoryModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/RepositoryModule.kt deleted file mode 100644 index af8cfb88..00000000 --- a/app/src/main/java/com/keylesspalace/tusky/di/RepositoryModule.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.keylesspalace.tusky.di - -import com.google.gson.Gson -import com.keylesspalace.tusky.db.AccountManager -import com.keylesspalace.tusky.db.AppDatabase -import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.repository.TimelineRepository -import com.keylesspalace.tusky.repository.TimelineRepositoryImpl -import dagger.Module -import dagger.Provides - -@Module -class RepositoryModule { - @Provides - fun providesTimelineRepository( - 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 5f649554..156020f6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ServicesModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ServicesModule.kt @@ -15,25 +15,12 @@ package com.keylesspalace.tusky.di -import android.content.Context import com.keylesspalace.tusky.service.SendTootService -import com.keylesspalace.tusky.service.ServiceClient -import com.keylesspalace.tusky.service.ServiceClientImpl import dagger.Module -import dagger.Provides import dagger.android.ContributesAndroidInjector @Module abstract class ServicesModule { @ContributesAndroidInjector abstract fun contributesSendTootService(): SendTootService - - @Module - companion object { - @Provides - @JvmStatic - fun providesServiceClient(context: Context): ServiceClient { - 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 ce83deda..71d721f8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt @@ -4,6 +4,7 @@ package com.keylesspalace.tusky.di import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import com.keylesspalace.tusky.components.account.AccountViewModel import com.keylesspalace.tusky.components.announcements.AnnouncementsViewModel import com.keylesspalace.tusky.components.compose.ComposeViewModel import com.keylesspalace.tusky.components.conversation.ConversationsViewModel @@ -11,7 +12,8 @@ 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.viewmodel.AccountViewModel +import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineViewModel +import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel import com.keylesspalace.tusky.viewmodel.EditProfileViewModel import com.keylesspalace.tusky.viewmodel.ListsViewModel @@ -61,7 +63,6 @@ abstract class ViewModelModule { @ViewModelKey(ListsViewModel::class) internal abstract fun listsViewModel(viewModel: ListsViewModel): ViewModel - @Binds @IntoMap @ViewModelKey(AccountsInListViewModel::class) @@ -97,5 +98,15 @@ abstract class ViewModelModule { @ViewModelKey(DraftsViewModel::class) internal abstract fun draftsViewModel(viewModel: DraftsViewModel): ViewModel - //Add more ViewModels here + @Binds + @IntoMap + @ViewModelKey(CachedTimelineViewModel::class) + internal abstract fun cachedTimelineViewModel(viewModel: CachedTimelineViewModel): ViewModel + + @Binds + @IntoMap + @ViewModelKey(NetworkTimelineViewModel::class) + internal abstract fun networkTimelineViewModel(viewModel: NetworkTimelineViewModel): ViewModel + + // Add more ViewModels here } 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 18107883..e974ce19 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 d4940fe0..66c8022c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt @@ -20,23 +20,23 @@ import com.google.gson.annotations.SerializedName import java.util.Date data class Account( - val id: String, - @SerializedName("username") val localUsername: String, - @SerializedName("acct") val username: String, - @SerializedName("display_name") 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 + val id: String, + @SerializedName("username") val localUsername: String, + @SerializedName("acct") val username: String, + @SerializedName("display_name") 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 ) { @@ -57,41 +57,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 5cd32fe8..400e9764 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 95a829c1..fe6b0c3c 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 3e14519a..27fdc8be 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 ada9ec20..52011f3d 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 0e66385f..cb09981d 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 289a93fb..92a35b69 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 42bb99e9..130831a2 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 58bdc79a..34b80e83 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 1eaaf68f..a334257a 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 9473f037..98af734b 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 a9f6f499..d1e2aca9 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 16fd9e31..78572054 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 2f8eecf3..bfec7cc5 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 16cbc6a7..83ed56e9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/NewStatus.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/NewStatus.kt @@ -20,19 +20,19 @@ 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? + 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? ) @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 0dbefd61..6198867d 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 02c236c4..584b76f8 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,23 @@ 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, + @SerializedName("own_votes") val ownVotes: List? ) { 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 +25,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 e25a3d10..e99bcce6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Relationship.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Relationship.kt @@ -15,9 +15,11 @@ package com.keylesspalace.tusky.entity +import com.google.gson.annotations.JsonAdapter import com.google.gson.annotations.SerializedName +import com.keylesspalace.tusky.json.GuardedBooleanAdapter -data class Relationship ( +data class Relationship( val id: String, val following: Boolean, @SerializedName("followed_by") val followedBy: Boolean, @@ -26,7 +28,11 @@ data class Relationship ( @SerializedName("muting_notifications") val mutingNotifications: Boolean, val requested: Boolean, @SerializedName("showing_reblogs") val showingReblogs: Boolean, - val subscribing: Boolean? = null, // Pleroma extension + /* Pleroma extension, same as 'notifying' on Mastodon. + * Some instances like qoto.org have a custom subscription feature where 'subscribing' is a json object, + * so we use the custom GuardedBooleanAdapter to ignore the field if it is not a boolean. + */ + @JsonAdapter(GuardedBooleanAdapter::class) val subscribing: Boolean? = null, @SerializedName("domain_blocking") val blockingDomain: Boolean, val note: String?, // nullable for backward compatibility / feature detection val notifying: Boolean? // since 3.3.0rc 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 2621bd5e..dfaeb499 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 4307380c..18e3d71b 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 aee30e8d..25588f1a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt @@ -19,33 +19,34 @@ 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") 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") val spoilerText: String, - val visibility: Visibility, - @SerializedName("media_attachments") var attachments: ArrayList, - val mentions: Array, - val application: Application?, - var pinned: Boolean?, - var muted: Boolean?, - val poll: Poll?, - val card: Card? + 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") 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") val spoilerText: String, + val visibility: Visibility, + @SerializedName("media_attachments") var attachments: ArrayList, + val mentions: List, + val application: Application?, + val pinned: Boolean?, + val muted: Boolean?, + val poll: Poll?, + val card: Card? ) { val actionableId: String @@ -54,6 +55,12 @@ data class Status( val actionableStatus: Status get() = reblog ?: this + /** Helpers for Java */ + fun copyWithFavourited(favourited: Boolean): Status = copy(favourited = favourited) + fun copyWithReblogged(reblogged: Boolean): Status = copy(reblogged = reblogged) + fun copyWithBookmarked(bookmarked: Boolean): Status = copy(bookmarked = bookmarked) + fun copyWithPoll(poll: Poll?): Status = copy(poll = poll) + fun copyWithPinned(pinned: Boolean): Status = copy(pinned = pinned) enum class Visibility(val num: Int) { UNKNOWN(0), @@ -114,14 +121,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 ) } @@ -141,27 +148,14 @@ data class Status( return builder.toString() } - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other == null || javaClass != other.javaClass) return false - - val status = other as Status? - return id == status?.id - } - - override fun hashCode(): Int { - 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 1287619b..ce5bb144 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 0e25e6c1..d3235337 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 cf7050b8..fad5c58a 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.components.account.AccountActivity 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/NotificationsFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java index ecc8172d..e9581e24 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.ReblogEvent; import com.keylesspalace.tusky.db.AccountEntity; @@ -83,6 +84,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; @@ -92,6 +94,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; @@ -99,18 +102,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, @@ -182,10 +185,13 @@ public class NotificationsFragment extends SFragment implements Notification notification = input.asRight() .rewriteToStatusTypeIfNeeded(accountManager.getActiveAccount().getAccountId()); + boolean sensitiveStatus = notification.getStatus() != null && notification.getStatus().getActionableStatus().getSensitive(); + return ViewDataUtils.notificationToViewData( notification, - alwaysShowSensitiveMedia, - alwaysOpenSpoiler + alwaysShowSensitiveMedia || !sensitiveStatus, + alwaysOpenSpoiler, + true ); } else { return new NotificationViewData.Placeholder(input.asLeft().id, false); @@ -252,6 +258,7 @@ public class NotificationsFragment extends SFragment implements preferences.getBoolean("useBlurhash", true), CardViewMode.NONE, preferences.getBoolean("confirmReblogs", true), + preferences.getBoolean("confirmFavourites", false), preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) ); @@ -306,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); @@ -383,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) { @@ -423,34 +403,18 @@ 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.copyWithReblogged(reblog)); } @Override @@ -458,34 +422,18 @@ 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.copyWithFavourited(favourite)); } @Override @@ -493,63 +441,35 @@ 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.copyWithBookmarked(bookmark)); } 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 @@ -562,13 +482,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 @@ -579,30 +503,16 @@ 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)); } @Override @@ -628,42 +538,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 @@ -687,7 +629,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 @@ -832,7 +774,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)) @@ -844,8 +786,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); @@ -951,8 +896,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()) { @@ -1284,7 +1229,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() ); 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 ef1074a3..c9395b38 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; @@ -316,14 +300,16 @@ 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()) + .to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) + .subscribe(); 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; } @@ -335,12 +321,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; + } ); } @@ -352,7 +338,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; } @@ -368,20 +354,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(), @@ -394,7 +378,7 @@ public abstract class SFragment extends Fragment implements Injectable { } default: case UNKNOWN: { - LinkHelper.openLink(active.getUrl(), getContext()); + LinkHelper.openLink(active.getAttachment().getUrl(), getContext()); break; } } @@ -416,7 +400,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 -> { }, @@ -439,7 +423,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); @@ -510,83 +494,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 7e91d230..00000000 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.kt +++ /dev/null @@ -1,1265 +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.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.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 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.BlockEvent -import com.keylesspalace.tusky.appstore.BookmarkEvent -import com.keylesspalace.tusky.appstore.DomainMuteEvent -import com.keylesspalace.tusky.appstore.Event -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.PreferenceChangedEvent -import com.keylesspalace.tusky.appstore.ReblogEvent -import com.keylesspalace.tusky.appstore.StatusComposedEvent -import com.keylesspalace.tusky.appstore.StatusDeletedEvent -import com.keylesspalace.tusky.appstore.UnfollowEvent -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.CardViewMode -import com.keylesspalace.tusky.util.Either -import com.keylesspalace.tusky.util.Either.Left -import com.keylesspalace.tusky.util.Either.Right -import com.keylesspalace.tusky.util.HttpHeaderLink -import com.keylesspalace.tusky.util.LinkHelper -import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate -import com.keylesspalace.tusky.util.PairedList -import com.keylesspalace.tusky.util.StatusDisplayOptions -import com.keylesspalace.tusky.util.ViewDataUtils -import com.keylesspalace.tusky.util.dec -import com.keylesspalace.tusky.util.hide -import com.keylesspalace.tusky.util.inc -import com.keylesspalace.tusky.util.show -import com.keylesspalace.tusky.util.viewBinding -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 retrofit2.Response -import java.io.IOException -import java.util.concurrent.TimeUnit -import javax.inject.Inject - -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 - - 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 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) - - 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) - ) - 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() - } - } - } - - 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 - if (kind == Kind.HOME) { - val preferences = PreferenceManager.getDefaultSharedPreferences(context) - filterRemoveReplies = !preferences.getBoolean("tabFilterHomeReplies", true) - filterRemoveReblogs = !preferences.getBoolean("tabFilterHomeBoosts", true) - } - reloadFilters(false) - } - - 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.chinwag_green) - } - - 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) - } - } - } - 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) { - 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 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 - } - } - } - - 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() - } - } - - /** - * 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) { - when (kind) { - Kind.HOME, Kind.PUBLIC_FEDERATED, Kind.PUBLIC_LOCAL -> onRefresh() - Kind.USER, Kind.USER_WITH_REPLIES -> if (status.account.id == id) { - onRefresh() - } else { - return - } - Kind.TAG, Kind.FAVOURITES, Kind.LIST, Kind.BOOKMARKS, Kind.USER_PINNED -> return - } - } - - 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 && adapter.itemCount != count) { - 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(from(this, Lifecycle.Event.ON_PAUSE)) - .subscribe { updateAdapter() } - } - } - - override fun onReselect() { - if (isAdded) { - layoutManager!!.scrollToPosition(0) - binding.recyclerView.stopScroll() - scrollListener!!.reset() - } - } - - 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 LOAD_AT_ONCE = 30 - - fun newInstance(kind: Kind, hashtagOrId: String? = null, enableSwipeToRefresh: Boolean = true): 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) - 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 - } - } - } -} \ No newline at end of file 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 c68cfb5f..0362da9c 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 b25fec26..89c65e10 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 ccd0c3d4..33828cab 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; @@ -56,14 +56,17 @@ 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; +import com.keylesspalace.tusky.util.LinkHelper; import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate; 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; @@ -73,10 +76,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 { @@ -86,6 +91,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; @@ -99,11 +106,12 @@ public final class ViewThreadFragment extends SFragment implements private final PairedList statuses = new PairedList<>(new Function() { @Override - public StatusViewData.Concrete apply(Status input) { + public StatusViewData.Concrete apply(Status status) { return ViewDataUtils.statusToViewData( - input, - alwaysShowSensitiveMedia, - alwaysOpenSpoiler + status, + alwaysShowSensitiveMedia || !status.getActionableStatus().getSensitive(), + alwaysOpenSpoiler, + true ); } }); @@ -134,6 +142,7 @@ public final class ViewThreadFragment extends SFragment implements CardViewMode.INDENTED : CardViewMode.NONE, preferences.getBoolean("confirmReblogs", true), + preferences.getBoolean("confirmFavourites", false), preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) ); @@ -163,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); @@ -182,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); @@ -190,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) { @@ -203,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(); } @@ -239,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) ); @@ -253,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) ); @@ -267,32 +273,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)); } } @@ -304,7 +307,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 @@ -314,7 +317,23 @@ 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 + public void onViewUrl(String url) { + Status status = null; + if (!statuses.isEmpty()) { + status = statuses.get(statusIndex); + } + if (status != null && status.getUrl().equals(url)) { + // already viewing the status with this url + // probably just a preview federated and the user is clicking again to view more -> open the browser + // this can happen with some friendica statuses + LinkHelper.openLink(url, requireContext()); + return; + } + super.onViewUrl(url); } @Override @@ -325,21 +344,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); } @@ -365,28 +385,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 @@ -412,28 +415,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) { @@ -462,7 +458,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); @@ -475,7 +471,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); @@ -503,7 +499,7 @@ public final class ViewThreadFragment extends SFragment implements private int setStatus(Status status) { if (statuses.size() > 0 && statusIndex < statuses.size() - && statuses.get(statusIndex).equals(status)) { + && statuses.get(statusIndex).getId().equals(status.getId())) { // Do not add this status on refresh, it's already in there. statuses.set(statusIndex, status); return statusIndex; @@ -530,7 +526,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 @@ -560,7 +556,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 @@ -581,71 +577,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; @@ -671,23 +627,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() { @@ -710,13 +659,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 a0912837..a1930da8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt @@ -21,6 +21,7 @@ import android.annotation.SuppressLint import android.os.Bundle import android.os.Handler import android.os.Looper +import android.text.method.ScrollingMovementMethod import android.view.KeyEvent import android.view.LayoutInflater import android.view.View @@ -48,7 +49,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,13 +73,14 @@ 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) + binding.mediaDescription.movementMethod = ScrollingMovementMethod() binding.videoView.transitionName = url binding.videoView.setVideoPath(url) @@ -105,7 +107,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 +127,10 @@ class ViewVideoFragment : ViewMediaFragment() { val videoWidth = mp.videoWidth.toFloat() val videoHeight = mp.videoHeight.toFloat() - if(containerWidth/containerHeight > videoWidth/videoHeight) { + if (isAudio) { + binding.videoView.layoutParams.height = 1 + binding.videoView.layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT + } else if (containerWidth / containerHeight > videoWidth / videoHeight) { binding.videoView.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT binding.videoView.layoutParams.width = ViewGroup.LayoutParams.WRAP_CONTENT } else { @@ -190,15 +195,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 04b1ebd2..b86c55c7 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 5032774f..83fc20c9 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 c50178c1..598894f6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/interfaces/ReselectableFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/interfaces/ReselectableFragment.kt @@ -8,4 +8,4 @@ interface ReselectableFragment { * Call this method when tab reselected */ fun onReselect() -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java b/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java index ec37680c..116e582c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java +++ b/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java @@ -38,7 +38,7 @@ public interface StatusActionListener extends LinkListener { void onOpenReblog(int position); void onExpandedChange(boolean expanded, int position); void onContentHiddenChange(boolean isShowing, int position); - void onLoadMore(int position); + void onLoadMore(int position); /** * Called when the status {@link android.widget.ToggleButton} responsible for collapsing long diff --git a/app/src/main/java/com/keylesspalace/tusky/db/TootDao.java b/app/src/main/java/com/keylesspalace/tusky/json/GuardedBooleanAdapter.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/json/GuardedBooleanAdapter.kt index f46c2753..8ed702a8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/TootDao.java +++ b/app/src/main/java/com/keylesspalace/tusky/json/GuardedBooleanAdapter.kt @@ -1,4 +1,4 @@ -/* Copyright 2017 Andrew Dawson +/* Copyright 2022 Tusky Contributors * * This file is a part of Tusky. * @@ -13,33 +13,21 @@ * 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.json -import androidx.room.Dao; -import androidx.room.Query; +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import com.google.gson.JsonParseException +import java.lang.reflect.Type -import java.util.List; - -import io.reactivex.Observable; - -/** - * 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 +class GuardedBooleanAdapter : JsonDeserializer { + @Throws(JsonParseException::class) + override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): Boolean? { + return if (json.isJsonObject) { + null + } else { + json.asBoolean + } + } +} 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 6eabea52..ceb96f4a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/json/SpannedTypeAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/json/SpannedTypeAdapter.kt @@ -19,7 +19,13 @@ import android.text.Spanned import android.text.SpannedString 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 java.lang.reflect.Type @@ -34,4 +40,4 @@ class SpannedTypeAdapter : JsonDeserializer, JsonSerializer { override fun serialize(src: Spanned?, typeOfSrc: Type, context: JsonSerializationContext): JsonElement { return JsonPrimitive(HtmlCompat.toHtml(src!!, HtmlCompat.TO_HTML_PARAGRAPH_LINES_INDIVIDUAL)) } -} \ No newline at end of file +} 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 00000000..062191ad --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/network/FilterModel.kt @@ -0,0 +1,65 @@ +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 + val attachmentsDescriptions = status.attachments + .mapNotNull { it.description } + + return ( + matcher.reset(status.actionableStatus.content).find() || + (spoilerText.isNotEmpty() && matcher.reset(spoilerText).find()) || + ( + attachmentsDescriptions.isNotEmpty() && + matcher.reset(attachmentsDescriptions.joinToString("\n")).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 28ac77b6..a8b1050c 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? = null, + @Query("since_id") sinceId: String? = null, + @Query("limit") limit: Int? = null ): 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? = null, + @Query("max_id") maxId: String? = null, + @Query("since_id") sinceId: String? = null, + @Query("limit") limit: Int? = null ): 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/TimelineCases.kt b/app/src/main/java/com/keylesspalace/tusky/network/TimelineCases.kt index 6e79f075..86148e51 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/TimelineCases.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/TimelineCases.kt @@ -16,35 +16,32 @@ 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 +import javax.inject.Inject /** * 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 -} - -class TimelineCasesImpl( - private val mastodonApi: MastodonApi, - private val eventHub: EventHub -) : TimelineCases { +class TimelineCases @Inject constructor( + private val mastodonApi: MastodonApi, + private val eventHub: EventHub +) { /** * Unused yet but can be use for cancellation later. It's always a good idea to save @@ -52,104 +49,98 @@ class TimelineCasesImpl( */ private val cancelDisposable = CompositeDisposable() - override fun reblog(status: Status, reblog: Boolean): Single { - val id = status.actionableId - + 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 - + 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 - + 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 - + 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 -> + 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) { + fun block(statusId: String) { + mastodonApi.blockAccount(statusId) + .subscribe( + { + eventHub.dispatch(BlockEvent(statusId)) + }, + { t -> + Log.w("Failed to block account", t) + } + ) + .addTo(cancelDisposable) + } + + fun delete(statusId: String): Single { + return mastodonApi.deleteStatus(statusId) + .doAfterSuccess { + eventHub.dispatch(StatusDeletedEvent(statusId)) + } + } + + 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()) { + 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/ImagePagerAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/pager/ImagePagerAdapter.kt index 4f813d8b..26c5fc05 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 1e102941..4fe92660 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 c8306f70..c1f5342a 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 d9b94857..6d4e9719 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 db861701..25dd91d4 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.chinwag_green))) - .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.chinwag_green)) + .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,35 +86,34 @@ 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, - 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, + 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.chinwag_green))) - .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.chinwag_green))) + .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)) @@ -134,14 +133,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) @@ -154,5 +156,4 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() { return remoteInput.getCharSequence(NotificationHelper.KEY_REPLY, "") } - -} \ No newline at end of file +} 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 b3e12aeb..00000000 --- a/app/src/main/java/com/keylesspalace/tusky/repository/TimelineRepository.kt +++ /dev/null @@ -1,392 +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> - - 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) - } - } - - 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 - ) - } - 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 - ) - } 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 - ) - } - 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 dff91bd1..d2092118 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.chinwag_green)) - .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.chinwag_green)) + .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) { @@ -134,24 +135,23 @@ class SendTootService : Service(), Injectable { tootToSend.retries++ val newStatus = NewStatus( - tootToSend.text, - tootToSend.warningText, - tootToSend.inReplyToId, - tootToSend.visibility, - tootToSend.sensitive, - tootToSend.mediaIds, - tootToSend.scheduledAt, - tootToSend.poll + tootToSend.text, + tootToSend.warningText, + tootToSend.inReplyToId, + tootToSend.visibility, + tootToSend.sensitive, + tootToSend.mediaIds, + tootToSend.scheduledAt, + tootToSend.poll ) 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 { @@ -162,12 +162,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) { @@ -177,24 +175,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.chinwag_green)) + .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.chinwag_green)) notificationManager.cancel(tootId) notificationManager.notify(errorNotificationId--, builder.build()) - } stopSelfWhenDone() - } override fun onFailure(call: Call, t: Throwable) { @@ -203,16 +198,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() { @@ -232,26 +229,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.chinwag_green)) + .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.chinwag_green)) 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, @@ -263,7 +262,8 @@ class SendTootService : Service(), Injectable { mediaDescriptions = toot.mediaDescriptions, poll = toot.poll, failedToSend = true - ).subscribe() + ) + } } private fun cancelSendingIntent(tootId: Int): PendingIntent { @@ -275,6 +275,10 @@ class SendTootService : Service(), Injectable { return PendingIntent.getService(this, tootId, intent, PendingIntent.FLAG_UPDATE_CURRENT) } + override fun onDestroy() { + super.onDestroy() + supervisorJob.cancel() + } companion object { @@ -288,8 +292,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) @@ -298,42 +303,39 @@ 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 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 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 b60377f5..9ac5adac 100644 --- a/app/src/main/java/com/keylesspalace/tusky/service/ServiceClient.kt +++ b/app/src/main/java/com/keylesspalace/tusky/service/ServiceClient.kt @@ -16,19 +16,12 @@ package com.keylesspalace.tusky.service import android.content.Context -import android.os.Build +import androidx.core.content.ContextCompat +import javax.inject.Inject -interface ServiceClient { - fun sendToot(tootToSend: TootToSend) -} - -class ServiceClientImpl(private val context: Context) : ServiceClient { - override fun sendToot(tootToSend: TootToSend) { +class ServiceClient @Inject constructor(private val context: Context) { + fun sendToot(tootToSend: TootToSend) { val intent = SendTootService.sendTootIntent(context, tootToSend) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - context.startForegroundService(intent) - } else { - context.startService(intent) - } + ContextCompat.startForegroundService(context, intent) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt index d014ec0c..c59ba58b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt +++ b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt @@ -30,6 +30,7 @@ object PrefKeys { const val SHOW_NOTIFICATIONS_FILTER = "showNotificationsFilter" const val SHOW_CARDS_IN_TIMELINES = "showCardsInTimelines" const val CONFIRM_REBLOGS = "confirmReblogs" + const val CONFIRM_FAVOURITES = "confirmFavourites" const val ENABLE_SWIPE_FOR_TABS = "enableSwipeForTabs" const val ANIMATE_CUSTOM_EMOJIS = "animateCustomEmojis" 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 82dfa14e..1569cb15 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 dad6d552..00000000 --- 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 a7a4c972..62167ee6 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 bd5f9007..117f59c0 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 2cf2348c..81c2216b 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 a9e7ba89..00000000 --- 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 c0da4275..6fee42ed 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 7521afe4..df7b4d9f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/CustomEmojiHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/CustomEmojiHelper.kt @@ -18,46 +18,43 @@ 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 - * @param text the text containing custom emojis + * @receiver the text containing custom emojis * @param emojis a list of the custom emojis (nullable for backward compatibility with old mastodon instances) * @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) - emojis.forEach { (shortcode, url) -> + emojis.forEach { (shortcode, url, staticUrl) -> 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) Glide.with(view) .asDrawable() - .load(url) + .load(if (animate) { url } else { staticUrl }) .into(span.getTarget(animate)) } } @@ -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 bda20614..eb31032f 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 e772162e..00000000 --- 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 f0955cfa..728ccd0e 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 d0a0e443..f513feee 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 6f2542b5..41d1034c 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/GlideModule.kt b/app/src/main/java/com/keylesspalace/tusky/util/GlideModule.kt new file mode 100644 index 00000000..774c860e --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/GlideModule.kt @@ -0,0 +1,7 @@ +package com.keylesspalace.tusky.util + +import com.bumptech.glide.annotation.GlideModule +import com.bumptech.glide.module.AppGlideModule + +@GlideModule +class GlideModule : AppGlideModule() 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 9daf16f8..1cd9b99a 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 ec0c8a3e..4969c9ad 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,27 +82,22 @@ 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) { - 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 - * that can't be found then just go with whichever one matched last. */ + } else if (text.charAt(0) == '@' && mentions != null && mentions.size() > 0) { + // https://github.com/tuskyapp/Tusky/pull/2339 String id = null; for (Status.Mention mention : mentions) { - if (mention.getLocalUsername().equalsIgnoreCase(accountUsername)) { + if (mention.getUrl().equals(span.getURL())) { id = mention.getId(); - if (mention.getUrl().contains(getDomain(span.getURL()))) { - break; - } + break; } } 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 +105,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()); } }; @@ -141,8 +137,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 +150,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 +176,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 859162da..879fccc3 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 8a5223ce..7cdc12e8 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 3d4234c5..00000000 --- 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 b0048aef..21c4307c 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 4a80bca2..45f3ab37 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 43f05e9c..5482b292 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 deleted file mode 100644 index 09a00339..00000000 --- a/app/src/main/java/com/keylesspalace/tusky/util/NetworkState.kt +++ /dev/null @@ -1,34 +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 - -enum class Status { - RUNNING, - SUCCESS, - FAILED -} - -@Suppress("DataClassPrivateConstructor") -data class NetworkState private constructor( - 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/di/MediaUploaderModule.kt b/app/src/main/java/com/keylesspalace/tusky/util/NoUnderlineURLSpan.kt similarity index 56% rename from app/src/main/java/com/keylesspalace/tusky/di/MediaUploaderModule.kt rename to app/src/main/java/com/keylesspalace/tusky/util/NoUnderlineURLSpan.kt index 66dc2711..a9b56b89 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/MediaUploaderModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/NoUnderlineURLSpan.kt @@ -1,4 +1,4 @@ -/* Copyright 2019 Tusky Contributors +/* Copyright 2021 Tusky Contributors * * This file is a part of Tusky. * @@ -13,18 +13,22 @@ * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ -package com.keylesspalace.tusky.di +package com.keylesspalace.tusky.util -import android.content.Context -import com.keylesspalace.tusky.components.compose.MediaUploader -import com.keylesspalace.tusky.components.compose.MediaUploaderImpl -import com.keylesspalace.tusky.network.MastodonApi -import dagger.Module -import dagger.Provides +import android.text.TextPaint +import android.text.style.URLSpan +import android.view.View -@Module -class MediaUploaderModule { - @Provides - fun providesMediaUploder(context: Context, mastodonApi: MastodonApi): MediaUploader = - MediaUploaderImpl(context, mastodonApi) -} \ No newline at end of file +open class NoUnderlineURLSpan( + url: String +) : URLSpan(url) { + + override fun updateDrawState(ds: TextPaint) { + super.updateDrawState(ds) + ds.isUnderlineText = false + } + + 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 65c8f6c0..34e8924f 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 4f7d3eff..00000000 --- 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 00000000..4d3fcd5b --- /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 1f9f35d2..ddc88f45 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/RickRoll.kt b/app/src/main/java/com/keylesspalace/tusky/util/RickRoll.kt index 03c3339f..788786ed 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/RickRoll.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/RickRoll.kt @@ -6,9 +6,9 @@ import android.net.Uri import com.keylesspalace.tusky.R fun shouldRickRoll(context: Context, domain: String) = - context.resources.getStringArray(R.array.rick_roll_domains).any { candidate -> - domain.equals(candidate, true) || domain.endsWith(".$candidate", true) - } + context.resources.getStringArray(R.array.rick_roll_domains).any { candidate -> + domain.equals(candidate, true) || domain.endsWith(".$candidate", true) + } fun rickRoll(context: Context) { val uri = Uri.parse(context.getString(R.string.rick_roll_url)) 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 c78b0f78..0f326743 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 29693550..00000000 --- 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 1acf2260..ee687496 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 ba9c4203..078639ac 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 307fbeae..7734d9d7 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 ce19e00e..cb782107 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt @@ -1,22 +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 -) \ No newline at end of file + @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("confirmFavourites") + val confirmFavourites: Boolean, + @get:JvmName("hideStats") + val hideStats: Boolean, + @get:JvmName("animateEmojis") + val animateEmojis: 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 82210029..f2e48f4a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/StatusViewHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/StatusViewHelper.kt @@ -23,6 +23,7 @@ import android.view.View import android.widget.ImageView import android.widget.TextView import androidx.annotation.DrawableRes +import androidx.core.content.ContextCompat import com.bumptech.glide.Glide import com.keylesspalace.tusky.R import com.keylesspalace.tusky.entity.Attachment @@ -34,7 +35,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 +49,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 +90,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 +109,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 +123,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 +149,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 +175,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 +187,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 +209,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 +258,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 +274,6 @@ class StatusViewHelper(private val itemView: View) { } else { val timestamp = System.currentTimeMillis() - setupPollResult(poll, emojis, pollResults, statusDisplayOptions.animateEmojis) pollDescription.visibility = View.VISIBLE @@ -271,7 +284,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 +304,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 @@ -299,14 +311,19 @@ class StatusViewHelper(private val itemView: View) { if (i < options.size) { val percent = calculatePercent(options[i].votesCount, poll.votersCount, poll.votesCount) - val pollOptionText = buildDescription(options[i].title, percent, pollResults[i].context) + val pollOptionText = buildDescription(options[i].title, percent, options[i].voted, pollResults[i].context) pollResults[i].text = pollOptionText.emojify(emojis, pollResults[i], animateEmojis) pollResults[i].visibility = View.VISIBLE val level = percent * 100 + val optionColor = if (options[i].voted) { + R.color.colorBackgroundHighlight + } else { + R.color.colorBackgroundAccent + } pollResults[i].background.level = level - + pollResults[i].background.setTint(ContextCompat.getColor(pollResults[i].context, optionColor)) } else { pollResults[i].visibility = View.GONE } @@ -329,4 +346,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 83eaeafa..57b87f92 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 5fa80fcd..e2db79c6 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 2e8e67ef..00000000 --- a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.java +++ /dev/null @@ -1,86 +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()) - .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 00000000..52d9713f --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt @@ -0,0 +1,53 @@ +@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( + isShowingContent: Boolean, + isExpanded: Boolean, + isCollapsed: Boolean +): StatusViewData.Concrete { + val visibleStatus = this.reblog ?: this + + return StatusViewData.Concrete( + status = this, + isShowingContent = isShowingContent, + isCollapsible = shouldTrimStatus(visibleStatus.content), + isCollapsed = isCollapsed, + isExpanded = isExpanded, + ) +} + +@JvmName("notificationToViewData") +fun Notification.toViewData( + isShowingContent: Boolean, + isExpanded: Boolean, + isCollapsed: Boolean +): NotificationViewData.Concrete { + return NotificationViewData.Concrete( + this.type, + this.id, + this.account, + this.status?.toViewData(isShowingContent, isExpanded, isCollapsed) + ) +} 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 389995ae..07a9539f 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 b003cb2d..00000000 --- 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 32a7d6b3..82860f9f 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 4011d69d..c291bd01 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 09e648ad..0a9bfbf7 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 ec748e04..444e71dc 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 ad9ae52c..116f0170 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 42bfc276..8922fafd 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 022927e6..715fa603 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/MuteAccountDialog.kt +++ b/app/src/main/java/com/keylesspalace/tusky/view/MuteAccountDialog.kt @@ -17,20 +17,20 @@ fun showMuteAccountDialog( 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) + .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] - } - - onOk(binding.checkbox.isChecked, duration) + // 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 d0e73052..d7e753bb 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 a7b2bffc..b0a8062f 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 6438a25d..409b858d 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 b6eefd71..3dc5ca10 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,25 @@ 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, + var voted: Boolean ) fun calculatePercent(fraction: Int, totalVoters: Int?, totalVotes: Int): Int { @@ -51,30 +52,35 @@ fun calculatePercent(fraction: Int, totalVoters: Int?, totalVotes: Int): Int { } } -fun buildDescription(title: String, percent: Int, context: Context): Spanned { - return SpannableStringBuilder(context.getString(R.string.poll_percent_format, percent).parseAsHtml()) - .append(" ") - .append(title) +fun buildDescription(title: String, percent: Int, voted: Boolean, context: Context): Spanned { + val builder = SpannableStringBuilder(context.getString(R.string.poll_percent_format, percent).parseAsHtml()) + if (voted) { + builder.append(" ✓ ") + } else { + builder.append(" ") + } + return builder.append(title) } 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.mapIndexed { index, option -> option.toViewData(ownVotes?.contains(index) == true) }, + voted = voted, ) } -fun PollOption.toViewData(): PollOptionViewData { +fun PollOption.toViewData(voted: Boolean): PollOptionViewData { return PollOptionViewData( - title = title, - votesCount = votesCount, - selected = false + title = title, + votesCount = votesCount, + selected = false, + voted = voted ) -} \ 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 10820fbd..00000000 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.java +++ /dev/null @@ -1,677 +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; - - 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) { - - 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; - } - - 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() { - 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; - } - - @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; - } - - 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; - - 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(); - } - - 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) { - 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 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); - } - } -} 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 00000000..92675eb6 --- /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/AccountsInListViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountsInListViewModel.kt index 1dc41228..b02c2ac0 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 24a73396..a51fea0d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt @@ -15,12 +15,12 @@ package com.keylesspalace.tusky.viewmodel -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel import android.content.Context import android.graphics.Bitmap import android.net.Uri import android.util.Log +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel import com.keylesspalace.tusky.EditProfileActivity.Companion.AVATAR_SIZE import com.keylesspalace.tusky.EditProfileActivity.Companion.HEADER_HEIGHT import com.keylesspalace.tusky.EditProfileActivity.Companion.HEADER_WIDTH @@ -30,16 +30,22 @@ 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.RequestBody.Companion.asRequestBody -import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.MultipartBody import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.asRequestBody +import okhttp3.RequestBody.Companion.toRequestBody import org.json.JSONException import org.json.JSONObject import retrofit2.Call @@ -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) } } - - -} \ No newline at end of file +} 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 22f509b5..68263155 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/res/layout/activity_license.xml b/app/src/main/res/layout/activity_license.xml index a8401f13..5256d93a 100644 --- a/app/src/main/res/layout/activity_license.xml +++ b/app/src/main/res/layout/activity_license.xml @@ -94,6 +94,16 @@ license:link="https://square.github.io/retrofit/" license:name="Retrofit" /> + + + + - diff --git a/app/src/main/res/layout/activity_saved_toot.xml b/app/src/main/res/layout/activity_saved_toot.xml deleted file mode 100644 index 1771135e..00000000 --- a/app/src/main/res/layout/activity_saved_toot.xml +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/fragment_view_video.xml b/app/src/main/res/layout/fragment_view_video.xml index 180481c4..29136137 100644 --- a/app/src/main/res/layout/fragment_view_video.xml +++ b/app/src/main/res/layout/fragment_view_video.xml @@ -20,6 +20,7 @@ android:textAlignment="center" android:textColor="#eee" android:textSize="?attr/status_text_medium" + android:scrollbars="vertical" app:layout_constraintTop_toTopOf="parent" tools:text="Some media description" /> diff --git a/app/src/main/res/layout/item_media_preview.xml b/app/src/main/res/layout/item_media_preview.xml index 27b58e7a..3d89335f 100644 --- a/app/src/main/res/layout/item_media_preview.xml +++ b/app/src/main/res/layout/item_media_preview.xml @@ -141,6 +141,8 @@ android:importantForAccessibility="no" android:textSize="?attr/status_text_medium" android:visibility="gone" + android:maxLines="10" + android:ellipsize="end" app:drawableTint="?android:attr/textColorTertiary" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> @@ -155,6 +157,8 @@ android:importantForAccessibility="no" android:textSize="?attr/status_text_medium" android:visibility="gone" + android:maxLines="10" + android:ellipsize="end" app:drawableTint="?android:attr/textColorTertiary" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/status_media_label_0" /> @@ -169,6 +173,8 @@ android:importantForAccessibility="no" android:textSize="?attr/status_text_medium" android:visibility="gone" + android:maxLines="10" + android:ellipsize="end" app:drawableTint="?android:attr/textColorTertiary" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/status_media_label_1" /> @@ -183,6 +189,8 @@ android:importantForAccessibility="no" android:textSize="?attr/status_text_medium" android:visibility="gone" + android:maxLines="10" + android:ellipsize="end" app:drawableTint="?android:attr/textColorTertiary" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/status_media_label_2" /> 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 c95473f2..00000000 --- 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 00000000..2f5dedd9 --- /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 bbc9202f..00000000 --- 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/raw/keep.xml b/app/src/main/res/raw/keep.xml deleted file mode 100644 index c9c47c15..00000000 --- a/app/src/main/res/raw/keep.xml +++ /dev/null @@ -1,3 +0,0 @@ - - diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 40d9ea8f..a565a2c8 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -101,7 +101,7 @@ موافقة رفض البحث - المسودات + المسودات كيفية عرض التبويق تحذير عن المحتوى لوحة مفاتيح الإيموجي @@ -215,6 +215,11 @@ %1$s, %2$s, و %3$s %1$s و %2$s + %d تفاعلات جديدة + تفاعل جديد + تفاعلين جديدين + %d تفاعلات جديدة + %d تفاعلات جديدة %d تفاعلات جديدة حساب مقفل @@ -330,6 +335,11 @@ %1$s و %2$s %1$s و %2$s و %3$d آخَرون + لقد بلغت الحد الأقصى مِن الألسنة %1$d + لقد بلغت الحد الأقصى مِن الألسنة %1$d + لقد بلغت الحد الأقصى مِن الألسنة %1$d + لقد بلغت الحد الأقصى مِن الألسنة %1$d + لقد بلغت الحد الأقصى مِن الألسنة %1$d لقد بلغت الحد الأقصى مِن الألسنة %1$d الوسائط: %s @@ -473,7 +483,7 @@ أضيف إلى الفواصل المرجعية اختر قائمة القائمة - ليس لديك أية مسودات. + ليس لديك أية مسودات. ليس لديك أية منشورات مُبرمَجة للنشر. يجب أن يكون حجم الملفات الصوتية أقل مِن 40 ميغابايت. تُقدّر أدنى فترة لبرمجة النشر في ماستدون بـ 5 دقائق. @@ -508,4 +518,31 @@ إلغاء كتم إشعارات %s إلغاء كتم %s إخفاء عنوان شريط الأدوات العلوي + الاعلانات + أتريد حقا حذف القائمة %s؟ + فشلت عملية إرسال التبويق! + حُذفَت المسودة + اشترك + إلغاء الإشتراك + نشر %s للتوّ + احذف المحادثة + هل تريد حذف هذه المحادثة؟ + تبويقات جديدة + مرفقات + المدة + لا توجد إعلانات. + ملاحظتك الخاصة عن هذا الحساب + تم حفظها! + راجع الإشعارات + صوت + لقد حُذِف التبويق الذي حررت من أجله مسودة الرد + شخص ما أنا مشترك في حسابه قد نشر تبويقا جديدا + حرّك الإيموجيات المخصصة + إخفاء الإحصائيات الكمية عن المنشورات + إخفاء الإحصائيات الكمية عن الملفات التعريفية + احذف الفاصلة المرجعية + أظهر نافذة لطلب التأكيد قبل الإضافة إلى المفضلة + فشل في تحميل معلومات الرد + غير محددة + وضع حد لإشعارات الخيط الزمني \ No newline at end of file diff --git a/app/src/main/res/values-ber/strings.xml b/app/src/main/res/values-ber/strings.xml index 9726d4ff..b6d317b5 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 0056bfdb..a3db937b 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 f8aed7f2..33db1b27 100644 --- a/app/src/main/res/values-bn-rBD/strings.xml +++ b/app/src/main/res/values-bn-rBD/strings.xml @@ -236,7 +236,7 @@ ইমোজি কীবোর্ড সতর্কবার্তা টুট দৃশ্যমানতা - ড্রাফটগুলি + ড্রাফটগুলি অনুসন্ধান প্রত্যাখ্যান গ্রহণ @@ -331,7 +331,7 @@ আলাপ বন্ধ করো মাস্টোডনের সর্বনিম্ন ৫ মিনিটের সময়সূচীর বিরতি আছে। অডিও ফাইলগুলি অবশ্যই ৪০MB এর চেয়ে কম হওয়া উচিত। - তোমার কোনো খসড়া নেই। + তোমার কোনো খসড়া নেই। তোমার কোনো সময়সূচীত স্ট্যাটাস নেই। তালিকা তালিকা নির্বাচন করো @@ -447,10 +447,7 @@ সদস্যতা আছে এমন একজন টুট দিয়েছে কোনো ঘোষণা নেই। যদিও তোমার অ্যাকাউন্ট রুদ্ধকৃত না, %1$s রা ভেবেছে এই অ্যাকাউন্টগুলোর অনুসরণ অনুরোধ তোমার পরীক্ষা করা উচিত। - নতুন খসড়া বৈশিষ্ট দ্রুততর হওয়ার জন্য নতুনভাবে নকশা করা হয়েছে, যা সহজে ব্যবহারযোগ্য ও কম সমস্যাপূর্ণ। -\n আগের খসড়াগুলো খসড়া পাতার বোতাম দিয়ে যেতে পারো, কিন্তু ভবিষ্যত হালনাগাদে তা সরিয়ে ফেলা হবে! যে টুটের উত্তর খসড়া করেছিলে তা মুছে ফেলা হয়েছে - এই তালিকাটা আসলেই মুছতে চাও\? %1$d টার বেশি সংযুক্তি পাঠানো যাবে না। %1$d টার বেশি সংযুক্তি পাঠানো যাবে না। @@ -471,7 +468,6 @@ এই অ্যাকাউন্ট নিয়ে তোমার ব্যক্তিগত লেখা শীর্ষস্থানীয় সরঞ্জামের শিরোনামটি লুকাও খসড়া মুছো হয়েছে - পুরোনো খসড়া বিজ্ঞপ্তি সুস্থতা সময়হীন diff --git a/app/src/main/res/values-bn-rIN/strings.xml b/app/src/main/res/values-bn-rIN/strings.xml index 35fdd231..3de2ffe3 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 0cc1d5fe..668bfc51 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 5fb07bed..c5702f28 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 dd90d890..2c15e98f 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 diff --git a/app/src/main/res/values-cy/strings.xml b/app/src/main/res/values-cy/strings.xml index 2c30322e..f83df624 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 c2ea3ae1..7515ccd9 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -2,11 +2,11 @@ Ein Fehler ist aufgetreten. Ein Netzwerkfehler ist aufgetreten! Bitte überprüfen Sie Ihre Internetverbindung und versuchen Sie es erneut! - Dies darf nicht leer sein. + Das darf nicht leer sein. Ungültige Domain angegeben Authentifizieren mit dieser Instanz fehlgeschlagen. Kein Webbrowser gefunden. - Ein undefinierbarer Autorisierungsfehler ist aufgetreten. + Ein unbekannter Fehler ist bei der Autorisierung aufgetreten. Autorisierung fehlgeschlagen. Es konnte kein Login-Token abgerufen werden. Der Beitrag ist zu lang! @@ -16,7 +16,7 @@ Die Datei konnte nicht geöffnet werden. Eine Leseberechtigung wird für das Hochladen der Mediendatei benötigt. Eine Berechtigung wird zum Speichern des Mediums benötigt. - Bilder und Videos können nicht beide gleichzeitig an einen Beitrag angehängt werden. + Bilder und Videos können nicht an den gleichen Beitrag angehängt werden. Die Mediendatei konnte nicht hochgeladen werden. Fehler beim Senden des Beitrags. Start @@ -62,7 +62,7 @@ Favorisierung entfernen Mehr Beitrag erstellen - Anmelden mit Mastodon + Mit Mastodon anmelden Ausloggen Bist du sicher, dass du dich aus dem Konto %1$s ausloggen möchtest\? Folgen @@ -101,7 +101,7 @@ Akzeptieren Ablehnen Suche - Entwürfe + Entwürfe Beitragssichtbarkeit Inhaltswarnung Emoji @@ -117,7 +117,7 @@ Medium #%d öffnen %1$s heruntergeladen Link kopieren - Öffnen als %s + Öffne als %s Teilen als … Beitragslink teilen… Beitragsinhalt teilen… @@ -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 @@ -334,11 +334,11 @@ Suche nach Leuten denen du folgst Von der Liste entfernen Hashtag ohne # - Öffne Autor des geteilten Beitrages + Boost Autor öffnen Öffentliche Zeitleisten - %1$s Favorit - %1$s Favoriten + <b>%1$s</b> Favorit + <b>%1$s</b> Favoriten Geteilt von Medien: %s @@ -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 @@ -461,8 +461,8 @@ Oben %s nicht mehr verstecken - %s Boost - %s Boosts + <b>%s</b> Boost + <b>%s</b> Boosts Hauptnavigations-Position Benachrichtigungen ausblenden @@ -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,14 @@ 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. + Lesezeichen entfernen + Bestätigungsdialog vor dem Favorisieren eines Beitrags + Diese Unterhaltung wirklich löschen\? + Unterhaltung löschen \ 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 2529448b..4f99bbf6 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 @@ -217,6 +217,7 @@ %1$s, %2$s, kaj %3$s %1$s kaj %2$s + %d nova interago %d novaj interagoj Ŝlosita konto @@ -343,7 +344,8 @@ %1$s kaj %2$s %1$s, %2$s kaj %3$d aliaj - maksimuma nombro %1$d da langetoj atingita + maksimumo da %1$d langeto atingita + maksimumo da %1$d langetoj atingita Aŭdovidaĵo: %s @@ -394,7 +396,7 @@ Ĉu vi certas ke vi volas tute bloki %s\? Vi ne vidos enhavon de tiu domajno en publika tempolinio aŭ en viaj sciigoj. Viaj sekvantoj de tiu domajno estos forigitaj. Kaŝi la tutan domajnon La aktuala emoĝiaro de Google - Balotenketo kun elektoj: %s, %s, %s, %s; %s + Balotenketo kun elektoj: %1$s, %2$s, %3$s, %4$s, %5$s Daŭrigi Reveni Farita @@ -438,7 +440,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 @@ -488,4 +490,40 @@ Estas neniu anonco. Ebligi ŝovumadon por ŝanĝi inter la langetoj Mastodon havas minimuman intervalon de planado de 5 minutoj. + Kunsendaĵoj + iu kiun mi sekvas afiŝis novan mesaĝon + Ĉu vi vere volas forigi la liston %s\? + Aŭdio + Aboni + Malneto forigita + + Vi ne povas elŝuti pli ol %1$d aŭdovidaĵa kunsendaĵo. + Vi ne povas elŝuti pli ol %1$d aŭdovidaĵaj kunsendaĵoj. + + Daŭro + Nedefinita + Malaboni + Novaj mesaĝoj + Forigi la legosignon + Ĉu forigi ĉi-tiun konversacion\? + Animacii proprajn emoĝiojn + Kaŝi kvantecajn statistikaĵojn sur la profiloj + Forigi konversacion + %s ĵus afiŝis + Sciigoj kiam iu kiun vi sekvas afiŝis novan mesaĝon + Sendo de ĉi-tiu mesaĝo malsukcesis! + Kaŝi kvantecajn statistikaĵojn sur la mesaĝoj + Demandi konfirmon antaŭ ol stelumi + Bonstato + Ŝarĝado de respondaj informoj malsukcesis + Kelkaj informoj kiuj povas afekci vian mensan bonstaton estos kaŝitaj. Ĉi tiuj inkluzivas: +\n +\n - Sciigoj pri stelumo/diskonigo/sekvado +\n- Nombro de stelumoj/diskonigoj sur la mesaĝoj +\n- Statistikoj pri mesaĝoj/sekvantoj sur la profiloj +\n +\n Puŝosciigoj ne estos influitaj, sed vi povas kontroli viajn sciigojn preferojn permane. + Kontroli la sciigojn + Limigi sciigojn pri tempolinio + La mesaĝo al kiu ĉi tiu malneto respondas estis forigita \ No newline at end of file diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 93127439..5bc4dd3b 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 @@ -204,6 +204,7 @@ %1$s, %2$s, y %3$s %1$s y %2$s + %d nueva interacción %d nuevas interacciones Cuenta protegida @@ -313,6 +314,7 @@ %1$s y %2$s %1$s, %2$s y %3$d más + máximo de %1$d pestaña alcanzada máximo de %1$d pestañas alcanzadas Menciones @@ -451,7 +453,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 @@ -471,7 +473,7 @@ Etiquetas Añadir etiqueta %s solicita seguirte - Fondo + Abajo Arriba Posición de navegación principal Mostrar degradados colorido para los medios ocultos @@ -487,7 +489,8 @@ Anuncios %s recién publicado - No puedes cargar más de %1$d archivos adjuntos multimedia. + No puedes cargar más de %1$d archivo multimedia adjunto. + No puedes cargar más de %1$d archivos multimedia adjuntos. Esconder las estadísticas cuantitativas de los perfiles Esconder las estadísticas cuantitativas de las publicaciones @@ -496,24 +499,29 @@ Notificaciones cuando alguien al que estoy suscrito publicó un nuevo toot Nuevos toots alguien al que estoy suscrito publicó un nuevo toot - Algunas informaciones que podríam afectar tu bienestar van a ser ocultas. Esto incluye: -\n -\n- Notificaciones de favoritos, impulsos e seguidores -\n- Conteo de favoritos e impulsos en toots + Algunas informaciones que podríam afectar tu bienestar van a ser ocultas. Esto incluye: +\n +\n- Notificaciones de favoritos, impulsos y seguidores +\n- Conteo de favoritos e impulsos en toots \n- Estadísticas de seguidores e toots en perfiles -\n +\n \nLas notificaciones Push no serán afectadas, pero puedes revisar manualmente tus preferencias. 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 Duración Adjuntos Audio - Limitar notificaciones de cronología + Limitar cronología de notificaciones + Quitar marcador + Aunque su cuenta no está bloqueada, el personal de %1$s pensó que podría querer revisar las solicitudes de seguimiento de estas cuentas manualmente. + Suscribir + ¿Eliminar esta conversación\? + Animar emojis personalizados + Darse de baja + Eliminar conversación + Mostrar diálogo de confirmación antes de marcar como favorito \ No newline at end of file diff --git a/app/src/main/res/values-eu/strings.xml b/app/src/main/res/values-eu/strings.xml index 7362a720..52722060 100644 --- a/app/src/main/res/values-eu/strings.xml +++ b/app/src/main/res/values-eu/strings.xml @@ -2,7 +2,7 @@ Errorea gertatu da. Eremu hau ezin da hutsik egon. - Domeinu izen okerra. + Domeinu baliogabea sartu da Akatsa saioa hasterakoan. Ez da web nabigatzailerik aurkitu. Identifikatu gabeko baimentza akatsa gertatu da. @@ -93,7 +93,7 @@ Onartu Ukatu Bilatu - Zirriborroak + Zirriborroak Tutaren ikusgarritasuna Edukiaren abisua Emoji teklatua @@ -104,10 +104,10 @@ Partekatu media hona… Bidalia! Erabiltzailea desblokeatuta - Iada erabiltzailea ez dago isilarazia. + Erabiltzailea isilgabetuta Bidalia! Erantzuna ongi bidali da. - Instantzia hautatu + Zein instantzia\? Zer duzu buruan? Edukiaren abisua Agertuko den izena @@ -119,13 +119,13 @@ Goiburua Zer da instantzia? Konektatzen… - Sartu hemen helbidea edo mastodon.eus, mastodon.jalgi.eus, mastodon.social bezalako
edozein instantzia, + Edozein instantziaren helbidea edo domeinua hemen sar daiteke, hala nola mastodon.eus, mastodon.jalgi.eus, mastodon.social eta gehiago!, \n -\n Oraindik ez baduzu konturik, instantziaren izena sartu eta bertan kontua sortu dezakezu. +\nOraindik konturik ez baduzu, sartu nahi duzun instantziaren izena sar dezakezu eta bertan sortu kontua. \n -\nInstantzia zure kontua dagoen gunea da, baino beste instantzietako erabiltzaileak zurean egongo balira bezala jarraitu ditzakezu. +\nInstantzia zure kontua ostatatzen den leku bakarra da, baina beste instantzia batzuetako jendearekin erraz komunikatu eta jarrai dezakezu gune berean egongo bazina bezala. \n -\nInformazio gehiago joinmastodon.org helbidean topatuko duzu. +\nInformazio gehiago joinmastodon.org webgunean aurki daiteke.
Mediaren igoera bukatzen Igotzen… Jaitsi @@ -142,7 +142,7 @@ Soinuarekin jakinarazi Bibrazioarekin jakinarazi Led-arekin jakinarazi - Noiz jakinarazi: + Jakinarazi noiz Aipatzen naute Jarraitzen didate Bultzatzen naute @@ -193,6 +193,7 @@ %1$s, %2$s eta %3$s %1$s eta %2$s + interakzio berri %d %d interakzio berri Kontu babestua @@ -245,7 +246,7 @@ Kontua babestu Jarraitzaileak eskuz onartu beharko dituzu Zirriborroa gorde? - Tuta ezabatzen… + Tuta bidaltzen… Errorea tuta bidaltzerakoan Tuta bidaltzen Bidalketa ezeztatua @@ -308,7 +309,7 @@ Estekak Ireki media #%d %s bezala ireki - ... bezala partekatu + … bezala partekatu Media jaisten Media jaisten %s ez dago ezkutatua @@ -353,12 +354,12 @@ CC-BY 4.0 CC-BY-SA 4.0 - <b>%1$s</b> Gogoko - <b>%1$s</b> Gogoko + Gogoko %1$s + %1$s Gogoko - <b>%s</b> Bultzada - <b>%s</b> Bultzada + Bultzada %s + %s Bultzada Bultzatuta Gogokoa @@ -366,6 +367,7 @@ %1$s eta %2$s %1$s, %2$s eta %3$d gehiago + gehienezko %1$d fitxa iritsita gehienezko %1$d fitxa iritsita Media: %s @@ -390,7 +392,7 @@ %1$s • %2$s Boto %s - %s Boto + %s boto %s amaitzen da Itxita @@ -444,7 +446,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 @@ -454,7 +456,7 @@ Mututu elkarrizketa %s(e)k zu jarraitzeko eskatu dizu Traolak - Ez erakutsi jakinarazpenak + Jakinarazpenak ezkutatu Desmututu %s Ezkutatu goiko tresna-barraren izenburua Erakutsi berrespen-abisua tuta bultzatu aurretik @@ -474,4 +476,49 @@ Desmututu %s Mututu %s(r)en jakinarazpenak Desmututu %s(r)en jakinarazpenak + Jakinarazpenak berrikusi + Erakutsi baieztapen elkarrizketa-koadroa gogokoenetara gehitu aurretik + Zure kontua blokeatuta ez badago ere, %1$s-ko langileek kontu hauetako eskaerak eskuz berrikusi nahi dituzula pentsatu dute. + harpidedun naizen norbaitek tut berria argitaratu du + Eranskinak + Ziur %s zerrenda ezabatu nahi duzula\? + Audioa + Harpidetu + Elkarrizketa ezabatu nahi duzu\? + Animatu emoji pertsonalizatuak + Erantzunaren informazioa ezin izan da kargatu + Profiletan estatistika kuantitatiboak ezkutatu + Zirriborroa ezabatu da + Erantzuna idatzi zenuen tuta ezabatu da + Ongizatea + + Ezin duzu multimedia eranskin %1$d baino gehiago kargatu. + Ezin dituzu %1$d multimedia eranskin baino gehiago kargatu. + + Denbora-lerroaren jakinarazpenak mugatu + Iraupena + Zehaztugabea + Harpidetza kendu + Elkarrizketa ezabatu + Ez daude iragarkirik. + %s argitaratu berri du + Gordeta! + Tut berriak + + Pertsona %s + %s pertsona + + Iragarpenak + Jakinarazpenak harpidetuta zauden norbaitek tut berria argitaratu duenean + Kontu honi buruzko zure ohar pribatua + Tut honek ezin izan du bidali! + Zure ongizate mentalean eragina izan dezaketen zenbait informazio ezkutatuta egongo dira. Honek honako hauek ditu: +\n +\n - Gogokoak, bultzadak eta jarraitzaileen jakinarazpenak +\n - Tutetan gogokoen eta bultzaden kopurua +\n - Profiletan jarraitzaileen eta argitalpenen estatistikak +\n +\nPush-jakinarazpenek ez dute eraginik izango, baina jakinarazpenen hobespenak eskuz berrikus ditzakezu. + Mezuetan estatistika kuantitatiboak ezkutatu + Laster-marka kendu \ No newline at end of file diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 50f987e2..538f5afe 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -56,7 +56,6 @@ ایجاد ورود با ماستودون خروج - مطمئنید می‌خواهید از حساب %1s خارج شوید؟ پیگیری ناپیگیری انسداد @@ -93,7 +92,7 @@ پذیرش رد جست‌وجو - پیش‌نویس‌ها + پیش‌نویس‌ها نمایانی بوق هشدار محتوا صفحه‌کلید اموجی @@ -439,7 +438,7 @@ نشان‌شده گزینش فهرست فهرست - هیچ پیش‌نویسی ندارید. + هیچ پیش‌نویسی ندارید. هیچ وضعیت زمان‌بسته‌ای ندارید. ماستودون، بازهٔ زمان‌بندی‌ای با کمینهٔ ۵ دقیقه دارد. نمایش گفت‌وگوی تأیید، پیش از تقویت @@ -485,7 +484,6 @@ عدم اشتراک اشتراک پیش‌نویس حذف شد - پیش‌نویس‌های قدیمی فرستادن این بوق شکست خورد! نهفتن آمار کمی روی نمایه‌ها نهفتن آمار کمی روی فرسته‌ها @@ -509,8 +507,6 @@ \n - آمار پی‌گیر و فرسته روی نمایه‌ها \n \n فرستادن آگاهی‌ها تأثیر نمی‌پذیرد، ولی می‌توانید ترجیحات آگاهیتان را به صورت دستی بازبینی کنید.
- ویژگی پیش‌نویس در تاسکی به صورت کامل بازطرّاحی شده تا سریع‌تر، کاربرپسندتر و کم‌مشکل‌تر باشد. -\n همجنان می‌توانید از طریق دکمه‌ای دز صفحهٔ پیش‌نویس‌های جدید، به پیش‌نویس‌های قدیمیتان دسترسی داشته باشید، ولی در به‌روز رسانی آینده برداشته خواهند شد! واقعاً می‌خواهید فهرست %s را حذف کنید؟ نمی‌توانید بیش از %1$d رسانه بارگذارید. @@ -518,4 +514,6 @@ نامعیّن با این که حسابتان قفل نیست، کارکنان %1$s فکر کردند ممکن است بخواهید درخواست‌های پی‌گیری از این حساب‌ها را دستی بازبینی کنید. + حذف این گفت‌وگو؟ + حذف گفت‌وگو \ No newline at end of file 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 00000000..3638aeaf --- /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 729fdc1e..4707dc17 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -18,7 +18,7 @@ Permission requise pour enregistrer le média. Un même pouet ne peut contenir à la fois une vidéo et une image. Échec d’envoi du média. - Erreur lors de l’envoi du pouet. + Erreur lors de l’envoi du message. Accueil Notifications Local @@ -53,7 +53,7 @@ %s a ajouté votre pouet à ses favoris %s vous suit Signaler @%s - Commentaires additonnels ? + Commentaires additionnels \? Réponse rapide Répondre Partager @@ -101,7 +101,7 @@ Accepter Refuser Rechercher - Brouillons + Brouillons Visibilité du pouet Contenu sensible Clavier d’émojis @@ -197,7 +197,7 @@ Port du proxy HTTP Confidentialité par défaut Toujours marquer les médias comme sensibles - Publication + Publication (synchronisée avec le serveur) Échec de synchronisation des paramètres Public Non listé @@ -221,6 +221,7 @@ %1$s, %2$s et %3$s %1$s et %2$s + %d nouvelle interaction %d nouvelles interactions Compte verrouillé @@ -259,7 +260,7 @@ %dm %ds Vous suit - Toujours afficher le contenu sensible + Toujours afficher les contenus sensibles Média Réponse à @%s en charger plus @@ -348,7 +349,8 @@ %1$s et %2$s %1$s, %2$s et %3$d autres - nombre maximum d\'onglets %1$d atteint + maximum de %1$d onglet atteint + maximum de %1$d onglets atteint Média : %s @@ -456,14 +458,14 @@ 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 Bloquer @%s \? - Afficher une fenêtre de confirmation avant de partager + Demander confirmation avant de partager Afficher les aperçus des liens dans les fils - %s a demandé à vous suivre + %s vous a envoyé une demande d’abonnement Notifications à propos des demandes d’abonnement on demande à me suivre Mettre en sourdine @%s \? @@ -501,16 +503,34 @@ \n - des statistiques sur les profils \n \n Les notifications \"push\" ne seront pas affectées, mais vous pouvez revoir vos préférences de notification manuellement.
- une personne à laquelle je suis abonné a publié un nouveau pouet + une personne que je suis a publié un nouveau pouet %s vient de publier Examiner les notifications - Nouveau pouets + Nouveaux pouets + Vous ne pouvez pas téléverser plus de %1$d pièce jointe. Vous ne pouvez pas téléverser plus de %1$d pièces jointes. Bien-être Notifications quand quelqu\'un que vous suivez publie un nouveau pouet Limiter les notifications de la timeline Cacher les statistiques quantitatives sur les profils - Cacher les statistiques quantitatives sur les publications + Cacher les statistiques quantitatives sur les messages + Supprimer le marque-page + Pièces jointes + Voulez-vous vraiment supprimer la liste %s \? + S’abonner + Supprimer cette conversation \? + Animer les émojis personnalisés + Brouillon supprimé + Durée + Indéfinie + Se désabonner + Supprimer la conversation + Audio + Demander confirmation avant de mettre en favoris + Le message auquel répondait ce brouillon a été supprimé + Échec d’envoi du pouet ! + Bien que votre compte ne soit pas verrouillé, l\'équipe de %1$s a pensé que vous voudriez valider manuellement les demandes de suivi provenant de ces comptes. + Échec du chargement des informations de réponse \ No newline at end of file diff --git a/app/src/main/res/values-fy/strings.xml b/app/src/main/res/values-fy/strings.xml new file mode 100644 index 00000000..69cc8b7d --- /dev/null +++ b/app/src/main/res/values-fy/strings.xml @@ -0,0 +1,269 @@ + + + Ûnjildich domein ynfierd + Dit mei net leech wêze. + Systeem standert + Emoji styl + Nei it klemboerd kopiearre + Gearstelle + Ferstjoeren ôfbrutsen + Toots oan it ferstjoeren + Flater by it ferstjoeren fan toot + Toot oan it ferstjoeren… + Skets bewarje\? + Fuortsmite + Ûnderskrift pleatse + Koe ûnderskrift net pleatse + Account oan de list tafoegje + Sykje om minsken dy\'t jo folgje + Pas de list oan + Smyt de list fuort + Neam de list om + Meitsje in list oan + Koe list net fuortsmite + Koe list net omneame + Koe list net oanmeitsje + Listen + Listen + Nij Mastodon Account Tafoegje + Account Tafoegje + Fernije + Fuortsmite + Filter oanpasse + Filter tafoegje + Petearen + mear lade + Oan it reagearren op @%s + Media + Altyd gefoeliche ynhâld sjen litte + Folget jo + %dy + oer %ds + oer %dm + oer %dh + oer %dd + Taheaksels + Lûd + Fideo + Ôfbyldingen + Keppeling nei toot diele + Ynhâld fan toot diele + Tusky %s + Oer + + %d nije ynteraksje + %d nije ynteraksjes + + %1$s en %2$s + %1$s, %2$s, en %3$s + Nije toots + Favoriten + Folgfersyken + Nije Folgers + Grutst + Grut + Gewoan + Lyts + Lytst + Allinnich folgers + Iepenbier + Ûnder + Boppe + Koe ynstellingen net syngronisearje + Media altyd as gefoelich oanmerke + HTTP proksje poarte + HTTP proksje tsjinner + HTTP proksje ynskeakelje + HTTP proksje + Proksje + Media foarfertoaningen delhelje + Reaksjes sjen litte + Ljepblêden + Taal + Webblêder + Systeem Opmaak Brûke + Automatysk as de sinne ûnder giet + Swart + Ljocht + Tsjuster + Filters + Applikaasje Tema + Uterlik + Ien dy\'t ik folgje hat in nije toot pleatst + Myn berjochten bin as favoryt oanmurken + Folgfersyk + Folgers + beneamd + Op\'e hichte steld wurde mei in ljochtsje + Op\'e hichte steld wurde mei in trilling + Op\'e hichte stelt wurde mei in lûdsje + Notifikaasjes + Notifikaasjes + Notifikaasjes ferbergje + \@%s negearje\? + \@%s blokkearje\? + Folsleine domein ferbergje + Dit petear fuortsmite\? + Dizze toot fuortsmite en opnij opstelle\? + Dizze toot fuortsmite\? + Dit account net mear folgje\? + Folgfersyk ynlûke\? + Delhelje + Oan it uploaden… + It Uploaden fan Media oan it Ôfrûnjen + Oan it ferbinen… + Reagearre… + Gjin resultaten + Sykje… + Ynhâld warskôging + Wat bard der\? + Reaksje mei sukses ferstjoerd. + Ferstjoerd! + %s net mear ferburgen + Brûker net mear negearre + Brûker net mear blokkearre + Ferstjoerd! + Media ferstjoere nei… + Toot ferstjoere nei… + Toot URL ferstjoere nei… + Media oan it delheljen + Media delhelje + Diele as… + Iepenje as %s + Keppeling kopiearje + Oan it delheljen fan %1$s + Media #%d iepenje + Keppelingen + Favoriten besjen + Keppelingen + Ljepblêd Tafoegje + Toot ynplanne + Emoji toetseboerd + Ynhâld warskôging + Toot sichtberheid + Ynplanne toots + Sketsen + Sykje + Net akseptearje + Akseptearje + Ûngedien meitsje + Oanpasse + Profyl oanpasse + Bewarje + Laad iepenje + Media ferbergje + Beneame + Petear net mear negearre + Petear negearre + %s net mear negearre + %s negearre + Notifikaasjes fan %s negearre + Notifikaasjes fan %s net mear negearre + %s net mear negearre + Net mear negearre + Negearre + Diele + Foto nimme + Fragelist tafoegje + Media tafoegje + Yn webblêder iepenje + Media + Folgfersyken + Ferburgen domeinen + Blokkearre brûkers + Negearre brûkers + Blêdwiizers + Favoriten + Account Foarkarren + Foarkarren + Profyl + Slute + Opnij probearje + TOOT! + TOOT + Fuortsmite en opnij opstelle + Petear fuortsmite + Fuortsmite + Oanpasse + Oanjaan + Net mear blokkearje + Blokkearje + Net mear folgje + Folgje + Útlogge + Ynlogge mei Mastodon + Gearstelle + Mear + Net mear as favoryt oanmerke + As favoryt oanmerke + Reagearje + Flugge Reaksje + Oanfoljende opmerkingen\? + Jou @%s oan + %s hat krekt in berjocht pleatst + %s fersiket jo te folgjen + %s folget jo + %s hat jo toot as favoryt oanmurken + Hjir is neat. Lûk nei ûnderen om te ferfarskjen! + Hjir is neat. + Yntearre + Ûttearre + Minder sjen litte + Mear sjen litte + Klik om te besjen + Media ferburgen + Gefoelige ynhâld + \@%s + Lisinsjes + Ynplanne toots + Sketsen + Jo profyl oanpasse + Folgfersyken + Ferburgen domeinen + Blokkearre brûkers + Negearre brûkers + Blêdwiizers + Favoriten + Folgers + Folget + Fêstset + Mei reaksjes + Berjochten + Toot + Ljepblêden + Direkte Berjochten + Federearre + Lokaal + Notifikaasjes + Thús + Flater by it ferstjoeren fan de toot. + De upload is mislearre. + Ôfbyldingen en fideo\'s kinne net beide taheake wêze oan deselde status. + Tastimming om media op te slaan is nedich. + Tastimming om media te lêzen is nedich. + Die triem koe net iepene wurde. + Dat type triem kin net upload wurde. + Lûdstriemen moatte lytser as 40MB wêze. + Fideo\'s moatte lytse as 40MB wêze. + De triem moat lytser as 8MB wêze. + De status is te lang! + Koe gjin ynlogtoken krije. + Ferifikaasje ôfkard. + Der die harren in net definiearre flater foar. + Koe gjin webblêder fine om te brûken. + In netwurk flater die harren foar! Kontrolearje jo ferbining en probearje it noch ris! + Der die harren in flater foar. + Profylôfbylding + Oer dy + Hashtags + Boosts sjen litte + Auteur fan boost iepenje + Hashtags + Nei standert ynstelling weromsette + Boosts sjen litte + Boosts ferburgje + Boost fuorthelje + Boost + %s hat dyn toot boost + Oankundigingen + \ No newline at end of file diff --git a/app/src/main/res/values-ga/strings.xml b/app/src/main/res/values-ga/strings.xml index 6e7cba17..9410b503 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 @@ -301,11 +301,11 @@ An bhfuil tú cinnte gur mhaith leat do chuid fógraí go léir a ghlanadh go buan\? Tá deireadh le vótaíocht a chruthaigh tú - D\'imigh $d nóiméad - D\'imigh $d nóiméad - D\'imigh $d nóiméad - D\'imigh $d nóiméad - D\'imigh $d nóiméad + D\'imigh %d nóiméad + D\'imigh %d nóiméad + D\'imigh %d nóiméad + D\'imigh %d nóiméad + D\'imigh %d nóiméad Theip ar stádas a fháil Seolfar an tuarascáil chuig do mhodhnóir freastalaí. Féadfaidh tú míniú a thabhairt ar an bhfáth go bhfuil tú ag tuairisciú an chuntais seo thíos: @@ -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 13d53a29..2dd5c4b9 100644 --- a/app/src/main/res/values-gd/strings.xml +++ b/app/src/main/res/values-gd/strings.xml @@ -47,7 +47,7 @@ Sgrìobh Seall na brosnachaidhean Seall na brosnachaidhean - Dreachdan + Dreachdan Annsachdan Brathan Brathan @@ -58,10 +58,7 @@ 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 am post a chur! + Cha b’ urrainn dhuinn an dùd a chur! Ceanglachain Fuaim A bheil thu cinnteach gu bheil thu airson an liosta %s a sguabadh às\? @@ -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 @@ -224,14 +221,14 @@ Fo cheadachas Apache License (chì thu lethbhreac dheth gu h-ìosal) Tha còs is maoin o na pròiseactan open source seo am broinn Tusky: Na brosnaich tuilleadh - Brosnaich dhan èisteachd tùsail + Brosnaich dhan èisteachd thùsail Chaidh %1$s a ghluasad gu: Robotair Dh’fhàillig an luchdadh a-nuas Seata làithreach nan Emoji aig Google Seata stannardach nan Emoji aig Mastodon Emojis Blob aig Android 4.4–7.1 - Seata tùsail nan Emojis air an uidheam agad + Seata bunaiteach nan Emojis air an uidheam agad Ath-thòisich Uaireigin eile Feumaidh tu Tusky ath-thòiseachadh gus na roghainnean seo a chur an sàs @@ -338,10 +335,10 @@ Tusky %s Cunntas glaiste - %d eadar-ghabhail ùr - %d eadar-ghabhail ùr - %d eadar-ghabhailean ùra - %d eadar-ghabhail ùr + %d chonaltradh ùr + %d chonaltradh ùr + %d conaltraidhean ùra + %d conaltradh ùr %1$s ’s %2$s %1$s, %2$s ’s %3$s @@ -371,7 +368,7 @@ Dh’fhàillig le sioncronachadh nan roghainnean ’Ga fhoillseachadh (ga shioncronachadh le frithealaiche) Cuir comharra ri meadhanan an-còmhnaidh gu bheil iad frionasach - Prìobhaideachd thùsail nam post + Prìobhaideachd bhunaiteach nam post Port progsaidh HTTP Frithealaiche progsaidh HTTP Cuir an comas a’ phrogsaidh HTTP @@ -533,4 +530,8 @@ 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. + A bheil thu airson an còmhradh seo a sguabadh às\? + Sguab às an còmhradh + Thoir an comharra-lìn air falbh + Ceall còmhradh dearbhaidh mus dèid post ’na annsachd \ 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 bbba46cf..dbf0f5b8 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 @@ -159,8 +159,8 @@ quedan %d horas - queda %s día - quedan %s días + queda %d día + quedan %d días Rematou unha enquisa creada por ti Rematou unha enquisa na que votaches @@ -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,8 @@ 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 + Eliminar marcador + Mostrar diálogo de confirmación antes de favorecer \ 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 a99791f9..2ed1f08f 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 9c07a49d..8a8a6753 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 @@ -235,7 +235,7 @@ 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\? @@ -520,4 +517,8 @@ 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. + Töröljük ezt a beszélgetést\? + Beszélgetés törlése + Könyvjelző törlése + Jóváhagyás mutatása kedvencnek jelölés előtt \ No newline at end of file diff --git a/app/src/main/res/values-is/strings.xml b/app/src/main/res/values-is/strings.xml index 7a80ba9c..ae33eecc 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 @@ -416,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. @@ -475,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 @@ -490,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óð @@ -511,4 +508,8 @@ 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. + Fjarlægja bókamerki + Birta staðfestingarglugga áður en sett er í eftirlæti + Eyða þessu samtali\? + Eyða samtali \ 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 b940b5f3..216e0fa0 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 19ad213c..f9d868fe 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -98,7 +98,7 @@ 許可 拒否 検索 - 下書き + 下書き トゥートの公開範囲 注意書き 絵文字キーボード @@ -411,7 +411,7 @@ %sさんがあなたにフォローリクエストしました \@%sさんを通報しました 予約した投稿はありません。 - 下書きはありません。 + 下書きはありません。 項目 %d このアカウントは外部のサーバーにあります。匿名化された通報の複製をそちらにも送信しますか? 通報をサーバーのモデレーターに送信します。以下にこのアカウントを通報理由を入力できます: @@ -464,4 +464,8 @@ Mastodonにおける予約までの最小間隔は5分です。 %sさんがトゥートしました お知らせ + 本当に %s のすべてをブロックするのですか? そのドメインからのコンテンツは、公開タイムラインや通知に表示されなくなります。また、そのドメインのフォロワーは削除されます。 + 音声 + ドメイン全体を非表示 + Tuskyによって提供されています \ No newline at end of file diff --git a/app/src/main/res/values-kab/strings.xml b/app/src/main/res/values-kab/strings.xml index ece80a67..c467920c 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\? @@ -198,8 +198,8 @@ Assenqed 5 n tisdidin 30 n tisdidin - 1 n usrag - 6 n isragen + 1 n wesrag + 6 n yisragen 1 n wass 3 n wussan 7 n wussan diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 6b6fff2c..f0255d70 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 4f4a7868..86549097 100644 --- a/app/src/main/res/values-ml/strings.xml +++ b/app/src/main/res/values-ml/strings.xml @@ -106,10 +106,59 @@ പിന്തുടാനുള്ള അപേക്ഷകൾ ബൂട്ട്‌സ് കാണിക്കുക മുന്‍നിശ്ചയിച്ച ടൂറ്റ്‌സ് - കരടുകൾ + കരടുകൾ തിരുത്ത് അറിയിപ്പുകൾ ടാബുകൾ അറിയിപ്പുകൾ പ്രഖ്യാപനങ്ങൾ + പിന്നീട് + സംരക്ഷിച്ചു! + %s ബൂസ്റ്റ് ചെയ്തു + ഫലങ്ങൾ ഒന്നും ഇല്ല + അയച്ചൂ! + പങ്കിടുക + ബ്രൗസർ + പുതിയ സൂചനകൾ + ലിങ്കുകൾ + ബൂസ്റ്റ് ചെയ്യേണ്ട + പ്രയോഗിക്കുക + അക്കൗണ്ട് ചേർക്കുക + സൂചിപ്പിച്ചു + യന്ത്രം‍ + രൂപം + നിങ്ങളെ പിന്തുടരുന്നു + ഫോട്ടോ എടുക്കുക + തിരയുക… + ചിത്രങ്ങൾ + കൂടുതൽ ലഭ്യമാക്കുക + സൂചനകൾ + ബയോ + %1$s + സംഭാഷണങ്ങൾ + മറുപടി… + നിരസിക്കുക + കറുപ്പ് + തുടരുക + സമയരേഖകൾ + പ്രോക്സി + മുന്നറിയിപ്പുകൾ + മായ്ക്കുക + നീക്കം ചെയ്യുക + ബൂസ്റ്റുകൾ + മീഡിയ ചേർ‍ക്കുക + അക്കൗണ്ടുകൾ + മറുപടികൾ കാണിക്കൂ + സംരക്ഷിക്കുക + ഫിൽടറുകൾ + ഭാഷ + ഉള്ളടക്കം + സൂചനകൾ + പുതുക്കുക + അവതാർ + ലിങ്കുകൾ + \@%s + വിഡിയോ + സൂചിപ്പിക്കുക + നീക്കം ചെയ്യുക \ No newline at end of file diff --git a/app/src/main/res/values-night/theme_colors.xml b/app/src/main/res/values-night/theme_colors.xml index 78454a4e..1054b5ee 100644 --- a/app/src/main/res/values-night/theme_colors.xml +++ b/app/src/main/res/values-night/theme_colors.xml @@ -15,6 +15,7 @@ @color/tusky_grey_70 @color/tusky_grey_30 + @color/tusky_grey_50 @color/tusky_grey_25 @color/tusky_orange diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 2299cce8..0b987231 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -101,7 +101,7 @@ Goedkeuren Afwijzen Zoeken - Concepten + Concepten Zichtbaarheid toot Tekstwaarschuwing Emojis @@ -395,7 +395,7 @@ GIF-avatars animeren Google\'s huidige emojiset Weet je zeker dat je alles van %s wilt blokkeren\? Je zult op alle openbare tijdlijnen en in jouw meldingen geen inhoud van dat domein zien. Jouw volgers van dat domein worden verwijderd. - Poll met keuzes: %s, %s, %s, %s; %s + Poll met keuzes: %1$s, %2$s, %3$s, %4$s; %5$s Acties voor afbeelding %s %d dag te gaan @@ -456,8 +456,8 @@ Zoeken mislukt Poll Fout tijdens opzoeken toot %s - Je hebt nog geen concepten. - Je hebt nog geen ingeplande toots. + 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 @@ -513,7 +513,16 @@ Concept verwijderd Kwantitatieve statistieken voor toots verbergen Laden van reactie-informatie mislukt - Oude concepten Kwantitatieve statistieken in profielen verbergen Hoofd navigatiepositie + Dit gesprek verwijderen\? + Gesprek verwijderen + Ook al heb je geen besloten account, de medewerkers van %1$s dachten dat je misschien de volgverzoeken van deze accounts handmatig zou willen controleren. + Bepaalde informatie die invloed kan hebben op uw geestelijk welzijn zal worden verborgen. Dit bevat onder andere: +\n +\n- Favoriet/Boost/Volg notificaties +\n- Favoriet/Boost/Aantal boosts per toot +\n- Volger/Bericht statistieken op profielen +\n +\nPush-notificaties zullen niet worden beïnvloed, maar uw kunt uw notificatie voorkeuren handmatig wijzigen. \ 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 13a6e7e5..033f4578 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,13 @@ 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 + Slett bokmerke + Vis bekreftelsesdialog når favoritt skal legges til \ 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 c3647aac..4b6a27ee 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 @@ -186,6 +186,7 @@ %1$s, %2$s e %3$s %1$s e %2$s + %d interaccion nòva %d interaccions nòvas Compte blocat @@ -345,6 +346,7 @@ %1$s e %2$s %1$s, %2$s e %3$d mai + nombre maximum d’onglet %1$d atengut nombre maximum d’onglets %1$d atengut Mèdia : %s @@ -450,7 +452,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 @@ -459,4 +461,53 @@ Activar las notificacions per %s Amagar pas mai a %s %s a demandat a vos seguir + Mostrar los apercebuts dels ligams + Notificacions quand qualqu’un que seguissètz publica un tut novèl + Suprimir aquesta conversacion \? + %s ven de publicar + Quitar de seguir + Seguir + Borrolhon suprimit + Fracàs del cargament de las info de responsa + Fracàs de l’enviament ! + Volètz vertadièrament suprimir la lista %s \? + Repassar las notificacions + Enregistrat ! + Vòstra nòta privada tocant aqueste compte + I a pas cap d’anóncia. + Infinit + Durada + + %s persona + %s personas + + Apondre hashtag + Pèças juntas + Àudio + Tuts novèls + Notificacions de demandas de seguiment + Enbàs + Ennaut + Posicion de navigacion principala + Emoji animats personalizats + Afichar un degradat colorat pels mèdias amagats + qualqu’un que seguissi a publicat un tut novèl + abonament demandat + Amagar @%s \? + Blocar @%s \? + Amagar las notificacions + Amagar pas mai la conversacion + Amagar la conversacion + Amagar pas mai a %s + Suprimir la conversacion + Anóncias + + Podètz pas enviar mai de %1$d pèça junta. + Podètz pas enviar mai de %1$d pèças juntas. + + Amagar las estatisticas dels perfils + Amagar las estatisticas dels tuts + Limitar las notificacions de la cronologia + Amagar lo títol ennaut de la barra + Afichar una fenèstra de confirmacion abans de partejar \ No newline at end of file diff --git a/app/src/main/res/values-pa/strings.xml b/app/src/main/res/values-pa/strings.xml deleted file mode 100644 index a6b3daec..00000000 --- a/app/src/main/res/values-pa/strings.xml +++ /dev/null @@ -1,2 +0,0 @@ - - \ No newline at end of file diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 2253a9ca..d4239cf5 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -20,7 +20,7 @@ Strona główna Powiadomienia Lokalne - Federalne + Globalne Wątek Wpisy Z odpowiedziami @@ -68,7 +68,7 @@ Zamknij Profil Preferencje - Polubione + Ulubione Wyciszeni użytkownicy Zablokowani użytkownicy Prośby o możliwość śledzenia @@ -89,7 +89,7 @@ Akceptuj Odrzuć Szukaj - Szkice + Szkice Widoczność wpisu Ostrzeżenie o zawartości Klawiatura emoji @@ -183,6 +183,9 @@ %1$s, %2$s, i %3$s %1$s i %2$s + %d nowe powiadomienie + %d nowe powiadomienia + %d nowych powiadomień %d nowych powiadomień Konto zablokowane @@ -273,7 +276,7 @@ Zwiń Nic tu nie ma. Usuń z ulubionych - Usuń i napisz jeszcze raz + Usuń i przeredaguj Ustawienia konta Linki Wzmianki @@ -365,6 +368,9 @@ %1$s i %2$s %1$s, %2$s i %3$d innych + maksymalna liczba zakładek (%1$d) osiągnięta + maksymalna liczba zakładek (%1$d) osiągnięta + maksymalna liczba zakładek (%1$d) osiągnięta maksymalna liczba zakładek (%1$d) osiągnięta Media: %s @@ -455,13 +461,13 @@ Napędzane przez Tusky Błąd przy wyszukiwaniu wpisu %s Zakładki - Zakładka + Dodaj do zakładek Zakładki Dodane do zakładek 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 @@ -487,4 +493,53 @@ Ukryj tytuł górnego paska narzędzi Dół Góra + + Nie możesz przesłać więcej niż %1$d załącznika. + Nie możesz przesłać więcej niż %1$d załączników. + Nie możesz przesłać więcej niż %1$d załączników. + Nie możesz przesłać więcej niż %1$d załączników. + + Nie udało się załadować informacji o odpowiedzi + Przesłanie wpisu nie powiodło się! + Czy na pewno chcesz usunąć listę %s\? + Nie ma ogłoszeń. + Ogranicz liczbę powiadomień o zmianach na osi czasu + Czas trwania + Nowe wpisy + Niektóre informacje, które mogą wpływać na Twoj dobrostan psychiczny zostaną ukryte. W ich skład wchodzą: +\n +\n - powiadomienia o ulubionych/podbiciach/obserwowaniu +\n - liczba polubień/podbić toota +\n - statystyki obserwujących/postów na profilach +\n +\nNie będzie to miało wpływu na powiadomienia typu push, ale możesz zmienić ustawienia powiadomień ręcznie. + Włącz gest przesuwania by przełączać między zakładkami + Załączniki + Powiadomienia o prośbach o obserwowanie + ktoś kogo zasubskrybowałem/zasubskrybowałam opublikował nowy wpis + Wysłano prośbę o obserwowanie + Ogłoszenia + Samopoczucie + Anuluj subskrypcję + Zasubskrybuj + Mimo tego, że twoje konto nie jest zablokowane, administracja %1$s uznała, że możesz chcieć ręcznie przejrzeć te prośby o możliwość śledzenia od tych kont. + Wpis dla którego naszkicowałeś/naszkicowałaś odpowiedź został usunięty + Usunięto szkic + Ukryj ilościowe statystyki na profilach + Ukryj ilościowe statystyki na postach + Przejrzyj powiadomienia + Zapisano! + Twoja prywatna notatka o tym koncie + Czas nieokreślony + Dźwięk + Powiadomienia o opublikowaniu nowego wpisu przez kogoś, kogo obserwujesz + Pozycja głównego paska nawigacji + Animuj niestandardowe emoji + Usunąć tą konwersację\? + Wyłącz wyciszenie powiadomień od %s + Usuń konwersację + %s opublikował/a post + %s poprosił(a) o możliwość śledzenia Cię + Usuń z zakładek + Pytaj o potwierdzenie przed dodaniem do ulubionych \ No newline at end of file diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 97b6566a..eee9856a 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 Tusky %s - Tusky é 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 + Tusky é 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\? \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 6a2c5230..7656877a 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -18,22 +18,22 @@ Необходимо разрешение для хранения медиаконтента. Изображения и видео не могут быть прикреплены к статусу одновременно. Загрузка не удалась. - Ошибка при отправке поста. + Ошибка при засвистывании. Главная Уведомления - Локальная лента - Глобальная лента + Местная лента + Объединенная лента Личные сообщения Вкладки - Обсуждение + Кутеж Посты Посты и ответы Закреплённые Подписки Подписчики Избранное - Список глушения - Список блокировки + Заглушенные пользователи + Заблокированные пользователи Запросы на подписку Редактировать профиль Черновики @@ -61,7 +61,7 @@ Нравится Убрать из избранного Больше - Написать + Сочинить Войти Выйти Вы действительно хотите выйти из учётной записи %1$s? @@ -79,11 +79,11 @@ Повторить Закрыть Профиль - Настройки - Настройки аккаунта + Предпочтения + Предпочтения учетной записи Избранное - Список глушения - Список блокировки + Заглушенные пользователи + Заблокированные пользователи Запросы на подписку Медиаконтент Открыть в браузере @@ -94,17 +94,17 @@ Отменить глушение Упомянуть Скрыть медиаконтент - Открыть drawer + Открыть черновик Сохранить Редактировать профиль - Редактировать + Изменить Отменить Принять Отклонить Поиск - Черновики - Видимость поста - Предупреждение о контенте + Черновики + Видимость гудка + Предупреждение о содержимом Эмодзи-клавиатура Добавить вкладку Ссылки @@ -120,19 +120,19 @@ Загрузка %1$s Копировать ссылку Открыть как %s - Поделиться как … + Поделиться как… Скачать медиафайл Скачивание медиафайла - Поделиться ссылкой на запись… - Поделиться записью… + Поделиться ссылкой на гудок… + Поделиться гудком с… Поделиться медифайлом… Отправить! Пользователь разблокирован - Пользователь разглушен + Пользователь включен Отправлено! Ответ успешно отправлен. Какой узел? - Что происходит? + Что тут происходит\? Предупреждение о содержании Отображаемое имя О себе @@ -147,26 +147,26 @@ \n \nЕсли у вас ещё нет аккаунта, введите адрес узла, на котором хотите зарегистрироваться, и создайте аккаунт. \n -\n Узел - это то место, где размещён ваш аккаунт, но вы можете взаимодействовать с пользователями других узлов, как будто вы находитесь на одном сайте. -\n +\n Узел — это то место, где размещён ваш аккаунт, но вы можете взаимодействовать с пользователями других узлов, как будто вы находитесь на одном сайте. +\n \n Чтобы получить больше информации посетите joinmastodon.org.
Завершается загрузка медиаконтента Загружается… Скачать - Отменить запрос на подписку? + Отозвать запрос на подписку\? Отписаться от этого аккаунта? - Удалить запись\? - Удалить запись и превратить её в черновик\? + Удалить гудок\? + Удалить гудок и превратить его в черновик\? Публичный: Показать в публичных лентах - Скрытый: Не показывать в лентах - Приватный: Показать только подписчикам - Направленный: Показать только упомянутым - Push-уведомления - Push-уведомления - Предупреждения - Уведомлять звуком - Уведомлять вибрацией - Уведомлять светом + Неизвестно: Не показывать в публичных лентах + Только подписчики: Показать только подписчикам + Непосредственно: публиковать сообщения только для упомянутых пользователей + Уведомления + Уведомления + Оповещения + Уведомлять со звуком + Уведомлять с вибрацией + Уведомлять со светом Уведомлять когда упомянули подписались @@ -179,28 +179,28 @@ Тёмная Светлая Чёрная - Автоматическая (по времени) + Автоматическая (от заката до восхода солнца) Как в системе Браузер - Исп. встроенный веб-браузер - Скрывать кнопку создания поста при прокрутке ленты + Используйте пользовательские вкладки Chrome + Скрывать кнопку композиционирования гудка при прокрутке ленты Язык - Фильтрация ленты - Фильтры + Фильтрование ленты + Вкладки Показывать продвижения Показывать ответы - Предзагрузка медиаконтента + Загрузить предпросмотр медиаконтента Прокси HTTP прокси Использовать HTTP прокси Адрес сервера Порт - Приватность статусов по умолчанию + Приватность публикаций по умолчанию Считать все медиа чувствительными Публикация (синхронизировано с сервером) Не удалось синхронизировать настройки Публичные - Скрытые + Неизвестно Для подписчиков Размер текста статусов Крохотный @@ -208,7 +208,7 @@ Средний Большой Огромный - Упоминания + Новые Упоминания Уведомлять о новых упоминаниях Новые подписчики Уведомлять о новых подписчиках @@ -231,19 +231,16 @@ Закрытый аккаунт О приложении Tusky %s - Tusky – это бесплатное приложение с открытым исходным кодом. - Выпускается по лицензии GNU General Public License Version 3. - Вы можете прочитать текст лицензии по ссылке: https://www.gnu.org/licenses/gpl-3.0.ru.html + Tusky — это свободное приложение с открытым исходным кодом. Выпускается по лицензии GNU General Public License Version 3. Вы можете прочитать текст лицензии здесь: https://www.gnu.org/licenses/gpl-3.0.ru.html Веб-сайт проекта:\n https://chinwag.org - - Отчеты об ошибках и пожелания:\n - https://git.chinwag.org/chinwag/chinwag-android/issues + Отчеты об ошибках и ваши пожелания: +\n https://git.chinwag.org/chinwag/chinwag-android/issues Профиль Tusky - Поделиться содержанием поста - Поделиться ссылкой на пост + Поделиться содержанием гудка + Поделиться ссылкой на гудок Изображения Видео Запрошенные подписки @@ -289,17 +286,17 @@ Ответить @%s показать ещё Публичные ленты - Диалоги + Разговоры Добавить фильтр Изм. фильтр Удалить Обновить - Слово или фраза + Слова на фильтр Добавить аккаунт Добавить новый акканут Mastodon Списки Списки - Лента списка + Список лент Не удалось создать список Не удалось переименовать список Не удалось удалить список @@ -315,28 +312,28 @@ Описание для слабовидящих\n(не более %d символов) - Добавить подпись + Добавить примечание Удалить Закрыть аккаунт Вам придётся вручную подтверждать подписчиков Сохранить черновик? - Отправка поста… - Ошибка при отправке поста - Отправка постов + Отправка гудка… + Ошибка при отправке гудка + Отправка гудков Отправка отменена Копия поста сохранена в ваши черновики - Написать + Сочинить У вашего узла %s нет собственных эмодзи Скопировано в буфер обмена Стиль эмодзи Системный - Для начала эти наборы эмодзи нужно скачать + Сперва эти наборы эмодзи нужно скачать Производится поиск… Раскрыть/свернуть все статусы - Перейти к статусу + Открыть гудок Необходимо перезапустить приложение Вам нужно перезапустить Tusky для применения изменений - Не сейчас + Позже Перезапустить Набор эмодзи по умолчанию Набор эмодзи Blob из Android 4.4-7.1 @@ -346,16 +343,16 @@ %1$s переехал(а) на: Продвинуть для исходной аудитории Убрать продвижение - Tusky содержит код и элементы из следующих приложений с открытым исходным кодом: + Tusky содержит код и ассеты из следующих приложений с открытым исходным кодом: Используется лицензия Apache License (копия ниже) CC-BY 4.0 CC-BY-SA 4.0 Метаданные профиля добавить данные Заголовок - Значение + Содержание Показывать точное время - Информация может не полностью отражать профиль пользователя. Нажмите чтобы открыть профиль в браузере. + Информация ниже может не полностью отражать профиль пользователя. Нажмите чтобы открыть профиль в браузере. Открепить Закрепить @@ -384,35 +381,25 @@ Медиафайл: %s - - Предупреждение: %s - + Предупреждение о содержании: %s Без описания - - Продвинуто - - - Нравится - + Реблогнуто + Понравилось Публичный - - Скрытый - + Неизвестно Подписчики - - Для упомянутых - + Непосредственно Название списка Хэштег без # Очистить Фильтр Применить - Написать пост - Написать + Сочинить Гудок + Сочинить Показывать индикатор ботов Вы действительно хотите безвозвратно удалить все уведомления? @@ -434,25 +421,25 @@ Готово Дополнительные комментарии Скрытые домены - Скрытые домены + Скрытые области Заглушить %s %s показывается Вы уверены, что хотите заблокировать %s целиком\? Вы перестанете видеть посты из того домена во всех публичных лентах и уведомлениях. Все ваши подписчики из того домена будут удалены. - Скрыть узел целиком - опросы завершились + Скрыть домен целиком + опросы закончились Анимировать GIF-аватары Слово целиком Если слово или фраза состоит только из букв и цифр, будет учитываться полное совпадение Набор эмодзи от Google - Опрос с вариантами: %1$s, %2$s, %3$s, %4$s; %5$s + Опрос с выбором: %1$s, %2$s, %3$s, %4$s; %5$s Действия для изображения %s Жалоба на @%s отправлена Переслать в %s Не удалось пожаловаться - Не удалось загрузить посты + Не удалось получить статусы Жалоба будет отправлена модератору вашего узла. Ниже вы можете добавить пояснение о причинах жалобы: Этот аккаунт расположен на другом узле. Отправить анонимную копию жалобы туда\? - Показывать фильтр уведомлений + Показать фильтр уведомлений Создать опрос Всегда разворачивать посты с предупреждением о содержимом Аккаунты @@ -465,35 +452,35 @@ 1 день 3 дня 7 дней - Добавить + Добавить выбор Множественный выбор Вариант %d Изменить - Запланированные записи + Запланированные гудки Редактировать - Запланированные записи - Запланировать запись + Запланированные гудки + Запланировать гудок Сброс Закладки Добавить в закладки Закладки - Под управлением Tusky + Работает на Tusky Добавлено в закладки Выбрать список Список Аудиофайлы должны быть меньше 40МБ. Ошибка поиска поста %s - У вас нет черновиков. - У вас нет запланированных постов. + У вас нет черновиков. + У вас нет запланированный статусов. Минимальный интервал планирования в Mastodon составляет 5 минут. Показывать диалог подтверждения перед продвижением Показывать предпросмотр ссылок в лентах Включить переключение между вкладками смахиванием %s человек - %s человека - %s человек - %s человек + %s людей + %s людей + %s людей Уведомления о запросах на подписку Запросы на подписку @@ -503,8 +490,8 @@ Показать обсуждение Заглушить обсуждение запрос на подписку от %s - Тэги - Добавить тэг + Хэштеги + Добавить хэштэг Отображать цветные градиенты для скрытых изображений Снизу Сверху @@ -519,42 +506,43 @@ Скрыть заголовок верхней панели Объявлений нет. Объявления - "Информация, могущая повлиять на ваше психическое благополучие, будет скрыта. Она включает: + Некоторая информация, которая может повлиять на ваше психическое здоровье будет скрыта. Она включает: \n -\n - Избранное/Продвижение/Уведомления подписок -\n - Избранное/Счётчики продвижения постов -\n - Статистика подписчиков/постов в профилях -\n -\n На push-уведомления это не повлияет, но вы можете просмотреть настройки уведомлений вручную." - Благосостояние +\n - Уведомления о добавлении в избранное/продвижении/подписке +\n - Количество добавлений в избранное/количество продвижений +\n - Статистика подписчиков/постов в профилях +\n +\n На всплывающие уведомления это не повлияет, однако, вы можете пересмотреть настройки уведомлений самостоятельно. + Самочувствие Неопределённая Продолжительность Вложения Аудио %s только что опубликовал(а) - Ваша учётная запись не заблокирована, но персонал %1$s подумал, что вы можете захотеть вручную просмотреть запросы на отслеживание от этих учётных записей. + Несмотря на то, что ваша учетная запись не закрыта, наши сотрудники %1$s решили, что вы, возможно, захотите просмотреть запросы на отслеживание от этих учетных записей вручную. Вы не можете загрузить более %1$d мультимедийного вложения. Вы не можете загрузить более %1$d мультимедийных вложений. Вы не можете загрузить более %1$d мультимедийных вложений. Вы не можете загрузить более %1$d мультимедийных вложений. - Скрыть количественную статистику по сообщениям + Скрыть количественную статистику по постам Отписаться Подписаться - Пост, на который вы написали ответ, был удалён + Гудок который Вы записали в черновик был удален Не удалось загрузить информацию об ответе - Функция черновика в Tusky была полностью переработана, чтобы сделать её более быстрой, удобной и стабильной. -\nВы по-прежнему можете получить доступ к своим старым черновикам с помощью кнопки на экране новых черновиков, но они будут удалены в будущем обновлении! - Старые черновики Черновик удалён - Этот пост не удалось отправить! + Этот гудок не удалось отправить! Вы действительно хотите удалить список %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 271136af..ffb8a374 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-si/strings.xml b/app/src/main/res/values-si/strings.xml index d9993b2f..5b88bf6f 100644 --- a/app/src/main/res/values-si/strings.xml +++ b/app/src/main/res/values-si/strings.xml @@ -8,10 +8,265 @@ ගිණුම් විනාඩි 5 විනාඩි 30 - හෝරා 6 - හෝරා 1 - දින 3 - දින 1 - දින 7 + පැය 6 + පැය 1 + දවස් 3 + දවස් 1 + දවස් 7 සංස්කරණය + පිළිතුරු සමඟ + අවහිර කළ පරිශීලකයින් + කටුපිටපත් + කිසිවක් නැත. නැවුම් කිරීමට පහළට අදින්න! + ඉක්මන් පිළිතුර + පොත්යොමුව ඉවත් කරන්න + ටූට් + අවහිර කළ පරිශීලකයින් + අතිරික්සුවෙන් විවෘත කරන්න + වාර්තා කරන්න + ප්‍රතිඵල නැත + සම්බන්ධ වෙමින්… + දැනුම්දීම් + ප්‍රසිද්ධ + ඇමුණුම් + නව සැඳහුම් + රචනා කරන්න + තව පෙන්වන්න + ටූට් හි පිටපතක් ඔබගේ කටුපිටපත් තුළට සුරකින ලදි + මාධ්‍ය සඟවන්න + පැතිකඩ සංස්කරණය + යැවිණි! + යළි සකසන්න + අඩුවෙන් පෙන්වන්න + තත්. %d කින් + නිහඬ කළ පරිශීලකයින් + බෙදාගන්න + %1$s ගෙන ගොස් ඇත: + ටූට් වෙත සබැඳියක් බෙදාගන්න + බලපත්‍ර + පැතිකඩ සංස්කරණය + දර්ශන නාමය + මධ්‍යම + ශ්‍රව්‍ය + දැනුම්දීම් පෙරහන පෙන්වන්න + දව. %d + නික්මෙන්න + පෙරහන සංස්කරණය + %s වෙතින් දැනුම්දීම් නිහඬ කරන්න + %1$s, %2$s, සහ %3$s + මාස්ටඩන් හි සම්මත ඉමෝජි කට්ටලය + %1$s • %2$s + යළි උත්සාහය + ගිණුම අගුළුලන්න + මාධ්‍ය සැඟවී ඇත + පිළිතුර සාර්ථකව යැවිණි. + ඒකාබද්ධ + උඩුගත වෙමින්… + ගිණුම එකතු කරන්න + ශීර්ෂය + + පැය %d ක් ඉතිරිය + පැය %d ක් ඉතිරිය + + ප්‍රියතමය ඉවත් කරන්න + පිළිතුරෙහි තොරතුරු පූරණය වීමට අසමත් විය + \@%s වාර්තා කරන්න + අතිරේක අදහස්\? + සඳහන් කළ + වෙත ටූට් ඒ.ස.නි. බෙදාගන්න… + දීප්ත + කටුපිටපත සුරකින්නද\? + සංවේදී අන්තර්ගතයකි + ස්වයංක්‍රමලේඛය + පොත්යොමුව + ගූගල් හි වත්මන් ඉමෝජි කට්ටලය + ස්වයංක්‍රීව ඉර බැසීමේදී + වාර්තා කිරීමට අසමත් විය + අවු. %d + %s ලෙස විවෘත කරන්න + ප්‍රියතමයන් පෙන්වන්න + වි. %d කින් + + ජන්ද %s + ජන්ද %s + + තව + පොත්යොමු + %s ඔබව සඳහන් කළා + %1$s ගිණුම සමඟ පළකරන්න + අතිරේක අදහස් + ප්‍රියතමයන් + ඔබ මේ ඉමෝජි කට්ටල පළමුව බාගත යුතුයි + සෘජු + සොයන්න… + යෙදුමේ තේමාව + පොත්යොමු + \@%s නිහඬ\? + රචනා කරන්න + ඔබ ජන්දය දුන් මත විමසුව නිම වී ඇත + පිළිබඳව + සමස්ථ වසම සඟවන්න + පැතිකඩ පාරදත්ත + සබැඳිය පිටපත් + සෘජු පණිවිඩ + මාධ්‍ය බාගත වෙමින් + මාධ්‍ය පෙරදසුන් බාගන්න + තව පූරණය + බාගන්න + සඳැහුම් + පිළිතුර + දැනුම්දීම් + පරිශීලක අනවහිර කෙරිණි + දත්ත එක්කරන්න + වෙත ටූට් බෙදාගන්න… + යැවීම අවලංගු කෙරිණි + අනවහිර + ටූට්! + ප්‍රියතමයන් + %1$s බාගත වෙමින් + තත්ව ගෙන ඒමට අසමත් විය + මාධ්‍ය: %s + සංවාදය නිහඬ කරන්න + ටූට්ස් යැවෙමින් + සංවාද + විශාල + ප්‍රසිද්ධ + නව මාස්ටඩන් ගිණුමක් එක්කරන්න + මාධ්‍ය උඩුගත වීම අහවර වෙමින් + + තත්. %d ක් ඉතිරිය + තත්. %d ක් ඉතිරිය + + ඔබට %1$s ගිණුමෙන් නික්මෙන්ට ඇවැසි බව විශ්වාසද\? + ස්ථානීය + ප්‍රියතමයන් + පිළිතුරු… + කාලරේඛා දැනුම්දීම් සීමාකරන්න + ටූට් යැවීමේ දෝෂයකි + පෙරහන එකතු කරන්න + සැමවිටම මාධ්‍ය සංවේදී ලෙස සලකුණු කරන්න + යෙදුම යළි ඇරඹීම ඇවැසිය + යළි අරඹන්න + කළු + වර්. %d කින් + මෙම වෙනස්කම් යෙදීමට ඔබ ටුස්කි නැවත ඇරඹිය යුතුය + සංස්කරණය + ඉදිරියට + කාලරේඛාව පෙරීම + %1$s, %2$s සහ තවත් %3$d + කාලරේඛා + %s නිහඬ කරන්න + CC-BY 4.0 + \@%s වෙත පිළිතුරු දෙමින් + අවහිර + මාධ්‍ය බාගන්න + සැඟවුනු වසම් + කුඩා + අන්තර්ගත අවවාද සමඟ ඇති ටූට්ස් සැමවිටම විහිදන්න + පෙරසේ + විහිදන්න + නිහඬ කරන්න + ටුස්කි %s + වියමන අඩවිය: +\n https://tusky.app + පිළිගන්න + පැ. %d කින් + පසුරුපුවරුවට පිටපත් විය + මතවිමසුම + ඉවත් කරන්න + මාධ්‍ය එකතු කරන්න + ද. %d කින් + %1$s, %2$s, %3$s සහ වෙනත් %4$d + ටූට් විවෘත කරන්න + සැඟවුනු වසම් + පැය %d + සැකසුම් සමමුහූර්ත වීමට අසමත් විය + පිළිතුරු පෙන්වන්න + පැතිකඩ + ඔබගේ උපාංගයේ පෙරනිමි ඉමෝජි කට්ටලය + නව සැඳහුම් පිළිබඳව දැනුම්දීම් + සොයන්න + ලෙස බෙදාගන්න … + මුල + වි. %d + සෙවීමට අසමත් විය + නිවේදන නැත. + CC-BY-SA 4.0 + සුරකින්න + නිහඬ කළ පරිශීලකයින් + ඔබ සතුව කටුපිටපත් නැත. + ටූට් + පෙරහන් + \@%s වෙත සාර්ථකව වාර්තා කෙරිණි + බාගැනීමට අසමත් විය + භාෂාව + සුරැකිණි! + ටුස්කි\'හි පැතිකඩ + ටූට්හි අන්තර්ගතය බෙදාගන්න + අන්තර්ගතය + නව ටූට්ස් + #%d මාධ්‍ය විවෘත කරන්න + මාධ්‍ය + + පුද්ගලයින් %s + මිනිසුන් %s + + වෙත මාධ්‍ය බෙදාගන්න… + ප්‍රසිද්ධ කාලරේඛා + කුඩාම + මත විමසුම් + දැකීමට ඔබන්න + වසා ඇත + + දවස් %d ක් ඉතිරිය + දවස් %d ක් ඉතිරිය + + ශබ්දය සමඟ දන්වන්න + ටුස්කි මගින් බලගන්වා ඇත + සැමවිටම සංවේදී අන්තර්ගත පෙන්වන්න + නිවේදන + + විනාඩි %d ක් ඉතිරිය + විනාඩි %d ක් ඉතිරිය + + කටුපිටපත් + ඔබ සෑදූ මත විමසුම නිම වී ඇත + ස්වයංක්‍රමලේඛ සඳහා දර්ශකය පෙන්වන්න + + ප්‍රියතමයන් %1$s + ප්‍රියතමයන් %1$s + + ප්‍රියතම + සඳැහුම් + යාවත්කාල + %1$s සහ %2$s + ටුස්කි යනු නොමිලේ සහ විවෘත-මූලාශ්‍ර මෘදුකාංගයකි. එය ජීඑන්යූ පොදු බලපත්‍ර අනුවාදය 3 යටතේ අවසර ලබා ඇත. ඔබට මෙතැනින් බලපත්‍රය දැකීමට හැකිය: https://www.gnu.org/licenses/gpl-3.0.en.html + සබැඳි + මාධ්‍ය + \@%s අවහිර\? + ආලෝකය සමඟ දන්වන්න + මෙම ගිණුම පිළිබඳව ඔබගේ පෞද්ගලික සටහන + වසන්න + දැනුම්දීම් + \@%s + යැවිණි! + මාස්ටඩන් සමඟ පිවිසෙන්න + ජන්දය + මුළු වචනය + මෙම ටූට් යැවීමට අසමත් විය! + දෘශ්‍යකය + පසුව + සංස්කරණය + අඳුරු + කිසිවක් නැත. + ටූට් යැවෙමින්… + පද්ධති පෙරනිමිය + සඳැහුම + ඉවත් කරන්න + දැනුම්දීම් සඟවන්න + තත්ව පාඨයේ ප්‍රමාණය + කාලරේඛාවෙහි සබැඳි පෙරදසුන් පෙන්වන්න + සබැඳි + අතිරික්සුව + තත්. %d \ No newline at end of file diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index 14af6584..5b026442 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 f910db4e..c5b531b7 100644 --- a/app/src/main/res/values-sl/strings.xml +++ b/app/src/main/res/values-sl/strings.xml @@ -46,9 +46,9 @@ Strni Tukaj ni ničesar. Tukaj ni ničesar. Potegnite navzdol za osvežitev! - % je spodbudil tvoj tut + %s je spodbudil tvoj tut % je vzljubil vaš tut - % vam sledi + %s vam sledi Prijavi @%s Dodatni komentarji\? Hiter odgovor @@ -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 9735f512..4e0a6e14 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 5c562a07..203f33e9 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-te/strings.xml b/app/src/main/res/values-te/strings.xml deleted file mode 100644 index a6b3daec..00000000 --- a/app/src/main/res/values-te/strings.xml +++ /dev/null @@ -1,2 +0,0 @@ - - \ No newline at end of file diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index cb11f26c..c0b270bb 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -80,7 +80,7 @@ ชื่นชอบโดย บูสต์โดย - <b>%1$s</b> บูสต์ + <b>%s</b> บูสต์ <b>%1$s</b> ชื่นชอบ @@ -321,7 +321,7 @@ เตือนเนื้อหา การมองเห็น Toot Toot แบบตั้งเวลา - ฉบับร่าง + ฉบับร่าง ปฏิเสธ ยอมรับ ยกเลิก @@ -345,41 +345,41 @@ โดเมนที่ซ่อนไว้ ผู้ใช้ที่ถูกบล็อกไว้ ผู้ใช้ที่ปิดเสียงไว้ - คั่นหน้า + ที่คั่นหน้า ชื่นชอบ โปรไฟล์ ปิด ลองอีกครั้ง - TOOT! - TOOT - ลบ แล้ว ร่างใหม่ + โพสต์! + โพสต์ + ลบแล้วร่างใหม่ ลบ แก้ไข รายงาน - แสดงบูสต์ - ซ่อนบูสต์ + แสดงการดัน + ซ่อนการดัน เลิกบล็อก บล็อก เลิกติดตาม ติดตาม - ต้องการออกจากระบบของบัญชี %1$s \? + คุณต้องการออกจากระบบของบัญชี %1$s หรือไม่\? เขียนโพสต์ใหม่ อื่น ๆ เลิกชื่นชอบ คั่นหน้า ชื่นชอบ - ลบบูสต์ + ลบการดัน ดัน ตอบกลับ ตอบกลับด่วน - ความคิดเห็นเพิ่มเติม\? + ความคิดเห็นเพิ่มเติม รายงาน @%s %s ต้องการติดตามคุณ %s ได้ติดตามคุณ %s ได้ชื่นชอบโพสต์ของคุณ %s ได้ดันโพสต์ของคุณ - ไม่มีอะไรเลย ลากลงเพื่อรีเฟรช! - ไม่มีอะไร + ไม่มีอะไรที่นี่ ลากลงเพื่อรีเฟรช! + ไม่มีอะไรที่นี่ ย่อ ขยาย แสดงน้อยลง @@ -396,37 +396,37 @@ โดเมนที่ซ่อนไว้ ผู้ใช้ที่ถูกบล็อก ผู้ใช้ที่ปิดเสียงไว้ - คั่นหน้า + ที่คั่นหน้า ผู้ติดตาม ติดตาม ปักหมุด โพสต์และตอบกลับ โพสต์ - เธรด + โพสต์ แท็บ ข้อความโดยตรง ที่ติดต่อกับภายนอก ในเซิร์ฟเวอร์ - แจ้งเตือน + การแจ้งเตือน หน้าหลัก การส่งโพสต์เกิดความผิดพลาด อัปโหลดล้มเหลว - ไม่สามารถแนบรูปภาพและวิดีทัศน์ในโพสต์เดียวกันได้ + ไม่สามารถแนบรูปภาพและวิดีโอในโพสต์เดียวกันได้ ต้องมีสิทธิ์จัดเก็บสื่อ ต้องมีสิทธิ์อ่านสื่อ ไม่สามารถเปิดไฟล์ได้ ไม่สามารถอัปโหลดไฟล์ประเภทนี้ได้ ไฟล์เสียงต้องมีขนาดน้อยกว่า 40MB - ไฟล์วีดิทัศน์ต้องมีขนาดน้อยกว่า 40MB + ไฟล์วิดีโอต้องมีขนาดน้อยกว่า 40MB ไฟล์ต้องมีขนาดน้อยกว่า 8MB ข้อความสถานะยาวเกินไป! ไม่สามารถรับโทเค็นการเข้าสู่ระบบ การขออนุญาตสิทธิถูกปฏิเสธ เกิดข้อผิดพลาดในการขออนุญาตสิทธิโดยไม่ทราบสาเหตุ - ไม่พบเว็บเบราว์เซอร์ + ไม่พบเว็บเบราว์เซอร์ที่จะใช้งาน โดเมนที่ป้อนไม่ถูกต้อง - ไม่สามารถโพสต์โดยไร้ข้อความได้ - เครือข่ายมีข้อผิดพลาดเกิดขึ้น! กรุณาตรวจสอบการเชื่อมต่อและลองอีกครั้ง! + ต้องใส่ข้อความ + เกิดข้อผิดพลาดเครือข่าย! กรุณาตรวจสอบการเชื่อมต่อและลองอีกครั้ง! เกิดข้อผิดพลาด รายการ รายการ @@ -434,8 +434,8 @@ ล้างค่า ค้นหา แก้ไขโปรไฟล์ - ตั้งค่าบัญชี - ตั้งค่า + การกำหนดลักษณะบัญชี + การกำหนดลักษณะ ออกจากระบบ ฉบับร่าง ชื่นชอบ @@ -447,7 +447,7 @@ แสดงตัวอย่างลิงก์ในไทม์ไลน์ Mastodon กำหนดเวลาขั้นต่ำ 5 นาที ไม่มีสถานะแบบตั้งเวลาใด ๆ - ไม่มีฉบับร่างใด ๆ + ไม่มีฉบับร่างใด ๆ การค้นหาโพสต์ %s เกิดข้อผิดผลาด แก้ไข ตัวเลือกที่ %d @@ -472,8 +472,6 @@ แจ้งเตือน Limit timeline แจ้งเตือน Review ใครบางคนที่ฉันได้ติดตาม ได้เผยแพร่โพสต์ใหม่ - ฟีเจอร์ฉบับร่างใน Tusky ได้รับการออกแบบใหม่ทั้งหมดเพื่อให้เร็วขึ้นเป็นมิตรกับผู้ใช้มากขึ้นและบั๊กน้อยลง -\n คุณยังสามารถเข้าถึงฉบับร่างเก่าผ่านปุ่มในหน้าฉบับร่างใหม่ แต่จะถูกลบออกในการอัปเดตในอนาคต! ซ่อนสถิติเชิงปริมาณในโปรไฟล์ ซ่อนสถิติเชิงปริมาณของโพสต์ สุขภาวะ @@ -482,7 +480,6 @@ โพสต์ที่คุณได้ร่างตอบไว้ ถูกลบแลัว ลบฉบับร่างแล้ว ล้มเหลวในการโหลดข้อมูลตอบกลับ - ฉบับร่างเก่า คุณต้องการลบลิสต์ %s ใช่ไหม\? คุณไม่สามารถอัปโหลดไฟล์แนบมากกว่า %1$d ได้ @@ -494,6 +491,7 @@ ไฟล์แนบ เสียง โพสต์ใหม่ - %s พึ่งโพสต์ + %s เพิ่งโพสต์ ประกาศ + ลบการสนทนา \ No newline at end of file diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 4320b973..d2f9294d 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -3,8 +3,8 @@ Bir hata oluştu. Bir ağ hatası oluştu! Lütfen bağlantınızı kontrol edin ve tekrar deneyin! Bu alan boş bırakılmaz. - Girilen alan alanı geçersiz - Kimlik doğrulama başarısız oldu. + Geçersiz alan adı girildi + Bu sunucuda kimlik doğrulama başarısız oldu. Kullanılabilir web tarayıcısı bulunamadı. Tanımlanamayan bir yetkilendirme hatası oluştu. Yetkilendirme reddedildi. @@ -12,7 +12,7 @@ Durum çok uzun! Dosya 8 MB\'dan küçük olmalı. Video dosyaları 40 MB’dan küçük olmalı. - Bu biçimdeki dosyalar yüklenmez. + Bu tür bir dosya yüklenemez. Dosya açılamadı. Medya okuma izni gerekli. Medya kaydetme izni gerekli. @@ -22,53 +22,53 @@ Ana sayfa Bildirimler Yerel - Birleşmiş - Direkt Mesajlar + Federe + Direkt mesajlar Sekmeler Toot Gönderiler - Yanıtlar ile + Yanıtlarıyla Sabitlenmiş Takip edilenler Takipçiler Favoriler Sesize alınmış kullanıcılar Engellenmiş kullanıcılar - Takip Etme İstekleri - Profili düzeltme + Takip istekleri + Profili düzenle Taslaklar Lisanslar \@%s %s yineledi - Hasas Medya + Hasas medya Gizlenmiş medya Görüntülemek için dokunun - Daha Fazla Göster - Daha Az Göster + Daha fazla göster + Daha az göster Genişlet Daralt Burada hiçbir şey yok. - Henüz hiç ileti yoktur. Yenilemek için aşağıya çek! - %s tootunuzu boost etti + Burada henüz hiç birşey yok. Yenilemek için aşağıya çekin! + %s tootunuzu yineledi %s tootunuzu favorilerine ekledi %s seni takip etti \@%s bildir Daha fazla yorum? - Hızlı Yanıt + Hızlı yanıt Yanıtla - Yükselt - Favori + Yinele + Favorile Daha fazla Oluştur Mastodon ile giriş yap - Oturumu Kapat + Oturumu kapat Bu %1$s oturumu sonlandırmak istediğinizden emin misiniz\? Takip et Takibi bırak Engelle Engeli kaldır - Yükseltilenleri gizle - Boostları göster + Yinelemeleri gizle + Yinelemeleri göster Bildir Sil İLET @@ -77,64 +77,64 @@ Kapat Profil Tercihler - Hesap Tercihleri + Hesap tercihleri Favoriler Sesize alınmış kullanıcılar Engellenmiş kullanıcılar - Takip İstekleri + Takip istekleri Medya Tarayıcıda aç Medya ekle Fotoğraf çek Paylaş Sesize al - Sesizden kaldır + Sesizden çıkar Bahset Medyayı gizle Çekmece aç Kaydet - Profili düzelt + Profili düzenle Düzenle Geri al Kabul et Reddet Ara - Taslaklar + Taslaklar Toot görünürlüğü - İçerik uyarı + İçerik uyarısı İfade klavyesi - Sekme Ekle + Sekme ekle %1$s indiriliyor Bağlantıyı kopyala Farklı aç %s Olarak paylaş … Durumun adresini paylaş… - Durumu paylaş… + Tootu paylaş… Medyayı paylaş… Gönderildi! Kullanıcının engeli kaldırıldı Kullanıcının sesi açıldı İletildi! Yanıt başarıyla gönderildi. - Hangi örnek\? + Hangi sunucu\? Neler oluyor? - İçerik uyarı + İçerik uyarısı Görünen ad Biyo Hesaplarda ara… Sonuç bulunamadı Yanıt… - Simge - Üstlük + Avatar + Başlık Sunucu nedir\? Bağlantı kuruluyor… - Burada her hangi bir Mastodon sunucusunun adresi (mastodon.social, icosahedron.website, social.tchncs.de, ve daha fazla!) girilebiliri. + mastodon.social, icosahedron.website, social.tchncs.de ve daha fazlası gibi herhangi bir sunucunun adresi buraya girilebilir! \n -\nEğer hesabınız henüz yok ise katılmak istediğiniz sunucunun adresini girerek hesap yaratabilirsin. +\nHenüz hesabınız yoksa, katılmak istediğiniz sunucunun adını girebilir ve orada bir hesap oluşturabilirsiniz. \n -\nHer bir sunucu hesaplar ağırlayan bir yer olur ancak diğer sunucularda bulunan insanlarla aynı sitede olmuşcasına iletişime geçip takip edebilirsiniz. +\nSunucu, hesabınızın barındırıldığı tek yerdir, ancak aynı sitedeymişsiniz gibi diğer sunuculardaki kişilerle kolayca iletişim kurabilir ve onları takip edebilirsiniz. \n -\nDaha fazla bilgi için mastodon.social. +\n Daha fazla bilgiyi joinmastodon.org adresinde bulabilirsiniz. Medya yüklemesi tamamlanıyor Yükleniyor… İndir @@ -306,10 +306,10 @@ %1$d maksimum sekme sayısına ulaşıldı - Gizli alanadları - Boostu kaldır + Gizli alan adları + Yinelemekten vazgeç Favoriyi kaldır - Gizli alanadları + Gizli alan adları %s alan adını sessize al Bağlantılar Hashtags\'ler @@ -402,11 +402,11 @@ Bildirim filtresini göster Bahsedenler Yineleyen yayıncıyı aç - Boostları göster + Yinelemeleri göster Bahsedenler #%d medyayı aç Yer imleri - Zamanlanmış iletiler + Zamanlanmış tootlar Yer imi Düzenle Sil ve düzenle @@ -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 @@ -462,9 +462,9 @@ Bildirimleri gizle \@%s sessize al\? \@%s engellensin mi\? - %s senini aç + %s alan adının sesini aç %s gelen bildirimleri yoksay - %s kullanıcısından gelen bildirimleri yoksay + %s kullanıcısından gelen bildirimleri göster %s sesini aç %s seni takip etmek istiyor Ses dosyaları 40 MB\'dan küçük olmalı. @@ -476,4 +476,6 @@ Başlık ayarlanamadı Mastodon\'un minimum 5 dakikalık zamanlama aralığı vardır. Üst araç çubuğunun başlığını gizle + Konuşmayı sil + Duyurular \ No newline at end of file diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 6144773a..fc73d1b5 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 Увімкнути перемикання між вкладками жестом проведення пальцем Показати фільтр сповіщень @@ -533,4 +530,8 @@ Поточний набір емодзі 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 5c132817..091725ef 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -1,12 +1,12 @@ Bạn có muốn xóa toàn bộ thông báo\? - Đã lưu vào nháp một bản sao của tút + Đã lưu tút vào nháp Hủy đăng Đăng Tút Đang đăng… - %d tương tác mới + %d thông báo mới %1$s và %2$s %1$s, %2$s, và %3$s @@ -39,7 +39,7 @@ Truy cập bị từ chối. Xảy ra lỗi khi cố gắng truy cập. Không tìm thấy trình duyệt web. - Tài khoản không đúng + Tài khoản không hợp lệ Không được để trống. Rớt mạng! Xin kiểm tra kết nối và thử lại! Đã có lỗi xảy ra. @@ -58,23 +58,23 @@ Quay lại Tiếp tục Cập nhật - NGƯNG + Xóa TÚT Đăng nhập Mastodon Xóa và viết lại tút này\? Xóa tút này\? - Hủy theo dõi người này\? + Ngưng theo dõi người này\? Hủy yêu cầu theo dõi\? Tải về Đang tải… Đã tải xong tập tin - Bạn phải nhập một tên miền, ví dụ mastodon.social, icosahedron.website, social.tchncs.de, và vô số khác! + Bạn phải nhập một tên miền. Ví dụ mastodon.social, icosahedron.website, social.tchncs.de và vô số khác! \n -\nNếu chưa có tài khoản, bạn phải tạo tài khoản trước ở đó. +\nNếu chưa có tài khoản, bạn phải tạo tài khoản ở đó trước. \n -\nMáy chủ, nói cách khác là một cộng đồng nơi mà tài khoản của bạn lưu trữ trên đó, nhưng bạn vẫn có thể dễ dàng giao tiếp và theo dõi mọi người trên các máy chủ khác. +\nMáy chủ, nói cách khác là một cộng đồng nơi mà bạn đăng ký tài khoản trên đó, nhưng bạn vẫn có thể dễ dàng giao tiếp và theo dõi mọi người trên các máy chủ khác. \n -\nTham khảo joinmastodon.org. +\nTham khảo joinmastodon.org Đang kết nối… Ảnh bìa Ảnh đại diện @@ -83,7 +83,7 @@ Tìm kiếm… Tiểu sử Tên hiển thị - Nội dung nhạy cảm + Nội dung bạn muốn ẩn Bạn đang nghĩ về điều gì\? Bạn ở máy chủ nào\? Trả lời đã được gửi đi. @@ -114,10 +114,10 @@ Thêm Tab Lên lịch Emoji - Nội dung nhạy cảm + Nội dung ẩn Công khai - Tút đã lên lịch - Nháp + Đăng tự động + Nháp Từ chối Đồng ý Trở về @@ -171,16 +171,16 @@ Trượt xuống để tải nội dung! Trống. Thu gọn - Xem thêm + Tiếp tục đọc Thu gọn - Xem thêm + Mở rộng Hiển thị Nội dung bị ẩn Nhạy cảm %s chia sẻ \@%s Giấy phép - Lịch đăng + Những tút đã lên lịch Chỉnh sửa trang cá nhân Yêu cầu theo dõi Máy chủ đã ẩn @@ -199,7 +199,7 @@ Máy chủ Thông báo Bảng tin - Nháp + Những tút nháp Những tút đã thích Máy chủ là gì\? Tải xem trước hình ảnh @@ -208,10 +208,10 @@ Tabs Lọc bảng tin Che mờ nội dung nhạy cảm - Hiện ảnh đại diện GIF - Hiện icon cho tài khoản Bot + Ảnh đại diện GIF + Icon cho tài khoản Bot Ngôn ngữ - Ẩn nút viết tút khi xem bảng tin + Ẩn nút viết tút tự động Mở luôn trong app Trình duyệt Mặc định của thiết bị @@ -236,10 +236,10 @@ Báo động Thông báo Thông báo - Nhắn tin: Chỉ người được nhắc tới mới thấy - Người theo dõi: Ai đã theo dõi mới được xem - Riêng tư: Không hiện trên bảng tin - Công khai: Mọi người đều có thể thấy + Nhắn riêng: Chỉ người được nhắc đến thấy + Riêng tư: Chỉ người theo dõi + Hạn chế: Không hiện trên bảng tin + Công khai: Mọi người đều thấy Ẩn @%s\? Chặn @%s\? Ẩn toàn bộ máy chủ @@ -253,9 +253,9 @@ Trung bình Nhỏ vừa Nhỏ - Kích thức phông chữ - Người theo dõi - Riêng tư + Phông chữ + Riêng tư + Hạn chế Công khai Dưới màn hình Trên màn hình @@ -263,7 +263,7 @@ Đồng bộ hoá thất bại Đăng (đồng bộ với máy chủ) Tài khoản nhạy cảm - Trạng thái tút mặc định + Kiểu tút mặc định Máy chủ proxy Cổng Bật proxy @@ -288,18 +288,18 @@ Sửa bộ lọc Thêm bộ lọc Chủ đề - Cộng đồng - xem thêm + Thế giới + xem tiếp Trả lời @%s 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 - %d phút trước - %d giờ trước - %d ngày trước - %d năm trước + %d phút + %d giờ + %d ngày + %d năm %ds %d phút %d giờ @@ -311,11 +311,11 @@ URL tút Nội dung của tút Trang cá nhân Tusky - Hiện xác nhận trước khi chia sẻ + Xác nhận trước khi chia sẻ 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ó tút nháp. Sửa Lựa chọn %d Cho phép chọn nhiều lựa chọn @@ -376,9 +376,9 @@ Thêm hashtag Tên danh sách Lượt bình chọn: %1$s, %2$s, %3$s, %4$s; %5$s - Tin nhắn + Nhắn riêng Người theo dõi - Riêng tư + Hạn chế Công khai Đã lưu Đã thích @@ -401,7 +401,7 @@ Ghim Gỡ ghim - Thông tin có thể hiển thị không đầy đủ. Nhấn để mở xem chi tiết trên trình duyệt. + Nội dung có thể hiển thị không đầy đủ. Nhấn để mở xem chi tiết trên trình duyệt. Sử dụng thời gian thiết bị Nội dung Nhãn @@ -424,12 +424,12 @@ Để sau Bạn cần khởi động lại Tusky để áp dụng các thiết lập Yêu cầu khởi động lại ứng dụng - Xem tút + Đọc tút Mở rộng/Thu gọn toàn bộ tút Đang tìm kiếm… Bạn cần tải về bộ emoji này trước Mặc định của thiết bị - Kiểu Emoji + Emoji Đã chép vào clipboard Viết Lưu nháp\? @@ -453,7 +453,7 @@ Thêm tài khoản Mastodon Thêm tài khoản Thêm mô tả - Bất kể từ khóa là từ hoặc cụm từ, những kết quả hiện ra sẽ giống hệt như bạn nhập + Chỉ áp dụng nếu từ khóa là chữ-số trùng khớp, viết liền không dấu cách Media: %s Ẩn thông báo Ẩn thông báo từ %s @@ -467,7 +467,7 @@ Có gì mới\? Ẩn số liệu trên trang cá nhân Ẩn số liệu trên tút - Hạn chế thông báo trên bảng tin + Giảm bớt thông báo Chọn loại thông báo Các thông tin ảnh hưởng tới tâm lý hành vi của bạn sẽ bị ẩn. Bao gồm: \n @@ -489,15 +489,16 @@ Bạn thật sự muốn xóa danh sách %s\? Đí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 + Đã xóa tút trả lời nháp + Đã xóa tút lên lịch 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 Nhận thông báo - Dù biết tài khoản của bạn công khai, quản trị viên %1$s vẫn nghĩ bạn hãy nên xem lại yêu cầu theo dõi từ những tài khoản lạ. + Dù biết tài khoản của bạn công khai, quản trị viên %1$s vẫn nghĩ bạn hãy nên duyệt thủ công yêu cầu theo dõi từ những tài khoản lạ. + Xóa cuộc thảo luận này\? + Xóa thảo luận + Xác nhận trước khi thích + Bỏ lưu \ No newline at end of file diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 710997b4..dca03122 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1,24 +1,24 @@ - 应用程序出现异常 + 应用程序出现异常。 网络请求出错,请检查互联网连接并重试! - 内容不能为空 + 内容不能为空。 该域名无效 - 无法连接此服务器 - 没有可用的浏览器 - 认证过程出现未知错误 - 授权被拒绝 - 无法获取登录信息 + 未能通过该实例的身份验证。 + 找不到可用的浏览器。 + 发生不明授权错误。 + 授权被拒绝。 + 未能获取登录令牌。 嘟文太长了! - 文件大小限制 8MB - 视频文件大小限制 40MB - 无法上传此类型的文件 - 此文件无法打开 - 需要授予 Tusky 读取媒体文件的权限 - 需要授予 Tusky 写入存储空间的权限 - 无法在嘟文中同时插入视频和图片 - 媒体文件上传失败 - 嘟文发送时出错 + 文件大小限制为 8MB。 + 视频文件大小限制为 40MB。 + 无法上传此类型的文件。 + 打不开此文件。 + 需要授予 Tusky 读取媒体文件的权限。 + 需要授予 Tusky 存储媒体的权限。 + 无法在嘟文中同时插入视频和图片。 + 上传失败。 + 嘟文发送时出错。 主页 通知 本站时间轴 @@ -47,7 +47,7 @@ 折叠内容 展开 折叠 - 还没有内容 + 还没有内容。 还没有内容,向下拉动即可刷新! %s 转嘟了你的嘟文 %s 收藏了你的嘟文 @@ -102,7 +102,7 @@ 接受 拒绝 搜索 - 草稿 + 草稿 设置嘟文可见范围 设置内容提醒 插入表情符号 @@ -130,8 +130,8 @@ 已解除屏蔽 已取消隐藏 已发送! - 成功发送回复 - 域名 + 成功发送回复。 + 哪个实例? 有什么新鲜事? 内容提醒 昵称 @@ -405,7 +405,7 @@ 剩余 %d 秒 重置 - 音频文件大小限制 40M + 音频文件大小限制为 40M。 书签 隐藏的域名 定时嘟文 @@ -454,7 +454,7 @@ 选择 %d 编辑 查找嘟文时出错 %s - 您没有草稿。 + 您没有草稿。 您没有任何定时嘟文。 Mastodon的最小预订时间为5分钟。 关注请求 @@ -485,20 +485,17 @@ 隐藏顶部工具栏标题 本站暂无公告。 公告 - 已保存 + 已保存! 此账号的备注 取消关注 关注 该草稿回复的原嘟文已被删除 草稿已删除 加载回复信息失败 - 旧草稿 - Tusky 的草稿功能已被重新设计,现在它更快、更友好,Bug也更少。 -\n 旧草稿依然可以通过新草稿页面的按钮查看,但他们将在未来版本中移除! 嘟文发送失败! 确认删除列表 %s? - 最多只可上传 %1$d 个媒体附件 + 最多只可上传 %1$d 个媒体附件。 隐藏账号的统计信息 反馈通知 @@ -525,4 +522,6 @@ 关注的人发送了新嘟文 %s 发送了新嘟文 即使您的账号未上锁,管理员 %1$s 认为您可能需要手动处理来自这些账号的关注请求。 + 删除此对话吗? + 删除对话 \ No newline at end of file diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index 4d16d4c8..09037808 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 fda310d6..2d8b31ac 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 166a1dd0..86fcbe19 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 c041972c..a14af28f 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/attrs.xml b/app/src/main/res/values/attrs.xml index 3dd3e30f..66109928 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -10,6 +10,7 @@ + diff --git a/app/src/main/res/values/donottranslate.xml b/app/src/main/res/values/donottranslate.xml index 612c1fe3..43a44d88 100644 --- a/app/src/main/res/values/donottranslate.xml +++ b/app/src/main/res/values/donottranslate.xml @@ -47,6 +47,8 @@ Euskara Français Gaeilge + Gàidhlig + Galego íslenska Italiano Magyar @@ -62,6 +64,7 @@ Türkçe български Русский + Українська العربية کوردیی ناوەندی বাংলা (বাংলাদেশ) @@ -93,6 +96,8 @@ eu fr ga + gd + gl is it hu @@ -108,6 +113,7 @@ tr bg ru + uk ar ckb bn-bd diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fa8cadfb..29bb9092 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -72,8 +72,9 @@ Boost Remove boost Favorite - Bookmark Remove favorite + Bookmark + Remove bookmark More Compose Login to Instance @@ -88,6 +89,7 @@ Report Edit Delete + Delete conversation Delete and re-draft POST POST! @@ -127,7 +129,7 @@ Accept Reject Search - Drafts + Drafts Scheduled posts Post visibility Content warning @@ -200,6 +202,7 @@ Unfollow this account? Delete this post? Delete and re-draft this post? + 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? @@ -583,12 +586,13 @@ 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. Show link previews in timelines Show confirmation dialog before boosting + Show confirmation dialog before favoriting Hide the title of the top toolbar Wellbeing Your private note about this account @@ -611,13 +615,6 @@ Do you really want to delete the list %s? This post failed to send! - - - The draft feature in Chinwag 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 post you drafted a reply to has been removed diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 8872e124..71c6f607 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -54,6 +54,7 @@ @color/colorBackground @color/colorBackgroundAccent + @color/colorBackgroundHighlight @color/windowBackground @color/textColorPrimary @@ -142,6 +143,7 @@ @color/tusky_grey_10 @color/tusky_grey_40 + @color/tusky_grey_40 @color/tusky_grey_20 @color/tusky_grey_10 diff --git a/app/src/main/res/values/theme_colors.xml b/app/src/main/res/values/theme_colors.xml index bdb40788..bab199ea 100644 --- a/app/src/main/res/values/theme_colors.xml +++ b/app/src/main/res/values/theme_colors.xml @@ -15,6 +15,7 @@ @color/tusky_grey_50 @color/tusky_grey_70 + @color/tusky_grey_50 @color/tusky_grey_80 @color/tusky_orange_light 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 c4e4e4cc..dc8cd831 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 86d0925f..6d761b38 100644 --- a/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt @@ -17,28 +17,39 @@ 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 org.junit.Assert +import com.nhaarman.mockitokotlin2.doReturn +import com.nhaarman.mockitokotlin2.mock +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.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue 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.ArgumentMatchers.anyBoolean +import org.mockito.Mockito.eq +import org.mockito.Mockito.mock +import java.util.ArrayList +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,51 +57,47 @@ 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( + id = "1", + localUsername = "admin", + username = "admin", + displayName = "Ad Min", + note = SpannedString(""), + url = "http://mastodon.foo.bar", + avatar = "", + header = "", + locked = false, + followersCount = 0, + followingCount = 0, + statusesCount = 0 ) 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 + id = "1", + url = statusQuery, + account = account, + inReplyToId = null, + inReplyToAccountId = null, + reblog = null, + content = SpannedString("omgwat"), + createdAt = Date(), + emojis = emptyList(), + reblogsCount = 0, + favouritesCount = 0, + reblogged = false, + favourited = false, + bookmarked = false, + sensitive = false, + spoilerText = "", + visibility = Status.Visibility.PUBLIC, + attachments = ArrayList(), + mentions = emptyList(), + application = null, + pinned = false, + muted = false, + poll = null, + card = null ) private val statusSingle = Single.just(SearchResult(emptyList(), listOf(status), emptyList())) @@ -100,10 +107,11 @@ class BottomSheetActivityTest { RxJavaPlugins.setIoSchedulerHandler { testScheduler } RxAndroidPlugins.setMainThreadSchedulerHandler { testScheduler } - apiMock = mock(MastodonApi::class.java) - `when`(apiMock.searchObservable(eq(accountQuery), eq(null), ArgumentMatchers.anyBoolean(), eq(null), eq(null), eq(null))).thenReturn(accountSingle) - `when`(apiMock.searchObservable(eq(statusQuery), eq(null), ArgumentMatchers.anyBoolean(), eq(null), eq(null), eq(null))).thenReturn(statusSingle) - `when`(apiMock.searchObservable(eq(nonMastodonQuery), eq(null), ArgumentMatchers.anyBoolean(), eq(null), eq(null), eq(null))).thenReturn(emptyCallback) + apiMock = mock { + on { searchObservable(eq(accountQuery), eq(null), anyBoolean(), eq(null), eq(null), eq(null)) } doReturn accountSingle + on { searchObservable(eq(statusQuery), eq(null), anyBoolean(), eq(null), eq(null), eq(null)) } doReturn statusSingle + on { searchObservable(eq(nonMastodonQuery), eq(null), anyBoolean(), eq(null), eq(null), eq(null)) } doReturn emptyCallback + } activity = FakeBottomSheetActivity(apiMock) } @@ -113,7 +121,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), @@ -159,21 +167,23 @@ class BottomSheetActivityTest { arrayOf("https://friendica.foo.bar/profile/@mew/", false), arrayOf("https://misskey.foo.bar/notes/@nyan", false), arrayOf("https://misskey.foo.bar/notes/NYAN123", false), - arrayOf("https://misskey.foo.bar/notes/meow123/", false) + arrayOf("https://misskey.foo.bar/notes/meow123/", false), + arrayOf("https://pixelfed.social/p/connyduck/391263492998670833", true), + arrayOf("https://pixelfed.social/connyduck", true) ) } } @Test fun test() { - Assert.assertEquals(expectedResult, looksLikeMastodonUrl(url)) + assertEquals(expectedResult, looksLikeMastodonUrl(url)) } } @Test fun beginEndSearch_setIsSearching_isSearchingAfterBegin() { activity.onBeginSearch("https://mastodon.foo.bar/@User") - Assert.assertTrue(activity.isSearching()) + assertTrue(activity.isSearching()) } @Test @@ -181,7 +191,7 @@ class BottomSheetActivityTest { val validUrl = "https://mastodon.foo.bar/@User" activity.onBeginSearch(validUrl) activity.onEndSearch(validUrl) - Assert.assertFalse(activity.isSearching()) + assertFalse(activity.isSearching()) } @Test @@ -191,7 +201,7 @@ class BottomSheetActivityTest { activity.onBeginSearch(validUrl) activity.onEndSearch(invalidUrl) - Assert.assertTrue(activity.isSearching()) + assertTrue(activity.isSearching()) } @Test @@ -200,7 +210,7 @@ class BottomSheetActivityTest { activity.onBeginSearch(url) activity.cancelActiveSearch() - Assert.assertFalse(activity.isSearching()) + assertFalse(activity.isSearching()) } @Test @@ -212,29 +222,29 @@ class BottomSheetActivityTest { activity.cancelActiveSearch() activity.onBeginSearch(secondUrl) - Assert.assertTrue(activity.getCancelSearchRequested(firstUrl)) - Assert.assertFalse(activity.getCancelSearchRequested(secondUrl)) + assertTrue(activity.getCancelSearchRequested(firstUrl)) + assertFalse(activity.getCancelSearchRequested(secondUrl)) } @Test fun search_inIdealConditions_returnsRequestedResults_forAccount() { activity.viewUrl(accountQuery) testScheduler.advanceTimeBy(100, TimeUnit.MILLISECONDS) - Assert.assertEquals(account.id, activity.accountId) + assertEquals(account.id, activity.accountId) } @Test fun search_inIdealConditions_returnsRequestedResults_forStatus() { activity.viewUrl(statusQuery) testScheduler.advanceTimeBy(100, TimeUnit.MILLISECONDS) - Assert.assertEquals(status.id, activity.statusId) + assertEquals(status.id, activity.statusId) } @Test fun search_inIdealConditions_returnsRequestedResults_forNonMastodonURL() { activity.viewUrl(nonMastodonQuery) testScheduler.advanceTimeBy(100, TimeUnit.MILLISECONDS) - Assert.assertEquals(nonMastodonQuery, activity.link) + assertEquals(nonMastodonQuery, activity.link) } @Test @@ -242,32 +252,32 @@ class BottomSheetActivityTest { for (fallbackBehavior in listOf(PostLookupFallbackBehavior.OPEN_IN_BROWSER, PostLookupFallbackBehavior.DISPLAY_ERROR)) { activity.viewUrl(nonMastodonQuery, fallbackBehavior) testScheduler.advanceTimeBy(100, TimeUnit.MILLISECONDS) - Assert.assertEquals(nonMastodonQuery, activity.link) - Assert.assertEquals(fallbackBehavior, activity.fallbackBehavior) + assertEquals(nonMastodonQuery, activity.link) + assertEquals(fallbackBehavior, activity.fallbackBehavior) } } @Test fun search_withCancellation_doesNotLoadUrl_forAccount() { activity.viewUrl(accountQuery) - Assert.assertTrue(activity.isSearching()) + assertTrue(activity.isSearching()) activity.cancelActiveSearch() - Assert.assertFalse(activity.isSearching()) - Assert.assertEquals(null, activity.accountId) + assertFalse(activity.isSearching()) + assertEquals(null, activity.accountId) } @Test fun search_withCancellation_doesNotLoadUrl_forStatus() { activity.viewUrl(accountQuery) activity.cancelActiveSearch() - Assert.assertEquals(null, activity.accountId) + assertEquals(null, activity.accountId) } @Test fun search_withCancellation_doesNotLoadUrl_forNonMastodonURL() { activity.viewUrl(nonMastodonQuery) activity.cancelActiveSearch() - Assert.assertEquals(null, activity.searchUrl) + assertEquals(null, activity.searchUrl) } @Test @@ -280,15 +290,15 @@ class BottomSheetActivityTest { activity.viewUrl(statusQuery) // ensure that search is still ongoing - Assert.assertTrue(activity.isSearching()) + assertTrue(activity.isSearching()) // return searchResults testScheduler.advanceTimeBy(100, TimeUnit.MILLISECONDS) // ensure that the result of the status search was recorded // and the account search wasn't - Assert.assertEquals(status.id, activity.statusId) - Assert.assertEquals(null, activity.accountId) + assertEquals(status.id, activity.statusId) + assertEquals(null, activity.accountId) } class FakeBottomSheetActivity(api: MastodonApi) : BottomSheetActivity() { diff --git a/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt b/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt index bd9be3b2..156a6b41 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 b603a4a7..e203dde2 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 6b35d4a4..a48ff121 100644 --- a/app/src/test/java/com/keylesspalace/tusky/FilterTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/FilterTest.kt @@ -1,260 +1,213 @@ package com.keylesspalace.tusky -import android.os.Bundle import android.text.SpannedString import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.keylesspalace.tusky.entity.Attachment 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.ArrayList +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 shouldFilter_whenMediaDescriptionDoesMatch() { + assertTrue( + filterModel.shouldFilterStatus( + mockStatus( + content = "should not be filtered", + spoilerText = "should not be filtered", + attachmentsDescriptions = 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 + ) ) } -} - -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, + attachmentsDescriptions: 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 = if (attachmentsDescriptions != null) { + ArrayList( + attachmentsDescriptions.map { + Attachment( + id = "1234", + url = "", + previewUrl = null, + meta = null, + type = Attachment.Type.IMAGE, + description = it, + blurhash = null + ) + } + ) + } else 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, + ownVotes = null + ) + } else null, + card = 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 f3094ee9..444da061 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 68bdaaa9..21340560 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 7b23297e..5966cc39 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 7f82e249..7724ba76 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/components/timeline/CachedTimelineRemoteMediatorTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRemoteMediatorTest.kt new file mode 100644 index 00000000..ad633f3d --- /dev/null +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRemoteMediatorTest.kt @@ -0,0 +1,535 @@ +package com.keylesspalace.tusky.components.timeline + +import android.os.Looper.getMainLooper +import androidx.paging.ExperimentalPagingApi +import androidx.paging.LoadType +import androidx.paging.PagingConfig +import androidx.paging.PagingSource +import androidx.paging.PagingState +import androidx.paging.RemoteMediator +import androidx.room.Room +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.google.gson.Gson +import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineRemoteMediator +import com.keylesspalace.tusky.db.AccountEntity +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.db.Converters +import com.keylesspalace.tusky.db.TimelineStatusWithAccount +import com.nhaarman.mockitokotlin2.anyOrNull +import com.nhaarman.mockitokotlin2.doReturn +import com.nhaarman.mockitokotlin2.mock +import io.reactivex.rxjava3.core.Single +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.After +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 +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import retrofit2.HttpException +import retrofit2.Response +import java.io.IOException + +@Config(sdk = [28]) +@RunWith(AndroidJUnit4::class) +class CachedTimelineRemoteMediatorTest { + + private val accountManager: AccountManager = mock { + on { activeAccount } doReturn AccountEntity( + id = 1, + domain = "mastodon.example", + accessToken = "token", + isActive = true + ) + } + + private lateinit var db: AppDatabase + + @Before + @ExperimentalCoroutinesApi + fun setup() { + shadowOf(getMainLooper()).idle() + + val context = InstrumentationRegistry.getInstrumentation().targetContext + db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java) + .addTypeConverter(Converters(Gson())) + .build() + } + + @After + @ExperimentalCoroutinesApi + fun tearDown() { + db.close() + } + + @Test + @ExperimentalPagingApi + fun `should return error when network call returns error code`() { + + val remoteMediator = CachedTimelineRemoteMediator( + accountManager = accountManager, + api = mock { + on { homeTimeline(anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Single.just(Response.error(500, "".toResponseBody())) + }, + db = db, + gson = Gson() + ) + + val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state()) } + + assertTrue(result is RemoteMediator.MediatorResult.Error) + assertTrue((result as RemoteMediator.MediatorResult.Error).throwable is HttpException) + assertEquals(500, (result.throwable as HttpException).code()) + } + + @Test + @ExperimentalPagingApi + fun `should return error when network call fails`() { + + val remoteMediator = CachedTimelineRemoteMediator( + accountManager = accountManager, + api = mock { + on { homeTimeline(anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Single.error(IOException()) + }, + db = db, + gson = Gson() + ) + + val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state()) } + + assertTrue(result is RemoteMediator.MediatorResult.Error) + assertTrue((result as RemoteMediator.MediatorResult.Error).throwable is IOException) + } + + @Test + @ExperimentalPagingApi + fun `should not prepend statuses`() { + + val remoteMediator = CachedTimelineRemoteMediator( + accountManager = accountManager, + api = mock(), + db = db, + gson = Gson() + ) + + val state = state( + listOf( + PagingSource.LoadResult.Page( + data = listOf( + mockStatusEntityWithAccount("3") + ), + prevKey = null, + nextKey = 1 + ) + ) + ) + + val result = runBlocking { remoteMediator.load(LoadType.PREPEND, state) } + + assertTrue(result is RemoteMediator.MediatorResult.Success) + assertTrue((result as RemoteMediator.MediatorResult.Success).endOfPaginationReached) + } + + @Test + @ExperimentalPagingApi + fun `should refresh and insert placeholder`() { + + val statusesAlreadyInDb = listOf( + mockStatusEntityWithAccount("3"), + mockStatusEntityWithAccount("2"), + mockStatusEntityWithAccount("1"), + ) + + db.insert(statusesAlreadyInDb) + + val remoteMediator = CachedTimelineRemoteMediator( + accountManager = accountManager, + api = mock { + on { homeTimeline(limit = 20) } doReturn Single.just( + Response.success( + listOf( + mockStatus("8"), + mockStatus("7"), + mockStatus("5") + ) + ) + ) + on { homeTimeline(maxId = "3", limit = 20) } doReturn Single.just( + Response.success( + listOf( + mockStatus("3"), + mockStatus("2"), + mockStatus("1") + ) + ) + ) + }, + db = db, + gson = Gson() + ) + + val state = state( + listOf( + PagingSource.LoadResult.Page( + data = statusesAlreadyInDb, + prevKey = null, + nextKey = 0 + ) + ) + ) + + val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state) } + + assertTrue(result is RemoteMediator.MediatorResult.Success) + assertEquals(false, (result as RemoteMediator.MediatorResult.Success).endOfPaginationReached) + + db.assertStatuses( + listOf( + mockStatusEntityWithAccount("8"), + mockStatusEntityWithAccount("7"), + mockStatusEntityWithAccount("5"), + TimelineStatusWithAccount().apply { + status = Placeholder("4", loading = false).toEntity(1) + }, + mockStatusEntityWithAccount("3"), + mockStatusEntityWithAccount("2"), + mockStatusEntityWithAccount("1"), + ) + ) + } + + @Test + @ExperimentalPagingApi + fun `should refresh and not insert placeholders`() { + + val statusesAlreadyInDb = listOf( + mockStatusEntityWithAccount("3"), + mockStatusEntityWithAccount("2"), + mockStatusEntityWithAccount("1"), + ) + + db.insert(statusesAlreadyInDb) + + val remoteMediator = CachedTimelineRemoteMediator( + accountManager = accountManager, + api = mock { + on { homeTimeline(limit = 20) } doReturn Single.just( + Response.success( + listOf( + mockStatus("6"), + mockStatus("4"), + mockStatus("3") + ) + ) + ) + on { homeTimeline(maxId = "3", limit = 20) } doReturn Single.just( + Response.success( + listOf( + mockStatus("3"), + mockStatus("2"), + mockStatus("1") + ) + ) + ) + }, + db = db, + gson = Gson() + ) + + val state = state( + listOf( + PagingSource.LoadResult.Page( + data = statusesAlreadyInDb, + prevKey = null, + nextKey = 0 + ) + ) + ) + + val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state) } + + assertTrue(result is RemoteMediator.MediatorResult.Success) + assertEquals(false, (result as RemoteMediator.MediatorResult.Success).endOfPaginationReached) + + db.assertStatuses( + listOf( + mockStatusEntityWithAccount("6"), + mockStatusEntityWithAccount("4"), + mockStatusEntityWithAccount("3"), + mockStatusEntityWithAccount("2"), + mockStatusEntityWithAccount("1"), + ) + ) + } + + @Test + @ExperimentalPagingApi + fun `should not try to refresh already cached statuses when db is empty`() { + + val remoteMediator = CachedTimelineRemoteMediator( + accountManager = accountManager, + api = mock { + on { homeTimeline(limit = 20) } doReturn Single.just( + Response.success( + listOf( + mockStatus("5"), + mockStatus("4"), + mockStatus("3") + ) + ) + ) + }, + db = db, + gson = Gson() + ) + + val state = state( + listOf( + PagingSource.LoadResult.Page( + data = emptyList(), + prevKey = null, + nextKey = 0 + ) + ) + ) + + val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state) } + + assertTrue(result is RemoteMediator.MediatorResult.Success) + assertEquals(false, (result as RemoteMediator.MediatorResult.Success).endOfPaginationReached) + + db.assertStatuses( + listOf( + mockStatusEntityWithAccount("5"), + mockStatusEntityWithAccount("4"), + mockStatusEntityWithAccount("3") + ) + ) + } + + @Test + @ExperimentalPagingApi + fun `should remove deleted status from db and keep state of other cached statuses`() { + + val statusesAlreadyInDb = listOf( + mockStatusEntityWithAccount("3", expanded = true), + mockStatusEntityWithAccount("2"), + mockStatusEntityWithAccount("1", expanded = false), + ) + + db.insert(statusesAlreadyInDb) + + val remoteMediator = CachedTimelineRemoteMediator( + accountManager = accountManager, + api = mock { + on { homeTimeline(limit = 20) } doReturn Single.just( + Response.success(emptyList()) + ) + on { homeTimeline(maxId = "3", limit = 20) } doReturn Single.just( + Response.success( + listOf( + mockStatus("3"), + mockStatus("1") + ) + ) + ) + }, + db = db, + gson = Gson() + ) + + val state = state( + listOf( + PagingSource.LoadResult.Page( + data = statusesAlreadyInDb, + prevKey = null, + nextKey = 0 + ) + ) + ) + + val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state) } + + assertTrue(result is RemoteMediator.MediatorResult.Success) + assertTrue((result as RemoteMediator.MediatorResult.Success).endOfPaginationReached) + + db.assertStatuses( + listOf( + mockStatusEntityWithAccount("3", expanded = true), + mockStatusEntityWithAccount("1", expanded = false) + ) + ) + } + + @Test + @ExperimentalPagingApi + fun `should not remove placeholder in timeline`() { + + val statusesAlreadyInDb = listOf( + mockStatusEntityWithAccount("8"), + mockStatusEntityWithAccount("7"), + mockPlaceholderEntityWithAccount("6"), + mockStatusEntityWithAccount("1"), + ) + + db.insert(statusesAlreadyInDb) + + val remoteMediator = CachedTimelineRemoteMediator( + accountManager = accountManager, + api = mock { + on { homeTimeline(sinceId = "6", limit = 20) } doReturn Single.just( + Response.success( + listOf( + mockStatus("9"), + mockStatus("8"), + mockStatus("7") + ) + ) + ) + on { homeTimeline(maxId = "8", sinceId = "6", limit = 20) } doReturn Single.just( + Response.success( + listOf( + mockStatus("8"), + mockStatus("7") + ) + ) + ) + }, + db = db, + gson = Gson() + ) + + val state = state( + listOf( + PagingSource.LoadResult.Page( + data = statusesAlreadyInDb, + prevKey = null, + nextKey = 0 + ) + ) + ) + + val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state) } + + assertTrue(result is RemoteMediator.MediatorResult.Success) + assertFalse((result as RemoteMediator.MediatorResult.Success).endOfPaginationReached) + + db.assertStatuses( + listOf( + mockStatusEntityWithAccount("9"), + mockStatusEntityWithAccount("8"), + mockStatusEntityWithAccount("7"), + mockPlaceholderEntityWithAccount("6"), + mockStatusEntityWithAccount("1"), + ) + ) + } + + @Test + @ExperimentalPagingApi + fun `should append statuses`() { + + val statusesAlreadyInDb = listOf( + mockStatusEntityWithAccount("8"), + mockStatusEntityWithAccount("7"), + mockStatusEntityWithAccount("5"), + ) + + db.insert(statusesAlreadyInDb) + + val remoteMediator = CachedTimelineRemoteMediator( + accountManager = accountManager, + api = mock { + on { homeTimeline(maxId = "5", limit = 20) } doReturn Single.just( + Response.success( + listOf( + mockStatus("3"), + mockStatus("2"), + mockStatus("1") + ) + ) + ) + }, + db = db, + gson = Gson() + ) + + val state = state( + listOf( + PagingSource.LoadResult.Page( + data = statusesAlreadyInDb, + prevKey = null, + nextKey = 0 + ) + ) + ) + + val result = runBlocking { remoteMediator.load(LoadType.APPEND, state) } + + assertTrue(result is RemoteMediator.MediatorResult.Success) + assertEquals(false, (result as RemoteMediator.MediatorResult.Success).endOfPaginationReached) + db.assertStatuses( + listOf( + mockStatusEntityWithAccount("8"), + mockStatusEntityWithAccount("7"), + mockStatusEntityWithAccount("5"), + mockStatusEntityWithAccount("3"), + mockStatusEntityWithAccount("2"), + mockStatusEntityWithAccount("1"), + ) + ) + } + + private fun state(pages: List> = emptyList()) = PagingState( + pages = pages, + anchorPosition = null, + config = PagingConfig( + pageSize = 20 + ), + leadingPlaceholderCount = 0 + ) + + private fun AppDatabase.insert(statuses: List) { + runBlocking { + statuses.forEach { statusWithAccount -> + if (statusWithAccount.status.authorServerId != null) { + timelineDao().insertAccount(statusWithAccount.account) + } + statusWithAccount.reblogAccount?.let { account -> + timelineDao().insertAccount(account) + } + timelineDao().insertStatus(statusWithAccount.status) + } + } + } + + private fun AppDatabase.assertStatuses( + expected: List, + forAccount: Long = 1 + ) { + val pagingSource = timelineDao().getStatusesForAccount(forAccount) + + val loadResult = runBlocking { + pagingSource.load(PagingSource.LoadParams.Refresh(null, 100, false)) + } + + val loadedStatuses = (loadResult as PagingSource.LoadResult.Page).data + + assertEquals(expected.size, loadedStatuses.size) + + for ((exp, prov) in expected.zip(loadedStatuses)) { + assertEquals(exp.status, prov.status) + if (exp.status.authorServerId != null) { // only check if no placeholder + assertEquals(exp.account, prov.account) + assertEquals(exp.reblogAccount, prov.reblogAccount) + } + } + } +} diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelinePagingSourceTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelinePagingSourceTest.kt new file mode 100644 index 00000000..2e67c6fe --- /dev/null +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelinePagingSourceTest.kt @@ -0,0 +1,59 @@ +package com.keylesspalace.tusky.components.timeline + +import androidx.paging.PagingSource +import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelinePagingSource +import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel +import com.nhaarman.mockitokotlin2.doReturn +import com.nhaarman.mockitokotlin2.mock +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Test + +class NetworkTimelinePagingSourceTest { + + private val status = mockStatusViewData() + + private val timelineViewModel: NetworkTimelineViewModel = mock { + on { statusData } doReturn mutableListOf(status) + } + + @Test + fun `should return empty list when params are Append`() { + val pagingSource = NetworkTimelinePagingSource(timelineViewModel) + + val params = PagingSource.LoadParams.Append("132", 20, false) + + val expectedResult = PagingSource.LoadResult.Page(emptyList(), null, null) + + runBlocking { + assertEquals(expectedResult, pagingSource.load(params)) + } + } + + @Test + fun `should return empty list when params are Prepend`() { + val pagingSource = NetworkTimelinePagingSource(timelineViewModel) + + val params = PagingSource.LoadParams.Prepend("132", 20, false) + + val expectedResult = PagingSource.LoadResult.Page(emptyList(), null, null) + + runBlocking { + assertEquals(expectedResult, pagingSource.load(params)) + } + } + + @Test + fun `should return full list when params are Refresh`() { + val pagingSource = NetworkTimelinePagingSource(timelineViewModel) + + val params = PagingSource.LoadParams.Refresh(null, 20, false) + + val expectedResult = PagingSource.LoadResult.Page(listOf(status), null, null) + + runBlocking { + val result = pagingSource.load(params) + assertEquals(expectedResult, result) + } + } +} diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt new file mode 100644 index 00000000..601fc00a --- /dev/null +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt @@ -0,0 +1,393 @@ +package com.keylesspalace.tusky.components.timeline + +import androidx.paging.ExperimentalPagingApi +import androidx.paging.LoadType +import androidx.paging.PagingConfig +import androidx.paging.PagingSource +import androidx.paging.PagingState +import androidx.paging.RemoteMediator +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineRemoteMediator +import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel +import com.keylesspalace.tusky.db.AccountEntity +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.viewdata.StatusViewData +import com.nhaarman.mockitokotlin2.anyOrNull +import com.nhaarman.mockitokotlin2.doReturn +import com.nhaarman.mockitokotlin2.doThrow +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.verify +import kotlinx.coroutines.runBlocking +import okhttp3.Headers +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import retrofit2.HttpException +import retrofit2.Response +import java.lang.RuntimeException + +@Config(sdk = [29]) +@RunWith(AndroidJUnit4::class) +class NetworkTimelineRemoteMediatorTest { + + private val accountManager: AccountManager = mock { + on { activeAccount } doReturn AccountEntity( + id = 1, + domain = "mastodon.example", + accessToken = "token", + isActive = true + ) + } + + @Test + @ExperimentalPagingApi + fun `should return error when network call returns error code`() { + + val timelineViewModel: NetworkTimelineViewModel = mock { + on { statusData } doReturn mutableListOf() + onBlocking { fetchStatusesForKind(anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Response.error(500, "".toResponseBody()) + } + + val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel) + + val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state()) } + + assertTrue(result is RemoteMediator.MediatorResult.Error) + assertTrue((result as RemoteMediator.MediatorResult.Error).throwable is HttpException) + assertEquals(500, (result.throwable as HttpException).code()) + } + + @Test + @ExperimentalPagingApi + fun `should return error when network call fails`() { + + val timelineViewModel: NetworkTimelineViewModel = mock { + on { statusData } doReturn mutableListOf() + onBlocking { fetchStatusesForKind(anyOrNull(), anyOrNull(), anyOrNull()) } doThrow RuntimeException() + } + + val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel) + + val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state()) } + + assertTrue(result is RemoteMediator.MediatorResult.Error) + assertTrue((result as RemoteMediator.MediatorResult.Error).throwable is RuntimeException) + } + + @Test + @ExperimentalPagingApi + fun `should do initial loading`() { + val statuses: MutableList = mutableListOf() + + val timelineViewModel: NetworkTimelineViewModel = mock { + on { statusData } doReturn statuses + on { nextKey } doReturn null + onBlocking { fetchStatusesForKind(null, null, 20) } doReturn Response.success( + listOf( + mockStatus("7"), + mockStatus("6"), + mockStatus("5") + ), + Headers.headersOf( + "Link", "; rel=\"next\", ; rel=\"prev\"" + ) + ) + } + + val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel) + + val state = state( + listOf( + PagingSource.LoadResult.Page( + data = emptyList(), + prevKey = null, + nextKey = null + ) + ) + ) + + val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state) } + + val newStatusData = mutableListOf( + mockStatusViewData("7"), + mockStatusViewData("6"), + mockStatusViewData("5"), + ) + + verify(timelineViewModel).nextKey = "4" + assertTrue(result is RemoteMediator.MediatorResult.Success) + assertEquals(false, (result as RemoteMediator.MediatorResult.Success).endOfPaginationReached) + assertEquals(newStatusData, statuses) + } + + @Test + @ExperimentalPagingApi + fun `should not prepend statuses`() { + val statuses: MutableList = mutableListOf( + mockStatusViewData("3"), + mockStatusViewData("2"), + mockStatusViewData("1"), + ) + + val timelineViewModel: NetworkTimelineViewModel = mock { + on { statusData } doReturn statuses + on { nextKey } doReturn "0" + onBlocking { fetchStatusesForKind(null, null, 20) } doReturn Response.success( + listOf( + mockStatus("5"), + mockStatus("4"), + mockStatus("3") + ) + ) + } + + val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel) + + val state = state( + listOf( + PagingSource.LoadResult.Page( + data = listOf( + mockStatusViewData("3"), + mockStatusViewData("2"), + mockStatusViewData("1"), + ), + prevKey = null, + nextKey = "0" + ) + ) + ) + + val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state) } + + val newStatusData = mutableListOf( + mockStatusViewData("5"), + mockStatusViewData("4"), + mockStatusViewData("3"), + mockStatusViewData("2"), + mockStatusViewData("1"), + ) + + assertTrue(result is RemoteMediator.MediatorResult.Success) + assertEquals(false, (result as RemoteMediator.MediatorResult.Success).endOfPaginationReached) + assertEquals(newStatusData, statuses) + } + + @Test + @ExperimentalPagingApi + fun `should refresh and insert placeholder`() { + val statuses: MutableList = mutableListOf( + mockStatusViewData("3"), + mockStatusViewData("2"), + mockStatusViewData("1"), + ) + + val timelineViewModel: NetworkTimelineViewModel = mock { + on { statusData } doReturn statuses + on { nextKey } doReturn "0" + onBlocking { fetchStatusesForKind(null, null, 20) } doReturn Response.success( + listOf( + mockStatus("10"), + mockStatus("9"), + mockStatus("7") + ) + ) + } + + val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel) + + val state = state( + listOf( + PagingSource.LoadResult.Page( + data = listOf( + mockStatusViewData("3"), + mockStatusViewData("2"), + mockStatusViewData("1"), + ), + prevKey = null, + nextKey = "0" + ) + ) + ) + + val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state) } + + val newStatusData = mutableListOf( + mockStatusViewData("10"), + mockStatusViewData("9"), + mockStatusViewData("7"), + StatusViewData.Placeholder("6", false), + mockStatusViewData("3"), + mockStatusViewData("2"), + mockStatusViewData("1"), + ) + + assertTrue(result is RemoteMediator.MediatorResult.Success) + assertEquals(false, (result as RemoteMediator.MediatorResult.Success).endOfPaginationReached) + assertEquals(newStatusData, statuses) + } + + @Test + @ExperimentalPagingApi + fun `should refresh and not insert placeholders`() { + val statuses: MutableList = mutableListOf( + mockStatusViewData("8"), + mockStatusViewData("7"), + mockStatusViewData("5"), + ) + + val timelineViewModel: NetworkTimelineViewModel = mock { + on { statusData } doReturn statuses + on { nextKey } doReturn "3" + onBlocking { fetchStatusesForKind("3", null, 20) } doReturn Response.success( + listOf( + mockStatus("3"), + mockStatus("2"), + mockStatus("1") + ) + ) + } + + val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel) + + val state = state( + listOf( + PagingSource.LoadResult.Page( + data = listOf( + mockStatusViewData("8"), + mockStatusViewData("7"), + mockStatusViewData("5"), + ), + prevKey = null, + nextKey = "3" + ) + ) + ) + + val result = runBlocking { remoteMediator.load(LoadType.APPEND, state) } + + val newStatusData = mutableListOf( + mockStatusViewData("8"), + mockStatusViewData("7"), + mockStatusViewData("5"), + mockStatusViewData("3"), + mockStatusViewData("2"), + mockStatusViewData("1"), + ) + + assertTrue(result is RemoteMediator.MediatorResult.Success) + assertEquals(false, (result as RemoteMediator.MediatorResult.Success).endOfPaginationReached) + assertEquals(newStatusData, statuses) + } + + @Test + @ExperimentalPagingApi + fun `should append statuses`() { + val statuses: MutableList = mutableListOf( + mockStatusViewData("8"), + mockStatusViewData("7"), + mockStatusViewData("5"), + ) + + val timelineViewModel: NetworkTimelineViewModel = mock { + on { statusData } doReturn statuses + on { nextKey } doReturn "3" + onBlocking { fetchStatusesForKind("3", null, 20) } doReturn Response.success( + listOf( + mockStatus("3"), + mockStatus("2"), + mockStatus("1") + ), + Headers.headersOf( + "Link", "; rel=\"next\", ; rel=\"prev\"" + ) + ) + } + + val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel) + + val state = state( + listOf( + PagingSource.LoadResult.Page( + data = listOf( + mockStatusViewData("8"), + mockStatusViewData("7"), + mockStatusViewData("5"), + ), + prevKey = null, + nextKey = "3" + ) + ) + ) + + val result = runBlocking { remoteMediator.load(LoadType.APPEND, state) } + + val newStatusData = mutableListOf( + mockStatusViewData("8"), + mockStatusViewData("7"), + mockStatusViewData("5"), + mockStatusViewData("3"), + mockStatusViewData("2"), + mockStatusViewData("1"), + ) + + verify(timelineViewModel).nextKey = "0" + assertTrue(result is RemoteMediator.MediatorResult.Success) + assertEquals(false, (result as RemoteMediator.MediatorResult.Success).endOfPaginationReached) + assertEquals(newStatusData, statuses) + } + + @Test + @ExperimentalPagingApi + fun `should not append statuses when pagination end has been reached`() { + val statuses: MutableList = mutableListOf( + mockStatusViewData("8"), + mockStatusViewData("7"), + mockStatusViewData("5"), + ) + + val timelineViewModel: NetworkTimelineViewModel = mock { + on { statusData } doReturn statuses + on { nextKey } doReturn null + } + + val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel) + + val state = state( + listOf( + PagingSource.LoadResult.Page( + data = listOf( + mockStatusViewData("8"), + mockStatusViewData("7"), + mockStatusViewData("5"), + ), + prevKey = null, + nextKey = null + ) + ) + ) + + val result = runBlocking { remoteMediator.load(LoadType.APPEND, state) } + + val newStatusData = mutableListOf( + mockStatusViewData("8"), + mockStatusViewData("7"), + mockStatusViewData("5") + ) + + assertTrue(result is RemoteMediator.MediatorResult.Success) + assertTrue((result as RemoteMediator.MediatorResult.Success).endOfPaginationReached) + assertEquals(newStatusData, statuses) + } + + private fun state(pages: List> = emptyList()) = PagingState( + pages = pages, + anchorPosition = null, + config = PagingConfig( + pageSize = 20 + ), + leadingPlaceholderCount = 0 + ) +} diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/StatusMocker.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/StatusMocker.kt new file mode 100644 index 00000000..c4ab2faa --- /dev/null +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/StatusMocker.kt @@ -0,0 +1,88 @@ +package com.keylesspalace.tusky.components.timeline + +import android.text.SpannedString +import com.google.gson.Gson +import com.keylesspalace.tusky.db.TimelineStatusWithAccount +import com.keylesspalace.tusky.entity.Account +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.viewdata.StatusViewData +import java.util.ArrayList +import java.util.Date + +private val fixedDate = Date(1638889052000) + +fun mockStatus(id: String = "100") = Status( + id = id, + url = "https://mastodon.example/@ConnyDuck/$id", + account = Account( + id = "1", + localUsername = "connyduck", + username = "connyduck@mastodon.example", + displayName = "Conny Duck", + note = SpannedString(""), + url = "https://mastodon.example/@ConnyDuck", + avatar = "https://mastodon.example/system/accounts/avatars/000/150/486/original/ab27d7ddd18a10ea.jpg", + header = "https://mastodon.example/system/accounts/header/000/106/476/original/e590545d7eb4da39.jpg" + ), + inReplyToId = null, + inReplyToAccountId = null, + reblog = null, + content = SpannedString("Test"), + createdAt = fixedDate, + emojis = emptyList(), + reblogsCount = 1, + favouritesCount = 2, + reblogged = false, + favourited = true, + bookmarked = true, + sensitive = true, + spoilerText = "", + visibility = Status.Visibility.PUBLIC, + attachments = ArrayList(), + mentions = emptyList(), + application = Status.Application("Tusky", "https://tusky.app"), + pinned = false, + muted = false, + poll = null, + card = null +) + +fun mockStatusViewData(id: String = "100") = StatusViewData.Concrete( + status = mockStatus(id), + isExpanded = false, + isShowingContent = false, + isCollapsible = false, + isCollapsed = true, +) + +fun mockStatusEntityWithAccount( + id: String = "100", + userId: Long = 1, + expanded: Boolean = false +): TimelineStatusWithAccount { + val mockedStatus = mockStatus(id) + val gson = Gson() + + return TimelineStatusWithAccount().apply { + status = mockedStatus.toEntity( + timelineUserId = userId, + gson = gson, + expanded = expanded, + contentShowing = false, + contentCollapsed = true + ) + account = mockedStatus.account.toEntity( + accountId = userId, + gson = gson + ) + } +} + +fun mockPlaceholderEntityWithAccount( + id: String, + userId: Long = 1, +): TimelineStatusWithAccount { + return TimelineStatusWithAccount().apply { + status = Placeholder(id, false).toEntity(userId) + } +} 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 00000000..e99cb14a --- /dev/null +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/TimelineViewModelTest.kt @@ -0,0 +1,216 @@ +package com.keylesspalace.tusky.components.timeline + +import android.os.Looper +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.paging.AsyncPagingDataDiffer +import androidx.paging.ExperimentalPagingApi +import androidx.recyclerview.widget.ListUpdateCallback +import androidx.room.Room +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.google.gson.Gson +import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.components.timeline.TimelinePagingAdapter.Companion.TimelineDifferCallback +import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineViewModel +import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel +import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel +import com.keylesspalace.tusky.db.AccountEntity +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.db.Converters +import com.keylesspalace.tusky.network.FilterModel +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.network.TimelineCases +import com.nhaarman.mockitokotlin2.doReturn +import com.nhaarman.mockitokotlin2.mock +import io.reactivex.rxjava3.core.Single +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.TestCoroutineDispatcher +import kotlinx.coroutines.test.TestCoroutineScope +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import okhttp3.Headers +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import retrofit2.Response +import java.util.concurrent.Executors + +@ExperimentalCoroutinesApi +@Config(sdk = [29]) +@RunWith(AndroidJUnit4::class) +class TimelineViewModelTest { + + @get:Rule + val instantRule = InstantTaskExecutorRule() + + private val testDispatcher = TestCoroutineDispatcher() + private val testScope = TestCoroutineScope(testDispatcher) + + private val accountManager: AccountManager = mock { + on { activeAccount } doReturn AccountEntity( + id = 1, + domain = "mastodon.example", + accessToken = "token", + isActive = true + ) + } + + private val eventHub = EventHub() + + private lateinit var db: AppDatabase + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + + shadowOf(Looper.getMainLooper()).idle() + + val context = InstrumentationRegistry.getInstrumentation().targetContext + db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java) + .addTypeConverter(Converters(Gson())) + .setTransactionExecutor(Executors.newSingleThreadExecutor()) + .allowMainThreadQueries() + .build() + } + + @After + fun tearDown() { + Dispatchers.resetMain() + testDispatcher.cleanupTestCoroutines() + db.close() + } + + @Test + @ExperimentalPagingApi + fun shouldLoadNetworkTimeline() = runBlocking { + + val api: MastodonApi = mock { + on { publicTimeline(local = true, maxId = null, sinceId = null, limit = 30) } doReturn Single.just( + Response.success( + listOf( + mockStatus("6"), + mockStatus("5"), + mockStatus("4") + ), + Headers.headersOf( + "Link", "; rel=\"next\", ; rel=\"prev\"" + ) + ) + ) + + on { publicTimeline(local = true, maxId = "1", sinceId = null, limit = 30) } doReturn Single.just( + Response.success(emptyList()) + ) + + on { getFilters() } doReturn Single.just(emptyList()) + } + + val viewModel = NetworkTimelineViewModel( + TimelineCases(api, eventHub), + api, + eventHub, + accountManager, + mock(), + FilterModel() + ) + + viewModel.init(TimelineViewModel.Kind.PUBLIC_LOCAL, null, emptyList()) + + val differ = AsyncPagingDataDiffer( + diffCallback = TimelineDifferCallback, + updateCallback = NoopListCallback(), + workerDispatcher = testDispatcher + ) + + viewModel.statuses.take(2).collectLatest { + testScope.launch { + differ.submitData(it) + } + } + + assertEquals( + listOf( + mockStatusViewData("6"), + mockStatusViewData("5"), + mockStatusViewData("4") + ), + differ.snapshot().items + ) + } + + // ToDo: Find out why Room & coroutines are not playing nice here + // @Test + @ExperimentalPagingApi + fun shouldLoadCachedTimeline() = runBlocking { + + val api: MastodonApi = mock { + on { homeTimeline(limit = 30) } doReturn Single.just( + Response.success( + listOf( + mockStatus("6"), + mockStatus("5"), + mockStatus("4") + ) + ) + ) + + on { homeTimeline(maxId = "1", sinceId = null, limit = 30) } doReturn Single.just( + Response.success(emptyList()) + ) + + on { getFilters() } doReturn Single.just(emptyList()) + } + + val viewModel = CachedTimelineViewModel( + TimelineCases(api, eventHub), + api, + eventHub, + accountManager, + mock(), + FilterModel(), + db, + Gson() + ) + + viewModel.init(TimelineViewModel.Kind.HOME, null, emptyList()) + + val differ = AsyncPagingDataDiffer( + diffCallback = TimelineDifferCallback, + updateCallback = NoopListCallback(), + workerDispatcher = testDispatcher + ) + + viewModel.statuses.take(1000).collectLatest { + testScope.launch { + differ.submitData(it) + } + } + + assertEquals( + listOf( + mockStatusViewData("6"), + mockStatusViewData("5"), + mockStatusViewData("4") + ), + differ.snapshot().items + ) + } +} + +class NoopListCallback : ListUpdateCallback { + override fun onChanged(position: Int, count: Int, payload: Any?) {} + override fun onMoved(fromPosition: Int, toPosition: Int) {} + override fun onInserted(position: Int, count: Int) {} + override fun onRemoved(position: Int, count: Int) {} +} diff --git a/app/src/test/java/com/keylesspalace/tusky/db/TimelineDaoTest.kt b/app/src/test/java/com/keylesspalace/tusky/db/TimelineDaoTest.kt new file mode 100644 index 00000000..c60f7d4f --- /dev/null +++ b/app/src/test/java/com/keylesspalace/tusky/db/TimelineDaoTest.kt @@ -0,0 +1,445 @@ +package com.keylesspalace.tusky.db + +import androidx.paging.PagingSource +import androidx.room.Room +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.google.gson.Gson +import com.keylesspalace.tusky.components.timeline.Placeholder +import com.keylesspalace.tusky.components.timeline.toEntity +import com.keylesspalace.tusky.entity.Status +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@Config(sdk = [28]) +@RunWith(AndroidJUnit4::class) +class TimelineDaoTest { + private lateinit var timelineDao: TimelineDao + private lateinit var db: AppDatabase + + @Before + fun createDb() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java) + .addTypeConverter(Converters(Gson())) + .allowMainThreadQueries() + .build() + timelineDao = db.timelineDao() + } + + @After + fun closeDb() { + db.close() + } + + @Test + fun insertGetStatus() = runBlocking { + val setOne = makeStatus(statusId = 3) + val setTwo = makeStatus(statusId = 20, reblog = true) + val ignoredOne = makeStatus(statusId = 1) + val ignoredTwo = makeStatus(accountId = 2) + + for ((status, author, reblogger) in listOf(setOne, setTwo, ignoredOne, ignoredTwo)) { + timelineDao.insertAccount(author) + reblogger?.let { + timelineDao.insertAccount(it) + } + timelineDao.insertStatus(status) + } + + val pagingSource = timelineDao.getStatusesForAccount(setOne.first.timelineUserId) + + val loadResult = pagingSource.load(PagingSource.LoadParams.Refresh(null, 2, false)) + + val loadedStatuses = (loadResult as PagingSource.LoadResult.Page).data + + assertEquals(2, loadedStatuses.size) + assertStatuses(listOf(setTwo, setOne), loadedStatuses) + } + + @Test + fun cleanup() = runBlocking { + + val statusesBeforeCleanup = listOf( + makeStatus(statusId = 100), + makeStatus(statusId = 10, authorServerId = "3"), + makeStatus(statusId = 8, reblog = true, authorServerId = "10"), + makeStatus(statusId = 5), + makeStatus(statusId = 3, authorServerId = "4"), + makeStatus(statusId = 2, accountId = 2, authorServerId = "5"), + makeStatus(statusId = 1, authorServerId = "5") + ) + + val statusesAfterCleanup = listOf( + makeStatus(statusId = 100), + makeStatus(statusId = 10, authorServerId = "3"), + makeStatus(statusId = 8, reblog = true, authorServerId = "10"), + makeStatus(statusId = 2, accountId = 2, authorServerId = "5"), + ) + + for ((status, author, reblogAuthor) in statusesBeforeCleanup) { + timelineDao.insertAccount(author) + reblogAuthor?.let { + timelineDao.insertAccount(it) + } + timelineDao.insertStatus(status) + } + + timelineDao.cleanup(accountId = 1, limit = 3) + timelineDao.cleanupAccounts(accountId = 1) + + val loadParams: PagingSource.LoadParams = PagingSource.LoadParams.Refresh(null, 100, false) + + val loadedStatuses = (timelineDao.getStatusesForAccount(1).load(loadParams) as PagingSource.LoadResult.Page).data + + assertStatuses(statusesAfterCleanup, loadedStatuses) + + val loadedAccounts: MutableList> = mutableListOf() + val accountCursor = db.query("SELECT timelineUserId, serverId FROM TimelineAccountEntity", null) + accountCursor.moveToFirst() + while (!accountCursor.isAfterLast) { + val accountId: Long = accountCursor.getLong(accountCursor.getColumnIndex("timelineUserId")) + val serverId: String = accountCursor.getString(accountCursor.getColumnIndex("serverId")) + loadedAccounts.add(accountId to serverId) + accountCursor.moveToNext() + } + + val expectedAccounts = listOf( + 1L to "3", + 1L to "10", + 1L to "R10", + 1L to "20", + 2L to "5" + ) + + assertEquals(expectedAccounts, loadedAccounts) + } + + @Test + fun overwriteDeletedStatus() = runBlocking { + + val oldStatuses = listOf( + makeStatus(statusId = 3), + makeStatus(statusId = 2), + makeStatus(statusId = 1) + ) + + for ((status, author, reblogAuthor) in oldStatuses) { + timelineDao.insertAccount(author) + reblogAuthor?.let { + timelineDao.insertAccount(it) + } + timelineDao.insertStatus(status) + } + + // status 2 gets deleted, newly loaded status contain only 1 + 3 + val newStatuses = listOf( + makeStatus(statusId = 3), + makeStatus(statusId = 1) + ) + + timelineDao.deleteRange(1, newStatuses.last().first.serverId, newStatuses.first().first.serverId) + + for ((status, author, reblogAuthor) in newStatuses) { + timelineDao.insertAccount(author) + reblogAuthor?.let { + timelineDao.insertAccount(it) + } + timelineDao.insertStatus(status) + } + + // make sure status 2 is no longer in db + + val pagingSource = timelineDao.getStatusesForAccount(1) + + val loadResult = pagingSource.load(PagingSource.LoadParams.Refresh(null, 100, false)) + + val loadedStatuses = (loadResult as PagingSource.LoadResult.Page).data + + assertStatuses(newStatuses, loadedStatuses) + } + + @Test + fun deleteRange() = runBlocking { + val statuses = listOf( + makeStatus(statusId = 100), + makeStatus(statusId = 15), + makeStatus(statusId = 14), + makeStatus(statusId = 13), + makeStatus(statusId = 12), + makeStatus(statusId = 11), + makeStatus(statusId = 9) + ) + + for ((status, author, reblogAuthor) in statuses) { + timelineDao.insertAccount(author) + reblogAuthor?.let { + timelineDao.insertAccount(it) + } + timelineDao.insertStatus(status) + } + + timelineDao.deleteRange(1, "12", "14") + + val pagingSource = timelineDao.getStatusesForAccount(1) + val loadResult = pagingSource.load(PagingSource.LoadParams.Refresh(null, 100, false)) + val loadedStatuses = (loadResult as PagingSource.LoadResult.Page).data + + val remainingStatuses = listOf( + makeStatus(statusId = 100), + makeStatus(statusId = 15), + makeStatus(statusId = 11), + makeStatus(statusId = 9) + ) + + assertStatuses(remainingStatuses, loadedStatuses) + } + + @Test + fun deleteAllForInstance() = runBlocking { + + val statusWithRedDomain1 = makeStatus( + statusId = 15, + accountId = 1, + domain = "mastodon.red", + authorServerId = "1" + ) + val statusWithRedDomain2 = makeStatus( + statusId = 14, + accountId = 1, + domain = "mastodon.red", + authorServerId = "2" + ) + val statusWithRedDomainOtherAccount = makeStatus( + statusId = 12, + accountId = 2, + domain = "mastodon.red", + authorServerId = "2" + ) + val statusWithBlueDomain = makeStatus( + statusId = 10, + accountId = 1, + domain = "mastodon.blue", + authorServerId = "4" + ) + val statusWithBlueDomainOtherAccount = makeStatus( + statusId = 10, + accountId = 2, + domain = "mastodon.blue", + authorServerId = "5" + ) + val statusWithGreenDomain = makeStatus( + statusId = 8, + accountId = 1, + domain = "mastodon.green", + authorServerId = "6" + ) + + for ((status, author, reblogAuthor) in listOf(statusWithRedDomain1, statusWithRedDomain2, statusWithRedDomainOtherAccount, statusWithBlueDomain, statusWithBlueDomainOtherAccount, statusWithGreenDomain)) { + timelineDao.insertAccount(author) + reblogAuthor?.let { + timelineDao.insertAccount(it) + } + timelineDao.insertStatus(status) + } + + timelineDao.deleteAllFromInstance(1, "mastodon.red") + timelineDao.deleteAllFromInstance(1, "mastodon.blu") // shouldn't delete anything + timelineDao.deleteAllFromInstance(1, "greenmastodon.green") // shouldn't delete anything + + val loadParams: PagingSource.LoadParams = PagingSource.LoadParams.Refresh(null, 100, false) + + val statusesAccount1 = (timelineDao.getStatusesForAccount(1).load(loadParams) as PagingSource.LoadResult.Page).data + val statusesAccount2 = (timelineDao.getStatusesForAccount(2).load(loadParams) as PagingSource.LoadResult.Page).data + + assertStatuses(listOf(statusWithBlueDomain, statusWithGreenDomain), statusesAccount1) + assertStatuses(listOf(statusWithRedDomainOtherAccount, statusWithBlueDomainOtherAccount), statusesAccount2) + } + + @Test + fun `should return null as topId when db is empty`() = runBlocking { + assertNull(timelineDao.getTopId(1)) + } + + @Test + fun `should return correct topId`() = runBlocking { + + val statusData = listOf( + makeStatus( + statusId = 4, + accountId = 1, + domain = "mastodon.test", + authorServerId = "1" + ), + makeStatus( + statusId = 33, + accountId = 1, + domain = "mastodon.test", + authorServerId = "2" + ), + makeStatus( + statusId = 22, + accountId = 1, + domain = "mastodon.test", + authorServerId = "2" + ) + ) + + for ((status, author, reblogAuthor) in statusData) { + timelineDao.insertAccount(author) + reblogAuthor?.let { + timelineDao.insertAccount(it) + } + timelineDao.insertStatus(status) + } + + assertEquals("33", timelineDao.getTopId(1)) + } + + @Test + fun `should return correct placeholderId after other ids`() = runBlocking { + + val statusData = listOf( + makeStatus(statusId = 1000), + makePlaceholder(id = 99), + makeStatus(statusId = 97), + makeStatus(statusId = 95), + makePlaceholder(id = 94), + makeStatus(statusId = 90) + ) + + for ((status, author, reblogAuthor) in statusData) { + author?.let { + timelineDao.insertAccount(it) + } + reblogAuthor?.let { + timelineDao.insertAccount(it) + } + timelineDao.insertStatus(status) + } + + assertEquals("99", timelineDao.getNextPlaceholderIdAfter(1, "1000")) + assertEquals("94", timelineDao.getNextPlaceholderIdAfter(1, "97")) + assertNull(timelineDao.getNextPlaceholderIdAfter(1, "90")) + } + + @Test + fun `should return correct top placeholderId`() = runBlocking { + + val statusData = listOf( + makeStatus(statusId = 1000), + makePlaceholder(id = 99), + makeStatus(statusId = 97), + makePlaceholder(id = 96), + makeStatus(statusId = 90), + makePlaceholder(id = 80), + makeStatus(statusId = 77) + ) + + for ((status, author, reblogAuthor) in statusData) { + author?.let { + timelineDao.insertAccount(it) + } + reblogAuthor?.let { + timelineDao.insertAccount(it) + } + timelineDao.insertStatus(status) + } + + assertEquals("99", timelineDao.getTopPlaceholderId(1)) + } + + private fun makeStatus( + accountId: Long = 1, + statusId: Long = 10, + reblog: Boolean = false, + createdAt: Long = statusId, + authorServerId: String = "20", + domain: String = "mastodon.example" + ): Triple { + val author = TimelineAccountEntity( + authorServerId, + accountId, + "localUsername@$domain", + "username@$domain", + "displayName", + "blah", + "avatar", + "[\"tusky\": \"http://tusky.cool/emoji.jpg\"]", + false + ) + + val reblogAuthor = if (reblog) { + TimelineAccountEntity( + "R$authorServerId", + accountId, + "RlocalUsername", + "Rusername", + "RdisplayName", + "Rblah", + "Ravatar", + "[]", + false + ) + } else null + + val even = accountId % 2 == 0L + val status = TimelineStatusEntity( + serverId = statusId.toString(), + url = "https://$domain/whatever/$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, + expanded = false, + contentCollapsed = false, + contentShowing = true, + pinned = false + ) + return Triple(status, author, reblogAuthor) + } + + private fun makePlaceholder( + accountId: Long = 1, + id: Long + ): Triple { + val placeholder = Placeholder(id.toString(), false).toEntity(accountId) + return Triple(placeholder, null, null) + } + + private fun assertStatuses( + expected: List>, + provided: List + ) { + for ((exp, prov) in expected.zip(provided)) { + val (status, author, reblogger) = exp + assertEquals(status, prov.status) + assertEquals(author, prov.account) + assertEquals(reblogger, prov.reblogAccount) + } + } +} diff --git a/app/src/test/java/com/keylesspalace/tusky/fragment/TimelineRepositoryTest.kt b/app/src/test/java/com/keylesspalace/tusky/fragment/TimelineRepositoryTest.kt deleted file mode 100644 index 7a7c3f7d..00000000 --- a/app/src/test/java/com/keylesspalace/tusky/fragment/TimelineRepositoryTest.kt +++ /dev/null @@ -1,342 +0,0 @@ -package com.keylesspalace.tusky.fragment - -import android.text.SpannableString -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.google.gson.Gson -import com.keylesspalace.tusky.db.AccountEntity -import com.keylesspalace.tusky.db.AccountManager -import com.keylesspalace.tusky.db.TimelineDao -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 org.junit.Assert.assertEquals -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.ArgumentMatchers.* -import org.mockito.Mock -import org.mockito.MockitoAnnotations -import org.robolectric.annotation.Config -import retrofit2.Response -import java.util.* -import java.util.concurrent.TimeUnit -import kotlin.collections.ArrayList - -@Config(sdk = [28]) -@RunWith(AndroidJUnit4::class) -class TimelineRepositoryTest { - @Mock - lateinit var timelineDao: TimelineDao - - @Mock - lateinit var mastodonApi: MastodonApi - - @Mock - private lateinit var accountManager: AccountManager - - private lateinit var gson: Gson - - private lateinit var subject: TimelineRepository - - private lateinit var testScheduler: TestScheduler - - - private val limit = 30 - private val account = AccountEntity( - id = 2, - accessToken = "token", - domain = "domain.com", - isActive = true - ) - - @Before - fun setup() { - MockitoAnnotations.initMocks(this) - whenever(accountManager.activeAccount).thenReturn(account) - - gson = Gson() - testScheduler = TestScheduler() - RxJavaPlugins.setIoSchedulerHandler { testScheduler } - subject = TimelineRepositoryImpl(timelineDao, mastodonApi, accountManager, gson) - } - - @Test - fun testNetworkUnbounded() { - val statuses = listOf( - makeStatus("3"), - makeStatus("2") - ) - whenever(mastodonApi.homeTimeline(isNull(), isNull(), anyInt())) - .thenReturn(Single.just(Response.success(statuses))) - val result = subject.getStatuses(null, null, null, limit, TimelineRequestMode.NETWORK) - .blockingGet() - - assertEquals(statuses.map(Status::lift), result) - testScheduler.advanceTimeBy(100, TimeUnit.SECONDS) - - verify(timelineDao).deleteRange(account.id, statuses.last().id, statuses.first().id) - - 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 - ) - } - verify(timelineDao).cleanup(anyLong()) - verifyNoMoreInteractions(timelineDao) - } - - @Test - fun testNetworkLoadingTopNoGap() { - val response = listOf( - 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() - - assertEquals( - 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 - ) - } - verify(timelineDao).removeAllPlaceholdersBetween(account.id, response.first().id, - response.last().id) - verify(timelineDao).cleanup(anyLong()) - verifyNoMoreInteractions(timelineDao) - } - - @Test - fun testNetworkLoadingTopWithGap() { - val response = listOf( - 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() - - val placeholder = Placeholder("3") - assertEquals(response.map(Status::lift) + Either.Left(placeholder), result) - testScheduler.advanceTimeBy(100, TimeUnit.SECONDS) - 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 - ) - } - verify(timelineDao).insertStatusIfNotThere(placeholder.toEntity(account.id)) - verify(timelineDao).cleanup(anyLong()) - verifyNoMoreInteractions(timelineDao) - } - - @Test - fun testNetworkLoadingMiddleNoGap() { - // Example timelne: - // 5 - // 4 - // [gap] - // 2 - // 1 - - val response = listOf( - 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() - - assertEquals( - 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 - ) - } - verify(timelineDao).removeAllPlaceholdersBetween(account.id, response.first().id, - response.last().id) - verify(timelineDao).cleanup(anyLong()) - verifyNoMoreInteractions(timelineDao) - } - - @Test - fun testNetworkLoadingMiddleWithGap() { - // Example timelne: - // 6 - // 5 - // [gap] - // 2 - // 1 - - val response = listOf( - 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() - - val placeholder = Placeholder("3") - assertEquals( - 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 - - 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 - ) - } - verify(timelineDao).removeAllPlaceholdersBetween(account.id, response.first().id, - response.last().id) - verify(timelineDao).insertStatusIfNotThere(placeholder.toEntity(account.id)) - verify(timelineDao).cleanup(anyLong()) - verifyNoMoreInteractions(timelineDao) - } - - @Test - fun addingFromDb() { - RxJavaPlugins.setIoSchedulerHandler { Schedulers.single() } - val status = makeStatus("2") - val dbStatus = makeStatus("1") - val dbResult = TimelineStatusWithAccount() - dbResult.status = dbStatus.toEntity(account.id, gson) - dbResult.account = status.account.toEntity(account.id, gson) - - whenever(mastodonApi.homeTimeline(any(), any(), any())) - .thenReturn(Single.just(Response.success((listOf(status))))) - whenever(timelineDao.getStatusesForAccount(account.id, status.id, null, 30)) - .thenReturn(Single.just(listOf(dbResult))) - val result = subject.getStatuses(null, null, null, limit, TimelineRequestMode.ANY) - .blockingGet() - assertEquals(listOf(status, dbStatus).map(Status::lift), result) - } - - @Test - fun addingFromDbExhausted() { - RxJavaPlugins.setIoSchedulerHandler { Schedulers.single() } - val status = makeStatus("4") - val dbResult = TimelineStatusWithAccount() - dbResult.status = Placeholder("2").toEntity(account.id) - val dbResult2 = TimelineStatusWithAccount() - dbResult2.status = Placeholder("1").toEntity(account.id) - - whenever(mastodonApi.homeTimeline(any(), any(), any())) - .thenReturn(Single.just(Response.success(listOf(status)))) - whenever(timelineDao.getStatusesForAccount(account.id, status.id, null, 30)) - .thenReturn(Single.just(listOf(dbResult, dbResult2))) - val result = subject.getStatuses(null, null, null, limit, TimelineRequestMode.ANY) - .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 - ) - } - - 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 diff --git a/app/src/test/java/com/keylesspalace/tusky/json/GuardedBooleanAdapterTest.kt b/app/src/test/java/com/keylesspalace/tusky/json/GuardedBooleanAdapterTest.kt new file mode 100644 index 00000000..15e05d53 --- /dev/null +++ b/app/src/test/java/com/keylesspalace/tusky/json/GuardedBooleanAdapterTest.kt @@ -0,0 +1,130 @@ +package com.keylesspalace.tusky.json + +import com.google.gson.Gson +import com.keylesspalace.tusky.entity.Relationship +import org.junit.Assert.assertEquals +import org.junit.Test + +class GuardedBooleanAdapterTest { + + private val gson = Gson() + + @Test + fun `should deserialize Relationship when attribute 'subscribing' is a boolean`() { + val jsonInput = """ + { + "id": "1", + "following": true, + "showing_reblogs": true, + "notifying": false, + "followed_by": true, + "blocking": false, + "blocked_by": false, + "muting": false, + "muting_notifications": false, + "requested": false, + "domain_blocking": false, + "endorsed": false, + "note": "Hi", + "subscribing": true + } + """.trimIndent() + + assertEquals( + Relationship( + id = "1", + following = true, + followedBy = true, + blocking = false, + muting = false, + mutingNotifications = false, + requested = false, + showingReblogs = true, + subscribing = true, + blockingDomain = false, + note = "Hi", + notifying = false + ), + gson.fromJson(jsonInput, Relationship::class.java) + ) + } + + @Test + fun `should deserialize Relationship when attribute 'subscribing' is an object`() { + val jsonInput = """ + { + "id": "2", + "following": true, + "showing_reblogs": true, + "notifying": false, + "followed_by": true, + "blocking": false, + "blocked_by": false, + "muting": false, + "muting_notifications": false, + "requested": false, + "domain_blocking": false, + "endorsed": false, + "note": "Hi", + "subscribing": { } + } + """.trimIndent() + + assertEquals( + Relationship( + id = "2", + following = true, + followedBy = true, + blocking = false, + muting = false, + mutingNotifications = false, + requested = false, + showingReblogs = true, + subscribing = null, + blockingDomain = false, + note = "Hi", + notifying = false + ), + gson.fromJson(jsonInput, Relationship::class.java) + ) + } + + @Test + fun `should deserialize Relationship when attribute 'subscribing' does not exist`() { + val jsonInput = """ + { + "id": "3", + "following": true, + "showing_reblogs": true, + "notifying": false, + "followed_by": true, + "blocking": false, + "blocked_by": false, + "muting": false, + "muting_notifications": false, + "requested": false, + "domain_blocking": false, + "endorsed": false, + "note": "Hi" + } + """.trimIndent() + + assertEquals( + Relationship( + id = "3", + following = true, + followedBy = true, + blocking = false, + muting = false, + mutingNotifications = false, + requested = false, + showingReblogs = true, + subscribing = null, + blockingDomain = false, + note = "Hi", + notifying = false + ), + gson.fromJson(jsonInput, Relationship::class.java) + ) + } +} 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 badaa709..5dd5ea84 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/RickRollTest.kt b/app/src/test/java/com/keylesspalace/tusky/util/RickRollTest.kt index b9b3a374..c5bfad42 100644 --- a/app/src/test/java/com/keylesspalace/tusky/util/RickRollTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/util/RickRollTest.kt @@ -23,11 +23,13 @@ class RickRollTest { @Test fun testShouldRickRoll() { listOf("gab.Com", "social.gab.ai", "whatever.GAB.com").forEach { - rollableDomain -> assertTrue(shouldRickRoll(activity, rollableDomain)) + rollableDomain -> + assertTrue(shouldRickRoll(activity, rollableDomain)) } listOf("chaos.social", "notgab.com").forEach { - notRollableDomain -> assertFalse(shouldRickRoll(activity, notRollableDomain)) + notRollableDomain -> + assertFalse(shouldRickRoll(activity, notRollableDomain)) } } } 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 b85d60a1..5b6f417b 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 03ab3d94..2731228a 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 d6ef9eec..18b1872a 100644 --- a/build.gradle +++ b/build.gradle @@ -1,19 +1,25 @@ buildscript { - ext.kotlin_version = '1.4.31' + ext.kotlin_version = '1.6.10' repositories { google() - jcenter() + mavenCentral() + gradlePluginPortal() } dependencies { - classpath 'com.android.tools.build:gradle:4.1.2' + classpath "com.android.tools.build:gradle:7.0.4" 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/fastlane/metadata/android/bn_BD/full_description.txt b/fastlane/metadata/android/bn-BD/full_description.txt similarity index 100% rename from fastlane/metadata/android/bn_BD/full_description.txt rename to fastlane/metadata/android/bn-BD/full_description.txt diff --git a/fastlane/metadata/android/bn_BD/short_description.txt b/fastlane/metadata/android/bn-BD/short_description.txt similarity index 100% rename from fastlane/metadata/android/bn_BD/short_description.txt rename to fastlane/metadata/android/bn-BD/short_description.txt diff --git a/fastlane/metadata/android/bn_BD/title.txt b/fastlane/metadata/android/bn-BD/title.txt similarity index 100% rename from fastlane/metadata/android/bn_BD/title.txt rename to fastlane/metadata/android/bn-BD/title.txt diff --git a/fastlane/metadata/android/de/changelogs/58.txt b/fastlane/metadata/android/de/changelogs/58.txt new file mode 100644 index 00000000..e16973f9 --- /dev/null +++ b/fastlane/metadata/android/de/changelogs/58.txt @@ -0,0 +1,11 @@ +Tusky v6.0 + +- Timelinefilter wurden in die Kontoeinstellungen verschoben und werden mit dem Server synchronisiert +- Hashtags können jetzt als eigene Tabs hinzugefügt werden +- Listen können jetzt bearbeitet werden +- Sicherheit: TLS 1.0 und TLS 1.1 entfernt, Unterstützung für TLS 1.3 auf Android 6+ hinzugefügt +- Automatische Vorschläge von Emojis beim Tippen +- "Systemthema verwenden" hinzugefügt +- Verbesserte Barrierefreiheit +- Eine Sprache kann jetzt in der App gesetzt werden +- Fehlerkorrekturen diff --git a/fastlane/metadata/android/de/changelogs/72.txt b/fastlane/metadata/android/de/changelogs/72.txt new file mode 100644 index 00000000..58705863 --- /dev/null +++ b/fastlane/metadata/android/de/changelogs/72.txt @@ -0,0 +1,11 @@ +Tusky v11.0 + +- Benachrichtigungen über neue Folgeanfragen wenn das Konto gesperrt ist +- Neue Funktionen die in den Einstellungen aktiviert werden können: + - Wischgeste zum Wechseln zwischen Tabs + - Bestätigung vor dem Teilen eines Beitrags + - Linkvorschauen in Timelines +- Konversationen können jetzt stummgeschaltet werden +- Umfrageergebnisse für Umfragen mit Mehrfachauswahl sind jetzt einfacher zu verstehen +- Viele Fehlerkorrekturen, primär beim Postverfassen +- Verbesserte Übersetzungen diff --git a/fastlane/metadata/android/de/changelogs/77.txt b/fastlane/metadata/android/de/changelogs/77.txt new file mode 100644 index 00000000..f88fc4d6 --- /dev/null +++ b/fastlane/metadata/android/de/changelogs/77.txt @@ -0,0 +1,10 @@ +Tusky v13.0 + +- Unterstützung für Profilnotizen (Mastodon 3.2.0 Funktion) +- Unterstützung für Ankündigungen von Administratoren (Mastodon 3.1.0 Funktion) + +- Der Avatar des ausgewählten Kontos wird nun in der Hauptnavigation angezeigt +- Klicken auf einen Anzeigenamen in einer Timeline öffnet jetzt das Profil dieses Nutzers + +- Viele Fehlerkorrekturen und kleine Verbesserungen +- Verbesserte Übersetzungen diff --git a/fastlane/metadata/android/de/changelogs/80.txt b/fastlane/metadata/android/de/changelogs/80.txt new file mode 100644 index 00000000..e8115536 --- /dev/null +++ b/fastlane/metadata/android/de/changelogs/80.txt @@ -0,0 +1,7 @@ +Tusky v14.0 + +- Notifikationen wenn ein Nutzer dem du folgst postet - Klicke auf das Glockenicon in deren Profil! (Funktion von Mastodon 3.3.0) +- Die Entwurfsfunktion in Tusky wurde vollständig neu gestaltet um schneller, nutzerfreundlicher und weniger fehleranfällig zu sein. +- Ein neue Wohlbefinden-Modus der dir erlaubt bestimmte Funktionen von Tusky zu beschränken wurde hinzugefügt. +- Tusky kann jetzt animierte GIF-Emojis darstellen. +Alle Änderungen: https://github.com/tuskyapp/Tusky/releases diff --git a/fastlane/metadata/android/de/changelogs/83.txt b/fastlane/metadata/android/de/changelogs/83.txt new file mode 100644 index 00000000..bd764a05 --- /dev/null +++ b/fastlane/metadata/android/de/changelogs/83.txt @@ -0,0 +1,3 @@ +Tusky v15.1 + +Dieses Release behebt einen Absturz bei der Eingabe einer Bildbeschreibung diff --git a/fastlane/metadata/android/de/changelogs/87.txt b/fastlane/metadata/android/de/changelogs/87.txt new file mode 100644 index 00000000..4e2b18a5 --- /dev/null +++ b/fastlane/metadata/android/de/changelogs/87.txt @@ -0,0 +1,8 @@ +Tusky v16.0 + +- Die Logik des Ladens der Timeline wurde komplett neu geschrieben um schneller, weniger fehlerhaft und wartungsfreundlicher zu sein. +- Tusky kann nun benutzerdefinierte Emojis im APNG- & Animated-WebP-Format animieren. +- Viele Fehlerbehebungen +- Unterstützung von Android 11 +- Neue Übersetzungen: Schottisches Gälisch, Galicisch, Ukrainisch +- Verbesserte Übersetzungen diff --git a/fastlane/metadata/android/en-US/changelogs/87.txt b/fastlane/metadata/android/en-US/changelogs/87.txt new file mode 100644 index 00000000..81411b48 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/87.txt @@ -0,0 +1,8 @@ +Tusky v16.0 + +- The timeline loading logic has been completely rewritten in order to be faster, less buggy and easier to maintain. +- Tusky can now animate custom emojis in APNG & Animated WebP format. +- A lot of bugfixes +- Support for Android 11 +- New translations: Scottish Gaelic, Galician, Ukrainian +- Improved translations diff --git a/fastlane/metadata/android/es/changelogs/58.txt b/fastlane/metadata/android/es/changelogs/58.txt new file mode 100644 index 00000000..4fe2e386 --- /dev/null +++ b/fastlane/metadata/android/es/changelogs/58.txt @@ -0,0 +1,10 @@ +Tusky v6.0 + +- Filtros de linea de tiempo movidos a preferencias de cuenta y se sincroniza con servidor +- Hashtags personalizados como una pestaña en la interfaz principal +- Puedes editar listas +- Seguridad: el soporte para TLS 1.0 y 1.1 se ha removido, se añade soporte para TLS 1.3 en Android 6+ +- La vista de composición ahora sugiere emojis personalizados al escribir +- Nueva opción para seguir tema del sistema +- Nueva traducción: Czech y Esperanto +- Otras mejoras y arreglos diff --git a/fastlane/metadata/android/es/changelogs/61.txt b/fastlane/metadata/android/es/changelogs/61.txt new file mode 100644 index 00000000..abdb1caa --- /dev/null +++ b/fastlane/metadata/android/es/changelogs/61.txt @@ -0,0 +1,7 @@ +Tusky v7.0 + +- Soporte para mostrar encuestas, votar y recibir notificaciones de ellas +- Nuevos botones para filtrar la pestaña de notificaciones y borrar todas las notificaciones +- Borra y edita tus propios toots +- nuevo indicador para mostrar si la cuenta es un bot en la foto de perfil (puede apagarse en las preferencias) +- Nueva traduccion: Norwegian Bokmål and Slovenian. diff --git a/fastlane/metadata/android/es/changelogs/67.txt b/fastlane/metadata/android/es/changelogs/67.txt new file mode 100644 index 00000000..03485384 --- /dev/null +++ b/fastlane/metadata/android/es/changelogs/67.txt @@ -0,0 +1,9 @@ +Tusky v9.0 + +- Ahora puedes crear encuestas desde Tusky +- Se mejoro la búsqueda +- Nueva opción en preferencias de Cuenta para siempre expandir contenido con aviso +- Los avatar en el cajón de navegación ahora tienen forma de cuadro redondeado +- Ahora puedes reportar usuarios incluso cuando nunca hayan tenido un estado +- Tusky no se conectara a conexiones cleartext en Android 6+ +- Otras mejoras y arreglo de errores diff --git a/fastlane/metadata/android/es/changelogs/68.txt b/fastlane/metadata/android/es/changelogs/68.txt new file mode 100644 index 00000000..8d7c5e63 --- /dev/null +++ b/fastlane/metadata/android/es/changelogs/68.txt @@ -0,0 +1,3 @@ +Tusky v9.1 + +Esta versión asegura la compatibilidad con Mastodon 3 y mejora le estabilidad y desempeño. diff --git a/fastlane/metadata/android/es/changelogs/70.txt b/fastlane/metadata/android/es/changelogs/70.txt new file mode 100644 index 00000000..d9caf029 --- /dev/null +++ b/fastlane/metadata/android/es/changelogs/70.txt @@ -0,0 +1,8 @@ +Tusky v10.0 + +- Ahora puedes marcar estas y listar tus marcadores en Tusky. +- Ahora puedes programar toots con Tusky. Ten en cuenta que el tiempo que elijas tiene que ser al menos 5 minutos en el futuro. +- Ahora puedes añadir listas a la pantalla principal. +- Ahora puedes publicar audio adjunto en Tusky. + +¡Y muchos otros pequeños cambios y mejoras! diff --git a/fastlane/metadata/android/es/changelogs/74.txt b/fastlane/metadata/android/es/changelogs/74.txt new file mode 100644 index 00000000..23cdd965 --- /dev/null +++ b/fastlane/metadata/android/es/changelogs/74.txt @@ -0,0 +1,8 @@ +Tusky v12.0 + +- La interfaz principal fue mejorada - puedes mover las pestañas a la parte inferior +- Al silenciar un usuario, puedes decidir silenciar sus notificaciones +- Ahora puedes seguir todos los hashtags que quieras en una sola pestaña de hashtags +- Se mejoro la forma en la que la descripción de medios se muestra, ahora funciona para descripciones mas largas + +Todos los cambios: https://github.com/tuskyapp/Tusky/releases diff --git a/fastlane/metadata/android/es/changelogs/77.txt b/fastlane/metadata/android/es/changelogs/77.txt new file mode 100644 index 00000000..c70aa23c --- /dev/null +++ b/fastlane/metadata/android/es/changelogs/77.txt @@ -0,0 +1,10 @@ +Tusky v13.0 + +- soporte para notas de perfil (Mastodon 3.2.0) +- soporte para anuncios de administradores (Mastodon 3.1.0) + +- el avatar de tu cuenta escogida ahora se muestra en la barra de herramientas principal +- al presionar el nombre de usuario en la linea de tiempo se abrira el perfil de dicho usuario + +- arreglo de errores y algunas mejoras +- traducciones mejoradas diff --git a/fastlane/metadata/android/es/changelogs/80.txt b/fastlane/metadata/android/es/changelogs/80.txt new file mode 100644 index 00000000..3579d861 --- /dev/null +++ b/fastlane/metadata/android/es/changelogs/80.txt @@ -0,0 +1,7 @@ +Tusky v14.0 + +- Recibe notificaciones cuando un usuario que sigues hace una publicación - toca la campana en su perfil! (Mastodon 3.3.0) +- La función de borradores en Tusky ha sido completamente rediseñada para ser mas rápida, amigable y con menos errores +- Se añadió un nuevo modo de bienestar digital que puede limitar la funcionalidad de Tusky +- Tusky ahora puede animar emojis personalizados. +Todos los cambios: https://github.com/tuskyapp/Tusky/releases diff --git a/fastlane/metadata/android/es/changelogs/82.txt b/fastlane/metadata/android/es/changelogs/82.txt new file mode 100644 index 00000000..3baaa419 --- /dev/null +++ b/fastlane/metadata/android/es/changelogs/82.txt @@ -0,0 +1,5 @@ +Tusky v15.0 + +- Las solicitudes de seguimiento ahora se muestran siempre en el menú principal +- El selector de tiempo para programar una publicación tiene un diseño consistente con el resto de la app +Registro completo: https://github.com/tuskyapp/Tusky/releases diff --git a/fastlane/metadata/android/es/changelogs/83.txt b/fastlane/metadata/android/es/changelogs/83.txt new file mode 100644 index 00000000..0acbe895 --- /dev/null +++ b/fastlane/metadata/android/es/changelogs/83.txt @@ -0,0 +1,3 @@ +Tusky v15.1 + +Esta versión arregla un fallo al subtitular imágenes diff --git a/fastlane/metadata/android/eu/changelogs/72.txt b/fastlane/metadata/android/eu/changelogs/72.txt new file mode 100644 index 00000000..f249229f --- /dev/null +++ b/fastlane/metadata/android/eu/changelogs/72.txt @@ -0,0 +1,9 @@ +Tusky v11.0 + +- Jarraipena egiteko eskaera berriei buruzko jakinarazpenak zure kontua blokeatuta dagoenean. +- Hobespenen pantailan txanda daitezkeen eginbide berriak: + - Desgaitu fitxen artean irristatzea. + - Erakutsi baieztapen elkarrizketa-koadroa tuta bultzatu aurretik. + - Erakutsi esteken aurrebistak kronogrametan. +- Elkarrizketak isil daitezke. +- Inkesten emaitzak orain hautesle kopuruaren arabera kalkulatuko dira horrek egiten du anitz aukerako inkesta errazago ulertzen. diff --git a/fastlane/metadata/android/eu/changelogs/74.txt b/fastlane/metadata/android/eu/changelogs/74.txt new file mode 100644 index 00000000..f084cb0f --- /dev/null +++ b/fastlane/metadata/android/eu/changelogs/74.txt @@ -0,0 +1,8 @@ +Tusky v12.0 + +- Interfaze nagusia hobetuta - fitxak beheko aldera eraman ditzakezu. +- Erabiltzailea mututzean, orain ere jakinarazpenak isilarazi nahi dituzun erabaki dezakezu. +- Orain nahi dituzun traola traola fitxa bakarrean jarrai ditzakezu. +- Multimedia deskribapenak bistaratzeko modua hobetu da, beraz deskribapen oso luzeetarako ere funtzionatzen du. + +Aldaketa erregistro osoa: https://github.com/tuskyapp/Tusky/releases diff --git a/fastlane/metadata/android/eu/changelogs/77.txt b/fastlane/metadata/android/eu/changelogs/77.txt new file mode 100644 index 00000000..b00469fb --- /dev/null +++ b/fastlane/metadata/android/eu/changelogs/77.txt @@ -0,0 +1,10 @@ +Tusky v13.0 + +- Profileko oharren laguntza. (Mastodon 3.2.0-ren ezaugarria) +- Administratzaileentzako iragarkien laguntza. (Mastodon 3.1.0-ren ezaugarria) + +- Hautatutako kontuaren abatarra tresna barra nagusian erakutsiko da. +- Denbora-lerro batean bistaratzeko izena klikatuz gero, erabiltzaile horren profil orria irekiko da. + +- Akats konponketa asko eta hobekuntza txikiak. +- Itzulpenak hobetuta. diff --git a/fastlane/metadata/android/eu/changelogs/80.txt b/fastlane/metadata/android/eu/changelogs/80.txt new file mode 100644 index 00000000..aefcc4ec --- /dev/null +++ b/fastlane/metadata/android/eu/changelogs/80.txt @@ -0,0 +1,7 @@ +Tusky v14.0 + +- Jaso jakinarazpena jarraitutako erabiltzaile batek argitaratzen duenean - egin klik bere profileko kanpaiaren ikonoan! (Mastodon 3.3.0-ren ezaugarria) +- Tusky-ren zirriborroa erabat birmoldatu da azkarragoa, erabilerrazagoa eta txikiagoa izan dadin. +- Tusky-ren zenbait ezaugarri mugatzea ahalbidetzen duen ongizate modu berria gehitu da. +- Tusky-k emoji pertsonalizatuak animatu ditzake orain. +Aldaketa erregistro osoa : https://github.com/tuskyapp/Tusky/releases diff --git a/fastlane/metadata/android/eu/changelogs/82.txt b/fastlane/metadata/android/eu/changelogs/82.txt new file mode 100644 index 00000000..2d58b9eb --- /dev/null +++ b/fastlane/metadata/android/eu/changelogs/82.txt @@ -0,0 +1,5 @@ +Tusky v15.0 + +- Jarraitzeko eskaerak menu nagusian agertzen dira beti. +- Mezu bat antolatzeko denbora-hautatzaileak orain gainerako aplikazioekin bat datorren diseinua du +Aldaketa erregistro osoa: https://github.com/tuskyapp/Tusky/releases diff --git a/fastlane/metadata/android/eu/changelogs/83.txt b/fastlane/metadata/android/eu/changelogs/83.txt new file mode 100644 index 00000000..454e2e5e --- /dev/null +++ b/fastlane/metadata/android/eu/changelogs/83.txt @@ -0,0 +1,3 @@ +Tusky v15.1 + +Bertsio honek konpondu egin du irudiak azpititulatzean huts egitea diff --git a/fastlane/metadata/android/fa/changelogs/83.txt b/fastlane/metadata/android/fa/changelogs/83.txt new file mode 100644 index 00000000..dd666af3 --- /dev/null +++ b/fastlane/metadata/android/fa/changelogs/83.txt @@ -0,0 +1,3 @@ +تاسکی نگارش ۱۵٫۱ + +این ارائه، فروپاشی‌ای هنگام شرح نوشتن بر تصویرها را تعمیر می‌کند diff --git a/fastlane/metadata/android/fr/changelogs/72.txt b/fastlane/metadata/android/fr/changelogs/72.txt index fb48ccbc..73f36ff2 100644 --- a/fastlane/metadata/android/fr/changelogs/72.txt +++ b/fastlane/metadata/android/fr/changelogs/72.txt @@ -1,9 +1,11 @@ Tusky v11.0 -- Notifications à propos des nouvelles demandes d’abonnement lorsque votre compte est verrouillé -- Nouvelles fonctionalités activables via l’écran Préférences : - - désactiver le pivotement entre onglets - - afficher une boîte de dialogue de confirmation avant de booster un pouet +- Notifications à propos des nouvelles demandes d’abonnement en cas de compte verrouillé +- Nouvelles fonctionnalités dans l’écran Préférences : + - désactiver le changement d'onglet + - afficher une confirmation avant de partager un pouet - afficher les aperçus des liens dans les fils - Possibilité de mettre en sourdine les conversations -- Les résultats des sondages seront désormais calculés en fonction du nombre des sond·é·s +- Les résultats des sondages seront désormais calculés en fonction du nombre de sond·é·s +- Résolution de bugs +- Amélioration des traductions diff --git a/fastlane/metadata/android/fr/changelogs/80.txt b/fastlane/metadata/android/fr/changelogs/80.txt new file mode 100644 index 00000000..88f9f05e --- /dev/null +++ b/fastlane/metadata/android/fr/changelogs/80.txt @@ -0,0 +1,7 @@ +Tusky v14.0 + +- Soyez notifié·e des nouveaux message d’un compte suivi — cliquez l’icône de cloche sur son profil ! (fonctionnalité de Mastodon 3.3.0) +- La fonctionnalité de brouillon a été complètement repensée pour être plus rapide, plus conviviale et moins boguée. +- Un nouveau mode bien-être qui vous permet de limiter certaines fonctions de Tusky a été ajouté. +- Tusky peut désormais animer les émojis personnalisés. +Liste complète des changements : https://github.com/tuskyapp/Tusky/releases diff --git a/fastlane/metadata/android/fr/changelogs/82.txt b/fastlane/metadata/android/fr/changelogs/82.txt new file mode 100644 index 00000000..3e7971a5 --- /dev/null +++ b/fastlane/metadata/android/fr/changelogs/82.txt @@ -0,0 +1,5 @@ +Tusky v15.0 + +- Les demandes de suivi sont désormais toujours montrées dans le menu principal. +- Le sélecteur de date/heure pour planifier un message a désormais un design cohérent avec le reste de l’application +Liste complète des changements : https://github.com/tuskyapp/Tusky/releases diff --git a/fastlane/metadata/android/fr/changelogs/83.txt b/fastlane/metadata/android/fr/changelogs/83.txt new file mode 100644 index 00000000..6b8f2b63 --- /dev/null +++ b/fastlane/metadata/android/fr/changelogs/83.txt @@ -0,0 +1,3 @@ +Tusky v15.1 + +Cette version corrige un plantage lors de l’ajout de description d’image diff --git a/fastlane/metadata/android/fr/changelogs/87.txt b/fastlane/metadata/android/fr/changelogs/87.txt new file mode 100644 index 00000000..b237fcbc --- /dev/null +++ b/fastlane/metadata/android/fr/changelogs/87.txt @@ -0,0 +1,8 @@ +Tusky v16.0 + +- Le fonctionnement des fils a été complètement revu de manière à être plus rapide, moins dysfonctionnel et plus simple à maintenir, +- Tusky peut désormais lire les émoticônes animées au format APNG et au format Animated WebP, +- Une quantité considérable de bugs a été résolue, +- Le support pour Android 11 a été mis en place, +- De nouvelles traductions sont disponibles : Gaélique écossais, Galicien et Ukrainien, +- Les traductions existantes ont été améliorées. diff --git a/fastlane/metadata/android/gl/changelogs/83.txt b/fastlane/metadata/android/gl/changelogs/83.txt new file mode 100644 index 00000000..60ed158d --- /dev/null +++ b/fastlane/metadata/android/gl/changelogs/83.txt @@ -0,0 +1,3 @@ +Tusky v15.1 + +Esta versión arranxa o problema coa descrición de imaxes diff --git a/fastlane/metadata/android/hu/changelogs/83.txt b/fastlane/metadata/android/hu/changelogs/83.txt new file mode 100644 index 00000000..a2b9d6c2 --- /dev/null +++ b/fastlane/metadata/android/hu/changelogs/83.txt @@ -0,0 +1,3 @@ +Tusky v15.1 + +Ez a kiadás javít egy képek feliratozása közben jelentkező összeomlást diff --git a/fastlane/metadata/android/hu/changelogs/87.txt b/fastlane/metadata/android/hu/changelogs/87.txt new file mode 100644 index 00000000..81078c73 --- /dev/null +++ b/fastlane/metadata/android/hu/changelogs/87.txt @@ -0,0 +1,8 @@ +Tusky v16.0 + +- Az idővonal betöltési logikáját teljesen újraírtuk, hogy gyorsabb, hibamentesebb és karbantarthatóbb legyen. +- A Tusky már animálja az APNG és Animated WebP formátumú emodzsikat. +- Sok hibajavítás +- Android 11 támogatás +- Új fordítások: skót, galíciai, ukrán +- Javított fordítások diff --git a/fastlane/metadata/android/hu/full_description.txt b/fastlane/metadata/android/hu/full_description.txt index b922cbeb..2a5e7cc0 100644 --- a/fastlane/metadata/android/hu/full_description.txt +++ b/fastlane/metadata/android/hu/full_description.txt @@ -1,12 +1,12 @@ -A Chinwag Social egy kliens a Mastodonhoz, mely egy nyílt forráskódú szociális háló szerver. +A Chinwag Social egy könnyűsúlyú kliens a Mastodonhoz, mely egy nyílt forráskódú közösségi hálózati kiszolgáló. • Material design -• A legtöbb Mastodon API-t implementáltuk +• A legtöbb Mastodon API-t megvalósításra került • Többfiókos támogatás • Sötét és világos téma, valamint automatikus váltási lehetőség napszak szerint -• Piszkozatok - készíts tülköket és mentsd el őket későbbre -• Emoji stílusok közötti választás +• Piszkozatok – tülkök készítése, és mentés későbbre +• Emodzsi stílusok közötti választás • Minden képernyőméretre optimalizálva -• Teljesen nyílt forráskód - nincsenek nem szabad függőségeink pl. Google Services +• Teljesen nyílt forráskód – nincsenek nem szabad függőségek, mint például a Google szolgáltatások -Többet a Mastodonról: https://joinmastodon.org/ +Több információ a Mastodonról: https://joinmastodon.org/ diff --git a/fastlane/metadata/android/hu/short_description.txt b/fastlane/metadata/android/hu/short_description.txt index 7c7b0a95..130eac3b 100644 --- a/fastlane/metadata/android/hu/short_description.txt +++ b/fastlane/metadata/android/hu/short_description.txt @@ -1 +1 @@ -Többfiókos kliens a Mastodon szociális hálóhoz +Többfiókos kliens a Mastodon közösségi hálóhoz diff --git a/fastlane/metadata/android/is/changelogs/83.txt b/fastlane/metadata/android/is/changelogs/83.txt new file mode 100644 index 00000000..d90eb92b --- /dev/null +++ b/fastlane/metadata/android/is/changelogs/83.txt @@ -0,0 +1,3 @@ +Tusky útg.15.1 + +Þessi útgáfa lagfærir hrun þegar skýringatexti er settur á myndefni diff --git a/fastlane/metadata/android/is/changelogs/87.txt b/fastlane/metadata/android/is/changelogs/87.txt new file mode 100644 index 00000000..d01b52c5 --- /dev/null +++ b/fastlane/metadata/android/is/changelogs/87.txt @@ -0,0 +1,8 @@ +Tusky útg.16.0 + +- Aðferðin við hleðslu tímalínu hefur verið endurskrifuð til að vera hraðari og einfaldari í viðhaldi. +- Tusky getur núna hreyft sérsniðin emoji-tákn á APNG & Animated WebP sniði. +- Mikið af smávægilegum göllum leystir +- Stuðningur við Android 11 +- Nýjar þýðingar: Skosk gelíska, Galisíska, Úkraínska +- Bættar þýðingar diff --git a/fastlane/metadata/android/nb-NO/changelogs/83.txt b/fastlane/metadata/android/nb-NO/changelogs/83.txt new file mode 100644 index 00000000..3d7767f5 --- /dev/null +++ b/fastlane/metadata/android/nb-NO/changelogs/83.txt @@ -0,0 +1,3 @@ +Tusky v15.1 + +Denne versjonen retter en programfeil ved skriving av bildetekst diff --git a/fastlane/metadata/android/nb-NO/changelogs/87.txt b/fastlane/metadata/android/nb-NO/changelogs/87.txt new file mode 100644 index 00000000..93d30fcf --- /dev/null +++ b/fastlane/metadata/android/nb-NO/changelogs/87.txt @@ -0,0 +1,8 @@ +Tusky v16.0 + +- Tidslinjelogikken er skrevet om, og er nå raskere, med færre feil og enklere å vedlikeholde +- Tusky kan nå animere egendefinerte emojis i formatene APNG og Animated WebP +- Mange feilrettinger +- Støtte for Android 11 +- Nye oversettelser: Skotsk-gælisk, galisisk, ukrainsk +- Oppdaterte oversettelser diff --git a/fastlane/metadata/android/pl/changelogs/61.txt b/fastlane/metadata/android/pl/changelogs/61.txt index 114fbc7a..0134a9ef 100644 --- a/fastlane/metadata/android/pl/changelogs/61.txt +++ b/fastlane/metadata/android/pl/changelogs/61.txt @@ -1,6 +1,6 @@ Tusky v7.0 -- Wsparcie dla wyświetlania ankiet, głosowania i powiadomień o nich +- Wsparcie dla wyświetlania ankiet, głosowania i powiadomień o ankietach - Nowy przycisk dla filtrowania powiadomień i usuwania wszystkich powiadomień - Możesz usunąć i napisać ponownie własne wpisy - Przy ikonach kont ustawionych jako bot pojawia się mała ikonka robota (opcję można wyłączyć w preferencjach) diff --git a/fastlane/metadata/android/pl/changelogs/67.txt b/fastlane/metadata/android/pl/changelogs/67.txt index 45f21e22..8f5c772b 100644 --- a/fastlane/metadata/android/pl/changelogs/67.txt +++ b/fastlane/metadata/android/pl/changelogs/67.txt @@ -1,6 +1,6 @@ Tusky v9.0 -- Możesz teraz tworzyć ankiety z poziomu programu Tusky +- Możesz teraz tworzyć ankiety z poziomu Tusky - Ulepszone wyszukiwanie - Nowa opcja w Ustawieniach konta która zawsze rozwija ostrzeżenia o zawartości - Awatary w szufladzie nawigacji mają teraz kształt zaokrąglonego kwadratu diff --git a/fastlane/metadata/android/pl/changelogs/68.txt b/fastlane/metadata/android/pl/changelogs/68.txt index fc217ee1..8a8be0b0 100644 --- a/fastlane/metadata/android/pl/changelogs/68.txt +++ b/fastlane/metadata/android/pl/changelogs/68.txt @@ -1,3 +1,3 @@ Tusky v9.1 -Ta aktualizacja zapewnia kompatybilność z Mastodonem 3 i poprawia szybkość i stabilność aplikacji. +Ta aktualizacja zapewnia kompatybilność z Mastodonem 3 i poprawia wydajność i stabilność aplikacji. diff --git a/fastlane/metadata/android/pl/changelogs/72.txt b/fastlane/metadata/android/pl/changelogs/72.txt index 9faee2f3..f7c1bbb5 100644 --- a/fastlane/metadata/android/pl/changelogs/72.txt +++ b/fastlane/metadata/android/pl/changelogs/72.txt @@ -1,11 +1,11 @@ Tusky v11.0 - Powiadomienia o nowych prośbach o obserwowanie kiedy Twoje konto jest zablokowane -- Nowe funkcje, które mogą być zmienione w ekranie Ustawień: +- Nowe funkcje, które mogą być przełączone na ekranie Ustawień: - wyłącz przesuwanie pomiędzy kartami - pokaż potwierdzenie przed podbiciem - pokaż podgląd linku na osi czasu - Rozmowy mogą być teraz wyciszone -- Wyniki ankiet są teraz przeliczane na podstawie liczby głosujących, a nie łącznej liczbie wszystkich głosów +- Wyniki ankiet są teraz przeliczane na podstawie liczby głosujących, a nie łącznej liczby wszystkich głosów - Dużo poprawek błędów, większość z nich dotyczy wpisów - Poprawiono tłumaczenia diff --git a/fastlane/metadata/android/pl/changelogs/74.txt b/fastlane/metadata/android/pl/changelogs/74.txt new file mode 100644 index 00000000..3014b0e2 --- /dev/null +++ b/fastlane/metadata/android/pl/changelogs/74.txt @@ -0,0 +1,8 @@ +Tusky v12.0 + +- Ulepszony interfejs - teraz można przenieść zakładki na dół ekranu +- Podczas wyciszania użytkownika można teraz wyciszyć wysłane przez niego powiadomienia +- Można teraz obserwować kilka hashtagów w jednej zakładce +- Ulepszono sposób, w jaki są wyświetlane opisy załączników + +Pełna lista zmian: https://github.com/tuskyapp/Tusky/releases diff --git a/fastlane/metadata/android/pl/changelogs/77.txt b/fastlane/metadata/android/pl/changelogs/77.txt new file mode 100644 index 00000000..5a63eea4 --- /dev/null +++ b/fastlane/metadata/android/pl/changelogs/77.txt @@ -0,0 +1,10 @@ +Tusky v13.0 + +- Wsparcie dla opisów profilów (Funkcja dostępna w Mastodonie 3.2.0) +- Wsparcie dla ogłoszeń od administracji (Funkcja dostępna w Mastodonie 3.1.0) + +- Zdjęcie profilowe wybranego konta widnieje teraz w głównym pasku nawigacyjnym +- Kliknięcie nazwy użytkownika na osi czasu teraz otwiera profil tego użytkownika + +- Wiele małych poprawek +- Ulepszone tłumaczenia diff --git a/fastlane/metadata/android/pl/changelogs/80.txt b/fastlane/metadata/android/pl/changelogs/80.txt new file mode 100644 index 00000000..e4d1eea1 --- /dev/null +++ b/fastlane/metadata/android/pl/changelogs/80.txt @@ -0,0 +1,7 @@ +Tusky v14.0 + +- Otrzymaj powiadomienie gdy użytkownik, którego obserwujesz, prześle wpis - kliknij na ikonkę dzwonka na ich profilu! (Funkcja dostępna w Mastodonie 3.3.0) +- Szkice zostały przeprojektowane, by ułatwić ich używanie. +- Nowy tryb samopoczucia, który pozwala Ci limitować niektóre funkcje. +- Dodano funkcję animowania niestandardowych emoji. +Pełna lista zmian: https://github.com/tuskyapp/Tusky/releases diff --git a/fastlane/metadata/android/pl/changelogs/82.txt b/fastlane/metadata/android/pl/changelogs/82.txt new file mode 100644 index 00000000..6329da71 --- /dev/null +++ b/fastlane/metadata/android/pl/changelogs/82.txt @@ -0,0 +1,5 @@ +Tusky v15.0 + +- Prośby o zezwolenie na obserwowanie są teraz wyświetlane na menu głównym +- Design wyboru czasu wysłania zaplanowanych wpisów jest bardziej zgodny z resztą aplikacji +Pełna lista zmian: https://github.com/tuskyapp/Tusky/releases diff --git a/fastlane/metadata/android/pl/changelogs/83.txt b/fastlane/metadata/android/pl/changelogs/83.txt new file mode 100644 index 00000000..c9038395 --- /dev/null +++ b/fastlane/metadata/android/pl/changelogs/83.txt @@ -0,0 +1,3 @@ +Tusky v15.1 + +To wydanie naprawia błąd aplikacji przy dodawaniu opisów do zdjęć diff --git a/fastlane/metadata/android/pl/full_description.txt b/fastlane/metadata/android/pl/full_description.txt index 618e95df..76ab9c9e 100644 --- a/fastlane/metadata/android/pl/full_description.txt +++ b/fastlane/metadata/android/pl/full_description.txt @@ -6,7 +6,7 @@ Chinwag Social jest lekkim klientem dla Mastodona, darmowej i otwartoźródłowe • Ciemny i jasny motyw z możliwością automatycznej zmiany bazując na porze dnia • Szkice - stwórz wpis i zachowaj go na później • Wybierz między różnymi stylami emoji -• Optymizowana dla wszystkich rozmiarów ekranu +• Zoptymalizowany dla wszystkich rozmiarów ekranu • W pełni open-source - bez niewolnych elementów np. serwisów Google By dowiedzieć się więcej o sieci Mastodon, zobacz stronę https://joinmastodon.org/ diff --git a/fastlane/metadata/android/pt-BR/changelogs/77.txt b/fastlane/metadata/android/pt-BR/changelogs/77.txt new file mode 100644 index 00000000..48cb81de --- /dev/null +++ b/fastlane/metadata/android/pt-BR/changelogs/77.txt @@ -0,0 +1,10 @@ +Tusky v13.0 + +- Suporte à função de nota pessoal sobre o perfil (novidade do Mastodon 3.2.0) +- Suporte à função de Comunicados da administração (novidade do Mastodon 3.1.0) + +- O avatar de sua conta selecionada agora ficará visível no cantinho da barra de títulos +- Tocar no nome de exibição na linha do tempo abrirá o perfil em questão + +- O desenvolvedor pegou o mata-moscas e fez um monte de pequenas melhorias e correções +- Tradução atualizada diff --git a/fastlane/metadata/android/pt-BR/changelogs/80.txt b/fastlane/metadata/android/pt-BR/changelogs/80.txt new file mode 100644 index 00000000..7ffefce5 --- /dev/null +++ b/fastlane/metadata/android/pt-BR/changelogs/80.txt @@ -0,0 +1,7 @@ +Tusky v14.0 + +- Receba notificação quando a pessoa amada tootar - toca no sininho e aproveita! (novidade do Mastodon 3.3.0) +- Rascunhos no Tusky foi completamente redesenhado e agora está mais chique! +- Foi adicionado funções de bem-estar que permite limitar certas coisinhas no Tusky. +- Tusky consegue animar os emojis personalizados +Para ver mais: https://github.com/tuskyapp/Tusky/releases diff --git a/fastlane/metadata/android/pt-BR/changelogs/82.txt b/fastlane/metadata/android/pt-BR/changelogs/82.txt new file mode 100644 index 00000000..5451dee9 --- /dev/null +++ b/fastlane/metadata/android/pt-BR/changelogs/82.txt @@ -0,0 +1,5 @@ +Tusky v15.0 + +- Seguidores Pendentes agora ficará fixo no menu principal! +- O relógio para agendar toots ficou mais bonitinho e combina melhor com o resto do Tusky +Para ver mais: https://github.com/tuskyapp/Tusky/releases diff --git a/fastlane/metadata/android/pt-BR/changelogs/83.txt b/fastlane/metadata/android/pt-BR/changelogs/83.txt new file mode 100644 index 00000000..e49556d0 --- /dev/null +++ b/fastlane/metadata/android/pt-BR/changelogs/83.txt @@ -0,0 +1,3 @@ +Tusky v15.1 + +Esta atualização corrige aquele inconveniente ao descrever imagens diff --git a/fastlane/metadata/android/ru/changelogs/83.txt b/fastlane/metadata/android/ru/changelogs/83.txt new file mode 100644 index 00000000..4c4ee839 --- /dev/null +++ b/fastlane/metadata/android/ru/changelogs/83.txt @@ -0,0 +1,3 @@ +Tusky v15.1 + +В этом выпуске исправлена ошибка при создании подписей к изображениям diff --git a/fastlane/metadata/android/ru/changelogs/87.txt b/fastlane/metadata/android/ru/changelogs/87.txt new file mode 100644 index 00000000..5a16d63d --- /dev/null +++ b/fastlane/metadata/android/ru/changelogs/87.txt @@ -0,0 +1,8 @@ +Tusky v16.0 + +- Логика загрузки временной шкалы была полностью переписана, чтобы быть быстрее, меньше ошибок и проще в обслуживании. +- Tusky теперь может анимировать собственные смайлики в формате APNG и Animated WebP. +- Множество исправлений +- Поддержка Android 11 +- Новые переводы: шотландский гэльский, галисийский, украинский +- Улучшенные переводы diff --git a/fastlane/metadata/android/si/title.txt b/fastlane/metadata/android/si/title.txt new file mode 100644 index 00000000..028f5f54 --- /dev/null +++ b/fastlane/metadata/android/si/title.txt @@ -0,0 +1 @@ +ටුස්කි diff --git a/fastlane/metadata/android/uk/changelogs/83.txt b/fastlane/metadata/android/uk/changelogs/83.txt new file mode 100644 index 00000000..d8e3f5f0 --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/83.txt @@ -0,0 +1,3 @@ +Tusky v15.1 + +У цьому випуску виправлено збої під час захоплення зображень diff --git a/fastlane/metadata/android/uk/changelogs/87.txt b/fastlane/metadata/android/uk/changelogs/87.txt new file mode 100644 index 00000000..9b8c1732 --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/87.txt @@ -0,0 +1,8 @@ +Tusky v16.0 + +- Повністю переписано логіку завантаження подій для пришвидшення роботи, зменшення вад та полегшення підтримки. +- Tusky тепер може анімувати користувацькі емоджі у форматі APNG та Animated WebP. +- Виправлено багато помилок +- Підтримка для Android 11 +- Нові переклади: шотландською гельською, галісійською, українською +- Удосконалено переклади diff --git a/fastlane/metadata/android/vi/changelogs/83.txt b/fastlane/metadata/android/vi/changelogs/83.txt new file mode 100644 index 00000000..68839be2 --- /dev/null +++ b/fastlane/metadata/android/vi/changelogs/83.txt @@ -0,0 +1,3 @@ +Tusky v15.1 + +Sửa lỗi crash khi ghi chú cho hình diff --git a/fastlane/metadata/android/vi/changelogs/87.txt b/fastlane/metadata/android/vi/changelogs/87.txt new file mode 100644 index 00000000..a6fe6a0c --- /dev/null +++ b/fastlane/metadata/android/vi/changelogs/87.txt @@ -0,0 +1,8 @@ +Tusky v16.0 + +- Viết lại hoàn toàn phương thức tải bảng tin để tải nhanh hơn, ít lỗi hơn và dễ bảo trì +- Hỗ trợ emoji định dạng APNG & WebP động +- Sửa nhiều lỗi khác +- Hỗ trợ Android 11 +- Ngôn ngữ mới: Scottish Gaelic, Galician, Ukrainian +- Cải thiện bản dịch diff --git a/gradle.properties b/gradle.properties index bada7909..8144ece0 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.useAndroidX=true diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 1c4bcc29..669386b8 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.3.3-all.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists