Merge tag 'v16.0'

This commit is contained in:
Mike Barnes 2022-06-01 21:45:02 +10:00
commit 450f9f310e
435 changed files with 16070 additions and 12463 deletions

View file

@ -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```. 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 ### Translation
Translations are done through https://weblate.tusky.app/projects/tusky/tusky/ . Translations are done through our [Weblate](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. To add a new language, click on the 'Start a new translation' button on at the bottom of the page.
### Kotlin ### 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 ### 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 ### 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 ### 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: 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 ## 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 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```. 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 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. 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.

View file

@ -15,13 +15,13 @@ def getGitSha = {
} }
android { android {
compileSdkVersion 29 compileSdkVersion 30
defaultConfig { defaultConfig {
applicationId APP_ID applicationId APP_ID
minSdkVersion 21 minSdkVersion 21
targetSdkVersion 29 targetSdkVersion 30
versionCode 81 versionCode 87
versionName "15.1-CW1" versionName "16.0-CW1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true vectorDrawables.useSupportLibrary = true
@ -35,7 +35,6 @@ android {
kapt { kapt {
arguments { arguments {
arg("room.schemaLocation", "$projectDir/schemas") arg("room.schemaLocation", "$projectDir/schemas")
arg("room.incremental", "true")
} }
} }
} }
@ -61,10 +60,6 @@ android {
lintOptions { lintOptions {
disable 'MissingTranslation' disable 'MissingTranslation'
} }
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
buildFeatures { buildFeatures {
viewBinding true viewBinding true
} }
@ -89,70 +84,76 @@ android {
enableSplit = false enableSplit = false
} }
} }
}
project.tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all {
kotlinOptions { 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.roomVersion = '2.3.0'
ext.retrofitVersion = '2.9.0' ext.retrofitVersion = '2.9.0'
ext.okhttpVersion = '4.9.0' ext.okhttpVersion = '4.9.3'
ext.glideVersion = '4.11.0' ext.glideVersion = '4.12.0'
ext.daggerVersion = '2.30.1' ext.daggerVersion = '2.40.5'
ext.materialdrawerVersion = '8.2.0' ext.materialdrawerVersion = '8.4.5'
// if libraries are changed here, they should also be changed in LicenseActivity // if libraries are changed here, they should also be changed in LicenseActivity
dependencies { dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation "androidx.core:core-ktx:1.3.2" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion"
implementation "androidx.appcompat:appcompat:1.2.0" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-rx3:$coroutinesVersion"
implementation "androidx.fragment:fragment-ktx:1.2.5"
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.browser:browser:1.3.0"
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0" implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
implementation "androidx.recyclerview:recyclerview:1.2.0" implementation "androidx.recyclerview:recyclerview:1.2.1"
implementation "androidx.exifinterface:exifinterface:1.3.2" implementation "androidx.exifinterface:exifinterface:1.3.3"
implementation "androidx.cardview:cardview:1.0.0" implementation "androidx.cardview:cardview:1.0.0"
implementation "androidx.preference:preference-ktx:1.1.1" 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:1.1.0"
implementation "androidx.emoji:emoji-appcompat:1.1.0" implementation "androidx.emoji:emoji-appcompat:1.1.0"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion" implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion" implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion" implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-reactivestreams-ktx:$lifecycleVersion" implementation "androidx.lifecycle:lifecycle-reactivestreams-ktx:$lifecycleVersion"
implementation "androidx.constraintlayout:constraintlayout:2.0.4" implementation "androidx.constraintlayout:constraintlayout:2.1.2"
implementation "androidx.paging:paging-runtime-ktx:2.1.2" implementation "androidx.paging:paging-runtime-ktx:3.0.0"
implementation "androidx.viewpager2:viewpager2:1.0.0" implementation "androidx.viewpager2:viewpager2:1.0.0"
implementation "androidx.work:work-runtime:2.4.0" implementation "androidx.work:work-runtime:2.5.0"
implementation "androidx.room:room-runtime:$roomVersion" implementation "androidx.room:room-ktx:$roomVersion"
implementation "androidx.room:room-rxjava2:$roomVersion" implementation "androidx.room:room-rxjava3:$roomVersion"
kapt "androidx.room:room-compiler:$roomVersion" kapt "androidx.room:room-compiler:$roomVersion"
implementation "com.google.android.material:material:1.3.0" implementation "com.google.android.material:material:1.4.0"
implementation "com.google.code.gson:gson:2.8.9"
implementation "com.squareup.retrofit2:retrofit:$retrofitVersion" implementation "com.squareup.retrofit2:retrofit:$retrofitVersion"
implementation "com.squareup.retrofit2:converter-gson:$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:okhttp:$okhttpVersion"
implementation "com.squareup.okhttp3:logging-interceptor:$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:glide:$glideVersion"
implementation "com.github.bumptech.glide:okhttp3-integration:$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 "com.github.penfeizhou.android.animation:glide-plugin:2.17.0"
implementation "io.reactivex.rxjava2:rxandroid:2.1.1"
implementation "io.reactivex.rxjava2:rxkotlin:2.4.0"
implementation "com.uber.autodispose:autodispose-android-archcomponents:1.4.0" implementation "io.reactivex.rxjava3:rxjava:3.0.12"
implementation "com.uber.autodispose:autodispose:1.4.0" 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" implementation "com.google.dagger:dagger:$daggerVersion"
kapt "com.google.dagger:dagger-compiler:$daggerVersion" kapt "com.google.dagger:dagger-compiler:$daggerVersion"
@ -166,18 +167,21 @@ dependencies {
implementation "com.mikepenz:materialdrawer:$materialdrawerVersion" implementation "com.mikepenz:materialdrawer:$materialdrawerVersion"
implementation "com.mikepenz:materialdrawer-iconics:$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.robolectric:robolectric:4.4"
testImplementation "org.mockito:mockito-inline:3.6.28" testImplementation "org.mockito:mockito-inline:3.6.28"
testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0" 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.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"
} }

View file

@ -33,6 +33,10 @@
public static final ** CREATOR; public static final ** CREATOR;
} }
-keepclassmembers class **.R$* {
public static <fields>;
}
# TUSKY SPECIFIC OPTIONS # TUSKY SPECIFIC OPTIONS
# keep members of our model classes, they are used in json de/serialization # keep members of our model classes, they are used in json de/serialization
@ -43,10 +47,37 @@
public *; public *;
} }
-keepclassmembers class com.keylesspalace.tusky.components.conversation.ConversationAccountEntity { *; }
-keepclassmembers class com.keylesspalace.tusky.db.DraftAttachment { *; }
-keep enum com.keylesspalace.tusky.db.DraftAttachment$Type { -keep enum com.keylesspalace.tusky.db.DraftAttachment$Type {
public *; 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 # preserve line numbers for crash reporting
-keepattributes SourceFile,LineNumberTable -keepattributes SourceFile,LineNumberTable
-renamesourcefileattribute SourceFile -renamesourcefileattribute SourceFile
@ -65,7 +96,14 @@
# remove some kotlin overhead # remove some kotlin overhead
-assumenosideeffects class kotlin.jvm.internal.Intrinsics { -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 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 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); static void throwUninitializedPropertyAccessException(java.lang.String);
} }

View file

@ -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')"
]
}
}

View file

@ -0,0 +1,753 @@
{
"formatVersion": 1,
"database": {
"version": 27,
"identityHash": "be914d4eb3f406b6970fef53a925afa1",
"entities": [
{
"tableName": "DraftEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountId` INTEGER NOT NULL, `inReplyToId` TEXT, `content` TEXT, `contentWarning` TEXT, `sensitive` INTEGER NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `poll` TEXT, `failedToSend` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "inReplyToId",
"columnName": "inReplyToId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "contentWarning",
"columnName": "contentWarning",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "sensitive",
"columnName": "sensitive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "visibility",
"columnName": "visibility",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "poll",
"columnName": "poll",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "failedToSend",
"columnName": "failedToSend",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "AccountEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsSubscriptions` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "domain",
"columnName": "domain",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accessToken",
"columnName": "accessToken",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isActive",
"columnName": "isActive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "profilePictureUrl",
"columnName": "profilePictureUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "notificationsEnabled",
"columnName": "notificationsEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsMentioned",
"columnName": "notificationsMentioned",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsFollowed",
"columnName": "notificationsFollowed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsFollowRequested",
"columnName": "notificationsFollowRequested",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsReblogged",
"columnName": "notificationsReblogged",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsFavorited",
"columnName": "notificationsFavorited",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsPolls",
"columnName": "notificationsPolls",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsSubscriptions",
"columnName": "notificationsSubscriptions",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationSound",
"columnName": "notificationSound",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationVibration",
"columnName": "notificationVibration",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationLight",
"columnName": "notificationLight",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "defaultPostPrivacy",
"columnName": "defaultPostPrivacy",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "defaultMediaSensitivity",
"columnName": "defaultMediaSensitivity",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "alwaysShowSensitiveMedia",
"columnName": "alwaysShowSensitiveMedia",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "alwaysOpenSpoiler",
"columnName": "alwaysOpenSpoiler",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "mediaPreviewEnabled",
"columnName": "mediaPreviewEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastNotificationId",
"columnName": "lastNotificationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "activeNotifications",
"columnName": "activeNotifications",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "emojis",
"columnName": "emojis",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "tabPreferences",
"columnName": "tabPreferences",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "notificationsFilter",
"columnName": "notificationsFilter",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_AccountEntity_domain_accountId",
"unique": true,
"columnNames": [
"domain",
"accountId"
],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)"
}
],
"foreignKeys": []
},
{
"tableName": "InstanceEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `version` TEXT, PRIMARY KEY(`instance`))",
"fields": [
{
"fieldPath": "instance",
"columnName": "instance",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "emojiList",
"columnName": "emojiList",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "maximumTootCharacters",
"columnName": "maximumTootCharacters",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "maxPollOptions",
"columnName": "maxPollOptions",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "maxPollOptionLength",
"columnName": "maxPollOptionLength",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "version",
"columnName": "version",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"instance"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "TimelineStatusEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT, `visibility` INTEGER, `attachments` TEXT, `mentions` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
"fields": [
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "timelineUserId",
"columnName": "timelineUserId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "authorServerId",
"columnName": "authorServerId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "inReplyToId",
"columnName": "inReplyToId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "inReplyToAccountId",
"columnName": "inReplyToAccountId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "emojis",
"columnName": "emojis",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogsCount",
"columnName": "reblogsCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "favouritesCount",
"columnName": "favouritesCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "reblogged",
"columnName": "reblogged",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bookmarked",
"columnName": "bookmarked",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "favourited",
"columnName": "favourited",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sensitive",
"columnName": "sensitive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "spoilerText",
"columnName": "spoilerText",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "visibility",
"columnName": "visibility",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "mentions",
"columnName": "mentions",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "application",
"columnName": "application",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogServerId",
"columnName": "reblogServerId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogAccountId",
"columnName": "reblogAccountId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "poll",
"columnName": "poll",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "muted",
"columnName": "muted",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"serverId",
"timelineUserId"
],
"autoGenerate": false
},
"indices": [
{
"name": "index_TimelineStatusEntity_authorServerId_timelineUserId",
"unique": false,
"columnNames": [
"authorServerId",
"timelineUserId"
],
"createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)"
}
],
"foreignKeys": [
{
"table": "TimelineAccountEntity",
"onDelete": "NO ACTION",
"onUpdate": "NO ACTION",
"columns": [
"authorServerId",
"timelineUserId"
],
"referencedColumns": [
"serverId",
"timelineUserId"
]
}
]
},
{
"tableName": "TimelineAccountEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))",
"fields": [
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "timelineUserId",
"columnName": "timelineUserId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "localUsername",
"columnName": "localUsername",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "avatar",
"columnName": "avatar",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "emojis",
"columnName": "emojis",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bot",
"columnName": "bot",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"serverId",
"timelineUserId"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "ConversationEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsible` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accounts",
"columnName": "accounts",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "unread",
"columnName": "unread",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.id",
"columnName": "s_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.url",
"columnName": "s_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastStatus.inReplyToId",
"columnName": "s_inReplyToId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastStatus.inReplyToAccountId",
"columnName": "s_inReplyToAccountId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastStatus.account",
"columnName": "s_account",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.content",
"columnName": "s_content",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.createdAt",
"columnName": "s_createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.emojis",
"columnName": "s_emojis",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.favouritesCount",
"columnName": "s_favouritesCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.favourited",
"columnName": "s_favourited",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.bookmarked",
"columnName": "s_bookmarked",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.sensitive",
"columnName": "s_sensitive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.spoilerText",
"columnName": "s_spoilerText",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.attachments",
"columnName": "s_attachments",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.mentions",
"columnName": "s_mentions",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.showingHiddenContent",
"columnName": "s_showingHiddenContent",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.expanded",
"columnName": "s_expanded",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.collapsible",
"columnName": "s_collapsible",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.collapsed",
"columnName": "s_collapsed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.muted",
"columnName": "s_muted",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.poll",
"columnName": "s_poll",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id",
"accountId"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'be914d4eb3f406b6970fef53a925afa1')"
]
}
}

View file

@ -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')"
]
}
}

View file

@ -2,8 +2,8 @@ package com.keylesspalace.tusky
import androidx.room.testing.MigrationTestHelper import androidx.room.testing.MigrationTestHelper
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.AppDatabase
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Rule import org.junit.Rule
@ -33,12 +33,15 @@ class MigrationsTest {
val active = true val active = true
val accountId = "accountId" val accountId = "accountId"
val username = "username" val username = "username"
val values = arrayOf(id, domain, token, active, accountId, username, "Display Name", val values = arrayOf(
id, domain, token, active, accountId, username, "Display Name",
"https://picture.url", true, true, true, true, true, true, true, "https://picture.url", true, true, true, true, true, true, true,
true, "1000", "[]", "[{\"shortcode\": \"emoji\", \"url\": \"yes\"}]", 0, false, true, "1000", "[]", "[{\"shortcode\": \"emoji\", \"url\": \"yes\"}]", 0, false,
false, true) 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`," + "`accountId`,`username`,`displayName`,`profilePictureUrl`,`notificationsEnabled`," +
"`notificationsMentioned`,`notificationsFollowed`,`notificationsReblogged`," + "`notificationsMentioned`,`notificationsFollowed`,`notificationsReblogged`," +
"`notificationsFavorited`,`notificationSound`,`notificationVibration`," + "`notificationsFavorited`,`notificationSound`,`notificationVibration`," +
@ -46,7 +49,8 @@ class MigrationsTest {
"`defaultPostPrivacy`,`defaultMediaSensitivity`,`alwaysShowSensitiveMedia`," + "`defaultPostPrivacy`,`defaultMediaSensitivity`,`alwaysShowSensitiveMedia`," +
"`mediaPreviewEnabled`) " + "`mediaPreviewEnabled`) " +
"VALUES (nullif(?, 0),?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", "VALUES (nullif(?, 0),?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
values) values
)
db.close() db.close()

View file

@ -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<TimelineStatusEntity, TimelineAccountEntity, TimelineAccountEntity?> {
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)
}

View file

@ -34,9 +34,6 @@
android:resource="@xml/share_shortcuts" /> android:resource="@xml/share_shortcuts" />
</activity> </activity>
<activity
android:name=".SavedTootActivity"
android:configChanges="orientation|screenSize|keyboardHidden" />
<activity <activity
android:name=".LoginActivity" android:name=".LoginActivity"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
@ -114,7 +111,7 @@
android:name=".ViewMediaActivity" android:name=".ViewMediaActivity"
android:theme="@style/TuskyBaseTheme" /> android:theme="@style/TuskyBaseTheme" />
<activity <activity
android:name=".AccountActivity" android:name=".components.account.AccountActivity"
android:configChanges="orientation|screenSize|keyboardHidden|screenLayout|smallestScreenSize" /> android:configChanges="orientation|screenSize|keyboardHidden|screenLayout|smallestScreenSize" />
<activity android:name=".EditProfileActivity" /> <activity android:name=".EditProfileActivity" />
<activity android:name=".components.preference.PreferencesActivity" /> <activity android:name=".components.preference.PreferencesActivity" />
@ -123,7 +120,7 @@
<activity android:name=".AboutActivity" /> <activity android:name=".AboutActivity" />
<activity android:name=".TabPreferenceActivity" /> <activity android:name=".TabPreferenceActivity" />
<activity <activity
android:name="com.theartofdev.edmodo.cropper.CropImageActivity" android:name="com.canhub.cropper.CropImageActivity"
android:theme="@style/Base.Theme.AppCompat" /> android:theme="@style/Base.Theme.AppCompat" />
<activity <activity
android:name=".components.search.SearchActivity" android:name=".components.search.SearchActivity"

View file

@ -2,16 +2,16 @@ package com.keylesspalace.tusky
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.annotation.StringRes
import android.text.SpannableString import android.text.SpannableString
import android.text.SpannableStringBuilder import android.text.SpannableStringBuilder
import android.text.method.LinkMovementMethod import android.text.method.LinkMovementMethod
import android.text.style.URLSpan import android.text.style.URLSpan
import android.text.util.Linkify import android.text.util.Linkify
import android.widget.TextView import android.widget.TextView
import androidx.annotation.StringRes
import com.keylesspalace.tusky.databinding.ActivityAboutBinding import com.keylesspalace.tusky.databinding.ActivityAboutBinding
import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.util.CustomURLSpan import com.keylesspalace.tusky.util.NoUnderlineURLSpan
import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.hide
class AboutActivity : BottomSheetActivity(), Injectable { class AboutActivity : BottomSheetActivity(), Injectable {
@ -63,7 +63,7 @@ private fun TextView.setClickableTextWithoutUnderlines(@StringRes textId: Int) {
val end = builder.getSpanEnd(span) val end = builder.getSpanEnd(span)
val flags = builder.getSpanFlags(span) val flags = builder.getSpanFlags(span)
val customSpan = object : CustomURLSpan(span.url) {} val customSpan = NoUnderlineURLSpan(span.url)
builder.removeSpan(span) builder.removeSpan(span)
builder.setSpan(customSpan, start, end, flags) builder.setSpan(customSpan, start, end, flags)

View file

@ -28,18 +28,24 @@ import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.ListAdapter
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from
import autodispose2.autoDispose
import com.keylesspalace.tusky.databinding.FragmentAccountsInListBinding import com.keylesspalace.tusky.databinding.FragmentAccountsInListBinding
import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding
import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.* import com.keylesspalace.tusky.util.BindingHolder
import com.keylesspalace.tusky.util.Either
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.loadAvatar
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel
import com.keylesspalace.tusky.viewmodel.State import com.keylesspalace.tusky.viewmodel.State
import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import com.uber.autodispose.autoDispose
import io.reactivex.android.schedulers.AndroidSchedulers
import java.io.IOException import java.io.IOException
import javax.inject.Inject import javax.inject.Inject
@ -146,11 +152,15 @@ class AccountsInListFragment : DialogFragment(), Injectable {
viewModel.load(listId) viewModel.load(listId)
} }
if (error is IOException) { if (error is IOException) {
binding.messageView.setup(R.drawable.elephant_offline, binding.messageView.setup(
R.string.error_network, retryAction) R.drawable.elephant_offline,
R.string.error_network, retryAction
)
} else { } else {
binding.messageView.setup(R.drawable.elephant_error, binding.messageView.setup(
R.string.error_generic, retryAction) R.drawable.elephant_error,
R.string.error_generic, retryAction
)
} }
} }
@ -203,8 +213,8 @@ class AccountsInListFragment : DialogFragment(), Injectable {
} }
override fun areContentsTheSame(oldItem: AccountInfo, newItem: AccountInfo): Boolean { override fun areContentsTheSame(oldItem: AccountInfo, newItem: AccountInfo): Boolean {
return oldItem.second == newItem.second return oldItem.second == newItem.second &&
&& oldItem.first.deepEquals(newItem.first) oldItem.first.deepEquals(newItem.first)
} }
} }

View file

@ -199,6 +199,7 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
@Override @Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
if (requesters.containsKey(requestCode)) { if (requesters.containsKey(requestCode)) {
PermissionRequester requester = requesters.remove(requestCode); PermissionRequester requester = requesters.remove(requestCode);
requester.onRequestPermissionsResult(permissions, grantResults); requester.onRequestPermissionsResult(permissions, grantResults);

View file

@ -22,12 +22,13 @@ import android.widget.LinearLayout
import android.widget.Toast import android.widget.Toast
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider
import autodispose2.autoDispose
import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.keylesspalace.tusky.components.account.AccountActivity
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.LinkHelper import com.keylesspalace.tusky.util.LinkHelper
import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import com.uber.autodispose.autoDispose
import io.reactivex.android.schedulers.AndroidSchedulers
import java.net.URI import java.net.URI
import java.net.URISyntaxException import java.net.URISyntaxException
import javax.inject.Inject import javax.inject.Inject
@ -60,7 +61,6 @@ abstract class BottomSheetActivity : BaseActivity() {
override fun onSlide(bottomSheet: View, slideOffset: Float) {} override fun onSlide(bottomSheet: View, slideOffset: Float) {}
}) })
} }
open fun viewUrl(url: String, lookupFallbackBehavior: PostLookupFallbackBehavior = PostLookupFallbackBehavior.OPEN_IN_BROWSER) { open fun viewUrl(url: String, lookupFallbackBehavior: PostLookupFallbackBehavior = PostLookupFallbackBehavior.OPEN_IN_BROWSER) {
@ -74,7 +74,8 @@ abstract class BottomSheetActivity : BaseActivity() {
resolve = true resolve = true
).observeOn(AndroidSchedulers.mainThread()) ).observeOn(AndroidSchedulers.mainThread())
.autoDispose(AndroidLifecycleScopeProvider.from(this, Lifecycle.Event.ON_DESTROY)) .autoDispose(AndroidLifecycleScopeProvider.from(this, Lifecycle.Event.ON_DESTROY))
.subscribe({ (accounts, statuses) -> .subscribe(
{ (accounts, statuses) ->
if (getCancelSearchRequested(url)) { if (getCancelSearchRequested(url)) {
return@subscribe return@subscribe
} }
@ -90,12 +91,14 @@ abstract class BottomSheetActivity : BaseActivity() {
} }
performUrlFallbackAction(url, lookupFallbackBehavior) performUrlFallbackAction(url, lookupFallbackBehavior)
}, { },
{
if (!getCancelSearchRequested(url)) { if (!getCancelSearchRequested(url)) {
onEndSearch(url) onEndSearch(url)
performUrlFallbackAction(url, lookupFallbackBehavior) performUrlFallbackAction(url, lookupFallbackBehavior)
} }
}) }
)
onBeginSearch(url) onBeginSearch(url)
} }
@ -177,6 +180,8 @@ abstract class BottomSheetActivity : BaseActivity() {
// https://friendica.foo.bar/profile/user // https://friendica.foo.bar/profile/user
// https://friendica.foo.bar/display/d4643c42-3ae0-4b73-b8b0-c725f5819207 // https://friendica.foo.bar/display/d4643c42-3ae0-4b73-b8b0-c725f5819207
// https://misskey.foo.bar/notes/83w6r388br (always lowercase) // https://misskey.foo.bar/notes/83w6r388br (always lowercase)
// https://pixelfed.social/p/connyduck/391263492998670833
// https://pixelfed.social/connyduck
fun looksLikeMastodonUrl(urlString: String): Boolean { fun looksLikeMastodonUrl(urlString: String): Boolean {
val uri: URI val uri: URI
try { try {
@ -187,7 +192,8 @@ fun looksLikeMastodonUrl(urlString: String): Boolean {
if (uri.query != null || if (uri.query != null ||
uri.fragment != null || uri.fragment != null ||
uri.path == null) { uri.path == null
) {
return false return false
} }
@ -199,7 +205,9 @@ fun looksLikeMastodonUrl(urlString: String): Boolean {
path.matches("^/objects/[-a-f0-9]+$".toRegex()) || path.matches("^/objects/[-a-f0-9]+$".toRegex()) ||
path.matches("^/notes/[a-z0-9]+$".toRegex()) || path.matches("^/notes/[a-z0-9]+$".toRegex()) ||
path.matches("^/display/[-a-f0-9]+$".toRegex()) || path.matches("^/display/[-a-f0-9]+$".toRegex()) ||
path.matches("^/profile/\\w+$".toRegex()) path.matches("^/profile/\\w+$".toRegex()) ||
path.matches("^/p/\\w+/\\d+$".toRegex()) ||
path.matches("^/\\w+$".toRegex())
} }
enum class PostLookupFallbackBehavior { enum class PostLookupFallbackBehavior {

View file

@ -36,18 +36,24 @@ import androidx.recyclerview.widget.LinearLayoutManager
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.FitCenter import com.bumptech.glide.load.resource.bitmap.FitCenter
import com.bumptech.glide.load.resource.bitmap.RoundedCorners import com.bumptech.glide.load.resource.bitmap.RoundedCorners
import com.canhub.cropper.CropImage
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.adapter.AccountFieldEditAdapter import com.keylesspalace.tusky.adapter.AccountFieldEditAdapter
import com.keylesspalace.tusky.databinding.ActivityEditProfileBinding import com.keylesspalace.tusky.databinding.ActivityEditProfileBinding
import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.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.keylesspalace.tusky.viewmodel.EditProfileViewModel
import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizeDp import com.mikepenz.iconics.utils.sizeDp
import com.theartofdev.edmodo.cropper.CropImage
import javax.inject.Inject import javax.inject.Inject
class EditProfileActivity : BaseActivity(), Injectable { class EditProfileActivity : BaseActivity(), Injectable {
@ -150,7 +156,6 @@ class EditProfileActivity : BaseActivity(), Injectable {
.load(me.header) .load(me.header)
.into(binding.headerPreview) .into(binding.headerPreview)
} }
} }
} }
is Error -> { is Error -> {
@ -159,27 +164,27 @@ class EditProfileActivity : BaseActivity(), Injectable {
viewModel.obtainProfile() viewModel.obtainProfile()
} }
snackbar.show() snackbar.show()
} }
is Loading -> { }
} }
} }
viewModel.obtainInstance() viewModel.obtainInstance()
viewModel.instanceData.observe(this) { result -> viewModel.instanceData.observe(this) { result ->
when (result) { if (result is Success) {
is Success -> {
val instance = result.data val instance = result.data
if (instance?.maxBioChars != null && instance.maxBioChars > 0) { if (instance?.maxBioChars != null && instance.maxBioChars > 0) {
binding.noteEditTextLayout.counterMaxLength = instance.maxBioChars binding.noteEditTextLayout.counterMaxLength = instance.maxBioChars
} }
} }
} }
}
observeImage(viewModel.avatarData, binding.avatarPreview, binding.avatarProgressBar, true) observeImage(viewModel.avatarData, binding.avatarPreview, binding.avatarProgressBar, true)
observeImage(viewModel.headerData, binding.headerPreview, binding.headerProgressBar, false) observeImage(viewModel.headerData, binding.headerPreview, binding.headerProgressBar, false)
viewModel.saveData.observe(this, { viewModel.saveData.observe(
this,
{
when (it) { when (it) {
is Success -> { is Success -> {
finish() finish()
@ -191,8 +196,8 @@ class EditProfileActivity : BaseActivity(), Injectable {
onSaveFailure(it.errorMessage) onSaveFailure(it.errorMessage)
} }
} }
}) }
)
} }
override fun onSaveInstanceState(outState: Bundle) { override fun onSaveInstanceState(outState: Bundle) {
@ -203,18 +208,24 @@ class EditProfileActivity : BaseActivity(), Injectable {
override fun onStop() { override fun onStop() {
super.onStop() super.onStop()
if (!isFinishing) { if (!isFinishing) {
viewModel.updateProfile(binding.displayNameEditText.text.toString(), viewModel.updateProfile(
binding.displayNameEditText.text.toString(),
binding.noteEditText.text.toString(), binding.noteEditText.text.toString(),
binding.lockedCheckBox.isChecked, binding.lockedCheckBox.isChecked,
accountFieldEditAdapter.getFieldData()) accountFieldEditAdapter.getFieldData()
)
} }
} }
private fun observeImage(liveData: LiveData<Resource<Bitmap>>, private fun observeImage(
liveData: LiveData<Resource<Bitmap>>,
imageView: ImageView, imageView: ImageView,
progressBar: View, progressBar: View,
roundedCorners: Boolean) { roundedCorners: Boolean
liveData.observe(this, { ) {
liveData.observe(
this,
{
when (it) { when (it) {
is Success -> { is Success -> {
@ -242,10 +253,10 @@ class EditProfileActivity : BaseActivity(), Injectable {
onResizeFailure() onResizeFailure()
it.consumed = true it.consumed = true
} }
} }
} }
}) }
)
} }
private fun onMediaPick(pickType: PickType) { private fun onMediaPick(pickType: PickType) {
@ -261,8 +272,12 @@ class EditProfileActivity : BaseActivity(), Injectable {
} }
} }
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, override fun onRequestPermissionsResult(
grantResults: IntArray) { requestCode: Int,
permissions: Array<String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
when (requestCode) { when (requestCode) {
PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE -> { PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE -> {
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
@ -310,11 +325,13 @@ class EditProfileActivity : BaseActivity(), Injectable {
return return
} }
viewModel.save(binding.displayNameEditText.text.toString(), viewModel.save(
binding.displayNameEditText.text.toString(),
binding.noteEditText.text.toString(), binding.noteEditText.text.toString(),
binding.lockedCheckBox.isChecked, binding.lockedCheckBox.isChecked,
accountFieldEditAdapter.getFieldData(), accountFieldEditAdapter.getFieldData(),
this) this
)
} }
private fun onSaveFailure(msg: String?) { private fun onSaveFailure(msg: String?) {
@ -374,7 +391,7 @@ class EditProfileActivity : BaseActivity(), Injectable {
CropImage.CROP_IMAGE_ACTIVITY_REQUEST_CODE -> { CropImage.CROP_IMAGE_ACTIVITY_REQUEST_CODE -> {
val result = CropImage.getActivityResult(data) val result = CropImage.getActivityResult(data)
when (resultCode) { when (resultCode) {
Activity.RESULT_OK -> beginResize(result.uri) Activity.RESULT_OK -> beginResize(result?.uriContent)
CropImage.CROP_IMAGE_ACTIVITY_RESULT_ERROR_CODE -> onResizeFailure() CropImage.CROP_IMAGE_ACTIVITY_RESULT_ERROR_CODE -> onResizeFailure()
else -> endMediaPicking() 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() beginMediaPicking()
when (currentlyPicking) { when (currentlyPicking) {
@ -398,12 +420,10 @@ class EditProfileActivity : BaseActivity(), Injectable {
} }
currentlyPicking = PickType.NOTHING currentlyPicking = PickType.NOTHING
} }
private fun onResizeFailure() { private fun onResizeFailure() {
Snackbar.make(binding.avatarButton, R.string.error_media_upload_sending, Snackbar.LENGTH_LONG).show() Snackbar.make(binding.avatarButton, R.string.error_media_upload_sending, Snackbar.LENGTH_LONG).show()
endMediaPicking() endMediaPicking()
} }
} }

View file

@ -5,6 +5,7 @@ import android.widget.AdapterView
import android.widget.ArrayAdapter import android.widget.ArrayAdapter
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.lifecycleScope
import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.databinding.ActivityFiltersBinding 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.hide
import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.viewBinding
import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.await
import okhttp3.ResponseBody import okhttp3.ResponseBody
import retrofit2.Call import retrofit2.Call
import retrofit2.Callback import retrofit2.Callback
@ -90,8 +93,10 @@ class FiltersActivity: BaseActivity() {
} else { } else {
// Keep the filter, but remove it from this context // Keep the filter, but remove it from this context
val oldFilter = filters[itemIndex] val oldFilter = filters[itemIndex]
val newFilter = Filter(oldFilter.id, oldFilter.phrase, oldFilter.context.filter { c -> c != context }, val newFilter = Filter(
oldFilter.expiresAt, oldFilter.irreversible, oldFilter.wholeWord) oldFilter.id, oldFilter.phrase, oldFilter.context.filter { c -> c != context },
oldFilter.expiresAt, oldFilter.irreversible, oldFilter.wholeWord
)
updateFilter(newFilter, itemIndex) updateFilter(newFilter, itemIndex)
} }
} }
@ -139,8 +144,10 @@ class FiltersActivity: BaseActivity() {
.setView(binding.root) .setView(binding.root)
.setPositiveButton(R.string.filter_dialog_update_button) { _, _ -> .setPositiveButton(R.string.filter_dialog_update_button) { _, _ ->
val oldFilter = filters[itemIndex] val oldFilter = filters[itemIndex]
val newFilter = Filter(oldFilter.id, binding.phraseEditText.text.toString(), oldFilter.context, val newFilter = Filter(
oldFilter.expiresAt, oldFilter.irreversible, binding.phraseWholeWord.isChecked) oldFilter.id, binding.phraseEditText.text.toString(), oldFilter.context,
oldFilter.expiresAt, oldFilter.irreversible, binding.phraseWholeWord.isChecked
)
updateFilter(newFilter, itemIndex) updateFilter(newFilter, itemIndex)
} }
.setNegativeButton(R.string.filter_dialog_remove_button) { _, _ -> .setNegativeButton(R.string.filter_dialog_remove_button) { _, _ ->
@ -162,39 +169,35 @@ class FiltersActivity: BaseActivity() {
binding.addFilterButton.hide() binding.addFilterButton.hide()
binding.filterProgressBar.show() binding.filterProgressBar.show()
api.getFilters().enqueue(object : Callback<List<Filter>> { lifecycleScope.launch {
override fun onResponse(call: Call<List<Filter>>, response: Response<List<Filter>>) { val newFilters = try {
val filterResponse = response.body() api.getFilters().await()
if(response.isSuccessful && filterResponse != null) { } 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() }
} else {
binding.filterMessageView.setup(
R.drawable.elephant_error,
R.string.error_generic
) { loadFilters() }
}
return@launch
}
filters = filterResponse.filter { filter -> filter.context.contains(context) }.toMutableList() filters = newFilters.filter { it.context.contains(context) }.toMutableList()
refreshFilterDisplay() refreshFilterDisplay()
binding.filtersView.show() binding.filtersView.show()
binding.addFilterButton.show() binding.addFilterButton.show()
binding.filterProgressBar.hide() 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<List<Filter>>, t: Throwable) {
binding.filterProgressBar.hide()
binding.filterMessageView.show()
if (t is IOException) {
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() }
}
}
})
}
companion object { companion object {
const val FILTERS_CONTEXT = "filters_context" const val FILTERS_CONTEXT = "filters_context"
const val FILTERS_TITLE = "filters_title" const val FILTERS_TITLE = "filters_title"

View file

@ -16,9 +16,9 @@
package com.keylesspalace.tusky package com.keylesspalace.tusky
import android.os.Bundle import android.os.Bundle
import androidx.annotation.RawRes
import android.util.Log import android.util.Log
import android.widget.TextView import android.widget.TextView
import androidx.annotation.RawRes
import com.keylesspalace.tusky.databinding.ActivityLicenseBinding import com.keylesspalace.tusky.databinding.ActivityLicenseBinding
import com.keylesspalace.tusky.util.IOUtils import com.keylesspalace.tusky.util.IOUtils
import java.io.BufferedReader import java.io.BufferedReader
@ -41,7 +41,6 @@ class LicenseActivity : BaseActivity() {
setTitle(R.string.title_licenses) setTitle(R.string.title_licenses)
loadFileIntoTextView(R.raw.apache, binding.licenseApacheTextView) loadFileIntoTextView(R.raw.apache, binding.licenseApacheTextView)
} }
private fun loadFileIntoTextView(@RawRes fileId: Int, textView: TextView) { private fun loadFileIntoTextView(@RawRes fileId: Int, textView: TextView) {

View file

@ -23,32 +23,48 @@ import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import 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.activity.viewModels
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog 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.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import at.connyduck.sparkbutton.helpers.Utils import at.connyduck.sparkbutton.helpers.Utils
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from
import autodispose2.autoDispose
import com.google.android.material.snackbar.Snackbar 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.databinding.ActivityListsBinding
import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.MastoList import com.keylesspalace.tusky.entity.MastoList
import com.keylesspalace.tusky.fragment.TimelineFragment import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.* 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
import com.keylesspalace.tusky.viewmodel.ListsViewModel.Event.* import com.keylesspalace.tusky.viewmodel.ListsViewModel.Event
import com.keylesspalace.tusky.viewmodel.ListsViewModel.LoadingState.* 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.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizeDp 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.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector import dagger.android.HasAndroidInjector
import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import javax.inject.Inject import javax.inject.Inject
/** /**
@ -84,7 +100,8 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
binding.listsRecycler.adapter = adapter binding.listsRecycler.adapter = adapter
binding.listsRecycler.layoutManager = LinearLayoutManager(this) binding.listsRecycler.layoutManager = LinearLayoutManager(this)
binding.listsRecycler.addItemDecoration( binding.listsRecycler.addItemDecoration(
DividerItemDecoration(this, DividerItemDecoration.VERTICAL)) DividerItemDecoration(this, DividerItemDecoration.VERTICAL)
)
viewModel.state viewModel.state
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
@ -101,9 +118,9 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
.subscribe { event -> .subscribe { event ->
@Suppress("WHEN_ENUM_CAN_BE_NULL_IN_JAVA") @Suppress("WHEN_ENUM_CAN_BE_NULL_IN_JAVA")
when (event) { when (event) {
CREATE_ERROR -> showMessage(R.string.error_create_list) Event.CREATE_ERROR -> showMessage(R.string.error_create_list)
RENAME_ERROR -> showMessage(R.string.error_rename_list) Event.RENAME_ERROR -> showMessage(R.string.error_rename_list)
DELETE_ERROR -> showMessage(R.string.error_delete_list) Event.DELETE_ERROR -> showMessage(R.string.error_delete_list)
} }
} }
} }
@ -121,7 +138,8 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
.setView(layout) .setView(layout)
.setPositiveButton( .setPositiveButton(
if (list == null) R.string.action_create_list if (list == null) R.string.action_create_list
else R.string.action_rename_list) { _, _ -> else R.string.action_rename_list
) { _, _ ->
onPickedDialogName(editText.text, list?.id) onPickedDialogName(editText.text, list?.id)
} }
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)
@ -145,7 +163,6 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
.show() .show()
} }
private fun update(state: ListsViewModel.State) { private fun update(state: ListsViewModel.State) {
adapter.submitList(state.lists) adapter.submitList(state.lists)
binding.progressBar.visible(state.loadingState == LOADING) binding.progressBar.visible(state.loadingState == LOADING)
@ -166,8 +183,10 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
LOADED -> LOADED ->
if (state.lists.isEmpty()) { if (state.lists.isEmpty()) {
binding.messageView.show() binding.messageView.show()
binding.messageView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, binding.messageView.setup(
null) R.drawable.elephant_friend_empty, R.string.message_empty,
null
)
} else { } else {
binding.messageView.hide() binding.messageView.hide()
} }
@ -182,7 +201,8 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
private fun onListSelected(listId: String) { private fun onListSelected(listId: String) {
startActivityWithSlideInAnimation( startActivityWithSlideInAnimation(
ModalTimelineActivity.newIntent(this, TimelineFragment.Kind.LIST, listId)) ModalTimelineActivity.newIntent(this, TimelineViewModel.Kind.LIST, listId)
)
} }
private fun openListSettings(list: MastoList) { private fun openListSettings(list: MastoList) {
@ -219,8 +239,8 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
} }
} }
private inner class ListsAdapter private inner class ListsAdapter :
: ListAdapter<MastoList, ListsAdapter.ListViewHolder>(ListsDiffer) { ListAdapter<MastoList, ListsAdapter.ListViewHolder>(ListsDiffer) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ListViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ListViewHolder {
return LayoutInflater.from(parent.context).inflate(R.layout.item_list, parent, false) return LayoutInflater.from(parent.context).inflate(R.layout.item_list, parent, false)
@ -238,7 +258,8 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
holder.nameTextView.text = getItem(position).title holder.nameTextView.text = getItem(position).title
} }
private inner class ListViewHolder(view: View) : RecyclerView.ViewHolder(view), private inner class ListViewHolder(view: View) :
RecyclerView.ViewHolder(view),
View.OnClickListener { View.OnClickListener {
val nameTextView: TextView = view.findViewById(R.id.list_name_textview) val nameTextView: TextView = view.findViewById(R.id.list_name_textview)
val moreButton: ImageButton = view.findViewById(R.id.editListButton) val moreButton: ImageButton = view.findViewById(R.id.editListButton)

View file

@ -34,7 +34,11 @@ import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.AccessToken import com.keylesspalace.tusky.entity.AccessToken
import com.keylesspalace.tusky.entity.AppCredentials import com.keylesspalace.tusky.entity.AppCredentials
import com.keylesspalace.tusky.network.MastodonApi 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 okhttp3.HttpUrl
import retrofit2.Call import retrofit2.Call
import retrofit2.Callback import retrofit2.Callback
@ -75,7 +79,8 @@ class LoginActivity : BaseActivity(), Injectable {
} }
preferences = getSharedPreferences( 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.loginButton.setOnClickListener { onButtonClick() }
binding.registerButton.setOnClickListener { onRegisterClick() } binding.registerButton.setOnClickListener { onRegisterClick() }
@ -96,7 +101,6 @@ class LoginActivity : BaseActivity(), Injectable {
} else { } else {
binding.toolbar.visibility = View.GONE binding.toolbar.visibility = View.GONE
} }
} }
override fun requiresLogin(): Boolean { override fun requiresLogin(): Boolean {
@ -150,8 +154,10 @@ class LoginActivity : BaseActivity(), Injectable {
} }
val callback = object : Callback<AppCredentials> { val callback = object : Callback<AppCredentials> {
override fun onResponse(call: Call<AppCredentials>, override fun onResponse(
response: Response<AppCredentials>) { call: Call<AppCredentials>,
response: Response<AppCredentials>
) {
if (!response.isSuccessful) { if (!response.isSuccessful) {
binding.loginButton.isEnabled = true binding.loginButton.isEnabled = true
binding.domainTextInputLayout.error = getString(R.string.error_failed_app_registration) binding.domainTextInputLayout.error = getString(R.string.error_failed_app_registration)
@ -181,11 +187,12 @@ class LoginActivity : BaseActivity(), Injectable {
} }
mastodonApi mastodonApi
.authenticateApp(domain, getString(R.string.app_name), oauthRedirectUri, .authenticateApp(
OAUTH_SCOPES, getString(R.string.tusky_website)) domain, getString(R.string.app_name), oauthRedirectUri,
OAUTH_SCOPES, getString(R.string.tusky_website)
)
.enqueue(callback) .enqueue(callback)
setLoading(true) setLoading(true)
} }
private fun redirectUserToAuthorizeAndLogin(domain: String, clientId: String) { private fun redirectUserToAuthorizeAndLogin(domain: String, clientId: String) {
@ -240,31 +247,27 @@ class LoginActivity : BaseActivity(), Injectable {
} else { } else {
setLoading(false) setLoading(false)
binding.domainTextInputLayout.error = getString(R.string.error_retrieving_oauth_token) binding.domainTextInputLayout.error = getString(R.string.error_retrieving_oauth_token)
Log.e(TAG, String.format("%s %s", Log.e(TAG, "%s %s".format(getString(R.string.error_retrieving_oauth_token), response.message()))
getString(R.string.error_retrieving_oauth_token),
response.message()))
} }
} }
override fun onFailure(call: Call<AccessToken>, t: Throwable) { override fun onFailure(call: Call<AccessToken>, t: Throwable) {
setLoading(false) setLoading(false)
binding.domainTextInputLayout.error = getString(R.string.error_retrieving_oauth_token) binding.domainTextInputLayout.error = getString(R.string.error_retrieving_oauth_token)
Log.e(TAG, String.format("%s %s", Log.e(TAG, "%s %s".format(getString(R.string.error_retrieving_oauth_token), t.message))
getString(R.string.error_retrieving_oauth_token),
t.message))
} }
} }
mastodonApi.fetchOAuthToken(domain, clientId, clientSecret, redirectUri, code, mastodonApi.fetchOAuthToken(
"authorization_code").enqueue(callback) domain, clientId, clientSecret, redirectUri, code,
"authorization_code"
).enqueue(callback)
} else if (error != null) { } else if (error != null) {
/* Authorization failed. Put the error response where the user can read it and they /* Authorization failed. Put the error response where the user can read it and they
* can try again. */ * can try again. */
setLoading(false) setLoading(false)
binding.domainTextInputLayout.error = getString(R.string.error_authorization_denied) binding.domainTextInputLayout.error = getString(R.string.error_authorization_denied)
Log.e(TAG, String.format("%s %s", Log.e(TAG, "%s %s".format(getString(R.string.error_authorization_denied), error))
getString(R.string.error_authorization_denied),
error))
} else { } else {
// This case means a junk response was received somehow. // This case means a junk response was received somehow.
setLoading(false) setLoading(false)

View file

@ -19,7 +19,10 @@ import android.content.Context
import android.content.DialogInterface import android.content.DialogInterface
import android.content.Intent import android.content.Intent
import android.content.res.ColorStateList import android.content.res.ColorStateList
import android.graphics.Bitmap
import android.graphics.Color import android.graphics.Color
import android.graphics.drawable.Animatable
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
@ -31,24 +34,30 @@ import android.widget.ImageView
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.edit
import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.content.pm.ShortcutManagerCompat
import androidx.emoji.text.EmojiCompat import androidx.emoji.text.EmojiCompat
import androidx.emoji.text.EmojiCompat.InitCallback import androidx.emoji.text.EmojiCompat.InitCallback
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import androidx.viewpager2.widget.MarginPageTransformer import androidx.viewpager2.widget.MarginPageTransformer
import autodispose2.androidx.lifecycle.autoDispose
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.RequestManager import com.bumptech.glide.RequestManager
import com.bumptech.glide.load.resource.bitmap.RoundedCorners import com.bumptech.glide.load.resource.bitmap.RoundedCorners
import com.bumptech.glide.request.target.CustomTarget import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.target.FixedSizeDrawable import com.bumptech.glide.request.target.FixedSizeDrawable
import com.bumptech.glide.request.transition.Transition 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
import com.google.android.material.tabs.TabLayout.OnTabSelectedListener import com.google.android.material.tabs.TabLayout.OnTabSelectedListener
import com.google.android.material.tabs.TabLayoutMediator 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.announcements.AnnouncementsActivity
import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.components.compose.ComposeActivity
import com.keylesspalace.tusky.components.compose.ComposeActivity.Companion.canHandleMimeType 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.components.search.SearchActivity
import com.keylesspalace.tusky.databinding.ActivityMainBinding import com.keylesspalace.tusky.databinding.ActivityMainBinding
import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.fragment.SFragment
import com.keylesspalace.tusky.interfaces.AccountSelectionListener import com.keylesspalace.tusky.interfaces.AccountSelectionListener
import com.keylesspalace.tusky.interfaces.ActionButtonActivity import com.keylesspalace.tusky.interfaces.ActionButtonActivity
import com.keylesspalace.tusky.interfaces.ReselectableFragment import com.keylesspalace.tusky.interfaces.ReselectableFragment
import com.keylesspalace.tusky.pager.MainPagerAdapter import com.keylesspalace.tusky.pager.MainPagerAdapter
import com.keylesspalace.tusky.settings.PrefKeys 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.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt 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.ColorHolder
import com.mikepenz.materialdrawer.holder.StringHolder import com.mikepenz.materialdrawer.holder.StringHolder
import com.mikepenz.materialdrawer.iconics.iconicsIcon import com.mikepenz.materialdrawer.iconics.iconicsIcon
import com.mikepenz.materialdrawer.model.* import com.mikepenz.materialdrawer.model.AbstractDrawerItem
import com.mikepenz.materialdrawer.model.interfaces.* import com.mikepenz.materialdrawer.model.DividerDrawerItem
import com.mikepenz.materialdrawer.util.* 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.mikepenz.materialdrawer.widget.AccountHeaderView
import com.uber.autodispose.android.lifecycle.autoDispose
import dagger.android.DispatchingAndroidInjector import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector import dagger.android.HasAndroidInjector
import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers import io.reactivex.rxjava3.schedulers.Schedulers
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInjector { class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInjector {
@ -102,9 +131,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
@Inject @Inject
lateinit var conversationRepository: ConversationsRepository lateinit var conversationRepository: ConversationsRepository
@Inject
lateinit var appDb: AppDatabase
@Inject @Inject
lateinit var draftHelper: DraftHelper lateinit var draftHelper: DraftHelper
@ -135,10 +161,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val activeAccount = accountManager.activeAccount val activeAccount = accountManager.activeAccount
if (activeAccount == null) { ?: return // will be redirected to LoginActivity by BaseActivity
// will be redirected to LoginActivity by BaseActivity
return
}
var showNotificationTab = false var showNotificationTab = false
if (intent != null) { if (intent != null) {
/** there are two possibilities the accountId can be passed to MainActivity: /** there are two possibilities the accountId can be passed to MainActivity:
@ -163,7 +187,9 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
forwardShare(intent) forwardShare(intent)
} else { } else {
// No account was provided, show the chooser // No account was provided, show the chooser
showAccountChooserDialog(getString(R.string.action_share_as), true, object : AccountSelectionListener { showAccountChooserDialog(
getString(R.string.action_share_as), true,
object : AccountSelectionListener {
override fun onAccountSelected(account: AccountEntity) { override fun onAccountSelected(account: AccountEntity) {
val requestedId = account.id val requestedId = account.id
if (requestedId == activeAccount.id) { if (requestedId == activeAccount.id) {
@ -175,7 +201,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
changeAccount(requestedId, intent) changeAccount(requestedId, intent)
} }
} }
}) }
)
} }
} else if (accountRequested && savedInstanceState == null) { } else if (accountRequested && savedInstanceState == null) {
// user clicked a notification, show notification tab // user clicked a notification, show notification tab
@ -243,7 +270,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
// Flush old media that was cached for sharing // Flush old media that was cached for sharing
deleteStaleCachedMedia(applicationContext.getExternalFilesDir("Tusky")) deleteStaleCachedMedia(applicationContext.getExternalFilesDir("Tusky"))
} }
draftWarning()
} }
override fun onResume() { override fun onResume() {
@ -331,12 +357,15 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
headerBackgroundScaleType = ImageView.ScaleType.CENTER_CROP headerBackgroundScaleType = ImageView.ScaleType.CENTER_CROP
currentHiddenInList = true currentHiddenInList = true
onAccountHeaderListener = { _: View?, profile: IProfile, current: Boolean -> handleProfileClick(profile, current) } onAccountHeaderListener = { _: View?, profile: IProfile, current: Boolean -> handleProfileClick(profile, current) }
addProfile(ProfileSettingDrawerItem().apply { addProfile(
ProfileSettingDrawerItem().apply {
identifier = DRAWER_ITEM_ADD_ACCOUNT identifier = DRAWER_ITEM_ADD_ACCOUNT
nameRes = R.string.add_account_name nameRes = R.string.add_account_name
descriptionRes = R.string.add_account_description descriptionRes = R.string.add_account_description
iconicsIcon = GoogleMaterial.Icon.gmd_add iconicsIcon = GoogleMaterial.Icon.gmd_add
}, 0) },
0
)
attachToSliderView(binding.mainDrawer) attachToSliderView(binding.mainDrawer)
dividerBelowHeader = false dividerBelowHeader = false
closeDrawerOnProfileListClick = true closeDrawerOnProfileListClick = true
@ -417,7 +446,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
} }
}, },
primaryDrawerItem { primaryDrawerItem {
nameRes = R.string.action_access_saved_toot nameRes = R.string.action_access_drafts
iconRes = R.drawable.ic_notebook iconRes = R.drawable.ic_notebook
onClick = { onClick = {
val intent = DraftsActivity.newIntent(context) val intent = DraftsActivity.newIntent(context)
@ -476,14 +505,16 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
) )
if (addSearchButton) { if (addSearchButton) {
binding.mainDrawer.addItemsAtPosition(4, binding.mainDrawer.addItemsAtPosition(
4,
primaryDrawerItem { primaryDrawerItem {
nameRes = R.string.action_search nameRes = R.string.action_search
iconicsIcon = GoogleMaterial.Icon.gmd_search iconicsIcon = GoogleMaterial.Icon.gmd_search
onClick = { onClick = {
startActivityWithSlideInAnimation(SearchActivity.getIntent(context)) startActivityWithSlideInAnimation(SearchActivity.getIntent(context))
} }
}) }
)
} }
setSavedInstance(savedInstanceState) setSavedInstance(savedInstanceState)
@ -580,7 +611,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
binding.mainToolbar.setOnClickListener { binding.mainToolbar.setOnClickListener {
(adapter.getFragment(activeTabLayout.selectedTabPosition) as? ReselectableFragment)?.onReselect() (adapter.getFragment(activeTabLayout.selectedTabPosition) as? ReselectableFragment)?.onReselect()
} }
} }
private fun handleProfileClick(profile: IProfile, current: Boolean): Boolean { private fun handleProfileClick(profile: IProfile, current: Boolean): Boolean {
@ -604,7 +634,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
private fun changeAccount(newSelectedId: Long, forward: Intent?) { private fun changeAccount(newSelectedId: Long, forward: Intent?) {
cacheUpdater.stop() cacheUpdater.stop()
SFragment.flushFilters()
accountManager.setActiveAccount(newSelectedId) accountManager.setActiveAccount(newSelectedId)
val intent = Intent(this, MainActivity::class.java) val intent = Intent(this, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
@ -623,25 +652,31 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
AlertDialog.Builder(this) AlertDialog.Builder(this)
.setTitle(R.string.action_logout) .setTitle(R.string.action_logout)
.setMessage(getString(R.string.action_logout_confirm, activeAccount.fullName)) .setMessage(getString(R.string.action_logout_confirm, activeAccount.fullName))
.setPositiveButton(android.R.string.yes) { _: DialogInterface?, _: Int -> .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int ->
NotificationHelper.deleteNotificationChannelsForAccount(activeAccount, this) lifecycleScope.launch {
NotificationHelper.deleteNotificationChannelsForAccount(activeAccount, this@MainActivity)
cacheUpdater.clearForUser(activeAccount.id) cacheUpdater.clearForUser(activeAccount.id)
conversationRepository.deleteCacheForAccount(activeAccount.id) conversationRepository.deleteCacheForAccount(activeAccount.id)
draftHelper.deleteAllDraftsAndAttachmentsForAccount(activeAccount.id) draftHelper.deleteAllDraftsAndAttachmentsForAccount(activeAccount.id)
removeShortcut(this, activeAccount) removeShortcut(this@MainActivity, activeAccount)
val newAccount = accountManager.logActiveAccountOut() val newAccount = accountManager.logActiveAccountOut()
if (!NotificationHelper.areNotificationsEnabled(this, accountManager)) { if (!NotificationHelper.areNotificationsEnabled(
NotificationHelper.disablePullNotifications(this) this@MainActivity,
accountManager
)
) {
NotificationHelper.disablePullNotifications(this@MainActivity)
} }
val intent = if (newAccount == null) { val intent = if (newAccount == null) {
LoginActivity.getIntent(this, false) LoginActivity.getIntent(this@MainActivity, false)
} else { } else {
Intent(this, MainActivity::class.java) Intent(this@MainActivity, MainActivity::class.java)
} }
startActivity(intent) startActivity(intent)
finishWithoutSlideOutAnimation() finishWithoutSlideOutAnimation()
} }
.setNegativeButton(android.R.string.no, null) }
.setNegativeButton(android.R.string.cancel, null)
.show() .show()
} }
} }
@ -679,6 +714,9 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
private fun loadDrawerAvatar(avatarUrl: String, showPlaceholder: Boolean) { private fun loadDrawerAvatar(avatarUrl: String, showPlaceholder: Boolean) {
val navIconSize = resources.getDimensionPixelSize(R.dimen.avatar_toolbar_nav_icon_size) val navIconSize = resources.getDimensionPixelSize(R.dimen.avatar_toolbar_nav_icon_size)
val animateAvatars = preferences.getBoolean("animateGifAvatars", false)
if (animateAvatars) {
glide.asDrawable() glide.asDrawable()
.load(avatarUrl) .load(avatarUrl)
.transform( .transform(
@ -698,6 +736,9 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
} }
override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) { override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) {
if (resource is Animatable) {
resource.start()
}
binding.mainToolbar.navigationIcon = FixedSizeDrawable(resource, navIconSize, navIconSize) binding.mainToolbar.navigationIcon = FixedSizeDrawable(resource, navIconSize, navIconSize)
} }
@ -707,6 +748,36 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
} }
} }
}) })
} 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<Bitmap>(navIconSize, navIconSize) {
override fun onLoadStarted(placeholder: Drawable?) {
if (placeholder != null) {
binding.mainToolbar.navigationIcon = FixedSizeDrawable(placeholder, navIconSize, navIconSize)
}
}
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
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() { private fun fetchAnnouncements() {
@ -755,30 +826,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
header.setActiveProfile(accountManager.activeAccount!!.id) header.setActiveProfile(accountManager.activeAccount!!.id)
} }
private fun draftWarning() { override fun getActionButton() = binding.composeButton
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 androidInjector() = androidInjector override fun androidInjector() = androidInjector

View file

@ -4,8 +4,9 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import com.google.android.material.floatingactionbutton.FloatingActionButton 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.databinding.ActivityModalTimelineBinding
import com.keylesspalace.tusky.fragment.TimelineFragment
import com.keylesspalace.tusky.interfaces.ActionButtonActivity import com.keylesspalace.tusky.interfaces.ActionButtonActivity
import dagger.android.DispatchingAndroidInjector import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector import dagger.android.HasAndroidInjector
@ -29,8 +30,8 @@ class ModalTimelineActivity : BottomSheetActivity(), ActionButtonActivity, HasAn
} }
if (supportFragmentManager.findFragmentById(R.id.contentFrame) == null) { if (supportFragmentManager.findFragmentById(R.id.contentFrame) == null) {
val kind = intent?.getSerializableExtra(ARG_KIND) as? TimelineFragment.Kind val kind = intent?.getSerializableExtra(ARG_KIND) as? TimelineViewModel.Kind
?: TimelineFragment.Kind.HOME ?: TimelineViewModel.Kind.HOME
val argument = intent?.getStringExtra(ARG_ARG) val argument = intent?.getStringExtra(ARG_ARG)
supportFragmentManager.beginTransaction() supportFragmentManager.beginTransaction()
.replace(R.id.contentFrame, TimelineFragment.newInstance(kind, argument)) .replace(R.id.contentFrame, TimelineFragment.newInstance(kind, argument))
@ -47,13 +48,15 @@ class ModalTimelineActivity : BottomSheetActivity(), ActionButtonActivity, HasAn
private const val ARG_ARG = "arg" private const val ARG_ARG = "arg"
@JvmStatic @JvmStatic
fun newIntent(context: Context, kind: TimelineFragment.Kind, fun newIntent(
argument: String?): Intent { context: Context,
kind: TimelineViewModel.Kind,
argument: String?
): Intent {
val intent = Intent(context, ModalTimelineActivity::class.java) val intent = Intent(context, ModalTimelineActivity::class.java)
intent.putExtra(ARG_KIND, kind) intent.putExtra(ARG_KIND, kind)
intent.putExtra(ARG_ARG, argument) intent.putExtra(ARG_ARG, argument)
return intent return intent
} }
} }
} }

View file

@ -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 <http://www.gnu.org/licenses>. */
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<TootEntity> 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<List<String>>() {}.getType();
List<String> jsonUrls = gson.fromJson(item.getUrls(), stringListType);
List<String> 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<Void, Void, List<TootEntity>> {
private final WeakReference<SavedTootActivity> activityRef;
private final TootDao tootDao;
FetchPojosTask(SavedTootActivity activity, TootDao tootDao) {
this.activityRef = new WeakReference<>(activity);
this.tootDao = tootDao;
}
@Override
protected List<TootEntity> doInBackground(Void... voids) {
return tootDao.loadAll();
}
@Override
protected void onPostExecute(List<TootEntity> 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();
}
}
}

View file

@ -18,10 +18,9 @@ package com.keylesspalace.tusky
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import com.keylesspalace.tusky.components.notifications.NotificationHelper
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.components.notifications.NotificationHelper
import javax.inject.Inject import javax.inject.Inject
class SplashActivity : AppCompatActivity(), Injectable { class SplashActivity : AppCompatActivity(), Injectable {
@ -46,5 +45,4 @@ class SplashActivity : AppCompatActivity(), Injectable {
startActivity(intent) startActivity(intent)
finish() finish()
} }
} }

View file

@ -19,15 +19,12 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.fragment.app.commit 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.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.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector import dagger.android.HasAndroidInjector
import javax.inject.Inject
class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
@ -60,7 +57,6 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
val fragment = TimelineFragment.newInstance(kind) val fragment = TimelineFragment.newInstance(kind)
replace(R.id.fragment_container, fragment) replace(R.id.fragment_container, fragment)
} }
} }
override fun androidInjector() = dispatchingAndroidInjector override fun androidInjector() = dispatchingAndroidInjector
@ -81,5 +77,4 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
putExtra(EXTRA_KIND, Kind.BOOKMARKS.name) putExtra(EXTRA_KIND, Kind.BOOKMARKS.name)
} }
} }
} }

View file

@ -20,8 +20,9 @@ import androidx.annotation.DrawableRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import com.keylesspalace.tusky.components.conversation.ConversationsFragment 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.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 */ /** this would be a good case for a sealed class, but that does not work nice with Room */
@ -33,7 +34,8 @@ const val DIRECT = "Direct"
const val HASHTAG = "Hashtag" const val HASHTAG = "Hashtag"
const val LIST = "List" const val LIST = "List"
data class TabData(val id: String, data class TabData(
val id: String,
@StringRes val text: Int, @StringRes val text: Int,
@DrawableRes val icon: Int, @DrawableRes val icon: Int,
val fragment: (List<String>) -> Fragment, val fragment: (List<String>) -> Fragment,
@ -47,7 +49,7 @@ fun createTabDataFromId(id: String, arguments: List<String> = emptyList()): TabD
HOME, HOME,
R.string.title_home, R.string.title_home,
R.drawable.ic_home_24dp, R.drawable.ic_home_24dp,
{ TimelineFragment.newInstance(TimelineFragment.Kind.HOME) } { TimelineFragment.newInstance(TimelineViewModel.Kind.HOME) }
) )
NOTIFICATIONS -> TabData( NOTIFICATIONS -> TabData(
NOTIFICATIONS, NOTIFICATIONS,
@ -59,13 +61,13 @@ fun createTabDataFromId(id: String, arguments: List<String> = emptyList()): TabD
LOCAL, LOCAL,
R.string.title_public_local, R.string.title_public_local,
R.drawable.ic_local_24dp, R.drawable.ic_local_24dp,
{ TimelineFragment.newInstance(TimelineFragment.Kind.PUBLIC_LOCAL) } { TimelineFragment.newInstance(TimelineViewModel.Kind.PUBLIC_LOCAL) }
) )
FEDERATED -> TabData( FEDERATED -> TabData(
FEDERATED, FEDERATED,
R.string.title_public_federated, R.string.title_public_federated,
R.drawable.ic_public_24dp, R.drawable.ic_public_24dp,
{ TimelineFragment.newInstance(TimelineFragment.Kind.PUBLIC_FEDERATED) } { TimelineFragment.newInstance(TimelineViewModel.Kind.PUBLIC_FEDERATED) }
) )
DIRECT -> TabData( DIRECT -> TabData(
DIRECT, DIRECT,
@ -85,7 +87,7 @@ fun createTabDataFromId(id: String, arguments: List<String> = emptyList()): TabD
LIST, LIST,
R.string.list, R.string.list,
R.drawable.ic_list, R.drawable.ic_list,
{ args -> TimelineFragment.newInstance(TimelineFragment.Kind.LIST, args.getOrNull(0).orEmpty()) }, { args -> TimelineFragment.newInstance(TimelineViewModel.Kind.LIST, args.getOrNull(0).orEmpty()) },
arguments, arguments,
{ arguments.getOrNull(1).orEmpty() } { arguments.getOrNull(1).orEmpty() }
) )

View file

@ -31,6 +31,8 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.transition.TransitionManager import androidx.transition.TransitionManager
import at.connyduck.sparkbutton.helpers.Utils 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.MaterialArcMotion
import com.google.android.material.transition.MaterialContainerTransform import com.google.android.material.transition.MaterialContainerTransform
import com.keylesspalace.tusky.adapter.ItemInteractionListener 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.onTextChanged
import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible import com.keylesspalace.tusky.util.visible
import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import com.uber.autodispose.autoDispose import io.reactivex.rxjava3.core.Single
import io.reactivex.Single import io.reactivex.rxjava3.schedulers.Schedulers
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers
import java.util.regex.Pattern import java.util.regex.Pattern
import javax.inject.Inject import javax.inject.Inject
@ -333,7 +333,6 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.autoDispose(from(this, Lifecycle.Event.ON_DESTROY)) .autoDispose(from(this, Lifecycle.Event.ON_DESTROY))
.subscribe() .subscribe()
} }
tabsChanged = true tabsChanged = true
} }
@ -357,5 +356,4 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
private const val MIN_TAB_COUNT = 2 private const val MIN_TAB_COUNT = 2
private const val MAX_TAB_COUNT = 5 private const val MAX_TAB_COUNT = 5
} }
} }

View file

@ -22,16 +22,16 @@ import android.util.Log
import androidx.emoji.text.EmojiCompat import androidx.emoji.text.EmojiCompat
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import androidx.work.WorkManager import androidx.work.WorkManager
import autodispose2.AutoDisposePlugins
import com.keylesspalace.tusky.components.notifications.NotificationWorkerFactory import com.keylesspalace.tusky.components.notifications.NotificationWorkerFactory
import com.keylesspalace.tusky.di.AppInjector import com.keylesspalace.tusky.di.AppInjector
import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.EmojiCompatFont import com.keylesspalace.tusky.util.EmojiCompatFont
import com.keylesspalace.tusky.util.LocaleManager import com.keylesspalace.tusky.util.LocaleManager
import com.keylesspalace.tusky.util.ThemeUtils import com.keylesspalace.tusky.util.ThemeUtils
import com.uber.autodispose.AutoDisposePlugins
import dagger.android.DispatchingAndroidInjector import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector import dagger.android.HasAndroidInjector
import io.reactivex.plugins.RxJavaPlugins import io.reactivex.rxjava3.plugins.RxJavaPlugins
import org.conscrypt.Conscrypt import org.conscrypt.Conscrypt
import java.security.Security import java.security.Security
import javax.inject.Inject import javax.inject.Inject

View file

@ -41,27 +41,27 @@ import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.viewpager2.adapter.FragmentStateAdapter import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.viewpager2.widget.ViewPager2 import androidx.viewpager2.widget.ViewPager2
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider
import autodispose2.autoDispose
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.request.FutureTarget import com.bumptech.glide.request.FutureTarget
import com.keylesspalace.tusky.BuildConfig.APPLICATION_ID import com.keylesspalace.tusky.BuildConfig.APPLICATION_ID
import com.keylesspalace.tusky.databinding.ActivityViewMediaBinding import com.keylesspalace.tusky.databinding.ActivityViewMediaBinding
import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.fragment.ViewImageFragment import com.keylesspalace.tusky.fragment.ViewImageFragment
import com.keylesspalace.tusky.pager.SingleImagePagerAdapter
import com.keylesspalace.tusky.pager.ImagePagerAdapter import com.keylesspalace.tusky.pager.ImagePagerAdapter
import com.keylesspalace.tusky.pager.SingleImagePagerAdapter
import com.keylesspalace.tusky.util.getTemporaryMediaFilename import com.keylesspalace.tusky.util.getTemporaryMediaFilename
import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.viewdata.AttachmentViewData import com.keylesspalace.tusky.viewdata.AttachmentViewData
import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import com.uber.autodispose.autoDispose import io.reactivex.rxjava3.core.Single
import io.reactivex.Single import io.reactivex.rxjava3.schedulers.Schedulers
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers
import java.io.File import java.io.File
import java.io.FileNotFoundException import java.io.FileNotFoundException
import java.io.FileOutputStream import java.io.FileOutputStream
import java.io.IOException import java.io.IOException
import java.util.* import java.util.Locale
typealias ToolbarVisibilityListener = (isVisible: Boolean) -> Unit typealias ToolbarVisibilityListener = (isVisible: Boolean) -> Unit
@ -102,7 +102,6 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
val realAttachs = attachments!!.map(AttachmentViewData::attachment) val realAttachs = attachments!!.map(AttachmentViewData::attachment)
// Setup the view pager. // Setup the view pager.
ImagePagerAdapter(this, realAttachs, initialPosition) ImagePagerAdapter(this, realAttachs, initialPosition)
} else { } else {
imageUrl = intent.getStringExtra(EXTRA_SINGLE_IMAGE_URL) 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")
@ -138,6 +137,7 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
} }
window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LOW_PROFILE window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LOW_PROFILE
window.statusBarColor = Color.BLACK window.statusBarColor = Color.BLACK
window.sharedElementEnterTransition.addListener(object : NoopTransitionListener { window.sharedElementEnterTransition.addListener(object : NoopTransitionListener {
override fun onTransitionEnd(transition: Transition) { override fun onTransitionEnd(transition: Transition) {
@ -205,8 +205,10 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
val downloadManager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager val downloadManager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
val request = DownloadManager.Request(Uri.parse(url)) val request = DownloadManager.Request(Uri.parse(url))
request.setDestinationInExternalPublicDir(Environment.DIRECTORY_PICTURES, request.setDestinationInExternalPublicDir(
getString(R.string.app_name) + "/" + filename) Environment.DIRECTORY_PICTURES,
getString(R.string.app_name) + "/" + filename
)
downloadManager.enqueue(request) downloadManager.enqueue(request)
} }
@ -260,7 +262,6 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_media_to))) startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_media_to)))
} }
private var isCreating: Boolean = false private var isCreating: Boolean = false
private fun shareImage(directory: File, url: String) { private fun shareImage(directory: File, url: String) {
@ -283,7 +284,6 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
Log.e(TAG, "Error writing temporary media.") Log.e(TAG, "Error writing temporary media.")
} }
return@fromCallable false return@fromCallable false
} }
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
@ -308,7 +308,6 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
Log.e(TAG, "Failed to download image", error) Log.e(TAG, "Failed to download image", error)
} }
) )
} }
private fun shareMediaFile(directory: File, url: String) { private fun shareMediaFile(directory: File, url: String) {

View file

@ -25,7 +25,7 @@ import androidx.appcompat.widget.Toolbar;
import androidx.fragment.app.Fragment; import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentTransaction; import androidx.fragment.app.FragmentTransaction;
import com.keylesspalace.tusky.fragment.TimelineFragment; import com.keylesspalace.tusky.components.timeline.TimelineFragment;
import java.util.Collections; import java.util.Collections;

View file

@ -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 <http://www.gnu.org/licenses>. */
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<Account> 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<Account> newAccounts) {
accountList = ListUtils.removeDuplicates(newAccounts);
notifyDataSetChanged();
}
public void addItems(@NonNull List<Account> 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<Account> 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);
}
}

View file

@ -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 <http://www.gnu.org/licenses>. */
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<AVH : RecyclerView.ViewHolder> internal constructor(
var accountActionListener: AccountActionListener,
protected val animateAvatar: Boolean,
protected val animateEmojis: Boolean
) : RecyclerView.Adapter<RecyclerView.ViewHolder?>() {
var accountList = mutableListOf<Account>()
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<Account>) {
accountList = removeDuplicates(newAccounts)
notifyDataSetChanged()
}
fun addItems(newAccounts: List<Account>) {
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
}
}

View file

@ -82,9 +82,7 @@ class AccountFieldEditAdapter : RecyclerView.Adapter<BindingHolder<ItemEditField
override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {} override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {}
}) })
} }
class MutableStringPair(var first: String, var second: String) class MutableStringPair(var first: String, var second: String)
} }

View file

@ -25,7 +25,8 @@ import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemAutocompleteAccountBinding import com.keylesspalace.tusky.databinding.ItemAutocompleteAccountBinding
import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.* import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.loadAvatar
class AccountSelectionAdapter(context: Context) : ArrayAdapter<AccountEntity>(context, R.layout.item_autocomplete_account) { class AccountSelectionAdapter(context: Context) : ArrayAdapter<AccountEntity>(context, R.layout.item_autocomplete_account) {
@ -48,7 +49,6 @@ class AccountSelectionAdapter(context: Context) : ArrayAdapter<AccountEntity>(co
val animateAvatar = pm.getBoolean("animateGifAvatars", false) val animateAvatar = pm.getBoolean("animateGifAvatars", false)
loadAvatar(account.profilePictureUrl, binding.avatar, avatarRadius, animateAvatar) loadAvatar(account.profilePictureUrl, binding.avatar, avatarRadius, animateAvatar)
} }
return binding.root return binding.root

View file

@ -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 <http://www.gnu.org/licenses>. */
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));
}
}
}

View file

@ -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 <http://www.gnu.org/licenses>. */
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<BlocksAdapter.BlockedUserViewHolder>(
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) }
}
}
}

View file

@ -22,7 +22,7 @@ import com.bumptech.glide.Glide
import com.keylesspalace.tusky.databinding.ItemEmojiButtonBinding import com.keylesspalace.tusky.databinding.ItemEmojiButtonBinding
import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.util.BindingHolder import com.keylesspalace.tusky.util.BindingHolder
import java.util.* import java.util.Locale
class EmojiAdapter( class EmojiAdapter(
emojiList: List<Emoji>, emojiList: List<Emoji>,
@ -30,7 +30,7 @@ class EmojiAdapter(
) : RecyclerView.Adapter<BindingHolder<ItemEmojiButtonBinding>>() { ) : RecyclerView.Adapter<BindingHolder<ItemEmojiButtonBinding>>() {
private val emojiList: List<Emoji> = emojiList.filter { emoji -> emoji.visibleInPicker == null || emoji.visibleInPicker } private val emojiList: List<Emoji> = emojiList.filter { emoji -> emoji.visibleInPicker == null || emoji.visibleInPicker }
.sortedBy { it.shortcode.toLowerCase(Locale.ROOT) } .sortedBy { it.shortcode.lowercase(Locale.ROOT) }
override fun getItemCount() = emojiList.size override fun getItemCount() = emojiList.size

View file

@ -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 <http://www.gnu.org/licenses>. */
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);
}
}
}

View file

@ -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 <http://www.gnu.org/licenses>. */
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<AccountViewHolder>(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)
}
}

View file

@ -24,7 +24,10 @@ import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding
import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.interfaces.AccountActionListener 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( class FollowRequestViewHolder(
private val binding: ItemFollowRequestBinding, private val binding: ItemFollowRequestBinding,

View file

@ -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 <http://www.gnu.org/licenses>. */
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());
}
}
}

View file

@ -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 <http://www.gnu.org/licenses>. */
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<FollowRequestViewHolder>(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)
}
}

View file

@ -34,7 +34,6 @@ class FollowRequestsHeaderAdapter(private val instanceName: String, private val
} }
override fun getItemCount() = if (accountLocked) 0 else 1 override fun getItemCount() = if (accountLocked) 0 else 1
} }
class HeaderViewHolder(var textView: TextView) : RecyclerView.ViewHolder(textView) class HeaderViewHolder(var textView: TextView) : RecyclerView.ViewHolder(textView)

View file

@ -15,7 +15,7 @@
package com.keylesspalace.tusky.adapter package com.keylesspalace.tusky.adapter
import androidx.recyclerview.widget.RecyclerView
import android.view.View import android.view.View
import androidx.recyclerview.widget.RecyclerView
class LoadingFooterViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) class LoadingFooterViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView)

View file

@ -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<String, Boolean> mutingNotificationsMap;
public MutesAdapter(AccountActionListener accountActionListener, boolean animateAvatar, boolean animateEmojis) {
super(accountActionListener, animateAvatar, animateEmojis);
mutingNotificationsMap = new HashMap<String, Boolean>();
}
@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<String, Boolean> 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));
}
}
}

View file

@ -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<MutesAdapter.MutedUserViewHolder>(
accountActionListener,
animateAvatar,
animateEmojis
) {
private val mutingNotificationsMap = HashMap<String, Boolean>()
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<String, Boolean>?) {
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) }
}
}
}

View file

@ -15,30 +15,28 @@
package com.keylesspalace.tusky.adapter package com.keylesspalace.tusky.adapter
import androidx.paging.LoadState
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import android.view.ViewGroup
import com.keylesspalace.tusky.databinding.ItemNetworkStateBinding import com.keylesspalace.tusky.databinding.ItemNetworkStateBinding
import com.keylesspalace.tusky.util.NetworkState
import com.keylesspalace.tusky.util.Status
import com.keylesspalace.tusky.util.visible import com.keylesspalace.tusky.util.visible
class NetworkStateViewHolder(private val binding: ItemNetworkStateBinding, class NetworkStateViewHolder(
private val retryCallback: () -> Unit) private val binding: ItemNetworkStateBinding,
: RecyclerView.ViewHolder(binding.root) { private val retryCallback: () -> Unit
) : RecyclerView.ViewHolder(binding.root) {
fun setUpWithNetworkState(state: NetworkState?, fullScreen: Boolean) { fun setUpWithNetworkState(state: LoadState) {
binding.progressBar.visible(state?.status == Status.RUNNING) binding.progressBar.visible(state == LoadState.Loading)
binding.retryButton.visible(state?.status == Status.FAILED) binding.retryButton.visible(state is LoadState.Error)
binding.errorMsg.visible(state?.msg != null) val msg = if (state is LoadState.Error) {
binding.errorMsg.text = state?.msg state.error.message
} else {
null
}
binding.errorMsg.visible(msg != null)
binding.errorMsg.text = msg
binding.retryButton.setOnClickListener { binding.retryButton.setOnClickListener {
retryCallback() retryCallback()
} }
if(fullScreen) {
binding.root.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT
} else {
binding.root.layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT
} }
} }
}

View file

@ -1,4 +1,4 @@
/* Copyright 2017 Andrew Dawson /* Copyright 2021 Tusky Contributors
* *
* This file is a part of Tusky. * 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.Account;
import com.keylesspalace.tusky.entity.Emoji; import com.keylesspalace.tusky.entity.Emoji;
import com.keylesspalace.tusky.entity.Notification; import com.keylesspalace.tusky.entity.Notification;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.interfaces.AccountActionListener; import com.keylesspalace.tusky.interfaces.AccountActionListener;
import com.keylesspalace.tusky.interfaces.LinkListener; import com.keylesspalace.tusky.interfaces.LinkListener;
import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.interfaces.StatusActionListener;
@ -195,14 +196,15 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
} else { } else {
holder.showNotificationContent(true); holder.showNotificationContent(true);
holder.setDisplayName(statusViewData.getUserFullName(), statusViewData.getAccountEmojis()); Status status = statusViewData.getActionable();
holder.setUsername(statusViewData.getNickname()); holder.setDisplayName(status.getAccount().getDisplayName(), status.getAccount().getEmojis());
holder.setCreatedAt(statusViewData.getCreatedAt()); holder.setUsername(status.getAccount().getUsername());
holder.setCreatedAt(status.getCreatedAt());
if (concreteNotificaton.getType() == Notification.Type.STATUS) { if (concreteNotificaton.getType() == Notification.Type.STATUS) {
holder.setAvatar(statusViewData.getAvatar(), statusViewData.isBot()); holder.setAvatar(status.getAccount().getAvatar(), status.getAccount().getBot());
} else { } else {
holder.setAvatars(statusViewData.getAvatar(), holder.setAvatars(status.getAccount().getAvatar(),
concreteNotificaton.getAccount().getAvatar()); concreteNotificaton.getAccount().getAvatar());
} }
} }
@ -215,7 +217,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
if (payloadForHolder instanceof List) if (payloadForHolder instanceof List)
for (Object item : (List) payloadForHolder) { for (Object item : (List) payloadForHolder) {
if (StatusBaseViewHolder.Key.KEY_CREATED.equals(item) && statusViewData != null) { 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(), statusDisplayOptions.useBlurhash(),
CardViewMode.NONE, CardViewMode.NONE,
statusDisplayOptions.confirmReblogs(), statusDisplayOptions.confirmReblogs(),
statusDisplayOptions.confirmFavourites(),
statusDisplayOptions.hideStats(), statusDisplayOptions.hideStats(),
statusDisplayOptions.animateEmojis() statusDisplayOptions.animateEmojis()
); );
@ -531,7 +534,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
message.setText(emojifiedText); message.setText(emojifiedText);
if (statusViewData != null) { if (statusViewData != null) {
boolean hasSpoiler = !TextUtils.isEmpty(statusViewData.getSpoilerText()); boolean hasSpoiler = !TextUtils.isEmpty(statusViewData.getStatus().getSpoilerText());
contentWarningDescriptionTextView.setVisibility(hasSpoiler ? View.VISIBLE : View.GONE); contentWarningDescriptionTextView.setVisibility(hasSpoiler ? View.VISIBLE : View.GONE);
contentWarningButton.setVisibility(hasSpoiler ? View.VISIBLE : View.GONE); contentWarningButton.setVisibility(hasSpoiler ? View.VISIBLE : View.GONE);
if (statusViewData.isExpanded()) { if (statusViewData.isExpanded()) {
@ -607,7 +610,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
private void setupContentAndSpoiler(final LinkListener listener) { private void setupContentAndSpoiler(final LinkListener listener) {
boolean shouldShowContentIfSpoiler = statusViewData.isExpanded(); boolean shouldShowContentIfSpoiler = statusViewData.isExpanded();
boolean hasSpoiler = !TextUtils.isEmpty(statusViewData.getSpoilerText()); boolean hasSpoiler = !TextUtils.isEmpty(statusViewData.getStatus(). getSpoilerText());
if (!shouldShowContentIfSpoiler && hasSpoiler) { if (!shouldShowContentIfSpoiler && hasSpoiler) {
statusContent.setVisibility(View.GONE); statusContent.setVisibility(View.GONE);
} else { } else {
@ -615,7 +618,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
} }
Spanned content = statusViewData.getContent(); Spanned content = statusViewData.getContent();
List<Emoji> emojis = statusViewData.getStatusEmojis(); List<Emoji> emojis = statusViewData.getActionable().getEmojis();
if (statusViewData.isCollapsible() && (statusViewData.isExpanded() || !hasSpoiler)) { if (statusViewData.isCollapsible() && (statusViewData.isExpanded() || !hasSpoiler)) {
contentCollapseButton.setOnClickListener(view -> { contentCollapseButton.setOnClickListener(view -> {
@ -641,14 +644,19 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
CharSequence emojifiedText = CustomEmojiHelper.emojify( CharSequence emojifiedText = CustomEmojiHelper.emojify(
content, emojis, statusContent, statusDisplayOptions.animateEmojis() content, emojis, statusContent, statusDisplayOptions.animateEmojis()
); );
LinkHelper.setClickableText(statusContent, emojifiedText, statusViewData.getMentions(), listener); LinkHelper.setClickableText(statusContent, emojifiedText, statusViewData.getActionable().getMentions(), listener);
CharSequence emojifiedContentWarning = CustomEmojiHelper.emojify( CharSequence emojifiedContentWarning;
if (statusViewData.getSpoilerText() != null) {
emojifiedContentWarning = CustomEmojiHelper.emojify(
statusViewData.getSpoilerText(), statusViewData.getSpoilerText(),
statusViewData.getStatusEmojis(), statusViewData.getActionable().getEmojis(),
contentWarningDescriptionTextView, contentWarningDescriptionTextView,
statusDisplayOptions.animateEmojis() statusDisplayOptions.animateEmojis()
); );
} else {
emojifiedContentWarning = "";
}
contentWarningDescriptionTextView.setText(emojifiedContentWarning); contentWarningDescriptionTextView.setText(emojifiedContentWarning);
} }

View file

@ -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 <http://www.gnu.org/licenses>. */
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());
});
}
}

View file

@ -0,0 +1,41 @@
/* Copyright 2021 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.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)
}
}
}

View file

@ -18,8 +18,10 @@ package com.keylesspalace.tusky.adapter
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.emoji.text.EmojiCompat import androidx.emoji.text.EmojiCompat
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemPollBinding import com.keylesspalace.tusky.databinding.ItemPollBinding
import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.util.BindingHolder import com.keylesspalace.tusky.util.BindingHolder
@ -46,7 +48,8 @@ class PollAdapter: RecyclerView.Adapter<BindingHolder<ItemPollBinding>>() {
emojis: List<Emoji>, emojis: List<Emoji>,
mode: Int, mode: Int,
resultClickListener: View.OnClickListener?, resultClickListener: View.OnClickListener?,
animateEmojis: Boolean) { animateEmojis: Boolean
) {
this.pollOptions = options this.pollOptions = options
this.voteCount = voteCount this.voteCount = voteCount
this.votersCount = votersCount this.votersCount = votersCount
@ -62,7 +65,6 @@ class PollAdapter: RecyclerView.Adapter<BindingHolder<ItemPollBinding>>() {
.map { pollOptions.indexOf(it) } .map { pollOptions.indexOf(it) }
} }
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemPollBinding> { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemPollBinding> {
val binding = ItemPollBinding.inflate(LayoutInflater.from(parent.context), parent, false) val binding = ItemPollBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return BindingHolder(binding) return BindingHolder(binding)
@ -85,13 +87,19 @@ class PollAdapter: RecyclerView.Adapter<BindingHolder<ItemPollBinding>>() {
when (mode) { when (mode) {
RESULT -> { RESULT -> {
val percent = calculatePercent(option.votesCount, votersCount, voteCount) val percent = calculatePercent(option.votesCount, votersCount, voteCount)
val emojifiedPollOptionText = buildDescription(option.title, percent, resultTextView.context) val emojifiedPollOptionText = buildDescription(option.title, percent, option.voted, resultTextView.context)
.emojify(emojis, resultTextView, animateEmojis) .emojify(emojis, resultTextView, animateEmojis)
resultTextView.text = EmojiCompat.get().process(emojifiedPollOptionText) resultTextView.text = EmojiCompat.get().process(emojifiedPollOptionText)
val level = percent * 100 val level = percent * 100
val optionColor = if (option.voted) {
R.color.colorBackgroundHighlight
} else {
R.color.colorBackgroundAccent
}
resultTextView.background.level = level resultTextView.background.level = level
resultTextView.background.setTint(ContextCompat.getColor(resultTextView.context, optionColor))
resultTextView.setOnClickListener(resultClickListener) resultTextView.setOnClickListener(resultClickListener)
} }
SINGLE -> { SINGLE -> {
@ -114,7 +122,6 @@ class PollAdapter: RecyclerView.Adapter<BindingHolder<ItemPollBinding>>() {
} }
} }
} }
} }
companion object { companion object {

View file

@ -60,7 +60,6 @@ class PreviewPollOptionsAdapter: RecyclerView.Adapter<PreviewViewHolder>() {
textView.setOnClickListener(clickListener) textView.setOnClickListener(clickListener)
} }
} }
class PreviewViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) class PreviewViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView)

View file

@ -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 <http://www.gnu.org/licenses>. */
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<TootEntity> 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<TootEntity> newToot) {
list = new ArrayList<>();
list.addAll(newToot);
}
public void addItems(List<TootEntity> 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));
}
}
}
}

View file

@ -201,7 +201,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
protected void setSpoilerAndContent(boolean expanded, protected void setSpoilerAndContent(boolean expanded,
@NonNull Spanned content, @NonNull Spanned content,
@Nullable String spoilerText, @Nullable String spoilerText,
@Nullable Status.Mention[] mentions, @Nullable List<Status.Mention> mentions,
@NonNull List<Emoji> emojis, @NonNull List<Emoji> emojis,
@Nullable PollViewData poll, @Nullable PollViewData poll,
@NonNull StatusDisplayOptions statusDisplayOptions, @NonNull StatusDisplayOptions statusDisplayOptions,
@ -243,7 +243,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
private void setTextVisible(boolean sensitive, private void setTextVisible(boolean sensitive,
boolean expanded, boolean expanded,
Spanned content, Spanned content,
Status.Mention[] mentions, List<Status.Mention> mentions,
List<Emoji> emojis, List<Emoji> emojis,
@Nullable PollViewData poll, @Nullable PollViewData poll,
StatusDisplayOptions statusDisplayOptions, StatusDisplayOptions statusDisplayOptions,
@ -651,11 +651,19 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
} }
favouriteButton.setEventListener((button, buttonState) -> { favouriteButton.setEventListener((button, buttonState) -> {
// return true to play animaion
int position = getBindingAdapterPosition(); int position = getBindingAdapterPosition();
if (position != RecyclerView.NO_POSITION) { if (position != RecyclerView.NO_POSITION) {
if (statusDisplayOptions.confirmFavourites()) {
showConfirmFavouriteDialog(listener, statusContent, buttonState, position);
return false;
} else {
listener.onFavourite(!buttonState, position); listener.onFavourite(!buttonState, position);
}
return true; return true;
}
} else {
return true;
}
}); });
bookmarkButton.setEventListener((button, buttonState) -> { bookmarkButton.setEventListener((button, buttonState) -> {
@ -703,26 +711,45 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
.show(); .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, public void setupWithStatus(StatusViewData.Concrete status, final StatusActionListener listener,
StatusDisplayOptions statusDisplayOptions) { StatusDisplayOptions statusDisplayOptions) {
this.setupWithStatus(status, listener, statusDisplayOptions, null); this.setupWithStatus(status, listener, statusDisplayOptions, null);
} }
protected void setupWithStatus(StatusViewData.Concrete status, public void setupWithStatus(StatusViewData.Concrete status,
final StatusActionListener listener, final StatusActionListener listener,
StatusDisplayOptions statusDisplayOptions, StatusDisplayOptions statusDisplayOptions,
@Nullable Object payloads) { @Nullable Object payloads) {
if (payloads == null) { if (payloads == null) {
setDisplayName(status.getUserFullName(), status.getAccountEmojis(), statusDisplayOptions); Status actionable = status.getActionable();
setUsername(status.getNickname()); setDisplayName(actionable.getAccount().getDisplayName(), actionable.getAccount().getEmojis(), statusDisplayOptions);
setCreatedAt(status.getCreatedAt(), statusDisplayOptions); setUsername(status.getUsername());
setIsReply(status.getInReplyToId() != null); setCreatedAt(actionable.getCreatedAt(), statusDisplayOptions);
setAvatar(status.getAvatar(), status.getRebloggedAvatar(), status.isBot(), statusDisplayOptions); setIsReply(actionable.getInReplyToId() != null);
setReblogged(status.isReblogged()); setAvatar(actionable.getAccount().getAvatar(), status.getRebloggedAvatar(),
setFavourited(status.isFavourited()); actionable.getAccount().getBot(), statusDisplayOptions);
setBookmarked(status.isBookmarked()); setReblogged(actionable.getReblogged());
List<Attachment> attachments = status.getAttachments(); setFavourited(actionable.getFavourited());
boolean sensitive = status.isSensitive(); setBookmarked(actionable.getBookmarked());
List<Attachment> attachments = actionable.getAttachments();
boolean sensitive = actionable.getSensitive();
if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) { if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) {
setMediaPreviews(attachments, sensitive, listener, status.isShowingContent(), statusDisplayOptions.useBlurhash()); setMediaPreviews(attachments, sensitive, listener, status.isShowingContent(), statusDisplayOptions.useBlurhash());
@ -744,14 +771,17 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
} }
if (cardView != null) { 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); 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); setDescriptionForStatus(status, statusDisplayOptions);
@ -765,7 +795,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
if (payloads instanceof List) if (payloads instanceof List)
for (Object item : (List<?>) payloads) { for (Object item : (List<?>) payloads) {
if (Key.KEY_CREATED.equals(item)) { 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, private void setDescriptionForStatus(@NonNull StatusViewData.Concrete status,
StatusDisplayOptions statusDisplayOptions) { StatusDisplayOptions statusDisplayOptions) {
Context context = itemView.getContext(); Context context = itemView.getContext();
Status actionable = status.getActionable();
String description = context.getString(R.string.description_status, String description = context.getString(R.string.description_status,
status.getUserFullName(), actionable.getAccount().getDisplayName(),
getContentWarningDescription(context, status), getContentWarningDescription(context, status),
(TextUtils.isEmpty(status.getSpoilerText()) || !status.isSensitive() || status.isExpanded() ? status.getContent() : ""), (TextUtils.isEmpty(status.getSpoilerText()) || !actionable.getSensitive() || status.isExpanded() ? status.getContent() : ""),
getCreatedAtDescription(status.getCreatedAt(), statusDisplayOptions), getCreatedAtDescription(actionable.getCreatedAt(), statusDisplayOptions),
getReblogDescription(context, status), getReblogDescription(context, status),
status.getNickname(), status.getUsername(),
status.isReblogged() ? context.getString(R.string.description_status_reblogged) : "", actionable.getReblogged() ? context.getString(R.string.description_status_reblogged) : "",
status.isFavourited() ? context.getString(R.string.description_status_favourited) : "", actionable.getFavourited() ? context.getString(R.string.description_status_favourited) : "",
status.isBookmarked() ? context.getString(R.string.description_status_bookmarked) : "", actionable.getBookmarked() ? context.getString(R.string.description_status_bookmarked) : "",
getMediaDescription(context, status), getMediaDescription(context, status),
getVisibilityDescription(context, status.getVisibility()), getVisibilityDescription(context, actionable.getVisibility()),
getFavsText(context, status.getFavouritesCount()), getFavsText(context, actionable.getFavouritesCount()),
getReblogsText(context, status.getReblogsCount()), getReblogsText(context, actionable.getReblogsCount()),
getPollDescription(status, context, statusDisplayOptions) getPollDescription(status, context, statusDisplayOptions)
); );
itemView.setContentDescription(description); itemView.setContentDescription(description);
@ -806,10 +837,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
private static CharSequence getReblogDescription(Context context, private static CharSequence getReblogDescription(Context context,
@NonNull StatusViewData.Concrete status) { @NonNull StatusViewData.Concrete status) {
String rebloggedUsername = status.getRebloggedByUsername(); Status reblog = status.getRebloggingStatus();
if (rebloggedUsername != null) { if (reblog != null) {
return context return context
.getString(R.string.status_boosted_format, rebloggedUsername); .getString(R.string.status_boosted_format, reblog.getAccount().getUsername());
} else { } else {
return ""; return "";
} }
@ -817,11 +848,11 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
private static CharSequence getMediaDescription(Context context, private static CharSequence getMediaDescription(Context context,
@NonNull StatusViewData.Concrete status) { @NonNull StatusViewData.Concrete status) {
if (status.getAttachments().isEmpty()) { if (status.getActionable().getAttachments().isEmpty()) {
return ""; return "";
} }
StringBuilder mediaDescriptions = CollectionsKt.fold( StringBuilder mediaDescriptions = CollectionsKt.fold(
status.getAttachments(), status.getActionable().getAttachments(),
new StringBuilder(), new StringBuilder(),
(builder, a) -> { (builder, a) -> {
if (a.getDescription() == null) { if (a.getDescription() == null) {
@ -874,7 +905,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
private CharSequence getPollDescription(@NonNull StatusViewData.Concrete status, private CharSequence getPollDescription(@NonNull StatusViewData.Concrete status,
Context context, Context context,
StatusDisplayOptions statusDisplayOptions) { StatusDisplayOptions statusDisplayOptions) {
PollViewData poll = status.getPoll(); PollViewData poll = PollViewDataKt.toViewData(status.getActionable().getPoll());
if (poll == null) { if (poll == null) {
return ""; return "";
} else { } else {
@ -883,7 +914,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
for (int i = 0; i < args.length; i++) { for (int i = 0; i < args.length; i++) {
if (i < options.size()) { if (i < options.size()) {
int percent = PollViewDataKt.calculatePercent(options.get(i).getVotesCount(), poll.getVotersCount(), poll.getVotesCount()); 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 { } else {
args[i] = ""; args[i] = "";
} }
@ -1003,13 +1034,18 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
return pollDescription.getContext().getString(R.string.poll_info_format, votesText, pollDurationInfo); 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 && if (cardViewMode != CardViewMode.NONE &&
status.getAttachments().size() == 0 && status.getActionable().getAttachments().size() == 0 &&
status.getCard() != null && card != null &&
!TextUtils.isEmpty(status.getCard().getUrl()) && !TextUtils.isEmpty(card.getUrl()) &&
(!status.isCollapsible() || !status.isCollapsed())) { (!status.isCollapsible() || !status.isCollapsed())) {
final Card card = status.getCard();
cardView.setVisibility(View.VISIBLE); cardView.setVisibility(View.VISIBLE);
cardTitle.setText(card.getTitle()); cardTitle.setText(card.getTitle());
if (TextUtils.isEmpty(card.getDescription()) && TextUtils.isEmpty(card.getAuthorName())) { 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, // Statuses from other activitypub sources can be marked sensitive even if there's no media,
// so let's blur the preview in that case // so let's blur the preview in that case
// If media previews are disabled, show placeholder for cards as well // 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 topLeftRadius = 0;
int topRightRadius = 0; int topRightRadius = 0;
@ -1094,7 +1130,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
cardImage.setImageResource(R.drawable.card_image_placeholder); 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())); View.OnClickListener openImage = v -> cardView.getContext().startActivity(ViewMediaActivity.newSingleImageIntent(cardView.getContext(), card.getEmbed_url()));
cardInfo.setOnClickListener(visitLink); cardInfo.setOnClickListener(visitLink);

View file

@ -101,21 +101,22 @@ class StatusDetailedViewHolder extends StatusBaseViewHolder {
} }
@Override @Override
protected void setupWithStatus(final StatusViewData.Concrete status, public void setupWithStatus(final StatusViewData.Concrete status,
final StatusActionListener listener, final StatusActionListener listener,
StatusDisplayOptions statusDisplayOptions, StatusDisplayOptions statusDisplayOptions,
@Nullable Object payloads) { @Nullable Object payloads) {
super.setupWithStatus(status, listener, statusDisplayOptions, 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 (payloads == null) {
if (!statusDisplayOptions.hideStats()) { if (!statusDisplayOptions.hideStats()) {
setReblogAndFavCount(status.getReblogsCount(), status.getFavouritesCount(), listener); setReblogAndFavCount(status.getActionable().getReblogsCount(),
status.getActionable().getFavouritesCount(), listener);
} else { } else {
hideQuantitativeStats(); hideQuantitativeStats();
} }
setApplication(status.getApplication()); setApplication(status.getActionable().getApplication());
View.OnLongClickListener longClickListener = view -> { View.OnLongClickListener longClickListener = view -> {
TextView textView = (TextView) view; TextView textView = (TextView) view;
@ -130,7 +131,7 @@ class StatusDetailedViewHolder extends StatusBaseViewHolder {
content.setOnLongClickListener(longClickListener); content.setOnLongClickListener(longClickListener);
contentWarningDescription.setOnLongClickListener(longClickListener); contentWarningDescription.setOnLongClickListener(longClickListener);
setStatusVisibility(status.getVisibility()); setStatusVisibility(status.getActionable().getVisibility());
} }
} }

View file

@ -26,6 +26,8 @@ import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView;
import com.keylesspalace.tusky.R; 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.interfaces.StatusActionListener;
import com.keylesspalace.tusky.util.CustomEmojiHelper; import com.keylesspalace.tusky.util.CustomEmojiHelper;
import com.keylesspalace.tusky.util.SmartLengthInputFilter; 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.util.StringUtils;
import com.keylesspalace.tusky.viewdata.StatusViewData; import com.keylesspalace.tusky.viewdata.StatusViewData;
import java.util.List;
import at.connyduck.sparkbutton.helpers.Utils; import at.connyduck.sparkbutton.helpers.Utils;
public class StatusViewHolder extends StatusBaseViewHolder { public class StatusViewHolder extends StatusBaseViewHolder {
@ -54,7 +58,7 @@ public class StatusViewHolder extends StatusBaseViewHolder {
} }
@Override @Override
protected void setupWithStatus(StatusViewData.Concrete status, public void setupWithStatus(StatusViewData.Concrete status,
final StatusActionListener listener, final StatusActionListener listener,
StatusDisplayOptions statusDisplayOptions, StatusDisplayOptions statusDisplayOptions,
@Nullable Object payloads) { @Nullable Object payloads) {
@ -62,11 +66,13 @@ public class StatusViewHolder extends StatusBaseViewHolder {
setupCollapsedState(status, listener); setupCollapsedState(status, listener);
String rebloggedByDisplayName = status.getRebloggedByUsername(); Status reblogging = status.getRebloggingStatus();
if (rebloggedByDisplayName == null) { if (reblogging == null) {
hideStatusInfo(); hideStatusInfo();
} else { } else {
setRebloggedByDisplayName(rebloggedByDisplayName, status, statusDisplayOptions); String rebloggedByDisplayName = reblogging.getAccount().getDisplayName();
setRebloggedByDisplayName(rebloggedByDisplayName,
reblogging.getAccount().getEmojis(), statusDisplayOptions);
statusInfo.setOnClickListener(v -> listener.onOpenReblog(getBindingAdapterPosition())); statusInfo.setOnClickListener(v -> listener.onOpenReblog(getBindingAdapterPosition()));
} }
@ -76,13 +82,13 @@ public class StatusViewHolder extends StatusBaseViewHolder {
} }
private void setRebloggedByDisplayName(final CharSequence name, private void setRebloggedByDisplayName(final CharSequence name,
final StatusViewData.Concrete status, final List<Emoji> accountEmoji,
final StatusDisplayOptions statusDisplayOptions) { final StatusDisplayOptions statusDisplayOptions) {
Context context = statusInfo.getContext(); Context context = statusInfo.getContext();
CharSequence wrappedName = StringUtils.unicodeWrap(name); CharSequence wrappedName = StringUtils.unicodeWrap(name);
CharSequence boostedText = context.getString(R.string.status_boosted_format, wrappedName); CharSequence boostedText = context.getString(R.string.status_boosted_format, wrappedName);
CharSequence emojifiedText = CustomEmojiHelper.emojify( CharSequence emojifiedText = CustomEmojiHelper.emojify(
boostedText, status.getRebloggedByAccountEmojis(), statusInfo, statusDisplayOptions.animateEmojis() boostedText, accountEmoji, statusInfo, statusDisplayOptions.animateEmojis()
); );
statusInfo.setText(emojifiedText); statusInfo.setText(emojifiedText);
statusInfo.setVisibility(View.VISIBLE); statusInfo.setVisibility(View.VISIBLE);

View file

@ -43,7 +43,8 @@ interface ItemInteractionListener {
fun onChipClicked(tab: TabData, tabPosition: Int, chipPosition: Int) fun onChipClicked(tab: TabData, tabPosition: Int, chipPosition: Int)
} }
class TabAdapter(private var data: List<TabData>, class TabAdapter(
private var data: List<TabData>,
private val small: Boolean, private val small: Boolean,
private val listener: ItemInteractionListener, private val listener: ItemInteractionListener,
private var removeButtonEnabled: Boolean = false private var removeButtonEnabled: Boolean = false
@ -77,7 +78,6 @@ class TabAdapter(private var data: List<TabData>,
binding.textView.setOnClickListener { binding.textView.setOnClickListener {
listener.onTabAdded(tab) listener.onTabAdded(tab)
} }
} else { } else {
val binding = holder.binding as ItemTabPreferenceBinding val binding = holder.binding as ItemTabPreferenceBinding
@ -143,7 +143,6 @@ class TabAdapter(private var data: List<TabData>,
binding.actionChip.setOnClickListener { binding.actionChip.setOnClickListener {
listener.onActionChipClicked(tab, holder.bindingAdapterPosition) listener.onActionChipClicked(tab, holder.bindingAdapterPosition)
} }
} else { } else {
binding.chipGroup.hide() binding.chipGroup.hide()
} }

View file

@ -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 <http://www.gnu.org/licenses>. */
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<StatusViewData.Concrete> 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<StatusViewData.Concrete> 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<StatusViewData.Concrete> statuses) {
this.statuses.addAll(position, statuses);
notifyItemRangeInserted(position, statuses.size());
}
public void addAll(List<StatusViewData.Concrete> 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;
}
}

View file

@ -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 <http://www.gnu.org/licenses>. */
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<StatusBaseViewHolder>() {
private val statuses = mutableListOf<StatusViewData.Concrete>()
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<StatusViewData.Concrete>?) {
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<StatusViewData.Concrete>) {
this.statuses.addAll(position, statuses)
notifyItemRangeInserted(position, statuses.size)
}
fun addAll(statuses: List<StatusViewData.Concrete>) {
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
}
}

View file

@ -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 <http://www.gnu.org/licenses>. */
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<T> {
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<StatusViewData> dataSource;
private StatusDisplayOptions statusDisplayOptions;
private final StatusActionListener statusListener;
public TimelineAdapter(AdapterDataSource<StatusViewData> 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();
}
}

View file

@ -3,14 +3,14 @@ package com.keylesspalace.tusky.appstore
import com.google.gson.Gson import com.google.gson.Gson
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.AppDatabase
import io.reactivex.Single import io.reactivex.rxjava3.core.Single
import io.reactivex.disposables.Disposable import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.schedulers.Schedulers import io.reactivex.rxjava3.schedulers.Schedulers
import javax.inject.Inject import javax.inject.Inject
class CacheUpdater @Inject constructor( class CacheUpdater @Inject constructor(
eventHub: EventHub, eventHub: EventHub,
accountManager: AccountManager, private val accountManager: AccountManager,
private val appDatabase: AppDatabase, private val appDatabase: AppDatabase,
gson: Gson gson: Gson
) { ) {
@ -19,6 +19,7 @@ class CacheUpdater @Inject constructor(
init { init {
val timelineDao = appDatabase.timelineDao() val timelineDao = appDatabase.timelineDao()
disposable = eventHub.events.subscribe { event -> disposable = eventHub.events.subscribe { event ->
val accountId = accountManager.activeAccount?.id ?: return@subscribe val accountId = accountManager.activeAccount?.id ?: return@subscribe
when (event) { when (event) {
@ -36,6 +37,8 @@ class CacheUpdater @Inject constructor(
val pollString = gson.toJson(event.poll) val pollString = gson.toJson(event.poll)
timelineDao.setVoted(accountId, event.statusId, pollString) timelineDao.setVoted(accountId, event.statusId, pollString)
} }
is PinEvent ->
timelineDao.setPinned(accountId, event.statusId, event.pinned)
} }
} }
} }

View file

@ -21,3 +21,4 @@ data class MainTabsChangedEvent(val newTabs: List<TabData>) : Dispatchable
data class PollVoteEvent(val statusId: String, val poll: Poll) : Dispatchable data class PollVoteEvent(val statusId: String, val poll: Poll) : Dispatchable
data class DomainMuteEvent(val instance: String) : Dispatchable data class DomainMuteEvent(val instance: String) : Dispatchable
data class AnnouncementReadEvent(val announcementId: String) : Dispatchable data class AnnouncementReadEvent(val announcementId: String) : Dispatchable
data class PinEvent(val statusId: String, val pinned: Boolean) : Dispatchable

View file

@ -1,22 +1,20 @@
package com.keylesspalace.tusky.appstore package com.keylesspalace.tusky.appstore
import io.reactivex.Observable import io.reactivex.rxjava3.core.Observable
import io.reactivex.subjects.PublishSubject import io.reactivex.rxjava3.subjects.PublishSubject
import javax.inject.Inject
import javax.inject.Singleton
interface Event interface Event
interface Dispatchable : Event interface Dispatchable : Event
interface EventHub { @Singleton
val events: Observable<Event> class EventHub @Inject constructor() {
fun dispatch(event: Dispatchable)
}
object EventHubImpl : EventHub {
private val eventsSubject = PublishSubject.create<Event>() private val eventsSubject = PublishSubject.create<Event>()
override val events: Observable<Event> = eventsSubject val events: Observable<Event> = eventsSubject
override fun dispatch(event: Dispatchable) { fun dispatch(event: Dispatchable) {
eventsSubject.onNext(event) eventsSubject.onNext(event)
} }
} }

View file

@ -13,7 +13,7 @@
* You should have received a copy of the GNU General Public License along with Tusky; if not, * You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */ * see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky package com.keylesspalace.tusky.components.account
import android.animation.ArgbEvaluator import android.animation.ArgbEvaluator
import android.content.Context import android.content.Context
@ -34,20 +34,29 @@ import androidx.annotation.Px
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.app.ActivityOptionsCompat import androidx.core.app.ActivityOptionsCompat
import androidx.core.content.ContextCompat 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.emoji.text.EmojiCompat
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.viewpager2.widget.MarginPageTransformer import androidx.viewpager2.widget.MarginPageTransformer
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.google.android.material.appbar.AppBarLayout 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.floatingactionbutton.FloatingActionButton
import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.shape.MaterialShapeDrawable
import com.google.android.material.shape.ShapeAppearanceModel import com.google.android.material.shape.ShapeAppearanceModel
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator 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.compose.ComposeActivity
import com.keylesspalace.tusky.components.report.ReportActivity import com.keylesspalace.tusky.components.report.ReportActivity
import com.keylesspalace.tusky.databinding.ActivityAccountBinding 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.ActionButtonActivity
import com.keylesspalace.tusky.interfaces.LinkListener import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.interfaces.ReselectableFragment import com.keylesspalace.tusky.interfaces.ReselectableFragment
import com.keylesspalace.tusky.pager.AccountPagerAdapter
import com.keylesspalace.tusky.settings.PrefKeys 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.view.showMuteAccountDialog
import com.keylesspalace.tusky.viewmodel.AccountViewModel
import dagger.android.DispatchingAndroidInjector import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector import dagger.android.HasAndroidInjector
import java.text.NumberFormat import java.text.NumberFormat
@ -129,6 +147,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
animateEmojis = sharedPrefs.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) animateEmojis = sharedPrefs.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
hideFab = sharedPrefs.getBoolean("fabHide", false) hideFab = sharedPrefs.getBoolean("fabHide", false)
handleWindowInsets()
setupToolbar() setupToolbar()
setupTabs() setupTabs()
setupAccountViews() setupAccountViews()
@ -170,7 +189,6 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
binding.accountFieldList.layoutManager = LinearLayoutManager(this) binding.accountFieldList.layoutManager = LinearLayoutManager(this)
binding.accountFieldList.adapter = accountFieldAdapter binding.accountFieldList.adapter = accountFieldAdapter
val accountListClickListener = { v: View -> val accountListClickListener = { v: View ->
val type = when (v.id) { val type = when (v.id) {
R.id.accountFollowers -> AccountListActivity.Type.FOLLOWERS R.id.accountFollowers -> AccountListActivity.Type.FOLLOWERS
@ -231,21 +249,25 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
override fun onTabUnselected(tab: TabLayout.Tab?) {} override fun onTabUnselected(tab: TabLayout.Tab?) {}
override fun onTabSelected(tab: TabLayout.Tab?) {} override fun onTabSelected(tab: TabLayout.Tab?) {}
}) })
} }
private fun setupToolbar() { private fun handleWindowInsets() {
// set toolbar top margin according to system window insets ViewCompat.setOnApplyWindowInsetsListener(binding.accountCoordinatorLayout) { _, insets ->
binding.accountCoordinatorLayout.setOnApplyWindowInsetsListener { _, insets -> val top = insets.getInsets(systemBars()).top
val top = insets.systemWindowInsetTop val toolbarParams = binding.accountToolbar.layoutParams as ViewGroup.MarginLayoutParams
val toolbarParams = binding.accountToolbar.layoutParams as CollapsingToolbarLayout.LayoutParams
toolbarParams.topMargin = top 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. // Setup the toolbar.
setSupportActionBar(binding.accountToolbar) setSupportActionBar(binding.accountToolbar)
supportActionBar?.run { supportActionBar?.run {
@ -314,12 +336,10 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
binding.swipeToRefreshLayout.isEnabled = verticalOffset == 0 binding.swipeToRefreshLayout.isEnabled = verticalOffset == 0
} }
}) })
} }
private fun makeNotificationBarTransparent() { private fun makeNotificationBarTransparent() {
val decorView = window.decorView WindowCompat.setDecorFitsSystemWindows(window, false)
decorView.systemUiVisibility = decorView.systemUiVisibility or View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
window.statusBarColor = statusBarColorTransparent window.statusBarColor = statusBarColorTransparent
} }
@ -335,6 +355,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
.setAction(R.string.action_retry) { viewModel.refresh() } .setAction(R.string.action_retry) { viewModel.refresh() }
.show() .show()
} }
is Loading -> { }
} }
} }
viewModel.relationshipData.observe(this) { viewModel.relationshipData.observe(this) {
@ -348,12 +369,14 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
.setAction(R.string.action_retry) { viewModel.refresh() } .setAction(R.string.action_retry) { viewModel.refresh() }
.show() .show()
} }
} }
viewModel.accountFieldData.observe(this, { viewModel.accountFieldData.observe(
this,
{
accountFieldAdapter.fields = it accountFieldAdapter.fields = it
accountFieldAdapter.notifyDataSetChanged() accountFieldAdapter.notifyDataSetChanged()
}) }
)
viewModel.noteSaved.observe(this) { viewModel.noteSaved.observe(this) {
binding.saveNoteInfo.visible(it, View.INVISIBLE) binding.saveNoteInfo.visible(it, View.INVISIBLE)
} }
@ -367,9 +390,12 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
viewModel.refresh() viewModel.refresh()
adapter.refreshContent() adapter.refreshContent()
} }
viewModel.isRefreshing.observe(this, { isRefreshing -> viewModel.isRefreshing.observe(
this,
{ isRefreshing ->
binding.swipeToRefreshLayout.isRefreshing = isRefreshing == true binding.swipeToRefreshLayout.isRefreshing = isRefreshing == true
}) }
)
binding.swipeToRefreshLayout.setColorSchemeResources(R.color.chinwag_green) binding.swipeToRefreshLayout.setColorSchemeResources(R.color.chinwag_green)
} }
@ -422,9 +448,9 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
.centerCrop() .centerCrop()
.into(binding.accountHeaderImageView) .into(binding.accountHeaderImageView)
binding.accountAvatarImageView.setOnClickListener { avatarView -> binding.accountAvatarImageView.setOnClickListener { avatarView ->
val intent = ViewMediaActivity.newSingleImageIntent(avatarView.context, account.avatar) val intent =
ViewMediaActivity.newSingleImageIntent(avatarView.context, account.avatar)
avatarView.transitionName = account.avatar avatarView.transitionName = account.avatar
val options = ActivityOptionsCompat.makeSceneTransitionAnimation(this, avatarView, 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) binding.accountMovedText.setCompoundDrawablesRelativeWithIntrinsicBounds(movedIcon, null, null, null)
} }
} }
/** /**
@ -555,8 +580,9 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
// because subscribing is Pleroma extension, enable it __only__ when we have non-null subscribing field // 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 // it's also now supported in Mastodon 3.3.0rc but called notifying and use different API call
if(!viewModel.isSelf && followState == FollowState.FOLLOWING if (!viewModel.isSelf && followState == FollowState.FOLLOWING &&
&& (relation.subscribing != null || relation.notifying != null)) { (relation.subscribing != null || relation.notifying != null)
) {
binding.accountSubscribeButton.show() binding.accountSubscribeButton.show()
binding.accountSubscribeButton.setOnClickListener { binding.accountSubscribeButton.setOnClickListener {
viewModel.changeSubscribingState() viewModel.changeSubscribingState()
@ -649,7 +675,6 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
binding.accountMuteButton.hide() binding.accountMuteButton.hide()
updateMuteButton() updateMuteButton()
} }
} else { } else {
binding.accountFloatingActionButton.hide() binding.accountFloatingActionButton.hide()
binding.accountFollowButton.hide() binding.accountFollowButton.hide()
@ -699,11 +724,9 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
} else { } else {
getString(R.string.action_show_reblogs) getString(R.string.action_show_reblogs)
} }
} else { } else {
menu.removeItem(R.id.action_show_reblogs) menu.removeItem(R.id.action_show_reblogs)
} }
} else { } else {
// It shouldn't be possible to block, mute or report yourself. // It shouldn't be possible to block, mute or report yourself.
menu.removeItem(R.id.action_block) menu.removeItem(R.id.action_block)
@ -773,8 +796,10 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
private fun mention() { private fun mention() {
loadedAccount?.let { loadedAccount?.let {
val intent = ComposeActivity.startIntent(this, val intent = ComposeActivity.startIntent(
ComposeActivity.ComposeOptions(mentionedUsernames = setOf(it.username))) this,
ComposeActivity.ComposeOptions(mentionedUsernames = setOf(it.username))
)
startActivity(intent) startActivity(intent)
} }
} }
@ -850,5 +875,4 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
return intent return intent
} }
} }
} }

View file

@ -13,19 +13,22 @@
* You should have received a copy of the GNU General Public License along with Tusky; if not, * You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */ * see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.adapter package com.keylesspalace.tusky.components.account
import android.text.method.LinkMovementMethod import android.text.method.LinkMovementMethod
import androidx.recyclerview.widget.RecyclerView
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemAccountFieldBinding import com.keylesspalace.tusky.databinding.ItemAccountFieldBinding
import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.Field import com.keylesspalace.tusky.entity.Field
import com.keylesspalace.tusky.entity.IdentityProof import com.keylesspalace.tusky.entity.IdentityProof
import com.keylesspalace.tusky.interfaces.LinkListener 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( class AccountFieldAdapter(
private val linkListener: LinkListener, private val linkListener: LinkListener,
@ -70,6 +73,5 @@ class AccountFieldAdapter(
valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0) valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0)
} }
} }
} }
} }

View file

@ -13,14 +13,14 @@
* You should have received a copy of the GNU General Public License along with Tusky; if not, * You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */ * see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.pager package com.keylesspalace.tusky.components.account
import androidx.fragment.app.* import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import com.keylesspalace.tusky.fragment.AccountMediaFragment import com.keylesspalace.tusky.components.account.media.AccountMediaFragment
import com.keylesspalace.tusky.fragment.TimelineFragment 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.interfaces.RefreshableFragment
import com.keylesspalace.tusky.util.CustomFragmentStateAdapter import com.keylesspalace.tusky.util.CustomFragmentStateAdapter
class AccountPagerAdapter( class AccountPagerAdapter(
@ -32,9 +32,9 @@ class AccountPagerAdapter(
override fun createFragment(position: Int): Fragment { override fun createFragment(position: Int): Fragment {
return when (position) { return when (position) {
0 -> TimelineFragment.newInstance(TimelineFragment.Kind.USER, accountId, false) 0 -> TimelineFragment.newInstance(TimelineViewModel.Kind.USER, accountId, false)
1 -> TimelineFragment.newInstance(TimelineFragment.Kind.USER_WITH_REPLIES, accountId, false) 1 -> TimelineFragment.newInstance(TimelineViewModel.Kind.USER_WITH_REPLIES, accountId, false)
2 -> TimelineFragment.newInstance(TimelineFragment.Kind.USER_PINNED, accountId, false) 2 -> TimelineFragment.newInstance(TimelineViewModel.Kind.USER_PINNED, accountId, false)
3 -> AccountMediaFragment.newInstance(accountId, false) 3 -> AccountMediaFragment.newInstance(accountId, false)
else -> throw AssertionError("Page $position is out of AccountPagerAdapter bounds") else -> throw AssertionError("Page $position is out of AccountPagerAdapter bounds")
} }

View file

@ -1,17 +1,28 @@
package com.keylesspalace.tusky.viewmodel package com.keylesspalace.tusky.components.account
import android.util.Log import android.util.Log
import androidx.lifecycle.MutableLiveData 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.db.AccountManager
import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Field import com.keylesspalace.tusky.entity.Field
import com.keylesspalace.tusky.entity.IdentityProof import com.keylesspalace.tusky.entity.IdentityProof
import com.keylesspalace.tusky.entity.Relationship import com.keylesspalace.tusky.entity.Relationship
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.* import com.keylesspalace.tusky.util.Either
import io.reactivex.Single import com.keylesspalace.tusky.util.Error
import io.reactivex.disposables.Disposable 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.Call
import retrofit2.Callback import retrofit2.Callback
import retrofit2.Response import retrofit2.Response
@ -59,16 +70,19 @@ class AccountViewModel @Inject constructor(
accountData.postValue(Loading()) accountData.postValue(Loading())
mastodonApi.account(accountId) mastodonApi.account(accountId)
.subscribe({ account -> .subscribe(
{ account ->
accountData.postValue(Success(account)) accountData.postValue(Success(account))
isDataLoading = false isDataLoading = false
isRefreshing.postValue(false) isRefreshing.postValue(false)
}, {t -> },
{ t ->
Log.w(TAG, "failed obtaining account", t) Log.w(TAG, "failed obtaining account", t)
accountData.postValue(Error()) accountData.postValue(Error())
isDataLoading = false isDataLoading = false
isRefreshing.postValue(false) isRefreshing.postValue(false)
}) }
)
.autoDispose() .autoDispose()
} }
} }
@ -79,12 +93,15 @@ class AccountViewModel @Inject constructor(
relationshipData.postValue(Loading()) relationshipData.postValue(Loading())
mastodonApi.relationships(listOf(accountId)) mastodonApi.relationships(listOf(accountId))
.subscribe({ relationships -> .subscribe(
{ relationships ->
relationshipData.postValue(Success(relationships[0])) relationshipData.postValue(Success(relationships[0]))
}, { t -> },
{ t ->
Log.w(TAG, "failed obtaining relationships", t) Log.w(TAG, "failed obtaining relationships", t)
relationshipData.postValue(Error()) relationshipData.postValue(Error())
}) }
)
.autoDispose() .autoDispose()
} }
} }
@ -93,11 +110,14 @@ class AccountViewModel @Inject constructor(
if (identityProofData.value == null || reload) { if (identityProofData.value == null || reload) {
mastodonApi.identityProofs(accountId) mastodonApi.identityProofs(accountId)
.subscribe({ proofs -> .subscribe(
{ proofs ->
identityProofData.postValue(proofs) identityProofData.postValue(proofs)
}, { t -> },
{ t ->
Log.w(TAG, "failed obtaining identity proofs", t) Log.w(TAG, "failed obtaining identity proofs", t)
}) }
)
.autoDispose() .autoDispose()
} }
} }
@ -129,8 +149,9 @@ class AccountViewModel @Inject constructor(
fun changeSubscribingState() { fun changeSubscribingState() {
val relationship = relationshipData.value?.data val relationship = relationshipData.value?.data
if(relationship?.notifying == true /* Mastodon 3.3.0rc1 */ if (relationship?.notifying == true || /* Mastodon 3.3.0rc1 */
|| relationship?.subscribing == true /* Pleroma */ ) { relationship?.subscribing == true /* Pleroma */
) {
changeRelationship(RelationShipAction.UNSUBSCRIBE) changeRelationship(RelationShipAction.UNSUBSCRIBE)
} else { } else {
changeRelationship(RelationShipAction.SUBSCRIBE) changeRelationship(RelationShipAction.SUBSCRIBE)
@ -269,11 +290,14 @@ class AccountViewModel @Inject constructor(
noteSaved.postValue(true) noteSaved.postValue(true)
} }
.delay(4, TimeUnit.SECONDS) .delay(4, TimeUnit.SECONDS)
.subscribe({ .subscribe(
{
noteSaved.postValue(false) noteSaved.postValue(false)
}, { },
{
Log.e(TAG, "Error updating note", it) Log.e(TAG, "Error updating note", it)
}) }
)
} }
override fun onCleared() { override fun onCleared() {

View file

@ -13,7 +13,7 @@
* You should have received a copy of the GNU General Public License along with Tusky; if not, * You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */ * see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.fragment package com.keylesspalace.tusky.components.account.media
import android.graphics.Color import android.graphics.Color
import android.os.Bundle import android.os.Bundle
@ -27,6 +27,7 @@ import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import autodispose2.androidx.lifecycle.autoDispose
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.ViewMediaActivity 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.util.viewBinding
import com.keylesspalace.tusky.view.SquareImageView import com.keylesspalace.tusky.view.SquareImageView
import com.keylesspalace.tusky.viewdata.AttachmentViewData import com.keylesspalace.tusky.viewdata.AttachmentViewData
import com.uber.autodispose.android.lifecycle.autoDispose import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.SingleObserver import io.reactivex.rxjava3.core.SingleObserver
import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.disposables.Disposable
import retrofit2.Response import retrofit2.Response
import java.io.IOException import java.io.IOException
import java.util.* import java.util.Random
import javax.inject.Inject import javax.inject.Inject
/** /**
@ -163,7 +163,6 @@ class AccountMediaFragment : Fragment(R.layout.fragment_timeline), RefreshableFr
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
val columnCount = view.context.resources.getInteger(R.integer.profile_media_column_count) val columnCount = view.context.resources.getInteger(R.integer.profile_media_column_count)
val layoutManager = GridLayoutManager(view.context, columnCount) val layoutManager = GridLayoutManager(view.context, columnCount)
@ -188,7 +187,7 @@ class AccountMediaFragment : Fragment(R.layout.fragment_timeline), RefreshableFr
val lastItem = layoutManager.findLastCompletelyVisibleItemPosition() val lastItem = layoutManager.findLastCompletelyVisibleItemPosition()
if (itemCount <= lastItem + 3 && fetchingStatus == FetchingStatus.NOT_FETCHING) { if (itemCount <= lastItem + 3 && fetchingStatus == FetchingStatus.NOT_FETCHING) {
statuses.lastOrNull()?.let { (id) -> 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 fetchingStatus = FetchingStatus.FETCHING_BOTTOM
api.accountStatuses(accountId, id, null, null, null, true, null) api.accountStatuses(accountId, id, null, null, null, true, null)
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
@ -230,8 +229,7 @@ class AccountMediaFragment : Fragment(R.layout.fragment_timeline), RefreshableFr
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.autoDispose(this@AccountMediaFragment, Lifecycle.Event.ON_DESTROY) .autoDispose(this@AccountMediaFragment, Lifecycle.Event.ON_DESTROY)
.subscribe(callback) .subscribe(callback)
} } else if (needToRefresh)
else if (needToRefresh)
refresh() refresh()
needToRefresh = false needToRefresh = false
} }
@ -310,9 +308,8 @@ class AccountMediaFragment : Fragment(R.layout.fragment_timeline), RefreshableFr
.into(holder.imageView) .into(holder.imageView)
} }
inner class MediaViewHolder(val imageView: ImageView) :
inner class MediaViewHolder(val imageView: ImageView) RecyclerView.ViewHolder(imageView),
: RecyclerView.ViewHolder(imageView),
View.OnClickListener { View.OnClickListener {
init { init {
itemView.setOnClickListener(this) itemView.setOnClickListener(this)

View file

@ -15,21 +15,25 @@
package com.keylesspalace.tusky.components.announcements package com.keylesspalace.tusky.components.announcements
import android.os.Build
import android.text.SpannableStringBuilder
import android.view.ContextThemeWrapper import android.view.ContextThemeWrapper
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.size import androidx.core.view.size
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.google.android.material.chip.Chip import com.google.android.material.chip.Chip
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemAnnouncementBinding import com.keylesspalace.tusky.databinding.ItemAnnouncementBinding
import com.keylesspalace.tusky.entity.Announcement import com.keylesspalace.tusky.entity.Announcement
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.interfaces.LinkListener import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.util.BindingHolder import com.keylesspalace.tusky.util.BindingHolder
import com.keylesspalace.tusky.util.EmojiSpan
import com.keylesspalace.tusky.util.LinkHelper import com.keylesspalace.tusky.util.LinkHelper
import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.emojify
import java.lang.ref.WeakReference
interface AnnouncementActionListener : LinkListener { interface AnnouncementActionListener : LinkListener {
fun openReactionPicker(announcementId: String, target: View) fun openReactionPicker(announcementId: String, target: View)
@ -56,7 +60,9 @@ class AnnouncementAdapter(
val chips = holder.binding.chipGroup val chips = holder.binding.chipGroup
val addReactionChip = holder.binding.addReactionChip 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 wellbeing mode is enabled, announcement badge counts should not be shown.
if (wellbeingEnabled) { if (wellbeingEnabled) {
@ -67,29 +73,32 @@ class AnnouncementAdapter(
} }
item.reactions.forEachIndexed { i, reaction -> 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 { ?: Chip(ContextThemeWrapper(chips.context, R.style.Widget_MaterialComponents_Chip_Choice)).apply {
isCheckable = true isCheckable = true
checkedIcon = null checkedIcon = null
chips.addView(this, i) chips.addView(this, i)
})
.apply {
val emojiText = if (reaction.url == null) {
reaction.name
} else {
context.getString(R.string.emoji_shortcode_format, reaction.name)
} }
this.text = ("$emojiText ${reaction.count}")
.emojify(
listOf(Emoji(
reaction.name,
reaction.url ?: "",
reaction.staticUrl ?: "",
null
)),
this,
animateEmojis
) )
.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
}
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

View file

@ -34,7 +34,12 @@ import com.keylesspalace.tusky.databinding.ActivityAnnouncementsBinding
import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.settings.PrefKeys 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 com.keylesspalace.tusky.view.EmojiPicker
import javax.inject.Inject import javax.inject.Inject

View file

@ -27,8 +27,13 @@ import com.keylesspalace.tusky.entity.Announcement
import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.Instance import com.keylesspalace.tusky.entity.Instance
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.* import com.keylesspalace.tusky.util.Either
import io.reactivex.rxkotlin.Singles 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 import javax.inject.Inject
class AnnouncementsViewModel @Inject constructor( class AnnouncementsViewModel @Inject constructor(
@ -45,15 +50,15 @@ class AnnouncementsViewModel @Inject constructor(
val emojis: LiveData<List<Emoji>> = emojisMutable val emojis: LiveData<List<Emoji>> = emojisMutable
init { init {
Singles.zip( Single.zip(
mastodonApi.getCustomEmojis(), mastodonApi.getCustomEmojis(),
appDatabase.instanceDao().loadMetadataForInstance(accountManager.activeAccount?.domain!!) appDatabase.instanceDao().loadMetadataForInstance(accountManager.activeAccount?.domain!!)
.map<Either<InstanceEntity, Instance>> { Either.Left(it) } .map<Either<InstanceEntity, Instance>> { Either.Left(it) }
.onErrorResumeNext( .onErrorResumeNext {
mastodonApi.getInstance() mastodonApi.getInstance()
.map { Either.Right(it) } .map { Either.Right(it) }
) },
) { emojis, either -> { emojis, either ->
either.asLeftOrNull()?.copy(emojiList = emojis) either.asLeftOrNull()?.copy(emojiList = emojis)
?: InstanceEntity( ?: InstanceEntity(
accountManager.activeAccount?.domain!!, accountManager.activeAccount?.domain!!,
@ -64,21 +69,26 @@ class AnnouncementsViewModel @Inject constructor(
either.asRight().version either.asRight().version
) )
} }
)
.doOnSuccess { .doOnSuccess {
appDatabase.instanceDao().insertOrReplace(it) appDatabase.instanceDao().insertOrReplace(it)
} }
.subscribe({ .subscribe(
emojisMutable.postValue(it.emojiList) {
}, { emojisMutable.postValue(it.emojiList.orEmpty())
},
{
Log.w(TAG, "Failed to get custom emojis.", it) Log.w(TAG, "Failed to get custom emojis.", it)
}) }
)
.autoDispose() .autoDispose()
} }
fun load() { fun load() {
announcementsMutable.postValue(Loading()) announcementsMutable.postValue(Loading())
mastodonApi.listAnnouncements() mastodonApi.listAnnouncements()
.subscribe({ .subscribe(
{
announcementsMutable.postValue(Success(it)) announcementsMutable.postValue(Success(it))
it.filter { announcement -> !announcement.read } it.filter { announcement -> !announcement.read }
.forEach { announcement -> .forEach { announcement ->
@ -93,15 +103,18 @@ class AnnouncementsViewModel @Inject constructor(
) )
.autoDispose() .autoDispose()
} }
}, { },
{
announcementsMutable.postValue(Error(cause = it)) announcementsMutable.postValue(Error(cause = it))
}) }
)
.autoDispose() .autoDispose()
} }
fun addReaction(announcementId: String, name: String) { fun addReaction(announcementId: String, name: String) {
mastodonApi.addAnnouncementReaction(announcementId, name) mastodonApi.addAnnouncementReaction(announcementId, name)
.subscribe({ .subscribe(
{
announcementsMutable.postValue( announcementsMutable.postValue(
Success( Success(
announcements.value!!.data!!.map { announcement -> announcements.value!!.data!!.map { announcement ->
@ -140,15 +153,18 @@ class AnnouncementsViewModel @Inject constructor(
} }
) )
) )
}, { },
{
Log.w(TAG, "Failed to add reaction to the announcement.", it) Log.w(TAG, "Failed to add reaction to the announcement.", it)
}) }
)
.autoDispose() .autoDispose()
} }
fun removeReaction(announcementId: String, name: String) { fun removeReaction(announcementId: String, name: String) {
mastodonApi.removeAnnouncementReaction(announcementId, name) mastodonApi.removeAnnouncementReaction(announcementId, name)
.subscribe({ .subscribe(
{
announcementsMutable.postValue( announcementsMutable.postValue(
Success( Success(
announcements.value!!.data!!.map { announcement -> announcements.value!!.data!!.map { announcement ->
@ -175,9 +191,11 @@ class AnnouncementsViewModel @Inject constructor(
} }
) )
) )
}, { },
{
Log.w(TAG, "Failed to remove reaction from the announcement.", it) Log.w(TAG, "Failed to remove reaction from the announcement.", it)
}) }
)
.autoDispose() .autoDispose()
} }

View file

@ -16,7 +16,6 @@
package com.keylesspalace.tusky.components.compose package com.keylesspalace.tusky.components.compose
import android.Manifest import android.Manifest
import android.app.Activity
import android.app.ProgressDialog import android.app.ProgressDialog
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
@ -28,13 +27,16 @@ import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Parcelable import android.os.Parcelable
import android.provider.MediaStore
import android.util.Log import android.util.Log
import android.view.KeyEvent import android.view.KeyEvent
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup 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.activity.viewModels
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import androidx.annotation.StringRes 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.NewPoll
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.settings.PrefKeys 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.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.colorInt
@ -84,7 +99,8 @@ import javax.inject.Inject
import kotlin.math.max import kotlin.math.max
import kotlin.math.min import kotlin.math.min
class ComposeActivity : BaseActivity(), class ComposeActivity :
BaseActivity(),
ComposeOptionsListener, ComposeOptionsListener,
ComposeAutoCompleteAdapter.AutocompletionProvider, ComposeAutoCompleteAdapter.AutocompletionProvider,
OnEmojiSelectedListener, OnEmojiSelectedListener,
@ -114,6 +130,21 @@ class ComposeActivity : BaseActivity(),
private val maxUploadMediaNumber = 4 private val maxUploadMediaNumber = 4
private var mediaCount = 0 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?) { public override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -274,8 +305,9 @@ class ComposeActivity : BaseActivity(),
} }
// work around Android platform bug -> https://issuetracker.google.com/issues/67102093 // work around Android platform bug -> https://issuetracker.google.com/issues/67102093
if (Build.VERSION.SDK_INT == Build.VERSION_CODES.O if (Build.VERSION.SDK_INT == Build.VERSION_CODES.O ||
|| Build.VERSION.SDK_INT == Build.VERSION_CODES.O_MR1) { Build.VERSION.SDK_INT == Build.VERSION_CODES.O_MR1
) {
binding.composeEditField.setLayerType(View.LAYER_TYPE_SOFTWARE, null) binding.composeEditField.setLayerType(View.LAYER_TYPE_SOFTWARE, null)
} }
} }
@ -316,9 +348,9 @@ class ComposeActivity : BaseActivity(),
updateScheduleButton() updateScheduleButton()
} }
combineOptionalLiveData(viewModel.media, viewModel.poll) { media, poll -> combineOptionalLiveData(viewModel.media, viewModel.poll) { media, poll ->
val active = poll == null val active = poll == null &&
&& media!!.size != 4 media!!.size != 4 &&
&& (media.isEmpty() || media.first().type == QueuedMedia.Type.IMAGE) (media.isEmpty() || media.first().type == QueuedMedia.Type.IMAGE)
enableButton(binding.composeAddMediaButton, active, active) enableButton(binding.composeAddMediaButton, active, active)
enablePollButton(media.isNullOrEmpty()) enablePollButton(media.isNullOrEmpty())
}.subscribe() }.subscribe()
@ -379,7 +411,6 @@ class ComposeActivity : BaseActivity(),
setDisplayShowHomeEnabled(true) setDisplayShowHomeEnabled(true)
setHomeAsUpIndicator(R.drawable.ic_close_24dp) setHomeAsUpIndicator(R.drawable.ic_close_24dp)
} }
} }
private fun setupAvatar(preferences: SharedPreferences, activeAccount: AccountEntity) { private fun setupAvatar(preferences: SharedPreferences, activeAccount: AccountEntity) {
@ -395,8 +426,10 @@ class ComposeActivity : BaseActivity(),
avatarSize / 8, avatarSize / 8,
animateAvatars animateAvatars
) )
binding.composeAvatar.contentDescription = getString(R.string.compose_active_account_description, binding.composeAvatar.contentDescription = getString(
activeAccount.fullName) R.string.compose_active_account_description,
activeAccount.fullName
)
} }
private fun replaceTextAtCaret(text: CharSequence) { private fun replaceTextAtCaret(text: CharSequence) {
@ -454,7 +487,6 @@ class ComposeActivity : BaseActivity(),
} }
} }
private fun atButtonClicked() { private fun atButtonClicked() {
prependSelectedWordsWith("@") prependSelectedWordsWith("@")
} }
@ -488,7 +520,6 @@ class ComposeActivity : BaseActivity(),
binding.composeHideMediaButton.setImageResource(R.drawable.ic_hide_media_24dp) binding.composeHideMediaButton.setImageResource(R.drawable.ic_hide_media_24dp)
binding.composeHideMediaButton.isClickable = false binding.composeHideMediaButton.isClickable = false
ContextCompat.getColor(this, R.color.transparent_chinwag_green) ContextCompat.getColor(this, R.color.transparent_chinwag_green)
} else { } else {
binding.composeHideMediaButton.isClickable = true binding.composeHideMediaButton.isClickable = true
if (markMediaSensitive) { if (markMediaSensitive) {
@ -601,11 +632,13 @@ class ComposeActivity : BaseActivity(),
if (newState == BottomSheetBehavior.STATE_COLLAPSED) { if (newState == BottomSheetBehavior.STATE_COLLAPSED) {
addMediaBehavior.removeBottomSheetCallback(this) addMediaBehavior.removeBottomSheetCallback(this)
if (ContextCompat.checkSelfPermission(this@ComposeActivity, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { if (ContextCompat.checkSelfPermission(this@ComposeActivity, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this@ComposeActivity, ActivityCompat.requestPermissions(
this@ComposeActivity,
arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE),
PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE) PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE
)
} else { } else {
initiateMediaPicking() pickMediaFile.launch(true)
} }
} }
} }
@ -619,8 +652,10 @@ class ComposeActivity : BaseActivity(),
private fun openPollDialog() { private fun openPollDialog() {
addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
val instanceParams = viewModel.instanceParams.value!! val instanceParams = viewModel.instanceParams.value!!
showAddPollDialog(this, viewModel.poll.value, instanceParams.pollMaxOptions, showAddPollDialog(
instanceParams.pollMaxLength, viewModel::updatePoll) this, viewModel.poll.value, instanceParams.pollMaxOptions,
instanceParams.pollMaxLength, viewModel::updatePoll
)
} }
private fun setupPollView() { private fun setupPollView() {
@ -741,34 +776,39 @@ class ComposeActivity : BaseActivity(),
if (viewModel.media.value!!.isNotEmpty()) { if (viewModel.media.value!!.isNotEmpty()) {
finishingUploadDialog = ProgressDialog.show( finishingUploadDialog = ProgressDialog.show(
this, getString(R.string.dialog_title_finishing_media_upload), this, getString(R.string.dialog_title_finishing_media_upload),
getString(R.string.dialog_message_uploading_media), true, true) getString(R.string.dialog_message_uploading_media), true, true
)
} }
viewModel.sendStatus(contentText, spoilerText).observe(this, { viewModel.sendStatus(contentText, spoilerText).observe(
this,
{
finishingUploadDialog?.dismiss() finishingUploadDialog?.dismiss()
deleteDraftAndFinish() deleteDraftAndFinish()
}) }
)
} else { } else {
binding.composeEditField.error = getString(R.string.error_compose_character_limit) binding.composeEditField.error = getString(R.string.error_compose_character_limit)
enableButtons(true) enableButtons(true)
} }
} }
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray) {
grantResults: IntArray) { super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE) { if (requestCode == PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE) {
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
initiateMediaPicking() pickMediaFile.launch(true)
} else { } else {
val bar = Snackbar.make(binding.activityCompose, R.string.error_media_upload_permission, Snackbar.make(
Snackbar.LENGTH_SHORT).apply { binding.activityCompose, R.string.error_media_upload_permission,
Snackbar.LENGTH_SHORT
} ).apply {
bar.setAction(R.string.action_retry) { onMediaPick() } setAction(R.string.action_retry) { onMediaPick() }
// 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) view.elevation = resources.getDimension(R.dimen.compose_activity_snackbar_elevation)
bar.show() show()
}
} }
} }
} }
@ -776,11 +816,6 @@ class ComposeActivity : BaseActivity(),
private fun initiateCameraApp() { private fun initiateCameraApp() {
addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED 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 { val photoFile: File = try {
createNewImageFile(this) createNewImageFile(this)
} catch (ex: IOException) { } catch (ex: IOException) {
@ -789,37 +824,30 @@ class ComposeActivity : BaseActivity(),
} }
// Continue only if the File was successfully created // Continue only if the File was successfully created
photoUploadUri = FileProvider.getUriForFile(this, photoUploadUri = FileProvider.getUriForFile(
this,
BuildConfig.APPLICATION_ID + ".fileprovider", BuildConfig.APPLICATION_ID + ".fileprovider",
photoFile) photoFile
intent.putExtra(MediaStore.EXTRA_OUTPUT, photoUploadUri) )
startActivityForResult(intent, MEDIA_TAKE_PHOTO_RESULT) takePicture.launch(photoUploadUri)
}
}
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)
} }
private fun enableButton(button: ImageButton, clickable: Boolean, colorActive: Boolean) { private fun enableButton(button: ImageButton, clickable: Boolean, colorActive: Boolean) {
button.isEnabled = clickable button.isEnabled = clickable
ThemeUtils.setDrawableTint(this, button.drawable, ThemeUtils.setDrawableTint(
this, button.drawable,
if (colorActive) android.R.attr.textColorTertiary if (colorActive) android.R.attr.textColorTertiary
else R.attr.textColorDisabled) else R.attr.textColorDisabled
)
} }
private fun enablePollButton(enable: Boolean) { private fun enablePollButton(enable: Boolean) {
binding.addPollTextActionTextView.isEnabled = enable binding.addPollTextActionTextView.isEnabled = enable
val textColor = ThemeUtils.getColor(this, val textColor = ThemeUtils.getColor(
this,
if (enable) android.R.attr.textColorTertiary if (enable) android.R.attr.textColorTertiary
else R.attr.textColorDisabled) else R.attr.textColorDisabled
)
binding.addPollTextActionTextView.setTextColor(textColor) binding.addPollTextActionTextView.setTextColor(textColor)
binding.addPollTextActionTextView.compoundDrawablesRelative[0].colorFilter = PorterDuffColorFilter(textColor, PorterDuff.Mode.SRC_IN) binding.addPollTextActionTextView.compoundDrawablesRelative[0].colorFilter = PorterDuffColorFilter(textColor, PorterDuff.Mode.SRC_IN)
} }
@ -828,31 +856,6 @@ class ComposeActivity : BaseActivity(),
viewModel.removeMediaFromQueue(item) 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) { private fun pickMedia(uri: Uri, contentInfoCompat: InputContentInfoCompat? = null) {
withLifecycleContext { withLifecycleContext {
viewModel.pickMedia(uri).observe { exceptionOrItem -> viewModel.pickMedia(uri).observe { exceptionOrItem ->
@ -876,7 +879,6 @@ class ComposeActivity : BaseActivity(),
} }
displayTransientError(errorId) displayTransientError(errorId)
} }
} }
} }
} }
@ -910,7 +912,8 @@ class ComposeActivity : BaseActivity(),
if (composeOptionsBehavior.state == BottomSheetBehavior.STATE_EXPANDED || if (composeOptionsBehavior.state == BottomSheetBehavior.STATE_EXPANDED ||
addMediaBehavior.state == BottomSheetBehavior.STATE_EXPANDED || addMediaBehavior.state == BottomSheetBehavior.STATE_EXPANDED ||
emojiBehavior.state == BottomSheetBehavior.STATE_EXPANDED || emojiBehavior.state == BottomSheetBehavior.STATE_EXPANDED ||
scheduleBehavior.state == BottomSheetBehavior.STATE_EXPANDED) { scheduleBehavior.state == BottomSheetBehavior.STATE_EXPANDED
) {
composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN
addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN
emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN
@ -1013,7 +1016,6 @@ class ComposeActivity : BaseActivity(),
data class ComposeOptions( data class ComposeOptions(
// Let's keep fields var until all consumers are Kotlin // Let's keep fields var until all consumers are Kotlin
var scheduledTootId: String? = null, var scheduledTootId: String? = null,
var savedTootUid: Int? = null,
var draftId: Int? = null, var draftId: Int? = null,
var tootText: String? = null, var tootText: String? = null,
var mediaUrls: List<String>? = null, var mediaUrls: List<String>? = null,
@ -1035,8 +1037,6 @@ class ComposeActivity : BaseActivity(),
companion object { companion object {
private const val TAG = "ComposeActivity" // logging tag 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 private const val PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE = 1
internal const val COMPOSE_OPTIONS_EXTRA = "COMPOSE_OPTIONS" internal const val COMPOSE_OPTIONS_EXTRA = "COMPOSE_OPTIONS"

View file

@ -21,22 +21,34 @@ import androidx.core.net.toUri
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
import androidx.lifecycle.viewModelScope
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
import com.keylesspalace.tusky.components.drafts.DraftHelper import com.keylesspalace.tusky.components.drafts.DraftHelper
import com.keylesspalace.tusky.components.search.SearchType import com.keylesspalace.tusky.components.search.SearchType
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.InstanceEntity 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.entity.Status
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.service.ServiceClient import com.keylesspalace.tusky.service.ServiceClient
import com.keylesspalace.tusky.service.TootToSend import com.keylesspalace.tusky.service.TootToSend
import com.keylesspalace.tusky.util.* import com.keylesspalace.tusky.util.Either
import io.reactivex.Observable.just import com.keylesspalace.tusky.util.RxAwareViewModel
import io.reactivex.disposables.Disposable import com.keylesspalace.tusky.util.VersionUtils
import io.reactivex.rxkotlin.Singles import com.keylesspalace.tusky.util.combineLiveData
import java.util.* 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 import javax.inject.Inject
class ComposeViewModel @Inject constructor( class ComposeViewModel @Inject constructor(
@ -45,14 +57,12 @@ class ComposeViewModel @Inject constructor(
private val mediaUploader: MediaUploader, private val mediaUploader: MediaUploader,
private val serviceClient: ServiceClient, private val serviceClient: ServiceClient,
private val draftHelper: DraftHelper, private val draftHelper: DraftHelper,
private val saveTootHelper: SaveTootHelper,
private val db: AppDatabase private val db: AppDatabase
) : RxAwareViewModel() { ) : RxAwareViewModel() {
private var replyingStatusAuthor: String? = null private var replyingStatusAuthor: String? = null
private var replyingStatusContent: String? = null private var replyingStatusContent: String? = null
internal var startingText: String? = null internal var startingText: String? = null
private var savedTootUid: Int = 0
private var draftId: Int = 0 private var draftId: Int = 0
private var scheduledTootId: String? = null private var scheduledTootId: String? = null
private var startingContentWarning: String = "" private var startingContentWarning: String = ""
@ -91,7 +101,9 @@ class ComposeViewModel @Inject constructor(
init { init {
Singles.zip(api.getCustomEmojis(), api.getInstance()) { emojis, instance -> Single.zip(
api.getCustomEmojis(), api.getInstance(),
{ emojis, instance ->
InstanceEntity( InstanceEntity(
instance = accountManager.activeAccount?.domain!!, instance = accountManager.activeAccount?.domain!!,
emojiList = emojis, emojiList = emojis,
@ -101,19 +113,23 @@ class ComposeViewModel @Inject constructor(
version = instance.version version = instance.version
) )
} }
)
.doOnSuccess { .doOnSuccess {
db.instanceDao().insertOrReplace(it) db.instanceDao().insertOrReplace(it)
} }
.onErrorResumeNext( .onErrorResumeNext {
db.instanceDao().loadMetadataForInstance(accountManager.activeAccount?.domain!!) db.instanceDao().loadMetadataForInstance(accountManager.activeAccount?.domain!!)
) }
.subscribe({ instanceEntity -> .subscribe(
{ instanceEntity ->
emoji.postValue(instanceEntity.emojiList) emoji.postValue(instanceEntity.emojiList)
instance.postValue(instanceEntity) instance.postValue(instanceEntity)
}, { throwable -> },
{ throwable ->
// this can happen on network error when no cached data is available // this can happen on network error when no cached data is available
Log.w(TAG, "error loading instance data", throwable) Log.w(TAG, "error loading instance data", throwable)
}) }
)
.autoDispose() .autoDispose()
} }
@ -124,19 +140,23 @@ class ComposeViewModel @Inject constructor(
mediaUploader.prepareMedia(uri) mediaUploader.prepareMedia(uri)
.map { (type, uri, size) -> .map { (type, uri, size) ->
val mediaItems = media.value!! val mediaItems = media.value!!
if (type != QueuedMedia.Type.IMAGE if (type != QueuedMedia.Type.IMAGE &&
&& mediaItems.isNotEmpty() mediaItems.isNotEmpty() &&
&& mediaItems[0].type == QueuedMedia.Type.IMAGE) { mediaItems[0].type == QueuedMedia.Type.IMAGE
) {
throw VideoOrImageException() throw VideoOrImageException()
} else { } else {
addMediaToQueue(type, uri, size, description) addMediaToQueue(type, uri, size, description)
} }
} }
.subscribe({ queuedMedia -> .subscribe(
{ queuedMedia ->
liveData.postValue(Either.Right(queuedMedia)) liveData.postValue(Either.Right(queuedMedia))
}, { error -> },
{ error ->
liveData.postValue(Either.Left(error)) liveData.postValue(Either.Left(error))
}) }
)
.autoDispose() .autoDispose()
return liveData return liveData
} }
@ -157,7 +177,8 @@ class ComposeViewModel @Inject constructor(
media.value = media.value!! + mediaItem media.value = media.value!! + mediaItem
mediaToDisposable[mediaItem.localId] = mediaUploader mediaToDisposable[mediaItem.localId] = mediaUploader
.uploadMedia(mediaItem) .uploadMedia(mediaItem)
.subscribe({ event -> .subscribe(
{ event ->
val item = media.value?.find { it.localId == mediaItem.localId } val item = media.value?.find { it.localId == mediaItem.localId }
?: return@subscribe ?: return@subscribe
val newMediaItem = when (event) { val newMediaItem = when (event) {
@ -169,16 +190,20 @@ class ComposeViewModel @Inject constructor(
synchronized(media) { synchronized(media) {
val mediaValue = media.value!! val mediaValue = media.value!!
val index = mediaValue.indexOfFirst { it.localId == newMediaItem.localId } val index = mediaValue.indexOfFirst { it.localId == newMediaItem.localId }
media.postValue(if (index == -1) { media.postValue(
if (index == -1) {
mediaValue + newMediaItem mediaValue + newMediaItem
} else { } else {
mediaValue.toMutableList().also { it[index] = newMediaItem } mediaValue.toMutableList().also { it[index] = newMediaItem }
})
} }
}, { error -> )
}
},
{ error ->
media.postValue(media.value?.filter { it.localId != mediaItem.localId } ?: emptyList()) media.postValue(media.value?.filter { it.localId != mediaItem.localId } ?: emptyList())
uploadError.postValue(error) uploadError.postValue(error)
}) }
)
return mediaItem return mediaItem
} }
@ -198,12 +223,14 @@ class ComposeViewModel @Inject constructor(
fun didChange(content: String?, contentWarning: String?): Boolean { fun didChange(content: String?, contentWarning: String?): Boolean {
val textChanged = !(content.isNullOrEmpty() val textChanged = !(
|| startingText?.startsWith(content.toString()) ?: false) content.isNullOrEmpty() ||
startingText?.startsWith(content.toString()) ?: false
)
val contentWarningChanged = showContentWarning.value!! val contentWarningChanged = showContentWarning.value!! &&
&& !contentWarning.isNullOrEmpty() !contentWarning.isNullOrEmpty() &&
&& !startingContentWarning.startsWith(contentWarning.toString()) !startingContentWarning.startsWith(contentWarning.toString())
val mediaChanged = !media.value.isNullOrEmpty() val mediaChanged = !media.value.isNullOrEmpty()
val pollChanged = poll.value != null val pollChanged = poll.value != null
@ -216,17 +243,15 @@ class ComposeViewModel @Inject constructor(
} }
fun deleteDraft() { fun deleteDraft() {
if (savedTootUid != 0) { viewModelScope.launch {
saveTootHelper.deleteDraft(savedTootUid)
}
if (draftId != 0) { if (draftId != 0) {
draftHelper.deleteDraftAndAttachments(draftId) draftHelper.deleteDraftAndAttachments(draftId)
.subscribe() }
} }
} }
fun saveDraft(content: String, contentWarning: String) { fun saveDraft(content: String, contentWarning: String) {
viewModelScope.launch {
val mediaUris: MutableList<String> = mutableListOf() val mediaUris: MutableList<String> = mutableListOf()
val mediaDescriptions: MutableList<String?> = mutableListOf() val mediaDescriptions: MutableList<String?> = mutableListOf()
media.value?.forEach { item -> media.value?.forEach { item ->
@ -246,7 +271,8 @@ class ComposeViewModel @Inject constructor(
mediaDescriptions = mediaDescriptions, mediaDescriptions = mediaDescriptions,
poll = poll.value, poll = poll.value,
failedToSend = false failedToSend = false
).subscribe() )
}
} }
/** /**
@ -262,7 +288,7 @@ class ComposeViewModel @Inject constructor(
val deletionObservable = if (isEditingScheduledToot) { val deletionObservable = if (isEditingScheduledToot) {
api.deleteScheduledStatus(scheduledTootId.toString()).toObservable().map { } api.deleteScheduledStatus(scheduledTootId.toString()).toObservable().map { }
} else { } else {
just(Unit) Observable.just(Unit)
}.toLiveData() }.toLiveData()
val sendObservable = media val sendObservable = media
@ -291,7 +317,6 @@ class ComposeViewModel @Inject constructor(
replyingStatusContent = null, replyingStatusContent = null,
replyingStatusAuthorUsername = null, replyingStatusAuthorUsername = null,
accountId = accountManager.activeAccount!!.id, accountId = accountManager.activeAccount!!.id,
savedTootUid = savedTootUid,
draftId = draftId, draftId = draftId,
idempotencyKey = randomAlphanumericString(16), idempotencyKey = randomAlphanumericString(16),
retries = 0 retries = 0
@ -318,11 +343,14 @@ class ComposeViewModel @Inject constructor(
media.removeObserver(this) media.removeObserver(this)
} else if (updatedItem.id != null) { } else if (updatedItem.id != null) {
api.updateMedia(updatedItem.id, description) api.updateMedia(updatedItem.id, description)
.subscribe({ .subscribe(
{
completedCaptioningLiveData.postValue(true) completedCaptioningLiveData.postValue(true)
}, { },
{
completedCaptioningLiveData.postValue(false) completedCaptioningLiveData.postValue(false)
}) }
)
.autoDispose() .autoDispose()
media.removeObserver(this) media.removeObserver(this)
} }
@ -357,11 +385,11 @@ class ComposeViewModel @Inject constructor(
':' -> { ':' -> {
val emojiList = emoji.value ?: return emptyList() 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<ComposeAutoCompleteAdapter.AutocompleteResult>() val results = ArrayList<ComposeAutoCompleteAdapter.AutocompleteResult>()
val resultsInside = ArrayList<ComposeAutoCompleteAdapter.AutocompleteResult>() val resultsInside = ArrayList<ComposeAutoCompleteAdapter.AutocompleteResult>()
for (emoji in emojiList) { for (emoji in emojiList) {
val shortcode = emoji.shortcode.toLowerCase(Locale.ROOT) val shortcode = emoji.shortcode.lowercase(Locale.ROOT)
if (shortcode.startsWith(incomplete)) { if (shortcode.startsWith(incomplete)) {
results.add(ComposeAutoCompleteAdapter.EmojiResult(emoji)) results.add(ComposeAutoCompleteAdapter.EmojiResult(emoji))
} else if (shortcode.indexOf(incomplete, 1) != -1) { } else if (shortcode.indexOf(incomplete, 1) != -1) {
@ -391,7 +419,8 @@ class ComposeViewModel @Inject constructor(
val replyVisibility = composeOptions?.replyVisibility ?: Status.Visibility.UNKNOWN val replyVisibility = composeOptions?.replyVisibility ?: Status.Visibility.UNKNOWN
startingVisibility = Status.Visibility.byNum( startingVisibility = Status.Visibility.byNum(
preferredVisibility.num.coerceAtLeast(replyVisibility.num)) preferredVisibility.num.coerceAtLeast(replyVisibility.num)
)
inReplyToId = composeOptions?.inReplyToId inReplyToId = composeOptions?.inReplyToId
@ -406,20 +435,8 @@ class ComposeViewModel @Inject constructor(
} }
// recreate media list // recreate media list
val loadedDraftMediaUris = composeOptions?.mediaUrls
val loadedDraftMediaDescriptions: List<String?>? = composeOptions?.mediaDescriptions
val draftAttachments = composeOptions?.draftAttachments val draftAttachments = composeOptions?.draftAttachments
if (loadedDraftMediaUris != null && loadedDraftMediaDescriptions != null) { if (draftAttachments != 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) {
// when coming from DraftActivity // when coming from DraftActivity
draftAttachments.forEach { attachment -> pickMedia(attachment.uri, attachment.description) } draftAttachments.forEach { attachment -> pickMedia(attachment.uri, attachment.description) }
} else composeOptions?.mediaAttachments?.forEach { a -> } else composeOptions?.mediaAttachments?.forEach { a ->
@ -432,7 +449,6 @@ class ComposeViewModel @Inject constructor(
addUploadedMedia(a.id, mediaType, a.url.toUri(), a.description) addUploadedMedia(a.id, mediaType, a.url.toUri(), a.description)
} }
savedTootUid = composeOptions?.savedTootUid ?: 0
draftId = composeOptions?.draftId ?: 0 draftId = composeOptions?.draftId ?: 0
scheduledTootId = composeOptions?.scheduledTootId scheduledTootId = composeOptions?.scheduledTootId
startingText = composeOptions?.tootText startingText = composeOptions?.tootText
@ -483,14 +499,13 @@ class ComposeViewModel @Inject constructor(
private companion object { private companion object {
const val TAG = "ComposeViewModel" const val TAG = "ComposeViewModel"
} }
} }
fun <T> mutableLiveData(default: T) = MutableLiveData<T>().apply { value = default } fun <T> mutableLiveData(default: T) = MutableLiveData<T>().apply { value = default }
const val DEFAULT_CHARACTER_LIMIT = 500 const val DEFAULT_CHARACTER_LIMIT = 500
private const val DEFAULT_MAX_OPTION_COUNT = 4 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( data class ComposeInstanceParams(
val maxChars: Int, val maxChars: Int,

View file

@ -81,7 +81,9 @@ class MediaPreviewAdapter(
} }
} }
private val differ = AsyncListDiffer(this, object : DiffUtil.ItemCallback<ComposeActivity.QueuedMedia>() { private val differ = AsyncListDiffer(
this,
object : DiffUtil.ItemCallback<ComposeActivity.QueuedMedia>() {
override fun areItemsTheSame(oldItem: ComposeActivity.QueuedMedia, newItem: ComposeActivity.QueuedMedia): Boolean { override fun areItemsTheSame(oldItem: ComposeActivity.QueuedMedia, newItem: ComposeActivity.QueuedMedia): Boolean {
return oldItem.localId == newItem.localId return oldItem.localId == newItem.localId
} }
@ -89,10 +91,11 @@ class MediaPreviewAdapter(
override fun areContentsTheSame(oldItem: ComposeActivity.QueuedMedia, newItem: ComposeActivity.QueuedMedia): Boolean { override fun areContentsTheSame(oldItem: ComposeActivity.QueuedMedia, newItem: ComposeActivity.QueuedMedia): Boolean {
return oldItem == newItem return oldItem == newItem
} }
}) }
)
inner class PreviewViewHolder(val progressImageView: ProgressImageView) inner class PreviewViewHolder(val progressImageView: ProgressImageView) :
: RecyclerView.ViewHolder(progressImageView) { RecyclerView.ViewHolder(progressImageView) {
init { init {
val layoutParams = ConstraintLayout.LayoutParams(thumbnailViewSize, thumbnailViewSize) val layoutParams = ConstraintLayout.LayoutParams(thumbnailViewSize, thumbnailViewSize)
val margin = itemView.context.resources val margin = itemView.context.resources

View file

@ -28,16 +28,20 @@ import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.network.ProgressRequestBody import com.keylesspalace.tusky.network.ProgressRequestBody
import com.keylesspalace.tusky.util.* import com.keylesspalace.tusky.util.MEDIA_SIZE_UNKNOWN
import io.reactivex.Observable import com.keylesspalace.tusky.util.getImageSquarePixels
import io.reactivex.Single import com.keylesspalace.tusky.util.getMediaSize
import io.reactivex.schedulers.Schedulers 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.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody import okhttp3.MultipartBody
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.io.IOException import java.io.IOException
import java.util.* import java.util.Date
import javax.inject.Inject
sealed class UploadEvent { sealed class UploadEvent {
data class ProgressEvent(val percentage: Int) : UploadEvent() data class ProgressEvent(val percentage: Int) : UploadEvent()
@ -58,21 +62,16 @@ fun createNewImageFile(context: Context): File {
data class PreparedMedia(val type: QueuedMedia.Type, val uri: Uri, val size: Long) data class PreparedMedia(val type: QueuedMedia.Type, val uri: Uri, val size: Long)
interface MediaUploader {
fun prepareMedia(inUri: Uri): Single<PreparedMedia>
fun uploadMedia(media: QueuedMedia): Observable<UploadEvent>
}
class AudioSizeException : Exception() class AudioSizeException : Exception()
class VideoSizeException : Exception() class VideoSizeException : Exception()
class MediaTypeException : Exception() class MediaTypeException : Exception()
class CouldNotOpenFileException : Exception() class CouldNotOpenFileException : Exception()
class MediaUploaderImpl( class MediaUploader @Inject constructor(
private val context: Context, private val context: Context,
private val mastodonApi: MastodonApi private val mastodonApi: MastodonApi
) : MediaUploader { ) {
override fun uploadMedia(media: QueuedMedia): Observable<UploadEvent> { fun uploadMedia(media: QueuedMedia): Observable<UploadEvent> {
return Observable return Observable
.fromCallable { .fromCallable {
if (shouldResizeMedia(media)) { if (shouldResizeMedia(media)) {
@ -83,7 +82,7 @@ class MediaUploaderImpl(
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
} }
override fun prepareMedia(inUri: Uri): Single<PreparedMedia> { fun prepareMedia(inUri: Uri): Single<PreparedMedia> {
return Single.fromCallable { return Single.fromCallable {
var mediaSize = getMediaSize(contentResolver, inUri) var mediaSize = getMediaSize(contentResolver, inUri)
var uri = inUri var uri = inUri
@ -101,12 +100,13 @@ class MediaUploaderImpl(
val file = File.createTempFile("randomTemp1", suffix, context.cacheDir) val file = File.createTempFile("randomTemp1", suffix, context.cacheDir)
FileOutputStream(file.absoluteFile).use { out -> FileOutputStream(file.absoluteFile).use { out ->
input.copyTo(out) input.copyTo(out)
uri = FileProvider.getUriForFile(context, uri = FileProvider.getUriForFile(
context,
BuildConfig.APPLICATION_ID + ".fileprovider", BuildConfig.APPLICATION_ID + ".fileprovider",
file) file
)
mediaSize = getMediaSize(contentResolver, uri) mediaSize = getMediaSize(contentResolver, uri)
} }
} }
} catch (e: IOException) { } catch (e: IOException) {
Log.w(TAG, e) Log.w(TAG, e)
@ -151,20 +151,22 @@ class MediaUploaderImpl(
var mimeType = contentResolver.getType(media.uri) var mimeType = contentResolver.getType(media.uri)
val map = MimeTypeMap.getSingleton() val map = MimeTypeMap.getSingleton()
val fileExtension = map.getExtensionFromMimeType(mimeType) val fileExtension = map.getExtensionFromMimeType(mimeType)
val filename = String.format("%s_%s_%s.%s", val filename = "%s_%s_%s.%s".format(
context.getString(R.string.app_name), context.getString(R.string.app_name),
Date().time.toString(), Date().time.toString(),
randomAlphanumericString(10), randomAlphanumericString(10),
fileExtension) fileExtension
)
val stream = contentResolver.openInputStream(media.uri) val stream = contentResolver.openInputStream(media.uri)
if (mimeType == null) mimeType = "multipart/form-data" if (mimeType == null) mimeType = "multipart/form-data"
var lastProgress = -1 var lastProgress = -1
val fileBody = ProgressRequestBody(stream, media.mediaSize, val fileBody = ProgressRequestBody(
mimeType.toMediaTypeOrNull()) { percentage -> stream, media.mediaSize,
mimeType.toMediaTypeOrNull()
) { percentage ->
if (percentage != lastProgress) { if (percentage != lastProgress) {
emitter.onNext(UploadEvent.ProgressEvent(percentage)) emitter.onNext(UploadEvent.ProgressEvent(percentage))
} }
@ -180,12 +182,15 @@ class MediaUploaderImpl(
} }
val uploadDisposable = mastodonApi.uploadMedia(body, description) val uploadDisposable = mastodonApi.uploadMedia(body, description)
.subscribe({ attachment -> .subscribe(
{ attachment ->
emitter.onNext(UploadEvent.FinishedEvent(attachment)) emitter.onNext(UploadEvent.FinishedEvent(attachment))
emitter.onComplete() emitter.onComplete()
}, { e -> },
{ e ->
emitter.onError(e) emitter.onError(e)
}) }
)
// Cancel the request when our observable is cancelled // Cancel the request when our observable is cancelled
emitter.setDisposable(uploadDisposable) emitter.setDisposable(uploadDisposable)
@ -194,15 +199,16 @@ class MediaUploaderImpl(
private fun downsize(media: QueuedMedia): QueuedMedia { private fun downsize(media: QueuedMedia): QueuedMedia {
val file = createNewImageFile(context) val file = createNewImageFile(context)
DownsizeImageTask.resize(arrayOf(media.uri), DownsizeImageTask.resize(
STATUS_IMAGE_SIZE_LIMIT, context.contentResolver, file) arrayOf(media.uri),
STATUS_IMAGE_SIZE_LIMIT, context.contentResolver, file
)
return media.copy(uri = file.toUri(), mediaSize = file.length()) return media.copy(uri = file.toUri(), mediaSize = file.length())
} }
private fun shouldResizeMedia(media: QueuedMedia): Boolean { private fun shouldResizeMedia(media: QueuedMedia): Boolean {
return media.type == QueuedMedia.Type.IMAGE return media.type == QueuedMedia.Type.IMAGE &&
&& (media.mediaSize > STATUS_IMAGE_SIZE_LIMIT (media.mediaSize > STATUS_IMAGE_SIZE_LIMIT || getImageSquarePixels(context.contentResolver, media.uri) > STATUS_IMAGE_PIXEL_SIZE_LIMIT)
|| getImageSquarePixels(context.contentResolver, media.uri) > STATUS_IMAGE_PIXEL_SIZE_LIMIT)
} }
private companion object { private companion object {
@ -211,6 +217,5 @@ class MediaUploaderImpl(
private const val STATUS_AUDIO_SIZE_LIMIT = 41943040 // 40MiB private const val STATUS_AUDIO_SIZE_LIMIT = 41943040 // 40MiB
private const val STATUS_IMAGE_SIZE_LIMIT = 8388608 // 8MiB private const val STATUS_IMAGE_SIZE_LIMIT = 8388608 // 8MiB
private const val STATUS_IMAGE_PIXEL_SIZE_LIMIT = 16777216 // 4096^2 Pixels private const val STATUS_IMAGE_PIXEL_SIZE_LIMIT = 16777216 // 4096^2 Pixels
} }
} }

View file

@ -82,11 +82,13 @@ fun showAddPollDialog(
val pollDuration = context.resources 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, options = adapter.pollOptions,
expiresIn = pollDuration, expiresIn = pollDuration,
multiple = binding.multipleChoicesCheckBox.isChecked multiple = binding.multipleChoicesCheckBox.isChecked
)) )
)
dialog.dismiss() dialog.dismiss()
} }

View file

@ -47,7 +47,7 @@ class AddPollOptionsAdapter(
binding.optionEditText.filters = arrayOf(InputFilter.LengthFilter(maxOptionLength)) binding.optionEditText.filters = arrayOf(InputFilter.LengthFilter(maxOptionLength))
binding.optionEditText.onTextChanged { s, _, _, _ -> binding.optionEditText.onTextChanged { s, _, _, _ ->
val pos = holder.adapterPosition val pos = holder.bindingAdapterPosition
if (pos != RecyclerView.NO_POSITION) { if (pos != RecyclerView.NO_POSITION) {
options[pos] = s.toString() options[pos] = s.toString()
onOptionChanged(validateInput()) onOptionChanged(validateInput())
@ -68,8 +68,8 @@ class AddPollOptionsAdapter(
holder.binding.deleteButton.setOnClickListener { holder.binding.deleteButton.setOnClickListener {
holder.binding.optionEditText.clearFocus() holder.binding.optionEditText.clearFocus()
options.removeAt(holder.adapterPosition) options.removeAt(holder.bindingAdapterPosition)
notifyItemRemoved(holder.adapterPosition) notifyItemRemoved(holder.bindingAdapterPosition)
onOptionRemoved(validateInput()) onOptionRemoved(validateInput())
} }
} }

View file

@ -21,7 +21,6 @@ import android.graphics.drawable.Drawable
import android.net.Uri import android.net.Uri
import android.text.InputFilter import android.text.InputFilter
import android.text.InputType import android.text.InputType
import android.util.DisplayMetrics
import android.view.WindowManager import android.view.WindowManager
import android.widget.EditText import android.widget.EditText
import android.widget.LinearLayout import android.widget.LinearLayout
@ -41,7 +40,8 @@ import com.keylesspalace.tusky.util.withLifecycleContext
// https://github.com/tootsuite/mastodon/blob/c6904c0d3766a2ea8a81ab025c127169ecb51373/app/models/media_attachment.rb#L32 // https://github.com/tootsuite/mastodon/blob/c6904c0d3766a2ea8a81ab025c127169ecb51373/app/models/media_attachment.rb#L32
private const val MEDIA_DESCRIPTION_CHARACTER_LIMIT = 1500 private const val MEDIA_DESCRIPTION_CHARACTER_LIMIT = 1500
fun <T> T.makeCaptionDialog(existingDescription: String?, fun <T> T.makeCaptionDialog(
existingDescription: String?,
previewUri: Uri, previewUri: Uri,
onUpdateDescription: (String) -> LiveData<Boolean> onUpdateDescription: (String) -> LiveData<Boolean>
) where T : Activity, T : LifecycleOwner { ) where T : Activity, T : LifecycleOwner {
@ -54,9 +54,6 @@ fun <T> T.makeCaptionDialog(existingDescription: String?,
maximumScale = 6f maximumScale = 6f
} }
val displayMetrics = DisplayMetrics()
windowManager.defaultDisplay.getMetrics(displayMetrics)
val margin = Utils.dpToPx(this, 4) val margin = Utils.dpToPx(this, 4)
dialogLayout.addView(imageView) dialogLayout.addView(imageView)
(imageView.layoutParams as LinearLayout.LayoutParams).weight = 1f (imageView.layoutParams as LinearLayout.LayoutParams).weight = 1f
@ -64,14 +61,18 @@ fun <T> T.makeCaptionDialog(existingDescription: String?,
(imageView.layoutParams as LinearLayout.LayoutParams).setMargins(0, margin, 0, 0) (imageView.layoutParams as LinearLayout.LayoutParams).setMargins(0, margin, 0, 0)
val input = EditText(this) val input = EditText(this)
input.hint = resources.getQuantityString(R.plurals.hint_describe_for_visually_impaired, input.hint = resources.getQuantityString(
MEDIA_DESCRIPTION_CHARACTER_LIMIT, MEDIA_DESCRIPTION_CHARACTER_LIMIT) R.plurals.hint_describe_for_visually_impaired,
MEDIA_DESCRIPTION_CHARACTER_LIMIT, MEDIA_DESCRIPTION_CHARACTER_LIMIT
)
dialogLayout.addView(input) dialogLayout.addView(input)
(input.layoutParams as LinearLayout.LayoutParams).setMargins(margin, margin, margin, margin) (input.layoutParams as LinearLayout.LayoutParams).setMargins(margin, margin, margin, margin)
input.setLines(2) input.setLines(2)
input.inputType = (InputType.TYPE_CLASS_TEXT input.inputType = (
InputType.TYPE_CLASS_TEXT
or InputType.TYPE_TEXT_FLAG_MULTI_LINE or InputType.TYPE_TEXT_FLAG_MULTI_LINE
or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES) or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
)
input.setText(existingDescription) input.setText(existingDescription)
input.filters = arrayOf(InputFilter.LengthFilter(MEDIA_DESCRIPTION_CHARACTER_LIMIT)) input.filters = arrayOf(InputFilter.LengthFilter(MEDIA_DESCRIPTION_CHARACTER_LIMIT))
@ -80,7 +81,6 @@ fun <T> T.makeCaptionDialog(existingDescription: String?,
withLifecycleContext { withLifecycleContext {
onUpdateDescription(input.text.toString()) onUpdateDescription(input.text.toString())
.observe { success -> if (!success) showFailedCaptionMessage() } .observe { success -> if (!success) showFailedCaptionMessage() }
} }
dialog.dismiss() dialog.dismiss()
@ -94,7 +94,8 @@ fun <T> T.makeCaptionDialog(existingDescription: String?,
val window = dialog.window val window = dialog.window
window?.setSoftInputMode( window?.setSoftInputMode(
WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE
)
dialog.show() dialog.show()
@ -113,7 +114,6 @@ fun <T> T.makeCaptionDialog(existingDescription: String?,
}) })
} }
private fun Activity.showFailedCaptionMessage() { private fun Activity.showFailedCaptionMessage() {
Toast.makeText(this, R.string.error_failed_set_caption, Toast.LENGTH_SHORT).show() Toast.makeText(this, R.string.error_failed_set_caption, Toast.LENGTH_SHORT).show()
} }

View file

@ -57,12 +57,10 @@ class ComposeOptionsView @JvmOverloads constructor(context: Context, attrs: Attr
R.id.directRadioButton R.id.directRadioButton
else -> else ->
R.id.directRadioButton R.id.directRadioButton
} }
check(selectedButton) check(selectedButton)
} }
} }
interface ComposeOptionsListener { interface ComposeOptionsListener {

View file

@ -16,19 +16,21 @@
package com.keylesspalace.tusky.components.compose.view package com.keylesspalace.tusky.components.compose.view
import android.content.Context 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.InputType
import android.text.method.KeyListener import android.text.method.KeyListener
import android.util.AttributeSet import android.util.AttributeSet
import android.view.inputmethod.EditorInfo import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputConnection 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, class EditTextTyped @JvmOverloads constructor(
attributeSet: AttributeSet? = null) context: Context,
: AppCompatMultiAutoCompleteTextView(context, attributeSet) { attributeSet: AttributeSet? = null
) :
AppCompatMultiAutoCompleteTextView(context, attributeSet) {
private var onCommitContentListener: InputConnectionCompat.OnCommitContentListener? = null private var onCommitContentListener: InputConnectionCompat.OnCommitContentListener? = null
private val emojiEditTextHelper: EmojiEditTextHelper = EmojiEditTextHelper(this) private val emojiEditTextHelper: EmojiEditTextHelper = EmojiEditTextHelper(this)
@ -52,8 +54,13 @@ class EditTextTyped @JvmOverloads constructor(context: Context,
val connection = super.onCreateInputConnection(editorInfo) val connection = super.onCreateInputConnection(editorInfo)
return if (onCommitContentListener != null) { return if (onCommitContentListener != null) {
EditorInfoCompat.setContentMimeTypes(editorInfo, arrayOf("image/*")) EditorInfoCompat.setContentMimeTypes(editorInfo, arrayOf("image/*"))
getEmojiEditTextHelper().onCreateInputConnection(InputConnectionCompat.createWrapper(connection, editorInfo, getEmojiEditTextHelper().onCreateInputConnection(
onCommitContentListener!!), editorInfo)!! InputConnectionCompat.createWrapper(
connection, editorInfo,
onCommitContentListener!!
),
editorInfo
)!!
} else { } else {
connection connection
} }

View file

@ -27,8 +27,9 @@ import com.keylesspalace.tusky.entity.NewPoll
class PollPreviewView @JvmOverloads constructor( class PollPreviewView @JvmOverloads constructor(
context: Context?, context: Context?,
attrs: AttributeSet? = null, attrs: AttributeSet? = null,
defStyleAttr: Int = 0) defStyleAttr: Int = 0
: LinearLayout(context, attrs, defStyleAttr) { ) :
LinearLayout(context, attrs, defStyleAttr) {
private val adapter = PreviewPollOptionsAdapter() private val adapter = PreviewPollOptionsAdapter()

View file

@ -68,8 +68,5 @@ class TootButton
} }
} }
} }
} }
} }

View file

@ -17,114 +17,39 @@ package com.keylesspalace.tusky.components.conversation
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.paging.AsyncPagedListDiffer import androidx.paging.PagingDataAdapter
import androidx.paging.PagedList
import androidx.recyclerview.widget.AsyncDifferConfig
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListUpdateCallback
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.NetworkStateViewHolder
import com.keylesspalace.tusky.databinding.ItemNetworkStateBinding
import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.interfaces.StatusActionListener
import com.keylesspalace.tusky.util.NetworkState
import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.StatusDisplayOptions
class ConversationAdapter( class ConversationAdapter(
private val statusDisplayOptions: StatusDisplayOptions, private val statusDisplayOptions: StatusDisplayOptions,
private val listener: StatusActionListener, private val listener: StatusActionListener
private val topLoadedCallback: () -> Unit, ) : PagingDataAdapter<ConversationEntity, ConversationViewHolder>(CONVERSATION_COMPARATOR) {
private val retryCallback: () -> Unit
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private var networkState: NetworkState? = null override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ConversationViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_conversation, parent, false)
private val differ: AsyncPagedListDiffer<ConversationEntity> = AsyncPagedListDiffer(object : ListUpdateCallback { return ConversationViewHolder(view, statusDisplayOptions, listener)
override fun onInserted(position: Int, count: Int) {
notifyItemRangeInserted(position, count)
if (position == 0) {
topLoadedCallback()
}
} }
override fun onRemoved(position: Int, count: Int) { override fun onBindViewHolder(holder: ConversationViewHolder, position: Int) {
notifyItemRangeRemoved(position, count) holder.setupWithConversation(getItem(position))
} }
override fun onMoved(fromPosition: Int, toPosition: Int) { fun item(position: Int): ConversationEntity? {
notifyItemMoved(fromPosition, toPosition) return getItem(position)
}
override fun onChanged(position: Int, count: Int, payload: Any?) {
notifyItemRangeChanged(position, count, payload)
}
}, AsyncDifferConfig.Builder(CONVERSATION_COMPARATOR).build())
fun submitList(list: PagedList<ConversationEntity>) {
differ.submitList(list)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return when (viewType) {
R.layout.item_network_state -> {
val binding = ItemNetworkStateBinding.inflate(LayoutInflater.from(parent.context), parent, false)
NetworkStateViewHolder(binding, retryCallback)
}
R.layout.item_conversation -> {
val view = LayoutInflater.from(parent.context).inflate(viewType, parent, false)
ConversationViewHolder(view, statusDisplayOptions, listener)
}
else -> throw IllegalArgumentException("unknown view type $viewType")
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (getItemViewType(position)) {
R.layout.item_network_state -> (holder as NetworkStateViewHolder).setUpWithNetworkState(networkState, differ.itemCount == 0)
R.layout.item_conversation -> (holder as ConversationViewHolder).setupWithConversation(differ.getItem(position))
}
}
private fun hasExtraRow() = networkState != null && networkState != NetworkState.LOADED
override fun getItemViewType(position: Int): Int {
return if (hasExtraRow() && position == itemCount - 1) {
R.layout.item_network_state
} else {
R.layout.item_conversation
}
}
override fun getItemCount(): Int {
return differ.itemCount + if (hasExtraRow()) 1 else 0
}
fun setNetworkState(newNetworkState: NetworkState?) {
val previousState = this.networkState
val hadExtraRow = hasExtraRow()
this.networkState = newNetworkState
val hasExtraRow = hasExtraRow()
if (hadExtraRow != hasExtraRow) {
if (hadExtraRow) {
notifyItemRemoved(differ.itemCount)
} else {
notifyItemInserted(differ.itemCount)
}
} else if (hasExtraRow && previousState != newNetworkState) {
notifyItemChanged(itemCount - 1)
}
} }
companion object { companion object {
val CONVERSATION_COMPARATOR = object : DiffUtil.ItemCallback<ConversationEntity>() { val CONVERSATION_COMPARATOR = object : DiffUtil.ItemCallback<ConversationEntity>() {
override fun areContentsTheSame(oldItem: ConversationEntity, newItem: ConversationEntity): Boolean = override fun areItemsTheSame(oldItem: ConversationEntity, newItem: ConversationEntity): Boolean {
oldItem == newItem return oldItem.id == newItem.id
override fun areItemsTheSame(oldItem: ConversationEntity, newItem: ConversationEntity): Boolean =
oldItem.id == newItem.id
} }
override fun areContentsTheSame(oldItem: ConversationEntity, newItem: ConversationEntity): Boolean {
return oldItem == newItem
}
}
} }
} }

View file

@ -1,4 +1,4 @@
/* Copyright 2019 Conny Duck /* Copyright 2021 Tusky Contributors
* *
* This file is a part of Tusky. * This file is a part of Tusky.
* *
@ -21,9 +21,14 @@ import androidx.room.Embedded
import androidx.room.Entity import androidx.room.Entity
import androidx.room.TypeConverters import androidx.room.TypeConverters
import com.keylesspalace.tusky.db.Converters import com.keylesspalace.tusky.db.Converters
import com.keylesspalace.tusky.entity.* import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Conversation
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.util.shouldTrimStatus import com.keylesspalace.tusky.util.shouldTrimStatus
import java.util.* import java.util.Date
@Entity(primaryKeys = ["id", "accountId"]) @Entity(primaryKeys = ["id", "accountId"])
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
@ -73,13 +78,13 @@ data class ConversationStatusEntity(
val sensitive: Boolean, val sensitive: Boolean,
val spoilerText: String, val spoilerText: String,
val attachments: ArrayList<Attachment>, val attachments: ArrayList<Attachment>,
val mentions: Array<Status.Mention>, val mentions: List<Status.Mention>,
val showingHiddenContent: Boolean, val showingHiddenContent: Boolean,
val expanded: Boolean, val expanded: Boolean,
val collapsible: Boolean, val collapsible: Boolean,
val collapsed: Boolean, val collapsed: Boolean,
val muted: Boolean,
val poll: Poll? val poll: Poll?
) { ) {
/** its necessary to override this because Spanned.equals does not work as expected */ /** its necessary to override this because Spanned.equals does not work as expected */
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
@ -101,11 +106,12 @@ data class ConversationStatusEntity(
if (sensitive != other.sensitive) return false if (sensitive != other.sensitive) return false
if (spoilerText != other.spoilerText) return false if (spoilerText != other.spoilerText) return false
if (attachments != other.attachments) 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 (showingHiddenContent != other.showingHiddenContent) return false
if (expanded != other.expanded) return false if (expanded != other.expanded) return false
if (collapsible != other.collapsible) return false if (collapsible != other.collapsible) return false
if (collapsed != other.collapsed) return false if (collapsed != other.collapsed) return false
if (muted != other.muted) return false
if (poll != other.poll) return false if (poll != other.poll) return false
return true return true
@ -125,11 +131,12 @@ data class ConversationStatusEntity(
result = 31 * result + sensitive.hashCode() result = 31 * result + sensitive.hashCode()
result = 31 * result + spoilerText.hashCode() result = 31 * result + spoilerText.hashCode()
result = 31 * result + attachments.hashCode() result = 31 * result + attachments.hashCode()
result = 31 * result + mentions.contentHashCode() result = 31 * result + mentions.hashCode()
result = 31 * result + showingHiddenContent.hashCode() result = 31 * result + showingHiddenContent.hashCode()
result = 31 * result + expanded.hashCode() result = 31 * result + expanded.hashCode()
result = 31 * result + collapsible.hashCode() result = 31 * result + collapsible.hashCode()
result = 31 * result + collapsed.hashCode() result = 31 * result + collapsed.hashCode()
result = 31 * result + muted.hashCode()
result = 31 * result + poll.hashCode() result = 31 * result + poll.hashCode()
return result return result
} }
@ -157,39 +164,52 @@ data class ConversationStatusEntity(
mentions = mentions, mentions = mentions,
application = null, application = null,
pinned = false, pinned = false,
muted = false, muted = muted,
poll = poll, poll = poll,
card = null) card = null
)
} }
} }
fun Account.toEntity() = fun Account.toEntity() =
ConversationAccountEntity( ConversationAccountEntity(
id, id = id,
username, username = username,
name, displayName = name,
avatar, avatar = avatar,
emojis ?: emptyList() emojis = emojis ?: emptyList()
) )
fun Status.toEntity() = fun Status.toEntity() =
ConversationStatusEntity( ConversationStatusEntity(
id, url, inReplyToId, inReplyToAccountId, account.toEntity(), content, id = id,
createdAt, emojis, favouritesCount, favourited, bookmarked, sensitive, url = url,
spoilerText, attachments, mentions, inReplyToId = inReplyToId,
false, inReplyToAccountId = inReplyToAccountId,
false, account = account.toEntity(),
shouldTrimStatus(content), content = content,
true, createdAt = createdAt,
poll emojis = emojis,
favouritesCount = favouritesCount,
favourited = favourited,
bookmarked = bookmarked,
sensitive = sensitive,
spoilerText = spoilerText,
attachments = attachments,
mentions = mentions,
showingHiddenContent = false,
expanded = false,
collapsible = shouldTrimStatus(content),
collapsed = true,
muted = muted ?: false,
poll = poll
) )
fun Conversation.toEntity(accountId: Long) = fun Conversation.toEntity(accountId: Long) =
ConversationEntity( ConversationEntity(
accountId, accountId = accountId,
id, id = id,
accounts.map { it.toEntity() }, accounts = accounts.map { it.toEntity() },
unread, unread = unread,
lastStatus!!.toEntity() lastStatus = lastStatus!!.toEntity()
) )

View file

@ -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 <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.conversation
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.paging.LoadState
import androidx.paging.LoadStateAdapter
import com.keylesspalace.tusky.adapter.NetworkStateViewHolder
import com.keylesspalace.tusky.databinding.ItemNetworkStateBinding
class ConversationLoadStateAdapter(
private val retryCallback: () -> Unit
) : LoadStateAdapter<NetworkStateViewHolder>() {
override fun onBindViewHolder(holder: NetworkStateViewHolder, loadState: LoadState) {
holder.setUpWithNetworkState(loadState)
}
override fun onCreateViewHolder(
parent: ViewGroup,
loadState: LoadState
): NetworkStateViewHolder {
val binding = ItemNetworkStateBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return NetworkStateViewHolder(binding, retryCallback)
}
}

View file

@ -1,98 +0,0 @@
/*
* Copyright (C) 2017 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.keylesspalace.tusky.components.conversation
import androidx.annotation.MainThread
import androidx.paging.PagedList
import com.keylesspalace.tusky.entity.Conversation
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.PagingRequestHelper
import com.keylesspalace.tusky.util.createStatusLiveData
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import java.util.concurrent.Executor
/**
* This boundary callback gets notified when user reaches to the edges of the list such that the
* database cannot provide any more data.
* <p>
* The boundary callback might be called multiple times for the same direction so it does its own
* rate limiting using the PagingRequestHelper class.
*/
class ConversationsBoundaryCallback(
private val accountId: Long,
private val mastodonApi: MastodonApi,
private val handleResponse: (Long, List<Conversation>?) -> Unit,
private val ioExecutor: Executor,
private val networkPageSize: Int)
: PagedList.BoundaryCallback<ConversationEntity>() {
val helper = PagingRequestHelper(ioExecutor)
val networkState = helper.createStatusLiveData()
/**
* Database returned 0 items. We should query the backend for more items.
*/
@MainThread
override fun onZeroItemsLoaded() {
helper.runIfNotRunning(PagingRequestHelper.RequestType.INITIAL) {
mastodonApi.getConversations(null, networkPageSize)
.enqueue(createWebserviceCallback(it))
}
}
/**
* User reached to the end of the list.
*/
@MainThread
override fun onItemAtEndLoaded(itemAtEnd: ConversationEntity) {
helper.runIfNotRunning(PagingRequestHelper.RequestType.AFTER) {
mastodonApi.getConversations(itemAtEnd.lastStatus.id, networkPageSize)
.enqueue(createWebserviceCallback(it))
}
}
/**
* every time it gets new items, boundary callback simply inserts them into the database and
* paging library takes care of refreshing the list if necessary.
*/
private fun insertItemsIntoDb(
response: Response<List<Conversation>>,
it: PagingRequestHelper.Request.Callback) {
ioExecutor.execute {
handleResponse(accountId, response.body())
it.recordSuccess()
}
}
override fun onItemAtFrontLoaded(itemAtFront: ConversationEntity) {
// ignored, since we only ever append to what's in the DB
}
private fun createWebserviceCallback(it: PagingRequestHelper.Request.Callback): Callback<List<Conversation>> {
return object : Callback<List<Conversation>> {
override fun onFailure(call: Call<List<Conversation>>, t: Throwable) {
it.recordFailure(t)
}
override fun onResponse(call: Call<List<Conversation>>, response: Response<List<Conversation>>) {
insertItemsIntoDb(response, it)
}
}
}
}

View file

@ -1,4 +1,4 @@
/* Copyright 2019 Conny Duck /* Copyright 2021 Tusky Contributors
* *
* This file is a part of Tusky. * This file is a part of Tusky.
* *
@ -20,14 +20,19 @@ import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.PopupMenu
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.paging.ExperimentalPagingApi
import androidx.paging.LoadState
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.SimpleItemAnimator import androidx.recyclerview.widget.SimpleItemAnimator
import com.keylesspalace.tusky.AccountActivity
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.ViewTagActivity import com.keylesspalace.tusky.ViewTagActivity
import com.keylesspalace.tusky.components.account.AccountActivity
import com.keylesspalace.tusky.databinding.FragmentTimelineBinding import com.keylesspalace.tusky.databinding.FragmentTimelineBinding
import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory 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.interfaces.StatusActionListener
import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.CardViewMode import com.keylesspalace.tusky.util.CardViewMode
import com.keylesspalace.tusky.util.NetworkState
import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding 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 import javax.inject.Inject
@OptIn(ExperimentalPagingApi::class)
class ConversationsFragment : SFragment(), StatusActionListener, Injectable, ReselectableFragment { class ConversationsFragment : SFragment(), StatusActionListener, Injectable, ReselectableFragment {
@Inject @Inject
@ -52,9 +63,12 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
private val binding by viewBinding(FragmentTimelineBinding::bind) private val binding by viewBinding(FragmentTimelineBinding::bind)
private lateinit var adapter: ConversationAdapter private lateinit var adapter: ConversationAdapter
private lateinit var loadStateAdapter: ConversationLoadStateAdapter
private var layoutManager: LinearLayoutManager? = null private var layoutManager: LinearLayoutManager? = null
private var initialRefreshDone: Boolean = false
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_timeline, container, false) return inflater.inflate(R.layout.fragment_timeline, container, false)
} }
@ -70,16 +84,18 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
useBlurhash = preferences.getBoolean("useBlurhash", true), useBlurhash = preferences.getBoolean("useBlurhash", true),
cardViewMode = CardViewMode.NONE, cardViewMode = CardViewMode.NONE,
confirmReblogs = preferences.getBoolean("confirmReblogs", true), confirmReblogs = preferences.getBoolean("confirmReblogs", true),
confirmFavourites = preferences.getBoolean("confirmFavourites", false),
hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false),
animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
) )
adapter = ConversationAdapter(statusDisplayOptions, this, ::onTopLoaded, viewModel::retry) adapter = ConversationAdapter(statusDisplayOptions, this)
loadStateAdapter = ConversationLoadStateAdapter(adapter::retry)
binding.recyclerView.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL)) binding.recyclerView.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL))
layoutManager = LinearLayoutManager(view.context) layoutManager = LinearLayoutManager(view.context)
binding.recyclerView.layoutManager = layoutManager binding.recyclerView.layoutManager = layoutManager
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter.withLoadStateFooter(loadStateAdapter)
(binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false (binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
binding.progressBar.hide() binding.progressBar.hide()
@ -87,58 +103,101 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
initSwipeToRefresh() initSwipeToRefresh()
viewModel.conversations.observe(viewLifecycleOwner) { lifecycleScope.launch {
adapter.submitList(it) viewModel.conversationFlow.collectLatest { pagingData ->
adapter.submitData(pagingData)
} }
viewModel.networkState.observe(viewLifecycleOwner) {
adapter.setNetworkState(it)
} }
viewModel.load() adapter.addLoadStateListener { loadStates ->
loadStates.refresh.let { refreshState ->
if (refreshState is LoadState.Error) {
binding.statusView.show()
if (refreshState.error is IOException) {
binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) {
adapter.refresh()
}
} else {
binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic) {
adapter.refresh()
}
}
} else {
binding.statusView.hide()
}
binding.progressBar.visible(refreshState == LoadState.Loading && adapter.itemCount == 0)
if (refreshState is LoadState.NotLoading && !initialRefreshDone) {
// jump to top after the initial refresh finished
binding.recyclerView.scrollToPosition(0)
initialRefreshDone = true
}
if (refreshState != LoadState.Loading) {
binding.swipeRefreshLayout.isRefreshing = false
}
}
}
} }
private fun initSwipeToRefresh() { private fun initSwipeToRefresh() {
viewModel.refreshState.observe(viewLifecycleOwner) {
binding.swipeRefreshLayout.isRefreshing = it == NetworkState.LOADING
}
binding.swipeRefreshLayout.setOnRefreshListener { binding.swipeRefreshLayout.setOnRefreshListener {
viewModel.refresh() adapter.refresh()
} }
binding.swipeRefreshLayout.setColorSchemeResources(R.color.chinwag_green) binding.swipeRefreshLayout.setColorSchemeResources(R.color.chinwag_green)
} }
private fun onTopLoaded() {
binding.recyclerView.scrollToPosition(0)
}
override fun onReblog(reblog: Boolean, position: Int) { override fun onReblog(reblog: Boolean, position: Int) {
// its impossible to reblog private messages // its impossible to reblog private messages
} }
override fun onFavourite(favourite: Boolean, position: Int) { override fun onFavourite(favourite: Boolean, position: Int) {
viewModel.favourite(favourite, position) adapter.item(position)?.let { conversation ->
viewModel.favourite(favourite, conversation)
}
} }
override fun onBookmark(favourite: Boolean, position: Int) { override fun onBookmark(favourite: Boolean, position: Int) {
viewModel.bookmark(favourite, position) adapter.item(position)?.let { conversation ->
viewModel.bookmark(favourite, conversation)
}
} }
override fun onMore(view: View, position: Int) { override fun onMore(view: View, position: Int) {
viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let { adapter.item(position)?.let { conversation ->
more(it.toStatus(), view, position)
val popup = PopupMenu(requireContext(), view)
popup.inflate(R.menu.conversation_more)
if (conversation.lastStatus.muted) {
popup.menu.removeItem(R.id.status_mute_conversation)
} else {
popup.menu.removeItem(R.id.status_unmute_conversation)
}
popup.setOnMenuItemClickListener { item ->
when (item.itemId) {
R.id.status_mute_conversation -> viewModel.muteConversation(conversation)
R.id.status_unmute_conversation -> viewModel.muteConversation(conversation)
R.id.conversation_delete -> deleteConversation(conversation)
}
true
}
popup.show()
} }
} }
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let { adapter.item(position)?.let { conversation ->
viewMedia(attachmentIndex, it.toStatus(), view) viewMedia(attachmentIndex, AttachmentViewData.list(conversation.lastStatus.toStatus()), view)
} }
} }
override fun onViewThread(position: Int) { override fun onViewThread(position: Int) {
viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let { adapter.item(position)?.let { conversation ->
viewThread(it.toStatus()) viewThread(conversation.lastStatus.id, conversation.lastStatus.url)
} }
} }
@ -147,11 +206,15 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
} }
override fun onExpandedChange(expanded: Boolean, position: Int) { override fun onExpandedChange(expanded: Boolean, position: Int) {
viewModel.expandHiddenStatus(expanded, position) adapter.item(position)?.let { conversation ->
viewModel.expandHiddenStatus(expanded, conversation)
}
} }
override fun onContentHiddenChange(isShowing: Boolean, position: Int) { override fun onContentHiddenChange(isShowing: Boolean, position: Int) {
viewModel.showContent(isShowing, position) adapter.item(position)?.let { conversation ->
viewModel.showContent(isShowing, conversation)
}
} }
override fun onLoadMore(position: Int) { override fun onLoadMore(position: Int) {
@ -159,7 +222,9 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
} }
override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) {
viewModel.collapseLongStatus(isCollapsed, position) adapter.item(position)?.let { conversation ->
viewModel.collapseLongStatus(isCollapsed, conversation)
}
} }
override fun onViewAccount(id: String) { override fun onViewAccount(id: String) {
@ -174,15 +239,25 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
} }
override fun removeItem(position: Int) { override fun removeItem(position: Int) {
viewModel.remove(position) // not needed
} }
override fun onReply(position: Int) { override fun onReply(position: Int) {
viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let { adapter.item(position)?.let { conversation ->
reply(it.toStatus()) reply(conversation.lastStatus.toStatus())
} }
} }
private fun deleteConversation(conversation: ConversationEntity) {
AlertDialog.Builder(requireContext())
.setMessage(R.string.dialog_delete_conversation_warning)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(android.R.string.ok) { _, _ ->
viewModel.remove(conversation)
}
.show()
}
private fun jumpToTop() { private fun jumpToTop() {
if (isAdded) { if (isAdded) {
layoutManager?.scrollToPosition(0) layoutManager?.scrollToPosition(0)
@ -195,7 +270,9 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
} }
override fun onVoteInPoll(position: Int, choices: MutableList<Int>) { override fun onVoteInPoll(position: Int, choices: MutableList<Int>) {
viewModel.voteInPoll(position, choices) adapter.item(position)?.let { conversation ->
viewModel.voteInPoll(choices, conversation)
}
} }
companion object { companion object {

View file

@ -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<Int, ConversationEntity>() {
override suspend fun load(
loadType: LoadType,
state: PagingState<Int, ConversationEntity>
): MediatorResult {
try {
val conversationsResult = when (loadType) {
LoadType.REFRESH -> {
api.getConversations(limit = state.config.initialLoadSize)
}
LoadType.PREPEND -> {
return MediatorResult.Success(endOfPaginationReached = true)
}
LoadType.APPEND -> {
val maxId = state.pages.findLast { it.data.isNotEmpty() }?.data?.lastOrNull()?.lastStatus?.id
api.getConversations(maxId = maxId, limit = state.config.pageSize)
}
}
if (loadType == LoadType.REFRESH) {
db.conversationDao().deleteForAccount(accountId)
}
db.conversationDao().insert(
conversationsResult
.filterNot { it.lastStatus == null }
.map { it.toEntity(accountId) }
)
return MediatorResult.Success(endOfPaginationReached = conversationsResult.isEmpty())
} catch (e: Exception) {
return MediatorResult.Error(e)
}
}
override suspend fun initialize() = InitializeAction.LAUNCH_INITIAL_REFRESH
}

View file

@ -1,99 +1,32 @@
/* Copyright 2021 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.conversation package com.keylesspalace.tusky.components.conversation
import androidx.annotation.MainThread
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Transformations
import androidx.paging.Config
import androidx.paging.toLiveData
import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.entity.Conversation
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.Listing import io.reactivex.rxjava3.core.Single
import com.keylesspalace.tusky.util.NetworkState import io.reactivex.rxjava3.schedulers.Schedulers
import io.reactivex.Single
import io.reactivex.schedulers.Schedulers
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import java.util.concurrent.Executors
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@Singleton @Singleton
class ConversationsRepository @Inject constructor(val mastodonApi: MastodonApi, val db: AppDatabase) { class ConversationsRepository @Inject constructor(
val mastodonApi: MastodonApi,
private val ioExecutor = Executors.newSingleThreadExecutor() val db: AppDatabase
) {
companion object {
private const val DEFAULT_PAGE_SIZE = 20
}
@MainThread
fun refresh(accountId: Long, showLoadingIndicator: Boolean): LiveData<NetworkState> {
val networkState = MutableLiveData<NetworkState>()
if(showLoadingIndicator) {
networkState.value = NetworkState.LOADING
}
mastodonApi.getConversations(limit = DEFAULT_PAGE_SIZE).enqueue(
object : Callback<List<Conversation>> {
override fun onFailure(call: Call<List<Conversation>>, t: Throwable) {
// retrofit calls this on main thread so safe to call set value
networkState.value = NetworkState.error(t.message)
}
override fun onResponse(call: Call<List<Conversation>>, response: Response<List<Conversation>>) {
ioExecutor.execute {
db.runInTransaction {
db.conversationDao().deleteForAccount(accountId)
insertResultIntoDb(accountId, response.body())
}
// since we are in bg thread now, post the result.
networkState.postValue(NetworkState.LOADED)
}
}
}
)
return networkState
}
@MainThread
fun conversations(accountId: Long): Listing<ConversationEntity> {
// create a boundary callback which will observe when the user reaches to the edges of
// the list and update the database with extra data.
val boundaryCallback = ConversationsBoundaryCallback(
accountId = accountId,
mastodonApi = mastodonApi,
handleResponse = this::insertResultIntoDb,
ioExecutor = ioExecutor,
networkPageSize = DEFAULT_PAGE_SIZE)
// we are using a mutable live data to trigger refresh requests which eventually calls
// refresh method and gets a new live data. Each refresh request by the user becomes a newly
// dispatched data in refreshTrigger
val refreshTrigger = MutableLiveData<Unit>()
val refreshState = Transformations.switchMap(refreshTrigger) {
refresh(accountId, true)
}
// We use toLiveData Kotlin extension function here, you could also use LivePagedListBuilder
val livePagedList = db.conversationDao().conversationsForAccount(accountId).toLiveData(
config = Config(pageSize = DEFAULT_PAGE_SIZE, prefetchDistance = DEFAULT_PAGE_SIZE / 2, enablePlaceholders = false),
boundaryCallback = boundaryCallback
)
return Listing(
pagedList = livePagedList,
networkState = boundaryCallback.networkState,
retry = {
boundaryCallback.helper.retryAllFailed()
},
refresh = {
refreshTrigger.value = null
},
refreshState = refreshState
)
}
fun deleteCacheForAccount(accountId: Long) { fun deleteCacheForAccount(accountId: Long) {
Single.fromCallable { Single.fromCallable {
@ -101,11 +34,4 @@ class ConversationsRepository @Inject constructor(val mastodonApi: MastodonApi,
}.subscribeOn(Schedulers.io()) }.subscribeOn(Schedulers.io())
.subscribe() .subscribe()
} }
private fun insertResultIntoDb(accountId: Long, result: List<Conversation>?) {
result?.filter { it.lastStatus != null }
?.map{ it.toEntity(accountId) }
?.let { db.conversationDao().insert(it) }
}
} }

View file

@ -1,107 +1,100 @@
/* Copyright 2021 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.conversation package com.keylesspalace.tusky.components.conversation
import android.util.Log import android.util.Log
import androidx.lifecycle.LiveData import androidx.lifecycle.viewModelScope
import androidx.lifecycle.MutableLiveData import androidx.paging.ExperimentalPagingApi
import androidx.lifecycle.Transformations import androidx.paging.Pager
import androidx.paging.PagedList import androidx.paging.PagingConfig
import androidx.paging.cachedIn
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.network.TimelineCases import com.keylesspalace.tusky.network.TimelineCases
import com.keylesspalace.tusky.util.Listing
import com.keylesspalace.tusky.util.NetworkState
import com.keylesspalace.tusky.util.RxAwareViewModel import com.keylesspalace.tusky.util.RxAwareViewModel
import io.reactivex.schedulers.Schedulers import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.await
import javax.inject.Inject import javax.inject.Inject
class ConversationsViewModel @Inject constructor( class ConversationsViewModel @Inject constructor(
private val repository: ConversationsRepository,
private val timelineCases: TimelineCases, private val timelineCases: TimelineCases,
private val database: AppDatabase, private val database: AppDatabase,
private val accountManager: AccountManager private val accountManager: AccountManager,
private val api: MastodonApi
) : RxAwareViewModel() { ) : RxAwareViewModel() {
private val repoResult = MutableLiveData<Listing<ConversationEntity>>() @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<PagedList<ConversationEntity>> = Transformations.switchMap(repoResult) { it.pagedList } fun favourite(favourite: Boolean, conversation: ConversationEntity) {
val networkState: LiveData<NetworkState> = Transformations.switchMap(repoResult) { it.networkState } viewModelScope.launch {
val refreshState: LiveData<NetworkState> = Transformations.switchMap(repoResult) { it.refreshState } try {
timelineCases.favourite(conversation.lastStatus.id, favourite).await()
fun load() {
val accountId = accountManager.activeAccount?.id ?: return
if (repoResult.value == null) {
repository.refresh(accountId, false)
}
repoResult.value = repository.conversations(accountId)
}
fun refresh() {
repoResult.value?.refresh?.invoke()
}
fun retry() {
repoResult.value?.retry?.invoke()
}
fun favourite(favourite: Boolean, position: Int) {
conversations.value?.getOrNull(position)?.let { conversation ->
timelineCases.favourite(conversation.lastStatus.toStatus(), favourite)
.flatMap {
val newConversation = conversation.copy( val newConversation = conversation.copy(
lastStatus = conversation.lastStatus.copy(favourited = favourite) lastStatus = conversation.lastStatus.copy(favourited = favourite)
) )
database.conversationDao().insert(newConversation) database.conversationDao().insert(newConversation)
} catch (e: Exception) {
Log.w(TAG, "failed to favourite status", e)
}
} }
.subscribeOn(Schedulers.io())
.doOnError { t -> Log.w("ConversationViewModel", "Failed to favourite conversation", t) }
.onErrorReturnItem(0)
.subscribe()
.autoDispose()
} }
} fun bookmark(bookmark: Boolean, conversation: ConversationEntity) {
viewModelScope.launch {
try {
timelineCases.bookmark(conversation.lastStatus.id, bookmark).await()
fun bookmark(bookmark: Boolean, position: Int) {
conversations.value?.getOrNull(position)?.let { conversation ->
timelineCases.bookmark(conversation.lastStatus.toStatus(), bookmark)
.flatMap {
val newConversation = conversation.copy( val newConversation = conversation.copy(
lastStatus = conversation.lastStatus.copy(bookmarked = bookmark) lastStatus = conversation.lastStatus.copy(bookmarked = bookmark)
) )
database.conversationDao().insert(newConversation) database.conversationDao().insert(newConversation)
} catch (e: Exception) {
Log.w(TAG, "failed to bookmark status", e)
}
} }
.subscribeOn(Schedulers.io())
.doOnError { t -> Log.w("ConversationViewModel", "Failed to bookmark conversation", t) }
.onErrorReturnItem(0)
.subscribe()
.autoDispose()
} }
} fun voteInPoll(choices: List<Int>, conversation: ConversationEntity) {
viewModelScope.launch {
fun voteInPoll(position: Int, choices: MutableList<Int>) { try {
conversations.value?.getOrNull(position)?.let { conversation -> val poll = timelineCases.voteInPoll(conversation.lastStatus.id, conversation.lastStatus.poll?.id!!, choices).await()
timelineCases.voteInPoll(conversation.lastStatus.toStatus(), choices)
.flatMap { poll ->
val newConversation = conversation.copy( val newConversation = conversation.copy(
lastStatus = conversation.lastStatus.copy(poll = poll) lastStatus = conversation.lastStatus.copy(poll = poll)
) )
database.conversationDao().insert(newConversation) database.conversationDao().insert(newConversation)
} catch (e: Exception) {
Log.w(TAG, "failed to vote in poll", e)
}
} }
.subscribeOn(Schedulers.io())
.doOnError { t -> Log.w("ConversationViewModel", "Failed to favourite conversation", t) }
.onErrorReturnItem(0)
.subscribe()
.autoDispose()
} }
} fun expandHiddenStatus(expanded: Boolean, conversation: ConversationEntity) {
viewModelScope.launch {
fun expandHiddenStatus(expanded: Boolean, position: Int) {
conversations.value?.getOrNull(position)?.let { conversation ->
val newConversation = conversation.copy( val newConversation = conversation.copy(
lastStatus = conversation.lastStatus.copy(expanded = expanded) lastStatus = conversation.lastStatus.copy(expanded = expanded)
) )
@ -109,8 +102,8 @@ class ConversationsViewModel @Inject constructor(
} }
} }
fun collapseLongStatus(collapsed: Boolean, position: Int) { fun collapseLongStatus(collapsed: Boolean, conversation: ConversationEntity) {
conversations.value?.getOrNull(position)?.let { conversation -> viewModelScope.launch {
val newConversation = conversation.copy( val newConversation = conversation.copy(
lastStatus = conversation.lastStatus.copy(collapsed = collapsed) lastStatus = conversation.lastStatus.copy(collapsed = collapsed)
) )
@ -118,8 +111,8 @@ class ConversationsViewModel @Inject constructor(
} }
} }
fun showContent(showing: Boolean, position: Int) { fun showContent(showing: Boolean, conversation: ConversationEntity) {
conversations.value?.getOrNull(position)?.let { conversation -> viewModelScope.launch {
val newConversation = conversation.copy( val newConversation = conversation.copy(
lastStatus = conversation.lastStatus.copy(showingHiddenContent = showing) lastStatus = conversation.lastStatus.copy(showingHiddenContent = showing)
) )
@ -127,16 +120,42 @@ class ConversationsViewModel @Inject constructor(
} }
} }
fun remove(position: Int) { fun remove(conversation: ConversationEntity) {
conversations.value?.getOrNull(position)?.let { viewModelScope.launch {
refresh() try {
api.deleteConversation(conversationId = conversation.id)
database.conversationDao().delete(conversation)
} catch (e: Exception) {
Log.w(TAG, "failed to delete conversation", e)
}
} }
} }
private fun saveConversationToDb(conversation: ConversationEntity) { fun muteConversation(conversation: ConversationEntity) {
viewModelScope.launch {
try {
val newStatus = timelineCases.muteConversation(
conversation.lastStatus.id,
!conversation.lastStatus.muted
).await()
val newConversation = conversation.copy(
lastStatus = newStatus.toEntity()
)
database.conversationDao().insert(newConversation)
} catch (e: Exception) {
Log.w(TAG, "failed to mute conversation", e)
}
}
}
suspend fun saveConversationToDb(conversation: ConversationEntity) {
database.conversationDao().insert(conversation) database.conversationDao().insert(conversation)
.subscribeOn(Schedulers.io())
.subscribe()
} }
companion object {
private const val TAG = "ConversationsViewModel"
}
} }

View file

@ -28,13 +28,12 @@ import com.keylesspalace.tusky.db.DraftEntity
import com.keylesspalace.tusky.entity.NewPoll import com.keylesspalace.tusky.entity.NewPoll
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.util.IOUtils import com.keylesspalace.tusky.util.IOUtils
import io.reactivex.Completable import kotlinx.coroutines.Dispatchers
import io.reactivex.Observable import kotlinx.coroutines.withContext
import io.reactivex.Single
import io.reactivex.schedulers.Schedulers
import java.io.File import java.io.File
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.Date
import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
class DraftHelper @Inject constructor( class DraftHelper @Inject constructor(
@ -44,7 +43,7 @@ class DraftHelper @Inject constructor(
private val draftDao = db.draftDao() private val draftDao = db.draftDao()
fun saveDraft( suspend fun saveDraft(
draftId: Int, draftId: Int,
accountId: Long, accountId: Long,
inReplyToId: String?, inReplyToId: String?,
@ -56,9 +55,7 @@ class DraftHelper @Inject constructor(
mediaDescriptions: List<String?>, mediaDescriptions: List<String?>,
poll: NewPoll?, poll: NewPoll?,
failedToSend: Boolean failedToSend: Boolean
): Completable { ) = withContext(Dispatchers.IO) {
return Single.fromCallable {
val externalFilesDir = context.getExternalFilesDir("Tusky") val externalFilesDir = context.getExternalFilesDir("Tusky")
if (externalFilesDir == null || !(externalFilesDir.exists())) { if (externalFilesDir == null || !(externalFilesDir.exists())) {
@ -103,7 +100,7 @@ class DraftHelper @Inject constructor(
) )
} }
DraftEntity( val draft = DraftEntity(
id = draftId, id = draftId,
accountId = accountId, accountId = accountId,
inReplyToId = inReplyToId, inReplyToId = inReplyToId,
@ -116,40 +113,34 @@ class DraftHelper @Inject constructor(
failedToSend = failedToSend failedToSend = failedToSend
) )
}.flatMapCompletable { draft ->
draftDao.insertOrReplace(draft) draftDao.insertOrReplace(draft)
}.subscribeOn(Schedulers.io())
} }
fun deleteDraftAndAttachments(draftId: Int): Completable { suspend fun deleteDraftAndAttachments(draftId: Int) {
return draftDao.find(draftId) draftDao.find(draftId)?.let { draft ->
.flatMapCompletable { draft ->
deleteDraftAndAttachments(draft) deleteDraftAndAttachments(draft)
} }
} }
fun deleteDraftAndAttachments(draft: DraftEntity): Completable { suspend fun deleteDraftAndAttachments(draft: DraftEntity) {
return deleteAttachments(draft) deleteAttachments(draft)
.andThen(draftDao.delete(draft.id)) draftDao.delete(draft.id)
} }
fun deleteAllDraftsAndAttachmentsForAccount(accountId: Long) { suspend fun deleteAllDraftsAndAttachmentsForAccount(accountId: Long) {
draftDao.loadDraftsSingle(accountId) draftDao.loadDrafts(accountId).forEach { draft ->
.flatMapObservable { Observable.fromIterable(it) }
.flatMapCompletable { draft ->
deleteDraftAndAttachments(draft) deleteDraftAndAttachments(draft)
}.subscribeOn(Schedulers.io()) }
.subscribe()
} }
fun deleteAttachments(draft: DraftEntity): Completable { suspend fun deleteAttachments(draft: DraftEntity) {
return Completable.fromCallable { withContext(Dispatchers.IO) {
draft.attachments.forEach { attachment -> draft.attachments.forEach { attachment ->
if (context.contentResolver.delete(attachment.uri, null, null) == 0) { if (context.contentResolver.delete(attachment.uri, null, null) == 0) {
Log.e("DraftHelper", "Did not delete file ${attachment.uriString}") Log.e("DraftHelper", "Did not delete file ${attachment.uriString}")
} }
} }
}.subscribeOn(Schedulers.io()) }
} }
private fun Uri.isNotInFolder(folder: File): Boolean { private fun Uri.isNotInFolder(folder: File): Boolean {
@ -171,5 +162,4 @@ class DraftHelper @Inject constructor(
IOUtils.copyToFile(contentResolver, this, file) IOUtils.copyToFile(contentResolver, this, file)
return FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".fileprovider", file) return FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".fileprovider", file)
} }
} }

View file

@ -38,7 +38,6 @@ class DraftMediaAdapter(
override fun areContentsTheSame(oldItem: DraftAttachment, newItem: DraftAttachment): Boolean { override fun areContentsTheSame(oldItem: DraftAttachment, newItem: DraftAttachment): Boolean {
return oldItem == newItem return oldItem == newItem
} }
} }
) { ) {
@ -60,8 +59,8 @@ class DraftMediaAdapter(
} }
} }
inner class DraftMediaViewHolder(val imageView: ImageView) inner class DraftMediaViewHolder(val imageView: ImageView) :
: RecyclerView.ViewHolder(imageView) { RecyclerView.ViewHolder(imageView) {
init { init {
val thumbnailViewSize = val thumbnailViewSize =
imageView.context.resources.getDimensionPixelSize(R.dimen.compose_media_preview_size) imageView.context.resources.getDimensionPixelSize(R.dimen.compose_media_preview_size)

View file

@ -19,28 +19,26 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import android.view.Menu
import android.view.MenuItem
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.Toast import android.widget.Toast
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager 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.bottomsheet.BottomSheetBehavior
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.SavedTootActivity
import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.components.compose.ComposeActivity
import com.keylesspalace.tusky.databinding.ActivityDraftsBinding import com.keylesspalace.tusky.databinding.ActivityDraftsBinding
import com.keylesspalace.tusky.db.DraftEntity import com.keylesspalace.tusky.db.DraftEntity
import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.visible
import com.keylesspalace.tusky.util.show import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import com.uber.autodispose.android.lifecycle.autoDispose import kotlinx.coroutines.flow.collectLatest
import io.reactivex.android.schedulers.AndroidSchedulers import kotlinx.coroutines.launch
import io.reactivex.schedulers.Schedulers
import retrofit2.HttpException import retrofit2.HttpException
import javax.inject.Inject import javax.inject.Inject
@ -54,10 +52,7 @@ class DraftsActivity : BaseActivity(), DraftActionListener {
private lateinit var binding: ActivityDraftsBinding private lateinit var binding: ActivityDraftsBinding
private lateinit var bottomSheet: BottomSheetBehavior<LinearLayout> private lateinit var bottomSheet: BottomSheetBehavior<LinearLayout>
private var oldDraftsButton: MenuItem? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
binding = ActivityDraftsBinding.inflate(layoutInflater) binding = ActivityDraftsBinding.inflate(layoutInflater)
@ -70,7 +65,7 @@ class DraftsActivity : BaseActivity(), DraftActionListener {
setDisplayShowHomeEnabled(true) 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) val adapter = DraftsAdapter(this)
@ -80,44 +75,15 @@ class DraftsActivity : BaseActivity(), DraftActionListener {
bottomSheet = BottomSheetBehavior.from(binding.bottomSheet.root) bottomSheet = BottomSheetBehavior.from(binding.bottomSheet.root)
viewModel.drafts.observe(this) { draftList -> lifecycleScope.launch {
if (draftList.isEmpty()) { viewModel.drafts.collectLatest { draftData ->
binding.draftsRecyclerView.hide() adapter.submitData(draftData)
binding.draftsErrorMessageView.show()
} else {
binding.draftsRecyclerView.show()
binding.draftsErrorMessageView.hide()
adapter.submitList(draftList)
}
} }
} }
override fun onCreateOptionsMenu(menu: Menu): Boolean { adapter.addLoadStateListener {
menuInflater.inflate(R.menu.drafts, menu) binding.draftsErrorMessageView.visible(adapter.itemCount == 0)
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
}
}
return super.onOptionsItemSelected(item)
} }
override fun onOpenDraft(draft: DraftEntity) { override fun onOpenDraft(draft: DraftEntity) {
@ -126,8 +92,9 @@ class DraftsActivity : BaseActivity(), DraftActionListener {
bottomSheet.state = BottomSheetBehavior.STATE_COLLAPSED bottomSheet.state = BottomSheetBehavior.STATE_COLLAPSED
viewModel.getToot(draft.inReplyToId) viewModel.getToot(draft.inReplyToId)
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.autoDispose(this) .autoDispose(from(this))
.subscribe({ status -> .subscribe(
{ status ->
val composeOptions = ComposeActivity.ComposeOptions( val composeOptions = ComposeActivity.ComposeOptions(
draftId = draft.id, draftId = draft.id,
tootText = draft.content, tootText = draft.content,
@ -144,8 +111,8 @@ class DraftsActivity : BaseActivity(), DraftActionListener {
bottomSheet.state = BottomSheetBehavior.STATE_HIDDEN bottomSheet.state = BottomSheetBehavior.STATE_HIDDEN
startActivity(ComposeActivity.startIntent(this, composeOptions)) startActivity(ComposeActivity.startIntent(this, composeOptions))
},
}, { throwable -> { throwable ->
bottomSheet.state = BottomSheetBehavior.STATE_HIDDEN bottomSheet.state = BottomSheetBehavior.STATE_HIDDEN
@ -160,7 +127,8 @@ class DraftsActivity : BaseActivity(), DraftActionListener {
Snackbar.make(binding.root, getString(R.string.drafts_failed_loading_reply), Snackbar.LENGTH_SHORT) Snackbar.make(binding.root, getString(R.string.drafts_failed_loading_reply), Snackbar.LENGTH_SHORT)
.show() .show()
} }
}) }
)
} else { } else {
openDraftWithoutReply(draft) openDraftWithoutReply(draft)
} }

View file

@ -17,7 +17,7 @@ package com.keylesspalace.tusky.components.drafts
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.paging.PagedListAdapter import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
@ -35,7 +35,7 @@ interface DraftActionListener {
class DraftsAdapter( class DraftsAdapter(
private val listener: DraftActionListener private val listener: DraftActionListener
) : PagedListAdapter<DraftEntity, BindingHolder<ItemDraftBinding>>( ) : PagingDataAdapter<DraftEntity, BindingHolder<ItemDraftBinding>>(
object : DiffUtil.ItemCallback<DraftEntity>() { object : DiffUtil.ItemCallback<DraftEntity>() {
override fun areItemsTheSame(oldItem: DraftEntity, newItem: DraftEntity): Boolean { override fun areItemsTheSame(oldItem: DraftEntity, newItem: DraftEntity): Boolean {
return oldItem.id == newItem.id return oldItem.id == newItem.id
@ -87,6 +87,5 @@ class DraftsAdapter(
holder.binding.draftPoll.hide() holder.binding.draftPoll.hide()
} }
} }
} }
} }

View file

@ -16,14 +16,17 @@
package com.keylesspalace.tusky.components.drafts package com.keylesspalace.tusky.components.drafts
import androidx.lifecycle.ViewModel 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.AccountManager
import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.DraftEntity import com.keylesspalace.tusky.db.DraftEntity
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import io.reactivex.Observable import io.reactivex.rxjava3.core.Single
import io.reactivex.Single import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
class DraftsViewModel @Inject constructor( class DraftsViewModel @Inject constructor(
@ -33,37 +36,39 @@ class DraftsViewModel @Inject constructor(
val draftHelper: DraftHelper val draftHelper: DraftHelper
) : ViewModel() { ) : 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<DraftEntity> = mutableListOf() private val deletedDrafts: MutableList<DraftEntity> = mutableListOf()
fun showOldDraftsButton(): Observable<Boolean> {
return database.tootDao().savedTootCount()
.map { count -> count > 0 }
}
fun deleteDraft(draft: DraftEntity) { fun deleteDraft(draft: DraftEntity) {
// this does not immediately delete media files to avoid unnecessary file operations // this does not immediately delete media files to avoid unnecessary file operations
// in case the user decides to restore the draft // in case the user decides to restore the draft
viewModelScope.launch {
database.draftDao().delete(draft.id) database.draftDao().delete(draft.id)
.subscribe()
deletedDrafts.add(draft) deletedDrafts.add(draft)
} }
}
fun restoreDraft(draft: DraftEntity) { fun restoreDraft(draft: DraftEntity) {
viewModelScope.launch {
database.draftDao().insertOrReplace(draft) database.draftDao().insertOrReplace(draft)
.subscribe()
deletedDrafts.remove(draft) deletedDrafts.remove(draft)
} }
}
fun getToot(tootId: String): Single<Status> { fun getToot(tootId: String): Single<Status> {
return api.status(tootId) return api.status(tootId)
} }
override fun onCleared() { override fun onCleared() {
viewModelScope.launch {
deletedDrafts.forEach { deletedDrafts.forEach {
draftHelper.deleteAttachments(it).subscribe() draftHelper.deleteAttachments(it)
}
} }
} }
} }

View file

@ -17,7 +17,7 @@ class InstanceListActivity: BaseActivity(), HasAndroidInjector {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val binding = ActivityAccountListBinding.inflate(layoutInflater) val binding = ActivityAccountListBinding.inflate(layoutInflater)
setContentView(R.layout.activity_account_list) setContentView(binding.root)
setSupportActionBar(binding.includedToolbar.toolbar) setSupportActionBar(binding.includedToolbar.toolbar)
supportActionBar?.apply { supportActionBar?.apply {
@ -33,5 +33,4 @@ class InstanceListActivity: BaseActivity(), HasAndroidInjector {
} }
override fun androidInjector() = androidInjector override fun androidInjector() = androidInjector
} }

View file

@ -8,6 +8,8 @@ import androidx.lifecycle.Lifecycle
import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from
import autodispose2.autoDispose
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.instancemute.adapter.DomainMutesAdapter import com.keylesspalace.tusky.components.instancemute.adapter.DomainMutesAdapter
@ -20,9 +22,7 @@ import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.view.EndlessOnScrollListener import com.keylesspalace.tusky.view.EndlessOnScrollListener
import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import com.uber.autodispose.autoDispose
import io.reactivex.android.schedulers.AndroidSchedulers
import retrofit2.Call import retrofit2.Call
import retrofit2.Callback import retrofit2.Callback
import retrofit2.Response import retrofit2.Response
@ -114,7 +114,8 @@ class InstanceListFragment: Fragment(R.layout.fragment_instance_list), Injectabl
api.domainBlocks(id, bottomId) api.domainBlocks(id, bottomId)
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.autoDispose(from(this, Lifecycle.Event.ON_DESTROY)) .autoDispose(from(this, Lifecycle.Event.ON_DESTROY))
.subscribe({ response -> .subscribe(
{ response ->
val instances = response.body() val instances = response.body()
if (response.isSuccessful && instances != null) { if (response.isSuccessful && instances != null) {
@ -122,9 +123,11 @@ class InstanceListFragment: Fragment(R.layout.fragment_instance_list), Injectabl
} else { } else {
onFetchInstancesFailure(Exception(response.message())) onFetchInstancesFailure(Exception(response.message()))
} }
}, {throwable -> },
{ throwable ->
onFetchInstancesFailure(throwable) onFetchInstancesFailure(throwable)
}) }
)
} }
private fun onFetchInstancesSuccess(instances: List<String>, linkHeader: String?) { private fun onFetchInstancesSuccess(instances: List<String>, linkHeader: String?) {

View file

@ -1,5 +1,6 @@
package com.keylesspalace.tusky.components.notifications package com.keylesspalace.tusky.components.notifications
import android.content.Context
import android.util.Log import android.util.Log
import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
@ -12,7 +13,7 @@ import javax.inject.Inject
class NotificationFetcher @Inject constructor( class NotificationFetcher @Inject constructor(
private val mastodonApi: MastodonApi, private val mastodonApi: MastodonApi,
private val accountManager: AccountManager, private val accountManager: AccountManager,
private val notifier: Notifier private val context: Context
) { ) {
fun fetchAndShow() { fun fetchAndShow() {
for (account in accountManager.getAllAccountsOrderedByActive()) { for (account in accountManager.getAllAccountsOrderedByActive()) {
@ -20,7 +21,7 @@ class NotificationFetcher @Inject constructor(
try { try {
val notifications = fetchNotifications(account) val notifications = fetchNotifications(account)
notifications.forEachIndexed { index, notification -> notifications.forEachIndexed { index, notification ->
notifier.show(notification, account, index == 0) NotificationHelper.make(context, notification, account, index == 0)
} }
accountManager.saveAccount(account) accountManager.saveAccount(account)
} catch (e: Exception) { } catch (e: Exception) {

View file

@ -70,8 +70,8 @@ import java.util.List;
import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import io.reactivex.Single; import io.reactivex.rxjava3.core.Single;
import io.reactivex.schedulers.Schedulers; import io.reactivex.rxjava3.schedulers.Schedulers;
import static com.keylesspalace.tusky.viewdata.PollViewDataKt.buildDescription; import static com.keylesspalace.tusky.viewdata.PollViewDataKt.buildDescription;
@ -316,7 +316,7 @@ public class NotificationHelper {
Status actionableStatus = status.getActionableStatus(); Status actionableStatus = status.getActionableStatus();
Status.Visibility replyVisibility = actionableStatus.getVisibility(); Status.Visibility replyVisibility = actionableStatus.getVisibility();
String contentWarning = actionableStatus.getSpoilerText(); String contentWarning = actionableStatus.getSpoilerText();
Status.Mention[] mentions = actionableStatus.getMentions(); List<Status.Mention> mentions = actionableStatus.getMentions();
List<String> mentionedUsernames = new ArrayList<>(); List<String> mentionedUsernames = new ArrayList<>();
mentionedUsernames.add(actionableStatus.getAccount().getUsername()); mentionedUsernames.add(actionableStatus.getAccount().getUsername());
for (Status.Mention mention : mentions) { for (Status.Mention mention : mentions) {
@ -381,7 +381,6 @@ public class NotificationHelper {
NotificationChannelGroup channelGroup = new NotificationChannelGroup(account.getIdentifier(), account.getFullName()); NotificationChannelGroup channelGroup = new NotificationChannelGroup(account.getIdentifier(), account.getFullName());
//noinspection ConstantConditions
notificationManager.createNotificationChannelGroup(channelGroup); notificationManager.createNotificationChannelGroup(channelGroup);
for (int i = 0; i < channelIds.length; i++) { for (int i = 0; i < channelIds.length; i++) {
@ -660,9 +659,12 @@ public class NotificationHelper {
StringBuilder builder = new StringBuilder(notification.getStatus().getContent()); StringBuilder builder = new StringBuilder(notification.getStatus().getContent());
builder.append('\n'); builder.append('\n');
Poll poll = notification.getStatus().getPoll(); Poll poll = notification.getStatus().getPoll();
for(PollOption option: poll.getOptions()) { List<PollOption> options = poll.getOptions();
for(int i = 0; i < options.size(); ++i) {
PollOption option = options.get(i);
builder.append(buildDescription(option.getTitle(), builder.append(buildDescription(option.getTitle(),
PollViewDataKt.calculatePercent(option.getVotesCount(), poll.getVotersCount(), poll.getVotesCount()), PollViewDataKt.calculatePercent(option.getVotesCount(), poll.getVotersCount(), poll.getVotesCount()),
poll.getOwnVotes() != null && poll.getOwnVotes().contains(i),
context)); context));
builder.append('\n'); builder.append('\n');
} }

View file

@ -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)
}
}

View file

@ -22,7 +22,11 @@ import android.util.Log
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat
import com.google.android.material.snackbar.Snackbar 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.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.components.instancemute.InstanceListActivity 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.Filter
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.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.keylesspalace.tusky.util.ThemeUtils
import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
@ -75,8 +84,10 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
setOnPreferenceClickListener { setOnPreferenceClickListener {
val intent = Intent(context, TabPreferenceActivity::class.java) val intent = Intent(context, TabPreferenceActivity::class.java)
activity?.startActivity(intent) activity?.startActivity(intent)
activity?.overridePendingTransition(R.anim.slide_from_right, activity?.overridePendingTransition(
R.anim.slide_to_left) R.anim.slide_from_right,
R.anim.slide_to_left
)
true true
} }
} }
@ -88,8 +99,10 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
val intent = Intent(context, AccountListActivity::class.java) val intent = Intent(context, AccountListActivity::class.java)
intent.putExtra("type", AccountListActivity.Type.MUTES) intent.putExtra("type", AccountListActivity.Type.MUTES)
activity?.startActivity(intent) activity?.startActivity(intent)
activity?.overridePendingTransition(R.anim.slide_from_right, activity?.overridePendingTransition(
R.anim.slide_to_left) R.anim.slide_from_right,
R.anim.slide_to_left
)
true true
} }
} }
@ -104,8 +117,10 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
val intent = Intent(context, AccountListActivity::class.java) val intent = Intent(context, AccountListActivity::class.java)
intent.putExtra("type", AccountListActivity.Type.BLOCKS) intent.putExtra("type", AccountListActivity.Type.BLOCKS)
activity?.startActivity(intent) activity?.startActivity(intent)
activity?.overridePendingTransition(R.anim.slide_from_right, activity?.overridePendingTransition(
R.anim.slide_to_left) R.anim.slide_from_right,
R.anim.slide_to_left
)
true true
} }
} }
@ -116,8 +131,10 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
setOnPreferenceClickListener { setOnPreferenceClickListener {
val intent = Intent(context, InstanceListActivity::class.java) val intent = Intent(context, InstanceListActivity::class.java)
activity?.startActivity(intent) activity?.startActivity(intent)
activity?.overridePendingTransition(R.anim.slide_from_right, activity?.overridePendingTransition(
R.anim.slide_to_left) R.anim.slide_from_right,
R.anim.slide_to_left
)
true true
} }
} }
@ -201,8 +218,10 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
preference { preference {
setTitle(R.string.pref_title_public_filter_keywords) setTitle(R.string.pref_title_public_filter_keywords)
setOnPreferenceClickListener { setOnPreferenceClickListener {
launchFilterActivity(Filter.PUBLIC, launchFilterActivity(
R.string.pref_title_public_filter_keywords) Filter.PUBLIC,
R.string.pref_title_public_filter_keywords
)
true true
} }
} }
@ -226,8 +245,10 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
preference { preference {
setTitle(R.string.pref_title_thread_filter_keywords) setTitle(R.string.pref_title_thread_filter_keywords)
setOnPreferenceClickListener { setOnPreferenceClickListener {
launchFilterActivity(Filter.THREAD, launchFilterActivity(
R.string.pref_title_thread_filter_keywords) Filter.THREAD,
R.string.pref_title_thread_filter_keywords
)
true true
} }
} }
@ -255,7 +276,6 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
it.startActivity(intent) it.startActivity(intent)
it.overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left) it.overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left)
} }
} }
} }
@ -289,7 +309,6 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
Log.e("AccountPreferences", "failed updating settings on server", t) Log.e("AccountPreferences", "failed updating settings on server", t)
showErrorSnackbar(visibility, sensitive) showErrorSnackbar(visibility, sensitive)
} }
}) })
} }

View file

@ -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.EmojiCompatFont.Companion.TWEMOJI
import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.show
import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.Disposable import io.reactivex.rxjava3.disposables.Disposable
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import kotlin.system.exitProcess import kotlin.system.exitProcess
@ -124,8 +124,6 @@ class EmojiPreference(
finishDownload(font, binding) finishDownload(font, binding)
} }
).also { downloadDisposables[font.id] = it } ).also { downloadDisposables[font.id] = it }
} }
private fun cancelDownload(font: EmojiCompatFont, binding: ItemEmojiPrefBinding) { private fun cancelDownload(font: EmojiCompatFont, binding: ItemEmojiPrefBinding) {
@ -222,12 +220,14 @@ class EmojiPreference(
context, context,
0x1f973, // This is the codepoint of the party face emoji :D 0x1f973, // This is the codepoint of the party face emoji :D
launchIntent, launchIntent,
PendingIntent.FLAG_CANCEL_CURRENT) PendingIntent.FLAG_CANCEL_CURRENT
)
val mgr = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager val mgr = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
mgr.set( mgr.set(
AlarmManager.RTC, AlarmManager.RTC,
System.currentTimeMillis() + 100, System.currentTimeMillis() + 100,
mPendingIntent) mPendingIntent
)
exitProcess(0) exitProcess(0)
}.show() }.show()
} }

Some files were not shown because too many files have changed in this diff Show more