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```.
### Translation
Translations are done through https://weblate.tusky.app/projects/tusky/tusky/ .
To add a new language, clic on the 'Start a new translation' button on at the bottom of the page.
Translations are done through our [Weblate](https://weblate.tusky.app/projects/tusky/tusky/).
To add a new language, click on the 'Start a new translation' button on at the bottom of the page.
### Kotlin
This project is in the process of migrating to Kotlin, we prefer new code to be written in Kotlin. We try to follow the [Kotlin Style Guide](https://android.github.io/kotlin-guides/style.html) and make use of the [Kotlin Android Extensions](https://kotlinlang.org/docs/tutorials/android-plugin.html).
This project is in the process of migrating to Kotlin, all new code must be written in Kotlin.
We try to follow the [Kotlin Style Guide](https://developer.android.com/kotlin/style-guide) and make format the code according to the default [ktlint codestyle](https://github.com/pinterest/ktlint).
You can check the codestyle by running `./gradlew ktlintCheck`.
### Java
Existing code in Java should follow the [Android Style Guide](https://source.android.com/source/code-style), which is what Android uses for their own source code. ```@Nullable``` and ```@NotNull``` annotations are really helpful for Kotlin interoperability.
Existing code in Java should follow the [Android Style Guide](https://source.android.com/source/code-style), which is what Android uses for their own source code. ```@Nullable``` and ```@NotNull``` annotations are really helpful for Kotlin interoperability. Please don't submit new features written in Java.
### Viewbinding
We use [Viewbinding](https://developer.android.com/topic/libraries/view-binding) to reference views. No contribution using another mechanism will be accepted.
There are useful extensions in `src/main/java/com/keylesspalace/tusky/util/ViewExtensions.kt` that make working with viewbinding easier.
### Visuals
There are three themes in the app, so any visual changes should be checked with each of them to ensure they look appropriate no matter which theme is selected. Usually, you can use existing color attributes like ```?attr/colorPrimary``` and ```?attr/textColorSecondary```. For icons and drawables, use a white drawable and tint it at runtime using ```ThemeUtils``` and specify an attribute that references different colours depending on the theme.
There are three themes in the app, so any visual changes should be checked with each of them to ensure they look appropriate no matter which theme is selected. Usually, you can use existing color attributes like ```?attr/colorPrimary``` and ```?attr/textColorSecondary```.
### Saving
Any time you get a good chunk of work done it's good to make a commit. You can either uses Android Studio's built-in UI for doing this or running the commands:
@ -31,15 +37,15 @@ git commit -m "Describe the changes in this commit here."
```
## Submitting Your Changes
1. Make sure your branch is up-to-date with the ```master``` branch. Run:
1. Make sure your branch is up-to-date with the ```develop``` branch. Run:
```
git fetch
git rebase origin/master
git rebase origin/develop
```
It may refuse to start the rebase if there's changes that haven't been committed, so make sure you've added and committed everything. If there were changes on master to any of the parts of files you worked on, a conflict will arise when you rebase. [Resolving a merge conflict](https://help.github.com/articles/resolving-a-merge-conflict-using-the-command-line) is a good guide to help with this. After committing the resolution, you can run ```git rebase --continue``` to finish the rebase. If you want to cancel, like if you make some mistake in resolving the conflict, you can always do ```git rebase --abort```.
It may refuse to start the rebase if there's changes that haven't been committed, so make sure you've added and committed everything. If there were changes on develop to any of the parts of files you worked on, a conflict will arise when you rebase. [Resolving a merge conflict](https://help.github.com/articles/resolving-a-merge-conflict-using-the-command-line) is a good guide to help with this. After committing the resolution, you can run ```git rebase --continue``` to finish the rebase. If you want to cancel, like if you make some mistake in resolving the conflict, you can always do ```git rebase --abort```.
2. Push your local branch to your fork on GitHub by running ```git push origin your-change-name```.
3. Then, go to the original project page and make a pull request. Select your fork/branch and use ```master``` as the base branch.
3. Then, go to the original project page and make a pull request. Select your fork/branch and use ```develop``` as the base branch.
4. Wait for feedback on your pull request and be ready to make some changes
If you have any questions, don't hesitate to open an issue or contact [Tusky@mastodon.social](https://mastodon.social/@Tusky). Please also ask before you start implementing a new big feature.

View file

@ -15,13 +15,13 @@ def getGitSha = {
}
android {
compileSdkVersion 29
compileSdkVersion 30
defaultConfig {
applicationId APP_ID
minSdkVersion 21
targetSdkVersion 29
versionCode 81
versionName "15.1-CW1"
targetSdkVersion 30
versionCode 87
versionName "16.0-CW1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true
@ -35,7 +35,6 @@ android {
kapt {
arguments {
arg("room.schemaLocation", "$projectDir/schemas")
arg("room.incremental", "true")
}
}
}
@ -61,10 +60,6 @@ android {
lintOptions {
disable 'MissingTranslation'
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
buildFeatures {
viewBinding true
}
@ -89,70 +84,76 @@ android {
enableSplit = false
}
}
}
project.tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all {
kotlinOptions {
jvmTarget = "1.8"
freeCompilerArgs += "-opt-in=kotlin.RequiresOptIn"
}
}
ext.lifecycleVersion = "2.2.0"
ext.coroutinesVersion = "1.6.0"
ext.lifecycleVersion = "2.3.1"
ext.roomVersion = '2.3.0'
ext.retrofitVersion = '2.9.0'
ext.okhttpVersion = '4.9.0'
ext.glideVersion = '4.11.0'
ext.daggerVersion = '2.30.1'
ext.materialdrawerVersion = '8.2.0'
ext.okhttpVersion = '4.9.3'
ext.glideVersion = '4.12.0'
ext.daggerVersion = '2.40.5'
ext.materialdrawerVersion = '8.4.5'
// if libraries are changed here, they should also be changed in LicenseActivity
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation "androidx.core:core-ktx:1.3.2"
implementation "androidx.appcompat:appcompat:1.2.0"
implementation "androidx.fragment:fragment-ktx:1.2.5"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-rx3:$coroutinesVersion"
implementation "androidx.core:core-ktx:1.5.0"
implementation "androidx.appcompat:appcompat:1.3.0"
implementation "androidx.fragment:fragment-ktx:1.3.4"
implementation "androidx.browser:browser:1.3.0"
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
implementation "androidx.recyclerview:recyclerview:1.2.0"
implementation "androidx.exifinterface:exifinterface:1.3.2"
implementation "androidx.recyclerview:recyclerview:1.2.1"
implementation "androidx.exifinterface:exifinterface:1.3.3"
implementation "androidx.cardview:cardview:1.0.0"
implementation "androidx.preference:preference-ktx:1.1.1"
implementation "androidx.sharetarget:sharetarget:1.0.0"
implementation "androidx.sharetarget:sharetarget:1.1.0"
implementation "androidx.emoji:emoji:1.1.0"
implementation "androidx.emoji:emoji-appcompat:1.1.0"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-reactivestreams-ktx:$lifecycleVersion"
implementation "androidx.constraintlayout:constraintlayout:2.0.4"
implementation "androidx.paging:paging-runtime-ktx:2.1.2"
implementation "androidx.constraintlayout:constraintlayout:2.1.2"
implementation "androidx.paging:paging-runtime-ktx:3.0.0"
implementation "androidx.viewpager2:viewpager2:1.0.0"
implementation "androidx.work:work-runtime:2.4.0"
implementation "androidx.room:room-runtime:$roomVersion"
implementation "androidx.room:room-rxjava2:$roomVersion"
implementation "androidx.work:work-runtime:2.5.0"
implementation "androidx.room:room-ktx:$roomVersion"
implementation "androidx.room:room-rxjava3:$roomVersion"
kapt "androidx.room:room-compiler:$roomVersion"
implementation "com.google.android.material:material:1.3.0"
implementation "com.google.android.material:material:1.4.0"
implementation "com.google.code.gson:gson:2.8.9"
implementation "com.squareup.retrofit2:retrofit:$retrofitVersion"
implementation "com.squareup.retrofit2:converter-gson:$retrofitVersion"
implementation "com.squareup.retrofit2:adapter-rxjava2:$retrofitVersion"
implementation "com.squareup.retrofit2:adapter-rxjava3:$retrofitVersion"
implementation "com.squareup.okhttp3:okhttp:$okhttpVersion"
implementation "com.squareup.okhttp3:logging-interceptor:$okhttpVersion"
implementation "org.conscrypt:conscrypt-android:2.5.1"
implementation "org.conscrypt:conscrypt-android:2.5.2"
implementation "com.github.bumptech.glide:glide:$glideVersion"
implementation "com.github.bumptech.glide:okhttp3-integration:$glideVersion"
kapt "com.github.bumptech.glide:compiler:$glideVersion"
implementation "io.reactivex.rxjava2:rxjava:2.2.20"
implementation "io.reactivex.rxjava2:rxandroid:2.1.1"
implementation "io.reactivex.rxjava2:rxkotlin:2.4.0"
implementation "com.github.penfeizhou.android.animation:glide-plugin:2.17.0"
implementation "com.uber.autodispose:autodispose-android-archcomponents:1.4.0"
implementation "com.uber.autodispose:autodispose:1.4.0"
implementation "io.reactivex.rxjava3:rxjava:3.0.12"
implementation "io.reactivex.rxjava3:rxandroid:3.0.0"
implementation "io.reactivex.rxjava3:rxkotlin:3.0.1"
implementation "com.uber.autodispose2:autodispose-androidx-lifecycle:2.0.0"
implementation "com.uber.autodispose2:autodispose:2.0.0"
implementation "com.google.dagger:dagger:$daggerVersion"
kapt "com.google.dagger:dagger-compiler:$daggerVersion"
@ -166,18 +167,21 @@ dependencies {
implementation "com.mikepenz:materialdrawer:$materialdrawerVersion"
implementation "com.mikepenz:materialdrawer-iconics:$materialdrawerVersion"
implementation 'com.mikepenz:google-material-typeface:3.0.1.4.original-kotlin@aar'
implementation 'com.mikepenz:google-material-typeface:4.0.0.2-kotlin@aar'
implementation "com.theartofdev.edmodo:android-image-cropper:2.8.0"
implementation "com.github.CanHub:Android-Image-Cropper:3.1.0"
implementation "de.c1710:filemojicompat:1.0.17"
implementation "de.c1710:filemojicompat:1.0.18"
testImplementation "androidx.test.ext:junit:1.1.2"
testImplementation "androidx.test.ext:junit:1.1.3"
testImplementation "org.robolectric:robolectric:4.4"
testImplementation "org.mockito:mockito-inline:3.6.28"
testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0"
androidTestImplementation "androidx.test.espresso:espresso-core:3.3.0"
androidTestImplementation "androidx.test.espresso:espresso-core:3.4.0"
androidTestImplementation "androidx.room:room-testing:$roomVersion"
androidTestImplementation "androidx.test.ext:junit:1.1.2"
androidTestImplementation "androidx.test.ext:junit:1.1.3"
testImplementation "androidx.arch.core:core-testing:2.1.0"
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion"
}

View file

@ -33,6 +33,10 @@
public static final ** CREATOR;
}
-keepclassmembers class **.R$* {
public static <fields>;
}
# TUSKY SPECIFIC OPTIONS
# keep members of our model classes, they are used in json de/serialization
@ -43,10 +47,37 @@
public *;
}
-keepclassmembers class com.keylesspalace.tusky.components.conversation.ConversationAccountEntity { *; }
-keepclassmembers class com.keylesspalace.tusky.db.DraftAttachment { *; }
-keep enum com.keylesspalace.tusky.db.DraftAttachment$Type {
public *;
}
# https://github.com/google/gson/blob/master/examples/android-proguard-example/proguard.cfg
# Prevent proguard from stripping interface information from TypeAdapter, TypeAdapterFactory,
# JsonSerializer, JsonDeserializer instances (so they can be used in @JsonAdapter)
-keep class * extends com.google.gson.TypeAdapter
-keep class * implements com.google.gson.TypeAdapterFactory
-keep class * implements com.google.gson.JsonSerializer
-keep class * implements com.google.gson.JsonDeserializer
# Retain generic signatures of TypeToken and its subclasses with R8 version 3.0 and higher.
-keep,allowobfuscation,allowshrinking class com.google.gson.reflect.TypeToken
-keep,allowobfuscation,allowshrinking class * extends com.google.gson.reflect.TypeToken
# Retain generic signatures of classes used in MastodonApi so Retrofit works
-keep,allowobfuscation,allowshrinking class io.reactivex.rxjava3.core.Single
-keep,allowobfuscation,allowshrinking class retrofit2.Response
-keep,allowobfuscation,allowshrinking class kotlin.collections.List
-keep,allowobfuscation,allowshrinking class kotlin.collections.Map
-keep,allowobfuscation,allowshrinking class retrofit2.Call
# https://r8.googlesource.com/r8/+/refs/heads/master/compatibility-faq.md#retrofit
-keepattributes Signature
-keep class kotlin.coroutines.Continuation
# preserve line numbers for crash reporting
-keepattributes SourceFile,LineNumberTable
-renamesourcefileattribute SourceFile
@ -65,7 +96,14 @@
# remove some kotlin overhead
-assumenosideeffects class kotlin.jvm.internal.Intrinsics {
static void checkNotNull(java.lang.Object);
static void checkNotNull(java.lang.Object, java.lang.String);
static void checkParameterIsNotNull(java.lang.Object, java.lang.String);
static void checkParameterIsNotNull(java.lang.Object, java.lang.String);
static void checkNotNullParameter(java.lang.Object, java.lang.String);
static void checkExpressionValueIsNotNull(java.lang.Object, java.lang.String);
static void checkNotNullExpressionValue(java.lang.Object, java.lang.String);
static void checkReturnedValueIsNotNull(java.lang.Object, java.lang.String);
static void checkReturnedValueIsNotNull(java.lang.Object, java.lang.String, java.lang.String);
static void throwUninitializedPropertyAccessException(java.lang.String);
}

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

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

View file

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

View file

@ -64,9 +64,9 @@ class AccountListActivity : BaseActivity(), HasAndroidInjector {
}
supportFragmentManager
.beginTransaction()
.replace(R.id.fragment_container, AccountListFragment.newInstance(type, id, accountLocked))
.commit()
.beginTransaction()
.replace(R.id.fragment_container, AccountListFragment.newInstance(type, id, accountLocked))
.commit()
}
override fun androidInjector() = dispatchingAndroidInjector

View file

@ -28,18 +28,24 @@ import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.ListAdapter
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from
import autodispose2.autoDispose
import com.keylesspalace.tusky.databinding.FragmentAccountsInListBinding
import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.*
import com.keylesspalace.tusky.util.BindingHolder
import com.keylesspalace.tusky.util.Either
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.loadAvatar
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel
import com.keylesspalace.tusky.viewmodel.State
import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from
import com.uber.autodispose.autoDispose
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import java.io.IOException
import javax.inject.Inject
@ -93,19 +99,19 @@ class AccountsInListFragment : DialogFragment(), Injectable {
binding.accountsSearchRecycler.adapter = searchAdapter
viewModel.state
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(from(this))
.subscribe { state ->
adapter.submitList(state.accounts.asRightOrNull() ?: listOf())
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(from(this))
.subscribe { state ->
adapter.submitList(state.accounts.asRightOrNull() ?: listOf())
when (state.accounts) {
is Either.Right -> binding.messageView.hide()
is Either.Left -> handleError(state.accounts.value)
}
setupSearchView(state)
when (state.accounts) {
is Either.Right -> binding.messageView.hide()
is Either.Left -> handleError(state.accounts.value)
}
setupSearchView(state)
}
binding.searchView.isSubmitButtonEnabled = true
binding.searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean {
@ -146,11 +152,15 @@ class AccountsInListFragment : DialogFragment(), Injectable {
viewModel.load(listId)
}
if (error is IOException) {
binding.messageView.setup(R.drawable.elephant_offline,
R.string.error_network, retryAction)
binding.messageView.setup(
R.drawable.elephant_offline,
R.string.error_network, retryAction
)
} else {
binding.messageView.setup(R.drawable.elephant_error,
R.string.error_generic, retryAction)
binding.messageView.setup(
R.drawable.elephant_error,
R.string.error_generic, retryAction
)
}
}
@ -184,7 +194,7 @@ class AccountsInListFragment : DialogFragment(), Injectable {
onRemoveFromList(getItem(holder.bindingAdapterPosition).id)
}
binding.rejectButton.contentDescription =
binding.root.context.getString(R.string.action_remove_from_list)
binding.root.context.getString(R.string.action_remove_from_list)
return holder
}
@ -203,8 +213,8 @@ class AccountsInListFragment : DialogFragment(), Injectable {
}
override fun areContentsTheSame(oldItem: AccountInfo, newItem: AccountInfo): Boolean {
return oldItem.second == newItem.second
&& oldItem.first.deepEquals(newItem.first)
return oldItem.second == newItem.second &&
oldItem.first.deepEquals(newItem.first)
}
}

View file

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

View file

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

View file

@ -36,18 +36,24 @@ import androidx.recyclerview.widget.LinearLayoutManager
import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.FitCenter
import com.bumptech.glide.load.resource.bitmap.RoundedCorners
import com.canhub.cropper.CropImage
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.adapter.AccountFieldEditAdapter
import com.keylesspalace.tusky.databinding.ActivityEditProfileBinding
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.util.*
import com.keylesspalace.tusky.util.Error
import com.keylesspalace.tusky.util.Loading
import com.keylesspalace.tusky.util.Resource
import com.keylesspalace.tusky.util.Success
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.viewmodel.EditProfileViewModel
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizeDp
import com.theartofdev.edmodo.cropper.CropImage
import javax.inject.Inject
class EditProfileActivity : BaseActivity(), Injectable {
@ -110,11 +116,11 @@ class EditProfileActivity : BaseActivity(), Injectable {
binding.addFieldButton.setOnClickListener {
accountFieldEditAdapter.addField()
if(accountFieldEditAdapter.itemCount >= MAX_ACCOUNT_FIELDS) {
if (accountFieldEditAdapter.itemCount >= MAX_ACCOUNT_FIELDS) {
it.isVisible = false
}
binding.scrollView.post{
binding.scrollView.post {
binding.scrollView.smoothScrollTo(0, it.bottom)
}
}
@ -134,23 +140,22 @@ class EditProfileActivity : BaseActivity(), Injectable {
accountFieldEditAdapter.setFields(me.source?.fields ?: emptyList())
binding.addFieldButton.isEnabled = me.source?.fields?.size ?: 0 < MAX_ACCOUNT_FIELDS
if(viewModel.avatarData.value == null) {
if (viewModel.avatarData.value == null) {
Glide.with(this)
.load(me.avatar)
.placeholder(R.drawable.avatar_default)
.transform(
FitCenter(),
RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_80dp))
)
.into(binding.avatarPreview)
.load(me.avatar)
.placeholder(R.drawable.avatar_default)
.transform(
FitCenter(),
RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_80dp))
)
.into(binding.avatarPreview)
}
if(viewModel.headerData.value == null) {
if (viewModel.headerData.value == null) {
Glide.with(this)
.load(me.header)
.into(binding.headerPreview)
.load(me.header)
.into(binding.headerPreview)
}
}
}
is Error -> {
@ -159,19 +164,17 @@ class EditProfileActivity : BaseActivity(), Injectable {
viewModel.obtainProfile()
}
snackbar.show()
}
is Loading -> { }
}
}
viewModel.obtainInstance()
viewModel.instanceData.observe(this) { result ->
when (result) {
is Success -> {
val instance = result.data
if (instance?.maxBioChars != null && instance.maxBioChars > 0) {
binding.noteEditTextLayout.counterMaxLength = instance.maxBioChars
}
if (result is Success) {
val instance = result.data
if (instance?.maxBioChars != null && instance.maxBioChars > 0) {
binding.noteEditTextLayout.counterMaxLength = instance.maxBioChars
}
}
}
@ -179,20 +182,22 @@ class EditProfileActivity : BaseActivity(), Injectable {
observeImage(viewModel.avatarData, binding.avatarPreview, binding.avatarProgressBar, true)
observeImage(viewModel.headerData, binding.headerPreview, binding.headerProgressBar, false)
viewModel.saveData.observe(this, {
when(it) {
is Success -> {
finish()
}
is Loading -> {
binding.saveProgressBar.visibility = View.VISIBLE
}
is Error -> {
onSaveFailure(it.errorMessage)
viewModel.saveData.observe(
this,
{
when (it) {
is Success -> {
finish()
}
is Loading -> {
binding.saveProgressBar.visibility = View.VISIBLE
}
is Error -> {
onSaveFailure(it.errorMessage)
}
}
}
})
)
}
override fun onSaveInstanceState(outState: Bundle) {
@ -202,50 +207,56 @@ class EditProfileActivity : BaseActivity(), Injectable {
override fun onStop() {
super.onStop()
if(!isFinishing) {
viewModel.updateProfile(binding.displayNameEditText.text.toString(),
binding.noteEditText.text.toString(),
binding.lockedCheckBox.isChecked,
accountFieldEditAdapter.getFieldData())
if (!isFinishing) {
viewModel.updateProfile(
binding.displayNameEditText.text.toString(),
binding.noteEditText.text.toString(),
binding.lockedCheckBox.isChecked,
accountFieldEditAdapter.getFieldData()
)
}
}
private fun observeImage(liveData: LiveData<Resource<Bitmap>>,
imageView: ImageView,
progressBar: View,
roundedCorners: Boolean) {
liveData.observe(this, {
private fun observeImage(
liveData: LiveData<Resource<Bitmap>>,
imageView: ImageView,
progressBar: View,
roundedCorners: Boolean
) {
liveData.observe(
this,
{
when (it) {
is Success -> {
val glide = Glide.with(imageView)
when (it) {
is Success -> {
val glide = Glide.with(imageView)
.load(it.data)
if (roundedCorners) {
glide.transform(
FitCenter(),
RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_80dp))
)
}
if (roundedCorners) {
glide.transform(
FitCenter(),
RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_80dp))
)
}
glide.into(imageView)
glide.into(imageView)
imageView.show()
progressBar.hide()
}
is Loading -> {
progressBar.show()
}
is Error -> {
progressBar.hide()
if(!it.consumed) {
onResizeFailure()
it.consumed = true
imageView.show()
progressBar.hide()
}
is Loading -> {
progressBar.show()
}
is Error -> {
progressBar.hide()
if (!it.consumed) {
onResizeFailure()
it.consumed = true
}
}
}
}
})
)
}
private fun onMediaPick(pickType: PickType) {
@ -261,8 +272,12 @@ class EditProfileActivity : BaseActivity(), Injectable {
}
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>,
grantResults: IntArray) {
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
when (requestCode) {
PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE -> {
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
@ -307,14 +322,16 @@ class EditProfileActivity : BaseActivity(), Injectable {
private fun save() {
if (currentlyPicking != PickType.NOTHING) {
return
return
}
viewModel.save(binding.displayNameEditText.text.toString(),
binding.noteEditText.text.toString(),
binding.lockedCheckBox.isChecked,
accountFieldEditAdapter.getFieldData(),
this)
viewModel.save(
binding.displayNameEditText.text.toString(),
binding.noteEditText.text.toString(),
binding.lockedCheckBox.isChecked,
accountFieldEditAdapter.getFieldData(),
this
)
}
private fun onSaveFailure(msg: String?) {
@ -352,10 +369,10 @@ class EditProfileActivity : BaseActivity(), Injectable {
AVATAR_PICK_RESULT -> {
if (resultCode == Activity.RESULT_OK && data != null) {
CropImage.activity(data.data)
.setInitialCropWindowPaddingRatio(0f)
.setOutputCompressFormat(Bitmap.CompressFormat.PNG)
.setAspectRatio(AVATAR_SIZE, AVATAR_SIZE)
.start(this)
.setInitialCropWindowPaddingRatio(0f)
.setOutputCompressFormat(Bitmap.CompressFormat.PNG)
.setAspectRatio(AVATAR_SIZE, AVATAR_SIZE)
.start(this)
} else {
endMediaPicking()
}
@ -363,10 +380,10 @@ class EditProfileActivity : BaseActivity(), Injectable {
HEADER_PICK_RESULT -> {
if (resultCode == Activity.RESULT_OK && data != null) {
CropImage.activity(data.data)
.setInitialCropWindowPaddingRatio(0f)
.setOutputCompressFormat(Bitmap.CompressFormat.PNG)
.setAspectRatio(HEADER_WIDTH, HEADER_HEIGHT)
.start(this)
.setInitialCropWindowPaddingRatio(0f)
.setOutputCompressFormat(Bitmap.CompressFormat.PNG)
.setAspectRatio(HEADER_WIDTH, HEADER_HEIGHT)
.start(this)
} else {
endMediaPicking()
}
@ -374,7 +391,7 @@ class EditProfileActivity : BaseActivity(), Injectable {
CropImage.CROP_IMAGE_ACTIVITY_REQUEST_CODE -> {
val result = CropImage.getActivityResult(data)
when (resultCode) {
Activity.RESULT_OK -> beginResize(result.uri)
Activity.RESULT_OK -> beginResize(result?.uriContent)
CropImage.CROP_IMAGE_ACTIVITY_RESULT_ERROR_CODE -> onResizeFailure()
else -> endMediaPicking()
}
@ -382,7 +399,12 @@ class EditProfileActivity : BaseActivity(), Injectable {
}
}
private fun beginResize(uri: Uri) {
private fun beginResize(uri: Uri?) {
if (uri == null) {
currentlyPicking = PickType.NOTHING
return
}
beginMediaPicking()
when (currentlyPicking) {
@ -398,12 +420,10 @@ class EditProfileActivity : BaseActivity(), Injectable {
}
currentlyPicking = PickType.NOTHING
}
private fun onResizeFailure() {
Snackbar.make(binding.avatarButton, R.string.error_media_upload_sending, Snackbar.LENGTH_LONG).show()
endMediaPicking()
}
}

View file

@ -5,6 +5,7 @@ import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.lifecycleScope
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.databinding.ActivityFiltersBinding
@ -14,6 +15,8 @@ import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding
import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.await
import okhttp3.ResponseBody
import retrofit2.Call
import retrofit2.Callback
@ -21,7 +24,7 @@ import retrofit2.Response
import java.io.IOException
import javax.inject.Inject
class FiltersActivity: BaseActivity() {
class FiltersActivity : BaseActivity() {
@Inject
lateinit var api: MastodonApi
@ -30,7 +33,7 @@ class FiltersActivity: BaseActivity() {
private val binding by viewBinding(ActivityFiltersBinding::inflate)
private lateinit var context : String
private lateinit var context: String
private lateinit var filters: MutableList<Filter>
override fun onCreate(savedInstanceState: Bundle?) {
@ -54,7 +57,7 @@ class FiltersActivity: BaseActivity() {
private fun updateFilter(filter: Filter, itemIndex: Int) {
api.updateFilter(filter.id, filter.phrase, filter.context, filter.irreversible, filter.wholeWord, filter.expiresAt)
.enqueue(object: Callback<Filter>{
.enqueue(object : Callback<Filter> {
override fun onFailure(call: Call<Filter>, t: Throwable) {
Toast.makeText(this@FiltersActivity, "Error updating filter '${filter.phrase}'", Toast.LENGTH_SHORT).show()
}
@ -76,7 +79,7 @@ class FiltersActivity: BaseActivity() {
val filter = filters[itemIndex]
if (filter.context.size == 1) {
// This is the only context for this filter; delete it
api.deleteFilter(filters[itemIndex].id).enqueue(object: Callback<ResponseBody> {
api.deleteFilter(filters[itemIndex].id).enqueue(object : Callback<ResponseBody> {
override fun onFailure(call: Call<ResponseBody>, t: Throwable) {
Toast.makeText(this@FiltersActivity, "Error updating filter '${filters[itemIndex].phrase}'", Toast.LENGTH_SHORT).show()
}
@ -90,17 +93,19 @@ class FiltersActivity: BaseActivity() {
} else {
// Keep the filter, but remove it from this context
val oldFilter = filters[itemIndex]
val newFilter = Filter(oldFilter.id, oldFilter.phrase, oldFilter.context.filter { c -> c != context },
oldFilter.expiresAt, oldFilter.irreversible, oldFilter.wholeWord)
val newFilter = Filter(
oldFilter.id, oldFilter.phrase, oldFilter.context.filter { c -> c != context },
oldFilter.expiresAt, oldFilter.irreversible, oldFilter.wholeWord
)
updateFilter(newFilter, itemIndex)
}
}
private fun createFilter(phrase: String, wholeWord: Boolean) {
api.createFilter(phrase, listOf(context), false, wholeWord, "").enqueue(object: Callback<Filter> {
api.createFilter(phrase, listOf(context), false, wholeWord, "").enqueue(object : Callback<Filter> {
override fun onResponse(call: Call<Filter>, response: Response<Filter>) {
val filterResponse = response.body()
if(response.isSuccessful && filterResponse != null) {
if (response.isSuccessful && filterResponse != null) {
filters.add(filterResponse)
refreshFilterDisplay()
eventHub.dispatch(PreferenceChangedEvent(context))
@ -119,13 +124,13 @@ class FiltersActivity: BaseActivity() {
val binding = DialogFilterBinding.inflate(layoutInflater)
binding.phraseWholeWord.isChecked = true
AlertDialog.Builder(this@FiltersActivity)
.setTitle(R.string.filter_addition_dialog_title)
.setView(binding.root)
.setPositiveButton(android.R.string.ok){ _, _ ->
createFilter(binding.phraseEditText.text.toString(), binding.phraseWholeWord.isChecked)
}
.setNeutralButton(android.R.string.cancel, null)
.show()
.setTitle(R.string.filter_addition_dialog_title)
.setView(binding.root)
.setPositiveButton(android.R.string.ok) { _, _ ->
createFilter(binding.phraseEditText.text.toString(), binding.phraseWholeWord.isChecked)
}
.setNeutralButton(android.R.string.cancel, null)
.show()
}
private fun setupEditDialogForItem(itemIndex: Int) {
@ -135,19 +140,21 @@ class FiltersActivity: BaseActivity() {
binding.phraseWholeWord.isChecked = filter.wholeWord
AlertDialog.Builder(this@FiltersActivity)
.setTitle(R.string.filter_edit_dialog_title)
.setView(binding.root)
.setPositiveButton(R.string.filter_dialog_update_button) { _, _ ->
val oldFilter = filters[itemIndex]
val newFilter = Filter(oldFilter.id, binding.phraseEditText.text.toString(), oldFilter.context,
oldFilter.expiresAt, oldFilter.irreversible, binding.phraseWholeWord.isChecked)
updateFilter(newFilter, itemIndex)
}
.setNegativeButton(R.string.filter_dialog_remove_button) { _, _ ->
deleteFilter(itemIndex)
}
.setNeutralButton(android.R.string.cancel, null)
.show()
.setTitle(R.string.filter_edit_dialog_title)
.setView(binding.root)
.setPositiveButton(R.string.filter_dialog_update_button) { _, _ ->
val oldFilter = filters[itemIndex]
val newFilter = Filter(
oldFilter.id, binding.phraseEditText.text.toString(), oldFilter.context,
oldFilter.expiresAt, oldFilter.irreversible, binding.phraseWholeWord.isChecked
)
updateFilter(newFilter, itemIndex)
}
.setNegativeButton(R.string.filter_dialog_remove_button) { _, _ ->
deleteFilter(itemIndex)
}
.setNeutralButton(android.R.string.cancel, null)
.show()
}
private fun refreshFilterDisplay() {
@ -162,37 +169,33 @@ class FiltersActivity: BaseActivity() {
binding.addFilterButton.hide()
binding.filterProgressBar.show()
api.getFilters().enqueue(object : Callback<List<Filter>> {
override fun onResponse(call: Call<List<Filter>>, response: Response<List<Filter>>) {
val filterResponse = response.body()
if(response.isSuccessful && filterResponse != null) {
filters = filterResponse.filter { filter -> filter.context.contains(context) }.toMutableList()
refreshFilterDisplay()
binding.filtersView.show()
binding.addFilterButton.show()
binding.filterProgressBar.hide()
} else {
binding.filterProgressBar.hide()
binding.filterMessageView.show()
binding.filterMessageView.setup(R.drawable.elephant_error,
R.string.error_generic) { loadFilters() }
}
}
override fun onFailure(call: Call<List<Filter>>, t: Throwable) {
lifecycleScope.launch {
val newFilters = try {
api.getFilters().await()
} catch (t: Exception) {
binding.filterProgressBar.hide()
binding.filterMessageView.show()
if (t is IOException) {
binding.filterMessageView.setup(R.drawable.elephant_offline,
R.string.error_network) { loadFilters() }
binding.filterMessageView.setup(
R.drawable.elephant_offline,
R.string.error_network
) { loadFilters() }
} else {
binding.filterMessageView.setup(R.drawable.elephant_error,
R.string.error_generic) { loadFilters() }
binding.filterMessageView.setup(
R.drawable.elephant_error,
R.string.error_generic
) { loadFilters() }
}
return@launch
}
})
filters = newFilters.filter { it.context.contains(context) }.toMutableList()
refreshFilterDisplay()
binding.filtersView.show()
binding.addFilterButton.show()
binding.filterProgressBar.hide()
}
}
companion object {

View file

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

View file

@ -23,32 +23,48 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.*
import android.widget.EditText
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.PopupMenu
import android.widget.TextView
import androidx.activity.viewModels
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
import androidx.recyclerview.widget.*
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import at.connyduck.sparkbutton.helpers.Utils
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from
import autodispose2.autoDispose
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel
import com.keylesspalace.tusky.databinding.ActivityListsBinding
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.MastoList
import com.keylesspalace.tusky.fragment.TimelineFragment
import com.keylesspalace.tusky.util.*
import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.onTextChanged
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible
import com.keylesspalace.tusky.viewmodel.ListsViewModel
import com.keylesspalace.tusky.viewmodel.ListsViewModel.Event.*
import com.keylesspalace.tusky.viewmodel.ListsViewModel.LoadingState.*
import com.keylesspalace.tusky.viewmodel.ListsViewModel.Event
import com.keylesspalace.tusky.viewmodel.ListsViewModel.LoadingState.ERROR_NETWORK
import com.keylesspalace.tusky.viewmodel.ListsViewModel.LoadingState.ERROR_OTHER
import com.keylesspalace.tusky.viewmodel.ListsViewModel.LoadingState.INITIAL
import com.keylesspalace.tusky.viewmodel.ListsViewModel.LoadingState.LOADED
import com.keylesspalace.tusky.viewmodel.ListsViewModel.LoadingState.LOADING
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizeDp
import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from
import com.uber.autodispose.autoDispose
import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import javax.inject.Inject
/**
@ -84,12 +100,13 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
binding.listsRecycler.adapter = adapter
binding.listsRecycler.layoutManager = LinearLayoutManager(this)
binding.listsRecycler.addItemDecoration(
DividerItemDecoration(this, DividerItemDecoration.VERTICAL))
DividerItemDecoration(this, DividerItemDecoration.VERTICAL)
)
viewModel.state
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(from(this))
.subscribe(this::update)
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(from(this))
.subscribe(this::update)
viewModel.retryLoading()
binding.addListButton.setOnClickListener {
@ -97,15 +114,15 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
}
viewModel.events.observeOn(AndroidSchedulers.mainThread())
.autoDispose(from(this))
.subscribe { event ->
@Suppress("WHEN_ENUM_CAN_BE_NULL_IN_JAVA")
when (event) {
CREATE_ERROR -> showMessage(R.string.error_create_list)
RENAME_ERROR -> showMessage(R.string.error_rename_list)
DELETE_ERROR -> showMessage(R.string.error_delete_list)
}
.autoDispose(from(this))
.subscribe { event ->
@Suppress("WHEN_ENUM_CAN_BE_NULL_IN_JAVA")
when (event) {
Event.CREATE_ERROR -> showMessage(R.string.error_create_list)
Event.RENAME_ERROR -> showMessage(R.string.error_rename_list)
Event.DELETE_ERROR -> showMessage(R.string.error_delete_list)
}
}
}
private fun showlistNameDialog(list: MastoList?) {
@ -115,17 +132,18 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
layout.addView(editText)
val margin = Utils.dpToPx(this, 8)
(editText.layoutParams as ViewGroup.MarginLayoutParams)
.setMargins(margin, margin, margin, 0)
.setMargins(margin, margin, margin, 0)
val dialog = AlertDialog.Builder(this)
.setView(layout)
.setPositiveButton(
if (list == null) R.string.action_create_list
else R.string.action_rename_list) { _, _ ->
onPickedDialogName(editText.text, list?.id)
}
.setNegativeButton(android.R.string.cancel, null)
.show()
.setView(layout)
.setPositiveButton(
if (list == null) R.string.action_create_list
else R.string.action_rename_list
) { _, _ ->
onPickedDialogName(editText.text, list?.id)
}
.setNegativeButton(android.R.string.cancel, null)
.show()
val positiveButton = dialog.getButton(Dialog.BUTTON_POSITIVE)
editText.onTextChanged { s, _, _, _ ->
@ -137,15 +155,14 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
private fun showListDeleteDialog(list: MastoList) {
AlertDialog.Builder(this)
.setMessage(getString(R.string.dialog_delete_list_warning, list.title))
.setPositiveButton(R.string.action_delete){ _, _ ->
viewModel.deleteList(list.id)
}
.setNegativeButton(android.R.string.cancel, null)
.show()
.setMessage(getString(R.string.dialog_delete_list_warning, list.title))
.setPositiveButton(R.string.action_delete) { _, _ ->
viewModel.deleteList(list.id)
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
private fun update(state: ListsViewModel.State) {
adapter.submitList(state.lists)
binding.progressBar.visible(state.loadingState == LOADING)
@ -166,8 +183,10 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
LOADED ->
if (state.lists.isEmpty()) {
binding.messageView.show()
binding.messageView.setup(R.drawable.elephant_friend_empty, R.string.message_empty,
null)
binding.messageView.setup(
R.drawable.elephant_friend_empty, R.string.message_empty,
null
)
} else {
binding.messageView.hide()
}
@ -176,13 +195,14 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
private fun showMessage(@StringRes messageId: Int) {
Snackbar.make(
binding.listsRecycler, messageId, Snackbar.LENGTH_SHORT
binding.listsRecycler, messageId, Snackbar.LENGTH_SHORT
).show()
}
private fun onListSelected(listId: String) {
startActivityWithSlideInAnimation(
ModalTimelineActivity.newIntent(this, TimelineFragment.Kind.LIST, listId))
ModalTimelineActivity.newIntent(this, TimelineViewModel.Kind.LIST, listId)
)
}
private fun openListSettings(list: MastoList) {
@ -219,27 +239,28 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
}
}
private inner class ListsAdapter
: ListAdapter<MastoList, ListsAdapter.ListViewHolder>(ListsDiffer) {
private inner class ListsAdapter :
ListAdapter<MastoList, ListsAdapter.ListViewHolder>(ListsDiffer) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ListViewHolder {
return LayoutInflater.from(parent.context).inflate(R.layout.item_list, parent, false)
.let(this::ListViewHolder)
.apply {
val context = nameTextView.context
val iconColor = ThemeUtils.getColor(context, android.R.attr.textColorTertiary)
val icon = IconicsDrawable(context, GoogleMaterial.Icon.gmd_list).apply { sizeDp = 20; colorInt = iconColor }
.let(this::ListViewHolder)
.apply {
val context = nameTextView.context
val iconColor = ThemeUtils.getColor(context, android.R.attr.textColorTertiary)
val icon = IconicsDrawable(context, GoogleMaterial.Icon.gmd_list).apply { sizeDp = 20; colorInt = iconColor }
nameTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(icon, null, null, null)
}
nameTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(icon, null, null, null)
}
}
override fun onBindViewHolder(holder: ListViewHolder, position: Int) {
holder.nameTextView.text = getItem(position).title
}
private inner class ListViewHolder(view: View) : RecyclerView.ViewHolder(view),
View.OnClickListener {
private inner class ListViewHolder(view: View) :
RecyclerView.ViewHolder(view),
View.OnClickListener {
val nameTextView: TextView = view.findViewById(R.id.list_name_textview)
val moreButton: ImageButton = view.findViewById(R.id.editListButton)

View file

@ -34,7 +34,11 @@ import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.AccessToken
import com.keylesspalace.tusky.entity.AppCredentials
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.*
import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.getNonNullString
import com.keylesspalace.tusky.util.rickRoll
import com.keylesspalace.tusky.util.shouldRickRoll
import com.keylesspalace.tusky.util.viewBinding
import okhttp3.HttpUrl
import retrofit2.Call
import retrofit2.Callback
@ -62,29 +66,30 @@ class LoginActivity : BaseActivity(), Injectable {
setContentView(binding.root)
if(savedInstanceState == null && BuildConfig.CUSTOM_INSTANCE.isNotBlank() && !isAdditionalLogin()) {
if (savedInstanceState == null && BuildConfig.CUSTOM_INSTANCE.isNotBlank() && !isAdditionalLogin()) {
binding.domainEditText.setText(BuildConfig.CUSTOM_INSTANCE)
binding.domainEditText.setSelection(BuildConfig.CUSTOM_INSTANCE.length)
}
if(BuildConfig.CUSTOM_LOGO_URL.isNotBlank()) {
if (BuildConfig.CUSTOM_LOGO_URL.isNotBlank()) {
Glide.with(binding.loginLogo)
.load(BuildConfig.CUSTOM_LOGO_URL)
.placeholder(null)
.into(binding.loginLogo)
.load(BuildConfig.CUSTOM_LOGO_URL)
.placeholder(null)
.into(binding.loginLogo)
}
preferences = getSharedPreferences(
getString(R.string.preferences_file_key), Context.MODE_PRIVATE)
getString(R.string.preferences_file_key), Context.MODE_PRIVATE
)
binding.loginButton.setOnClickListener { onButtonClick() }
binding.registerButton.setOnClickListener { onRegisterClick() }
binding.whatsAnInstanceTextView.setOnClickListener {
val dialog = AlertDialog.Builder(this)
.setMessage(R.string.dialog_whats_an_instance)
.setPositiveButton(R.string.action_close, null)
.show()
.setMessage(R.string.dialog_whats_an_instance)
.setPositiveButton(R.string.action_close, null)
.show()
val textView = dialog.findViewById<TextView>(android.R.id.message)
textView?.movementMethod = LinkMovementMethod.getInstance()
}
@ -96,7 +101,6 @@ class LoginActivity : BaseActivity(), Injectable {
} else {
binding.toolbar.visibility = View.GONE
}
}
override fun requiresLogin(): Boolean {
@ -105,7 +109,7 @@ class LoginActivity : BaseActivity(), Injectable {
override fun finish() {
super.finish()
if(isAdditionalLogin()) {
if (isAdditionalLogin()) {
overridePendingTransition(R.anim.slide_from_left, R.anim.slide_to_right)
}
}
@ -150,8 +154,10 @@ class LoginActivity : BaseActivity(), Injectable {
}
val callback = object : Callback<AppCredentials> {
override fun onResponse(call: Call<AppCredentials>,
response: Response<AppCredentials>) {
override fun onResponse(
call: Call<AppCredentials>,
response: Response<AppCredentials>
) {
if (!response.isSuccessful) {
binding.loginButton.isEnabled = true
binding.domainTextInputLayout.error = getString(R.string.error_failed_app_registration)
@ -164,10 +170,10 @@ class LoginActivity : BaseActivity(), Injectable {
val clientSecret = credentials.clientSecret
preferences.edit()
.putString("domain", domain)
.putString("clientId", clientId)
.putString("clientSecret", clientSecret)
.apply()
.putString("domain", domain)
.putString("clientId", clientId)
.putString("clientSecret", clientSecret)
.apply()
redirectUserToAuthorizeAndLogin(domain, clientId)
}
@ -181,11 +187,12 @@ class LoginActivity : BaseActivity(), Injectable {
}
mastodonApi
.authenticateApp(domain, getString(R.string.app_name), oauthRedirectUri,
OAUTH_SCOPES, getString(R.string.tusky_website))
.enqueue(callback)
.authenticateApp(
domain, getString(R.string.app_name), oauthRedirectUri,
OAUTH_SCOPES, getString(R.string.tusky_website)
)
.enqueue(callback)
setLoading(true)
}
private fun redirectUserToAuthorizeAndLogin(domain: String, clientId: String) {
@ -193,10 +200,10 @@ class LoginActivity : BaseActivity(), Injectable {
* login there, and the server will redirect back to the app with its response. */
val endpoint = MastodonApi.ENDPOINT_AUTHORIZE
val parameters = mapOf(
"client_id" to clientId,
"redirect_uri" to oauthRedirectUri,
"response_type" to "code",
"scope" to OAUTH_SCOPES
"client_id" to clientId,
"redirect_uri" to oauthRedirectUri,
"response_type" to "code",
"scope" to OAUTH_SCOPES
)
val url = "https://" + domain + endpoint + "?" + toQueryString(parameters)
val uri = Uri.parse(url)
@ -240,31 +247,27 @@ class LoginActivity : BaseActivity(), Injectable {
} else {
setLoading(false)
binding.domainTextInputLayout.error = getString(R.string.error_retrieving_oauth_token)
Log.e(TAG, String.format("%s %s",
getString(R.string.error_retrieving_oauth_token),
response.message()))
Log.e(TAG, "%s %s".format(getString(R.string.error_retrieving_oauth_token), response.message()))
}
}
override fun onFailure(call: Call<AccessToken>, t: Throwable) {
setLoading(false)
binding.domainTextInputLayout.error = getString(R.string.error_retrieving_oauth_token)
Log.e(TAG, String.format("%s %s",
getString(R.string.error_retrieving_oauth_token),
t.message))
Log.e(TAG, "%s %s".format(getString(R.string.error_retrieving_oauth_token), t.message))
}
}
mastodonApi.fetchOAuthToken(domain, clientId, clientSecret, redirectUri, code,
"authorization_code").enqueue(callback)
mastodonApi.fetchOAuthToken(
domain, clientId, clientSecret, redirectUri, code,
"authorization_code"
).enqueue(callback)
} else if (error != null) {
/* Authorization failed. Put the error response where the user can read it and they
* can try again. */
setLoading(false)
binding.domainTextInputLayout.error = getString(R.string.error_authorization_denied)
Log.e(TAG, String.format("%s %s",
getString(R.string.error_authorization_denied),
error))
Log.e(TAG, "%s %s".format(getString(R.string.error_authorization_denied), error))
} else {
// This case means a junk response was received somehow.
setLoading(false)
@ -356,14 +359,14 @@ class LoginActivity : BaseActivity(), Injectable {
val navigationbarDividerColor = ThemeUtils.getColor(context, R.attr.dividerColor)
val colorSchemeParams = CustomTabColorSchemeParams.Builder()
.setToolbarColor(toolbarColor)
.setNavigationBarColor(navigationbarColor)
.setNavigationBarDividerColor(navigationbarDividerColor)
.build()
.setToolbarColor(toolbarColor)
.setNavigationBarColor(navigationbarColor)
.setNavigationBarDividerColor(navigationbarDividerColor)
.build()
val customTabsIntent = CustomTabsIntent.Builder()
.setDefaultColorSchemeParams(colorSchemeParams)
.build()
.setDefaultColorSchemeParams(colorSchemeParams)
.build()
try {
customTabsIntent.launchUrl(context, uri)

View file

@ -19,7 +19,10 @@ import android.content.Context
import android.content.DialogInterface
import android.content.Intent
import android.content.res.ColorStateList
import android.graphics.Bitmap
import android.graphics.Color
import android.graphics.drawable.Animatable
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.net.Uri
import android.os.Bundle
@ -31,24 +34,30 @@ import android.widget.ImageView
import androidx.appcompat.app.AlertDialog
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.content.ContextCompat
import androidx.core.content.edit
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.emoji.text.EmojiCompat
import androidx.emoji.text.EmojiCompat.InitCallback
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceManager
import androidx.viewpager2.widget.MarginPageTransformer
import autodispose2.androidx.lifecycle.autoDispose
import com.bumptech.glide.Glide
import com.bumptech.glide.RequestManager
import com.bumptech.glide.load.resource.bitmap.RoundedCorners
import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.target.FixedSizeDrawable
import com.bumptech.glide.request.transition.Transition
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayout.OnTabSelectedListener
import com.google.android.material.tabs.TabLayoutMediator
import com.keylesspalace.tusky.appstore.*
import com.keylesspalace.tusky.appstore.AnnouncementReadEvent
import com.keylesspalace.tusky.appstore.CacheUpdater
import com.keylesspalace.tusky.appstore.Event
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.MainTabsChangedEvent
import com.keylesspalace.tusky.appstore.ProfileEditedEvent
import com.keylesspalace.tusky.components.account.AccountActivity
import com.keylesspalace.tusky.components.announcements.AnnouncementsActivity
import com.keylesspalace.tusky.components.compose.ComposeActivity
import com.keylesspalace.tusky.components.compose.ComposeActivity.Companion.canHandleMimeType
@ -61,15 +70,20 @@ import com.keylesspalace.tusky.components.scheduled.ScheduledTootActivity
import com.keylesspalace.tusky.components.search.SearchActivity
import com.keylesspalace.tusky.databinding.ActivityMainBinding
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.fragment.SFragment
import com.keylesspalace.tusky.interfaces.AccountSelectionListener
import com.keylesspalace.tusky.interfaces.ActionButtonActivity
import com.keylesspalace.tusky.interfaces.ReselectableFragment
import com.keylesspalace.tusky.pager.MainPagerAdapter
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.*
import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.deleteStaleCachedMedia
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.removeShortcut
import com.keylesspalace.tusky.util.updateShortcut
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt
@ -78,15 +92,30 @@ import com.mikepenz.materialdrawer.holder.BadgeStyle
import com.mikepenz.materialdrawer.holder.ColorHolder
import com.mikepenz.materialdrawer.holder.StringHolder
import com.mikepenz.materialdrawer.iconics.iconicsIcon
import com.mikepenz.materialdrawer.model.*
import com.mikepenz.materialdrawer.model.interfaces.*
import com.mikepenz.materialdrawer.util.*
import com.mikepenz.materialdrawer.model.AbstractDrawerItem
import com.mikepenz.materialdrawer.model.DividerDrawerItem
import com.mikepenz.materialdrawer.model.PrimaryDrawerItem
import com.mikepenz.materialdrawer.model.ProfileDrawerItem
import com.mikepenz.materialdrawer.model.ProfileSettingDrawerItem
import com.mikepenz.materialdrawer.model.SecondaryDrawerItem
import com.mikepenz.materialdrawer.model.interfaces.IProfile
import com.mikepenz.materialdrawer.model.interfaces.descriptionRes
import com.mikepenz.materialdrawer.model.interfaces.descriptionText
import com.mikepenz.materialdrawer.model.interfaces.iconRes
import com.mikepenz.materialdrawer.model.interfaces.iconUrl
import com.mikepenz.materialdrawer.model.interfaces.nameRes
import com.mikepenz.materialdrawer.model.interfaces.nameText
import com.mikepenz.materialdrawer.util.AbstractDrawerImageLoader
import com.mikepenz.materialdrawer.util.DrawerImageLoader
import com.mikepenz.materialdrawer.util.addItems
import com.mikepenz.materialdrawer.util.addItemsAtPosition
import com.mikepenz.materialdrawer.util.updateBadge
import com.mikepenz.materialdrawer.widget.AccountHeaderView
import com.uber.autodispose.android.lifecycle.autoDispose
import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import kotlinx.coroutines.launch
import javax.inject.Inject
class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInjector {
@ -102,9 +131,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
@Inject
lateinit var conversationRepository: ConversationsRepository
@Inject
lateinit var appDb: AppDatabase
@Inject
lateinit var draftHelper: DraftHelper
@ -135,10 +161,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
super.onCreate(savedInstanceState)
val activeAccount = accountManager.activeAccount
if (activeAccount == null) {
// will be redirected to LoginActivity by BaseActivity
return
}
?: return // will be redirected to LoginActivity by BaseActivity
var showNotificationTab = false
if (intent != null) {
/** there are two possibilities the accountId can be passed to MainActivity:
@ -163,19 +187,22 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
forwardShare(intent)
} else {
// No account was provided, show the chooser
showAccountChooserDialog(getString(R.string.action_share_as), true, object : AccountSelectionListener {
override fun onAccountSelected(account: AccountEntity) {
val requestedId = account.id
if (requestedId == activeAccount.id) {
// The correct account is already active
forwardShare(intent)
} else {
// A different account was requested, restart the activity
intent.putExtra(NotificationHelper.ACCOUNT_ID, requestedId)
changeAccount(requestedId, intent)
showAccountChooserDialog(
getString(R.string.action_share_as), true,
object : AccountSelectionListener {
override fun onAccountSelected(account: AccountEntity) {
val requestedId = account.id
if (requestedId == activeAccount.id) {
// The correct account is already active
forwardShare(intent)
} else {
// A different account was requested, restart the activity
intent.putExtra(NotificationHelper.ACCOUNT_ID, requestedId)
changeAccount(requestedId, intent)
}
}
}
})
)
}
} else if (accountRequested && savedInstanceState == null) {
// user clicked a notification, show notification tab
@ -226,24 +253,23 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
NotificationHelper.disablePullNotifications(this)
}
eventHub.events
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
.subscribe { event: Event? ->
when (event) {
is ProfileEditedEvent -> onFetchUserInfoSuccess(event.newProfileData)
is MainTabsChangedEvent -> setupTabs(false)
is AnnouncementReadEvent -> {
unreadAnnouncementsCount--
updateAnnouncementsBadge()
}
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
.subscribe { event: Event? ->
when (event) {
is ProfileEditedEvent -> onFetchUserInfoSuccess(event.newProfileData)
is MainTabsChangedEvent -> setupTabs(false)
is AnnouncementReadEvent -> {
unreadAnnouncementsCount--
updateAnnouncementsBadge()
}
}
}
Schedulers.io().scheduleDirect {
// Flush old media that was cached for sharing
deleteStaleCachedMedia(applicationContext.getExternalFilesDir("Tusky"))
}
draftWarning()
}
override fun onResume() {
@ -331,12 +357,15 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
headerBackgroundScaleType = ImageView.ScaleType.CENTER_CROP
currentHiddenInList = true
onAccountHeaderListener = { _: View?, profile: IProfile, current: Boolean -> handleProfileClick(profile, current) }
addProfile(ProfileSettingDrawerItem().apply {
identifier = DRAWER_ITEM_ADD_ACCOUNT
nameRes = R.string.add_account_name
descriptionRes = R.string.add_account_description
iconicsIcon = GoogleMaterial.Icon.gmd_add
}, 0)
addProfile(
ProfileSettingDrawerItem().apply {
identifier = DRAWER_ITEM_ADD_ACCOUNT
nameRes = R.string.add_account_name
descriptionRes = R.string.add_account_description
iconicsIcon = GoogleMaterial.Icon.gmd_add
},
0
)
attachToSliderView(binding.mainDrawer)
dividerBelowHeader = false
closeDrawerOnProfileListClick = true
@ -350,13 +379,13 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
override fun set(imageView: ImageView, uri: Uri, placeholder: Drawable, tag: String?) {
if (animateAvatars) {
glide.load(uri)
.placeholder(placeholder)
.into(imageView)
.placeholder(placeholder)
.into(imageView)
} else {
glide.asBitmap()
.load(uri)
.placeholder(placeholder)
.into(imageView)
.load(uri)
.placeholder(placeholder)
.into(imageView)
}
}
@ -376,114 +405,116 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
binding.mainDrawer.apply {
tintStatusBar = true
addItems(
primaryDrawerItem {
nameRes = R.string.action_edit_profile
iconicsIcon = GoogleMaterial.Icon.gmd_person
onClick = {
val intent = Intent(context, EditProfileActivity::class.java)
startActivityWithSlideInAnimation(intent)
}
},
primaryDrawerItem {
nameRes = R.string.action_view_favourites
isSelectable = false
iconicsIcon = GoogleMaterial.Icon.gmd_star
onClick = {
val intent = StatusListActivity.newFavouritesIntent(context)
startActivityWithSlideInAnimation(intent)
}
},
primaryDrawerItem {
nameRes = R.string.action_view_bookmarks
iconicsIcon = GoogleMaterial.Icon.gmd_bookmark
onClick = {
val intent = StatusListActivity.newBookmarksIntent(context)
startActivityWithSlideInAnimation(intent)
}
},
primaryDrawerItem {
nameRes = R.string.action_view_follow_requests
iconicsIcon = GoogleMaterial.Icon.gmd_person_add
onClick = {
val intent = AccountListActivity.newIntent(context, AccountListActivity.Type.FOLLOW_REQUESTS, accountLocked = accountLocked)
startActivityWithSlideInAnimation(intent)
}
},
primaryDrawerItem {
nameRes = R.string.action_lists
iconicsIcon = GoogleMaterial.Icon.gmd_list
onClick = {
startActivityWithSlideInAnimation(ListsActivity.newIntent(context))
}
},
primaryDrawerItem {
nameRes = R.string.action_access_saved_toot
iconRes = R.drawable.ic_notebook
onClick = {
val intent = DraftsActivity.newIntent(context)
startActivityWithSlideInAnimation(intent)
}
},
primaryDrawerItem {
nameRes = R.string.action_access_scheduled_toot
iconRes = R.drawable.ic_access_time
onClick = {
startActivityWithSlideInAnimation(ScheduledTootActivity.newIntent(context))
}
},
primaryDrawerItem {
identifier = DRAWER_ITEM_ANNOUNCEMENTS
nameRes = R.string.title_announcements
iconRes = R.drawable.ic_bullhorn_24dp
onClick = {
startActivityWithSlideInAnimation(AnnouncementsActivity.newIntent(context))
}
badgeStyle = BadgeStyle().apply {
textColor = ColorHolder.fromColor(ThemeUtils.getColor(this@MainActivity, R.attr.colorOnPrimary))
color = ColorHolder.fromColor(ThemeUtils.getColor(this@MainActivity, R.attr.colorPrimary))
}
},
DividerDrawerItem(),
secondaryDrawerItem {
nameRes = R.string.action_view_account_preferences
iconRes = R.drawable.ic_account_settings
onClick = {
val intent = PreferencesActivity.newIntent(context, PreferencesActivity.ACCOUNT_PREFERENCES)
startActivityWithSlideInAnimation(intent)
}
},
secondaryDrawerItem {
nameRes = R.string.action_view_preferences
iconicsIcon = GoogleMaterial.Icon.gmd_settings
onClick = {
val intent = PreferencesActivity.newIntent(context, PreferencesActivity.GENERAL_PREFERENCES)
startActivityWithSlideInAnimation(intent)
}
},
secondaryDrawerItem {
nameRes = R.string.about_title_activity
iconicsIcon = GoogleMaterial.Icon.gmd_info
onClick = {
val intent = Intent(context, AboutActivity::class.java)
startActivityWithSlideInAnimation(intent)
}
},
secondaryDrawerItem {
nameRes = R.string.action_logout
iconRes = R.drawable.ic_logout
onClick = ::logout
primaryDrawerItem {
nameRes = R.string.action_edit_profile
iconicsIcon = GoogleMaterial.Icon.gmd_person
onClick = {
val intent = Intent(context, EditProfileActivity::class.java)
startActivityWithSlideInAnimation(intent)
}
},
primaryDrawerItem {
nameRes = R.string.action_view_favourites
isSelectable = false
iconicsIcon = GoogleMaterial.Icon.gmd_star
onClick = {
val intent = StatusListActivity.newFavouritesIntent(context)
startActivityWithSlideInAnimation(intent)
}
},
primaryDrawerItem {
nameRes = R.string.action_view_bookmarks
iconicsIcon = GoogleMaterial.Icon.gmd_bookmark
onClick = {
val intent = StatusListActivity.newBookmarksIntent(context)
startActivityWithSlideInAnimation(intent)
}
},
primaryDrawerItem {
nameRes = R.string.action_view_follow_requests
iconicsIcon = GoogleMaterial.Icon.gmd_person_add
onClick = {
val intent = AccountListActivity.newIntent(context, AccountListActivity.Type.FOLLOW_REQUESTS, accountLocked = accountLocked)
startActivityWithSlideInAnimation(intent)
}
},
primaryDrawerItem {
nameRes = R.string.action_lists
iconicsIcon = GoogleMaterial.Icon.gmd_list
onClick = {
startActivityWithSlideInAnimation(ListsActivity.newIntent(context))
}
},
primaryDrawerItem {
nameRes = R.string.action_access_drafts
iconRes = R.drawable.ic_notebook
onClick = {
val intent = DraftsActivity.newIntent(context)
startActivityWithSlideInAnimation(intent)
}
},
primaryDrawerItem {
nameRes = R.string.action_access_scheduled_toot
iconRes = R.drawable.ic_access_time
onClick = {
startActivityWithSlideInAnimation(ScheduledTootActivity.newIntent(context))
}
},
primaryDrawerItem {
identifier = DRAWER_ITEM_ANNOUNCEMENTS
nameRes = R.string.title_announcements
iconRes = R.drawable.ic_bullhorn_24dp
onClick = {
startActivityWithSlideInAnimation(AnnouncementsActivity.newIntent(context))
}
badgeStyle = BadgeStyle().apply {
textColor = ColorHolder.fromColor(ThemeUtils.getColor(this@MainActivity, R.attr.colorOnPrimary))
color = ColorHolder.fromColor(ThemeUtils.getColor(this@MainActivity, R.attr.colorPrimary))
}
},
DividerDrawerItem(),
secondaryDrawerItem {
nameRes = R.string.action_view_account_preferences
iconRes = R.drawable.ic_account_settings
onClick = {
val intent = PreferencesActivity.newIntent(context, PreferencesActivity.ACCOUNT_PREFERENCES)
startActivityWithSlideInAnimation(intent)
}
},
secondaryDrawerItem {
nameRes = R.string.action_view_preferences
iconicsIcon = GoogleMaterial.Icon.gmd_settings
onClick = {
val intent = PreferencesActivity.newIntent(context, PreferencesActivity.GENERAL_PREFERENCES)
startActivityWithSlideInAnimation(intent)
}
},
secondaryDrawerItem {
nameRes = R.string.about_title_activity
iconicsIcon = GoogleMaterial.Icon.gmd_info
onClick = {
val intent = Intent(context, AboutActivity::class.java)
startActivityWithSlideInAnimation(intent)
}
},
secondaryDrawerItem {
nameRes = R.string.action_logout
iconRes = R.drawable.ic_logout
onClick = ::logout
}
)
if (addSearchButton) {
binding.mainDrawer.addItemsAtPosition(4,
primaryDrawerItem {
nameRes = R.string.action_search
iconicsIcon = GoogleMaterial.Icon.gmd_search
onClick = {
startActivityWithSlideInAnimation(SearchActivity.getIntent(context))
}
})
binding.mainDrawer.addItemsAtPosition(
4,
primaryDrawerItem {
nameRes = R.string.action_search
iconicsIcon = GoogleMaterial.Icon.gmd_search
onClick = {
startActivityWithSlideInAnimation(SearchActivity.getIntent(context))
}
}
)
}
setSavedInstance(savedInstanceState)
@ -491,11 +522,11 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
if (BuildConfig.DEBUG) {
binding.mainDrawer.addItems(
secondaryDrawerItem {
nameText = "debug"
isEnabled = false
textColor = ColorStateList.valueOf(Color.GREEN)
}
secondaryDrawerItem {
nameText = "debug"
isEnabled = false
textColor = ColorStateList.valueOf(Color.GREEN)
}
)
}
EmojiCompat.get().registerInitCallback(emojiInitCallback)
@ -528,7 +559,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
activeTabLayout.removeAllTabs()
for (i in tabs.indices) {
val tab = activeTabLayout.newTab()
.setIcon(tabs[i].icon)
.setIcon(tabs[i].icon)
if (tabs[i].id == LIST) {
tab.contentDescription = tabs[i].arguments[1]
} else {
@ -580,31 +611,29 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
binding.mainToolbar.setOnClickListener {
(adapter.getFragment(activeTabLayout.selectedTabPosition) as? ReselectableFragment)?.onReselect()
}
}
private fun handleProfileClick(profile: IProfile, current: Boolean): Boolean {
val activeAccount = accountManager.activeAccount
//open profile when active image was clicked
// open profile when active image was clicked
if (current && activeAccount != null) {
val intent = AccountActivity.getIntent(this, activeAccount.accountId)
startActivityWithSlideInAnimation(intent)
return false
}
//open LoginActivity to add new account
// open LoginActivity to add new account
if (profile.identifier == DRAWER_ITEM_ADD_ACCOUNT) {
startActivityWithSlideInAnimation(LoginActivity.getIntent(this, true))
return false
}
//change Account
// change Account
changeAccount(profile.identifier, null)
return false
}
private fun changeAccount(newSelectedId: Long, forward: Intent?) {
cacheUpdater.stop()
SFragment.flushFilters()
accountManager.setActiveAccount(newSelectedId)
val intent = Intent(this, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
@ -621,49 +650,55 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
private fun logout() {
accountManager.activeAccount?.let { activeAccount ->
AlertDialog.Builder(this)
.setTitle(R.string.action_logout)
.setMessage(getString(R.string.action_logout_confirm, activeAccount.fullName))
.setPositiveButton(android.R.string.yes) { _: DialogInterface?, _: Int ->
NotificationHelper.deleteNotificationChannelsForAccount(activeAccount, this)
.setTitle(R.string.action_logout)
.setMessage(getString(R.string.action_logout_confirm, activeAccount.fullName))
.setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int ->
lifecycleScope.launch {
NotificationHelper.deleteNotificationChannelsForAccount(activeAccount, this@MainActivity)
cacheUpdater.clearForUser(activeAccount.id)
conversationRepository.deleteCacheForAccount(activeAccount.id)
draftHelper.deleteAllDraftsAndAttachmentsForAccount(activeAccount.id)
removeShortcut(this, activeAccount)
removeShortcut(this@MainActivity, activeAccount)
val newAccount = accountManager.logActiveAccountOut()
if (!NotificationHelper.areNotificationsEnabled(this, accountManager)) {
NotificationHelper.disablePullNotifications(this)
if (!NotificationHelper.areNotificationsEnabled(
this@MainActivity,
accountManager
)
) {
NotificationHelper.disablePullNotifications(this@MainActivity)
}
val intent = if (newAccount == null) {
LoginActivity.getIntent(this, false)
LoginActivity.getIntent(this@MainActivity, false)
} else {
Intent(this, MainActivity::class.java)
Intent(this@MainActivity, MainActivity::class.java)
}
startActivity(intent)
finishWithoutSlideOutAnimation()
}
.setNegativeButton(android.R.string.no, null)
.show()
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
}
private fun fetchUserInfo() {
mastodonApi.accountVerifyCredentials()
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
.subscribe(
{ userInfo ->
onFetchUserInfoSuccess(userInfo)
},
{ throwable ->
Log.e(TAG, "Failed to fetch user info. " + throwable.message)
}
)
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
.subscribe(
{ userInfo ->
onFetchUserInfoSuccess(userInfo)
},
{ throwable ->
Log.e(TAG, "Failed to fetch user info. " + throwable.message)
}
)
}
private fun onFetchUserInfoSuccess(me: Account) {
glide.asBitmap()
.load(me.header)
.into(header.accountHeaderBackground)
.load(me.header)
.into(header.accountHeaderBackground)
loadDrawerAvatar(me.avatar, false)
@ -679,49 +714,85 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
private fun loadDrawerAvatar(avatarUrl: String, showPlaceholder: Boolean) {
val navIconSize = resources.getDimensionPixelSize(R.dimen.avatar_toolbar_nav_icon_size)
glide.asDrawable()
.load(avatarUrl)
.transform(
val animateAvatars = preferences.getBoolean("animateGifAvatars", false)
if (animateAvatars) {
glide.asDrawable()
.load(avatarUrl)
.transform(
RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp))
)
.apply {
if (showPlaceholder) {
placeholder(R.drawable.avatar_default)
}
}
.into(object : CustomTarget<Drawable>(navIconSize, navIconSize) {
override fun onLoadStarted(placeholder: Drawable?) {
if (placeholder != null) {
binding.mainToolbar.navigationIcon = FixedSizeDrawable(placeholder, navIconSize, navIconSize)
)
.apply {
if (showPlaceholder) {
placeholder(R.drawable.avatar_default)
}
}
.into(object : CustomTarget<Drawable>(navIconSize, navIconSize) {
override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) {
binding.mainToolbar.navigationIcon = FixedSizeDrawable(resource, navIconSize, navIconSize)
}
override fun onLoadStarted(placeholder: Drawable?) {
if (placeholder != null) {
binding.mainToolbar.navigationIcon = FixedSizeDrawable(placeholder, navIconSize, navIconSize)
}
}
override fun onLoadCleared(placeholder: Drawable?) {
if (placeholder != null) {
binding.mainToolbar.navigationIcon = FixedSizeDrawable(placeholder, navIconSize, navIconSize)
override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) {
if (resource is Animatable) {
resource.start()
}
binding.mainToolbar.navigationIcon = FixedSizeDrawable(resource, navIconSize, navIconSize)
}
override fun onLoadCleared(placeholder: Drawable?) {
if (placeholder != null) {
binding.mainToolbar.navigationIcon = FixedSizeDrawable(placeholder, navIconSize, navIconSize)
}
}
})
} else {
glide.asBitmap()
.load(avatarUrl)
.transform(
RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp))
)
.apply {
if (showPlaceholder) {
placeholder(R.drawable.avatar_default)
}
}
})
.into(object : CustomTarget<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() {
mastodonApi.listAnnouncements(false)
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
.subscribe(
{ announcements ->
unreadAnnouncementsCount = announcements.count { !it.read }
updateAnnouncementsBadge()
},
{
Log.w(TAG, "Failed to fetch announcements.", it)
}
)
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
.subscribe(
{ announcements ->
unreadAnnouncementsCount = announcements.count { !it.read }
updateAnnouncementsBadge()
},
{
Log.w(TAG, "Failed to fetch announcements.", it)
}
)
}
private fun updateAnnouncementsBadge() {
@ -755,30 +826,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
header.setActiveProfile(accountManager.activeAccount!!.id)
}
private fun draftWarning() {
val sharedPrefsKey = "show_draft_warning"
appDb.tootDao().savedTootCount()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
.subscribe { draftCount ->
val showDraftWarning = preferences.getBoolean(sharedPrefsKey, true)
if (draftCount > 0 && showDraftWarning) {
AlertDialog.Builder(this)
.setMessage(R.string.new_drafts_warning)
.setNegativeButton("Don't show again") { _, _ ->
preferences.edit(commit = true) {
putBoolean(sharedPrefsKey, false)
}
}
.setPositiveButton(android.R.string.ok, null)
.show()
}
}
}
override fun getActionButton(): FloatingActionButton? = binding.composeButton
override fun getActionButton() = binding.composeButton
override fun androidInjector() = androidInjector
@ -792,20 +840,20 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
private inline fun primaryDrawerItem(block: PrimaryDrawerItem.() -> Unit): PrimaryDrawerItem {
return PrimaryDrawerItem()
.apply {
isSelectable = false
isIconTinted = true
}
.apply(block)
.apply {
isSelectable = false
isIconTinted = true
}
.apply(block)
}
private inline fun secondaryDrawerItem(block: SecondaryDrawerItem.() -> Unit): SecondaryDrawerItem {
return SecondaryDrawerItem()
.apply {
isSelectable = false
isIconTinted = true
}
.apply(block)
.apply {
isSelectable = false
isIconTinted = true
}
.apply(block)
}
private var AbstractDrawerItem<*, *>.onClick: () -> Unit

View file

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

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.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import com.keylesspalace.tusky.components.notifications.NotificationHelper
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.components.notifications.NotificationHelper
import javax.inject.Inject
class SplashActivity : AppCompatActivity(), Injectable {
@ -46,5 +45,4 @@ class SplashActivity : AppCompatActivity(), Injectable {
startActivity(intent)
finish()
}
}

View file

@ -19,15 +19,12 @@ import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.fragment.app.commit
import com.keylesspalace.tusky.components.timeline.TimelineFragment
import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel.Kind
import com.keylesspalace.tusky.databinding.ActivityStatuslistBinding
import com.keylesspalace.tusky.fragment.TimelineFragment
import com.keylesspalace.tusky.fragment.TimelineFragment.Kind
import javax.inject.Inject
import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
import javax.inject.Inject
class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
@ -44,7 +41,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
setSupportActionBar(binding.includedToolbar.toolbar)
val title = if(kind == Kind.FAVOURITES) {
val title = if (kind == Kind.FAVOURITES) {
R.string.title_favourites
} else {
R.string.title_bookmarks
@ -60,7 +57,6 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
val fragment = TimelineFragment.newInstance(kind)
replace(R.id.fragment_container, fragment)
}
}
override fun androidInjector() = dispatchingAndroidInjector
@ -71,15 +67,14 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
@JvmStatic
fun newFavouritesIntent(context: Context) =
Intent(context, StatusListActivity::class.java).apply {
putExtra(EXTRA_KIND, Kind.FAVOURITES.name)
}
Intent(context, StatusListActivity::class.java).apply {
putExtra(EXTRA_KIND, Kind.FAVOURITES.name)
}
@JvmStatic
fun newBookmarksIntent(context: Context) =
Intent(context, StatusListActivity::class.java).apply {
putExtra(EXTRA_KIND, Kind.BOOKMARKS.name)
}
Intent(context, StatusListActivity::class.java).apply {
putExtra(EXTRA_KIND, Kind.BOOKMARKS.name)
}
}
}

View file

@ -20,8 +20,9 @@ import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.fragment.app.Fragment
import com.keylesspalace.tusky.components.conversation.ConversationsFragment
import com.keylesspalace.tusky.components.timeline.TimelineFragment
import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel
import com.keylesspalace.tusky.fragment.NotificationsFragment
import com.keylesspalace.tusky.fragment.TimelineFragment
/** this would be a good case for a sealed class, but that does not work nice with Room */
@ -33,71 +34,72 @@ const val DIRECT = "Direct"
const val HASHTAG = "Hashtag"
const val LIST = "List"
data class TabData(val id: String,
@StringRes val text: Int,
@DrawableRes val icon: Int,
val fragment: (List<String>) -> Fragment,
val arguments: List<String> = emptyList(),
val title: (Context) -> String = { context -> context.getString(text)}
)
data class TabData(
val id: String,
@StringRes val text: Int,
@DrawableRes val icon: Int,
val fragment: (List<String>) -> Fragment,
val arguments: List<String> = emptyList(),
val title: (Context) -> String = { context -> context.getString(text) }
)
fun createTabDataFromId(id: String, arguments: List<String> = emptyList()): TabData {
return when (id) {
HOME -> TabData(
HOME,
R.string.title_home,
R.drawable.ic_home_24dp,
{ TimelineFragment.newInstance(TimelineFragment.Kind.HOME) }
HOME,
R.string.title_home,
R.drawable.ic_home_24dp,
{ TimelineFragment.newInstance(TimelineViewModel.Kind.HOME) }
)
NOTIFICATIONS -> TabData(
NOTIFICATIONS,
R.string.title_notifications,
R.drawable.ic_notifications_24dp,
{ NotificationsFragment.newInstance() }
NOTIFICATIONS,
R.string.title_notifications,
R.drawable.ic_notifications_24dp,
{ NotificationsFragment.newInstance() }
)
LOCAL -> TabData(
LOCAL,
R.string.title_public_local,
R.drawable.ic_local_24dp,
{ TimelineFragment.newInstance(TimelineFragment.Kind.PUBLIC_LOCAL) }
LOCAL,
R.string.title_public_local,
R.drawable.ic_local_24dp,
{ TimelineFragment.newInstance(TimelineViewModel.Kind.PUBLIC_LOCAL) }
)
FEDERATED -> TabData(
FEDERATED,
R.string.title_public_federated,
R.drawable.ic_public_24dp,
{ TimelineFragment.newInstance(TimelineFragment.Kind.PUBLIC_FEDERATED) }
FEDERATED,
R.string.title_public_federated,
R.drawable.ic_public_24dp,
{ TimelineFragment.newInstance(TimelineViewModel.Kind.PUBLIC_FEDERATED) }
)
DIRECT -> TabData(
DIRECT,
R.string.title_direct_messages,
R.drawable.ic_reblog_direct_24dp,
{ ConversationsFragment.newInstance() }
DIRECT,
R.string.title_direct_messages,
R.drawable.ic_reblog_direct_24dp,
{ ConversationsFragment.newInstance() }
)
HASHTAG -> TabData(
HASHTAG,
R.string.hashtags,
R.drawable.ic_hashtag,
{ args -> TimelineFragment.newHashtagInstance(args) },
arguments,
{ context -> arguments.joinToString(separator = " ") { context.getString(R.string.title_tag, it) }}
HASHTAG,
R.string.hashtags,
R.drawable.ic_hashtag,
{ args -> TimelineFragment.newHashtagInstance(args) },
arguments,
{ context -> arguments.joinToString(separator = " ") { context.getString(R.string.title_tag, it) } }
)
LIST -> TabData(
LIST,
R.string.list,
R.drawable.ic_list,
{ args -> TimelineFragment.newInstance(TimelineFragment.Kind.LIST, args.getOrNull(0).orEmpty()) },
arguments,
{ arguments.getOrNull(1).orEmpty() }
)
LIST,
R.string.list,
R.drawable.ic_list,
{ args -> TimelineFragment.newInstance(TimelineViewModel.Kind.LIST, args.getOrNull(0).orEmpty()) },
arguments,
{ arguments.getOrNull(1).orEmpty() }
)
else -> throw IllegalArgumentException("unknown tab type")
}
}
fun defaultTabs(): List<TabData> {
return listOf(
createTabDataFromId(HOME),
createTabDataFromId(NOTIFICATIONS),
createTabDataFromId(LOCAL),
createTabDataFromId(FEDERATED)
createTabDataFromId(HOME),
createTabDataFromId(NOTIFICATIONS),
createTabDataFromId(LOCAL),
createTabDataFromId(FEDERATED)
)
}

View file

@ -31,6 +31,8 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.transition.TransitionManager
import at.connyduck.sparkbutton.helpers.Utils
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from
import autodispose2.autoDispose
import com.google.android.material.transition.MaterialArcMotion
import com.google.android.material.transition.MaterialContainerTransform
import com.keylesspalace.tusky.adapter.ItemInteractionListener
@ -44,11 +46,9 @@ import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.onTextChanged
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible
import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from
import com.uber.autodispose.autoDispose
import io.reactivex.Single
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import java.util.regex.Pattern
import javax.inject.Inject
@ -221,26 +221,26 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
frameLayout.addView(editText)
val dialog = AlertDialog.Builder(this)
.setTitle(R.string.add_hashtag_title)
.setView(frameLayout)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.action_save) { _, _ ->
val input = editText.text.toString().trim()
if (tab == null) {
val newTab = createTabDataFromId(HASHTAG, listOf(input))
currentTabs.add(newTab)
currentTabsAdapter.notifyItemInserted(currentTabs.size - 1)
} else {
val newTab = tab.copy(arguments = tab.arguments + input)
currentTabs[tabPosition] = newTab
.setTitle(R.string.add_hashtag_title)
.setView(frameLayout)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.action_save) { _, _ ->
val input = editText.text.toString().trim()
if (tab == null) {
val newTab = createTabDataFromId(HASHTAG, listOf(input))
currentTabs.add(newTab)
currentTabsAdapter.notifyItemInserted(currentTabs.size - 1)
} else {
val newTab = tab.copy(arguments = tab.arguments + input)
currentTabs[tabPosition] = newTab
currentTabsAdapter.notifyItemChanged(tabPosition)
}
updateAvailableTabs()
saveTabs()
currentTabsAdapter.notifyItemChanged(tabPosition)
}
.create()
updateAvailableTabs()
saveTabs()
}
.create()
editText.onTextChanged { s, _, _, _ ->
dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = validateHashtag(s)
@ -254,28 +254,28 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
private fun showSelectListDialog() {
val adapter = ListSelectionAdapter(this)
mastodonApi.getLists()
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(from(this, Lifecycle.Event.ON_DESTROY))
.subscribe (
{ lists ->
adapter.addAll(lists)
},
{ throwable ->
Log.e("TabPreferenceActivity", "failed to load lists", throwable)
}
)
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(from(this, Lifecycle.Event.ON_DESTROY))
.subscribe(
{ lists ->
adapter.addAll(lists)
},
{ throwable ->
Log.e("TabPreferenceActivity", "failed to load lists", throwable)
}
)
AlertDialog.Builder(this)
.setTitle(R.string.select_list_title)
.setAdapter(adapter) { _, position ->
val list = adapter.getItem(position)
val newTab = createTabDataFromId(LIST, listOf(list!!.id, list.title))
currentTabs.add(newTab)
currentTabsAdapter.notifyItemInserted(currentTabs.size - 1)
updateAvailableTabs()
saveTabs()
}
.show()
.setTitle(R.string.select_list_title)
.setAdapter(adapter) { _, position ->
val list = adapter.getItem(position)
val newTab = createTabDataFromId(LIST, listOf(list!!.id, list.title))
currentTabs.add(newTab)
currentTabsAdapter.notifyItemInserted(currentTabs.size - 1)
updateAvailableTabs()
saveTabs()
}
.show()
}
private fun validateHashtag(input: CharSequence?): Boolean {
@ -330,10 +330,9 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
it.tabPreferences = currentTabs
accountManager.saveAccount(it)
}
.subscribeOn(Schedulers.io())
.autoDispose(from(this, Lifecycle.Event.ON_DESTROY))
.subscribe()
.subscribeOn(Schedulers.io())
.autoDispose(from(this, Lifecycle.Event.ON_DESTROY))
.subscribe()
}
tabsChanged = true
}
@ -357,5 +356,4 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
private const val MIN_TAB_COUNT = 2
private const val MAX_TAB_COUNT = 5
}
}

View file

@ -22,16 +22,16 @@ import android.util.Log
import androidx.emoji.text.EmojiCompat
import androidx.preference.PreferenceManager
import androidx.work.WorkManager
import autodispose2.AutoDisposePlugins
import com.keylesspalace.tusky.components.notifications.NotificationWorkerFactory
import com.keylesspalace.tusky.di.AppInjector
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.EmojiCompatFont
import com.keylesspalace.tusky.util.LocaleManager
import com.keylesspalace.tusky.util.ThemeUtils
import com.uber.autodispose.AutoDisposePlugins
import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
import io.reactivex.plugins.RxJavaPlugins
import io.reactivex.rxjava3.plugins.RxJavaPlugins
import org.conscrypt.Conscrypt
import java.security.Security
import javax.inject.Inject
@ -68,8 +68,8 @@ class TuskyApplication : Application(), HasAndroidInjector {
// init the custom emoji fonts
val emojiSelection = preferences.getInt(PrefKeys.EMOJI, 0)
val emojiConfig = EmojiCompatFont.byId(emojiSelection)
.getConfig(this)
.setReplaceAll(true)
.getConfig(this)
.setReplaceAll(true)
EmojiCompat.init(emojiConfig)
// init night mode
@ -81,10 +81,10 @@ class TuskyApplication : Application(), HasAndroidInjector {
}
WorkManager.initialize(
this,
androidx.work.Configuration.Builder()
.setWorkerFactory(notificationWorkerFactory)
.build()
this,
androidx.work.Configuration.Builder()
.setWorkerFactory(notificationWorkerFactory)
.build()
)
}

View file

@ -41,27 +41,27 @@ import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.Lifecycle
import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.viewpager2.widget.ViewPager2
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider
import autodispose2.autoDispose
import com.bumptech.glide.Glide
import com.bumptech.glide.request.FutureTarget
import com.keylesspalace.tusky.BuildConfig.APPLICATION_ID
import com.keylesspalace.tusky.databinding.ActivityViewMediaBinding
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.fragment.ViewImageFragment
import com.keylesspalace.tusky.pager.SingleImagePagerAdapter
import com.keylesspalace.tusky.pager.ImagePagerAdapter
import com.keylesspalace.tusky.pager.SingleImagePagerAdapter
import com.keylesspalace.tusky.util.getTemporaryMediaFilename
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.viewdata.AttachmentViewData
import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider
import com.uber.autodispose.autoDispose
import io.reactivex.Single
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import java.io.File
import java.io.FileNotFoundException
import java.io.FileOutputStream
import java.io.IOException
import java.util.*
import java.util.Locale
typealias ToolbarVisibilityListener = (isVisible: Boolean) -> Unit
@ -102,17 +102,16 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
val realAttachs = attachments!!.map(AttachmentViewData::attachment)
// Setup the view pager.
ImagePagerAdapter(this, realAttachs, initialPosition)
} else {
imageUrl = intent.getStringExtra(EXTRA_SINGLE_IMAGE_URL)
?: throw IllegalArgumentException("attachment list or image url has to be set")
?: throw IllegalArgumentException("attachment list or image url has to be set")
SingleImagePagerAdapter(this, imageUrl!!)
}
binding.viewPager.adapter = adapter
binding.viewPager.setCurrentItem(initialPosition, false)
binding.viewPager.registerOnPageChangeCallback(object: ViewPager2.OnPageChangeCallback() {
binding.viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) {
binding.toolbar.title = getPageTitle(position)
}
@ -138,6 +137,7 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
}
window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LOW_PROFILE
window.statusBarColor = Color.BLACK
window.sharedElementEnterTransition.addListener(object : NoopTransitionListener {
override fun onTransitionEnd(transition: Transition) {
@ -182,17 +182,17 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
}
binding.toolbar.animate().alpha(alpha)
.setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
binding.toolbar.visibility = visibility
animation.removeListener(this)
}
})
.start()
.setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
binding.toolbar.visibility = visibility
animation.removeListener(this)
}
})
.start()
}
private fun getPageTitle(position: Int): CharSequence {
if(attachments == null) {
if (attachments == null) {
return ""
}
return String.format(Locale.getDefault(), "%d/%d", position + 1, attachments?.size)
@ -205,8 +205,10 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
val downloadManager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
val request = DownloadManager.Request(Uri.parse(url))
request.setDestinationInExternalPublicDir(Environment.DIRECTORY_PICTURES,
getString(R.string.app_name) + "/" + filename)
request.setDestinationInExternalPublicDir(
Environment.DIRECTORY_PICTURES,
getString(R.string.app_name) + "/" + filename
)
downloadManager.enqueue(request)
}
@ -260,7 +262,6 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_media_to)))
}
private var isCreating: Boolean = false
private fun shareImage(directory: File, url: String) {
@ -269,7 +270,7 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
invalidateOptionsMenu()
val file = File(directory, getTemporaryMediaFilename("png"))
val futureTask: FutureTarget<Bitmap> =
Glide.with(applicationContext).asBitmap().load(Uri.parse(url)).submit()
Glide.with(applicationContext).asBitmap().load(Uri.parse(url)).submit()
Single.fromCallable {
val bitmap = futureTask.get()
try {
@ -283,32 +284,30 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
Log.e(TAG, "Error writing temporary media.")
}
return@fromCallable false
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.doOnDispose {
futureTask.cancel(true)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.doOnDispose {
futureTask.cancel(true)
}
.autoDispose(AndroidLifecycleScopeProvider.from(this, Lifecycle.Event.ON_DESTROY))
.subscribe(
{ result ->
Log.d(TAG, "Download image result: $result")
isCreating = false
invalidateOptionsMenu()
binding.progressBarShare.visibility = View.GONE
if (result)
shareFile(file, "image/png")
},
{ error ->
isCreating = false
invalidateOptionsMenu()
binding.progressBarShare.visibility = View.GONE
Log.e(TAG, "Failed to download image", error)
}
.autoDispose(AndroidLifecycleScopeProvider.from(this, Lifecycle.Event.ON_DESTROY))
.subscribe(
{ result ->
Log.d(TAG, "Download image result: $result")
isCreating = false
invalidateOptionsMenu()
binding.progressBarShare.visibility = View.GONE
if (result)
shareFile(file, "image/png")
},
{ error ->
isCreating = false
invalidateOptionsMenu()
binding.progressBarShare.visibility = View.GONE
Log.e(TAG, "Failed to download image", error)
}
)
)
}
private fun shareMediaFile(directory: File, url: String) {
@ -351,7 +350,7 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
}
}
abstract class ViewMediaAdapter(activity: FragmentActivity): FragmentStateAdapter(activity) {
abstract class ViewMediaAdapter(activity: FragmentActivity) : FragmentStateAdapter(activity) {
abstract fun onTransitionEnd(position: Int)
}

View file

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

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

@ -34,7 +34,7 @@ class AccountFieldEditAdapter : RecyclerView.Adapter<BindingHolder<ItemEditField
fields.forEach { field ->
fieldData.add(MutableStringPair(field.name, field.value))
}
if(fieldData.isEmpty()) {
if (fieldData.isEmpty()) {
fieldData.add(MutableStringPair("", ""))
}
@ -63,7 +63,7 @@ class AccountFieldEditAdapter : RecyclerView.Adapter<BindingHolder<ItemEditField
holder.binding.accountFieldName.setText(fieldData[position].first)
holder.binding.accountFieldValue.setText(fieldData[position].second)
holder.binding.accountFieldName.addTextChangedListener(object: TextWatcher {
holder.binding.accountFieldName.addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(newText: Editable) {
fieldData[holder.bindingAdapterPosition].first = newText.toString()
}
@ -73,7 +73,7 @@ class AccountFieldEditAdapter : RecyclerView.Adapter<BindingHolder<ItemEditField
override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {}
})
holder.binding.accountFieldValue.addTextChangedListener(object: TextWatcher {
holder.binding.accountFieldValue.addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(newText: Editable) {
fieldData[holder.bindingAdapterPosition].second = newText.toString()
}
@ -82,9 +82,7 @@ class AccountFieldEditAdapter : RecyclerView.Adapter<BindingHolder<ItemEditField
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.db.AccountEntity
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) {
@ -48,7 +49,6 @@ class AccountSelectionAdapter(context: Context) : ArrayAdapter<AccountEntity>(co
val animateAvatar = pm.getBoolean("animateGifAvatars", false)
loadAvatar(account.profilePictureUrl, binding.avatar, avatarRadius, animateAvatar)
}
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,15 +22,15 @@ import com.bumptech.glide.Glide
import com.keylesspalace.tusky.databinding.ItemEmojiButtonBinding
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.util.BindingHolder
import java.util.*
import java.util.Locale
class EmojiAdapter(
emojiList: List<Emoji>,
private val onEmojiSelectedListener: OnEmojiSelectedListener
emojiList: List<Emoji>,
private val onEmojiSelectedListener: OnEmojiSelectedListener
) : RecyclerView.Adapter<BindingHolder<ItemEmojiButtonBinding>>() {
private val emojiList : List<Emoji> = emojiList.filter { emoji -> emoji.visibleInPicker == null || emoji.visibleInPicker }
.sortedBy { it.shortcode.toLowerCase(Locale.ROOT) }
private val emojiList: List<Emoji> = emojiList.filter { emoji -> emoji.visibleInPicker == null || emoji.visibleInPicker }
.sortedBy { it.shortcode.lowercase(Locale.ROOT) }
override fun getItemCount() = emojiList.size
@ -44,8 +44,8 @@ class EmojiAdapter(
val emojiImageView = holder.binding.root
Glide.with(emojiImageView)
.load(emoji.url)
.into(emojiImageView)
.load(emoji.url)
.into(emojiImageView)
emojiImageView.setOnClickListener {
onEmojiSelectedListener.onEmojiSelected(emoji.shortcode)

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,11 +24,14 @@ import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.interfaces.AccountActionListener
import com.keylesspalace.tusky.util.*
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.loadAvatar
import com.keylesspalace.tusky.util.unicodeWrap
import com.keylesspalace.tusky.util.visible
class FollowRequestViewHolder(
private val binding: ItemFollowRequestBinding,
private val showHeader: Boolean
private val binding: ItemFollowRequestBinding,
private val showHeader: Boolean
) : RecyclerView.ViewHolder(binding.root) {
fun setupWithAccount(account: Account, animateAvatar: Boolean, animateEmojis: Boolean) {

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

@ -25,7 +25,7 @@ class FollowRequestsHeaderAdapter(private val instanceName: String, private val
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HeaderViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_follow_requests_header, parent, false) as TextView
.inflate(R.layout.item_follow_requests_header, parent, false) as TextView
return HeaderViewHolder(view)
}
@ -34,7 +34,6 @@ class FollowRequestsHeaderAdapter(private val instanceName: String, private val
}
override fun getItemCount() = if (accountLocked) 0 else 1
}
class HeaderViewHolder(var textView: TextView) : RecyclerView.ViewHolder(textView)

View file

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

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

View file

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

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.View
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.emoji.text.EmojiCompat
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemPollBinding
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.util.BindingHolder
@ -29,7 +31,7 @@ import com.keylesspalace.tusky.viewdata.PollOptionViewData
import com.keylesspalace.tusky.viewdata.buildDescription
import com.keylesspalace.tusky.viewdata.calculatePercent
class PollAdapter: RecyclerView.Adapter<BindingHolder<ItemPollBinding>>() {
class PollAdapter : RecyclerView.Adapter<BindingHolder<ItemPollBinding>>() {
private var pollOptions: List<PollOptionViewData> = emptyList()
private var voteCount: Int = 0
@ -40,13 +42,14 @@ class PollAdapter: RecyclerView.Adapter<BindingHolder<ItemPollBinding>>() {
private var animateEmojis = false
fun setup(
options: List<PollOptionViewData>,
voteCount: Int,
votersCount: Int?,
emojis: List<Emoji>,
mode: Int,
resultClickListener: View.OnClickListener?,
animateEmojis: Boolean) {
options: List<PollOptionViewData>,
voteCount: Int,
votersCount: Int?,
emojis: List<Emoji>,
mode: Int,
resultClickListener: View.OnClickListener?,
animateEmojis: Boolean
) {
this.pollOptions = options
this.voteCount = voteCount
this.votersCount = votersCount
@ -57,12 +60,11 @@ class PollAdapter: RecyclerView.Adapter<BindingHolder<ItemPollBinding>>() {
notifyDataSetChanged()
}
fun getSelected() : List<Int> {
fun getSelected(): List<Int> {
return pollOptions.filter { it.selected }
.map { pollOptions.indexOf(it) }
.map { pollOptions.indexOf(it) }
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemPollBinding> {
val binding = ItemPollBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return BindingHolder(binding)
@ -82,16 +84,22 @@ class PollAdapter: RecyclerView.Adapter<BindingHolder<ItemPollBinding>>() {
radioButton.visible(mode == SINGLE)
checkBox.visible(mode == MULTIPLE)
when(mode) {
when (mode) {
RESULT -> {
val percent = calculatePercent(option.votesCount, votersCount, voteCount)
val emojifiedPollOptionText = buildDescription(option.title, percent, resultTextView.context)
.emojify(emojis, resultTextView, animateEmojis)
resultTextView.text = EmojiCompat.get().process(emojifiedPollOptionText)
val emojifiedPollOptionText = buildDescription(option.title, percent, option.voted, resultTextView.context)
.emojify(emojis, resultTextView, animateEmojis)
resultTextView.text = EmojiCompat.get().process(emojifiedPollOptionText)
val level = percent * 100
val optionColor = if (option.voted) {
R.color.colorBackgroundHighlight
} else {
R.color.colorBackgroundAccent
}
resultTextView.background.level = level
resultTextView.background.setTint(ContextCompat.getColor(resultTextView.context, optionColor))
resultTextView.setOnClickListener(resultClickListener)
}
SINGLE -> {
@ -114,7 +122,6 @@ class PollAdapter: RecyclerView.Adapter<BindingHolder<ItemPollBinding>>() {
}
}
}
}
companion object {

View file

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

View file

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

View file

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

View file

@ -43,10 +43,11 @@ interface ItemInteractionListener {
fun onChipClicked(tab: TabData, tabPosition: Int, chipPosition: Int)
}
class TabAdapter(private var data: List<TabData>,
private val small: Boolean,
private val listener: ItemInteractionListener,
private var removeButtonEnabled: Boolean = false
class TabAdapter(
private var data: List<TabData>,
private val small: Boolean,
private val listener: ItemInteractionListener,
private var removeButtonEnabled: Boolean = false
) : RecyclerView.Adapter<BindingHolder<ViewBinding>>() {
fun updateData(newData: List<TabData>) {
@ -77,7 +78,6 @@ class TabAdapter(private var data: List<TabData>,
binding.textView.setOnClickListener {
listener.onTabAdded(tab)
}
} else {
val binding = holder.binding as ItemTabPreferenceBinding
@ -102,9 +102,9 @@ class TabAdapter(private var data: List<TabData>,
}
binding.removeButton.isEnabled = removeButtonEnabled
ThemeUtils.setDrawableTint(
holder.itemView.context,
binding.removeButton.drawable,
(if (removeButtonEnabled) android.R.attr.textColorTertiary else R.attr.textColorDisabled)
holder.itemView.context,
binding.removeButton.drawable,
(if (removeButtonEnabled) android.R.attr.textColorTertiary else R.attr.textColorDisabled)
)
if (tab.id == HASHTAG) {
@ -118,14 +118,14 @@ class TabAdapter(private var data: List<TabData>,
tab.arguments.forEachIndexed { i, arg ->
val chip = binding.chipGroup.getChildAt(i).takeUnless { it.id == R.id.actionChip } as Chip?
?: Chip(context).apply {
binding.chipGroup.addView(this, binding.chipGroup.size - 1)
chipIconTint = ColorStateList.valueOf(ThemeUtils.getColor(context, android.R.attr.textColorPrimary))
}
?: Chip(context).apply {
binding.chipGroup.addView(this, binding.chipGroup.size - 1)
chipIconTint = ColorStateList.valueOf(ThemeUtils.getColor(context, android.R.attr.textColorPrimary))
}
chip.text = arg
if(tab.arguments.size <= 1) {
if (tab.arguments.size <= 1) {
chip.chipIcon = null
chip.setOnClickListener(null)
} else {
@ -136,14 +136,13 @@ class TabAdapter(private var data: List<TabData>,
}
}
while(binding.chipGroup.size - 1 > tab.arguments.size) {
while (binding.chipGroup.size - 1 > tab.arguments.size) {
binding.chipGroup.removeViewAt(tab.arguments.size)
}
binding.actionChip.setOnClickListener {
listener.onActionChipClicked(tab, holder.bindingAdapterPosition)
}
} else {
binding.chipGroup.hide()
}

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,22 +3,23 @@ package com.keylesspalace.tusky.appstore
import com.google.gson.Gson
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import io.reactivex.Single
import io.reactivex.disposables.Disposable
import io.reactivex.schedulers.Schedulers
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.schedulers.Schedulers
import javax.inject.Inject
class CacheUpdater @Inject constructor(
eventHub: EventHub,
accountManager: AccountManager,
private val appDatabase: AppDatabase,
gson: Gson
eventHub: EventHub,
private val accountManager: AccountManager,
private val appDatabase: AppDatabase,
gson: Gson
) {
private val disposable: Disposable
init {
val timelineDao = appDatabase.timelineDao()
disposable = eventHub.events.subscribe { event ->
val accountId = accountManager.activeAccount?.id ?: return@subscribe
when (event) {
@ -27,7 +28,7 @@ class CacheUpdater @Inject constructor(
is ReblogEvent ->
timelineDao.setReblogged(accountId, event.statusId, event.reblog)
is BookmarkEvent ->
timelineDao.setBookmarked(accountId, event.statusId, event.bookmark )
timelineDao.setBookmarked(accountId, event.statusId, event.bookmark)
is UnfollowEvent ->
timelineDao.removeAllByUser(accountId, event.accountId)
is StatusDeletedEvent ->
@ -36,6 +37,8 @@ class CacheUpdater @Inject constructor(
val pollString = gson.toJson(event.poll)
timelineDao.setVoted(accountId, event.statusId, pollString)
}
is PinEvent ->
timelineDao.setPinned(accountId, event.statusId, event.pinned)
}
}
}
@ -49,7 +52,7 @@ class CacheUpdater @Inject constructor(
appDatabase.timelineDao().removeAllForAccount(accountId)
appDatabase.timelineDao().removeAllUsersForAccount(accountId)
}
.subscribeOn(Schedulers.io())
.subscribe()
.subscribeOn(Schedulers.io())
.subscribe()
}
}

View file

@ -19,5 +19,6 @@ data class ProfileEditedEvent(val newProfileData: Account) : Dispatchable
data class PreferenceChangedEvent(val preferenceKey: String) : Dispatchable
data class MainTabsChangedEvent(val newTabs: List<TabData>) : Dispatchable
data class PollVoteEvent(val statusId: String, val poll: Poll) : Dispatchable
data class DomainMuteEvent(val instance: String): Dispatchable
data class AnnouncementReadEvent(val announcementId: String): Dispatchable
data class DomainMuteEvent(val instance: String) : Dispatchable
data class AnnouncementReadEvent(val announcementId: String) : Dispatchable
data class PinEvent(val statusId: String, val pinned: Boolean) : Dispatchable

View file

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

View file

@ -13,7 +13,7 @@
* 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
package com.keylesspalace.tusky.components.account
import android.animation.ArgbEvaluator
import android.content.Context
@ -34,20 +34,29 @@ import androidx.annotation.Px
import androidx.appcompat.app.AlertDialog
import androidx.core.app.ActivityOptionsCompat
import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsCompat.Type.systemBars
import androidx.core.view.updatePadding
import androidx.emoji.text.EmojiCompat
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.viewpager2.widget.MarginPageTransformer
import com.bumptech.glide.Glide
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.appbar.CollapsingToolbarLayout
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.google.android.material.shape.MaterialShapeDrawable
import com.google.android.material.shape.ShapeAppearanceModel
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import com.keylesspalace.tusky.adapter.AccountFieldAdapter
import com.keylesspalace.tusky.AccountListActivity
import com.keylesspalace.tusky.BottomSheetActivity
import com.keylesspalace.tusky.EditProfileActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.ViewMediaActivity
import com.keylesspalace.tusky.ViewTagActivity
import com.keylesspalace.tusky.components.compose.ComposeActivity
import com.keylesspalace.tusky.components.report.ReportActivity
import com.keylesspalace.tusky.databinding.ActivityAccountBinding
@ -57,11 +66,20 @@ import com.keylesspalace.tusky.entity.Relationship
import com.keylesspalace.tusky.interfaces.ActionButtonActivity
import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.interfaces.ReselectableFragment
import com.keylesspalace.tusky.pager.AccountPagerAdapter
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.*
import com.keylesspalace.tusky.util.DefaultTextWatcher
import com.keylesspalace.tusky.util.Error
import com.keylesspalace.tusky.util.LinkHelper
import com.keylesspalace.tusky.util.Loading
import com.keylesspalace.tusky.util.Success
import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.loadAvatar
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible
import com.keylesspalace.tusky.view.showMuteAccountDialog
import com.keylesspalace.tusky.viewmodel.AccountViewModel
import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
import java.text.NumberFormat
@ -79,7 +97,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
private val binding: ActivityAccountBinding by viewBinding(ActivityAccountBinding::inflate)
private lateinit var accountFieldAdapter : AccountFieldAdapter
private lateinit var accountFieldAdapter: AccountFieldAdapter
private var followState: FollowState = FollowState.NOT_FOLLOWING
private var blocking: Boolean = false
@ -129,6 +147,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
animateEmojis = sharedPrefs.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
hideFab = sharedPrefs.getBoolean("fabHide", false)
handleWindowInsets()
setupToolbar()
setupTabs()
setupAccountViews()
@ -170,7 +189,6 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
binding.accountFieldList.layoutManager = LinearLayoutManager(this)
binding.accountFieldList.adapter = accountFieldAdapter
val accountListClickListener = { v: View ->
val type = when (v.id) {
R.id.accountFollowers -> AccountListActivity.Type.FOLLOWERS
@ -231,21 +249,25 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
override fun onTabUnselected(tab: TabLayout.Tab?) {}
override fun onTabSelected(tab: TabLayout.Tab?) {}
})
}
private fun setupToolbar() {
// set toolbar top margin according to system window insets
binding.accountCoordinatorLayout.setOnApplyWindowInsetsListener { _, insets ->
val top = insets.systemWindowInsetTop
val toolbarParams = binding.accountToolbar.layoutParams as CollapsingToolbarLayout.LayoutParams
private fun handleWindowInsets() {
ViewCompat.setOnApplyWindowInsetsListener(binding.accountCoordinatorLayout) { _, insets ->
val top = insets.getInsets(systemBars()).top
val toolbarParams = binding.accountToolbar.layoutParams as ViewGroup.MarginLayoutParams
toolbarParams.topMargin = top
insets.consumeSystemWindowInsets()
}
val right = insets.getInsets(systemBars()).right
val bottom = insets.getInsets(systemBars()).bottom
val left = insets.getInsets(systemBars()).left
binding.accountCoordinatorLayout.updatePadding(right = right, bottom = bottom, left = left)
WindowInsetsCompat.CONSUMED
}
}
private fun setupToolbar() {
// Setup the toolbar.
setSupportActionBar(binding.accountToolbar)
supportActionBar?.run {
@ -266,8 +288,8 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
fillColor = ColorStateList.valueOf(toolbarColor)
elevation = appBarElevation
shapeAppearanceModel = ShapeAppearanceModel.builder()
.setAllCornerSizes(resources.getDimension(R.dimen.account_avatar_background_radius))
.build()
.setAllCornerSizes(resources.getDimension(R.dimen.account_avatar_background_radius))
.build()
}
binding.accountAvatarImageView.background = avatarBackground
@ -314,12 +336,10 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
binding.swipeToRefreshLayout.isEnabled = verticalOffset == 0
}
})
}
private fun makeNotificationBarTransparent() {
val decorView = window.decorView
decorView.systemUiVisibility = decorView.systemUiVisibility or View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
WindowCompat.setDecorFitsSystemWindows(window, false)
window.statusBarColor = statusBarColorTransparent
}
@ -332,9 +352,10 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
is Success -> onAccountChanged(it.data)
is Error -> {
Snackbar.make(binding.accountCoordinatorLayout, R.string.error_generic, Snackbar.LENGTH_LONG)
.setAction(R.string.action_retry) { viewModel.refresh() }
.show()
.setAction(R.string.action_retry) { viewModel.refresh() }
.show()
}
is Loading -> { }
}
}
viewModel.relationshipData.observe(this) {
@ -345,15 +366,17 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
if (it is Error) {
Snackbar.make(binding.accountCoordinatorLayout, R.string.error_generic, Snackbar.LENGTH_LONG)
.setAction(R.string.action_retry) { viewModel.refresh() }
.show()
.setAction(R.string.action_retry) { viewModel.refresh() }
.show()
}
}
viewModel.accountFieldData.observe(this, {
accountFieldAdapter.fields = it
accountFieldAdapter.notifyDataSetChanged()
})
viewModel.accountFieldData.observe(
this,
{
accountFieldAdapter.fields = it
accountFieldAdapter.notifyDataSetChanged()
}
)
viewModel.noteSaved.observe(this) {
binding.saveNoteInfo.visible(it, View.INVISIBLE)
}
@ -367,9 +390,12 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
viewModel.refresh()
adapter.refreshContent()
}
viewModel.isRefreshing.observe(this, { isRefreshing ->
binding.swipeToRefreshLayout.isRefreshing = isRefreshing == true
})
viewModel.isRefreshing.observe(
this,
{ isRefreshing ->
binding.swipeToRefreshLayout.isRefreshing = isRefreshing == true
}
)
binding.swipeToRefreshLayout.setColorSchemeResources(R.color.chinwag_green)
}
@ -383,7 +409,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
val emojifiedNote = account.note.emojify(account.emojis, binding.accountNoteTextView, animateEmojis)
LinkHelper.setClickableText(binding.accountNoteTextView, emojifiedNote, null, this)
// accountFieldAdapter.fields = account.fields ?: emptyList()
// accountFieldAdapter.fields = account.fields ?: emptyList()
accountFieldAdapter.emojis = account.emojis ?: emptyList()
accountFieldAdapter.notifyDataSetChanged()
@ -410,21 +436,21 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
loadedAccount?.let { account ->
loadAvatar(
account.avatar,
binding.accountAvatarImageView,
resources.getDimensionPixelSize(R.dimen.avatar_radius_94dp),
animateAvatar
account.avatar,
binding.accountAvatarImageView,
resources.getDimensionPixelSize(R.dimen.avatar_radius_94dp),
animateAvatar
)
Glide.with(this)
.asBitmap()
.load(account.header)
.centerCrop()
.into(binding.accountHeaderImageView)
.asBitmap()
.load(account.header)
.centerCrop()
.into(binding.accountHeaderImageView)
binding.accountAvatarImageView.setOnClickListener { avatarView ->
val intent = ViewMediaActivity.newSingleImageIntent(avatarView.context, account.avatar)
val intent =
ViewMediaActivity.newSingleImageIntent(avatarView.context, account.avatar)
avatarView.transitionName = account.avatar
val options = ActivityOptionsCompat.makeSceneTransitionAnimation(this, avatarView, account.avatar)
@ -479,7 +505,6 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
binding.accountMovedText.setCompoundDrawablesRelativeWithIntrinsicBounds(movedIcon, null, null, null)
}
}
/**
@ -555,15 +580,16 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
// because subscribing is Pleroma extension, enable it __only__ when we have non-null subscribing field
// it's also now supported in Mastodon 3.3.0rc but called notifying and use different API call
if(!viewModel.isSelf && followState == FollowState.FOLLOWING
&& (relation.subscribing != null || relation.notifying != null)) {
if (!viewModel.isSelf && followState == FollowState.FOLLOWING &&
(relation.subscribing != null || relation.notifying != null)
) {
binding.accountSubscribeButton.show()
binding.accountSubscribeButton.setOnClickListener {
viewModel.changeSubscribingState()
}
if(relation.notifying != null)
if (relation.notifying != null)
subscribing = relation.notifying
else if(relation.subscribing != null)
else if (relation.subscribing != null)
subscribing = relation.subscribing
}
@ -578,7 +604,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
updateButtons()
}
private val noteWatcher = object: DefaultTextWatcher() {
private val noteWatcher = object : DefaultTextWatcher() {
override fun afterTextChanged(s: Editable) {
viewModel.noteChanged(s.toString())
}
@ -616,11 +642,11 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
}
private fun updateSubscribeButton() {
if(followState != FollowState.FOLLOWING) {
if (followState != FollowState.FOLLOWING) {
binding.accountSubscribeButton.hide()
}
if(subscribing) {
if (subscribing) {
binding.accountSubscribeButton.setIconResource(R.drawable.ic_notifications_active_24dp)
binding.accountSubscribeButton.contentDescription = getString(R.string.action_unsubscribe_account)
} else {
@ -649,7 +675,6 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
binding.accountMuteButton.hide()
updateMuteButton()
}
} else {
binding.accountFloatingActionButton.hide()
binding.accountFollowButton.hide()
@ -699,11 +724,9 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
} else {
getString(R.string.action_show_reblogs)
}
} else {
menu.removeItem(R.id.action_show_reblogs)
}
} else {
// It shouldn't be possible to block, mute or report yourself.
menu.removeItem(R.id.action_block)
@ -718,39 +741,39 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
private fun showFollowRequestPendingDialog() {
AlertDialog.Builder(this)
.setMessage(R.string.dialog_message_cancel_follow_request)
.setPositiveButton(android.R.string.ok) { _, _ -> viewModel.changeFollowState() }
.setNegativeButton(android.R.string.cancel, null)
.show()
.setMessage(R.string.dialog_message_cancel_follow_request)
.setPositiveButton(android.R.string.ok) { _, _ -> viewModel.changeFollowState() }
.setNegativeButton(android.R.string.cancel, null)
.show()
}
private fun showUnfollowWarningDialog() {
AlertDialog.Builder(this)
.setMessage(R.string.dialog_unfollow_warning)
.setPositiveButton(android.R.string.ok) { _, _ -> viewModel.changeFollowState() }
.setNegativeButton(android.R.string.cancel, null)
.show()
.setMessage(R.string.dialog_unfollow_warning)
.setPositiveButton(android.R.string.ok) { _, _ -> viewModel.changeFollowState() }
.setNegativeButton(android.R.string.cancel, null)
.show()
}
private fun toggleBlockDomain(instance: String) {
if(blockingDomain) {
if (blockingDomain) {
viewModel.unblockDomain(instance)
} else {
AlertDialog.Builder(this)
.setMessage(getString(R.string.mute_domain_warning, instance))
.setPositiveButton(getString(R.string.mute_domain_warning_dialog_ok)) { _, _ -> viewModel.blockDomain(instance) }
.setNegativeButton(android.R.string.cancel, null)
.show()
.setMessage(getString(R.string.mute_domain_warning, instance))
.setPositiveButton(getString(R.string.mute_domain_warning_dialog_ok)) { _, _ -> viewModel.blockDomain(instance) }
.setNegativeButton(android.R.string.cancel, null)
.show()
}
}
private fun toggleBlock() {
if (viewModel.relationshipData.value?.data?.blocking != true) {
AlertDialog.Builder(this)
.setMessage(getString(R.string.dialog_block_warning, loadedAccount?.username))
.setPositiveButton(android.R.string.ok) { _, _ -> viewModel.changeBlockState() }
.setNegativeButton(android.R.string.cancel, null)
.show()
.setMessage(getString(R.string.dialog_block_warning, loadedAccount?.username))
.setPositiveButton(android.R.string.ok) { _, _ -> viewModel.changeBlockState() }
.setNegativeButton(android.R.string.cancel, null)
.show()
} else {
viewModel.changeBlockState()
}
@ -760,8 +783,8 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
if (viewModel.relationshipData.value?.data?.muting != true) {
loadedAccount?.let {
showMuteAccountDialog(
this,
it.username
this,
it.username
) { notifications, duration ->
viewModel.muteAccount(notifications, duration)
}
@ -773,8 +796,10 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
private fun mention() {
loadedAccount?.let {
val intent = ComposeActivity.startIntent(this,
ComposeActivity.ComposeOptions(mentionedUsernames = setOf(it.username)))
val intent = ComposeActivity.startIntent(
this,
ComposeActivity.ComposeOptions(mentionedUsernames = setOf(it.username))
)
startActivity(intent)
}
}
@ -850,5 +875,4 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
return intent
}
}
}

View file

@ -13,23 +13,26 @@
* 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
package com.keylesspalace.tusky.components.account
import android.text.method.LinkMovementMethod
import androidx.recyclerview.widget.RecyclerView
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemAccountFieldBinding
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.Field
import com.keylesspalace.tusky.entity.IdentityProof
import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.util.*
import com.keylesspalace.tusky.util.BindingHolder
import com.keylesspalace.tusky.util.Either
import com.keylesspalace.tusky.util.LinkHelper
import com.keylesspalace.tusky.util.emojify
class AccountFieldAdapter(
private val linkListener: LinkListener,
private val animateEmojis: Boolean
private val linkListener: LinkListener,
private val animateEmojis: Boolean
) : RecyclerView.Adapter<BindingHolder<ItemAccountFieldBinding>>() {
var emojis: List<Emoji> = emptyList()
@ -47,7 +50,7 @@ class AccountFieldAdapter(
val nameTextView = holder.binding.accountFieldName
val valueTextView = holder.binding.accountFieldValue
if(proofOrField.isLeft()) {
if (proofOrField.isLeft()) {
val identityProof = proofOrField.asLeft()
nameTextView.text = identityProof.provider
@ -55,7 +58,7 @@ class AccountFieldAdapter(
valueTextView.movementMethod = LinkMovementMethod.getInstance()
valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_check_circle, 0)
valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_check_circle, 0)
} else {
val field = proofOrField.asRight()
val emojifiedName = field.name.emojify(emojis, nameTextView, animateEmojis)
@ -64,12 +67,11 @@ class AccountFieldAdapter(
val emojifiedValue = field.value.emojify(emojis, valueTextView, animateEmojis)
LinkHelper.setClickableText(valueTextView, emojifiedValue, null, linkListener)
if(field.verifiedAt != null) {
valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_check_circle, 0)
if (field.verifiedAt != null) {
valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_check_circle, 0)
} else {
valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0 )
valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0)
}
}
}
}

View file

@ -13,28 +13,28 @@
* 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.pager
package com.keylesspalace.tusky.components.account
import androidx.fragment.app.*
import com.keylesspalace.tusky.fragment.AccountMediaFragment
import com.keylesspalace.tusky.fragment.TimelineFragment
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import com.keylesspalace.tusky.components.account.media.AccountMediaFragment
import com.keylesspalace.tusky.components.timeline.TimelineFragment
import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel
import com.keylesspalace.tusky.interfaces.RefreshableFragment
import com.keylesspalace.tusky.util.CustomFragmentStateAdapter
class AccountPagerAdapter(
activity: FragmentActivity,
private val accountId: String
activity: FragmentActivity,
private val accountId: String
) : CustomFragmentStateAdapter(activity) {
override fun getItemCount() = TAB_COUNT
override fun createFragment(position: Int): Fragment {
return when (position) {
0 -> TimelineFragment.newInstance(TimelineFragment.Kind.USER, accountId, false)
1 -> TimelineFragment.newInstance(TimelineFragment.Kind.USER_WITH_REPLIES, accountId, false)
2 -> TimelineFragment.newInstance(TimelineFragment.Kind.USER_PINNED, accountId, false)
0 -> TimelineFragment.newInstance(TimelineViewModel.Kind.USER, accountId, false)
1 -> TimelineFragment.newInstance(TimelineViewModel.Kind.USER_WITH_REPLIES, accountId, false)
2 -> TimelineFragment.newInstance(TimelineViewModel.Kind.USER_PINNED, accountId, false)
3 -> AccountMediaFragment.newInstance(accountId, false)
else -> throw AssertionError("Page $position is out of AccountPagerAdapter bounds")
}

View file

@ -1,17 +1,28 @@
package com.keylesspalace.tusky.viewmodel
package com.keylesspalace.tusky.components.account
import android.util.Log
import androidx.lifecycle.MutableLiveData
import com.keylesspalace.tusky.appstore.*
import com.keylesspalace.tusky.appstore.BlockEvent
import com.keylesspalace.tusky.appstore.DomainMuteEvent
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.MuteEvent
import com.keylesspalace.tusky.appstore.ProfileEditedEvent
import com.keylesspalace.tusky.appstore.UnfollowEvent
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Field
import com.keylesspalace.tusky.entity.IdentityProof
import com.keylesspalace.tusky.entity.Relationship
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.*
import io.reactivex.Single
import io.reactivex.disposables.Disposable
import com.keylesspalace.tusky.util.Either
import com.keylesspalace.tusky.util.Error
import com.keylesspalace.tusky.util.Loading
import com.keylesspalace.tusky.util.Resource
import com.keylesspalace.tusky.util.RxAwareViewModel
import com.keylesspalace.tusky.util.Success
import com.keylesspalace.tusky.util.combineOptionalLiveData
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.Disposable
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
@ -19,9 +30,9 @@ import java.util.concurrent.TimeUnit
import javax.inject.Inject
class AccountViewModel @Inject constructor(
private val mastodonApi: MastodonApi,
private val eventHub: EventHub,
private val accountManager: AccountManager
private val mastodonApi: MastodonApi,
private val eventHub: EventHub,
private val accountManager: AccountManager
) : RxAwareViewModel() {
val accountData = MutableLiveData<Resource<Account>>()
@ -33,7 +44,7 @@ class AccountViewModel @Inject constructor(
val accountFieldData = combineOptionalLiveData(accountData, identityProofData) { accountRes, identityProofs ->
identityProofs.orEmpty().map { Either.Left<IdentityProof, Field>(it) }
.plus(accountRes?.data?.fields.orEmpty().map { Either.Right(it) })
.plus(accountRes?.data?.fields.orEmpty().map { Either.Right(it) })
}
val isRefreshing = MutableLiveData<Boolean>().apply { value = false }
@ -46,11 +57,11 @@ class AccountViewModel @Inject constructor(
init {
eventHub.events
.subscribe { event ->
if (event is ProfileEditedEvent && event.newProfileData.id == accountData.value?.data?.id) {
accountData.postValue(Success(event.newProfileData))
}
}.autoDispose()
.subscribe { event ->
if (event is ProfileEditedEvent && event.newProfileData.id == accountData.value?.data?.id) {
accountData.postValue(Success(event.newProfileData))
}
}.autoDispose()
}
private fun obtainAccount(reload: Boolean = false) {
@ -59,17 +70,20 @@ class AccountViewModel @Inject constructor(
accountData.postValue(Loading())
mastodonApi.account(accountId)
.subscribe({ account ->
.subscribe(
{ account ->
accountData.postValue(Success(account))
isDataLoading = false
isRefreshing.postValue(false)
}, {t ->
},
{ t ->
Log.w(TAG, "failed obtaining account", t)
accountData.postValue(Error())
isDataLoading = false
isRefreshing.postValue(false)
})
.autoDispose()
}
)
.autoDispose()
}
}
@ -79,13 +93,16 @@ class AccountViewModel @Inject constructor(
relationshipData.postValue(Loading())
mastodonApi.relationships(listOf(accountId))
.subscribe({ relationships ->
.subscribe(
{ relationships ->
relationshipData.postValue(Success(relationships[0]))
}, { t ->
},
{ t ->
Log.w(TAG, "failed obtaining relationships", t)
relationshipData.postValue(Error())
})
.autoDispose()
}
)
.autoDispose()
}
}
@ -93,12 +110,15 @@ class AccountViewModel @Inject constructor(
if (identityProofData.value == null || reload) {
mastodonApi.identityProofs(accountId)
.subscribe({ proofs ->
.subscribe(
{ proofs ->
identityProofData.postValue(proofs)
}, { t ->
},
{ t ->
Log.w(TAG, "failed obtaining identity proofs", t)
})
.autoDispose()
}
)
.autoDispose()
}
}
@ -129,8 +149,9 @@ class AccountViewModel @Inject constructor(
fun changeSubscribingState() {
val relationship = relationshipData.value?.data
if(relationship?.notifying == true /* Mastodon 3.3.0rc1 */
|| relationship?.subscribing == true /* Pleroma */ ) {
if (relationship?.notifying == true || /* Mastodon 3.3.0rc1 */
relationship?.subscribing == true /* Pleroma */
) {
changeRelationship(RelationShipAction.UNSUBSCRIBE)
} else {
changeRelationship(RelationShipAction.SUBSCRIBE)
@ -138,12 +159,12 @@ class AccountViewModel @Inject constructor(
}
fun blockDomain(instance: String) {
mastodonApi.blockDomain(instance).enqueue(object: Callback<Any> {
mastodonApi.blockDomain(instance).enqueue(object : Callback<Any> {
override fun onResponse(call: Call<Any>, response: Response<Any>) {
if (response.isSuccessful) {
eventHub.dispatch(DomainMuteEvent(instance))
val relation = relationshipData.value?.data
if(relation != null) {
if (relation != null) {
relationshipData.postValue(Success(relation.copy(blockingDomain = true)))
}
} else {
@ -158,11 +179,11 @@ class AccountViewModel @Inject constructor(
}
fun unblockDomain(instance: String) {
mastodonApi.unblockDomain(instance).enqueue(object: Callback<Any> {
mastodonApi.unblockDomain(instance).enqueue(object : Callback<Any> {
override fun onResponse(call: Call<Any>, response: Response<Any>) {
if (response.isSuccessful) {
val relation = relationshipData.value?.data
if(relation != null) {
if (relation != null) {
relationshipData.postValue(Success(relation.copy(blockingDomain = false)))
}
} else {
@ -209,12 +230,12 @@ class AccountViewModel @Inject constructor(
RelationShipAction.MUTE -> relation.copy(muting = true)
RelationShipAction.UNMUTE -> relation.copy(muting = false)
RelationShipAction.SUBSCRIBE -> {
if(isMastodon)
if (isMastodon)
relation.copy(notifying = true)
else relation.copy(subscribing = true)
}
RelationShipAction.UNSUBSCRIBE -> {
if(isMastodon)
if (isMastodon)
relation.copy(notifying = false)
else relation.copy(subscribing = false)
}
@ -230,50 +251,53 @@ class AccountViewModel @Inject constructor(
RelationShipAction.MUTE -> mastodonApi.muteAccount(accountId, parameter ?: true, duration)
RelationShipAction.UNMUTE -> mastodonApi.unmuteAccount(accountId)
RelationShipAction.SUBSCRIBE -> {
if(isMastodon)
if (isMastodon)
mastodonApi.followAccount(accountId, notify = true)
else mastodonApi.subscribeAccount(accountId)
}
RelationShipAction.UNSUBSCRIBE -> {
if(isMastodon)
if (isMastodon)
mastodonApi.followAccount(accountId, notify = false)
else mastodonApi.unsubscribeAccount(accountId)
}
}.subscribe(
{ relationship ->
relationshipData.postValue(Success(relationship))
{ relationship ->
relationshipData.postValue(Success(relationship))
when (relationshipAction) {
RelationShipAction.UNFOLLOW -> eventHub.dispatch(UnfollowEvent(accountId))
RelationShipAction.BLOCK -> eventHub.dispatch(BlockEvent(accountId))
RelationShipAction.MUTE -> eventHub.dispatch(MuteEvent(accountId))
else -> {
}
when (relationshipAction) {
RelationShipAction.UNFOLLOW -> eventHub.dispatch(UnfollowEvent(accountId))
RelationShipAction.BLOCK -> eventHub.dispatch(BlockEvent(accountId))
RelationShipAction.MUTE -> eventHub.dispatch(MuteEvent(accountId))
else -> {
}
},
{
relationshipData.postValue(Error(relation))
}
},
{
relationshipData.postValue(Error(relation))
}
)
.autoDispose()
.autoDispose()
}
fun noteChanged(newNote: String) {
noteSaved.postValue(false)
noteDisposable?.dispose()
noteDisposable = Single.timer(1500, TimeUnit.MILLISECONDS)
.flatMap {
mastodonApi.updateAccountNote(accountId, newNote)
}
.doOnSuccess {
noteSaved.postValue(true)
}
.delay(4, TimeUnit.SECONDS)
.subscribe({
.flatMap {
mastodonApi.updateAccountNote(accountId, newNote)
}
.doOnSuccess {
noteSaved.postValue(true)
}
.delay(4, TimeUnit.SECONDS)
.subscribe(
{
noteSaved.postValue(false)
}, {
},
{
Log.e(TAG, "Error updating note", it)
})
}
)
}
override fun onCleared() {

View file

@ -13,7 +13,7 @@
* 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.fragment
package com.keylesspalace.tusky.components.account.media
import android.graphics.Color
import android.os.Bundle
@ -27,6 +27,7 @@ import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import autodispose2.androidx.lifecycle.autoDispose
import com.bumptech.glide.Glide
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.ViewMediaActivity
@ -43,13 +44,12 @@ import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.view.SquareImageView
import com.keylesspalace.tusky.viewdata.AttachmentViewData
import com.uber.autodispose.android.lifecycle.autoDispose
import io.reactivex.SingleObserver
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.Disposable
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.SingleObserver
import io.reactivex.rxjava3.disposables.Disposable
import retrofit2.Response
import java.io.IOException
import java.util.*
import java.util.Random
import javax.inject.Inject
/**
@ -156,18 +156,17 @@ class AccountMediaFragment : Fragment(R.layout.fragment_timeline), RefreshableFr
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
isSwipeToRefreshEnabled = arguments?.getBoolean(ARG_ENABLE_SWIPE_TO_REFRESH,true) == true
accountId = arguments?.getString(ACCOUNT_ID_ARG)!!
isSwipeToRefreshEnabled = arguments?.getBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true) == true
accountId = arguments?.getString(ACCOUNT_ID_ARG)!!
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val columnCount = view.context.resources.getInteger(R.integer.profile_media_column_count)
val layoutManager = GridLayoutManager(view.context, columnCount)
adapter.baseItemColor = ThemeUtils.getColor(view.context, android.R.attr.windowBackground)
adapter.baseItemColor = ThemeUtils.getColor(view.context, android.R.attr.windowBackground)
binding.recyclerView.layoutManager = layoutManager
binding.recyclerView.adapter = adapter
@ -188,12 +187,12 @@ class AccountMediaFragment : Fragment(R.layout.fragment_timeline), RefreshableFr
val lastItem = layoutManager.findLastCompletelyVisibleItemPosition()
if (itemCount <= lastItem + 3 && fetchingStatus == FetchingStatus.NOT_FETCHING) {
statuses.lastOrNull()?.let { (id) ->
Log.d(TAG, "Requesting statuses with max_id: ${id}, (bottom)")
Log.d(TAG, "Requesting statuses with max_id: $id, (bottom)")
fetchingStatus = FetchingStatus.FETCHING_BOTTOM
api.accountStatuses(accountId, id, null, null, null, true, null)
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this@AccountMediaFragment, Lifecycle.Event.ON_DESTROY)
.subscribe(bottomCallback)
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this@AccountMediaFragment, Lifecycle.Event.ON_DESTROY)
.subscribe(bottomCallback)
}
}
}
@ -213,8 +212,8 @@ class AccountMediaFragment : Fragment(R.layout.fragment_timeline), RefreshableFr
fetchingStatus = FetchingStatus.REFRESHING
api.accountStatuses(accountId, null, statuses[0].id, null, null, true, null)
}.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
.subscribe(callback)
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
.subscribe(callback)
if (!isSwipeToRefreshEnabled)
binding.topProgressBar.show()
@ -227,11 +226,10 @@ class AccountMediaFragment : Fragment(R.layout.fragment_timeline), RefreshableFr
if (fetchingStatus == FetchingStatus.NOT_FETCHING && statuses.isEmpty()) {
fetchingStatus = FetchingStatus.INITIAL_FETCHING
api.accountStatuses(accountId, null, null, null, null, true, null)
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this@AccountMediaFragment, Lifecycle.Event.ON_DESTROY)
.subscribe(callback)
}
else if (needToRefresh)
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this@AccountMediaFragment, Lifecycle.Event.ON_DESTROY)
.subscribe(callback)
} else if (needToRefresh)
refresh()
needToRefresh = false
}
@ -264,7 +262,7 @@ class AccountMediaFragment : Fragment(R.layout.fragment_timeline), RefreshableFr
}
inner class MediaGridAdapter :
RecyclerView.Adapter<MediaGridAdapter.MediaViewHolder>() {
RecyclerView.Adapter<MediaGridAdapter.MediaViewHolder>() {
var baseItemColor = Color.BLACK
@ -305,15 +303,14 @@ class AccountMediaFragment : Fragment(R.layout.fragment_timeline), RefreshableFr
val item = items[position]
Glide.with(holder.imageView)
.load(item.attachment.previewUrl)
.centerInside()
.into(holder.imageView)
.load(item.attachment.previewUrl)
.centerInside()
.into(holder.imageView)
}
inner class MediaViewHolder(val imageView: ImageView)
: RecyclerView.ViewHolder(imageView),
View.OnClickListener {
inner class MediaViewHolder(val imageView: ImageView) :
RecyclerView.ViewHolder(imageView),
View.OnClickListener {
init {
itemView.setOnClickListener(this)
}
@ -334,11 +331,11 @@ class AccountMediaFragment : Fragment(R.layout.fragment_timeline), RefreshableFr
companion object {
@JvmStatic
fun newInstance(accountId: String, enableSwipeToRefresh:Boolean=true): AccountMediaFragment {
fun newInstance(accountId: String, enableSwipeToRefresh: Boolean = true): AccountMediaFragment {
val fragment = AccountMediaFragment()
val args = Bundle()
args.putString(ACCOUNT_ID_ARG, accountId)
args.putBoolean(ARG_ENABLE_SWIPE_TO_REFRESH,enableSwipeToRefresh)
args.putBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, enableSwipeToRefresh)
fragment.arguments = args
return fragment
}

View file

@ -15,33 +15,37 @@
package com.keylesspalace.tusky.components.announcements
import android.os.Build
import android.text.SpannableStringBuilder
import android.view.ContextThemeWrapper
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.size
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.google.android.material.chip.Chip
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemAnnouncementBinding
import com.keylesspalace.tusky.entity.Announcement
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.util.BindingHolder
import com.keylesspalace.tusky.util.EmojiSpan
import com.keylesspalace.tusky.util.LinkHelper
import com.keylesspalace.tusky.util.emojify
import java.lang.ref.WeakReference
interface AnnouncementActionListener: LinkListener {
interface AnnouncementActionListener : LinkListener {
fun openReactionPicker(announcementId: String, target: View)
fun addReaction(announcementId: String, name: String)
fun removeReaction(announcementId: String, name: String)
}
class AnnouncementAdapter(
private var items: List<Announcement> = emptyList(),
private val listener: AnnouncementActionListener,
private val wellbeingEnabled: Boolean = false,
private val animateEmojis: Boolean = false
private var items: List<Announcement> = emptyList(),
private val listener: AnnouncementActionListener,
private val wellbeingEnabled: Boolean = false,
private val animateEmojis: Boolean = false
) : RecyclerView.Adapter<BindingHolder<ItemAnnouncementBinding>>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemAnnouncementBinding> {
@ -56,7 +60,9 @@ class AnnouncementAdapter(
val chips = holder.binding.chipGroup
val addReactionChip = holder.binding.addReactionChip
LinkHelper.setClickableText(text, item.content, null, listener)
val emojifiedText: CharSequence = item.content.emojify(item.emojis, text, animateEmojis)
LinkHelper.setClickableText(text, emojifiedText, item.mentions, listener)
// If wellbeing mode is enabled, announcement badge counts should not be shown.
if (wellbeingEnabled) {
@ -67,40 +73,43 @@ class AnnouncementAdapter(
}
item.reactions.forEachIndexed { i, reaction ->
(chips.getChildAt(i)?.takeUnless { it.id == R.id.addReactionChip } as Chip?
(
chips.getChildAt(i)?.takeUnless { it.id == R.id.addReactionChip } as Chip?
?: Chip(ContextThemeWrapper(chips.context, R.style.Widget_MaterialComponents_Chip_Choice)).apply {
isCheckable = true
checkedIcon = null
chips.addView(this, i)
})
.apply {
val emojiText = if (reaction.url == null) {
reaction.name
} else {
context.getString(R.string.emoji_shortcode_format, reaction.name)
}
)
.apply {
if (reaction.url == null) {
this.text = "${reaction.name} ${reaction.count}"
} else {
// we set the EmojiSpan on a space, because otherwise the Chip won't have the right size
// https://github.com/tuskyapp/Tusky/issues/2308
val spanBuilder = SpannableStringBuilder(" ${reaction.count}")
val span = EmojiSpan(WeakReference(this))
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
span.contentDescription = reaction.name
}
this.text = ("$emojiText ${reaction.count}")
.emojify(
listOf(Emoji(
reaction.name,
reaction.url ?: "",
reaction.staticUrl ?: "",
null
)),
this,
animateEmojis
)
spanBuilder.setSpan(span, 0, 1, 0)
Glide.with(this)
.asDrawable()
.load(if (animateEmojis) { reaction.url } else { reaction.staticUrl })
.into(span.getTarget(animateEmojis))
this.text = spanBuilder
}
isChecked = reaction.me
isChecked = reaction.me
setOnClickListener {
if (reaction.me) {
listener.removeReaction(item.id, reaction.name)
} else {
listener.addReaction(item.id, reaction.name)
}
setOnClickListener {
if (reaction.me) {
listener.removeReaction(item.id, reaction.name)
} else {
listener.addReaction(item.id, reaction.name)
}
}
}
}
while (chips.size - 1 > item.reactions.size) {

View file

@ -34,7 +34,12 @@ import com.keylesspalace.tusky.databinding.ActivityAnnouncementsBinding
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.*
import com.keylesspalace.tusky.util.Error
import com.keylesspalace.tusky.util.Loading
import com.keylesspalace.tusky.util.Success
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.view.EmojiPicker
import javax.inject.Inject
@ -52,13 +57,13 @@ class AnnouncementsActivity : BottomSheetActivity(), AnnouncementActionListener,
private val picker by lazy { EmojiPicker(this) }
private val pickerDialog by lazy {
PopupWindow(this)
.apply {
contentView = picker
isFocusable = true
setOnDismissListener {
currentAnnouncementId = null
}
.apply {
contentView = picker
isFocusable = true
setOnDismissListener {
currentAnnouncementId = null
}
}
}
private var currentAnnouncementId: String? = null

View file

@ -27,15 +27,20 @@ import com.keylesspalace.tusky.entity.Announcement
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.Instance
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.*
import io.reactivex.rxkotlin.Singles
import com.keylesspalace.tusky.util.Either
import com.keylesspalace.tusky.util.Error
import com.keylesspalace.tusky.util.Loading
import com.keylesspalace.tusky.util.Resource
import com.keylesspalace.tusky.util.RxAwareViewModel
import com.keylesspalace.tusky.util.Success
import io.reactivex.rxjava3.core.Single
import javax.inject.Inject
class AnnouncementsViewModel @Inject constructor(
accountManager: AccountManager,
private val appDatabase: AppDatabase,
private val mastodonApi: MastodonApi,
private val eventHub: EventHub
accountManager: AccountManager,
private val appDatabase: AppDatabase,
private val mastodonApi: MastodonApi,
private val eventHub: EventHub
) : RxAwareViewModel() {
private val announcementsMutable = MutableLiveData<Resource<List<Announcement>>>()
@ -45,140 +50,153 @@ class AnnouncementsViewModel @Inject constructor(
val emojis: LiveData<List<Emoji>> = emojisMutable
init {
Singles.zip(
mastodonApi.getCustomEmojis(),
appDatabase.instanceDao().loadMetadataForInstance(accountManager.activeAccount?.domain!!)
.map<Either<InstanceEntity, Instance>> { Either.Left(it) }
.onErrorResumeNext(
mastodonApi.getInstance()
.map { Either.Right(it) }
)
) { emojis, either ->
either.asLeftOrNull()?.copy(emojiList = emojis)
Single.zip(
mastodonApi.getCustomEmojis(),
appDatabase.instanceDao().loadMetadataForInstance(accountManager.activeAccount?.domain!!)
.map<Either<InstanceEntity, Instance>> { Either.Left(it) }
.onErrorResumeNext {
mastodonApi.getInstance()
.map { Either.Right(it) }
},
{ emojis, either ->
either.asLeftOrNull()?.copy(emojiList = emojis)
?: InstanceEntity(
accountManager.activeAccount?.domain!!,
emojis,
either.asRight().maxTootChars,
either.asRight().pollLimits?.maxOptions,
either.asRight().pollLimits?.maxOptionChars,
either.asRight().version
accountManager.activeAccount?.domain!!,
emojis,
either.asRight().maxTootChars,
either.asRight().pollLimits?.maxOptions,
either.asRight().pollLimits?.maxOptionChars,
either.asRight().version
)
}
.doOnSuccess {
appDatabase.instanceDao().insertOrReplace(it)
}
.subscribe({
emojisMutable.postValue(it.emojiList)
}, {
}
)
.doOnSuccess {
appDatabase.instanceDao().insertOrReplace(it)
}
.subscribe(
{
emojisMutable.postValue(it.emojiList.orEmpty())
},
{
Log.w(TAG, "Failed to get custom emojis.", it)
})
.autoDispose()
}
)
.autoDispose()
}
fun load() {
announcementsMutable.postValue(Loading())
mastodonApi.listAnnouncements()
.subscribe({
.subscribe(
{
announcementsMutable.postValue(Success(it))
it.filter { announcement -> !announcement.read }
.forEach { announcement ->
mastodonApi.dismissAnnouncement(announcement.id)
.subscribe(
{
eventHub.dispatch(AnnouncementReadEvent(announcement.id))
},
{ throwable ->
Log.d(TAG, "Failed to mark announcement as read.", throwable)
}
)
.autoDispose()
}
}, {
.forEach { announcement ->
mastodonApi.dismissAnnouncement(announcement.id)
.subscribe(
{
eventHub.dispatch(AnnouncementReadEvent(announcement.id))
},
{ throwable ->
Log.d(TAG, "Failed to mark announcement as read.", throwable)
}
)
.autoDispose()
}
},
{
announcementsMutable.postValue(Error(cause = it))
})
.autoDispose()
}
)
.autoDispose()
}
fun addReaction(announcementId: String, name: String) {
mastodonApi.addAnnouncementReaction(announcementId, name)
.subscribe({
.subscribe(
{
announcementsMutable.postValue(
Success(
announcements.value!!.data!!.map { announcement ->
if (announcement.id == announcementId) {
announcement.copy(
reactions = if (announcement.reactions.find { reaction -> reaction.name == name } != null) {
announcement.reactions.map { reaction ->
if (reaction.name == name) {
reaction.copy(
count = reaction.count + 1,
me = true
)
} else {
reaction
}
}
} else {
listOf(
*announcement.reactions.toTypedArray(),
emojis.value!!.find { emoji -> emoji.shortcode == name }
!!.run {
Announcement.Reaction(
name,
1,
true,
url,
staticUrl
)
}
)
}
)
Success(
announcements.value!!.data!!.map { announcement ->
if (announcement.id == announcementId) {
announcement.copy(
reactions = if (announcement.reactions.find { reaction -> reaction.name == name } != null) {
announcement.reactions.map { reaction ->
if (reaction.name == name) {
reaction.copy(
count = reaction.count + 1,
me = true
)
} else {
reaction
}
}
} else {
announcement
listOf(
*announcement.reactions.toTypedArray(),
emojis.value!!.find { emoji -> emoji.shortcode == name }
!!.run {
Announcement.Reaction(
name,
1,
true,
url,
staticUrl
)
}
)
}
}
)
)
} else {
announcement
}
}
)
)
}, {
},
{
Log.w(TAG, "Failed to add reaction to the announcement.", it)
})
.autoDispose()
}
)
.autoDispose()
}
fun removeReaction(announcementId: String, name: String) {
mastodonApi.removeAnnouncementReaction(announcementId, name)
.subscribe({
.subscribe(
{
announcementsMutable.postValue(
Success(
announcements.value!!.data!!.map { announcement ->
if (announcement.id == announcementId) {
announcement.copy(
reactions = announcement.reactions.mapNotNull { reaction ->
if (reaction.name == name) {
if (reaction.count > 1) {
reaction.copy(
count = reaction.count - 1,
me = false
)
} else {
null
}
} else {
reaction
}
}
)
} else {
announcement
Success(
announcements.value!!.data!!.map { announcement ->
if (announcement.id == announcementId) {
announcement.copy(
reactions = announcement.reactions.mapNotNull { reaction ->
if (reaction.name == name) {
if (reaction.count > 1) {
reaction.copy(
count = reaction.count - 1,
me = false
)
} else {
null
}
} else {
reaction
}
}
}
)
)
} else {
announcement
}
}
)
)
}, {
},
{
Log.w(TAG, "Failed to remove reaction from the announcement.", it)
})
.autoDispose()
}
)
.autoDispose()
}
companion object {

View file

@ -16,7 +16,6 @@
package com.keylesspalace.tusky.components.compose
import android.Manifest
import android.app.Activity
import android.app.ProgressDialog
import android.content.Context
import android.content.Intent
@ -28,13 +27,16 @@ import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Parcelable
import android.provider.MediaStore
import android.util.Log
import android.view.KeyEvent
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.*
import android.widget.ImageButton
import android.widget.LinearLayout
import android.widget.PopupMenu
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.annotation.ColorInt
import androidx.annotation.StringRes
@ -71,7 +73,20 @@ import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.NewPoll
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.*
import com.keylesspalace.tusky.util.ComposeTokenizer
import com.keylesspalace.tusky.util.PickMediaFiles
import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.afterTextChanged
import com.keylesspalace.tusky.util.combineLiveData
import com.keylesspalace.tusky.util.combineOptionalLiveData
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.highlightSpans
import com.keylesspalace.tusky.util.loadAvatar
import com.keylesspalace.tusky.util.onTextChanged
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible
import com.keylesspalace.tusky.util.withLifecycleContext
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt
@ -84,13 +99,14 @@ import javax.inject.Inject
import kotlin.math.max
import kotlin.math.min
class ComposeActivity : BaseActivity(),
ComposeOptionsListener,
ComposeAutoCompleteAdapter.AutocompletionProvider,
OnEmojiSelectedListener,
Injectable,
InputConnectionCompat.OnCommitContentListener,
ComposeScheduleView.OnTimeSetListener {
class ComposeActivity :
BaseActivity(),
ComposeOptionsListener,
ComposeAutoCompleteAdapter.AutocompletionProvider,
OnEmojiSelectedListener,
Injectable,
InputConnectionCompat.OnCommitContentListener,
ComposeScheduleView.OnTimeSetListener {
@Inject
lateinit var viewModelFactory: ViewModelFactory
@ -114,6 +130,21 @@ class ComposeActivity : BaseActivity(),
private val maxUploadMediaNumber = 4
private var mediaCount = 0
private val takePicture = registerForActivityResult(ActivityResultContracts.TakePicture()) { success ->
if (success) {
pickMedia(photoUploadUri!!)
}
}
private val pickMediaFile = registerForActivityResult(PickMediaFiles()) { uris ->
if (mediaCount + uris.size > maxUploadMediaNumber) {
Toast.makeText(this, resources.getQuantityString(R.plurals.error_upload_max_media_reached, maxUploadMediaNumber, maxUploadMediaNumber), Toast.LENGTH_SHORT).show()
} else {
uris.forEach { uri ->
pickMedia(uri)
}
}
}
public override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -130,16 +161,16 @@ class ComposeActivity : BaseActivity(),
setupAvatar(preferences, activeAccount)
val mediaAdapter = MediaPreviewAdapter(
this,
onAddCaption = { item ->
makeCaptionDialog(item.description, item.uri) { newDescription ->
viewModel.updateDescription(item.localId, newDescription)
}
},
onRemove = this::removeMediaFromQueue
this,
onAddCaption = { item ->
makeCaptionDialog(item.description, item.uri) { newDescription ->
viewModel.updateDescription(item.localId, newDescription)
}
},
onRemove = this::removeMediaFromQueue
)
binding.composeMediaPreviewBar.layoutManager =
LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)
LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)
binding.composeMediaPreviewBar.adapter = mediaAdapter
binding.composeMediaPreviewBar.itemAnimator = null
@ -255,11 +286,11 @@ class ComposeActivity : BaseActivity(),
binding.composeEditField.setOnKeyListener { _, keyCode, event -> this.onKeyDown(keyCode, event) }
binding.composeEditField.setAdapter(
ComposeAutoCompleteAdapter(
this,
preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false),
preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
)
ComposeAutoCompleteAdapter(
this,
preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false),
preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
)
)
binding.composeEditField.setTokenizer(ComposeTokenizer())
@ -274,8 +305,9 @@ class ComposeActivity : BaseActivity(),
}
// work around Android platform bug -> https://issuetracker.google.com/issues/67102093
if (Build.VERSION.SDK_INT == Build.VERSION_CODES.O
|| Build.VERSION.SDK_INT == Build.VERSION_CODES.O_MR1) {
if (Build.VERSION.SDK_INT == Build.VERSION_CODES.O ||
Build.VERSION.SDK_INT == Build.VERSION_CODES.O_MR1
) {
binding.composeEditField.setLayerType(View.LAYER_TYPE_SOFTWARE, null)
}
}
@ -316,9 +348,9 @@ class ComposeActivity : BaseActivity(),
updateScheduleButton()
}
combineOptionalLiveData(viewModel.media, viewModel.poll) { media, poll ->
val active = poll == null
&& media!!.size != 4
&& (media.isEmpty() || media.first().type == QueuedMedia.Type.IMAGE)
val active = poll == null &&
media!!.size != 4 &&
(media.isEmpty() || media.first().type == QueuedMedia.Type.IMAGE)
enableButton(binding.composeAddMediaButton, active, active)
enablePollButton(media.isNullOrEmpty())
}.subscribe()
@ -379,7 +411,6 @@ class ComposeActivity : BaseActivity(),
setDisplayShowHomeEnabled(true)
setHomeAsUpIndicator(R.drawable.ic_close_24dp)
}
}
private fun setupAvatar(preferences: SharedPreferences, activeAccount: AccountEntity) {
@ -390,13 +421,15 @@ class ComposeActivity : BaseActivity(),
val animateAvatars = preferences.getBoolean("animateGifAvatars", false)
loadAvatar(
activeAccount.profilePictureUrl,
binding.composeAvatar,
avatarSize / 8,
animateAvatars
activeAccount.profilePictureUrl,
binding.composeAvatar,
avatarSize / 8,
animateAvatars
)
binding.composeAvatar.contentDescription = getString(
R.string.compose_active_account_description,
activeAccount.fullName
)
binding.composeAvatar.contentDescription = getString(R.string.compose_active_account_description,
activeAccount.fullName)
}
private fun replaceTextAtCaret(text: CharSequence) {
@ -454,7 +487,6 @@ class ComposeActivity : BaseActivity(),
}
}
private fun atButtonClicked() {
prependSelectedWordsWith("@")
}
@ -470,7 +502,7 @@ class ComposeActivity : BaseActivity(),
private fun displayTransientError(@StringRes stringId: Int) {
val bar = Snackbar.make(binding.activityCompose, stringId, Snackbar.LENGTH_LONG)
//necessary so snackbar is shown over everything
// necessary so snackbar is shown over everything
bar.view.elevation = resources.getDimension(R.dimen.compose_activity_snackbar_elevation)
bar.show()
}
@ -488,7 +520,6 @@ class ComposeActivity : BaseActivity(),
binding.composeHideMediaButton.setImageResource(R.drawable.ic_hide_media_24dp)
binding.composeHideMediaButton.isClickable = false
ContextCompat.getColor(this, R.color.transparent_chinwag_green)
} else {
binding.composeHideMediaButton.isClickable = true
if (markMediaSensitive) {
@ -597,15 +628,17 @@ class ComposeActivity : BaseActivity(),
private fun onMediaPick() {
addMediaBehavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() {
override fun onStateChanged(bottomSheet: View, newState: Int) {
//Wait until bottom sheet is not collapsed and show next screen after
// Wait until bottom sheet is not collapsed and show next screen after
if (newState == BottomSheetBehavior.STATE_COLLAPSED) {
addMediaBehavior.removeBottomSheetCallback(this)
if (ContextCompat.checkSelfPermission(this@ComposeActivity, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this@ComposeActivity,
arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE),
PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE)
ActivityCompat.requestPermissions(
this@ComposeActivity,
arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE),
PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE
)
} else {
initiateMediaPicking()
pickMediaFile.launch(true)
}
}
}
@ -619,8 +652,10 @@ class ComposeActivity : BaseActivity(),
private fun openPollDialog() {
addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
val instanceParams = viewModel.instanceParams.value!!
showAddPollDialog(this, viewModel.poll.value, instanceParams.pollMaxOptions,
instanceParams.pollMaxLength, viewModel::updatePoll)
showAddPollDialog(
this, viewModel.poll.value, instanceParams.pollMaxOptions,
instanceParams.pollMaxLength, viewModel::updatePoll
)
}
private fun setupPollView() {
@ -740,35 +775,40 @@ class ComposeActivity : BaseActivity(),
} else if (characterCount <= maximumTootCharacters) {
if (viewModel.media.value!!.isNotEmpty()) {
finishingUploadDialog = ProgressDialog.show(
this, getString(R.string.dialog_title_finishing_media_upload),
getString(R.string.dialog_message_uploading_media), true, true)
this, getString(R.string.dialog_title_finishing_media_upload),
getString(R.string.dialog_message_uploading_media), true, true
)
}
viewModel.sendStatus(contentText, spoilerText).observe(this, {
finishingUploadDialog?.dismiss()
deleteDraftAndFinish()
})
viewModel.sendStatus(contentText, spoilerText).observe(
this,
{
finishingUploadDialog?.dismiss()
deleteDraftAndFinish()
}
)
} else {
binding.composeEditField.error = getString(R.string.error_compose_character_limit)
enableButtons(true)
}
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>,
grantResults: IntArray) {
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE) {
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
initiateMediaPicking()
pickMediaFile.launch(true)
} else {
val bar = Snackbar.make(binding.activityCompose, R.string.error_media_upload_permission,
Snackbar.LENGTH_SHORT).apply {
Snackbar.make(
binding.activityCompose, R.string.error_media_upload_permission,
Snackbar.LENGTH_SHORT
).apply {
setAction(R.string.action_retry) { onMediaPick() }
// necessary so snackbar is shown over everything
view.elevation = resources.getDimension(R.dimen.compose_activity_snackbar_elevation)
show()
}
bar.setAction(R.string.action_retry) { onMediaPick() }
//necessary so snackbar is shown over everything
bar.view.elevation = resources.getDimension(R.dimen.compose_activity_snackbar_elevation)
bar.show()
}
}
}
@ -776,50 +816,38 @@ class ComposeActivity : BaseActivity(),
private fun initiateCameraApp() {
addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
// We don't need to ask for permission in this case, because the used calls require
// android.permission.WRITE_EXTERNAL_STORAGE only on SDKs *older* than Kitkat, which was
// way before permission dialogues have been introduced.
val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
if (intent.resolveActivity(packageManager) != null) {
val photoFile: File = try {
createNewImageFile(this)
} catch (ex: IOException) {
displayTransientError(R.string.error_media_upload_opening)
return
}
// Continue only if the File was successfully created
photoUploadUri = FileProvider.getUriForFile(this,
BuildConfig.APPLICATION_ID + ".fileprovider",
photoFile)
intent.putExtra(MediaStore.EXTRA_OUTPUT, photoUploadUri)
startActivityForResult(intent, MEDIA_TAKE_PHOTO_RESULT)
val photoFile: File = try {
createNewImageFile(this)
} catch (ex: IOException) {
displayTransientError(R.string.error_media_upload_opening)
return
}
}
private fun initiateMediaPicking() {
val intent = Intent(Intent.ACTION_GET_CONTENT)
intent.addCategory(Intent.CATEGORY_OPENABLE)
val mimeTypes = arrayOf("image/*", "video/*", "audio/*")
intent.type = "*/*"
intent.putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes)
intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
startActivityForResult(intent, MEDIA_PICK_RESULT)
// Continue only if the File was successfully created
photoUploadUri = FileProvider.getUriForFile(
this,
BuildConfig.APPLICATION_ID + ".fileprovider",
photoFile
)
takePicture.launch(photoUploadUri)
}
private fun enableButton(button: ImageButton, clickable: Boolean, colorActive: Boolean) {
button.isEnabled = clickable
ThemeUtils.setDrawableTint(this, button.drawable,
if (colorActive) android.R.attr.textColorTertiary
else R.attr.textColorDisabled)
ThemeUtils.setDrawableTint(
this, button.drawable,
if (colorActive) android.R.attr.textColorTertiary
else R.attr.textColorDisabled
)
}
private fun enablePollButton(enable: Boolean) {
binding.addPollTextActionTextView.isEnabled = enable
val textColor = ThemeUtils.getColor(this,
if (enable) android.R.attr.textColorTertiary
else R.attr.textColorDisabled)
val textColor = ThemeUtils.getColor(
this,
if (enable) android.R.attr.textColorTertiary
else R.attr.textColorDisabled
)
binding.addPollTextActionTextView.setTextColor(textColor)
binding.addPollTextActionTextView.compoundDrawablesRelative[0].colorFilter = PorterDuffColorFilter(textColor, PorterDuff.Mode.SRC_IN)
}
@ -828,31 +856,6 @@ class ComposeActivity : BaseActivity(),
viewModel.removeMediaFromQueue(item)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) {
super.onActivityResult(requestCode, resultCode, intent)
if (resultCode == Activity.RESULT_OK && requestCode == MEDIA_PICK_RESULT && intent != null) {
if (intent.data != null) {
// Single media, upload it and done.
pickMedia(intent.data!!)
} else if (intent.clipData != null) {
val clipData = intent.clipData!!
val count = clipData.itemCount
if (mediaCount + count > maxUploadMediaNumber) {
// check if exist media + upcoming media > 4, then prob error message.
Toast.makeText(this, resources.getQuantityString(R.plurals.error_upload_max_media_reached, maxUploadMediaNumber, maxUploadMediaNumber), Toast.LENGTH_SHORT).show()
} else {
// if not grater then 4, upload all multiple media.
for (i in 0 until count) {
val imageUri = clipData.getItemAt(i).getUri()
pickMedia(imageUri)
}
}
}
} else if (resultCode == Activity.RESULT_OK && requestCode == MEDIA_TAKE_PHOTO_RESULT) {
pickMedia(photoUploadUri!!)
}
}
private fun pickMedia(uri: Uri, contentInfoCompat: InputContentInfoCompat? = null) {
withLifecycleContext {
viewModel.pickMedia(uri).observe { exceptionOrItem ->
@ -876,7 +879,6 @@ class ComposeActivity : BaseActivity(),
}
displayTransientError(errorId)
}
}
}
}
@ -908,9 +910,10 @@ class ComposeActivity : BaseActivity(),
override fun onBackPressed() {
// Acting like a teen: deliberately ignoring parent.
if (composeOptionsBehavior.state == BottomSheetBehavior.STATE_EXPANDED ||
addMediaBehavior.state == BottomSheetBehavior.STATE_EXPANDED ||
emojiBehavior.state == BottomSheetBehavior.STATE_EXPANDED ||
scheduleBehavior.state == BottomSheetBehavior.STATE_EXPANDED) {
addMediaBehavior.state == BottomSheetBehavior.STATE_EXPANDED ||
emojiBehavior.state == BottomSheetBehavior.STATE_EXPANDED ||
scheduleBehavior.state == BottomSheetBehavior.STATE_EXPANDED
) {
composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN
addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN
emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN
@ -945,12 +948,12 @@ class ComposeActivity : BaseActivity(),
val contentWarning = binding.composeContentWarningField.text.toString()
if (viewModel.didChange(contentText, contentWarning)) {
AlertDialog.Builder(this)
.setMessage(R.string.compose_save_draft)
.setPositiveButton(R.string.action_save) { _, _ ->
saveDraftAndFinish(contentText, contentWarning)
}
.setNegativeButton(R.string.action_delete) { _, _ -> deleteDraftAndFinish() }
.show()
.setMessage(R.string.compose_save_draft)
.setPositiveButton(R.string.action_save) { _, _ ->
saveDraftAndFinish(contentText, contentWarning)
}
.setNegativeButton(R.string.action_delete) { _, _ -> deleteDraftAndFinish() }
.show()
} else {
finishWithoutSlideOutAnimation()
}
@ -982,13 +985,13 @@ class ComposeActivity : BaseActivity(),
}
data class QueuedMedia(
val localId: Long,
val uri: Uri,
val type: Type,
val mediaSize: Long,
val uploadPercent: Int = 0,
val id: String? = null,
val description: String? = null
val localId: Long,
val uri: Uri,
val type: Type,
val mediaSize: Long,
val uploadPercent: Int = 0,
val id: String? = null,
val description: String? = null
) {
enum class Type {
IMAGE, VIDEO, AUDIO;
@ -1011,32 +1014,29 @@ class ComposeActivity : BaseActivity(),
@Parcelize
data class ComposeOptions(
// Let's keep fields var until all consumers are Kotlin
var scheduledTootId: String? = null,
var savedTootUid: Int? = null,
var draftId: Int? = null,
var tootText: String? = null,
var mediaUrls: List<String>? = null,
var mediaDescriptions: List<String>? = null,
var mentionedUsernames: Set<String>? = null,
var inReplyToId: String? = null,
var replyVisibility: Status.Visibility? = null,
var visibility: Status.Visibility? = null,
var contentWarning: String? = null,
var replyingStatusAuthor: String? = null,
var replyingStatusContent: String? = null,
var mediaAttachments: List<Attachment>? = null,
var draftAttachments: List<DraftAttachment>? = null,
var scheduledAt: String? = null,
var sensitive: Boolean? = null,
var poll: NewPoll? = null,
var modifiedInitialState: Boolean? = null
// Let's keep fields var until all consumers are Kotlin
var scheduledTootId: String? = null,
var draftId: Int? = null,
var tootText: String? = null,
var mediaUrls: List<String>? = null,
var mediaDescriptions: List<String>? = null,
var mentionedUsernames: Set<String>? = null,
var inReplyToId: String? = null,
var replyVisibility: Status.Visibility? = null,
var visibility: Status.Visibility? = null,
var contentWarning: String? = null,
var replyingStatusAuthor: String? = null,
var replyingStatusContent: String? = null,
var mediaAttachments: List<Attachment>? = null,
var draftAttachments: List<DraftAttachment>? = null,
var scheduledAt: String? = null,
var sensitive: Boolean? = null,
var poll: NewPoll? = null,
var modifiedInitialState: Boolean? = null
) : Parcelable
companion object {
private const val TAG = "ComposeActivity" // logging tag
private const val MEDIA_PICK_RESULT = 1
private const val MEDIA_TAKE_PHOTO_RESULT = 2
private const val PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE = 1
internal const val COMPOSE_OPTIONS_EXTRA = "COMPOSE_OPTIONS"

View file

@ -21,38 +21,48 @@ import androidx.core.net.toUri
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer
import androidx.lifecycle.viewModelScope
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
import com.keylesspalace.tusky.components.drafts.DraftHelper
import com.keylesspalace.tusky.components.search.SearchType
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.InstanceEntity
import com.keylesspalace.tusky.entity.*
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.NewPoll
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.service.ServiceClient
import com.keylesspalace.tusky.service.TootToSend
import com.keylesspalace.tusky.util.*
import io.reactivex.Observable.just
import io.reactivex.disposables.Disposable
import io.reactivex.rxkotlin.Singles
import java.util.*
import com.keylesspalace.tusky.util.Either
import com.keylesspalace.tusky.util.RxAwareViewModel
import com.keylesspalace.tusky.util.VersionUtils
import com.keylesspalace.tusky.util.combineLiveData
import com.keylesspalace.tusky.util.filter
import com.keylesspalace.tusky.util.map
import com.keylesspalace.tusky.util.randomAlphanumericString
import com.keylesspalace.tusky.util.toLiveData
import com.keylesspalace.tusky.util.withoutFirstWhich
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.Disposable
import kotlinx.coroutines.launch
import java.util.Locale
import javax.inject.Inject
class ComposeViewModel @Inject constructor(
private val api: MastodonApi,
private val accountManager: AccountManager,
private val mediaUploader: MediaUploader,
private val serviceClient: ServiceClient,
private val draftHelper: DraftHelper,
private val saveTootHelper: SaveTootHelper,
private val db: AppDatabase
private val api: MastodonApi,
private val accountManager: AccountManager,
private val mediaUploader: MediaUploader,
private val serviceClient: ServiceClient,
private val draftHelper: DraftHelper,
private val db: AppDatabase
) : RxAwareViewModel() {
private var replyingStatusAuthor: String? = null
private var replyingStatusContent: String? = null
internal var startingText: String? = null
private var savedTootUid: Int = 0
private var draftId: Int = 0
private var scheduledTootId: String? = null
private var startingContentWarning: String = ""
@ -66,15 +76,15 @@ class ComposeViewModel @Inject constructor(
val instanceParams: LiveData<ComposeInstanceParams> = instance.map { instance ->
ComposeInstanceParams(
maxChars = instance?.maximumTootCharacters ?: DEFAULT_CHARACTER_LIMIT,
pollMaxOptions = instance?.maxPollOptions ?: DEFAULT_MAX_OPTION_COUNT,
pollMaxLength = instance?.maxPollOptionLength ?: DEFAULT_MAX_OPTION_LENGTH,
supportsScheduled = instance?.version?.let { VersionUtils(it).supportsScheduledToots() } ?: false
maxChars = instance?.maximumTootCharacters ?: DEFAULT_CHARACTER_LIMIT,
pollMaxOptions = instance?.maxPollOptions ?: DEFAULT_MAX_OPTION_COUNT,
pollMaxLength = instance?.maxPollOptionLength ?: DEFAULT_MAX_OPTION_LENGTH,
supportsScheduled = instance?.version?.let { VersionUtils(it).supportsScheduledToots() } ?: false
)
}
val emoji: MutableLiveData<List<Emoji>?> = MutableLiveData()
val markMediaAsSensitive =
mutableLiveData(accountManager.activeAccount?.defaultMediaSensitivity ?: false)
mutableLiveData(accountManager.activeAccount?.defaultMediaSensitivity ?: false)
val statusVisibility = mutableLiveData(Status.Visibility.UNKNOWN)
val showContentWarning = mutableLiveData(false)
@ -91,30 +101,36 @@ class ComposeViewModel @Inject constructor(
init {
Singles.zip(api.getCustomEmojis(), api.getInstance()) { emojis, instance ->
InstanceEntity(
Single.zip(
api.getCustomEmojis(), api.getInstance(),
{ emojis, instance ->
InstanceEntity(
instance = accountManager.activeAccount?.domain!!,
emojiList = emojis,
maximumTootCharacters = instance.maxTootChars,
maxPollOptions = instance.pollLimits?.maxOptions,
maxPollOptionLength = instance.pollLimits?.maxOptionChars,
version = instance.version
)
}
.doOnSuccess {
db.instanceDao().insertOrReplace(it)
}
.onErrorResumeNext(
db.instanceDao().loadMetadataForInstance(accountManager.activeAccount?.domain!!)
)
.subscribe({ instanceEntity ->
}
)
.doOnSuccess {
db.instanceDao().insertOrReplace(it)
}
.onErrorResumeNext {
db.instanceDao().loadMetadataForInstance(accountManager.activeAccount?.domain!!)
}
.subscribe(
{ instanceEntity ->
emoji.postValue(instanceEntity.emojiList)
instance.postValue(instanceEntity)
}, { throwable ->
},
{ throwable ->
// this can happen on network error when no cached data is available
Log.w(TAG, "error loading instance data", throwable)
})
.autoDispose()
}
)
.autoDispose()
}
fun pickMedia(uri: Uri, description: String? = null): LiveData<Either<Throwable, QueuedMedia>> {
@ -122,44 +138,49 @@ class ComposeViewModel @Inject constructor(
// the Activity goes away temporarily (like on screen rotation).
val liveData = MutableLiveData<Either<Throwable, QueuedMedia>>()
mediaUploader.prepareMedia(uri)
.map { (type, uri, size) ->
val mediaItems = media.value!!
if (type != QueuedMedia.Type.IMAGE
&& mediaItems.isNotEmpty()
&& mediaItems[0].type == QueuedMedia.Type.IMAGE) {
throw VideoOrImageException()
} else {
addMediaToQueue(type, uri, size, description)
}
.map { (type, uri, size) ->
val mediaItems = media.value!!
if (type != QueuedMedia.Type.IMAGE &&
mediaItems.isNotEmpty() &&
mediaItems[0].type == QueuedMedia.Type.IMAGE
) {
throw VideoOrImageException()
} else {
addMediaToQueue(type, uri, size, description)
}
.subscribe({ queuedMedia ->
}
.subscribe(
{ queuedMedia ->
liveData.postValue(Either.Right(queuedMedia))
}, { error ->
},
{ error ->
liveData.postValue(Either.Left(error))
})
.autoDispose()
}
)
.autoDispose()
return liveData
}
private fun addMediaToQueue(
type: QueuedMedia.Type,
uri: Uri,
mediaSize: Long,
description: String? = null
type: QueuedMedia.Type,
uri: Uri,
mediaSize: Long,
description: String? = null
): QueuedMedia {
val mediaItem = QueuedMedia(
localId = System.currentTimeMillis(),
uri = uri,
type = type,
mediaSize = mediaSize,
description = description
localId = System.currentTimeMillis(),
uri = uri,
type = type,
mediaSize = mediaSize,
description = description
)
media.value = media.value!! + mediaItem
mediaToDisposable[mediaItem.localId] = mediaUploader
.uploadMedia(mediaItem)
.subscribe({ event ->
.uploadMedia(mediaItem)
.subscribe(
{ event ->
val item = media.value?.find { it.localId == mediaItem.localId }
?: return@subscribe
?: return@subscribe
val newMediaItem = when (event) {
is UploadEvent.ProgressEvent ->
item.copy(uploadPercent = event.percentage)
@ -169,16 +190,20 @@ class ComposeViewModel @Inject constructor(
synchronized(media) {
val mediaValue = media.value!!
val index = mediaValue.indexOfFirst { it.localId == newMediaItem.localId }
media.postValue(if (index == -1) {
mediaValue + newMediaItem
} else {
mediaValue.toMutableList().also { it[index] = newMediaItem }
})
media.postValue(
if (index == -1) {
mediaValue + newMediaItem
} else {
mediaValue.toMutableList().also { it[index] = newMediaItem }
}
)
}
}, { error ->
},
{ error ->
media.postValue(media.value?.filter { it.localId != mediaItem.localId } ?: emptyList())
uploadError.postValue(error)
})
}
)
return mediaItem
}
@ -198,12 +223,14 @@ class ComposeViewModel @Inject constructor(
fun didChange(content: String?, contentWarning: String?): Boolean {
val textChanged = !(content.isNullOrEmpty()
|| startingText?.startsWith(content.toString()) ?: false)
val textChanged = !(
content.isNullOrEmpty() ||
startingText?.startsWith(content.toString()) ?: false
)
val contentWarningChanged = showContentWarning.value!!
&& !contentWarning.isNullOrEmpty()
&& !startingContentWarning.startsWith(contentWarning.toString())
val contentWarningChanged = showContentWarning.value!! &&
!contentWarning.isNullOrEmpty() &&
!startingContentWarning.startsWith(contentWarning.toString())
val mediaChanged = !media.value.isNullOrEmpty()
val pollChanged = poll.value != null
@ -216,25 +243,23 @@ class ComposeViewModel @Inject constructor(
}
fun deleteDraft() {
if (savedTootUid != 0) {
saveTootHelper.deleteDraft(savedTootUid)
}
if (draftId != 0) {
draftHelper.deleteDraftAndAttachments(draftId)
.subscribe()
viewModelScope.launch {
if (draftId != 0) {
draftHelper.deleteDraftAndAttachments(draftId)
}
}
}
fun saveDraft(content: String, contentWarning: String) {
viewModelScope.launch {
val mediaUris: MutableList<String> = mutableListOf()
val mediaDescriptions: MutableList<String?> = mutableListOf()
media.value?.forEach { item ->
mediaUris.add(item.uri.toString())
mediaDescriptions.add(item.description)
}
val mediaUris: MutableList<String> = mutableListOf()
val mediaDescriptions: MutableList<String?> = mutableListOf()
media.value?.forEach { item ->
mediaUris.add(item.uri.toString())
mediaDescriptions.add(item.description)
}
draftHelper.saveDraft(
draftHelper.saveDraft(
draftId = draftId,
accountId = accountManager.activeAccount?.id!!,
inReplyToId = inReplyToId,
@ -246,7 +271,8 @@ class ComposeViewModel @Inject constructor(
mediaDescriptions = mediaDescriptions,
poll = poll.value,
failedToSend = false
).subscribe()
)
}
}
/**
@ -255,51 +281,50 @@ class ComposeViewModel @Inject constructor(
* @return LiveData which will signal once the screen can be closed or null if there are errors
*/
fun sendStatus(
content: String,
spoilerText: String
content: String,
spoilerText: String
): LiveData<Unit> {
val deletionObservable = if (isEditingScheduledToot) {
api.deleteScheduledStatus(scheduledTootId.toString()).toObservable().map { }
} else {
just(Unit)
Observable.just(Unit)
}.toLiveData()
val sendObservable = media
.filter { items -> items.all { it.uploadPercent == -1 } }
.map {
val mediaIds = ArrayList<String>()
val mediaUris = ArrayList<Uri>()
val mediaDescriptions = ArrayList<String>()
for (item in media.value!!) {
mediaIds.add(item.id!!)
mediaUris.add(item.uri)
mediaDescriptions.add(item.description ?: "")
}
val tootToSend = TootToSend(
text = content,
warningText = spoilerText,
visibility = statusVisibility.value!!.serverString(),
sensitive = mediaUris.isNotEmpty() && (markMediaAsSensitive.value!! || showContentWarning.value!!),
mediaIds = mediaIds,
mediaUris = mediaUris.map { it.toString() },
mediaDescriptions = mediaDescriptions,
scheduledAt = scheduledAt.value,
inReplyToId = inReplyToId,
poll = poll.value,
replyingStatusContent = null,
replyingStatusAuthorUsername = null,
accountId = accountManager.activeAccount!!.id,
savedTootUid = savedTootUid,
draftId = draftId,
idempotencyKey = randomAlphanumericString(16),
retries = 0
)
serviceClient.sendToot(tootToSend)
.filter { items -> items.all { it.uploadPercent == -1 } }
.map {
val mediaIds = ArrayList<String>()
val mediaUris = ArrayList<Uri>()
val mediaDescriptions = ArrayList<String>()
for (item in media.value!!) {
mediaIds.add(item.id!!)
mediaUris.add(item.uri)
mediaDescriptions.add(item.description ?: "")
}
val tootToSend = TootToSend(
text = content,
warningText = spoilerText,
visibility = statusVisibility.value!!.serverString(),
sensitive = mediaUris.isNotEmpty() && (markMediaAsSensitive.value!! || showContentWarning.value!!),
mediaIds = mediaIds,
mediaUris = mediaUris.map { it.toString() },
mediaDescriptions = mediaDescriptions,
scheduledAt = scheduledAt.value,
inReplyToId = inReplyToId,
poll = poll.value,
replyingStatusContent = null,
replyingStatusAuthorUsername = null,
accountId = accountManager.activeAccount!!.id,
draftId = draftId,
idempotencyKey = randomAlphanumericString(16),
retries = 0
)
serviceClient.sendToot(tootToSend)
}
return combineLiveData(deletionObservable, sendObservable) { _, _ -> }
}
@ -318,12 +343,15 @@ class ComposeViewModel @Inject constructor(
media.removeObserver(this)
} else if (updatedItem.id != null) {
api.updateMedia(updatedItem.id, description)
.subscribe({
.subscribe(
{
completedCaptioningLiveData.postValue(true)
}, {
},
{
completedCaptioningLiveData.postValue(false)
})
.autoDispose()
}
)
.autoDispose()
media.removeObserver(this)
}
}
@ -336,8 +364,8 @@ class ComposeViewModel @Inject constructor(
'@' -> {
return try {
api.searchAccounts(query = token.substring(1), limit = 10)
.blockingGet()
.map { ComposeAutoCompleteAdapter.AccountResult(it) }
.blockingGet()
.map { ComposeAutoCompleteAdapter.AccountResult(it) }
} catch (e: Throwable) {
Log.e(TAG, String.format("Autocomplete search for %s failed.", token), e)
emptyList()
@ -346,9 +374,9 @@ class ComposeViewModel @Inject constructor(
'#' -> {
return try {
api.searchObservable(query = token, type = SearchType.Hashtag.apiParameter, limit = 10)
.blockingGet()
.hashtags
.map { ComposeAutoCompleteAdapter.HashtagResult(it) }
.blockingGet()
.hashtags
.map { ComposeAutoCompleteAdapter.HashtagResult(it) }
} catch (e: Throwable) {
Log.e(TAG, String.format("Autocomplete search for %s failed.", token), e)
emptyList()
@ -357,11 +385,11 @@ class ComposeViewModel @Inject constructor(
':' -> {
val emojiList = emoji.value ?: return emptyList()
val incomplete = token.substring(1).toLowerCase(Locale.ROOT)
val incomplete = token.substring(1).lowercase(Locale.ROOT)
val results = ArrayList<ComposeAutoCompleteAdapter.AutocompleteResult>()
val resultsInside = ArrayList<ComposeAutoCompleteAdapter.AutocompleteResult>()
for (emoji in emojiList) {
val shortcode = emoji.shortcode.toLowerCase(Locale.ROOT)
val shortcode = emoji.shortcode.lowercase(Locale.ROOT)
if (shortcode.startsWith(incomplete)) {
results.add(ComposeAutoCompleteAdapter.EmojiResult(emoji))
} else if (shortcode.indexOf(incomplete, 1) != -1) {
@ -391,7 +419,8 @@ class ComposeViewModel @Inject constructor(
val replyVisibility = composeOptions?.replyVisibility ?: Status.Visibility.UNKNOWN
startingVisibility = Status.Visibility.byNum(
preferredVisibility.num.coerceAtLeast(replyVisibility.num))
preferredVisibility.num.coerceAtLeast(replyVisibility.num)
)
inReplyToId = composeOptions?.inReplyToId
@ -406,20 +435,8 @@ class ComposeViewModel @Inject constructor(
}
// recreate media list
val loadedDraftMediaUris = composeOptions?.mediaUrls
val loadedDraftMediaDescriptions: List<String?>? = composeOptions?.mediaDescriptions
val draftAttachments = composeOptions?.draftAttachments
if (loadedDraftMediaUris != null && loadedDraftMediaDescriptions != null) {
// when coming from SavedTootActivity
loadedDraftMediaUris.zip(loadedDraftMediaDescriptions)
.forEach { (uri, description) ->
pickMedia(uri.toUri()).observeForever { errorOrItem ->
if (errorOrItem.isRight() && description != null) {
updateDescription(errorOrItem.asRight().localId, description)
}
}
}
} else if (draftAttachments != null) {
if (draftAttachments != null) {
// when coming from DraftActivity
draftAttachments.forEach { attachment -> pickMedia(attachment.uri, attachment.description) }
} else composeOptions?.mediaAttachments?.forEach { a ->
@ -432,7 +449,6 @@ class ComposeViewModel @Inject constructor(
addUploadedMedia(a.id, mediaType, a.url.toUri(), a.description)
}
savedTootUid = composeOptions?.savedTootUid ?: 0
draftId = composeOptions?.draftId ?: 0
scheduledTootId = composeOptions?.scheduledTootId
startingText = composeOptions?.tootText
@ -483,20 +499,19 @@ class ComposeViewModel @Inject constructor(
private companion object {
const val TAG = "ComposeViewModel"
}
}
fun <T> mutableLiveData(default: T) = MutableLiveData<T>().apply { value = default }
const val DEFAULT_CHARACTER_LIMIT = 500
private const val DEFAULT_MAX_OPTION_COUNT = 4
private const val DEFAULT_MAX_OPTION_LENGTH = 25
private const val DEFAULT_MAX_OPTION_LENGTH = 50
data class ComposeInstanceParams(
val maxChars: Int,
val pollMaxOptions: Int,
val pollMaxLength: Int,
val supportsScheduled: Boolean
val maxChars: Int,
val pollMaxOptions: Int,
val pollMaxLength: Int,
val supportsScheduled: Boolean
)
/**

View file

@ -30,9 +30,9 @@ import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.compose.view.ProgressImageView
class MediaPreviewAdapter(
context: Context,
private val onAddCaption: (ComposeActivity.QueuedMedia) -> Unit,
private val onRemove: (ComposeActivity.QueuedMedia) -> Unit
context: Context,
private val onAddCaption: (ComposeActivity.QueuedMedia) -> Unit,
private val onRemove: (ComposeActivity.QueuedMedia) -> Unit
) : RecyclerView.Adapter<MediaPreviewAdapter.PreviewViewHolder>() {
fun submitList(list: List<ComposeActivity.QueuedMedia>) {
@ -57,7 +57,7 @@ class MediaPreviewAdapter(
}
private val thumbnailViewSize =
context.resources.getDimensionPixelSize(R.dimen.compose_media_preview_size)
context.resources.getDimensionPixelSize(R.dimen.compose_media_preview_size)
override fun getItemCount(): Int = differ.currentList.size
@ -74,31 +74,34 @@ class MediaPreviewAdapter(
holder.progressImageView.setImageResource(R.drawable.ic_music_box_preview_24dp)
} else {
Glide.with(holder.itemView.context)
.load(item.uri)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.dontAnimate()
.into(holder.progressImageView)
.load(item.uri)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.dontAnimate()
.into(holder.progressImageView)
}
}
private val differ = AsyncListDiffer(this, object : DiffUtil.ItemCallback<ComposeActivity.QueuedMedia>() {
override fun areItemsTheSame(oldItem: ComposeActivity.QueuedMedia, newItem: ComposeActivity.QueuedMedia): Boolean {
return oldItem.localId == newItem.localId
}
private val differ = AsyncListDiffer(
this,
object : DiffUtil.ItemCallback<ComposeActivity.QueuedMedia>() {
override fun areItemsTheSame(oldItem: ComposeActivity.QueuedMedia, newItem: ComposeActivity.QueuedMedia): Boolean {
return oldItem.localId == newItem.localId
}
override fun areContentsTheSame(oldItem: ComposeActivity.QueuedMedia, newItem: ComposeActivity.QueuedMedia): Boolean {
return oldItem == newItem
override fun areContentsTheSame(oldItem: ComposeActivity.QueuedMedia, newItem: ComposeActivity.QueuedMedia): Boolean {
return oldItem == newItem
}
}
})
)
inner class PreviewViewHolder(val progressImageView: ProgressImageView)
: RecyclerView.ViewHolder(progressImageView) {
inner class PreviewViewHolder(val progressImageView: ProgressImageView) :
RecyclerView.ViewHolder(progressImageView) {
init {
val layoutParams = ConstraintLayout.LayoutParams(thumbnailViewSize, thumbnailViewSize)
val margin = itemView.context.resources
.getDimensionPixelSize(R.dimen.compose_media_preview_margin)
.getDimensionPixelSize(R.dimen.compose_media_preview_margin)
val marginBottom = itemView.context.resources
.getDimensionPixelSize(R.dimen.compose_media_preview_margin_bottom)
.getDimensionPixelSize(R.dimen.compose_media_preview_margin_bottom)
layoutParams.setMargins(margin, 0, margin, marginBottom)
progressImageView.layoutParams = layoutParams
progressImageView.scaleType = ImageView.ScaleType.CENTER_CROP

View file

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

View file

@ -26,33 +26,33 @@ import com.keylesspalace.tusky.databinding.DialogAddPollBinding
import com.keylesspalace.tusky.entity.NewPoll
fun showAddPollDialog(
context: Context,
poll: NewPoll?,
maxOptionCount: Int,
maxOptionLength: Int,
onUpdatePoll: (NewPoll) -> Unit
context: Context,
poll: NewPoll?,
maxOptionCount: Int,
maxOptionLength: Int,
onUpdatePoll: (NewPoll) -> Unit
) {
val binding = DialogAddPollBinding.inflate(LayoutInflater.from(context))
val dialog = AlertDialog.Builder(context)
.setIcon(R.drawable.ic_poll_24dp)
.setTitle(R.string.create_poll_title)
.setView(binding.root)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(android.R.string.ok, null)
.create()
.setIcon(R.drawable.ic_poll_24dp)
.setTitle(R.string.create_poll_title)
.setView(binding.root)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(android.R.string.ok, null)
.create()
val adapter = AddPollOptionsAdapter(
options = poll?.options?.toMutableList() ?: mutableListOf("", ""),
maxOptionLength = maxOptionLength,
onOptionRemoved = { valid ->
binding.addChoiceButton.isEnabled = true
dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = valid
},
onOptionChanged = { valid ->
dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = valid
}
options = poll?.options?.toMutableList() ?: mutableListOf("", ""),
maxOptionLength = maxOptionLength,
onOptionRemoved = { valid ->
binding.addChoiceButton.isEnabled = true
dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = valid
},
onOptionChanged = { valid ->
dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = valid
}
)
binding.pollChoices.adapter = adapter
@ -80,13 +80,15 @@ fun showAddPollDialog(
val selectedPollDurationId = binding.pollDurationSpinner.selectedItemPosition
val pollDuration = context.resources
.getIntArray(R.array.poll_duration_values)[selectedPollDurationId]
.getIntArray(R.array.poll_duration_values)[selectedPollDurationId]
onUpdatePoll(NewPoll(
onUpdatePoll(
NewPoll(
options = adapter.pollOptions,
expiresIn = pollDuration,
multiple = binding.multipleChoicesCheckBox.isChecked
))
)
)
dialog.dismiss()
}

View file

@ -27,11 +27,11 @@ import com.keylesspalace.tusky.util.onTextChanged
import com.keylesspalace.tusky.util.visible
class AddPollOptionsAdapter(
private var options: MutableList<String>,
private val maxOptionLength: Int,
private val onOptionRemoved: (Boolean) -> Unit,
private val onOptionChanged: (Boolean) -> Unit
): RecyclerView.Adapter<BindingHolder<ItemAddPollOptionBinding>>() {
private var options: MutableList<String>,
private val maxOptionLength: Int,
private val onOptionRemoved: (Boolean) -> Unit,
private val onOptionChanged: (Boolean) -> Unit
) : RecyclerView.Adapter<BindingHolder<ItemAddPollOptionBinding>>() {
val pollOptions: List<String>
get() = options.toList()
@ -47,8 +47,8 @@ class AddPollOptionsAdapter(
binding.optionEditText.filters = arrayOf(InputFilter.LengthFilter(maxOptionLength))
binding.optionEditText.onTextChanged { s, _, _, _ ->
val pos = holder.adapterPosition
if(pos != RecyclerView.NO_POSITION) {
val pos = holder.bindingAdapterPosition
if (pos != RecyclerView.NO_POSITION) {
options[pos] = s.toString()
onOptionChanged(validateInput())
}
@ -68,8 +68,8 @@ class AddPollOptionsAdapter(
holder.binding.deleteButton.setOnClickListener {
holder.binding.optionEditText.clearFocus()
options.removeAt(holder.adapterPosition)
notifyItemRemoved(holder.adapterPosition)
options.removeAt(holder.bindingAdapterPosition)
notifyItemRemoved(holder.bindingAdapterPosition)
onOptionRemoved(validateInput())
}
}

View file

@ -21,7 +21,6 @@ import android.graphics.drawable.Drawable
import android.net.Uri
import android.text.InputFilter
import android.text.InputType
import android.util.DisplayMetrics
import android.view.WindowManager
import android.widget.EditText
import android.widget.LinearLayout
@ -41,9 +40,10 @@ import com.keylesspalace.tusky.util.withLifecycleContext
// https://github.com/tootsuite/mastodon/blob/c6904c0d3766a2ea8a81ab025c127169ecb51373/app/models/media_attachment.rb#L32
private const val MEDIA_DESCRIPTION_CHARACTER_LIMIT = 1500
fun <T> T.makeCaptionDialog(existingDescription: String?,
previewUri: Uri,
onUpdateDescription: (String) -> LiveData<Boolean>
fun <T> T.makeCaptionDialog(
existingDescription: String?,
previewUri: Uri,
onUpdateDescription: (String) -> LiveData<Boolean>
) where T : Activity, T : LifecycleOwner {
val dialogLayout = LinearLayout(this)
val padding = Utils.dpToPx(this, 8)
@ -54,9 +54,6 @@ fun <T> T.makeCaptionDialog(existingDescription: String?,
maximumScale = 6f
}
val displayMetrics = DisplayMetrics()
windowManager.defaultDisplay.getMetrics(displayMetrics)
val margin = Utils.dpToPx(this, 4)
dialogLayout.addView(imageView)
(imageView.layoutParams as LinearLayout.LayoutParams).weight = 1f
@ -64,14 +61,18 @@ fun <T> T.makeCaptionDialog(existingDescription: String?,
(imageView.layoutParams as LinearLayout.LayoutParams).setMargins(0, margin, 0, 0)
val input = EditText(this)
input.hint = resources.getQuantityString(R.plurals.hint_describe_for_visually_impaired,
MEDIA_DESCRIPTION_CHARACTER_LIMIT, MEDIA_DESCRIPTION_CHARACTER_LIMIT)
input.hint = resources.getQuantityString(
R.plurals.hint_describe_for_visually_impaired,
MEDIA_DESCRIPTION_CHARACTER_LIMIT, MEDIA_DESCRIPTION_CHARACTER_LIMIT
)
dialogLayout.addView(input)
(input.layoutParams as LinearLayout.LayoutParams).setMargins(margin, margin, margin, margin)
input.setLines(2)
input.inputType = (InputType.TYPE_CLASS_TEXT
or InputType.TYPE_TEXT_FLAG_MULTI_LINE
or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES)
input.inputType = (
InputType.TYPE_CLASS_TEXT
or InputType.TYPE_TEXT_FLAG_MULTI_LINE
or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
)
input.setText(existingDescription)
input.filters = arrayOf(InputFilter.LengthFilter(MEDIA_DESCRIPTION_CHARACTER_LIMIT))
@ -79,41 +80,40 @@ fun <T> T.makeCaptionDialog(existingDescription: String?,
onUpdateDescription(input.text.toString())
withLifecycleContext {
onUpdateDescription(input.text.toString())
.observe { success -> if (!success) showFailedCaptionMessage() }
.observe { success -> if (!success) showFailedCaptionMessage() }
}
dialog.dismiss()
}
val dialog = AlertDialog.Builder(this)
.setView(dialogLayout)
.setPositiveButton(android.R.string.ok, okListener)
.setNegativeButton(android.R.string.cancel, null)
.create()
.setView(dialogLayout)
.setPositiveButton(android.R.string.ok, okListener)
.setNegativeButton(android.R.string.cancel, null)
.create()
val window = dialog.window
window?.setSoftInputMode(
WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE
)
dialog.show()
// Load the image and manually set it into the ImageView because it doesn't have a fixed size.
Glide.with(this)
.load(previewUri)
.downsample(DownsampleStrategy.CENTER_INSIDE)
.into(object : CustomTarget<Drawable>(4096, 4096) {
override fun onLoadCleared(placeholder: Drawable?) {
imageView.setImageDrawable(placeholder)
}
.load(previewUri)
.downsample(DownsampleStrategy.CENTER_INSIDE)
.into(object : CustomTarget<Drawable>(4096, 4096) {
override fun onLoadCleared(placeholder: Drawable?) {
imageView.setImageDrawable(placeholder)
}
override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) {
imageView.setImageDrawable(resource)
}
})
override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) {
imageView.setImageDrawable(resource)
}
})
}
private fun Activity.showFailedCaptionMessage() {
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
else ->
R.id.directRadioButton
}
check(selectedButton)
}
}
interface ComposeOptionsListener {

View file

@ -16,25 +16,27 @@
package com.keylesspalace.tusky.components.compose.view
import android.content.Context
import androidx.emoji.widget.EmojiEditTextHelper
import androidx.core.view.inputmethod.EditorInfoCompat
import androidx.core.view.inputmethod.InputConnectionCompat
import androidx.appcompat.widget.AppCompatMultiAutoCompleteTextView
import android.text.InputType
import android.text.method.KeyListener
import android.util.AttributeSet
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputConnection
import androidx.appcompat.widget.AppCompatMultiAutoCompleteTextView
import androidx.core.view.inputmethod.EditorInfoCompat
import androidx.core.view.inputmethod.InputConnectionCompat
import androidx.emoji.widget.EmojiEditTextHelper
class EditTextTyped @JvmOverloads constructor(context: Context,
attributeSet: AttributeSet? = null)
: AppCompatMultiAutoCompleteTextView(context, attributeSet) {
class EditTextTyped @JvmOverloads constructor(
context: Context,
attributeSet: AttributeSet? = null
) :
AppCompatMultiAutoCompleteTextView(context, attributeSet) {
private var onCommitContentListener: InputConnectionCompat.OnCommitContentListener? = null
private val emojiEditTextHelper: EmojiEditTextHelper = EmojiEditTextHelper(this)
init {
//fix a bug with autocomplete and some keyboards
// fix a bug with autocomplete and some keyboards
val newInputType = inputType and (inputType xor InputType.TYPE_TEXT_FLAG_AUTO_COMPLETE)
inputType = newInputType
super.setKeyListener(getEmojiEditTextHelper().getKeyListener(keyListener))
@ -52,8 +54,13 @@ class EditTextTyped @JvmOverloads constructor(context: Context,
val connection = super.onCreateInputConnection(editorInfo)
return if (onCommitContentListener != null) {
EditorInfoCompat.setContentMimeTypes(editorInfo, arrayOf("image/*"))
getEmojiEditTextHelper().onCreateInputConnection(InputConnectionCompat.createWrapper(connection, editorInfo,
onCommitContentListener!!), editorInfo)!!
getEmojiEditTextHelper().onCreateInputConnection(
InputConnectionCompat.createWrapper(
connection, editorInfo,
onCommitContentListener!!
),
editorInfo
)!!
} else {
connection
}

View file

@ -25,10 +25,11 @@ import com.keylesspalace.tusky.databinding.ViewPollPreviewBinding
import com.keylesspalace.tusky.entity.NewPoll
class PollPreviewView @JvmOverloads constructor(
context: Context?,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0)
: LinearLayout(context, attrs, defStyleAttr) {
context: Context?,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) :
LinearLayout(context, attrs, defStyleAttr) {
private val adapter = PreviewPollOptionsAdapter()
@ -46,7 +47,7 @@ class PollPreviewView @JvmOverloads constructor(
binding.pollPreviewOptions.adapter = adapter
}
fun setPoll(poll: NewPoll){
fun setPoll(poll: NewPoll) {
adapter.update(poll.options, poll.multiple)
val pollDurationId = resources.getIntArray(R.array.poll_duration_values).indexOfLast {

View file

@ -28,15 +28,15 @@ import com.mikepenz.iconics.utils.sizeDp
class TootButton
@JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : MaterialButton(context, attrs, defStyleAttr) {
private val smallStyle: Boolean = context.resources.getBoolean(R.bool.show_small_toot_button)
init {
if(smallStyle) {
if (smallStyle) {
setIconResource(R.drawable.ic_send_24dp)
} else {
setText(R.string.action_send)
@ -47,7 +47,7 @@ class TootButton
}
fun setStatusVisibility(visibility: Status.Visibility) {
if(!smallStyle) {
if (!smallStyle) {
icon = when (visibility) {
Status.Visibility.PUBLIC -> {
@ -68,8 +68,5 @@ class TootButton
}
}
}
}
}

View file

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

View file

@ -1,4 +1,4 @@
/* Copyright 2019 Conny Duck
/* Copyright 2021 Tusky Contributors
*
* This file is a part of Tusky.
*
@ -21,65 +21,70 @@ import androidx.room.Embedded
import androidx.room.Entity
import androidx.room.TypeConverters
import com.keylesspalace.tusky.db.Converters
import com.keylesspalace.tusky.entity.*
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Conversation
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.util.shouldTrimStatus
import java.util.*
import java.util.Date
@Entity(primaryKeys = ["id","accountId"])
@Entity(primaryKeys = ["id", "accountId"])
@TypeConverters(Converters::class)
data class ConversationEntity(
val accountId: Long,
val id: String,
val accounts: List<ConversationAccountEntity>,
val unread: Boolean,
@Embedded(prefix = "s_") val lastStatus: ConversationStatusEntity
val accountId: Long,
val id: String,
val accounts: List<ConversationAccountEntity>,
val unread: Boolean,
@Embedded(prefix = "s_") val lastStatus: ConversationStatusEntity
)
data class ConversationAccountEntity(
val id: String,
val username: String,
val displayName: String,
val avatar: String,
val emojis: List<Emoji>
val id: String,
val username: String,
val displayName: String,
val avatar: String,
val emojis: List<Emoji>
) {
fun toAccount(): Account {
return Account(
id = id,
username = username,
displayName = displayName,
avatar = avatar,
emojis = emojis,
url = "",
localUsername = "",
note = SpannedString(""),
header = ""
id = id,
username = username,
displayName = displayName,
avatar = avatar,
emojis = emojis,
url = "",
localUsername = "",
note = SpannedString(""),
header = ""
)
}
}
@TypeConverters(Converters::class)
data class ConversationStatusEntity(
val id: String,
val url: String?,
val inReplyToId: String?,
val inReplyToAccountId: String?,
val account: ConversationAccountEntity,
val content: Spanned,
val createdAt: Date,
val emojis: List<Emoji>,
val favouritesCount: Int,
val favourited: Boolean,
val bookmarked: Boolean,
val sensitive: Boolean,
val spoilerText: String,
val attachments: ArrayList<Attachment>,
val mentions: Array<Status.Mention>,
val showingHiddenContent: Boolean,
val expanded: Boolean,
val collapsible: Boolean,
val collapsed: Boolean,
val poll: Poll?
val id: String,
val url: String?,
val inReplyToId: String?,
val inReplyToAccountId: String?,
val account: ConversationAccountEntity,
val content: Spanned,
val createdAt: Date,
val emojis: List<Emoji>,
val favouritesCount: Int,
val favourited: Boolean,
val bookmarked: Boolean,
val sensitive: Boolean,
val spoilerText: String,
val attachments: ArrayList<Attachment>,
val mentions: List<Status.Mention>,
val showingHiddenContent: Boolean,
val expanded: Boolean,
val collapsible: Boolean,
val collapsed: Boolean,
val muted: Boolean,
val poll: Poll?
) {
/** its necessary to override this because Spanned.equals does not work as expected */
override fun equals(other: Any?): Boolean {
@ -93,7 +98,7 @@ data class ConversationStatusEntity(
if (inReplyToId != other.inReplyToId) return false
if (inReplyToAccountId != other.inReplyToAccountId) return false
if (account != other.account) return false
if (content.toString() != other.content.toString()) return false //TODO find a better method to compare two spanned strings
if (content.toString() != other.content.toString()) return false // TODO find a better method to compare two spanned strings
if (createdAt != other.createdAt) return false
if (emojis != other.emojis) return false
if (favouritesCount != other.favouritesCount) return false
@ -101,11 +106,12 @@ data class ConversationStatusEntity(
if (sensitive != other.sensitive) return false
if (spoilerText != other.spoilerText) return false
if (attachments != other.attachments) return false
if (!mentions.contentEquals(other.mentions)) return false
if (mentions != other.mentions) return false
if (showingHiddenContent != other.showingHiddenContent) return false
if (expanded != other.expanded) return false
if (collapsible != other.collapsible) return false
if (collapsed != other.collapsed) return false
if (muted != other.muted) return false
if (poll != other.poll) return false
return true
@ -125,71 +131,85 @@ data class ConversationStatusEntity(
result = 31 * result + sensitive.hashCode()
result = 31 * result + spoilerText.hashCode()
result = 31 * result + attachments.hashCode()
result = 31 * result + mentions.contentHashCode()
result = 31 * result + mentions.hashCode()
result = 31 * result + showingHiddenContent.hashCode()
result = 31 * result + expanded.hashCode()
result = 31 * result + collapsible.hashCode()
result = 31 * result + collapsed.hashCode()
result = 31 * result + muted.hashCode()
result = 31 * result + poll.hashCode()
return result
}
fun toStatus(): Status {
return Status(
id = id,
url = url,
account = account.toAccount(),
inReplyToId = inReplyToId,
inReplyToAccountId = inReplyToAccountId,
content = content,
reblog = null,
createdAt = createdAt,
emojis = emojis,
reblogsCount = 0,
favouritesCount = favouritesCount,
reblogged = false,
favourited = favourited,
bookmarked = bookmarked,
sensitive= sensitive,
spoilerText = spoilerText,
visibility = Status.Visibility.DIRECT,
attachments = attachments,
mentions = mentions,
application = null,
pinned = false,
muted = false,
poll = poll,
card = null)
id = id,
url = url,
account = account.toAccount(),
inReplyToId = inReplyToId,
inReplyToAccountId = inReplyToAccountId,
content = content,
reblog = null,
createdAt = createdAt,
emojis = emojis,
reblogsCount = 0,
favouritesCount = favouritesCount,
reblogged = false,
favourited = favourited,
bookmarked = bookmarked,
sensitive = sensitive,
spoilerText = spoilerText,
visibility = Status.Visibility.DIRECT,
attachments = attachments,
mentions = mentions,
application = null,
pinned = false,
muted = muted,
poll = poll,
card = null
)
}
}
fun Account.toEntity() =
ConversationAccountEntity(
id,
username,
name,
avatar,
emojis ?: emptyList()
)
ConversationAccountEntity(
id = id,
username = username,
displayName = name,
avatar = avatar,
emojis = emojis ?: emptyList()
)
fun Status.toEntity() =
ConversationStatusEntity(
id, url, inReplyToId, inReplyToAccountId, account.toEntity(), content,
createdAt, emojis, favouritesCount, favourited, bookmarked, sensitive,
spoilerText, attachments, mentions,
false,
false,
shouldTrimStatus(content),
true,
poll
)
ConversationStatusEntity(
id = id,
url = url,
inReplyToId = inReplyToId,
inReplyToAccountId = inReplyToAccountId,
account = account.toEntity(),
content = content,
createdAt = createdAt,
emojis = emojis,
favouritesCount = favouritesCount,
favourited = favourited,
bookmarked = bookmarked,
sensitive = sensitive,
spoilerText = spoilerText,
attachments = attachments,
mentions = mentions,
showingHiddenContent = false,
expanded = false,
collapsible = shouldTrimStatus(content),
collapsed = true,
muted = muted ?: false,
poll = poll
)
fun Conversation.toEntity(accountId: Long) =
ConversationEntity(
accountId,
id,
accounts.map { it.toEntity() },
unread,
lastStatus!!.toEntity()
)
ConversationEntity(
accountId = accountId,
id = id,
accounts = accounts.map { it.toEntity() },
unread = unread,
lastStatus = lastStatus!!.toEntity()
)

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.
*
@ -20,14 +20,19 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.PopupMenu
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.paging.ExperimentalPagingApi
import androidx.paging.LoadState
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.SimpleItemAnimator
import com.keylesspalace.tusky.AccountActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.ViewTagActivity
import com.keylesspalace.tusky.components.account.AccountActivity
import com.keylesspalace.tusky.databinding.FragmentTimelineBinding
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
@ -36,12 +41,18 @@ import com.keylesspalace.tusky.interfaces.ReselectableFragment
import com.keylesspalace.tusky.interfaces.StatusActionListener
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.CardViewMode
import com.keylesspalace.tusky.util.NetworkState
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible
import com.keylesspalace.tusky.viewdata.AttachmentViewData
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import java.io.IOException
import javax.inject.Inject
@OptIn(ExperimentalPagingApi::class)
class ConversationsFragment : SFragment(), StatusActionListener, Injectable, ReselectableFragment {
@Inject
@ -52,9 +63,12 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
private val binding by viewBinding(FragmentTimelineBinding::bind)
private lateinit var adapter: ConversationAdapter
private lateinit var loadStateAdapter: ConversationLoadStateAdapter
private var layoutManager: LinearLayoutManager? = null
private var initialRefreshDone: Boolean = false
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_timeline, container, false)
}
@ -63,23 +77,25 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
val preferences = PreferenceManager.getDefaultSharedPreferences(view.context)
val statusDisplayOptions = StatusDisplayOptions(
animateAvatars = preferences.getBoolean("animateGifAvatars", false),
mediaPreviewEnabled = accountManager.activeAccount?.mediaPreviewEnabled ?: true,
useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false),
showBotOverlay = preferences.getBoolean("showBotOverlay", true),
useBlurhash = preferences.getBoolean("useBlurhash", true),
cardViewMode = CardViewMode.NONE,
confirmReblogs = preferences.getBoolean("confirmReblogs", true),
hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false),
animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
animateAvatars = preferences.getBoolean("animateGifAvatars", false),
mediaPreviewEnabled = accountManager.activeAccount?.mediaPreviewEnabled ?: true,
useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false),
showBotOverlay = preferences.getBoolean("showBotOverlay", true),
useBlurhash = preferences.getBoolean("useBlurhash", true),
cardViewMode = CardViewMode.NONE,
confirmReblogs = preferences.getBoolean("confirmReblogs", true),
confirmFavourites = preferences.getBoolean("confirmFavourites", false),
hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false),
animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
)
adapter = ConversationAdapter(statusDisplayOptions, this, ::onTopLoaded, viewModel::retry)
adapter = ConversationAdapter(statusDisplayOptions, this)
loadStateAdapter = ConversationLoadStateAdapter(adapter::retry)
binding.recyclerView.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL))
layoutManager = LinearLayoutManager(view.context)
binding.recyclerView.layoutManager = layoutManager
binding.recyclerView.adapter = adapter
binding.recyclerView.adapter = adapter.withLoadStateFooter(loadStateAdapter)
(binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
binding.progressBar.hide()
@ -87,58 +103,101 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
initSwipeToRefresh()
viewModel.conversations.observe(viewLifecycleOwner) {
adapter.submitList(it)
}
viewModel.networkState.observe(viewLifecycleOwner) {
adapter.setNetworkState(it)
lifecycleScope.launch {
viewModel.conversationFlow.collectLatest { pagingData ->
adapter.submitData(pagingData)
}
}
viewModel.load()
adapter.addLoadStateListener { loadStates ->
loadStates.refresh.let { refreshState ->
if (refreshState is LoadState.Error) {
binding.statusView.show()
if (refreshState.error is IOException) {
binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) {
adapter.refresh()
}
} else {
binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic) {
adapter.refresh()
}
}
} else {
binding.statusView.hide()
}
binding.progressBar.visible(refreshState == LoadState.Loading && adapter.itemCount == 0)
if (refreshState is LoadState.NotLoading && !initialRefreshDone) {
// jump to top after the initial refresh finished
binding.recyclerView.scrollToPosition(0)
initialRefreshDone = true
}
if (refreshState != LoadState.Loading) {
binding.swipeRefreshLayout.isRefreshing = false
}
}
}
}
private fun initSwipeToRefresh() {
viewModel.refreshState.observe(viewLifecycleOwner) {
binding.swipeRefreshLayout.isRefreshing = it == NetworkState.LOADING
}
binding.swipeRefreshLayout.setOnRefreshListener {
viewModel.refresh()
adapter.refresh()
}
binding.swipeRefreshLayout.setColorSchemeResources(R.color.chinwag_green)
}
private fun onTopLoaded() {
binding.recyclerView.scrollToPosition(0)
}
override fun onReblog(reblog: Boolean, position: Int) {
// its impossible to reblog private messages
}
override fun onFavourite(favourite: Boolean, position: Int) {
viewModel.favourite(favourite, position)
adapter.item(position)?.let { conversation ->
viewModel.favourite(favourite, conversation)
}
}
override fun onBookmark(favourite: Boolean, position: Int) {
viewModel.bookmark(favourite, position)
adapter.item(position)?.let { conversation ->
viewModel.bookmark(favourite, conversation)
}
}
override fun onMore(view: View, position: Int) {
viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let {
more(it.toStatus(), view, position)
adapter.item(position)?.let { conversation ->
val popup = PopupMenu(requireContext(), view)
popup.inflate(R.menu.conversation_more)
if (conversation.lastStatus.muted) {
popup.menu.removeItem(R.id.status_mute_conversation)
} else {
popup.menu.removeItem(R.id.status_unmute_conversation)
}
popup.setOnMenuItemClickListener { item ->
when (item.itemId) {
R.id.status_mute_conversation -> viewModel.muteConversation(conversation)
R.id.status_unmute_conversation -> viewModel.muteConversation(conversation)
R.id.conversation_delete -> deleteConversation(conversation)
}
true
}
popup.show()
}
}
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let {
viewMedia(attachmentIndex, it.toStatus(), view)
adapter.item(position)?.let { conversation ->
viewMedia(attachmentIndex, AttachmentViewData.list(conversation.lastStatus.toStatus()), view)
}
}
override fun onViewThread(position: Int) {
viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let {
viewThread(it.toStatus())
adapter.item(position)?.let { conversation ->
viewThread(conversation.lastStatus.id, conversation.lastStatus.url)
}
}
@ -147,11 +206,15 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
}
override fun onExpandedChange(expanded: Boolean, position: Int) {
viewModel.expandHiddenStatus(expanded, position)
adapter.item(position)?.let { conversation ->
viewModel.expandHiddenStatus(expanded, conversation)
}
}
override fun onContentHiddenChange(isShowing: Boolean, position: Int) {
viewModel.showContent(isShowing, position)
adapter.item(position)?.let { conversation ->
viewModel.showContent(isShowing, conversation)
}
}
override fun onLoadMore(position: Int) {
@ -159,7 +222,9 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
}
override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) {
viewModel.collapseLongStatus(isCollapsed, position)
adapter.item(position)?.let { conversation ->
viewModel.collapseLongStatus(isCollapsed, conversation)
}
}
override fun onViewAccount(id: String) {
@ -174,15 +239,25 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
}
override fun removeItem(position: Int) {
viewModel.remove(position)
// not needed
}
override fun onReply(position: Int) {
viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let {
reply(it.toStatus())
adapter.item(position)?.let { conversation ->
reply(conversation.lastStatus.toStatus())
}
}
private fun deleteConversation(conversation: ConversationEntity) {
AlertDialog.Builder(requireContext())
.setMessage(R.string.dialog_delete_conversation_warning)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(android.R.string.ok) { _, _ ->
viewModel.remove(conversation)
}
.show()
}
private fun jumpToTop() {
if (isAdded) {
layoutManager?.scrollToPosition(0)
@ -195,7 +270,9 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
}
override fun onVoteInPoll(position: Int, choices: MutableList<Int>) {
viewModel.voteInPoll(position, choices)
adapter.item(position)?.let { conversation ->
viewModel.voteInPoll(choices, conversation)
}
}
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,111 +1,37 @@
/* Copyright 2021 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.conversation
import androidx.annotation.MainThread
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Transformations
import androidx.paging.Config
import androidx.paging.toLiveData
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.entity.Conversation
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.Listing
import com.keylesspalace.tusky.util.NetworkState
import io.reactivex.Single
import io.reactivex.schedulers.Schedulers
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import java.util.concurrent.Executors
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ConversationsRepository @Inject constructor(val mastodonApi: MastodonApi, val db: AppDatabase) {
private val ioExecutor = Executors.newSingleThreadExecutor()
companion object {
private const val DEFAULT_PAGE_SIZE = 20
}
@MainThread
fun refresh(accountId: Long, showLoadingIndicator: Boolean): LiveData<NetworkState> {
val networkState = MutableLiveData<NetworkState>()
if(showLoadingIndicator) {
networkState.value = NetworkState.LOADING
}
mastodonApi.getConversations(limit = DEFAULT_PAGE_SIZE).enqueue(
object : Callback<List<Conversation>> {
override fun onFailure(call: Call<List<Conversation>>, t: Throwable) {
// retrofit calls this on main thread so safe to call set value
networkState.value = NetworkState.error(t.message)
}
override fun onResponse(call: Call<List<Conversation>>, response: Response<List<Conversation>>) {
ioExecutor.execute {
db.runInTransaction {
db.conversationDao().deleteForAccount(accountId)
insertResultIntoDb(accountId, response.body())
}
// since we are in bg thread now, post the result.
networkState.postValue(NetworkState.LOADED)
}
}
}
)
return networkState
}
@MainThread
fun conversations(accountId: Long): Listing<ConversationEntity> {
// create a boundary callback which will observe when the user reaches to the edges of
// the list and update the database with extra data.
val boundaryCallback = ConversationsBoundaryCallback(
accountId = accountId,
mastodonApi = mastodonApi,
handleResponse = this::insertResultIntoDb,
ioExecutor = ioExecutor,
networkPageSize = DEFAULT_PAGE_SIZE)
// we are using a mutable live data to trigger refresh requests which eventually calls
// refresh method and gets a new live data. Each refresh request by the user becomes a newly
// dispatched data in refreshTrigger
val refreshTrigger = MutableLiveData<Unit>()
val refreshState = Transformations.switchMap(refreshTrigger) {
refresh(accountId, true)
}
// We use toLiveData Kotlin extension function here, you could also use LivePagedListBuilder
val livePagedList = db.conversationDao().conversationsForAccount(accountId).toLiveData(
config = Config(pageSize = DEFAULT_PAGE_SIZE, prefetchDistance = DEFAULT_PAGE_SIZE / 2, enablePlaceholders = false),
boundaryCallback = boundaryCallback
)
return Listing(
pagedList = livePagedList,
networkState = boundaryCallback.networkState,
retry = {
boundaryCallback.helper.retryAllFailed()
},
refresh = {
refreshTrigger.value = null
},
refreshState = refreshState
)
}
class ConversationsRepository @Inject constructor(
val mastodonApi: MastodonApi,
val db: AppDatabase
) {
fun deleteCacheForAccount(accountId: Long) {
Single.fromCallable {
db.conversationDao().deleteForAccount(accountId)
}.subscribeOn(Schedulers.io())
.subscribe()
}
private fun insertResultIntoDb(accountId: Long, result: List<Conversation>?) {
result?.filter { it.lastStatus != null }
?.map{ it.toEntity(accountId) }
?.let { db.conversationDao().insert(it) }
.subscribe()
}
}

View file

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

View file

@ -28,128 +28,119 @@ import com.keylesspalace.tusky.db.DraftEntity
import com.keylesspalace.tusky.entity.NewPoll
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.util.IOUtils
import io.reactivex.Completable
import io.reactivex.Observable
import io.reactivex.Single
import io.reactivex.schedulers.Schedulers
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.text.SimpleDateFormat
import java.util.*
import java.util.Date
import java.util.Locale
import javax.inject.Inject
class DraftHelper @Inject constructor(
val context: Context,
db: AppDatabase
val context: Context,
db: AppDatabase
) {
private val draftDao = db.draftDao()
fun saveDraft(
draftId: Int,
accountId: Long,
inReplyToId: String?,
content: String?,
contentWarning: String?,
sensitive: Boolean,
visibility: Status.Visibility,
mediaUris: List<String>,
mediaDescriptions: List<String?>,
poll: NewPoll?,
failedToSend: Boolean
): Completable {
return Single.fromCallable {
suspend fun saveDraft(
draftId: Int,
accountId: Long,
inReplyToId: String?,
content: String?,
contentWarning: String?,
sensitive: Boolean,
visibility: Status.Visibility,
mediaUris: List<String>,
mediaDescriptions: List<String?>,
poll: NewPoll?,
failedToSend: Boolean
) = withContext(Dispatchers.IO) {
val externalFilesDir = context.getExternalFilesDir("Tusky")
val externalFilesDir = context.getExternalFilesDir("Tusky")
if (externalFilesDir == null || !(externalFilesDir.exists())) {
Log.e("DraftHelper", "Error obtaining directory to save media.")
throw Exception()
}
if (externalFilesDir == null || !(externalFilesDir.exists())) {
Log.e("DraftHelper", "Error obtaining directory to save media.")
throw Exception()
val draftDirectory = File(externalFilesDir, "Drafts")
if (!draftDirectory.exists()) {
draftDirectory.mkdir()
}
val uris = mediaUris.map { uriString ->
uriString.toUri()
}.map { uri ->
if (uri.isNotInFolder(draftDirectory)) {
uri.copyToFolder(draftDirectory)
} else {
uri
}
}
val draftDirectory = File(externalFilesDir, "Drafts")
if (!draftDirectory.exists()) {
draftDirectory.mkdir()
val types = uris.map { uri ->
val mimeType = context.contentResolver.getType(uri)
when (mimeType?.substring(0, mimeType.indexOf('/'))) {
"video" -> DraftAttachment.Type.VIDEO
"image" -> DraftAttachment.Type.IMAGE
"audio" -> DraftAttachment.Type.AUDIO
else -> throw IllegalStateException("unknown media type")
}
}
val uris = mediaUris.map { uriString ->
uriString.toUri()
}.map { uri ->
if (uri.isNotInFolder(draftDirectory)) {
uri.copyToFolder(draftDirectory)
} else {
uri
}
}
val types = uris.map { uri ->
val mimeType = context.contentResolver.getType(uri)
when (mimeType?.substring(0, mimeType.indexOf('/'))) {
"video" -> DraftAttachment.Type.VIDEO
"image" -> DraftAttachment.Type.IMAGE
"audio" -> DraftAttachment.Type.AUDIO
else -> throw IllegalStateException("unknown media type")
}
}
val attachments: MutableList<DraftAttachment> = mutableListOf()
for (i in mediaUris.indices) {
attachments.add(
DraftAttachment(
uriString = uris[i].toString(),
description = mediaDescriptions[i],
type = types[i]
)
val attachments: MutableList<DraftAttachment> = mutableListOf()
for (i in mediaUris.indices) {
attachments.add(
DraftAttachment(
uriString = uris[i].toString(),
description = mediaDescriptions[i],
type = types[i]
)
}
DraftEntity(
id = draftId,
accountId = accountId,
inReplyToId = inReplyToId,
content = content,
contentWarning = contentWarning,
sensitive = sensitive,
visibility = visibility,
attachments = attachments,
poll = poll,
failedToSend = failedToSend
)
}
}.flatMapCompletable { draft ->
draftDao.insertOrReplace(draft)
}.subscribeOn(Schedulers.io())
val draft = DraftEntity(
id = draftId,
accountId = accountId,
inReplyToId = inReplyToId,
content = content,
contentWarning = contentWarning,
sensitive = sensitive,
visibility = visibility,
attachments = attachments,
poll = poll,
failedToSend = failedToSend
)
draftDao.insertOrReplace(draft)
}
fun deleteDraftAndAttachments(draftId: Int): Completable {
return draftDao.find(draftId)
.flatMapCompletable { draft ->
deleteDraftAndAttachments(draft)
}
suspend fun deleteDraftAndAttachments(draftId: Int) {
draftDao.find(draftId)?.let { draft ->
deleteDraftAndAttachments(draft)
}
}
fun deleteDraftAndAttachments(draft: DraftEntity): Completable {
return deleteAttachments(draft)
.andThen(draftDao.delete(draft.id))
suspend fun deleteDraftAndAttachments(draft: DraftEntity) {
deleteAttachments(draft)
draftDao.delete(draft.id)
}
fun deleteAllDraftsAndAttachmentsForAccount(accountId: Long) {
draftDao.loadDraftsSingle(accountId)
.flatMapObservable { Observable.fromIterable(it) }
.flatMapCompletable { draft ->
deleteDraftAndAttachments(draft)
}.subscribeOn(Schedulers.io())
.subscribe()
suspend fun deleteAllDraftsAndAttachmentsForAccount(accountId: Long) {
draftDao.loadDrafts(accountId).forEach { draft ->
deleteDraftAndAttachments(draft)
}
}
fun deleteAttachments(draft: DraftEntity): Completable {
return Completable.fromCallable {
suspend fun deleteAttachments(draft: DraftEntity) {
withContext(Dispatchers.IO) {
draft.attachments.forEach { attachment ->
if (context.contentResolver.delete(attachment.uri, null, null) == 0) {
Log.e("DraftHelper", "Did not delete file ${attachment.uriString}")
}
}
}.subscribeOn(Schedulers.io())
}
}
private fun Uri.isNotInFolder(folder: File): Boolean {
@ -171,5 +162,4 @@ class DraftHelper @Inject constructor(
IOUtils.copyToFile(contentResolver, this, file)
return FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".fileprovider", file)
}
}

View file

@ -28,18 +28,17 @@ import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.db.DraftAttachment
class DraftMediaAdapter(
private val attachmentClick: () -> Unit
private val attachmentClick: () -> Unit
) : ListAdapter<DraftAttachment, DraftMediaAdapter.DraftMediaViewHolder>(
object: DiffUtil.ItemCallback<DraftAttachment>() {
override fun areItemsTheSame(oldItem: DraftAttachment, newItem: DraftAttachment): Boolean {
return oldItem == newItem
}
override fun areContentsTheSame(oldItem: DraftAttachment, newItem: DraftAttachment): Boolean {
return oldItem == newItem
}
object : DiffUtil.ItemCallback<DraftAttachment>() {
override fun areItemsTheSame(oldItem: DraftAttachment, newItem: DraftAttachment): Boolean {
return oldItem == newItem
}
override fun areContentsTheSame(oldItem: DraftAttachment, newItem: DraftAttachment): Boolean {
return oldItem == newItem
}
}
) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DraftMediaViewHolder {
@ -52,24 +51,24 @@ class DraftMediaAdapter(
holder.imageView.setImageResource(R.drawable.ic_music_box_preview_24dp)
} else {
Glide.with(holder.itemView.context)
.load(attachment.uri)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.dontAnimate()
.into(holder.imageView)
.load(attachment.uri)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.dontAnimate()
.into(holder.imageView)
}
}
}
inner class DraftMediaViewHolder(val imageView: ImageView)
: RecyclerView.ViewHolder(imageView) {
inner class DraftMediaViewHolder(val imageView: ImageView) :
RecyclerView.ViewHolder(imageView) {
init {
val thumbnailViewSize =
imageView.context.resources.getDimensionPixelSize(R.dimen.compose_media_preview_size)
imageView.context.resources.getDimensionPixelSize(R.dimen.compose_media_preview_size)
val layoutParams = ConstraintLayout.LayoutParams(thumbnailViewSize, thumbnailViewSize)
val margin = itemView.context.resources
.getDimensionPixelSize(R.dimen.compose_media_preview_margin)
.getDimensionPixelSize(R.dimen.compose_media_preview_margin)
val marginBottom = itemView.context.resources
.getDimensionPixelSize(R.dimen.compose_media_preview_margin_bottom)
.getDimensionPixelSize(R.dimen.compose_media_preview_margin_bottom)
layoutParams.setMargins(margin, 0, margin, marginBottom)
imageView.layoutParams = layoutParams
imageView.scaleType = ImageView.ScaleType.CENTER_CROP

View file

@ -19,28 +19,26 @@ import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.view.Menu
import android.view.MenuItem
import android.widget.LinearLayout
import android.widget.Toast
import androidx.activity.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from
import autodispose2.autoDispose
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.SavedTootActivity
import com.keylesspalace.tusky.components.compose.ComposeActivity
import com.keylesspalace.tusky.databinding.ActivityDraftsBinding
import com.keylesspalace.tusky.db.DraftEntity
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.uber.autodispose.android.lifecycle.autoDispose
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers
import com.keylesspalace.tusky.util.visible
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import retrofit2.HttpException
import javax.inject.Inject
@ -54,10 +52,7 @@ class DraftsActivity : BaseActivity(), DraftActionListener {
private lateinit var binding: ActivityDraftsBinding
private lateinit var bottomSheet: BottomSheetBehavior<LinearLayout>
private var oldDraftsButton: MenuItem? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityDraftsBinding.inflate(layoutInflater)
@ -70,7 +65,7 @@ class DraftsActivity : BaseActivity(), DraftActionListener {
setDisplayShowHomeEnabled(true)
}
binding.draftsErrorMessageView.setup(R.drawable.elephant_friend_empty, R.string.no_saved_status)
binding.draftsErrorMessageView.setup(R.drawable.elephant_friend_empty, R.string.no_drafts)
val adapter = DraftsAdapter(this)
@ -80,44 +75,15 @@ class DraftsActivity : BaseActivity(), DraftActionListener {
bottomSheet = BottomSheetBehavior.from(binding.bottomSheet.root)
viewModel.drafts.observe(this) { draftList ->
if (draftList.isEmpty()) {
binding.draftsRecyclerView.hide()
binding.draftsErrorMessageView.show()
} else {
binding.draftsRecyclerView.show()
binding.draftsErrorMessageView.hide()
adapter.submitList(draftList)
lifecycleScope.launch {
viewModel.drafts.collectLatest { draftData ->
adapter.submitData(draftData)
}
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.drafts, menu)
oldDraftsButton = menu.findItem(R.id.action_old_drafts)
viewModel.showOldDraftsButton()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
.subscribe { showOldDraftsButton ->
oldDraftsButton?.isVisible = showOldDraftsButton
}
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
android.R.id.home -> {
onBackPressed()
return true
}
R.id.action_old_drafts -> {
val intent = Intent(this, SavedTootActivity::class.java)
startActivityWithSlideInAnimation(intent)
return true
}
adapter.addLoadStateListener {
binding.draftsErrorMessageView.visible(adapter.itemCount == 0)
}
return super.onOptionsItemSelected(item)
}
override fun onOpenDraft(draft: DraftEntity) {
@ -125,27 +91,28 @@ class DraftsActivity : BaseActivity(), DraftActionListener {
if (draft.inReplyToId != null) {
bottomSheet.state = BottomSheetBehavior.STATE_COLLAPSED
viewModel.getToot(draft.inReplyToId)
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this)
.subscribe({ status ->
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(from(this))
.subscribe(
{ status ->
val composeOptions = ComposeActivity.ComposeOptions(
draftId = draft.id,
tootText = draft.content,
contentWarning = draft.contentWarning,
inReplyToId = draft.inReplyToId,
replyingStatusContent = status.content.toString(),
replyingStatusAuthor = status.account.localUsername,
draftAttachments = draft.attachments,
poll = draft.poll,
sensitive = draft.sensitive,
visibility = draft.visibility
draftId = draft.id,
tootText = draft.content,
contentWarning = draft.contentWarning,
inReplyToId = draft.inReplyToId,
replyingStatusContent = status.content.toString(),
replyingStatusAuthor = status.account.localUsername,
draftAttachments = draft.attachments,
poll = draft.poll,
sensitive = draft.sensitive,
visibility = draft.visibility
)
bottomSheet.state = BottomSheetBehavior.STATE_HIDDEN
startActivity(ComposeActivity.startIntent(this, composeOptions))
}, { throwable ->
},
{ throwable ->
bottomSheet.state = BottomSheetBehavior.STATE_HIDDEN
@ -158,9 +125,10 @@ class DraftsActivity : BaseActivity(), DraftActionListener {
openDraftWithoutReply(draft)
} else {
Snackbar.make(binding.root, getString(R.string.drafts_failed_loading_reply), Snackbar.LENGTH_SHORT)
.show()
.show()
}
})
}
)
} else {
openDraftWithoutReply(draft)
}
@ -168,13 +136,13 @@ class DraftsActivity : BaseActivity(), DraftActionListener {
private fun openDraftWithoutReply(draft: DraftEntity) {
val composeOptions = ComposeActivity.ComposeOptions(
draftId = draft.id,
tootText = draft.content,
contentWarning = draft.contentWarning,
draftAttachments = draft.attachments,
poll = draft.poll,
sensitive = draft.sensitive,
visibility = draft.visibility
draftId = draft.id,
tootText = draft.content,
contentWarning = draft.contentWarning,
draftAttachments = draft.attachments,
poll = draft.poll,
sensitive = draft.sensitive,
visibility = draft.visibility
)
startActivity(ComposeActivity.startIntent(this, composeOptions))
@ -183,10 +151,10 @@ class DraftsActivity : BaseActivity(), DraftActionListener {
override fun onDeleteDraft(draft: DraftEntity) {
viewModel.deleteDraft(draft)
Snackbar.make(binding.root, getString(R.string.draft_deleted), Snackbar.LENGTH_LONG)
.setAction(R.string.action_undo) {
viewModel.restoreDraft(draft)
}
.show()
.setAction(R.string.action_undo) {
viewModel.restoreDraft(draft)
}
.show()
}
companion object {

View file

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

View file

@ -16,14 +16,17 @@
package com.keylesspalace.tusky.components.drafts
import androidx.lifecycle.ViewModel
import androidx.paging.toLiveData
import androidx.lifecycle.viewModelScope
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.cachedIn
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.DraftEntity
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi
import io.reactivex.Observable
import io.reactivex.Single
import io.reactivex.rxjava3.core.Single
import kotlinx.coroutines.launch
import javax.inject.Inject
class DraftsViewModel @Inject constructor(
@ -33,27 +36,28 @@ class DraftsViewModel @Inject constructor(
val draftHelper: DraftHelper
) : ViewModel() {
val drafts = database.draftDao().loadDrafts(accountManager.activeAccount?.id!!).toLiveData(pageSize = 20)
val drafts = Pager(
config = PagingConfig(pageSize = 20),
pagingSourceFactory = { database.draftDao().draftsPagingSource(accountManager.activeAccount?.id!!) }
).flow
.cachedIn(viewModelScope)
private val deletedDrafts: MutableList<DraftEntity> = mutableListOf()
fun showOldDraftsButton(): Observable<Boolean> {
return database.tootDao().savedTootCount()
.map { count -> count > 0 }
}
fun deleteDraft(draft: DraftEntity) {
// this does not immediately delete media files to avoid unnecessary file operations
// in case the user decides to restore the draft
database.draftDao().delete(draft.id)
.subscribe()
deletedDrafts.add(draft)
viewModelScope.launch {
database.draftDao().delete(draft.id)
deletedDrafts.add(draft)
}
}
fun restoreDraft(draft: DraftEntity) {
database.draftDao().insertOrReplace(draft)
.subscribe()
deletedDrafts.remove(draft)
viewModelScope.launch {
database.draftDao().insertOrReplace(draft)
deletedDrafts.remove(draft)
}
}
fun getToot(tootId: String): Single<Status> {
@ -61,9 +65,10 @@ class DraftsViewModel @Inject constructor(
}
override fun onCleared() {
deletedDrafts.forEach {
draftHelper.deleteAttachments(it).subscribe()
viewModelScope.launch {
deletedDrafts.forEach {
draftHelper.deleteAttachments(it)
}
}
}
}

View file

@ -9,7 +9,7 @@ import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
import javax.inject.Inject
class InstanceListActivity: BaseActivity(), HasAndroidInjector {
class InstanceListActivity : BaseActivity(), HasAndroidInjector {
@Inject
lateinit var androidInjector: DispatchingAndroidInjector<Any>
@ -17,7 +17,7 @@ class InstanceListActivity: BaseActivity(), HasAndroidInjector {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding = ActivityAccountListBinding.inflate(layoutInflater)
setContentView(R.layout.activity_account_list)
setContentView(binding.root)
setSupportActionBar(binding.includedToolbar.toolbar)
supportActionBar?.apply {
@ -27,11 +27,10 @@ class InstanceListActivity: BaseActivity(), HasAndroidInjector {
}
supportFragmentManager
.beginTransaction()
.replace(R.id.fragment_container, InstanceListFragment())
.commit()
.beginTransaction()
.replace(R.id.fragment_container, InstanceListFragment())
.commit()
}
override fun androidInjector() = androidInjector
}

View file

@ -8,8 +8,8 @@ import com.keylesspalace.tusky.databinding.ItemMutedDomainBinding
import com.keylesspalace.tusky.util.BindingHolder
class DomainMutesAdapter(
private val actionListener: InstanceActionListener
): RecyclerView.Adapter<BindingHolder<ItemMutedDomainBinding>>() {
private val actionListener: InstanceActionListener
) : RecyclerView.Adapter<BindingHolder<ItemMutedDomainBinding>>() {
var instances: MutableList<String> = mutableListOf()
var bottomLoading: Boolean = false

View file

@ -8,6 +8,8 @@ import androidx.lifecycle.Lifecycle
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from
import autodispose2.autoDispose
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.instancemute.adapter.DomainMutesAdapter
@ -20,16 +22,14 @@ import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.view.EndlessOnScrollListener
import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from
import com.uber.autodispose.autoDispose
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import java.io.IOException
import javax.inject.Inject
class InstanceListFragment: Fragment(R.layout.fragment_instance_list), Injectable, InstanceActionListener {
class InstanceListFragment : Fragment(R.layout.fragment_instance_list), Injectable, InstanceActionListener {
@Inject
lateinit var api: MastodonApi
@ -65,7 +65,7 @@ class InstanceListFragment: Fragment(R.layout.fragment_instance_list), Injectabl
override fun mute(mute: Boolean, instance: String, position: Int) {
if (mute) {
api.blockDomain(instance).enqueue(object: Callback<Any> {
api.blockDomain(instance).enqueue(object : Callback<Any> {
override fun onFailure(call: Call<Any>, t: Throwable) {
Log.e(TAG, "Error muting domain $instance")
}
@ -79,7 +79,7 @@ class InstanceListFragment: Fragment(R.layout.fragment_instance_list), Injectabl
}
})
} else {
api.unblockDomain(instance).enqueue(object: Callback<Any> {
api.unblockDomain(instance).enqueue(object : Callback<Any> {
override fun onFailure(call: Call<Any>, t: Throwable) {
Log.e(TAG, "Error unmuting domain $instance")
}
@ -88,10 +88,10 @@ class InstanceListFragment: Fragment(R.layout.fragment_instance_list), Injectabl
if (response.isSuccessful) {
adapter.removeItem(position)
Snackbar.make(binding.recyclerView, getString(R.string.confirmation_domain_unmuted, instance), Snackbar.LENGTH_LONG)
.setAction(R.string.action_undo) {
mute(true, instance, position)
}
.show()
.setAction(R.string.action_undo) {
mute(true, instance, position)
}
.show()
} else {
Log.e(TAG, "Error unmuting domain $instance")
}
@ -112,9 +112,10 @@ class InstanceListFragment: Fragment(R.layout.fragment_instance_list), Injectabl
}
api.domainBlocks(id, bottomId)
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(from(this, Lifecycle.Event.ON_DESTROY))
.subscribe({ response ->
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(from(this, Lifecycle.Event.ON_DESTROY))
.subscribe(
{ response ->
val instances = response.body()
if (response.isSuccessful && instances != null) {
@ -122,9 +123,11 @@ class InstanceListFragment: Fragment(R.layout.fragment_instance_list), Injectabl
} else {
onFetchInstancesFailure(Exception(response.message()))
}
}, {throwable ->
},
{ throwable ->
onFetchInstancesFailure(throwable)
})
}
)
}
private fun onFetchInstancesSuccess(instances: List<String>, linkHeader: String?) {
@ -141,9 +144,9 @@ class InstanceListFragment: Fragment(R.layout.fragment_instance_list), Injectabl
if (adapter.itemCount == 0) {
binding.messageView.show()
binding.messageView.setup(
R.drawable.elephant_friend_empty,
R.string.message_empty,
null
R.drawable.elephant_friend_empty,
R.string.message_empty,
null
)
} else {
binding.messageView.hide()

View file

@ -1,5 +1,6 @@
package com.keylesspalace.tusky.components.notifications
import android.content.Context
import android.util.Log
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.AccountManager
@ -10,9 +11,9 @@ import com.keylesspalace.tusky.util.isLessThan
import javax.inject.Inject
class NotificationFetcher @Inject constructor(
private val mastodonApi: MastodonApi,
private val accountManager: AccountManager,
private val notifier: Notifier
private val mastodonApi: MastodonApi,
private val accountManager: AccountManager,
private val context: Context
) {
fun fetchAndShow() {
for (account in accountManager.getAllAccountsOrderedByActive()) {
@ -20,7 +21,7 @@ class NotificationFetcher @Inject constructor(
try {
val notifications = fetchNotifications(account)
notifications.forEachIndexed { index, notification ->
notifier.show(notification, account, index == 0)
NotificationHelper.make(context, notification, account, index == 0)
}
accountManager.saveAccount(account)
} catch (e: Exception) {
@ -39,9 +40,9 @@ class NotificationFetcher @Inject constructor(
}
Log.d(TAG, "getting Notifications for " + account.fullName)
val notifications = mastodonApi.notificationsWithAuth(
authHeader,
account.domain,
account.lastNotificationId
authHeader,
account.domain,
account.lastNotificationId
).blockingGet()
val newId = account.lastNotificationId
@ -63,9 +64,9 @@ class NotificationFetcher @Inject constructor(
private fun fetchMarker(authHeader: String, account: AccountEntity): Marker? {
return try {
val allMarkers = mastodonApi.markersWithAuth(
authHeader,
account.domain,
listOf("notifications")
authHeader,
account.domain,
listOf("notifications")
).blockingGet()
val notificationMarker = allMarkers["notifications"]
Log.d(TAG, "Fetched marker: $notificationMarker")

View file

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

View file

@ -23,9 +23,9 @@ import androidx.work.WorkerParameters
import javax.inject.Inject
class NotificationWorker(
context: Context,
params: WorkerParameters,
private val notificationsFetcher: NotificationFetcher
context: Context,
params: WorkerParameters,
private val notificationsFetcher: NotificationFetcher
) : Worker(context, params) {
override fun doWork(): Result {
@ -35,13 +35,13 @@ class NotificationWorker(
}
class NotificationWorkerFactory @Inject constructor(
private val notificationsFetcher: NotificationFetcher
private val notificationsFetcher: NotificationFetcher
) : WorkerFactory() {
override fun createWorker(
appContext: Context,
workerClassName: String,
workerParameters: WorkerParameters
appContext: Context,
workerClassName: String,
workerParameters: WorkerParameters
): ListenableWorker? {
if (workerClassName == NotificationWorker::class.java.name) {
return NotificationWorker(appContext, workerParameters, notificationsFetcher)

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