diff --git a/.editorconfig b/.editorconfig index 47d830da01..499ac15ade 100644 --- a/.editorconfig +++ b/.editorconfig @@ -7,11 +7,13 @@ indent_style = space insert_final_newline = true trim_trailing_whitespace = true +[*.{java,kt}] # Disable wildcard imports -[*.{java, kt}] ij_kotlin_name_count_to_use_star_import = 999 ij_kotlin_name_count_to_use_star_import_for_members = 999 ij_java_class_count_to_use_import_on_demand = 999 +# Enable trailing comma +ktlint_disabled_rules=trailing-comma-on-call-site,trailing-comma-on-declaration-site [*.{yml,yaml}] indent_size = 2 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0960c91eb9..fd22410e07 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,8 +22,9 @@ We try to follow the [Guide to app architecture](https://developer.android.com/t ### Kotlin Tusky was originally written in Java, but 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`. +We try to follow the [Kotlin Style Guide](https://developer.android.com/kotlin/style-guide) and format the code according to the default [ktlint codestyle](https://github.com/pinterest/ktlint). +You can check the codestyle by running `./gradlew ktlintCheck lint`. This will fail if you have any errors, and produces a detailed report which also lists warnings. +We intentionally have very few hard linting errors, so that new contributors can focus on what they want to achieve instead of fighting the linter. ### Text All English text that will be visible to users must be put in `app/src/main/res/values/strings.xml` so it is translateable into other languages. diff --git a/README.md b/README.md index 5bc9b63bcb..62c5e1dedd 100644 --- a/README.md +++ b/README.md @@ -32,4 +32,4 @@ If you have any bug reports, feature requests or questions please open an issue We always welcome new contributors! Please read our [contribution guide](https://github.com/tuskyapp/Tusky/blob/develop/CONTRIBUTING.md) to get started. ### Development chatroom -https://riot.im/app/#/room/#Tusky:matrix.org +https://matrix.to/#/#Tusky:matrix.org diff --git a/app/build.gradle b/app/build.gradle index 3c0702d3ed..28929ca405 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -27,7 +27,7 @@ android { defaultConfig { applicationId APP_ID namespace "com.keylesspalace.tusky" - minSdk 23 + minSdk 24 targetSdk 33 versionCode 113 versionName "23.0" @@ -99,12 +99,6 @@ android { includeInApk false includeInBundle false } - // Can remove this once https://issuetracker.google.com/issues/260059413 is fixed. - // https://kotlinlang.org/docs/gradle-configure-project.html#gradle-java-toolchains-support - compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 - } applicationVariants.configureEach { variant -> variant.outputs.configureEach { outputFileName = "Tusky_${variant.versionName}_${variant.versionCode}_${gitSha}_" + @@ -189,7 +183,7 @@ dependencies { // Work around warnings of: // WARNING: Illegal reflective access by org.jetbrains.kotlin.kapt3.util.ModuleManipulationUtilsKt (file:/C:/Users/Andi/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-annotation-processing-gradle/1.8.22/28dab7e0ee9ce62c03bf97de3543c911dc653700/kotlin-annotation-processing-gradle-1.8.22.jar) to constructor com.sun.tools.javac.util.Context() // See https://youtrack.jetbrains.com/issue/KT-30589/Kapt-An-illegal-reflective-access-operation-has-occurred -tasks.withType(org.jetbrains.kotlin.gradle.internal.KaptWithoutKotlincTask) { +tasks.withType(org.jetbrains.kotlin.gradle.internal.KaptWithoutKotlincTask).configureEach { kaptProcessJvmArgs.addAll([ "--add-opens", "jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED", "--add-opens", "jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED", @@ -202,18 +196,3 @@ tasks.withType(org.jetbrains.kotlin.gradle.internal.KaptWithoutKotlincTask) { "--add-opens", "jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED", "--add-opens", "jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED"]) } - -tasks.register("newLintBaseline") { - description 'Deletes and then recreates the lint baseline' - - // This task should always run, irrespective of caching - notCompatibleWithConfigurationCache("Is always out of date") - outputs.upToDateWhen { false } - - doLast { - delete android.lint.baseline.path - } - - // Regenerate the lint baseline - it.finalizedBy tasks.named("lintBlueDebug") -} diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml index 8dd9f6cf79..15757da0db 100644 --- a/app/lint-baseline.xml +++ b/app/lint-baseline.xml @@ -1,47 +1,36 @@ - - - - - + + errorLine1=" if (showPlaceholder) placeholder(R.drawable.avatar_default)" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="937" + column="42"/> + errorLine1=" if (showPlaceholder) placeholder(R.drawable.avatar_default)" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="964" + column="42"/> @@ -72,52 +61,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + id="PluralsCandidate" + message="Formatting %d followed by words ("posts"): This should probably be a plural rather than a string" + errorLine1=" <string name="notification_summary_report_format">%s · %d posts attached</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + id="PluralsCandidate" + message="Formatting %d followed by words ("and"): This should probably be a plural rather than a string" + errorLine1=" <string name="pref_title_http_proxy_port_message">Port should be between %d and %d</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + id="PluralsCandidate" + message="Formatting %d followed by words ("others"): This should probably be a plural rather than a string" + errorLine1=" <string name="notification_summary_large">%1$s, %2$s, %3$s and %4$d others</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + id="PluralsCandidate" + message="Formatting %d followed by words ("more"): This should probably be a plural rather than a string" + errorLine1=" <string name="conversation_more_recipients">%1$s, %2$s and %3$d more</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + id="PluralsCandidate" + message="Formatting %d followed by words ("people"): This should probably be a plural rather than a string" + errorLine1=" <string name="accessibility_talking_about_tag">%1$d people are talking about hashtag %2$s</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + id="UnusedTranslation" + message="The language `ber (Berber languages)` is present in this project, but not declared in the `localeConfig` resource" + errorLine1=" android:localeConfig="@xml/locales_config">" + errorLine2=" ~~~~~~~~~~~~~~~~~~~"> + file="src/main/AndroidManifest.xml" + line="21" + column="31"/> + id="UnusedTranslation" + message="The language `el (Greek)` is present in this project, but not declared in the `localeConfig` resource" + errorLine1=" android:localeConfig="@xml/locales_config">" + errorLine2=" ~~~~~~~~~~~~~~~~~~~"> + file="src/main/AndroidManifest.xml" + line="21" + column="31"/> + id="UnusedTranslation" + message="The language `fi (Finnish)` is present in this project, but not declared in the `localeConfig` resource" + errorLine1=" android:localeConfig="@xml/locales_config">" + errorLine2=" ~~~~~~~~~~~~~~~~~~~"> + file="src/main/AndroidManifest.xml" + line="21" + column="31"/> + id="UnusedTranslation" + message="The language `fy (Western Frisian)` is present in this project, but not declared in the `localeConfig` resource" + errorLine1=" android:localeConfig="@xml/locales_config">" + errorLine2=" ~~~~~~~~~~~~~~~~~~~"> + file="src/main/AndroidManifest.xml" + line="21" + column="31"/> + id="UnusedTranslation" + message="The language `in (Indonesian)` is present in this project, but not declared in the `localeConfig` resource" + errorLine1=" android:localeConfig="@xml/locales_config">" + errorLine2=" ~~~~~~~~~~~~~~~~~~~"> + file="src/main/AndroidManifest.xml" + line="21" + column="31"/> + id="UnusedTranslation" + message="The language `lv (Latvian)` is present in this project, but not declared in the `localeConfig` resource" + errorLine1=" android:localeConfig="@xml/locales_config">" + errorLine2=" ~~~~~~~~~~~~~~~~~~~"> + file="src/main/AndroidManifest.xml" + line="21" + column="31"/> + id="UnusedTranslation" + message="The language `ml (Malayalam)` is present in this project, but not declared in the `localeConfig` resource" + errorLine1=" android:localeConfig="@xml/locales_config">" + errorLine2=" ~~~~~~~~~~~~~~~~~~~"> + file="src/main/AndroidManifest.xml" + line="21" + column="31"/> + id="UnusedTranslation" + message="The language `si (Sinhala)` is present in this project, but not declared in the `localeConfig` resource" + errorLine1=" android:localeConfig="@xml/locales_config">" + errorLine2=" ~~~~~~~~~~~~~~~~~~~"> + file="src/main/AndroidManifest.xml" + line="21" + column="31"/> + id="UnusedTranslation" + message="The language `sk (Slovak)` is present in this project, but not declared in the `localeConfig` resource" + errorLine1=" android:localeConfig="@xml/locales_config">" + errorLine2=" ~~~~~~~~~~~~~~~~~~~"> + file="src/main/AndroidManifest.xml" + line="21" + column="31"/> + id="DataExtractionRules" + message="The attribute `android:allowBackup` is deprecated from Android 12 and higher and may be removed in future versions. Consider adding the attribute `android:dataExtractionRules` specifying an `@xml` resource which configures cloud backups and device transfers on Android 12 and higher." + errorLine1=" android:allowBackup="false"" + errorLine2=" ~~~~~"> + file="src/main/AndroidManifest.xml" + line="15" + column="30"/> + id="NotifyDataSetChanged" + message="It will always be more efficient to use more specific change events if you can. Rely on `notifyDataSetChanged` as a last resort." + errorLine1=" accountFieldAdapter.notifyDataSetChanged()" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt" + line="475" + column="9"/> + id="NotifyDataSetChanged" + message="It will always be more efficient to use more specific change events if you can. Rely on `notifyDataSetChanged` as a last resort." + errorLine1=" notifyDataSetChanged()" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/AccountAdapter.kt" + line="80" + column="9"/> + id="NotifyDataSetChanged" + message="It will always be more efficient to use more specific change events if you can. Rely on `notifyDataSetChanged` as a last resort." + errorLine1=" notifyDataSetChanged()" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/java/com/keylesspalace/tusky/adapter/AccountFieldEditAdapter.kt" + line="43" + column="9"/> + id="NotifyDataSetChanged" + message="It will always be more efficient to use more specific change events if you can. Rely on `notifyDataSetChanged` as a last resort." + errorLine1=" notifyDataSetChanged()" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/java/com/keylesspalace/tusky/adapter/AccountFieldEditAdapter.kt" + line="49" + column="9"/> + id="NotifyDataSetChanged" + message="It will always be more efficient to use more specific change events if you can. Rely on `notifyDataSetChanged` as a last resort." + errorLine1=" notifyDataSetChanged()" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementAdapter.kt" + line="133" + column="9"/> + id="NotifyDataSetChanged" + message="It will always be more efficient to use more specific change events if you can. Rely on `notifyDataSetChanged` as a last resort." + errorLine1=" notifyDataSetChanged()" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/MutesAdapter.kt" + line="107" + column="9"/> + id="NotifyDataSetChanged" + message="It will always be more efficient to use more specific change events if you can. Rely on `notifyDataSetChanged` as a last resort." + errorLine1=" notifyDataSetChanged()" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/java/com/keylesspalace/tusky/adapter/PollAdapter.kt" + line="62" + column="9"/> + id="NotifyDataSetChanged" + message="It will always be more efficient to use more specific change events if you can. Rely on `notifyDataSetChanged` as a last resort." + errorLine1=" notifyDataSetChanged()" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + file="src/main/java/com/keylesspalace/tusky/adapter/PollAdapter.kt" + line="62" + column="9"/> + id="NotifyDataSetChanged" + message="It will always be more efficient to use more specific change events if you can. Rely on `notifyDataSetChanged` as a last resort." + errorLine1=" notifyDataSetChanged()" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/java/com/keylesspalace/tusky/adapter/PreviewPollOptionsAdapter.kt" + line="35" + column="9"/> + id="NotifyDataSetChanged" + message="It will always be more efficient to use more specific change events if you can. Rely on `notifyDataSetChanged` as a last resort." + errorLine1=" notifyDataSetChanged()" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/java/com/keylesspalace/tusky/adapter/TabAdapter.kt" + line="54" + column="9"/> + id="NotifyDataSetChanged" + message="It will always be more efficient to use more specific change events if you can. Rely on `notifyDataSetChanged` as a last resort." + errorLine1=" notifyDataSetChanged()" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/java/com/keylesspalace/tusky/adapter/TabAdapter.kt" + line="157" + column="13"/> + id="ObsoleteSdkInt" + message="Unnecessary; SDK_INT is always >= 24" + errorLine1=" if (Build.VERSION.SDK_INT > 23) {" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt" + line="252" + column="13"/> + id="ObsoleteSdkInt" + message="Unnecessary; SDK_INT is never < 24" + errorLine1=" if (Build.VERSION.SDK_INT <= 23 || player == null) {" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt" + line="261" + column="13"/> + id="ObsoleteSdkInt" + message="Unnecessary; SDK_INT is never < 24" + errorLine1=" if (Build.VERSION.SDK_INT <= 23) {" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt" + line="284" + column="13"/> + id="ObsoleteSdkInt" + message="Unnecessary; SDK_INT is always >= 24" + errorLine1=" if (Build.VERSION.SDK_INT > 23) {" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt" + line="296" + column="13"/> + id="StringFormatTrivial" + message="This formatting string is trivial. Rather than using `String.format` to create your String, it will be more performant to concatenate your arguments with `+`. " + errorLine1=" (error) -> Log.e(TAG, String.format("Failed to %s account id %s", accept ? "accept" : "reject", id))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java" + line="806" + column="61"/> + id="StringFormatTrivial" + message="This formatting string is trivial. Rather than using `String.format` to create your String, it will be more performant to concatenate your arguments with `+`. " + errorLine1=" LinkHelper.openLink(requireContext(), String.format("https://%s/admin/reports/%s", accountManager.getActiveAccount().getDomain(), reportId));" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java" + line="827" + column="61"/> - - @@ -5917,7 +883,7 @@ errorLine2=" ~~~~~~"> @@ -5928,7 +894,7 @@ errorLine2=" ~~~~~~~~"> @@ -5939,7 +905,7 @@ errorLine2=" ~~~~"> @@ -5950,7 +916,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -5961,7 +927,7 @@ errorLine2=" ~~~~~~~~~~~~"> @@ -5972,7 +938,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -5983,7 +949,7 @@ errorLine2=" ~~~~~~~~"> @@ -5994,7 +960,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -6049,7 +1015,7 @@ errorLine2=" ~~~~~~~"> @@ -6060,7 +1026,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -6071,7 +1037,7 @@ errorLine2=" ~~~~~~~~~~~~~"> @@ -6082,7 +1048,7 @@ errorLine2=" ~~~~~~~"> @@ -6093,7 +1059,7 @@ errorLine2=" ~~~~~~~"> @@ -6104,7 +1070,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -6115,10 +1081,285 @@ errorLine2=" ~~~~~~~~~~~~~"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -36,19 +37,39 @@ - + + + - - + + + + + - - + + + - - + + + - - + + + + + + + + + + + + + + + + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 232bf491c6..53831a1c0b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -148,7 +148,7 @@ - + @@ -188,8 +188,7 @@ android:icon="@drawable/ic_quicksettings" android:label="@string/tusky_compose_post_quicksetting_label" android:permission="android.permission.BIND_QUICK_SETTINGS_TILE" - android:exported="true" - tools:targetApi="24"> + android:exported="true"> diff --git a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java index 6cdc471a02..e91cc57674 100644 --- a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java @@ -56,10 +56,13 @@ import javax.inject.Inject; +import static com.keylesspalace.tusky.settings.PrefKeys.APP_THEME; + public abstract class BaseActivity extends AppCompatActivity implements Injectable { private static final String TAG = "BaseActivity"; @Inject + @NonNull public AccountManager accountManager; private static final int REQUESTER_NONE = Integer.MAX_VALUE; @@ -74,9 +77,9 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { /* There isn't presently a way to globally change the theme of a whole application at * runtime, just individual activities. So, each activity has to set its theme before any * views are created. */ - String theme = preferences.getString("appTheme", ThemeUtils.APP_THEME_DEFAULT); + String theme = preferences.getString(APP_THEME, ThemeUtils.APP_THEME_DEFAULT); Log.d("activeTheme", theme); - if (theme.equals("black")) { + if (ThemeUtils.isBlack(getResources().getConfiguration(), theme)) { setTheme(R.style.TuskyBlackTheme); } @@ -87,7 +90,7 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { setTaskDescription(new ActivityManager.TaskDescription(appName, appIcon, recentsBackgroundColor)); - int style = textStyle(preferences.getString("statusTextSize", "medium")); + int style = textStyle(preferences.getString(PrefKeys.STATUS_TEXT_SIZE, "medium")); getTheme().applyStyle(style, true); if(requiresLogin()) { @@ -162,13 +165,13 @@ private static int textStyle(String name) { return style; } - public void startActivityWithSlideInAnimation(Intent intent) { + public void startActivityWithSlideInAnimation(@NonNull Intent intent) { super.startActivity(intent); overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left); } @Override - public boolean onOptionsItemSelected(MenuItem item) { + public boolean onOptionsItemSelected(@NonNull MenuItem item) { if (item.getItemId() == android.R.id.home) { getOnBackPressedDispatcher().onBackPressed(); return true; @@ -196,7 +199,7 @@ protected void redirectIfNotLoggedIn() { } } - protected void showErrorDialog(View anyView, @StringRes int descriptionId, @StringRes int actionId, View.OnClickListener listener) { + protected void showErrorDialog(@Nullable View anyView, @StringRes int descriptionId, @StringRes int actionId, @Nullable View.OnClickListener listener) { if (anyView != null) { Snackbar bar = Snackbar.make(anyView, getString(descriptionId), Snackbar.LENGTH_SHORT); bar.setAction(actionId, listener); @@ -204,7 +207,7 @@ protected void showErrorDialog(View anyView, @StringRes int descriptionId, @Stri } } - public void showAccountChooserDialog(CharSequence dialogTitle, boolean showActiveAccount, AccountSelectionListener listener) { + public void showAccountChooserDialog(@Nullable CharSequence dialogTitle, boolean showActiveAccount, @NonNull AccountSelectionListener listener) { List accounts = accountManager.getAllAccountsOrderedByActive(); AccountEntity activeAccount = accountManager.getActiveAccount(); @@ -271,7 +274,7 @@ public void onRequestPermissionsResult(int requestCode, @NonNull String[] permis } } - public void requestPermissions(String[] permissions, PermissionRequester requester) { + public void requestPermissions(@NonNull String[] permissions, @NonNull PermissionRequester requester) { ArrayList permissionsToRequest = new ArrayList<>(); for(String permission: permissions) { if (ContextCompat.checkSelfPermission(this, permission) != PackageManager.PERMISSION_GRANTED) { diff --git a/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt b/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt index 190421e697..94c160f740 100644 --- a/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt @@ -25,7 +25,9 @@ import android.view.Menu import android.view.MenuItem import android.view.View import android.widget.ImageView +import androidx.activity.OnBackPressedCallback import androidx.activity.viewModels +import androidx.appcompat.app.AlertDialog import androidx.core.view.isVisible import androidx.lifecycle.LiveData import androidx.lifecycle.lifecycleScope @@ -46,9 +48,11 @@ import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.util.Error import com.keylesspalace.tusky.util.Loading import com.keylesspalace.tusky.util.Success +import com.keylesspalace.tusky.util.await import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.viewmodel.EditProfileViewModel +import com.keylesspalace.tusky.viewmodel.ProfileDataInUi import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.utils.colorInt @@ -96,6 +100,14 @@ class EditProfileActivity : BaseActivity(), Injectable { } } + private val currentProfileData + get() = ProfileDataInUi( + displayName = binding.displayNameEditText.text.toString(), + note = binding.noteEditText.text.toString(), + locked = binding.lockedCheckBox.isChecked, + fields = accountFieldEditAdapter.getFieldData() + ) + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -200,17 +212,26 @@ class EditProfileActivity : BaseActivity(), Injectable { } } } + + val onBackCallback = object : OnBackPressedCallback(enabled = true) { + override fun handleOnBackPressed() = checkForUnsavedChanges() + } + + onBackPressedDispatcher.addCallback(this, onBackCallback) + } + + fun checkForUnsavedChanges() { + if (viewModel.hasUnsavedChanges(currentProfileData)) { + showUnsavedChangesDialog() + } else { + finish() + } } override fun onStop() { super.onStop() if (!isFinishing) { - viewModel.updateProfile( - binding.displayNameEditText.text.toString(), - binding.noteEditText.text.toString(), - binding.lockedCheckBox.isChecked, - accountFieldEditAdapter.getFieldData() - ) + viewModel.updateProfile(currentProfileData) } } @@ -287,14 +308,7 @@ class EditProfileActivity : BaseActivity(), Injectable { return super.onOptionsItemSelected(item) } - private fun save() { - viewModel.save( - binding.displayNameEditText.text.toString(), - binding.noteEditText.text.toString(), - binding.lockedCheckBox.isChecked, - accountFieldEditAdapter.getFieldData() - ) - } + private fun save() = viewModel.save(currentProfileData) private fun onSaveFailure(msg: String?) { val errorMsg = msg ?: getString(R.string.error_media_upload_sending) @@ -306,4 +320,16 @@ class EditProfileActivity : BaseActivity(), Injectable { Log.w("EditProfileActivity", "failed to pick media", throwable) Snackbar.make(binding.avatarButton, R.string.error_media_upload_sending, Snackbar.LENGTH_LONG).show() } + + private fun showUnsavedChangesDialog() = lifecycleScope.launch { + when (launchSaveDialog()) { + AlertDialog.BUTTON_POSITIVE -> save() + else -> finish() + } + } + + private suspend fun launchSaveDialog() = AlertDialog.Builder(this) + .setMessage(getString(R.string.dialog_save_profile_changes_message)) + .create() + .await(R.string.action_save, R.string.action_discard) } diff --git a/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt b/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt index 89b442a3a5..8467e85420 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt @@ -192,6 +192,7 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { R.string.message_empty, null ) + binding.messageView.showHelp(R.string.help_empty_lists) } else { binding.messageView.hide() } diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt index e79fab2512..090c9e2f65 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt @@ -28,6 +28,7 @@ import android.graphics.drawable.Animatable import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.Drawable import android.net.Uri +import android.os.Build import android.os.Bundle import android.text.TextUtils import android.util.Log @@ -180,6 +181,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje /** Adapter for the different timeline tabs */ private lateinit var tabAdapter: MainPagerAdapter + @Suppress("DEPRECATION") @SuppressLint("RestrictedApi") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -290,7 +292,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje setupDrawer( savedInstanceState, addSearchButton = hideTopToolbar, - addTrendingTagsButton = !accountManager.activeAccount!!.tabPreferences.hasTab(TRENDING_TAGS) + addTrendingTagsButton = !accountManager.activeAccount!!.tabPreferences.hasTab(TRENDING_TAGS), + addTrendingStatusesButton = !accountManager.activeAccount!!.tabPreferences.hasTab(TRENDING_STATUSES), ) /* Fetch user info while we're doing other things. This has to be done after setting up the @@ -315,7 +318,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje is MainTabsChangedEvent -> { refreshMainDrawerItems( addSearchButton = hideTopToolbar, - addTrendingTagsButton = !event.newTabs.hasTab(TRENDING_TAGS) + addTrendingTagsButton = !event.newTabs.hasTab(TRENDING_TAGS), + addTrendingStatusesButton = !event.newTabs.hasTab(TRENDING_STATUSES), ) setupTabs(false) @@ -355,7 +359,10 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje } ) - if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + if ( + Build.VERSION.SDK_INT >= 33 && + ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED + ) { ActivityCompat.requestPermissions( this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), @@ -477,7 +484,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje private fun setupDrawer( savedInstanceState: Bundle?, addSearchButton: Boolean, - addTrendingTagsButton: Boolean + addTrendingTagsButton: Boolean, + addTrendingStatusesButton: Boolean, ) { val drawerOpenClickListener = View.OnClickListener { binding.mainDrawerLayout.open() } @@ -508,7 +516,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje header.accountHeaderBackground.setColorFilter(getColor(R.color.headerBackgroundFilter)) header.accountHeaderBackground.setBackgroundColor(MaterialColors.getColor(header, R.attr.colorBackgroundAccent)) - val animateAvatars = preferences.getBoolean("animateGifAvatars", false) + val animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false) DrawerImageLoader.init(object : AbstractDrawerImageLoader() { override fun set(imageView: ImageView, uri: Uri, placeholder: Drawable, tag: String?) { @@ -530,7 +538,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje override fun placeholder(ctx: Context, tag: String?): Drawable { if (tag == DrawerImageLoader.Tags.PROFILE.name || tag == DrawerImageLoader.Tags.PROFILE_DRAWER_ITEM.name) { - return ctx.getDrawable(R.drawable.avatar_default)!! + return AppCompatResources.getDrawable(ctx, R.drawable.avatar_default)!! } return super.placeholder(ctx, tag) @@ -538,12 +546,20 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje }) binding.mainDrawer.apply { - refreshMainDrawerItems(addSearchButton, addTrendingTagsButton) + refreshMainDrawerItems( + addSearchButton = addSearchButton, + addTrendingTagsButton = addTrendingTagsButton, + addTrendingStatusesButton = addTrendingStatusesButton, + ) setSavedInstance(savedInstanceState) } } - private fun refreshMainDrawerItems(addSearchButton: Boolean, addTrendingTagsButton: Boolean) { + private fun refreshMainDrawerItems( + addSearchButton: Boolean, + addTrendingTagsButton: Boolean, + addTrendingStatusesButton: Boolean, + ) { binding.mainDrawer.apply { itemAdapter.clear() tintStatusBar = true @@ -672,6 +688,19 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje } ) } + + if (addTrendingStatusesButton) { + binding.mainDrawer.addItemsAtPosition( + 6, + primaryDrawerItem { + nameRes = R.string.title_public_trending_statuses + iconicsIcon = GoogleMaterial.Icon.gmd_local_fire_department + onClick = { + startActivityWithSlideInAnimation(StatusListActivity.newTrendingIntent(context)) + } + } + ) + } } if (BuildConfig.DEBUG) { @@ -914,12 +943,13 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje updateShortcut(this, accountManager.activeAccount!!) } + @SuppressLint("CheckResult") private fun loadDrawerAvatar(avatarUrl: String, showPlaceholder: Boolean) { val hideTopToolbar = preferences.getBoolean(PrefKeys.HIDE_TOP_TOOLBAR, false) - val animateAvatars = preferences.getBoolean("animateGifAvatars", false) + val animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false) val activeToolbar = if (hideTopToolbar) { - val navOnBottom = preferences.getString("mainNavPosition", "top") == "bottom" + val navOnBottom = preferences.getString(PrefKeys.MAIN_NAV_POSITION, "top") == "bottom" if (navOnBottom) { binding.bottomNav } else { diff --git a/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt b/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt index c055043d1c..c3ca4937e9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt @@ -27,6 +27,8 @@ import at.connyduck.calladapter.networkresult.fold import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.PreferenceChangedEvent +import com.keylesspalace.tusky.components.filters.EditFilterActivity +import com.keylesspalace.tusky.components.filters.FiltersActivity import com.keylesspalace.tusky.components.timeline.TimelineFragment import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel.Kind import com.keylesspalace.tusky.databinding.ActivityStatuslistBinding @@ -74,6 +76,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { Kind.FAVOURITES -> getString(R.string.title_favourites) Kind.BOOKMARKS -> getString(R.string.title_bookmarks) Kind.TAG -> getString(R.string.title_tag).format(hashtag) + Kind.PUBLIC_TRENDING_STATUSES -> getString(R.string.title_public_trending_statuses) else -> intent.getStringExtra(EXTRA_LIST_TITLE) } @@ -132,6 +135,8 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { { followTagItem?.isVisible = false unfollowTagItem?.isVisible = true + + Snackbar.make(binding.root, getString(R.string.following_hashtag_success_format, tag), Snackbar.LENGTH_SHORT).show() }, { Snackbar.make(binding.root, getString(R.string.error_following_hashtag_format, tag), Snackbar.LENGTH_SHORT).show() @@ -152,6 +157,8 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { { followTagItem?.isVisible = true unfollowTagItem?.isVisible = false + + Snackbar.make(binding.root, getString(R.string.unfollowing_hashtag_success_format, tag), Snackbar.LENGTH_SHORT).show() }, { Snackbar.make(binding.root, getString(R.string.error_unfollowing_hashtag_format, tag), Snackbar.LENGTH_SHORT).show() @@ -169,6 +176,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { */ private fun updateMuteTagMenuItems() { val tag = hashtag ?: return + val hashedTag = "#$tag" muteTagItem?.isVisible = true muteTagItem?.isEnabled = false @@ -178,9 +186,8 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { mastodonApi.getFilters().fold( { filters -> mutedFilter = filters.firstOrNull { filter -> - filter.context.contains(Filter.Kind.HOME.kind) && filter.keywords.any { - it.keyword == tag - } + // TODO shouldn't this be an exact match (only one keyword; exactly the hashtag)? + filter.context.contains(Filter.Kind.HOME.kind) && filter.title == hashedTag } updateTagMuteState(mutedFilter != null) }, @@ -189,7 +196,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { mastodonApi.getFiltersV1().fold( { filters -> mutedFilterV1 = filters.firstOrNull { filter -> - tag == filter.phrase && filter.context.contains(FilterV1.HOME) + hashedTag == filter.phrase && filter.context.contains(FilterV1.HOME) } updateTagMuteState(mutedFilterV1 != null) }, @@ -221,6 +228,9 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { val tag = hashtag ?: return true lifecycleScope.launch { + var filterCreateSuccess = false + val hashedTag = "#$tag" + mastodonApi.createFilter( title = "#$tag", context = listOf(FilterV1.HOME), @@ -228,10 +238,13 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { expiresInSeconds = null ).fold( { filter -> - if (mastodonApi.addFilterKeyword(filterId = filter.id, keyword = tag, wholeWord = true).isSuccess) { - mutedFilter = filter - updateTagMuteState(true) + if (mastodonApi.addFilterKeyword(filterId = filter.id, keyword = hashedTag, wholeWord = true).isSuccess) { + // must be requested again; otherwise does not contain the keyword (but server does) + mutedFilter = mastodonApi.getFilter(filter.id).getOrNull() + + // TODO the preference key here ("home") is not meaningful; should probably be another event if any eventHub.dispatch(PreferenceChangedEvent(filter.context[0])) + filterCreateSuccess = true } else { Snackbar.make(binding.root, getString(R.string.error_muting_hashtag_format, tag), Snackbar.LENGTH_SHORT).show() Log.e(TAG, "Failed to mute #$tag") @@ -240,7 +253,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { { throwable -> if (throwable is HttpException && throwable.code() == 404) { mastodonApi.createFilterV1( - tag, + hashedTag, listOf(FilterV1.HOME), irreversible = false, wholeWord = true, @@ -248,8 +261,8 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { ).fold( { filter -> mutedFilterV1 = filter - updateTagMuteState(true) eventHub.dispatch(PreferenceChangedEvent(filter.context[0])) + filterCreateSuccess = true }, { throwable -> Snackbar.make(binding.root, getString(R.string.error_muting_hashtag_format, tag), Snackbar.LENGTH_SHORT).show() @@ -262,6 +275,24 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { } } ) + + if (filterCreateSuccess) { + updateTagMuteState(true) + Snackbar.make(binding.root, getString(R.string.muting_hashtag_success_format, tag), Snackbar.LENGTH_LONG).apply { + setAction(R.string.action_view_filter) { + val intent = if (mutedFilter != null) { + Intent(this@StatusListActivity, EditFilterActivity::class.java).apply { + putExtra(EditFilterActivity.FILTER_TO_EDIT, mutedFilter) + } + } else { + Intent(this@StatusListActivity, FiltersActivity::class.java) + } + + startActivityWithSlideInAnimation(intent) + } + show() + } + } } return true @@ -307,6 +338,8 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { eventHub.dispatch(PreferenceChangedEvent(Filter.Kind.HOME.kind)) mutedFilterV1 = null mutedFilter = null + + Snackbar.make(binding.root, getString(R.string.unmuting_hashtag_success_format, tag), Snackbar.LENGTH_SHORT).show() }, { throwable -> Snackbar.make(binding.root, getString(R.string.error_unmuting_hashtag_format, tag), Snackbar.LENGTH_SHORT).show() @@ -351,5 +384,10 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { putExtra(EXTRA_KIND, Kind.TAG.name) putExtra(EXTRA_HASHTAG, hashtag) } + + fun newTrendingIntent(context: Context) = + Intent(context, StatusListActivity::class.java).apply { + putExtra(EXTRA_KIND, Kind.PUBLIC_TRENDING_STATUSES.name) + } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/TabData.kt b/app/src/main/java/com/keylesspalace/tusky/TabData.kt index 58969292f2..e779dc472f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TabData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TabData.kt @@ -20,10 +20,10 @@ 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.notifications.NotificationsFragment import com.keylesspalace.tusky.components.timeline.TimelineFragment import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel import com.keylesspalace.tusky.components.trending.TrendingTagsFragment +import com.keylesspalace.tusky.fragment.NotificationsFragment import java.util.Objects /** this would be a good case for a sealed class, but that does not work nice with Room */ @@ -34,8 +34,10 @@ const val LOCAL = "Local" const val FEDERATED = "Federated" const val DIRECT = "Direct" const val TRENDING_TAGS = "TrendingTags" +const val TRENDING_STATUSES = "TrendingStatuses" const val HASHTAG = "Hashtag" const val LIST = "List" +const val BOOKMARKS = "Bookmarks" data class TabData( val id: String, @@ -98,6 +100,12 @@ fun createTabDataFromId(id: String, arguments: List = emptyList()): TabD icon = R.drawable.ic_trending_up_24px, fragment = { TrendingTagsFragment.newInstance() } ) + TRENDING_STATUSES -> TabData( + id = TRENDING_STATUSES, + text = R.string.title_public_trending_statuses, + icon = R.drawable.ic_hot_24dp, + fragment = { TimelineFragment.newInstance(TimelineViewModel.Kind.PUBLIC_TRENDING_STATUSES) } + ) HASHTAG -> TabData( id = HASHTAG, text = R.string.hashtags, @@ -114,6 +122,12 @@ fun createTabDataFromId(id: String, arguments: List = emptyList()): TabD arguments = arguments, title = { arguments.getOrNull(1).orEmpty() } ) + BOOKMARKS -> TabData( + id = BOOKMARKS, + text = R.string.title_bookmarks, + icon = R.drawable.ic_bookmark_active_24dp, + fragment = { TimelineFragment.newInstance(TimelineViewModel.Kind.BOOKMARKS) } + ) else -> throw IllegalArgumentException("unknown tab type") } } diff --git a/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt b/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt index 5f85f7375c..4d91fd1a67 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt @@ -382,6 +382,14 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene if (!currentTabs.contains(trendingTagsTab)) { addableTabs.add(trendingTagsTab) } + val bookmarksTab = createTabDataFromId(BOOKMARKS) + if (!currentTabs.contains(trendingTagsTab)) { + addableTabs.add(bookmarksTab) + } + val trendingStatusesTab = createTabDataFromId(TRENDING_STATUSES) + if (!currentTabs.contains(trendingStatusesTab)) { + addableTabs.add(trendingStatusesTab) + } addableTabs.add(createTabDataFromId(HASHTAG)) addableTabs.add(createTabDataFromId(LIST)) diff --git a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt index 3c943863db..84fbabbaf2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt @@ -25,10 +25,13 @@ import androidx.work.WorkManager import autodispose2.AutoDisposePlugins import com.keylesspalace.tusky.components.notifications.NotificationHelper import com.keylesspalace.tusky.di.AppInjector +import com.keylesspalace.tusky.settings.NEW_INSTALL_SCHEMA_VERSION import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.settings.PrefKeys.APP_THEME import com.keylesspalace.tusky.settings.SCHEMA_VERSION import com.keylesspalace.tusky.util.APP_THEME_DEFAULT import com.keylesspalace.tusky.util.LocaleManager +import com.keylesspalace.tusky.util.THEME_NIGHT import com.keylesspalace.tusky.util.setAppNightMode import com.keylesspalace.tusky.worker.PruneCacheWorker import com.keylesspalace.tusky.worker.WorkerFactory @@ -76,7 +79,7 @@ class TuskyApplication : Application(), HasAndroidInjector { AppInjector.init(this) // Migrate shared preference keys and defaults from version to version. - val oldVersion = sharedPreferences.getInt(PrefKeys.SCHEMA_VERSION, 0) + val oldVersion = sharedPreferences.getInt(PrefKeys.SCHEMA_VERSION, NEW_INSTALL_SCHEMA_VERSION) if (oldVersion != SCHEMA_VERSION) { upgradeSharedPreferences(oldVersion, SCHEMA_VERSION) } @@ -87,7 +90,7 @@ class TuskyApplication : Application(), HasAndroidInjector { EmojiPackHelper.init(this, DefaultEmojiPackList.get(this), allowPackImports = false) // init night mode - val theme = sharedPreferences.getString("appTheme", APP_THEME_DEFAULT) + val theme = sharedPreferences.getString(APP_THEME, APP_THEME_DEFAULT) setAppNightMode(theme) localeManager.setLocale() @@ -136,6 +139,14 @@ class TuskyApplication : Application(), HasAndroidInjector { editor.remove(PrefKeys.Deprecated.SHOW_NOTIFICATIONS_FILTER) } + if (oldVersion != NEW_INSTALL_SCHEMA_VERSION && oldVersion < 2023082301) { + // Default value for appTheme is now THEME_SYSTEM. If the user is upgrading and + // didn't have an explicit preference set use the previous default, so the + // theme does not unexpectedly change. + if (!sharedPreferences.contains(APP_THEME)) { + editor.putString(APP_THEME, THEME_NIGHT) + } + } editor.putInt(PrefKeys.SCHEMA_VERSION, newVersion) editor.apply() } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountSelectionAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountSelectionAdapter.kt index 0b0115c2ad..cdb81fe0a5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountSelectionAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountSelectionAdapter.kt @@ -47,7 +47,7 @@ class AccountSelectionAdapter(context: Context) : ArrayAdapter(co binding.avatarBadge.visibility = View.GONE // We never want to display the bot badge here val avatarRadius = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_42dp) - val animateAvatar = pm.getBoolean("animateGifAvatars", false) + val animateAvatar = pm.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false) loadAvatar(account.profilePictureUrl, binding.avatar, avatarRadius, animateAvatar) } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt index 446536e6e9..2bbdf44e80 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt @@ -21,12 +21,10 @@ import android.text.Spanned import android.text.style.StyleSpan import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.components.notifications.NotificationsPagingAdapter import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.interfaces.AccountActionListener import com.keylesspalace.tusky.interfaces.LinkListener -import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.loadAvatar @@ -35,33 +33,12 @@ import com.keylesspalace.tusky.util.setClickableText import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.unicodeWrap import com.keylesspalace.tusky.util.visible -import com.keylesspalace.tusky.viewdata.NotificationViewData class FollowRequestViewHolder( private val binding: ItemFollowRequestBinding, - private val accountActionListener: AccountActionListener, private val linkListener: LinkListener, private val showHeader: Boolean -) : NotificationsPagingAdapter.ViewHolder, RecyclerView.ViewHolder(binding.root) { - - override fun bind( - viewData: NotificationViewData, - payloads: List<*>?, - statusDisplayOptions: StatusDisplayOptions - ) { - // Skip updates with payloads. That indicates a timestamp update, and - // this view does not have timestamps. - if (!payloads.isNullOrEmpty()) return - - setupWithAccount( - viewData.account, - statusDisplayOptions.animateAvatars, - statusDisplayOptions.animateEmojis, - statusDisplayOptions.showBotOverlay - ) - - setupActionListener(accountActionListener, viewData.account.id) - } +) : RecyclerView.ViewHolder(binding.root) { fun setupWithAccount( account: TimelineAccount, @@ -70,24 +47,12 @@ class FollowRequestViewHolder( showBotOverlay: Boolean ) { val wrappedName = account.name.unicodeWrap() - val emojifiedName: CharSequence = wrappedName.emojify( - account.emojis, - itemView, - animateEmojis - ) + val emojifiedName: CharSequence = wrappedName.emojify(account.emojis, itemView, animateEmojis) binding.displayNameTextView.text = emojifiedName if (showHeader) { - val wholeMessage: String = itemView.context.getString( - R.string.notification_follow_request_format, - wrappedName - ) + val wholeMessage: String = itemView.context.getString(R.string.notification_follow_request_format, wrappedName) binding.notificationTextView.text = SpannableStringBuilder(wholeMessage).apply { - setSpan( - StyleSpan(Typeface.BOLD), - 0, - wrappedName.length, - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE - ) + setSpan(StyleSpan(Typeface.BOLD), 0, wrappedName.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) }.emojify(account.emojis, itemView, animateEmojis) } binding.notificationTextView.visible(showHeader) diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java new file mode 100644 index 0000000000..1794645435 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java @@ -0,0 +1,708 @@ +/* Copyright 2021 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.adapter; + +import android.content.Context; +import android.graphics.Color; +import android.graphics.PorterDuff; +import android.graphics.Typeface; +import android.graphics.drawable.Drawable; +import android.text.InputFilter; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.TextUtils; +import android.text.style.StyleSpan; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.ColorRes; +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; +import androidx.recyclerview.widget.RecyclerView; + +import com.bumptech.glide.Glide; +import com.keylesspalace.tusky.R; +import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding; +import com.keylesspalace.tusky.databinding.ItemReportNotificationBinding; +import com.keylesspalace.tusky.entity.Emoji; +import com.keylesspalace.tusky.entity.Notification; +import com.keylesspalace.tusky.entity.Status; +import com.keylesspalace.tusky.entity.TimelineAccount; +import com.keylesspalace.tusky.interfaces.AccountActionListener; +import com.keylesspalace.tusky.interfaces.LinkListener; +import com.keylesspalace.tusky.interfaces.StatusActionListener; +import com.keylesspalace.tusky.util.AbsoluteTimeFormatter; +import com.keylesspalace.tusky.util.CardViewMode; +import com.keylesspalace.tusky.util.CustomEmojiHelper; +import com.keylesspalace.tusky.util.ImageLoadingHelper; +import com.keylesspalace.tusky.util.LinkHelper; +import com.keylesspalace.tusky.util.SmartLengthInputFilter; +import com.keylesspalace.tusky.util.StatusDisplayOptions; +import com.keylesspalace.tusky.util.StringUtils; +import com.keylesspalace.tusky.util.TimestampUtils; +import com.keylesspalace.tusky.viewdata.NotificationViewData; +import com.keylesspalace.tusky.viewdata.StatusViewData; + +import java.util.Date; +import java.util.List; + +import at.connyduck.sparkbutton.helpers.Utils; + +public class NotificationsAdapter extends RecyclerView.Adapter implements LinkListener{ + + public interface AdapterDataSource { + int getItemCount(); + + T getItemAt(int pos); + } + + + private static final int VIEW_TYPE_STATUS = 0; + private static final int VIEW_TYPE_STATUS_NOTIFICATION = 1; + private static final int VIEW_TYPE_FOLLOW = 2; + private static final int VIEW_TYPE_FOLLOW_REQUEST = 3; + private static final int VIEW_TYPE_PLACEHOLDER = 4; + private static final int VIEW_TYPE_REPORT = 5; + private static final int VIEW_TYPE_UNKNOWN = 6; + + private static final InputFilter[] COLLAPSE_INPUT_FILTER = new InputFilter[]{SmartLengthInputFilter.INSTANCE}; + private static final InputFilter[] NO_INPUT_FILTER = new InputFilter[0]; + + private final String accountId; + private StatusDisplayOptions statusDisplayOptions; + private final StatusActionListener statusListener; + private final NotificationActionListener notificationActionListener; + private final AccountActionListener accountActionListener; + private final AdapterDataSource dataSource; + private final AbsoluteTimeFormatter absoluteTimeFormatter = new AbsoluteTimeFormatter(); + + public NotificationsAdapter(String accountId, + AdapterDataSource dataSource, + StatusDisplayOptions statusDisplayOptions, + StatusActionListener statusListener, + NotificationActionListener notificationActionListener, + AccountActionListener accountActionListener) { + + this.accountId = accountId; + this.dataSource = dataSource; + this.statusDisplayOptions = statusDisplayOptions; + this.statusListener = statusListener; + this.notificationActionListener = notificationActionListener; + this.accountActionListener = accountActionListener; + } + + @NonNull + @Override + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + LayoutInflater inflater = LayoutInflater.from(parent.getContext()); + switch (viewType) { + case VIEW_TYPE_STATUS: { + View view = inflater + .inflate(R.layout.item_status, parent, false); + return new StatusViewHolder(view); + } + case VIEW_TYPE_STATUS_NOTIFICATION: { + View view = inflater + .inflate(R.layout.item_status_notification, parent, false); + return new StatusNotificationViewHolder(view, statusDisplayOptions, absoluteTimeFormatter); + } + case VIEW_TYPE_FOLLOW: { + View view = inflater + .inflate(R.layout.item_follow, parent, false); + return new FollowViewHolder(view, statusDisplayOptions); + } + case VIEW_TYPE_FOLLOW_REQUEST: { + ItemFollowRequestBinding binding = ItemFollowRequestBinding.inflate(inflater, parent, false); + return new FollowRequestViewHolder(binding, this, true); + } + case VIEW_TYPE_PLACEHOLDER: { + View view = inflater + .inflate(R.layout.item_status_placeholder, parent, false); + return new PlaceholderViewHolder(view); + } + case VIEW_TYPE_REPORT: { + ItemReportNotificationBinding binding = ItemReportNotificationBinding.inflate(inflater, parent, false); + return new ReportNotificationViewHolder(binding); + } + default: + case VIEW_TYPE_UNKNOWN: { + View view = new View(parent.getContext()); + view.setLayoutParams( + new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + Utils.dpToPx(parent.getContext(), 24) + ) + ); + return new RecyclerView.ViewHolder(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) { + Object payloadForHolder = payloads != null && !payloads.isEmpty() ? payloads.get(0) : null; + if (position < this.dataSource.getItemCount()) { + NotificationViewData notification = dataSource.getItemAt(position); + if (notification instanceof NotificationViewData.Placeholder) { + if (payloadForHolder == null) { + NotificationViewData.Placeholder placeholder = ((NotificationViewData.Placeholder) notification); + PlaceholderViewHolder holder = (PlaceholderViewHolder) viewHolder; + holder.setup(statusListener, placeholder.isLoading()); + } + return; + } + NotificationViewData.Concrete concreteNotification = + (NotificationViewData.Concrete) notification; + switch (viewHolder.getItemViewType()) { + case VIEW_TYPE_STATUS: { + StatusViewHolder holder = (StatusViewHolder) viewHolder; + StatusViewData.Concrete status = concreteNotification.getStatusViewData(); + if (status == null) { + /* in some very rare cases servers sends null status even though they should not, + * we have to handle it somehow */ + holder.showStatusContent(false); + } else { + if (payloads == null) { + holder.showStatusContent(true); + } + holder.setupWithStatus(status, statusListener, statusDisplayOptions, payloadForHolder); + } + if (concreteNotification.getType() == Notification.Type.POLL) { + holder.setPollInfo(accountId.equals(concreteNotification.getAccount().getId())); + } else { + holder.hideStatusInfo(); + } + break; + } + case VIEW_TYPE_STATUS_NOTIFICATION: { + StatusNotificationViewHolder holder = (StatusNotificationViewHolder) viewHolder; + StatusViewData.Concrete statusViewData = concreteNotification.getStatusViewData(); + if (payloadForHolder == null) { + if (statusViewData == null) { + /* in some very rare cases servers sends null status even though they should not, + * we have to handle it somehow */ + holder.showNotificationContent(false); + } else { + holder.showNotificationContent(true); + + Status status = statusViewData.getActionable(); + holder.setDisplayName(status.getAccount().getDisplayName(), status.getAccount().getEmojis()); + holder.setUsername(status.getAccount().getUsername()); + holder.setCreatedAt(status.getCreatedAt()); + + if (concreteNotification.getType() == Notification.Type.STATUS || + concreteNotification.getType() == Notification.Type.UPDATE) { + holder.setAvatar(status.getAccount().getAvatar(), status.getAccount().getBot()); + } else { + holder.setAvatars(status.getAccount().getAvatar(), + concreteNotification.getAccount().getAvatar()); + } + } + + holder.setMessage(concreteNotification, statusListener); + holder.setupButtons(notificationActionListener, + concreteNotification.getAccount().getId(), + concreteNotification.getId()); + } else { + if (payloadForHolder instanceof List) + for (Object item : (List) payloadForHolder) { + if (StatusBaseViewHolder.Key.KEY_CREATED.equals(item) && statusViewData != null) { + holder.setCreatedAt(statusViewData.getStatus().getActionableStatus().getCreatedAt()); + } + } + } + break; + } + case VIEW_TYPE_FOLLOW: { + if (payloadForHolder == null) { + FollowViewHolder holder = (FollowViewHolder) viewHolder; + holder.setMessage(concreteNotification.getAccount(), concreteNotification.getType() == Notification.Type.SIGN_UP); + holder.setupButtons(notificationActionListener, concreteNotification.getAccount().getId()); + } + break; + } + case VIEW_TYPE_FOLLOW_REQUEST: { + if (payloadForHolder == null) { + FollowRequestViewHolder holder = (FollowRequestViewHolder) viewHolder; + holder.setupWithAccount(concreteNotification.getAccount(), statusDisplayOptions.animateAvatars(), statusDisplayOptions.animateEmojis(), statusDisplayOptions.showBotOverlay()); + holder.setupActionListener(accountActionListener, concreteNotification.getAccount().getId()); + } + break; + } + case VIEW_TYPE_REPORT: { + if (payloadForHolder == null) { + ReportNotificationViewHolder holder = (ReportNotificationViewHolder) viewHolder; + holder.setupWithReport(concreteNotification.getAccount(), concreteNotification.getReport(), statusDisplayOptions.animateAvatars(), statusDisplayOptions.animateEmojis()); + holder.setupActionListener(notificationActionListener, concreteNotification.getReport().getTargetAccount().getId(), concreteNotification.getAccount().getId(), concreteNotification.getReport().getId()); + } + } + default: + } + } + } + + @Override + public int getItemCount() { + return dataSource.getItemCount(); + } + + public void setMediaPreviewEnabled(boolean mediaPreviewEnabled) { + this.statusDisplayOptions = statusDisplayOptions.copy( + statusDisplayOptions.animateAvatars(), + mediaPreviewEnabled, + statusDisplayOptions.useAbsoluteTime(), + statusDisplayOptions.showBotOverlay(), + statusDisplayOptions.useBlurhash(), + CardViewMode.NONE, + statusDisplayOptions.confirmReblogs(), + statusDisplayOptions.confirmFavourites(), + statusDisplayOptions.hideStats(), + statusDisplayOptions.animateEmojis(), + statusDisplayOptions.showStatsInline(), + statusDisplayOptions.showSensitiveMedia(), + statusDisplayOptions.openSpoiler() + ); + } + + public boolean isMediaPreviewEnabled() { + return this.statusDisplayOptions.mediaPreviewEnabled(); + } + + @Override + public int getItemViewType(int position) { + NotificationViewData notification = dataSource.getItemAt(position); + if (notification instanceof NotificationViewData.Concrete) { + NotificationViewData.Concrete concrete = ((NotificationViewData.Concrete) notification); + switch (concrete.getType()) { + case MENTION: + case POLL: { + return VIEW_TYPE_STATUS; + } + case STATUS: + case FAVOURITE: + case REBLOG: + case UPDATE: { + return VIEW_TYPE_STATUS_NOTIFICATION; + } + case FOLLOW: + case SIGN_UP: { + return VIEW_TYPE_FOLLOW; + } + case FOLLOW_REQUEST: { + return VIEW_TYPE_FOLLOW_REQUEST; + } + case REPORT: { + return VIEW_TYPE_REPORT; + } + default: { + return VIEW_TYPE_UNKNOWN; + } + } + } else if (notification instanceof NotificationViewData.Placeholder) { + return VIEW_TYPE_PLACEHOLDER; + } else { + throw new AssertionError("Unknown notification type"); + } + + + } + + public interface NotificationActionListener { + void onViewAccount(String id); + + void onViewStatusForNotificationId(String notificationId); + + void onViewReport(String reportId); + + void onExpandedChange(boolean expanded, int position); + + /** + * Called when the status {@link android.widget.ToggleButton} responsible for collapsing long + * status content is interacted with. + * + * @param isCollapsed Whether the status content is shown in a collapsed state or fully. + * @param position The position of the status in the list. + */ + void onNotificationContentCollapsedChange(boolean isCollapsed, int position); + } + + private static class FollowViewHolder extends RecyclerView.ViewHolder { + private final TextView message; + private final TextView usernameView; + private final TextView displayNameView; + private final ImageView avatar; + private final StatusDisplayOptions statusDisplayOptions; + + FollowViewHolder(View itemView, StatusDisplayOptions statusDisplayOptions) { + super(itemView); + message = itemView.findViewById(R.id.notification_text); + usernameView = itemView.findViewById(R.id.notification_username); + displayNameView = itemView.findViewById(R.id.notification_display_name); + avatar = itemView.findViewById(R.id.notification_avatar); + this.statusDisplayOptions = statusDisplayOptions; + } + + void setMessage(TimelineAccount account, Boolean isSignUp) { + Context context = message.getContext(); + + String format = context.getString(isSignUp ? R.string.notification_sign_up_format : R.string.notification_follow_format); + String wrappedDisplayName = StringUtils.unicodeWrap(account.getName()); + String wholeMessage = String.format(format, wrappedDisplayName); + CharSequence emojifiedMessage = CustomEmojiHelper.emojify( + wholeMessage, account.getEmojis(), message, statusDisplayOptions.animateEmojis() + ); + message.setText(emojifiedMessage); + + String username = context.getString(R.string.post_username_format, account.getUsername()); + usernameView.setText(username); + + CharSequence emojifiedDisplayName = CustomEmojiHelper.emojify( + wrappedDisplayName, account.getEmojis(), usernameView, statusDisplayOptions.animateEmojis() + ); + + displayNameView.setText(emojifiedDisplayName); + + int avatarRadius = avatar.getContext().getResources() + .getDimensionPixelSize(R.dimen.avatar_radius_42dp); + + ImageLoadingHelper.loadAvatar(account.getAvatar(), avatar, avatarRadius, + statusDisplayOptions.animateAvatars(), null); + + } + + void setupButtons(final NotificationActionListener listener, final String accountId) { + itemView.setOnClickListener(v -> listener.onViewAccount(accountId)); + } + } + + private static class StatusNotificationViewHolder extends RecyclerView.ViewHolder + implements View.OnClickListener { + + private final View container; + private final TextView message; +// private final View statusNameBar; + private final TextView displayName; + private final TextView username; + private final TextView timestampInfo; + private final TextView statusContent; + private final ImageView statusAvatar; + private final ImageView notificationAvatar; + private final TextView contentWarningDescriptionTextView; + private final Button contentWarningButton; + private final Button contentCollapseButton; // TODO: This code SHOULD be based on StatusBaseViewHolder + private final StatusDisplayOptions statusDisplayOptions; + private final AbsoluteTimeFormatter absoluteTimeFormatter; + + private String accountId; + private String notificationId; + private NotificationActionListener notificationActionListener; + private StatusViewData.Concrete statusViewData; + + private final int avatarRadius48dp; + private final int avatarRadius36dp; + private final int avatarRadius24dp; + + StatusNotificationViewHolder( + View itemView, + StatusDisplayOptions statusDisplayOptions, + AbsoluteTimeFormatter absoluteTimeFormatter + ) { + super(itemView); + message = itemView.findViewById(R.id.notification_top_text); +// statusNameBar = itemView.findViewById(R.id.status_name_bar); + displayName = itemView.findViewById(R.id.status_display_name); + username = itemView.findViewById(R.id.status_username); + timestampInfo = itemView.findViewById(R.id.status_meta_info); + statusContent = itemView.findViewById(R.id.notification_content); + statusAvatar = itemView.findViewById(R.id.notification_status_avatar); + notificationAvatar = itemView.findViewById(R.id.notification_notification_avatar); + contentWarningDescriptionTextView = itemView.findViewById(R.id.notification_content_warning_description); + contentWarningButton = itemView.findViewById(R.id.notification_content_warning_button); + contentCollapseButton = itemView.findViewById(R.id.button_toggle_notification_content); + + container = itemView.findViewById(R.id.notification_container); + + this.statusDisplayOptions = statusDisplayOptions; + this.absoluteTimeFormatter = absoluteTimeFormatter; + + int darkerFilter = Color.rgb(123, 123, 123); + statusAvatar.setColorFilter(darkerFilter, PorterDuff.Mode.MULTIPLY); + notificationAvatar.setColorFilter(darkerFilter, PorterDuff.Mode.MULTIPLY); + + itemView.setOnClickListener(this); + message.setOnClickListener(this); + statusContent.setOnClickListener(this); + + this.avatarRadius48dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_48dp); + this.avatarRadius36dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_36dp); + this.avatarRadius24dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_24dp); + } + + private void showNotificationContent(boolean show) { +// statusNameBar.setVisibility(show ? View.VISIBLE : View.GONE); + contentWarningDescriptionTextView.setVisibility(show ? View.VISIBLE : View.GONE); + contentWarningButton.setVisibility(show ? View.VISIBLE : View.GONE); + statusContent.setVisibility(show ? View.VISIBLE : View.GONE); + statusAvatar.setVisibility(show ? View.VISIBLE : View.GONE); + notificationAvatar.setVisibility(show ? View.VISIBLE : View.GONE); + } + + private void setDisplayName(String name, List emojis) { + CharSequence emojifiedName = CustomEmojiHelper.emojify(name, emojis, displayName, statusDisplayOptions.animateEmojis()); + displayName.setText(emojifiedName); + } + + private void setUsername(String name) { + Context context = username.getContext(); + String format = context.getString(R.string.post_username_format); + String usernameText = String.format(format, name); + username.setText(usernameText); + } + + protected void setCreatedAt(@Nullable Date createdAt) { + if (statusDisplayOptions.useAbsoluteTime()) { + timestampInfo.setText(absoluteTimeFormatter.format(createdAt, true)); + } else { + // This is the visible timestampInfo. + String readout; + /* This one is for screen-readers. Frequently, they would mispronounce timestamps like "17m" + * as 17 meters instead of minutes. */ + CharSequence readoutAloud; + if (createdAt != null) { + long then = createdAt.getTime(); + long now = new Date().getTime(); + readout = TimestampUtils.getRelativeTimeSpanString(timestampInfo.getContext(), then, now); + readoutAloud = android.text.format.DateUtils.getRelativeTimeSpanString(then, now, + android.text.format.DateUtils.SECOND_IN_MILLIS, + android.text.format.DateUtils.FORMAT_ABBREV_RELATIVE); + } else { + // unknown minutes~ + readout = "?m"; + readoutAloud = "? minutes"; + } + timestampInfo.setText(readout); + timestampInfo.setContentDescription(readoutAloud); + } + } + + Drawable getIconWithColor(Context context, @DrawableRes int drawable, @ColorRes int color) { + Drawable icon = ContextCompat.getDrawable(context, drawable); + if (icon != null) { + icon.setColorFilter(context.getColor(color), PorterDuff.Mode.SRC_ATOP); + } + return icon; + } + + void setMessage(NotificationViewData.Concrete notificationViewData, LinkListener listener) { + this.statusViewData = notificationViewData.getStatusViewData(); + + String displayName = StringUtils.unicodeWrap(notificationViewData.getAccount().getName()); + Notification.Type type = notificationViewData.getType(); + + Context context = message.getContext(); + String format; + Drawable icon; + switch (type) { + default: + case FAVOURITE: { + icon = getIconWithColor(context, R.drawable.ic_star_24dp, R.color.tusky_orange); + format = context.getString(R.string.notification_favourite_format); + break; + } + case REBLOG: { + icon = getIconWithColor(context, R.drawable.ic_repeat_24dp, R.color.tusky_blue); + format = context.getString(R.string.notification_reblog_format); + break; + } + case STATUS: { + icon = getIconWithColor(context, R.drawable.ic_home_24dp, R.color.tusky_blue); + format = context.getString(R.string.notification_subscription_format); + break; + } + case UPDATE: { + icon = getIconWithColor(context, R.drawable.ic_edit_24dp, R.color.tusky_blue); + format = context.getString(R.string.notification_update_format); + break; + } + } + message.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null); + String wholeMessage = String.format(format, displayName); + final SpannableStringBuilder str = new SpannableStringBuilder(wholeMessage); + int displayNameIndex = format.indexOf("%s"); + str.setSpan( + new StyleSpan(Typeface.BOLD), + displayNameIndex, + displayNameIndex + displayName.length(), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ); + CharSequence emojifiedText = CustomEmojiHelper.emojify( + str, notificationViewData.getAccount().getEmojis(), message, statusDisplayOptions.animateEmojis() + ); + message.setText(emojifiedText); + + if (statusViewData != null) { + boolean hasSpoiler = !TextUtils.isEmpty(statusViewData.getStatus().getSpoilerText()); + contentWarningDescriptionTextView.setVisibility(hasSpoiler ? View.VISIBLE : View.GONE); + contentWarningButton.setVisibility(hasSpoiler ? View.VISIBLE : View.GONE); + if (statusViewData.isExpanded()) { + contentWarningButton.setText(R.string.post_content_warning_show_less); + } else { + contentWarningButton.setText(R.string.post_content_warning_show_more); + } + + contentWarningButton.setOnClickListener(view -> { + if (getBindingAdapterPosition() != RecyclerView.NO_POSITION) { + notificationActionListener.onExpandedChange(!statusViewData.isExpanded(), getBindingAdapterPosition()); + } + statusContent.setVisibility(statusViewData.isExpanded() ? View.GONE : View.VISIBLE); + }); + + setupContentAndSpoiler(listener); + } + + } + + void setupButtons(final NotificationActionListener listener, final String accountId, + final String notificationId) { + this.notificationActionListener = listener; + this.accountId = accountId; + this.notificationId = notificationId; + } + + void setAvatar(@Nullable String statusAvatarUrl, boolean isBot) { + statusAvatar.setPaddingRelative(0, 0, 0, 0); + + ImageLoadingHelper.loadAvatar(statusAvatarUrl, + statusAvatar, avatarRadius48dp, statusDisplayOptions.animateAvatars(), null); + + if (statusDisplayOptions.showBotOverlay() && isBot) { + notificationAvatar.setVisibility(View.VISIBLE); + Glide.with(notificationAvatar) + .load(R.drawable.bot_badge) + .into(notificationAvatar); + + } else { + notificationAvatar.setVisibility(View.GONE); + } + } + + void setAvatars(@Nullable String statusAvatarUrl, @Nullable String notificationAvatarUrl) { + int padding = Utils.dpToPx(statusAvatar.getContext(), 12); + statusAvatar.setPaddingRelative(0, 0, padding, padding); + + ImageLoadingHelper.loadAvatar(statusAvatarUrl, + statusAvatar, avatarRadius36dp, statusDisplayOptions.animateAvatars(), null); + + notificationAvatar.setVisibility(View.VISIBLE); + ImageLoadingHelper.loadAvatar(notificationAvatarUrl, notificationAvatar, + avatarRadius24dp, statusDisplayOptions.animateAvatars(), null); + } + + @Override + public void onClick(View v) { + if (notificationActionListener == null) + return; + + if (v == container || v == statusContent) { + notificationActionListener.onViewStatusForNotificationId(notificationId); + } + else if (v == message) { + notificationActionListener.onViewAccount(accountId); + } + } + + private void setupContentAndSpoiler(final LinkListener listener) { + + boolean shouldShowContentIfSpoiler = statusViewData.isExpanded(); + boolean hasSpoiler = !TextUtils.isEmpty(statusViewData.getStatus(). getSpoilerText()); + if (!shouldShowContentIfSpoiler && hasSpoiler) { + statusContent.setVisibility(View.GONE); + } else { + statusContent.setVisibility(View.VISIBLE); + } + + Spanned content = statusViewData.getContent(); + List emojis = statusViewData.getActionable().getEmojis(); + + if (statusViewData.isCollapsible() && (statusViewData.isExpanded() || !hasSpoiler)) { + contentCollapseButton.setOnClickListener(view -> { + int position = getBindingAdapterPosition(); + if (position != RecyclerView.NO_POSITION && notificationActionListener != null) { + notificationActionListener.onNotificationContentCollapsedChange(!statusViewData.isCollapsed(), position); + } + }); + + contentCollapseButton.setVisibility(View.VISIBLE); + if (statusViewData.isCollapsed()) { + contentCollapseButton.setText(R.string.post_content_warning_show_more); + statusContent.setFilters(COLLAPSE_INPUT_FILTER); + } else { + contentCollapseButton.setText(R.string.post_content_warning_show_less); + statusContent.setFilters(NO_INPUT_FILTER); + } + } else { + contentCollapseButton.setVisibility(View.GONE); + statusContent.setFilters(NO_INPUT_FILTER); + } + + CharSequence emojifiedText = CustomEmojiHelper.emojify( + content, emojis, statusContent, statusDisplayOptions.animateEmojis() + ); + LinkHelper.setClickableText(statusContent, emojifiedText, statusViewData.getActionable().getMentions(), statusViewData.getActionable().getTags(), listener); + + CharSequence emojifiedContentWarning = CustomEmojiHelper.emojify( + statusViewData.getStatus().getSpoilerText(), + statusViewData.getActionable().getEmojis(), + contentWarningDescriptionTextView, + statusDisplayOptions.animateEmojis() + ); + contentWarningDescriptionTextView.setText(emojifiedContentWarning); + } + + } + + + @Override + public void onViewTag(@NonNull String tag) { + + } + + @Override + public void onViewAccount(@NonNull String id) { + + } + + @Override + public void onViewUrl(@NonNull String url) { + + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/ReportNotificationViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/ReportNotificationViewHolder.kt index 7502c24e94..db2f79a992 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/ReportNotificationViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/ReportNotificationViewHolder.kt @@ -20,76 +20,28 @@ import androidx.core.content.ContextCompat import androidx.recyclerview.widget.RecyclerView import at.connyduck.sparkbutton.helpers.Utils import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.components.notifications.NotificationActionListener -import com.keylesspalace.tusky.components.notifications.NotificationsPagingAdapter +import com.keylesspalace.tusky.adapter.NotificationsAdapter.NotificationActionListener import com.keylesspalace.tusky.databinding.ItemReportNotificationBinding import com.keylesspalace.tusky.entity.Report import com.keylesspalace.tusky.entity.TimelineAccount -import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.getRelativeTimeSpanString import com.keylesspalace.tusky.util.loadAvatar import com.keylesspalace.tusky.util.unicodeWrap -import com.keylesspalace.tusky.viewdata.NotificationViewData import java.util.Date class ReportNotificationViewHolder( private val binding: ItemReportNotificationBinding, - private val notificationActionListener: NotificationActionListener -) : NotificationsPagingAdapter.ViewHolder, RecyclerView.ViewHolder(binding.root) { +) : RecyclerView.ViewHolder(binding.root) { - override fun bind( - viewData: NotificationViewData, - payloads: List<*>?, - statusDisplayOptions: StatusDisplayOptions - ) { - // Skip updates with payloads. That indicates a timestamp update, and - // this view does not have timestamps. - if (!payloads.isNullOrEmpty()) return - - setupWithReport( - viewData.account, - viewData.report!!, - statusDisplayOptions.animateAvatars, - statusDisplayOptions.animateEmojis - ) - setupActionListener( - notificationActionListener, - viewData.report.targetAccount.id, - viewData.account.id, - viewData.report.id - ) - } - - private fun setupWithReport( - reporter: TimelineAccount, - report: Report, - animateAvatar: Boolean, - animateEmojis: Boolean - ) { - val reporterName = reporter.name.unicodeWrap().emojify( - reporter.emojis, - binding.root, - animateEmojis - ) - val reporteeName = report.targetAccount.name.unicodeWrap().emojify( - report.targetAccount.emojis, - itemView, - animateEmojis - ) - val icon = ContextCompat.getDrawable(binding.root.context, R.drawable.ic_flag_24dp) + fun setupWithReport(reporter: TimelineAccount, report: Report, animateAvatar: Boolean, animateEmojis: Boolean) { + val reporterName = reporter.name.unicodeWrap().emojify(reporter.emojis, itemView, animateEmojis) + val reporteeName = report.targetAccount.name.unicodeWrap().emojify(report.targetAccount.emojis, itemView, animateEmojis) + val icon = ContextCompat.getDrawable(itemView.context, R.drawable.ic_flag_24dp) binding.notificationTopText.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null) - binding.notificationTopText.text = itemView.context.getString( - R.string.notification_header_report_format, - reporterName, - reporteeName - ) - binding.notificationSummary.text = itemView.context.getString( - R.string.notification_summary_report_format, - getRelativeTimeSpanString(itemView.context, report.createdAt.time, Date().time), - report.status_ids?.size ?: 0 - ) + binding.notificationTopText.text = itemView.context.getString(R.string.notification_header_report_format, reporterName, reporteeName) + binding.notificationSummary.text = itemView.context.getString(R.string.notification_summary_report_format, getRelativeTimeSpanString(itemView.context, report.createdAt.time, Date().time), report.status_ids?.size ?: 0) binding.notificationCategory.text = getTranslatedCategory(itemView.context, report.category) // Fancy avatar inset @@ -100,22 +52,17 @@ class ReportNotificationViewHolder( report.targetAccount.avatar, binding.notificationReporteeAvatar, itemView.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp), - animateAvatar + animateAvatar, ) loadAvatar( reporter.avatar, binding.notificationReporterAvatar, itemView.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_24dp), - animateAvatar + animateAvatar, ) } - private fun setupActionListener( - listener: NotificationActionListener, - reporteeId: String, - reporterId: String, - reportId: String - ) { + fun setupActionListener(listener: NotificationActionListener, reporteeId: String, reporterId: String, reportId: String) { binding.notificationReporteeAvatar.setOnClickListener { val position = bindingAdapterPosition if (position != RecyclerView.NO_POSITION) { diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java index 5435dc8f3a..49e70fc71e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java @@ -130,7 +130,7 @@ public static class Key { private final Drawable mediaPreviewUnloaded; - protected StatusBaseViewHolder(View itemView) { + protected StatusBaseViewHolder(@NonNull View itemView) { super(itemView); displayName = itemView.findViewById(R.id.status_display_name); username = itemView.findViewById(R.id.status_username); @@ -191,14 +191,14 @@ protected StatusBaseViewHolder(View itemView) { TouchDelegateHelper.expandTouchSizeToFillRow((ViewGroup) itemView, CollectionsKt.listOfNotNull(replyButton, reblogButton, favouriteButton, bookmarkButton, moreButton)); } - protected void setDisplayName(String name, List customEmojis, StatusDisplayOptions statusDisplayOptions) { + protected void setDisplayName(@NonNull String name, @Nullable List customEmojis, @NonNull StatusDisplayOptions statusDisplayOptions) { CharSequence emojifiedName = CustomEmojiHelper.emojify( name, customEmojis, displayName, statusDisplayOptions.animateEmojis() ); displayName.setText(emojifiedName); } - protected void setUsername(String name) { + protected void setUsername(@Nullable String name) { Context context = username.getContext(); String usernameText = context.getString(R.string.post_username_format, name); username.setText(usernameText); @@ -210,10 +210,10 @@ public void toggleContentWarning() { protected void setSpoilerAndContent(@NonNull StatusViewData.Concrete status, @NonNull StatusDisplayOptions statusDisplayOptions, - final StatusActionListener listener) { + final @NonNull StatusActionListener listener) { Status actionable = status.getActionable(); - String spoilerText = status.getSpoilerText(); + String spoilerText = actionable.getSpoilerText(); List emojis = actionable.getEmojis(); boolean sensitive = !TextUtils.isEmpty(spoilerText); @@ -340,7 +340,7 @@ private void setAvatar(String url, Collections.singletonList(new CompositeWithOpaqueBackground(avatar))); } - protected void setMetaData(StatusViewData.Concrete statusViewData, StatusDisplayOptions statusDisplayOptions, StatusActionListener listener) { + protected void setMetaData(@NonNull StatusViewData.Concrete statusViewData, @NonNull StatusDisplayOptions statusDisplayOptions, @NonNull StatusActionListener listener) { Status status = statusViewData.getActionable(); Date createdAt = status.getCreatedAt(); @@ -491,9 +491,9 @@ private void loadImage(MediaPreviewImageView imageView, } protected void setMediaPreviews( - final List attachments, + final @NonNull List attachments, boolean sensitive, - final StatusActionListener listener, + final @NonNull StatusActionListener listener, boolean showingContent, boolean useBlurhash ) { @@ -584,8 +584,8 @@ private void updateMediaLabel(int index, boolean sensitive, boolean showingConte mediaLabels[index].setText(label); } - protected void setMediaLabel(List attachments, boolean sensitive, - final StatusActionListener listener, boolean showingContent) { + protected void setMediaLabel(@NonNull List attachments, boolean sensitive, + final @NonNull StatusActionListener listener, boolean showingContent) { Context context = itemView.getContext(); for (int i = 0; i < mediaLabels.length; i++) { TextView mediaLabel = mediaLabels[i]; @@ -606,7 +606,7 @@ protected void setMediaLabel(List attachments, boolean sensitive, } } - private void setAttachmentClickListener(View view, StatusActionListener listener, + private void setAttachmentClickListener(View view, @NonNull StatusActionListener listener, int index, Attachment attachment, boolean animateTransition) { view.setOnClickListener(v -> { int position = getBindingAdapterPosition(); @@ -630,10 +630,10 @@ protected void hideSensitiveMediaWarning() { sensitiveMediaShow.setVisibility(View.GONE); } - protected void setupButtons(final StatusActionListener listener, - final String accountId, - final String statusContent, - StatusDisplayOptions statusDisplayOptions) { + protected void setupButtons(final @NonNull StatusActionListener listener, + final @NonNull String accountId, + final @Nullable String statusContent, + @NonNull StatusDisplayOptions statusDisplayOptions) { View.OnClickListener profileButtonClickListener = button -> listener.onViewAccount(accountId); avatar.setOnClickListener(profileButtonClickListener); @@ -752,8 +752,8 @@ private void showConfirmFavourite(StatusActionListener listener, popup.show(); } - public void setupWithStatus(StatusViewData.Concrete status, final StatusActionListener listener, - StatusDisplayOptions statusDisplayOptions) { + public void setupWithStatus(@NonNull StatusViewData.Concrete status, final @NonNull StatusActionListener listener, + @NonNull StatusDisplayOptions statusDisplayOptions) { this.setupWithStatus(status, listener, statusDisplayOptions, null); } @@ -764,7 +764,7 @@ public void setupWithStatus(@NonNull StatusViewData.Concrete status, if (payloads == null) { Status actionable = status.getActionable(); setDisplayName(actionable.getAccount().getName(), actionable.getAccount().getEmojis(), statusDisplayOptions); - setUsername(status.getUsername()); + setUsername(actionable.getAccount().getUsername()); setMetaData(status, statusDisplayOptions, listener); setIsReply(actionable.getInReplyToId() != null); setReplyCount(actionable.getRepliesCount(), statusDisplayOptions.showStatsInline()); @@ -843,7 +843,7 @@ private void setupFilterPlaceholder(StatusViewData.Concrete status, StatusAction filteredPlaceholderShowButton.setOnClickListener(view -> listener.clearWarningAction(getBindingAdapterPosition())); } - protected static boolean hasPreviewableAttachment(List attachments) { + protected static boolean hasPreviewableAttachment(@NonNull List attachments) { for (Attachment attachment : attachments) { if (attachment.getType() == Attachment.Type.AUDIO || attachment.getType() == Attachment.Type.UNKNOWN) { return false; @@ -860,11 +860,11 @@ private void setDescriptionForStatus(@NonNull StatusViewData.Concrete status, String description = context.getString(R.string.description_status, actionable.getAccount().getDisplayName(), getContentWarningDescription(context, status), - (TextUtils.isEmpty(status.getSpoilerText()) || !actionable.getSensitive() || status.isExpanded() ? status.getContent() : ""), + (TextUtils.isEmpty(actionable.getSpoilerText()) || !actionable.getSensitive() || status.isExpanded() ? status.getContent() : ""), getCreatedAtDescription(actionable.getCreatedAt(), statusDisplayOptions), actionable.getEditedAt() != null ? context.getString(R.string.description_post_edited) : "", getReblogDescription(context, status), - status.getUsername(), + actionable.getAccount().getUsername(), actionable.getReblogged() ? context.getString(R.string.description_post_reblogged) : "", actionable.getFavourited() ? context.getString(R.string.description_post_favourited) : "", actionable.getBookmarked() ? context.getString(R.string.description_post_bookmarked) : "", @@ -911,14 +911,15 @@ private static CharSequence getMediaDescription(Context context, private static CharSequence getContentWarningDescription(Context context, @NonNull StatusViewData.Concrete status) { - if (!TextUtils.isEmpty(status.getSpoilerText())) { - return context.getString(R.string.description_post_cw, status.getSpoilerText()); + if (!TextUtils.isEmpty(status.getActionable().getSpoilerText())) { + return context.getString(R.string.description_post_cw, status.getActionable().getSpoilerText()); } else { return ""; } } - protected static CharSequence getVisibilityDescription(Context context, Status.Visibility visibility) { + @NonNull + protected static CharSequence getVisibilityDescription(@NonNull Context context, @Nullable Status.Visibility visibility) { if (visibility == null) { return ""; @@ -967,7 +968,8 @@ private CharSequence getPollDescription(@NonNull StatusViewData.Concrete status, } } - protected CharSequence getFavsText(Context context, int count) { + @NonNull + protected CharSequence getFavsText(@NonNull Context context, int count) { if (count > 0) { String countString = numberFormat.format(count); return HtmlCompat.fromHtml(context.getResources().getQuantityString(R.plurals.favs, count, countString), HtmlCompat.FROM_HTML_MODE_LEGACY); @@ -976,7 +978,8 @@ protected CharSequence getFavsText(Context context, int count) { } } - protected CharSequence getReblogsText(Context context, int count) { + @NonNull + protected CharSequence getReblogsText(@NonNull Context context, int count) { if (count > 0) { String countString = numberFormat.format(count); return HtmlCompat.fromHtml(context.getResources().getQuantityString(R.plurals.reblogs, count, countString), HtmlCompat.FROM_HTML_MODE_LEGACY); @@ -1077,11 +1080,11 @@ private CharSequence getPollInfoText(long timestamp, PollViewData poll, } protected void setupCard( - final StatusViewData.Concrete status, + final @NonNull StatusViewData.Concrete status, boolean expanded, - final CardViewMode cardViewMode, - final StatusDisplayOptions statusDisplayOptions, - final StatusActionListener listener + final @NonNull CardViewMode cardViewMode, + final @NonNull StatusDisplayOptions statusDisplayOptions, + final @NonNull StatusActionListener listener ) { if (cardView == null) { return; diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java index 76eda11074..08145f09b7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java @@ -35,7 +35,7 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder { private static final DateFormat dateFormat = DateFormat.getDateTimeInstance(DateFormat.DEFAULT, DateFormat.SHORT); - public StatusDetailedViewHolder(View view) { + public StatusDetailedViewHolder(@NonNull View view) { super(view); reblogs = view.findViewById(R.id.status_reblogs); favourites = view.findViewById(R.id.status_favourites); @@ -43,7 +43,7 @@ public StatusDetailedViewHolder(View view) { } @Override - protected void setMetaData(StatusViewData.Concrete statusViewData, StatusDisplayOptions statusDisplayOptions, StatusActionListener listener) { + protected void setMetaData(@NonNull StatusViewData.Concrete statusViewData, @NonNull StatusDisplayOptions statusDisplayOptions, @NonNull StatusActionListener listener) { Status status = statusViewData.getActionable(); diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java index 304cf93a57..327f7cfbb8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java @@ -51,7 +51,7 @@ public class StatusViewHolder extends StatusBaseViewHolder { private final TextView favouritedCountLabel; private final TextView reblogsCountLabel; - public StatusViewHolder(View itemView) { + public StatusViewHolder(@NonNull View itemView) { super(itemView); statusInfo = itemView.findViewById(R.id.status_info); contentCollapseButton = itemView.findViewById(R.id.button_toggle_content); diff --git a/app/src/main/java/com/keylesspalace/tusky/appstore/EventsHub.kt b/app/src/main/java/com/keylesspalace/tusky/appstore/EventsHub.kt index 4030b116f6..9fb4a3c82a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/appstore/EventsHub.kt +++ b/app/src/main/java/com/keylesspalace/tusky/appstore/EventsHub.kt @@ -1,5 +1,7 @@ package com.keylesspalace.tusky.appstore +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.subjects.PublishSubject import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import javax.inject.Inject @@ -13,7 +15,16 @@ class EventHub @Inject constructor() { private val sharedEventFlow: MutableSharedFlow = MutableSharedFlow() val events: Flow = sharedEventFlow + // TODO remove this old stuff as soon as NotificationsFragment is Kotlin + private val eventsSubject = PublishSubject.create() + val eventsObservable: Observable = eventsSubject + suspend fun dispatch(event: Event) { sharedEventFlow.emit(event) + eventsSubject.onNext(event) + } + + fun dispatchOld(event: Event) { + eventsSubject.onNext(event) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt index f5e962d8e2..d31ff1d3c2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt @@ -173,9 +173,9 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide viewModel.setAccountInfo(intent.getStringExtra(KEY_ACCOUNT_ID)!!) val sharedPrefs = PreferenceManager.getDefaultSharedPreferences(this) - animateAvatar = sharedPrefs.getBoolean("animateGifAvatars", false) + animateAvatar = sharedPrefs.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false) animateEmojis = sharedPrefs.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) - hideFab = sharedPrefs.getBoolean("fabHide", false) + hideFab = sharedPrefs.getBoolean(PrefKeys.FAB_HIDE, false) handleWindowInsets() setupToolbar() diff --git a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListFragment.kt index 30d75402dc..e08429aad7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListFragment.kt @@ -61,7 +61,6 @@ import com.keylesspalace.tusky.view.EndlessOnScrollListener import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import kotlinx.coroutines.launch import retrofit2.Response -import java.io.IOException import javax.inject.Inject class AccountListFragment : @@ -332,7 +331,7 @@ class AccountListFragment : val linkHeader = response.headers()["Link"] onFetchAccountsSuccess(accountList, linkHeader) - } catch (exception: IOException) { + } catch (exception: Exception) { onFetchAccountsFailure(exception) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsAdapter.kt index fc860e59e3..cdce381424 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsAdapter.kt @@ -44,7 +44,6 @@ class FollowRequestsAdapter( ) return FollowRequestViewHolder( binding, - accountActionListener, linkListener, showHeader = false ) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementAdapter.kt index 8f30c5e49f..657254807c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementAdapter.kt @@ -15,6 +15,7 @@ package com.keylesspalace.tusky.components.announcements +import android.annotation.SuppressLint import android.os.Build import android.text.SpannableStringBuilder import android.view.ContextThemeWrapper @@ -55,6 +56,7 @@ class AnnouncementAdapter( return BindingHolder(binding) } + @SuppressLint("SetTextI18n") override fun onBindViewHolder(holder: BindingHolder, position: Int) { val item = items[position] diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt index a2828f3a3d..bd4b2a08cb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt @@ -94,6 +94,7 @@ 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.settings.PrefKeys.APP_THEME import com.keylesspalace.tusky.util.APP_THEME_DEFAULT import com.keylesspalace.tusky.util.MentionSpan import com.keylesspalace.tusky.util.PickMediaFiles @@ -208,7 +209,7 @@ class ComposeActivity : activeAccount = accountManager.activeAccount ?: return - val theme = preferences.getString("appTheme", APP_THEME_DEFAULT) + val theme = preferences.getString(APP_THEME, APP_THEME_DEFAULT) if (theme == "black") { setTheme(R.style.TuskyDialogActivityBlackTheme) } @@ -577,7 +578,7 @@ class ComposeActivity : a.getDimensionPixelSize(0, 1) } - val animateAvatars = preferences.getBoolean("animateGifAvatars", false) + val animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false) loadAvatar( activeAccount.profilePictureUrl, binding.composeAvatar, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt index 0919d29d04..78cf25f542 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt @@ -275,7 +275,7 @@ class ComposeViewModel @Inject constructor( val mediaUris: MutableList = mutableListOf() val mediaDescriptions: MutableList = mutableListOf() val mediaFocus: MutableList = mutableListOf() - media.value.forEach { item -> + for (item in media.value) { mediaUris.add(item.uri.toString()) mediaDescriptions.add(item.description) mediaFocus.add(item.focus) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt index 58c3cf2a05..caab8401ee 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt @@ -15,6 +15,7 @@ package com.keylesspalace.tusky.components.compose +import android.annotation.SuppressLint import android.content.ContentResolver import android.content.Context import android.media.MediaMetadataRetriever @@ -246,6 +247,7 @@ class MediaUploader @Inject constructor( private val contentResolver = context.contentResolver + @SuppressLint("Recycle") // stream is closed in ProgressRequestBody private suspend fun upload(media: QueuedMedia): Flow { return callbackFlow { var mimeType = contentResolver.getType(media.uri) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/FocusDialog.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/FocusDialog.kt index 93c99ee6f5..e6abea84e4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/FocusDialog.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/FocusDialog.kt @@ -49,12 +49,12 @@ fun T.makeFocusDialog( .load(previewUri) .downsample(DownsampleStrategy.CENTER_INSIDE) .listener(object : RequestListener { - override fun onLoadFailed(p0: GlideException?, p1: Any?, p2: Target?, p3: Boolean): Boolean { + override fun onLoadFailed(p0: GlideException?, p1: Any?, p2: Target, p3: Boolean): Boolean { return false } - override fun onResourceReady(resource: Drawable?, model: Any?, target: Target?, dataSource: DataSource?, isFirstResource: Boolean): Boolean { - val width = resource!!.intrinsicWidth + override fun onResourceReady(resource: Drawable, model: Any, target: Target?, dataSource: DataSource, isFirstResource: Boolean): Boolean { + val width = resource.intrinsicWidth val height = resource.intrinsicHeight dialogBinding.focusIndicator.setImageSize(width, height) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java index 5dc0bf982f..3c3103e0d7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java @@ -78,7 +78,7 @@ void setupWithConversation( if (payloads == null) { TimelineAccount account = status.getAccount(); - setupCollapsedState(statusViewData.isCollapsible(), statusViewData.isCollapsed(), statusViewData.isExpanded(), statusViewData.getSpoilerText(), listener); + setupCollapsedState(statusViewData.isCollapsible(), statusViewData.isCollapsed(), statusViewData.isExpanded(), status.getSpoilerText(), listener); setDisplayName(account.getDisplayName(), account.getEmojis(), statusDisplayOptions); setUsername(account.getUsername()); diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt index 27a3be579d..1df469f7a8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt @@ -99,14 +99,14 @@ class ConversationsFragment : val preferences = PreferenceManager.getDefaultSharedPreferences(view.context) val statusDisplayOptions = StatusDisplayOptions( - animateAvatars = preferences.getBoolean("animateGifAvatars", false), + animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), mediaPreviewEnabled = accountManager.activeAccount?.mediaPreviewEnabled ?: true, - useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false), - showBotOverlay = preferences.getBoolean("showBotOverlay", true), - useBlurhash = preferences.getBoolean("useBlurhash", true), + useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false), + showBotOverlay = preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true), + useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true), cardViewMode = CardViewMode.NONE, - confirmReblogs = preferences.getBoolean("confirmReblogs", true), - confirmFavourites = preferences.getBoolean("confirmFavourites", false), + confirmReblogs = preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true), + confirmFavourites = preferences.getBoolean(PrefKeys.CONFIRM_FAVOURITES, false), hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), showStatsInline = preferences.getBoolean(PrefKeys.SHOW_STATS_INLINE, false), @@ -134,6 +134,7 @@ class ConversationsFragment : if (loadState.append is LoadState.NotLoading && loadState.source.refresh is LoadState.NotLoading) { binding.statusView.show() binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null) + binding.statusView.showHelp(R.string.help_empty_conversations) } } is LoadState.Error -> { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/instancemute/InstanceListActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksActivity.kt similarity index 78% rename from app/src/main/java/com/keylesspalace/tusky/components/instancemute/InstanceListActivity.kt rename to app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksActivity.kt index 667360c53c..6181749076 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/instancemute/InstanceListActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksActivity.kt @@ -1,15 +1,14 @@ -package com.keylesspalace.tusky.components.instancemute +package com.keylesspalace.tusky.components.domainblocks import android.os.Bundle import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.components.instancemute.fragment.InstanceListFragment import com.keylesspalace.tusky.databinding.ActivityAccountListBinding import dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector import javax.inject.Inject -class InstanceListActivity : BaseActivity(), HasAndroidInjector { +class DomainBlocksActivity : BaseActivity(), HasAndroidInjector { @Inject lateinit var androidInjector: DispatchingAndroidInjector @@ -28,7 +27,7 @@ class InstanceListActivity : BaseActivity(), HasAndroidInjector { supportFragmentManager .beginTransaction() - .replace(R.id.fragment_container, InstanceListFragment()) + .replace(R.id.fragment_container, DomainBlocksFragment()) .commit() } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksAdapter.kt new file mode 100644 index 0000000000..e37aa917ca --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksAdapter.kt @@ -0,0 +1,27 @@ +package com.keylesspalace.tusky.components.domainblocks + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.paging.PagingDataAdapter +import com.keylesspalace.tusky.components.followedtags.FollowedTagsAdapter.Companion.STRING_COMPARATOR +import com.keylesspalace.tusky.databinding.ItemBlockedDomainBinding +import com.keylesspalace.tusky.util.BindingHolder + +class DomainBlocksAdapter( + private val onUnmute: (String) -> Unit +) : PagingDataAdapter>(STRING_COMPARATOR) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder { + val binding = ItemBlockedDomainBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return BindingHolder(binding) + } + + override fun onBindViewHolder(holder: BindingHolder, position: Int) { + getItem(position)?.let { instance -> + holder.binding.blockedDomain.text = instance + holder.binding.blockedDomainUnblock.setOnClickListener { + onUnmute(instance) + } + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksFragment.kt new file mode 100644 index 0000000000..896e81ead9 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksFragment.kt @@ -0,0 +1,92 @@ +package com.keylesspalace.tusky.components.domainblocks + +import android.os.Bundle +import android.util.Log +import android.view.View +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import androidx.paging.LoadState +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.snackbar.Snackbar +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.databinding.FragmentDomainBlocksBinding +import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.di.ViewModelFactory +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 kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import javax.inject.Inject + +class DomainBlocksFragment : Fragment(R.layout.fragment_domain_blocks), Injectable { + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + private val binding by viewBinding(FragmentDomainBlocksBinding::bind) + + private val viewModel: DomainBlocksViewModel by viewModels { viewModelFactory } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val adapter = DomainBlocksAdapter(viewModel::unblock) + + binding.recyclerView.setHasFixedSize(true) + binding.recyclerView.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL)) + binding.recyclerView.adapter = adapter + binding.recyclerView.layoutManager = LinearLayoutManager(view.context) + + viewLifecycleOwner.lifecycleScope.launch { + viewModel.uiEvents.collect { event -> + showSnackbar(event) + } + } + + lifecycleScope.launch { + viewModel.domainPager.collectLatest { pagingData -> + adapter.submitData(pagingData) + } + } + + adapter.addLoadStateListener { loadState -> + binding.progressBar.visible(loadState.refresh == LoadState.Loading && adapter.itemCount == 0) + + if (loadState.refresh is LoadState.Error) { + binding.recyclerView.hide() + binding.messageView.show() + val errorState = loadState.refresh as LoadState.Error + binding.messageView.setup(errorState.error) { adapter.retry() } + Log.w(TAG, "error loading blocked domains", errorState.error) + } else if (loadState.refresh is LoadState.NotLoading && adapter.itemCount == 0) { + binding.recyclerView.hide() + binding.messageView.show() + binding.messageView.setup(R.drawable.elephant_friend_empty, R.string.message_empty) + } else { + binding.recyclerView.show() + binding.messageView.hide() + } + } + } + + private fun showSnackbar(event: SnackbarEvent) { + val message = if (event.throwable == null) { + getString(event.message, event.domain) + } else { + Log.w(TAG, event.throwable) + val error = event.throwable.localizedMessage ?: getString(R.string.ui_error_unknown) + getString(event.message, event.domain, error) + } + + Snackbar.make(binding.recyclerView, message, Snackbar.LENGTH_LONG) + .setTextMaxLines(5) + .setAction(event.actionText, event.action) + .show() + } + + companion object { + private const val TAG = "DomainBlocksFragment" + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksPagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksPagingSource.kt new file mode 100644 index 0000000000..0438a268f6 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksPagingSource.kt @@ -0,0 +1,19 @@ +package com.keylesspalace.tusky.components.domainblocks + +import androidx.paging.PagingSource +import androidx.paging.PagingState + +class DomainBlocksPagingSource( + private val domains: List, + private val nextKey: String? +) : PagingSource() { + override fun getRefreshKey(state: PagingState): String? = null + + override suspend fun load(params: LoadParams): LoadResult { + return if (params is LoadParams.Refresh) { + LoadResult.Page(domains, null, nextKey) + } else { + LoadResult.Page(emptyList(), null, null) + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksRemoteMediator.kt new file mode 100644 index 0000000000..09f99044ef --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksRemoteMediator.kt @@ -0,0 +1,57 @@ +package com.keylesspalace.tusky.components.domainblocks + +import androidx.paging.ExperimentalPagingApi +import androidx.paging.LoadType +import androidx.paging.PagingState +import androidx.paging.RemoteMediator +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.HttpHeaderLink +import retrofit2.HttpException +import retrofit2.Response + +@OptIn(ExperimentalPagingApi::class) +class DomainBlocksRemoteMediator( + private val api: MastodonApi, + private val repository: DomainBlocksRepository +) : RemoteMediator() { + + override suspend fun load( + loadType: LoadType, + state: PagingState + ): MediatorResult { + return try { + val response = request(loadType) + ?: return MediatorResult.Success(endOfPaginationReached = true) + + return applyResponse(response) + } catch (e: Exception) { + MediatorResult.Error(e) + } + } + + private suspend fun request(loadType: LoadType): Response>? { + return when (loadType) { + LoadType.PREPEND -> null + LoadType.APPEND -> api.domainBlocks(maxId = repository.nextKey) + LoadType.REFRESH -> { + repository.nextKey = null + repository.domains.clear() + api.domainBlocks() + } + } + } + + private fun applyResponse(response: Response>): MediatorResult { + val tags = response.body() + if (!response.isSuccessful || tags == null) { + return MediatorResult.Error(HttpException(response)) + } + + val links = HttpHeaderLink.parse(response.headers()["Link"]) + repository.nextKey = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter("max_id") + repository.domains.addAll(tags) + repository.invalidate() + + return MediatorResult.Success(endOfPaginationReached = repository.nextKey == null) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksRepository.kt new file mode 100644 index 0000000000..bdc9b9367f --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksRepository.kt @@ -0,0 +1,69 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.components.domainblocks + +import androidx.paging.ExperimentalPagingApi +import androidx.paging.InvalidatingPagingSourceFactory +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingSource +import at.connyduck.calladapter.networkresult.NetworkResult +import at.connyduck.calladapter.networkresult.onSuccess +import com.keylesspalace.tusky.network.MastodonApi +import javax.inject.Inject + +class DomainBlocksRepository @Inject constructor( + private val api: MastodonApi +) { + val domains: MutableList = mutableListOf() + var nextKey: String? = null + + private var factory = InvalidatingPagingSourceFactory { + DomainBlocksPagingSource(domains.toList(), nextKey) + } + + @OptIn(ExperimentalPagingApi::class) + val domainPager = Pager( + config = PagingConfig(pageSize = PAGE_SIZE, initialLoadSize = PAGE_SIZE), + remoteMediator = DomainBlocksRemoteMediator(api, this), + pagingSourceFactory = factory + ).flow + + /** Invalidate the active paging source, see [PagingSource.invalidate] */ + fun invalidate() { + factory.invalidate() + } + + suspend fun block(domain: String): NetworkResult { + return api.blockDomain(domain).onSuccess { + domains.add(domain) + factory.invalidate() + } + } + + suspend fun unblock(domain: String): NetworkResult { + return api.unblockDomain(domain).onSuccess { + domains.remove(domain) + factory.invalidate() + } + } + + companion object { + private const val PAGE_SIZE = 20 + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksViewModel.kt new file mode 100644 index 0000000000..6458977f0e --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksViewModel.kt @@ -0,0 +1,72 @@ +package com.keylesspalace.tusky.components.domainblocks + +import android.view.View +import androidx.annotation.StringRes +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.cachedIn +import at.connyduck.calladapter.networkresult.fold +import at.connyduck.calladapter.networkresult.onFailure +import com.keylesspalace.tusky.R +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +class DomainBlocksViewModel @Inject constructor( + private val repo: DomainBlocksRepository +) : ViewModel() { + + val domainPager = repo.domainPager.cachedIn(viewModelScope) + + val uiEvents = MutableSharedFlow() + + fun block(domain: String) { + viewModelScope.launch { + repo.block(domain).onFailure { e -> + uiEvents.emit( + SnackbarEvent( + message = R.string.error_blocking_domain, + domain = domain, + throwable = e, + actionText = R.string.action_retry, + action = { block(domain) } + ) + ) + } + } + } + + fun unblock(domain: String) { + viewModelScope.launch { + repo.unblock(domain).fold({ + uiEvents.emit( + SnackbarEvent( + message = R.string.confirmation_domain_unmuted, + domain = domain, + throwable = null, + actionText = R.string.action_undo, + action = { block(domain) } + ) + ) + }, { e -> + uiEvents.emit( + SnackbarEvent( + message = R.string.error_unblocking_domain, + domain = domain, + throwable = e, + actionText = R.string.action_retry, + action = { unblock(domain) } + ) + ) + }) + } + } +} + +class SnackbarEvent( + @StringRes val message: Int, + val domain: String, + val throwable: Throwable?, + @StringRes val actionText: Int, + val action: (View) -> Unit +) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/instancemute/adapter/DomainMutesAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/instancemute/adapter/DomainMutesAdapter.kt deleted file mode 100644 index 13d8f2d83e..0000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/instancemute/adapter/DomainMutesAdapter.kt +++ /dev/null @@ -1,56 +0,0 @@ -package com.keylesspalace.tusky.components.instancemute.adapter - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView -import com.keylesspalace.tusky.components.instancemute.interfaces.InstanceActionListener -import com.keylesspalace.tusky.databinding.ItemMutedDomainBinding -import com.keylesspalace.tusky.util.BindingHolder - -class DomainMutesAdapter( - private val actionListener: InstanceActionListener -) : RecyclerView.Adapter>() { - - var instances: MutableList = mutableListOf() - var bottomLoading: Boolean = false - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder { - val binding = ItemMutedDomainBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return BindingHolder(binding) - } - - override fun onBindViewHolder(holder: BindingHolder, position: Int) { - val instance = instances[position] - - holder.binding.mutedDomain.text = instance - holder.binding.mutedDomainUnmute.setOnClickListener { - actionListener.mute(false, instance, holder.bindingAdapterPosition) - } - } - - override fun getItemCount(): Int { - var count = instances.size - if (bottomLoading) { - ++count - } - return count - } - - fun addItems(newInstances: List) { - val end = instances.size - instances.addAll(newInstances) - notifyItemRangeInserted(end, instances.size) - } - - fun addItem(instance: String) { - instances.add(instance) - notifyItemInserted(instances.size) - } - - fun removeItem(position: Int) { - if (position >= 0 && position < instances.size) { - instances.removeAt(position) - notifyItemRemoved(position) - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/instancemute/fragment/InstanceListFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/instancemute/fragment/InstanceListFragment.kt deleted file mode 100644 index 1da0a2b7d8..0000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/instancemute/fragment/InstanceListFragment.kt +++ /dev/null @@ -1,158 +0,0 @@ -package com.keylesspalace.tusky.components.instancemute.fragment - -import android.os.Bundle -import android.util.Log -import android.view.View -import androidx.fragment.app.Fragment -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.DividerItemDecoration -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import at.connyduck.calladapter.networkresult.fold -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 -import com.keylesspalace.tusky.components.instancemute.interfaces.InstanceActionListener -import com.keylesspalace.tusky.databinding.FragmentInstanceListBinding -import com.keylesspalace.tusky.di.Injectable -import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.util.HttpHeaderLink -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 io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import kotlinx.coroutines.launch -import javax.inject.Inject - -class InstanceListFragment : Fragment(R.layout.fragment_instance_list), Injectable, InstanceActionListener { - - @Inject - lateinit var api: MastodonApi - - private val binding by viewBinding(FragmentInstanceListBinding::bind) - - private var fetching = false - private var bottomId: String? = null - private var adapter = DomainMutesAdapter(this) - private lateinit var scrollListener: EndlessOnScrollListener - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - binding.recyclerView.setHasFixedSize(true) - binding.recyclerView.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL)) - binding.recyclerView.adapter = adapter - - val layoutManager = LinearLayoutManager(view.context) - binding.recyclerView.layoutManager = layoutManager - - scrollListener = object : EndlessOnScrollListener(layoutManager) { - override fun onLoadMore(totalItemsCount: Int, view: RecyclerView) { - if (bottomId != null) { - fetchInstances(bottomId) - } - } - } - - binding.recyclerView.addOnScrollListener(scrollListener) - fetchInstances() - } - - override fun mute(mute: Boolean, instance: String, position: Int) { - viewLifecycleOwner.lifecycleScope.launch { - if (mute) { - api.blockDomain(instance).fold({ - adapter.addItem(instance) - }, { e -> - Log.e(TAG, "Error muting domain $instance", e) - }) - } else { - api.unblockDomain(instance).fold({ - 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() - }, { e -> - Log.e(TAG, "Error unmuting domain $instance", e) - }) - } - } - } - - private fun fetchInstances(id: String? = null) { - if (fetching) { - return - } - fetching = true - binding.instanceProgressBar.show() - - if (id != null) { - binding.recyclerView.post { adapter.bottomLoading = true } - } - - api.domainBlocks(id, bottomId) - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(from(this, Lifecycle.Event.ON_DESTROY)) - .subscribe( - { response -> - val instances = response.body() - - if (response.isSuccessful && instances != null) { - onFetchInstancesSuccess(instances, response.headers()["Link"]) - } else { - onFetchInstancesFailure(Exception(response.message())) - } - }, - { throwable -> - onFetchInstancesFailure(throwable) - } - ) - } - - private fun onFetchInstancesSuccess(instances: List, linkHeader: String?) { - adapter.bottomLoading = false - binding.instanceProgressBar.hide() - - val links = HttpHeaderLink.parse(linkHeader) - val next = HttpHeaderLink.findByRelationType(links, "next") - val fromId = next?.uri?.getQueryParameter("max_id") - adapter.addItems(instances) - bottomId = fromId - fetching = false - - if (adapter.itemCount == 0) { - binding.messageView.show() - binding.messageView.setup( - R.drawable.elephant_friend_empty, - R.string.message_empty, - null - ) - } else { - binding.messageView.hide() - } - } - - private fun onFetchInstancesFailure(throwable: Throwable) { - fetching = false - binding.instanceProgressBar.hide() - Log.e(TAG, "Fetch failure", throwable) - - if (adapter.itemCount == 0) { - binding.messageView.show() - binding.messageView.setup(throwable) { - binding.messageView.hide() - this.fetchInstances(null) - } - } - } - - companion object { - private const val TAG = "InstanceList" // logging tag - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/instancemute/interfaces/InstanceActionListener.kt b/app/src/main/java/com/keylesspalace/tusky/components/instancemute/interfaces/InstanceActionListener.kt deleted file mode 100644 index 9b88ad9665..0000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/instancemute/interfaces/InstanceActionListener.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.keylesspalace.tusky.components.instancemute.interfaces - -interface InstanceActionListener { - fun mute(mute: Boolean, instance: String, position: Int) -} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/FollowViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/FollowViewHolder.kt deleted file mode 100644 index 70f564c0c1..0000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/FollowViewHolder.kt +++ /dev/null @@ -1,111 +0,0 @@ -/* - * Copyright 2023 Tusky Contributors - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . - */ - -package com.keylesspalace.tusky.components.notifications - -import androidx.recyclerview.widget.RecyclerView -import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.databinding.ItemFollowBinding -import com.keylesspalace.tusky.entity.Notification -import com.keylesspalace.tusky.entity.TimelineAccount -import com.keylesspalace.tusky.interfaces.LinkListener -import com.keylesspalace.tusky.util.StatusDisplayOptions -import com.keylesspalace.tusky.util.emojify -import com.keylesspalace.tusky.util.loadAvatar -import com.keylesspalace.tusky.util.parseAsMastodonHtml -import com.keylesspalace.tusky.util.setClickableText -import com.keylesspalace.tusky.util.unicodeWrap -import com.keylesspalace.tusky.viewdata.NotificationViewData - -class FollowViewHolder( - private val binding: ItemFollowBinding, - private val notificationActionListener: NotificationActionListener, - private val linkListener: LinkListener -) : NotificationsPagingAdapter.ViewHolder, RecyclerView.ViewHolder(binding.root) { - private val avatarRadius42dp = itemView.context.resources.getDimensionPixelSize( - R.dimen.avatar_radius_42dp - ) - - override fun bind( - viewData: NotificationViewData, - payloads: List<*>?, - statusDisplayOptions: StatusDisplayOptions - ) { - // Skip updates with payloads. That indicates a timestamp update, and - // this view does not have timestamps. - if (!payloads.isNullOrEmpty()) return - - setMessage( - viewData.account, - viewData.type === Notification.Type.SIGN_UP, - statusDisplayOptions.animateAvatars, - statusDisplayOptions.animateEmojis - ) - setupButtons(notificationActionListener, viewData.account.id) - } - - private fun setMessage( - account: TimelineAccount, - isSignUp: Boolean, - animateAvatars: Boolean, - animateEmojis: Boolean - ) { - val context = binding.notificationText.context - val format = - context.getString( - if (isSignUp) { - R.string.notification_sign_up_format - } else { - R.string.notification_follow_format - } - ) - val wrappedDisplayName = account.name.unicodeWrap() - val wholeMessage = String.format(format, wrappedDisplayName) - val emojifiedMessage = - wholeMessage.emojify( - account.emojis, - binding.notificationText, - animateEmojis - ) - binding.notificationText.text = emojifiedMessage - val username = context.getString(R.string.post_username_format, account.username) - binding.notificationUsername.text = username - val emojifiedDisplayName = wrappedDisplayName.emojify( - account.emojis, - binding.notificationUsername, - animateEmojis - ) - binding.notificationDisplayName.text = emojifiedDisplayName - loadAvatar( - account.avatar, - binding.notificationAvatar, - avatarRadius42dp, - animateAvatars - ) - - val emojifiedNote = account.note.parseAsMastodonHtml().emojify( - account.emojis, - binding.notificationAccountNote, - animateEmojis - ) - setClickableText(binding.notificationAccountNote, emojifiedNote, emptyList(), null, linkListener) - } - - private fun setupButtons(listener: NotificationActionListener, accountId: String) { - binding.root.setOnClickListener { listener.onViewAccount(accountId) } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationFetcher.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationFetcher.kt index 633ca08f7e..5083ea9b14 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationFetcher.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationFetcher.kt @@ -10,12 +10,30 @@ import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Marker import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.HttpHeaderLink import com.keylesspalace.tusky.util.isLessThan import kotlinx.coroutines.delay import javax.inject.Inject import kotlin.math.min import kotlin.time.Duration.Companion.milliseconds +/** Models next/prev links from the "Links" header in an API response */ +data class Links(val next: String?, val prev: String?) { + companion object { + fun from(linkHeader: String?): Links { + val links = HttpHeaderLink.parse(linkHeader) + return Links( + next = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter( + "max_id" + ), + prev = HttpHeaderLink.findByRelationType(links, "prev")?.uri?.getQueryParameter( + "min_id" + ) + ) + } + } +} + /** * Fetch Mastodon notifications and show Android notifications, with summaries, for them. * diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java index a0c5a8ed54..15a39780fb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java @@ -149,7 +149,7 @@ public class NotificationHelper { * @return the new notification */ @NonNull - public static android.app.Notification make(final Context context, NotificationManager notificationManager, Notification body, AccountEntity account, boolean isFirstOfBatch) { + public static android.app.Notification make(final @NonNull Context context, @NonNull NotificationManager notificationManager, @NonNull Notification body, @NonNull AccountEntity account, boolean isFirstOfBatch) { body = body.rewriteToStatusTypeIfNeeded(account.getAccountId()); String mastodonNotificationId = body.getId(); int accountId = (int) account.getId(); @@ -201,8 +201,7 @@ public static android.app.Notification make(final Context context, NotificationM builder.setLargeIcon(accountAvatar); // Reply to mention action; RemoteInput is available from KitKat Watch, but buttons are available from Nougat - if (body.getType() == Notification.Type.MENTION - && android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + if (body.getType() == Notification.Type.MENTION) { RemoteInput replyRemoteInput = new RemoteInput.Builder(KEY_REPLY) .setLabel(context.getString(R.string.label_quick_reply)) .build(); @@ -238,7 +237,7 @@ public static android.app.Notification make(final Context context, NotificationM Bundle extras = new Bundle(); // Add the sending account's name, so it can be used when summarising this notification extras.putString(EXTRA_ACCOUNT_NAME, body.getAccount().getName()); - extras.putString(EXTRA_NOTIFICATION_TYPE, body.getType().toString()); + extras.putSerializable(EXTRA_NOTIFICATION_TYPE, body.getType()); builder.addExtras(extras); // Only alert for the first notification of a batch to avoid multiple alerts at once @@ -271,7 +270,7 @@ public static android.app.Notification make(final Context context, NotificationM * @param notificationManager the system's NotificationManager * @param account the account for which the notification should be shown */ - public static void updateSummaryNotifications(Context context, NotificationManager notificationManager, AccountEntity account) { + public static void updateSummaryNotifications(@NonNull Context context, @NonNull NotificationManager notificationManager, @NonNull AccountEntity account) { // Map from the channel ID to a list of notifications in that channel. Those are the // notifications that will be summarised. Map> channelGroups = new HashMap<>(); @@ -609,7 +608,7 @@ public static boolean areNotificationsEnabled(@NonNull Context context, @NonNull } - public static void enablePullNotifications(Context context) { + public static void enablePullNotifications(@NonNull Context context) { WorkManager workManager = WorkManager.getInstance(context); workManager.cancelAllWorkByTag(NOTIFICATION_PULL_TAG); @@ -637,7 +636,7 @@ public static void enablePullNotifications(Context context) { Log.d(TAG, "enabled notification checks with "+ PeriodicWorkRequest.MIN_PERIODIC_INTERVAL_MILLIS + "ms interval"); } - public static void disablePullNotifications(Context context) { + public static void disablePullNotifications(@NonNull Context context) { WorkManager.getInstance(context).cancelAllWorkByTag(NOTIFICATION_PULL_TAG); Log.d(TAG, "disabled notification checks"); } @@ -653,7 +652,7 @@ public static void clearNotificationsForAccount(@NonNull Context context, @NonNu } } - public static boolean filterNotification(NotificationManager notificationManager, AccountEntity account, @NonNull Notification notification) { + public static boolean filterNotification(@NonNull NotificationManager notificationManager, @NonNull AccountEntity account, @NonNull Notification notification) { return filterNotification(notificationManager, account, notification.getType()); } @@ -859,7 +858,7 @@ public static int pendingIntentFlags(boolean mutable) { if (mutable) { return PendingIntent.FLAG_UPDATE_CURRENT | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ? PendingIntent.FLAG_MUTABLE : 0); } else { - return PendingIntent.FLAG_UPDATE_CURRENT | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0); + return PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE; } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt deleted file mode 100644 index 0a14f34c08..0000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt +++ /dev/null @@ -1,695 +0,0 @@ -/* - * Copyright 2023 Tusky Contributors - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . - */ - -package com.keylesspalace.tusky.components.notifications - -import android.app.Dialog -import android.content.DialogInterface -import android.os.Bundle -import android.util.Log -import android.view.LayoutInflater -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem -import android.view.View -import android.view.ViewGroup -import androidx.appcompat.app.AlertDialog -import androidx.core.view.MenuProvider -import androidx.core.view.isVisible -import androidx.fragment.app.DialogFragment -import androidx.fragment.app.viewModels -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import androidx.paging.LoadState -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.DividerItemDecoration -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import androidx.recyclerview.widget.RecyclerView.NO_POSITION -import androidx.recyclerview.widget.RecyclerView.SCROLL_STATE_IDLE -import androidx.recyclerview.widget.SimpleItemAnimator -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener -import at.connyduck.sparkbutton.helpers.Utils -import com.google.android.material.color.MaterialColors -import com.google.android.material.snackbar.Snackbar -import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.adapter.StatusBaseViewHolder -import com.keylesspalace.tusky.databinding.FragmentTimelineNotificationsBinding -import com.keylesspalace.tusky.di.Injectable -import com.keylesspalace.tusky.di.ViewModelFactory -import com.keylesspalace.tusky.entity.Notification -import com.keylesspalace.tusky.entity.Status -import com.keylesspalace.tusky.fragment.SFragment -import com.keylesspalace.tusky.interfaces.AccountActionListener -import com.keylesspalace.tusky.interfaces.ActionButtonActivity -import com.keylesspalace.tusky.interfaces.ReselectableFragment -import com.keylesspalace.tusky.interfaces.StatusActionListener -import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate -import com.keylesspalace.tusky.util.openLink -import com.keylesspalace.tusky.util.viewBinding -import com.keylesspalace.tusky.viewdata.AttachmentViewData.Companion.list -import com.keylesspalace.tusky.viewdata.NotificationViewData -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 kotlinx.coroutines.delay -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.distinctUntilChangedBy -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch -import java.io.IOException -import javax.inject.Inject - -class NotificationsFragment : - SFragment(), - StatusActionListener, - NotificationActionListener, - AccountActionListener, - OnRefreshListener, - MenuProvider, - Injectable, - ReselectableFragment { - - @Inject - lateinit var viewModelFactory: ViewModelFactory - - private val viewModel: NotificationsViewModel by viewModels { viewModelFactory } - - private val binding by viewBinding(FragmentTimelineNotificationsBinding::bind) - - private lateinit var adapter: NotificationsPagingAdapter - - private lateinit var layoutManager: LinearLayoutManager - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - adapter = NotificationsPagingAdapter( - notificationDiffCallback, - accountId = viewModel.account.accountId, - statusActionListener = this, - notificationActionListener = this, - accountActionListener = this, - statusDisplayOptions = viewModel.statusDisplayOptions.value - ) - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - return inflater.inflate(R.layout.fragment_timeline_notifications, container, false) - } - - private fun confirmClearNotifications() { - AlertDialog.Builder(requireContext()) - .setMessage(R.string.notification_clear_text) - .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> clearNotifications() } - .setNegativeButton(android.R.string.cancel, null) - .show() - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) - - // Setup the SwipeRefreshLayout. - binding.swipeRefreshLayout.setOnRefreshListener(this) - binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue) - - // Setup the RecyclerView. - binding.recyclerView.setHasFixedSize(true) - layoutManager = LinearLayoutManager(context) - binding.recyclerView.layoutManager = layoutManager - binding.recyclerView.setAccessibilityDelegateCompat( - ListStatusAccessibilityDelegate( - binding.recyclerView, - this - ) { pos: Int -> - val notification = adapter.snapshot().getOrNull(pos) - // We support replies only for now - if (notification is NotificationViewData) { - notification.statusViewData - } else { - null - } - } - ) - binding.recyclerView.addItemDecoration( - DividerItemDecoration( - context, - DividerItemDecoration.VERTICAL - ) - ) - - binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { - val actionButton = (activity as ActionButtonActivity).actionButton - - override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) { - actionButton?.let { fab -> - if (!viewModel.uiState.value.showFabWhileScrolling) { - if (dy > 0 && fab.isShown) { - fab.hide() // Hide when scrolling down - } else if (dy < 0 && !fab.isShown) { - fab.show() // Show when scrolling up - } - } else if (!fab.isShown) { - fab.show() - } - } - } - - @Suppress("SyntheticAccessor") - override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { - newState != SCROLL_STATE_IDLE && return - - // Save the ID of the first notification visible in the list, so the user's - // reading position is always restorable. - layoutManager.findFirstVisibleItemPosition().takeIf { it != NO_POSITION }?.let { position -> - adapter.snapshot().getOrNull(position)?.id?.let { id -> - viewModel.accept(InfallibleUiAction.SaveVisibleId(visibleId = id)) - } - } - } - }) - - binding.recyclerView.adapter = adapter.withLoadStateHeaderAndFooter( - header = NotificationsLoadStateAdapter { adapter.retry() }, - footer = NotificationsLoadStateAdapter { adapter.retry() } - ) - - (binding.recyclerView.itemAnimator as SimpleItemAnimator?)!!.supportsChangeAnimations = - false - - // Signal the user that a refresh has loaded new items above their current position - // by scrolling up slightly to disclose the new content - adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { - override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { - if (positionStart == 0 && adapter.itemCount != itemCount) { - binding.recyclerView.post { - if (getView() != null) { - binding.recyclerView.scrollBy(0, Utils.dpToPx(requireContext(), -30)) - } - } - } - } - }) - - // update post timestamps - val updateTimestampFlow = flow { - while (true) { - delay(60000) - emit(Unit) - } - }.onEach { - adapter.notifyItemRangeChanged(0, adapter.itemCount, listOf(StatusBaseViewHolder.Key.KEY_CREATED)) - } - - viewLifecycleOwner.lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - launch { - viewModel.pagingData.collectLatest { pagingData -> - Log.d(TAG, "Submitting data to adapter") - adapter.submitData(pagingData) - } - } - - // Show errors from the view model as snack bars. - // - // Errors are shown: - // - Indefinitely, so the user has a chance to read and understand - // the message - // - With a max of 5 text lines, to allow space for longer errors. - // E.g., on a typical device, an error message like "Bookmarking - // post failed: Unable to resolve host 'mastodon.social': No - // address associated with hostname" is 3 lines. - // - With a "Retry" option if the error included a UiAction to retry. - launch { - viewModel.uiError.collect { error -> - Log.d(TAG, error.toString()) - val message = getString( - error.message, - error.throwable.localizedMessage - ?: getString(R.string.ui_error_unknown) - ) - val snackbar = Snackbar.make( - // Without this the FAB will not move out of the way - (activity as ActionButtonActivity).actionButton ?: binding.root, - message, - Snackbar.LENGTH_INDEFINITE - ).setTextMaxLines(5) - error.action?.let { action -> - snackbar.setAction(R.string.action_retry) { - viewModel.accept(action) - } - } - snackbar.show() - - // The status view has pre-emptively updated its state to show - // that the action succeeded. Since it hasn't, re-bind the view - // to show the correct data. - error.action?.let { action -> - action is StatusAction || return@let - - val position = adapter.snapshot().indexOfFirst { - it?.statusViewData?.status?.id == (action as StatusAction).statusViewData.id - } - if (position != NO_POSITION) { - adapter.notifyItemChanged(position) - } - } - } - } - - // Show successful notification action as brief snackbars, so the - // user is clear the action has happened. - launch { - viewModel.uiSuccess - .filterIsInstance() - .collect { - Snackbar.make( - (activity as ActionButtonActivity).actionButton ?: binding.root, - getString(it.msg), - Snackbar.LENGTH_SHORT - ).show() - - when (it) { - // The follow request is no longer valid, refresh the adapter to - // remove it. - is NotificationActionSuccess.AcceptFollowRequest, - is NotificationActionSuccess.RejectFollowRequest -> adapter.refresh() - } - } - } - - // Update adapter data when status actions are successful, and re-bind to update - // the UI. - launch { - viewModel.uiSuccess - .filterIsInstance() - .collect { - val indexedViewData = adapter.snapshot() - .withIndex() - .firstOrNull { notificationViewData -> - notificationViewData.value?.statusViewData?.status?.id == - it.action.statusViewData.id - } ?: return@collect - - val statusViewData = - indexedViewData.value?.statusViewData ?: return@collect - - val status = when (it) { - is StatusActionSuccess.Bookmark -> - statusViewData.status.copy(bookmarked = it.action.state) - is StatusActionSuccess.Favourite -> - statusViewData.status.copy(favourited = it.action.state) - is StatusActionSuccess.Reblog -> - statusViewData.status.copy(reblogged = it.action.state) - is StatusActionSuccess.VoteInPoll -> - statusViewData.status.copy( - poll = it.action.poll.votedCopy(it.action.choices) - ) - } - indexedViewData.value?.statusViewData = statusViewData.copy( - status = status - ) - - adapter.notifyItemChanged(indexedViewData.index) - } - } - - // Refresh adapter on mutes and blocks - launch { - viewModel.uiSuccess.collectLatest { - when (it) { - is UiSuccess.Block, is UiSuccess.Mute, is UiSuccess.MuteConversation -> - adapter.refresh() - else -> { /* nothing to do */ - } - } - } - } - - // Collect the uiState. Nothing is done with it, but if you don't collect it then - // accessing viewModel.uiState.value (e.g., when the filter dialog is created) - // returns an empty object. - launch { viewModel.uiState.collect() } - - // Update status display from statusDisplayOptions. If the new options request - // relative time display collect the flow to periodically update the timestamp in the list gui elements. - launch { - viewModel.statusDisplayOptions - .collectLatest { - // NOTE this this also triggered (emitted?) on resume. - - adapter.statusDisplayOptions = it - adapter.notifyItemRangeChanged(0, adapter.itemCount, null) - - if (!it.useAbsoluteTime) { - updateTimestampFlow.collect() - } - } - } - - // Update the UI from the loadState - adapter.loadStateFlow - .distinctUntilChangedBy { it.refresh } - .collect { loadState -> - binding.recyclerView.isVisible = true - binding.progressBar.isVisible = loadState.refresh is LoadState.Loading && - !binding.swipeRefreshLayout.isRefreshing - binding.swipeRefreshLayout.isRefreshing = - loadState.refresh is LoadState.Loading && !binding.progressBar.isVisible - - binding.statusView.isVisible = false - if (loadState.refresh is LoadState.NotLoading) { - if (adapter.itemCount == 0) { - binding.statusView.setup( - R.drawable.elephant_friend_empty, - R.string.message_empty - ) - binding.recyclerView.isVisible = false - binding.statusView.isVisible = true - } else { - binding.statusView.isVisible = false - } - } - - if (loadState.refresh is LoadState.Error) { - when ((loadState.refresh as LoadState.Error).error) { - is IOException -> { - binding.statusView.setup( - R.drawable.errorphant_offline, - R.string.error_network - ) { adapter.retry() } - } - else -> { - binding.statusView.setup( - R.drawable.errorphant_error, - R.string.error_generic - ) { adapter.retry() } - } - } - binding.recyclerView.isVisible = false - binding.statusView.isVisible = true - } - } - } - } - } - - override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { - menuInflater.inflate(R.menu.fragment_notifications, menu) - val iconColor = MaterialColors.getColor(binding.root, android.R.attr.textColorPrimary) - menu.findItem(R.id.action_refresh)?.apply { - icon = IconicsDrawable(requireContext(), GoogleMaterial.Icon.gmd_refresh).apply { - sizeDp = 20 - colorInt = iconColor - } - } - menu.findItem(R.id.action_edit_notification_filter)?.apply { - icon = IconicsDrawable(requireContext(), GoogleMaterial.Icon.gmd_tune).apply { - sizeDp = 20 - colorInt = iconColor - } - } - } - - override fun onMenuItemSelected(menuItem: MenuItem): Boolean { - return when (menuItem.itemId) { - R.id.action_refresh -> { - binding.swipeRefreshLayout.isRefreshing = true - onRefresh() - true - } - R.id.load_newest -> { - viewModel.accept(InfallibleUiAction.LoadNewest) - true - } - R.id.action_edit_notification_filter -> { - showFilterDialog() - true - } - R.id.action_clear_notifications -> { - confirmClearNotifications() - true - } - else -> false - } - } - - override fun onRefresh() { - binding.progressBar.isVisible = false - adapter.refresh() - NotificationHelper.clearNotificationsForAccount(requireContext(), viewModel.account) - } - - override fun onPause() { - super.onPause() - - // Save the ID of the first notification visible in the list - val position = layoutManager.findFirstVisibleItemPosition() - if (position >= 0) { - adapter.snapshot().getOrNull(position)?.id?.let { id -> - viewModel.accept(InfallibleUiAction.SaveVisibleId(visibleId = id)) - } - } - } - - override fun onResume() { - super.onResume() - NotificationHelper.clearNotificationsForAccount(requireContext(), viewModel.account) - } - - override fun onReply(position: Int) { - val status = adapter.peek(position)?.statusViewData?.status ?: return - super.reply(status) - } - - override fun onReblog(reblog: Boolean, position: Int) { - val statusViewData = adapter.peek(position)?.statusViewData ?: return - viewModel.accept(StatusAction.Reblog(reblog, statusViewData)) - } - - override fun onFavourite(favourite: Boolean, position: Int) { - val statusViewData = adapter.peek(position)?.statusViewData ?: return - viewModel.accept(StatusAction.Favourite(favourite, statusViewData)) - } - - override fun onBookmark(bookmark: Boolean, position: Int) { - val statusViewData = adapter.peek(position)?.statusViewData ?: return - viewModel.accept(StatusAction.Bookmark(bookmark, statusViewData)) - } - - override fun onTranslate(translate: Boolean, position: Int) { - return // TODO - } - - override fun onVoteInPoll(position: Int, choices: List) { - val statusViewData = adapter.peek(position)?.statusViewData ?: return - val poll = statusViewData.status.poll ?: return - viewModel.accept(StatusAction.VoteInPoll(poll, choices, statusViewData)) - } - - override fun onMore(view: View, position: Int) { - val status = adapter.peek(position)?.statusViewData?.status ?: return - super.more(status, view, position) - } - - override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { - val status = adapter.peek(position)?.statusViewData?.status ?: return - super.viewMedia( - attachmentIndex, - list(status, viewModel.statusDisplayOptions.value.showSensitiveMedia), - view - ) - } - - override fun onViewThread(position: Int) { - val status = adapter.peek(position)?.statusViewData?.status ?: return - super.viewThread(status.actionableId, status.actionableStatus.url) - } - - override fun onOpenReblog(position: Int) { - val account = adapter.peek(position)?.account!! - onViewAccount(account.id) - } - - override fun onExpandedChange(expanded: Boolean, position: Int) { - val notificationViewData = adapter.snapshot()[position] ?: return - notificationViewData.statusViewData = notificationViewData.statusViewData?.copy( - isExpanded = expanded - ) - adapter.notifyItemChanged(position) - } - - override fun onContentHiddenChange(isShowing: Boolean, position: Int) { - val notificationViewData = adapter.snapshot()[position] ?: return - notificationViewData.statusViewData = notificationViewData.statusViewData?.copy( - isShowingContent = isShowing - ) - adapter.notifyItemChanged(position) - } - - override fun onLoadMore(position: Int) { - // Empty -- this fragment doesn't show placeholders - } - - override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { - val notificationViewData = adapter.snapshot()[position] ?: return - notificationViewData.statusViewData = notificationViewData.statusViewData?.copy( - isCollapsed = isCollapsed - ) - adapter.notifyItemChanged(position) - } - - override fun onNotificationContentCollapsedChange(isCollapsed: Boolean, position: Int) { - onContentCollapsedChange(isCollapsed, position) - } - - override fun clearWarningAction(position: Int) { - } - - private fun clearNotifications() { - binding.swipeRefreshLayout.isRefreshing = false - binding.progressBar.isVisible = false - viewModel.accept(FallibleUiAction.ClearNotifications) - } - - private fun showFilterDialog() { - FilterDialogFragment(viewModel.uiState.value.activeFilter) { filter -> - if (viewModel.uiState.value.activeFilter != filter) { - viewModel.accept(InfallibleUiAction.ApplyFilter(filter)) - } - } - .show(parentFragmentManager, "dialogFilter") - } - - override fun onViewTag(tag: String) { - super.viewTag(tag) - } - - override fun onViewAccount(id: String) { - super.viewAccount(id) - } - - override fun onMute(mute: Boolean, id: String, position: Int, notifications: Boolean) { - adapter.refresh() - } - - override fun onBlock(block: Boolean, id: String, position: Int) { - adapter.refresh() - } - - override fun onRespondToFollowRequest(accept: Boolean, accountId: String, position: Int) { - if (accept) { - viewModel.accept(NotificationAction.AcceptFollowRequest(accountId)) - } else { - viewModel.accept(NotificationAction.RejectFollowRequest(accountId)) - } - } - - override fun onViewThreadForStatus(status: Status) { - super.viewThread(status.actionableId, status.actionableStatus.url) - } - - override fun onViewReport(reportId: String) { - requireContext().openLink( - "https://${viewModel.account.domain}/admin/reports/$reportId" - ) - } - - public override fun removeItem(position: Int) { - // Empty -- this fragment doesn't remove items - } - - override fun onReselect() { - if (isAdded) { - layoutManager.scrollToPosition(0) - } - } - - companion object { - private const val TAG = "NotificationsFragment" - fun newInstance() = NotificationsFragment() - - private val notificationDiffCallback: DiffUtil.ItemCallback = - object : DiffUtil.ItemCallback() { - override fun areItemsTheSame( - oldItem: NotificationViewData, - newItem: NotificationViewData - ): Boolean { - return oldItem.id == newItem.id - } - - override fun areContentsTheSame( - oldItem: NotificationViewData, - newItem: NotificationViewData - ): Boolean { - return false - } - - override fun getChangePayload( - oldItem: NotificationViewData, - newItem: NotificationViewData - ): Any? { - return if (oldItem == newItem) { - // If items are equal - update timestamp only - listOf(StatusBaseViewHolder.Key.KEY_CREATED) - } else { - // If items are different - update a whole view holder - null - } - } - } - } -} - -class FilterDialogFragment( - private val activeFilter: Set, - private val listener: ((filter: Set) -> Unit) -) : DialogFragment() { - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val context = requireContext() - - val items = Notification.Type.visibleTypes.map { getString(it.uiString) }.toTypedArray() - val checkedItems = Notification.Type.visibleTypes.map { - !activeFilter.contains(it) - }.toBooleanArray() - - val builder = AlertDialog.Builder(context) - .setTitle(R.string.notifications_apply_filter) - .setMultiChoiceItems(items, checkedItems) { _, which, isChecked -> - checkedItems[which] = isChecked - } - .setPositiveButton(android.R.string.ok) { _, _ -> - val excludes: MutableSet = HashSet() - for (i in Notification.Type.visibleTypes.indices) { - if (!checkedItems[i]) excludes.add(Notification.Type.visibleTypes[i]) - } - listener(excludes) - } - .setNegativeButton(android.R.string.cancel) { _, _ -> } - return builder.create() - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateAdapter.kt deleted file mode 100644 index 0a281ccd9c..0000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateAdapter.kt +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright 2023 Tusky Contributors - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . - */ - -package com.keylesspalace.tusky.components.notifications - -import android.view.ViewGroup -import androidx.paging.LoadState -import androidx.paging.LoadStateAdapter - -/** Show load state and retry options when loading notifications */ -class NotificationsLoadStateAdapter( - private val retry: () -> Unit -) : LoadStateAdapter() { - override fun onCreateViewHolder( - parent: ViewGroup, - loadState: LoadState - ): NotificationsLoadStateViewHolder { - return NotificationsLoadStateViewHolder.create(parent, retry) - } - - override fun onBindViewHolder(holder: NotificationsLoadStateViewHolder, loadState: LoadState) { - holder.bind(loadState) - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateViewHolder.kt deleted file mode 100644 index f3c006d329..0000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateViewHolder.kt +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright 2023 Tusky Contributors - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . - */ - -package com.keylesspalace.tusky.components.notifications - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.core.view.isVisible -import androidx.paging.LoadState -import androidx.recyclerview.widget.RecyclerView -import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.databinding.ItemNotificationsLoadStateFooterViewBinding -import java.net.SocketTimeoutException - -/** - * Display the header/footer loading state to the user. - * - * Either: - * - * 1. A page is being loaded, display a progress view, or - * 2. An error occurred, display an error message with a "retry" button - * - * @param retry function to invoke if the user clicks the "retry" button - */ -class NotificationsLoadStateViewHolder( - private val binding: ItemNotificationsLoadStateFooterViewBinding, - retry: () -> Unit -) : RecyclerView.ViewHolder(binding.root) { - init { - binding.retryButton.setOnClickListener { retry.invoke() } - } - - fun bind(loadState: LoadState) { - if (loadState is LoadState.Error) { - val ctx = binding.root.context - binding.errorMsg.text = when (loadState.error) { - is SocketTimeoutException -> ctx.getString(R.string.socket_timeout_exception) - // Other exceptions to consider: - // - UnknownHostException, default text is: - // Unable to resolve "%s": No address associated with hostname - else -> loadState.error.localizedMessage - } - } - binding.progressBar.isVisible = loadState is LoadState.Loading - binding.retryButton.isVisible = loadState is LoadState.Error - binding.errorMsg.isVisible = loadState is LoadState.Error - } - - companion object { - fun create(parent: ViewGroup, retry: () -> Unit): NotificationsLoadStateViewHolder { - val binding = ItemNotificationsLoadStateFooterViewBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) - return NotificationsLoadStateViewHolder(binding, retry) - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingAdapter.kt deleted file mode 100644 index 8f45a56f22..0000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingAdapter.kt +++ /dev/null @@ -1,207 +0,0 @@ -/* - * Copyright 2023 Tusky Contributors - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . - */ - -package com.keylesspalace.tusky.components.notifications - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.paging.PagingDataAdapter -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView -import com.keylesspalace.tusky.adapter.FollowRequestViewHolder -import com.keylesspalace.tusky.adapter.ReportNotificationViewHolder -import com.keylesspalace.tusky.databinding.ItemFollowBinding -import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding -import com.keylesspalace.tusky.databinding.ItemReportNotificationBinding -import com.keylesspalace.tusky.databinding.ItemStatusBinding -import com.keylesspalace.tusky.databinding.ItemStatusNotificationBinding -import com.keylesspalace.tusky.databinding.SimpleListItem1Binding -import com.keylesspalace.tusky.entity.Notification -import com.keylesspalace.tusky.entity.Status -import com.keylesspalace.tusky.interfaces.AccountActionListener -import com.keylesspalace.tusky.interfaces.StatusActionListener -import com.keylesspalace.tusky.util.AbsoluteTimeFormatter -import com.keylesspalace.tusky.util.StatusDisplayOptions -import com.keylesspalace.tusky.viewdata.NotificationViewData - -/** How to present the notification in the UI */ -enum class NotificationViewKind { - /** View as the original status */ - STATUS, - - /** View as the original status, with the interaction type above */ - NOTIFICATION, - FOLLOW, - FOLLOW_REQUEST, - REPORT, - UNKNOWN; - - companion object { - fun from(kind: Notification.Type?): NotificationViewKind { - return when (kind) { - Notification.Type.MENTION, - Notification.Type.POLL, - Notification.Type.UNKNOWN -> STATUS - Notification.Type.FAVOURITE, - Notification.Type.REBLOG, - Notification.Type.STATUS, - Notification.Type.UPDATE -> NOTIFICATION - Notification.Type.FOLLOW, - Notification.Type.SIGN_UP -> FOLLOW - Notification.Type.FOLLOW_REQUEST -> FOLLOW_REQUEST - Notification.Type.REPORT -> REPORT - null -> UNKNOWN - } - } - } -} - -interface NotificationActionListener { - fun onViewAccount(id: String) - fun onViewThreadForStatus(status: Status) - fun onViewReport(reportId: String) - - /** - * Called when the status has a content warning and the visibility of the content behind - * the warning is being changed. - * - * @param expanded the desired state of the content behind the content warning - * @param position the adapter position of the view - * - */ - fun onExpandedChange(expanded: Boolean, position: Int) - - /** - * Called when the status [android.widget.ToggleButton] responsible for collapsing long - * status content is interacted with. - * - * @param isCollapsed Whether the status content is shown in a collapsed state or fully. - * @param position The position of the status in the list. - */ - fun onNotificationContentCollapsedChange(isCollapsed: Boolean, position: Int) -} - -class NotificationsPagingAdapter( - diffCallback: DiffUtil.ItemCallback, - /** ID of the the account that notifications are being displayed for */ - private val accountId: String, - private val statusActionListener: StatusActionListener, - private val notificationActionListener: NotificationActionListener, - private val accountActionListener: AccountActionListener, - var statusDisplayOptions: StatusDisplayOptions -) : PagingDataAdapter(diffCallback) { - - private val absoluteTimeFormatter = AbsoluteTimeFormatter() - - /** View holders in this adapter must implement this interface */ - interface ViewHolder { - /** Bind the data from the notification and payloads to the view */ - fun bind( - viewData: NotificationViewData, - payloads: List<*>?, - statusDisplayOptions: StatusDisplayOptions - ) - } - - override fun getItemViewType(position: Int): Int { - return NotificationViewKind.from(getItem(position)?.type).ordinal - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - val inflater = LayoutInflater.from(parent.context) - - return when (NotificationViewKind.entries[viewType]) { - NotificationViewKind.STATUS -> { - StatusViewHolder( - ItemStatusBinding.inflate(inflater, parent, false), - statusActionListener, - accountId - ) - } - NotificationViewKind.NOTIFICATION -> { - StatusNotificationViewHolder( - ItemStatusNotificationBinding.inflate(inflater, parent, false), - statusActionListener, - notificationActionListener, - absoluteTimeFormatter - ) - } - NotificationViewKind.FOLLOW -> { - FollowViewHolder( - ItemFollowBinding.inflate(inflater, parent, false), - notificationActionListener, - statusActionListener - ) - } - NotificationViewKind.FOLLOW_REQUEST -> { - FollowRequestViewHolder( - ItemFollowRequestBinding.inflate(inflater, parent, false), - accountActionListener, - statusActionListener, - showHeader = true - ) - } - NotificationViewKind.REPORT -> { - ReportNotificationViewHolder( - ItemReportNotificationBinding.inflate(inflater, parent, false), - notificationActionListener - ) - } - else -> { - FallbackNotificationViewHolder( - SimpleListItem1Binding.inflate(inflater, parent, false) - ) - } - } - } - - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - bindViewHolder(holder, position, null) - } - - override fun onBindViewHolder( - holder: RecyclerView.ViewHolder, - position: Int, - payloads: MutableList - ) { - bindViewHolder(holder, position, payloads) - } - - private fun bindViewHolder( - holder: RecyclerView.ViewHolder, - position: Int, - payloads: List<*>? - ) { - getItem(position)?.let { (holder as ViewHolder).bind(it, payloads, statusDisplayOptions) } - } - - /** - * Notification view holder to use if no other type is appropriate. Should never normally - * be used, but is useful when migrating code. - */ - private class FallbackNotificationViewHolder( - val binding: SimpleListItem1Binding - ) : ViewHolder, RecyclerView.ViewHolder(binding.root) { - override fun bind( - viewData: NotificationViewData, - payloads: List<*>?, - statusDisplayOptions: StatusDisplayOptions - ) { - binding.text1.text = viewData.statusViewData?.content - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingSource.kt deleted file mode 100644 index b754989d06..0000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingSource.kt +++ /dev/null @@ -1,216 +0,0 @@ -/* - * Copyright 2023 Tusky Contributors - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . - */ - -package com.keylesspalace.tusky.components.notifications - -import android.util.Log -import androidx.paging.PagingSource -import androidx.paging.PagingState -import com.google.gson.Gson -import com.keylesspalace.tusky.entity.Notification -import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.util.HttpHeaderLink -import kotlinx.coroutines.async -import kotlinx.coroutines.coroutineScope -import okhttp3.Headers -import retrofit2.Response -import javax.inject.Inject - -/** Models next/prev links from the "Links" header in an API response */ -data class Links(val next: String?, val prev: String?) { - companion object { - fun from(linkHeader: String?): Links { - val links = HttpHeaderLink.parse(linkHeader) - return Links( - next = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter( - "max_id" - ), - prev = HttpHeaderLink.findByRelationType(links, "prev")?.uri?.getQueryParameter( - "min_id" - ) - ) - } - } -} - -/** [PagingSource] for Mastodon Notifications, identified by the Notification ID */ -class NotificationsPagingSource @Inject constructor( - private val mastodonApi: MastodonApi, - private val gson: Gson, - private val notificationFilter: Set -) : PagingSource() { - override suspend fun load(params: LoadParams): LoadResult { - Log.d(TAG, "load() with ${params.javaClass.simpleName} for key: ${params.key}") - - try { - val response = when (params) { - is LoadParams.Refresh -> { - getInitialPage(params) - } - is LoadParams.Append -> mastodonApi.notifications( - maxId = params.key, - limit = params.loadSize, - excludes = notificationFilter - ) - is LoadParams.Prepend -> mastodonApi.notifications( - minId = params.key, - limit = params.loadSize, - excludes = notificationFilter - ) - } - - if (!response.isSuccessful) { - val code = response.code() - - val msg = response.errorBody()?.string()?.let { errorBody -> - if (errorBody.isBlank()) return@let "no reason given" - - val error = try { - gson.fromJson(errorBody, com.keylesspalace.tusky.entity.Error::class.java) - } catch (e: Exception) { - return@let "$errorBody ($e)" - } - - when (val desc = error.error_description) { - null -> error.error - else -> "${error.error}: $desc" - } - } ?: "no reason given" - return LoadResult.Error(Throwable("HTTP $code: $msg")) - } - - val links = Links.from(response.headers()["link"]) - return LoadResult.Page( - data = response.body()!!, - nextKey = links.next, - prevKey = links.prev - ) - } catch (e: Exception) { - return LoadResult.Error(e) - } - } - - /** - * Fetch the initial page of notifications, using params.key as the ID of the initial - * notification to fetch. - * - * - If there is no key, a page of the most recent notifications is returned - * - If the notification exists, and is not filtered, a page of notifications is returned - * - If the notification does not exist, or is filtered, the page of notifications immediately - * before is returned (if non-empty) - * - If there is no page of notifications immediately before then the page immediately after - * is returned (if non-empty) - * - Finally, fall back to the most recent notifications - */ - private suspend fun getInitialPage(params: LoadParams): Response> = coroutineScope { - // If the key is null this is straightforward, just return the most recent notifications. - val key = params.key - ?: return@coroutineScope mastodonApi.notifications( - limit = params.loadSize, - excludes = notificationFilter - ) - - // It's important to return *something* from this state. If an empty page is returned - // (even with next/prev links) Pager3 assumes there is no more data to load and stops. - // - // In addition, the Mastodon API does not let you fetch a page that contains a given key. - // You can fetch the page immediately before the key, or the page immediately after, but - // you can not fetch the page itself. - - // First, try and get the notification itself, and the notifications immediately before - // it. This is so that a full page of results can be returned. Returning just the - // single notification means the displayed list can jump around a bit as more data is - // loaded. - // - // Make both requests, and wait for the first to complete. - val deferredNotification = async { mastodonApi.notification(id = key) } - val deferredNotificationPage = async { - mastodonApi.notifications(maxId = key, limit = params.loadSize, excludes = notificationFilter) - } - - val notification = deferredNotification.await() - if (notification.isSuccessful) { - // If this was successful we must still check that the user is not filtering this type - // of notification, as fetching a single notification ignores filters. Returning this - // notification if the user is filtering the type is wrong. - notification.body()?.let { body -> - if (!notificationFilter.contains(body.type)) { - // Notification is *not* filtered. We can return this, but need the next page of - // notifications as well - - // Collect all notifications in to this list - val notifications = mutableListOf(body) - val notificationPage = deferredNotificationPage.await() - if (notificationPage.isSuccessful) { - notificationPage.body()?.let { - notifications.addAll(it) - } - } - - // "notifications" now contains at least one notification we can return, and - // hopefully a full page. - - // Build correct max_id and min_id links for the response. The "min_id" to use - // when fetching the next page is the same as "key". The "max_id" is the ID of - // the oldest notification in the list. - val maxId = notifications.last().id - val headers = Headers.Builder() - .add("link: ; rel=\"next\", ; rel=\"prev\"") - .build() - - return@coroutineScope Response.success(notifications, headers) - } - } - } - - // The user's last read notification was missing or is filtered. Use the page of - // notifications chronologically older than their desired notification. This page must - // *not* be empty (as noted earlier, if it is, paging stops). - deferredNotificationPage.await().let { response -> - if (response.isSuccessful) { - if (!response.body().isNullOrEmpty()) return@coroutineScope response - } - } - - // There were no notifications older than the user's desired notification. Return the page - // of notifications immediately newer than their desired notification. This page must - // *not* be empty (as noted earlier, if it is, paging stops). - mastodonApi.notifications(minId = key, limit = params.loadSize, excludes = notificationFilter).let { response -> - if (response.isSuccessful) { - if (!response.body().isNullOrEmpty()) return@coroutineScope response - } - } - - // Everything failed -- fallback to fetching the most recent notifications - return@coroutineScope mastodonApi.notifications( - limit = params.loadSize, - excludes = notificationFilter - ) - } - - override fun getRefreshKey(state: PagingState): String? { - return state.anchorPosition?.let { anchorPosition -> - val id = state.closestItemToPosition(anchorPosition)?.id - Log.d(TAG, " getRefreshKey returning $id") - return id - } - } - - companion object { - private const val TAG = "NotificationsPagingSource" - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRepository.kt deleted file mode 100644 index 4bec1aa328..0000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRepository.kt +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Copyright 2023 Tusky Contributors - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . - */ - -package com.keylesspalace.tusky.components.notifications - -import android.util.Log -import androidx.paging.InvalidatingPagingSourceFactory -import androidx.paging.Pager -import androidx.paging.PagingConfig -import androidx.paging.PagingData -import androidx.paging.PagingSource -import com.google.gson.Gson -import com.keylesspalace.tusky.entity.Notification -import com.keylesspalace.tusky.network.MastodonApi -import kotlinx.coroutines.flow.Flow -import okhttp3.ResponseBody -import retrofit2.Response -import javax.inject.Inject - -class NotificationsRepository @Inject constructor( - private val mastodonApi: MastodonApi, - private val gson: Gson -) { - private var factory: InvalidatingPagingSourceFactory? = null - - /** - * @return flow of Mastodon [Notification], excluding all types in [filter]. - * Notifications are loaded in [pageSize] increments. - */ - fun getNotificationsStream( - filter: Set, - pageSize: Int = PAGE_SIZE, - initialKey: String? = null - ): Flow> { - Log.d(TAG, "getNotificationsStream(), filtering: $filter") - - factory = InvalidatingPagingSourceFactory { - NotificationsPagingSource(mastodonApi, gson, filter) - } - - return Pager( - config = PagingConfig(pageSize = pageSize, initialLoadSize = pageSize), - initialKey = initialKey, - pagingSourceFactory = factory!! - ).flow - } - - /** Invalidate the active paging source, see [PagingSource.invalidate] */ - fun invalidate() { - factory?.invalidate() - } - - /** Clear notifications */ - suspend fun clearNotifications(): Response { - return mastodonApi.clearNotifications() - } - - companion object { - private const val TAG = "NotificationsRepository" - private const val PAGE_SIZE = 30 - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt deleted file mode 100644 index d06a8bbcfc..0000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt +++ /dev/null @@ -1,548 +0,0 @@ -/* - * Copyright 2023 Tusky Contributors - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . - */ - -package com.keylesspalace.tusky.components.notifications - -import android.content.SharedPreferences -import android.util.Log -import androidx.annotation.StringRes -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import androidx.paging.PagingData -import androidx.paging.cachedIn -import androidx.paging.map -import at.connyduck.calladapter.networkresult.getOrThrow -import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.appstore.BlockEvent -import com.keylesspalace.tusky.appstore.EventHub -import com.keylesspalace.tusky.appstore.MuteConversationEvent -import com.keylesspalace.tusky.appstore.MuteEvent -import com.keylesspalace.tusky.appstore.PreferenceChangedEvent -import com.keylesspalace.tusky.components.timeline.util.ifExpected -import com.keylesspalace.tusky.db.AccountManager -import com.keylesspalace.tusky.entity.Notification -import com.keylesspalace.tusky.entity.Poll -import com.keylesspalace.tusky.settings.PrefKeys -import com.keylesspalace.tusky.usecase.TimelineCases -import com.keylesspalace.tusky.util.StatusDisplayOptions -import com.keylesspalace.tusky.util.deserialize -import com.keylesspalace.tusky.util.serialize -import com.keylesspalace.tusky.util.throttleFirst -import com.keylesspalace.tusky.util.toViewData -import com.keylesspalace.tusky.viewdata.NotificationViewData -import com.keylesspalace.tusky.viewdata.StatusViewData -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.getAndUpdate -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch -import kotlinx.coroutines.rx3.await -import retrofit2.HttpException -import javax.inject.Inject -import kotlin.time.Duration.Companion.milliseconds - -data class UiState( - /** Filtered notification types */ - val activeFilter: Set = emptySet(), - - /** True if the FAB should be shown while scrolling */ - val showFabWhileScrolling: Boolean = true -) - -/** Preferences the UI reacts to */ -data class UiPrefs( - val showFabWhileScrolling: Boolean -) { - companion object { - /** Relevant preference keys. Changes to any of these trigger a display update */ - val prefKeys = setOf( - PrefKeys.FAB_HIDE - ) - } -} - -/** Parent class for all UI actions, fallible or infallible. */ -sealed class UiAction - -/** Actions the user can trigger from the UI. These actions may fail. */ -sealed class FallibleUiAction : UiAction() { - /** Clear all notifications */ - data object ClearNotifications : FallibleUiAction() -} - -/** - * Actions the user can trigger from the UI that either cannot fail, or if they do fail, - * do not show an error. - */ -sealed class InfallibleUiAction : UiAction() { - /** Apply a new filter to the notification list */ - // This saves the list to the local database, which triggers a refresh of the data. - // Saving the data can't fail, which is why this is infallible. Refreshing the - // data may fail, but that's handled by the paging system / adapter refresh logic. - data class ApplyFilter(val filter: Set) : InfallibleUiAction() - - /** - * User is leaving the fragment, save the ID of the visible notification. - * - * Infallible because if it fails there's nowhere to show the error, and nothing the user - * can do. - */ - data class SaveVisibleId(val visibleId: String) : InfallibleUiAction() - - /** Ignore the saved reading position, load the page with the newest items */ - // Resets the account's `lastNotificationId`, which can't fail, which is why this is - // infallible. Reloading the data may fail, but that's handled by the paging system / - // adapter refresh logic. - data object LoadNewest : InfallibleUiAction() -} - -/** Actions the user can trigger on an individual notification. These may fail. */ -sealed class NotificationAction : FallibleUiAction() { - data class AcceptFollowRequest(val accountId: String) : NotificationAction() - - data class RejectFollowRequest(val accountId: String) : NotificationAction() -} - -sealed class UiSuccess { - // These three are from menu items on the status. Currently they don't come to the - // viewModel as actions, they're noticed when events are posted. That will change, - // but for the moment we can still report them to the UI. Typically, receiving any - // of these three should trigger the UI to refresh. - - /** A user was blocked */ - data object Block : UiSuccess() - - /** A user was muted */ - data object Mute : UiSuccess() - - /** A conversation was muted */ - data object MuteConversation : UiSuccess() -} - -/** The result of a successful action on a notification */ -sealed class NotificationActionSuccess( - /** String resource with an error message to show the user */ - @StringRes val msg: Int, - - /** - * The original action, in case additional information is required from it to display the - * message. - */ - open val action: NotificationAction -) : UiSuccess() { - data class AcceptFollowRequest(override val action: NotificationAction) : - NotificationActionSuccess(R.string.ui_success_accepted_follow_request, action) - data class RejectFollowRequest(override val action: NotificationAction) : - NotificationActionSuccess(R.string.ui_success_rejected_follow_request, action) - - companion object { - fun from(action: NotificationAction) = when (action) { - is NotificationAction.AcceptFollowRequest -> AcceptFollowRequest(action) - is NotificationAction.RejectFollowRequest -> RejectFollowRequest(action) - } - } -} - -/** Actions the user can trigger on an individual status */ -sealed class StatusAction( - open val statusViewData: StatusViewData.Concrete -) : FallibleUiAction() { - /** Set the bookmark state for a status */ - data class Bookmark(val state: Boolean, override val statusViewData: StatusViewData.Concrete) : - StatusAction(statusViewData) - - /** Set the favourite state for a status */ - data class Favourite(val state: Boolean, override val statusViewData: StatusViewData.Concrete) : - StatusAction(statusViewData) - - /** Set the reblog state for a status */ - data class Reblog(val state: Boolean, override val statusViewData: StatusViewData.Concrete) : - StatusAction(statusViewData) - - /** Vote in a poll */ - data class VoteInPoll( - val poll: Poll, - val choices: List, - override val statusViewData: StatusViewData.Concrete - ) : StatusAction(statusViewData) -} - -/** Changes to a status' visible state after API calls */ -sealed class StatusActionSuccess(open val action: StatusAction) : UiSuccess() { - data class Bookmark(override val action: StatusAction.Bookmark) : - StatusActionSuccess(action) - - data class Favourite(override val action: StatusAction.Favourite) : - StatusActionSuccess(action) - - data class Reblog(override val action: StatusAction.Reblog) : - StatusActionSuccess(action) - - data class VoteInPoll(override val action: StatusAction.VoteInPoll) : - StatusActionSuccess(action) - - companion object { - fun from(action: StatusAction) = when (action) { - is StatusAction.Bookmark -> Bookmark(action) - is StatusAction.Favourite -> Favourite(action) - is StatusAction.Reblog -> Reblog(action) - is StatusAction.VoteInPoll -> VoteInPoll(action) - } - } -} - -/** Errors from fallible view model actions that the UI will need to show */ -sealed class UiError( - /** The exception associated with the error */ - open val throwable: Throwable, - - /** String resource with an error message to show the user */ - @StringRes val message: Int, - - /** The action that failed. Can be resent to retry the action */ - open val action: UiAction? = null -) { - data class ClearNotifications(override val throwable: Throwable) : UiError( - throwable, - R.string.ui_error_clear_notifications - ) - - data class Bookmark( - override val throwable: Throwable, - override val action: StatusAction.Bookmark - ) : UiError(throwable, R.string.ui_error_bookmark, action) - - data class Favourite( - override val throwable: Throwable, - override val action: StatusAction.Favourite - ) : UiError(throwable, R.string.ui_error_favourite, action) - - data class Reblog( - override val throwable: Throwable, - override val action: StatusAction.Reblog - ) : UiError(throwable, R.string.ui_error_reblog, action) - - data class VoteInPoll( - override val throwable: Throwable, - override val action: StatusAction.VoteInPoll - ) : UiError(throwable, R.string.ui_error_vote, action) - - data class AcceptFollowRequest( - override val throwable: Throwable, - override val action: NotificationAction.AcceptFollowRequest - ) : UiError(throwable, R.string.ui_error_accept_follow_request, action) - - data class RejectFollowRequest( - override val throwable: Throwable, - override val action: NotificationAction.RejectFollowRequest - ) : UiError(throwable, R.string.ui_error_reject_follow_request, action) - - companion object { - fun make(throwable: Throwable, action: FallibleUiAction) = when (action) { - is StatusAction.Bookmark -> Bookmark(throwable, action) - is StatusAction.Favourite -> Favourite(throwable, action) - is StatusAction.Reblog -> Reblog(throwable, action) - is StatusAction.VoteInPoll -> VoteInPoll(throwable, action) - is NotificationAction.AcceptFollowRequest -> AcceptFollowRequest(throwable, action) - is NotificationAction.RejectFollowRequest -> RejectFollowRequest(throwable, action) - FallibleUiAction.ClearNotifications -> ClearNotifications(throwable) - } - } -} - -@OptIn(ExperimentalCoroutinesApi::class) -class NotificationsViewModel @Inject constructor( - private val repository: NotificationsRepository, - private val preferences: SharedPreferences, - private val accountManager: AccountManager, - private val timelineCases: TimelineCases, - private val eventHub: EventHub -) : ViewModel() { - /** The account to display notifications for */ - val account = accountManager.activeAccount!! - - val uiState: StateFlow - - /** Flow of changes to statusDisplayOptions, for use by the UI */ - val statusDisplayOptions: StateFlow - - val pagingData: Flow> - - /** Flow of user actions received from the UI */ - private val uiAction = MutableSharedFlow() - - /** Flow that can be used to trigger a full reload */ - private val reload = MutableStateFlow(0) - - /** Flow of successful action results */ - // Note: This is a SharedFlow instead of a StateFlow because success state does not need to be - // retained. A message is shown once to a user and then dismissed. Re-collecting the flow - // (e.g., after a device orientation change) should not re-show the most recent success - // message, as it will be confusing to the user. - val uiSuccess = MutableSharedFlow() - - /** Channel for error results */ - // Errors are sent to a channel to ensure that any errors that occur *before* there are any - // subscribers are retained. If this was a SharedFlow any errors would be dropped, and if it - // was a StateFlow any errors would be retained, and there would need to be an explicit - // mechanism to dismiss them. - private val _uiErrorChannel = Channel() - - /** Expose UI errors as a flow */ - val uiError = _uiErrorChannel.receiveAsFlow() - - /** Accept UI actions in to actionStateFlow */ - val accept: (UiAction) -> Unit = { action -> - viewModelScope.launch { uiAction.emit(action) } - } - - init { - // Handle changes to notification filters - val notificationFilter = uiAction - .filterIsInstance() - .distinctUntilChanged() - // Save each change back to the active account - .onEach { action -> - Log.d(TAG, "notificationFilter: $action") - account.notificationsFilter = serialize(action.filter) - accountManager.saveAccount(account) - } - // Load the initial filter from the active account - .onStart { - emit( - InfallibleUiAction.ApplyFilter( - filter = deserialize(account.notificationsFilter) - ) - ) - } - - // Reset the last notification ID to "0" to fetch the newest notifications, and - // increment `reload` to trigger creation of a new PagingSource. - viewModelScope.launch { - uiAction - .filterIsInstance() - .collectLatest { - account.lastNotificationId = "0" - accountManager.saveAccount(account) - reload.getAndUpdate { it + 1 } - } - } - - // Save the visible notification ID - viewModelScope.launch { - uiAction - .filterIsInstance() - .distinctUntilChanged() - .collectLatest { action -> - Log.d(TAG, "Saving visible ID: ${action.visibleId}, active account = ${account.id}") - account.lastNotificationId = action.visibleId - accountManager.saveAccount(account) - } - } - - // Set initial status display options from the user's preferences. - // - // Then collect future preference changes and emit new values in to - // statusDisplayOptions if necessary. - statusDisplayOptions = MutableStateFlow( - StatusDisplayOptions.from( - preferences, - account - ) - ) - - viewModelScope.launch { - eventHub.events - .filterIsInstance() - .filter { StatusDisplayOptions.prefKeys.contains(it.preferenceKey) } - .map { - statusDisplayOptions.value.make( - preferences, - it.preferenceKey, - account - ) - } - .collect { - statusDisplayOptions.emit(it) - } - } - - // Handle UiAction.ClearNotifications - viewModelScope.launch { - uiAction.filterIsInstance() - .collectLatest { - try { - repository.clearNotifications().apply { - if (this.isSuccessful) { - repository.invalidate() - } else { - _uiErrorChannel.send(UiError.make(HttpException(this), it)) - } - } - } catch (e: Exception) { - ifExpected(e) { _uiErrorChannel.send(UiError.make(e, it)) } - } - } - } - - // Handle NotificationAction.* - viewModelScope.launch { - uiAction.filterIsInstance() - .throttleFirst(THROTTLE_TIMEOUT) - .collect { action -> - try { - when (action) { - is NotificationAction.AcceptFollowRequest -> - timelineCases.acceptFollowRequest(action.accountId).await() - is NotificationAction.RejectFollowRequest -> - timelineCases.rejectFollowRequest(action.accountId).await() - } - uiSuccess.emit(NotificationActionSuccess.from(action)) - } catch (e: Exception) { - ifExpected(e) { _uiErrorChannel.send(UiError.make(e, action)) } - } - } - } - - // Handle StatusAction.* - viewModelScope.launch { - uiAction.filterIsInstance() - .throttleFirst(THROTTLE_TIMEOUT) // avoid double-taps - .collect { action -> - try { - when (action) { - is StatusAction.Bookmark -> - timelineCases.bookmark( - action.statusViewData.actionableId, - action.state - ) - is StatusAction.Favourite -> - timelineCases.favourite( - action.statusViewData.actionableId, - action.state - ) - is StatusAction.Reblog -> - timelineCases.reblog( - action.statusViewData.actionableId, - action.state - ) - is StatusAction.VoteInPoll -> - timelineCases.voteInPoll( - action.statusViewData.actionableId, - action.poll.id, - action.choices - ) - }.getOrThrow() - uiSuccess.emit(StatusActionSuccess.from(action)) - } catch (t: Throwable) { - _uiErrorChannel.send(UiError.make(t, action)) - } - } - } - - // Handle events that should refresh the list - viewModelScope.launch { - eventHub.events.collectLatest { - when (it) { - is BlockEvent -> uiSuccess.emit(UiSuccess.Block) - is MuteEvent -> uiSuccess.emit(UiSuccess.Mute) - is MuteConversationEvent -> uiSuccess.emit(UiSuccess.MuteConversation) - } - } - } - - // Re-fetch notifications if either of `notificationFilter` or `reload` flows have - // new items. - pagingData = combine(notificationFilter, reload) { action, _ -> action } - .flatMapLatest { action -> - getNotifications(filters = action.filter, initialKey = getInitialKey()) - }.cachedIn(viewModelScope) - - uiState = combine(notificationFilter, getUiPrefs()) { filter, prefs -> - UiState( - activeFilter = filter.filter, - showFabWhileScrolling = prefs.showFabWhileScrolling - ) - }.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000), - initialValue = UiState() - ) - } - - private fun getNotifications( - filters: Set, - initialKey: String? = null - ): Flow> { - return repository.getNotificationsStream(filter = filters, initialKey = initialKey) - .map { pagingData -> - pagingData.map { notification -> - notification.toViewData( - isShowingContent = statusDisplayOptions.value.showSensitiveMedia || - !(notification.status?.actionableStatus?.sensitive ?: false), - isExpanded = statusDisplayOptions.value.openSpoiler, - isCollapsed = true - ) - } - } - } - - // The database stores "0" as the last notification ID if notifications have not been - // fetched. Convert to null to ensure a full fetch in this case - private fun getInitialKey(): String? { - val initialKey = when (val id = account.lastNotificationId) { - "0" -> null - else -> id - } - Log.d(TAG, "Restoring at $initialKey") - return initialKey - } - - /** - * @return Flow of relevant preferences that change the UI - */ - // TODO: Preferences should be in a repository - private fun getUiPrefs() = eventHub.events - .filterIsInstance() - .filter { UiPrefs.prefKeys.contains(it.preferenceKey) } - .map { toPrefs() } - .onStart { emit(toPrefs()) } - - private fun toPrefs() = UiPrefs( - showFabWhileScrolling = !preferences.getBoolean(PrefKeys.FAB_HIDE, false) - ) - - companion object { - private const val TAG = "NotificationsViewModel" - private val THROTTLE_TIMEOUT = 500.milliseconds - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusNotificationViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusNotificationViewHolder.kt deleted file mode 100644 index 0b1d8dca44..0000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusNotificationViewHolder.kt +++ /dev/null @@ -1,387 +0,0 @@ -/* - * Copyright 2023 Tusky Contributors - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . - */ - -package com.keylesspalace.tusky.components.notifications - -import android.content.Context -import android.graphics.PorterDuff -import android.graphics.Typeface -import android.graphics.drawable.Drawable -import android.text.InputFilter -import android.text.SpannableStringBuilder -import android.text.Spanned -import android.text.TextUtils -import android.text.format.DateUtils -import android.text.style.StyleSpan -import android.view.View -import androidx.annotation.ColorRes -import androidx.annotation.DrawableRes -import androidx.core.content.ContextCompat -import androidx.recyclerview.widget.RecyclerView -import at.connyduck.sparkbutton.helpers.Utils -import com.bumptech.glide.Glide -import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.adapter.StatusBaseViewHolder -import com.keylesspalace.tusky.databinding.ItemStatusNotificationBinding -import com.keylesspalace.tusky.entity.Emoji -import com.keylesspalace.tusky.entity.Notification -import com.keylesspalace.tusky.interfaces.LinkListener -import com.keylesspalace.tusky.interfaces.StatusActionListener -import com.keylesspalace.tusky.util.AbsoluteTimeFormatter -import com.keylesspalace.tusky.util.SmartLengthInputFilter -import com.keylesspalace.tusky.util.StatusDisplayOptions -import com.keylesspalace.tusky.util.emojify -import com.keylesspalace.tusky.util.getRelativeTimeSpanString -import com.keylesspalace.tusky.util.loadAvatar -import com.keylesspalace.tusky.util.setClickableText -import com.keylesspalace.tusky.util.unicodeWrap -import com.keylesspalace.tusky.viewdata.NotificationViewData -import com.keylesspalace.tusky.viewdata.StatusViewData -import java.util.Date - -/** - * View holder for a status with an activity to be notified about (posted, boosted, - * favourited, or edited, per [NotificationViewKind.from]). - * - * Shows a line with the activity, and who initiated the activity. Clicking this should - * go to the profile page for the initiator. - * - * Displays the original status below that. Clicking this should go to the original - * status in context. - */ -internal class StatusNotificationViewHolder( - private val binding: ItemStatusNotificationBinding, - private val statusActionListener: StatusActionListener, - private val notificationActionListener: NotificationActionListener, - private val absoluteTimeFormatter: AbsoluteTimeFormatter -) : NotificationsPagingAdapter.ViewHolder, RecyclerView.ViewHolder(binding.root) { - private val avatarRadius48dp = itemView.context.resources.getDimensionPixelSize( - R.dimen.avatar_radius_48dp - ) - private val avatarRadius36dp = itemView.context.resources.getDimensionPixelSize( - R.dimen.avatar_radius_36dp - ) - private val avatarRadius24dp = itemView.context.resources.getDimensionPixelSize( - R.dimen.avatar_radius_24dp - ) - - override fun bind( - viewData: NotificationViewData, - payloads: List<*>?, - statusDisplayOptions: StatusDisplayOptions - ) { - val statusViewData = viewData.statusViewData - if (payloads.isNullOrEmpty()) { - // Hide null statuses. Shouldn't happen according to the spec, but some servers - // have been seen to do this (https://github.com/tuskyapp/Tusky/issues/2252) - if (statusViewData == null) { - showNotificationContent(false) - } else { - showNotificationContent(true) - val (_, _, account, _, _, _, _, createdAt) = statusViewData.actionable - setDisplayName(account.name, account.emojis, statusDisplayOptions.animateEmojis) - setUsername(account.username) - setCreatedAt(createdAt, statusDisplayOptions.useAbsoluteTime) - if (viewData.type == Notification.Type.STATUS || - viewData.type == Notification.Type.UPDATE - ) { - setAvatar( - account.avatar, - account.bot, - statusDisplayOptions.animateAvatars, - statusDisplayOptions.showBotOverlay - ) - } else { - setAvatars( - account.avatar, - viewData.account.avatar, - statusDisplayOptions.animateAvatars - ) - } - - binding.notificationContainer.setOnClickListener { - notificationActionListener.onViewThreadForStatus(statusViewData.status) - } - binding.notificationContent.setOnClickListener { - notificationActionListener.onViewThreadForStatus(statusViewData.status) - } - binding.notificationTopText.setOnClickListener { - notificationActionListener.onViewAccount(viewData.account.id) - } - } - setMessage(viewData, statusActionListener, statusDisplayOptions.animateEmojis) - } else { - for (item in payloads) { - if (StatusBaseViewHolder.Key.KEY_CREATED == item && statusViewData != null) { - setCreatedAt( - statusViewData.status.actionableStatus.createdAt, - statusDisplayOptions.useAbsoluteTime - ) - } - } - } - } - - private fun showNotificationContent(show: Boolean) { - binding.statusDisplayName.visibility = if (show) View.VISIBLE else View.GONE - binding.statusUsername.visibility = if (show) View.VISIBLE else View.GONE - binding.statusMetaInfo.visibility = if (show) View.VISIBLE else View.GONE - binding.notificationContentWarningDescription.visibility = - if (show) View.VISIBLE else View.GONE - binding.notificationContentWarningButton.visibility = - if (show) View.VISIBLE else View.GONE - binding.notificationContent.visibility = if (show) View.VISIBLE else View.GONE - binding.notificationStatusAvatar.visibility = if (show) View.VISIBLE else View.GONE - binding.notificationNotificationAvatar.visibility = if (show) View.VISIBLE else View.GONE - } - - private fun setDisplayName(name: String, emojis: List?, animateEmojis: Boolean) { - val emojifiedName = name.emojify(emojis, binding.statusDisplayName, animateEmojis) - binding.statusDisplayName.text = emojifiedName - } - - private fun setUsername(name: String) { - val context = binding.statusUsername.context - val format = context.getString(R.string.post_username_format) - val usernameText = String.format(format, name) - binding.statusUsername.text = usernameText - } - - private fun setCreatedAt(createdAt: Date?, useAbsoluteTime: Boolean) { - if (useAbsoluteTime) { - binding.statusMetaInfo.text = absoluteTimeFormatter.format(createdAt, true) - } else { - // This is the visible timestampInfo. - val readout: String - /* This one is for screen-readers. Frequently, they would mispronounce timestamps like "17m" - * as 17 meters instead of minutes. */ - val readoutAloud: CharSequence - if (createdAt != null) { - val then = createdAt.time - val now = Date().time - readout = getRelativeTimeSpanString(binding.statusMetaInfo.context, then, now) - readoutAloud = DateUtils.getRelativeTimeSpanString( - then, - now, - DateUtils.SECOND_IN_MILLIS, - DateUtils.FORMAT_ABBREV_RELATIVE - ) - } else { - // unknown minutes~ - readout = "?m" - readoutAloud = "? minutes" - } - binding.statusMetaInfo.text = readout - binding.statusMetaInfo.contentDescription = readoutAloud - } - } - - private fun getIconWithColor( - context: Context, - @DrawableRes drawable: Int, - @ColorRes color: Int - ): Drawable? { - val icon = ContextCompat.getDrawable(context, drawable) - icon?.setColorFilter(context.getColor(color), PorterDuff.Mode.SRC_ATOP) - return icon - } - - private fun setAvatar(statusAvatarUrl: String?, isBot: Boolean, animateAvatars: Boolean, showBotOverlay: Boolean) { - binding.notificationStatusAvatar.setPaddingRelative(0, 0, 0, 0) - loadAvatar( - statusAvatarUrl, - binding.notificationStatusAvatar, - avatarRadius48dp, - animateAvatars - ) - if (showBotOverlay && isBot) { - binding.notificationNotificationAvatar.visibility = View.VISIBLE - Glide.with(binding.notificationNotificationAvatar) - .load(R.drawable.bot_badge) - .into(binding.notificationNotificationAvatar) - } else { - binding.notificationNotificationAvatar.visibility = View.GONE - } - } - - private fun setAvatars(statusAvatarUrl: String?, notificationAvatarUrl: String?, animateAvatars: Boolean) { - val padding = Utils.dpToPx(binding.notificationStatusAvatar.context, 12) - binding.notificationStatusAvatar.setPaddingRelative(0, 0, padding, padding) - loadAvatar( - statusAvatarUrl, - binding.notificationStatusAvatar, - avatarRadius36dp, - animateAvatars - ) - binding.notificationNotificationAvatar.visibility = View.VISIBLE - loadAvatar( - notificationAvatarUrl, - binding.notificationNotificationAvatar, - avatarRadius24dp, - animateAvatars - ) - } - - fun setMessage( - notificationViewData: NotificationViewData, - listener: LinkListener, - animateEmojis: Boolean - ) { - val statusViewData = notificationViewData.statusViewData - val displayName = notificationViewData.account.name.unicodeWrap() - val type = notificationViewData.type - val context = binding.notificationTopText.context - val format: String - val icon: Drawable? - when (type) { - Notification.Type.FAVOURITE -> { - icon = getIconWithColor(context, R.drawable.ic_star_24dp, R.color.tusky_orange) - format = context.getString(R.string.notification_favourite_format) - } - Notification.Type.REBLOG -> { - icon = getIconWithColor(context, R.drawable.ic_repeat_24dp, R.color.tusky_blue) - format = context.getString(R.string.notification_reblog_format) - } - Notification.Type.STATUS -> { - icon = getIconWithColor(context, R.drawable.ic_home_24dp, R.color.tusky_blue) - format = context.getString(R.string.notification_subscription_format) - } - Notification.Type.UPDATE -> { - icon = getIconWithColor(context, R.drawable.ic_edit_24dp, R.color.tusky_blue) - format = context.getString(R.string.notification_update_format) - } - else -> { - icon = getIconWithColor(context, R.drawable.ic_star_24dp, R.color.tusky_orange) - format = context.getString(R.string.notification_favourite_format) - } - } - binding.notificationTopText.setCompoundDrawablesWithIntrinsicBounds( - icon, - null, - null, - null - ) - val wholeMessage = String.format(format, displayName) - val str = SpannableStringBuilder(wholeMessage) - val displayNameIndex = format.indexOf("%s") - str.setSpan( - StyleSpan(Typeface.BOLD), - displayNameIndex, - displayNameIndex + displayName.length, - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE - ) - val emojifiedText = str.emojify( - notificationViewData.account.emojis, - binding.notificationTopText, - animateEmojis - ) - binding.notificationTopText.text = emojifiedText - if (statusViewData != null) { - val hasSpoiler = !TextUtils.isEmpty(statusViewData.status.spoilerText) - binding.notificationContentWarningDescription.visibility = - if (hasSpoiler) View.VISIBLE else View.GONE - binding.notificationContentWarningButton.visibility = - if (hasSpoiler) View.VISIBLE else View.GONE - if (statusViewData.isExpanded) { - binding.notificationContentWarningButton.setText( - R.string.post_content_warning_show_less - ) - } else { - binding.notificationContentWarningButton.setText( - R.string.post_content_warning_show_more - ) - } - binding.notificationContentWarningButton.setOnClickListener { - if (bindingAdapterPosition != RecyclerView.NO_POSITION) { - notificationActionListener.onExpandedChange( - !statusViewData.isExpanded, - bindingAdapterPosition - ) - } - binding.notificationContent.visibility = - if (statusViewData.isExpanded) View.GONE else View.VISIBLE - } - setupContentAndSpoiler(listener, statusViewData, animateEmojis) - } - } - - private fun setupContentAndSpoiler( - listener: LinkListener, - statusViewData: StatusViewData.Concrete, - animateEmojis: Boolean - ) { - val shouldShowContentIfSpoiler = statusViewData.isExpanded - val hasSpoiler = !TextUtils.isEmpty(statusViewData.status.spoilerText) - if (!shouldShowContentIfSpoiler && hasSpoiler) { - binding.notificationContent.visibility = View.GONE - } else { - binding.notificationContent.visibility = View.VISIBLE - } - val content = statusViewData.content - val emojis = statusViewData.actionable.emojis - if (statusViewData.isCollapsible && (statusViewData.isExpanded || !hasSpoiler)) { - binding.buttonToggleNotificationContent.setOnClickListener { - val position = bindingAdapterPosition - if (position != RecyclerView.NO_POSITION) { - notificationActionListener.onNotificationContentCollapsedChange( - !statusViewData.isCollapsed, - position - ) - } - } - binding.buttonToggleNotificationContent.visibility = View.VISIBLE - if (statusViewData.isCollapsed) { - binding.buttonToggleNotificationContent.setText( - R.string.post_content_warning_show_more - ) - binding.notificationContent.filters = COLLAPSE_INPUT_FILTER - } else { - binding.buttonToggleNotificationContent.setText( - R.string.post_content_warning_show_less - ) - binding.notificationContent.filters = NO_INPUT_FILTER - } - } else { - binding.buttonToggleNotificationContent.visibility = View.GONE - binding.notificationContent.filters = NO_INPUT_FILTER - } - val emojifiedText = - content.emojify( - emojis, - binding.notificationContent, - animateEmojis - ) - setClickableText( - binding.notificationContent, - emojifiedText, - statusViewData.actionable.mentions, - statusViewData.actionable.tags, - listener - ) - val emojifiedContentWarning: CharSequence = statusViewData.spoilerText.emojify( - statusViewData.actionable.emojis, - binding.notificationContentWarningDescription, - animateEmojis - ) - binding.notificationContentWarningDescription.text = emojifiedContentWarning - } - - companion object { - private val COLLAPSE_INPUT_FILTER = arrayOf(SmartLengthInputFilter) - private val NO_INPUT_FILTER = arrayOfNulls(0) - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusViewHolder.kt deleted file mode 100644 index c719c084ad..0000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusViewHolder.kt +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright 2023 Tusky Contributors - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . - */ - -package com.keylesspalace.tusky.components.notifications - -import com.keylesspalace.tusky.adapter.StatusViewHolder -import com.keylesspalace.tusky.databinding.ItemStatusBinding -import com.keylesspalace.tusky.entity.Notification -import com.keylesspalace.tusky.interfaces.StatusActionListener -import com.keylesspalace.tusky.util.StatusDisplayOptions -import com.keylesspalace.tusky.viewdata.NotificationViewData - -internal class StatusViewHolder( - binding: ItemStatusBinding, - private val statusActionListener: StatusActionListener, - private val accountId: String -) : NotificationsPagingAdapter.ViewHolder, StatusViewHolder(binding.root) { - - override fun bind( - viewData: NotificationViewData, - payloads: List<*>?, - statusDisplayOptions: StatusDisplayOptions - ) { - val statusViewData = viewData.statusViewData - if (statusViewData == null) { - // Hide null statuses. Shouldn't happen according to the spec, but some servers - // have been seen to do this (https://github.com/tuskyapp/Tusky/issues/2252) - showStatusContent(false) - } else { - if (payloads.isNullOrEmpty()) { - showStatusContent(true) - } - setupWithStatus( - statusViewData, - statusActionListener, - statusDisplayOptions, - payloads?.firstOrNull() - ) - } - if (viewData.type == Notification.Type.POLL) { - setPollInfo(accountId == viewData.account.id) - } else { - hideStatusInfo() - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt index b0fedb2b1a..629c3709d5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt @@ -30,9 +30,9 @@ import com.keylesspalace.tusky.R import com.keylesspalace.tusky.TabPreferenceActivity import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.components.accountlist.AccountListActivity +import com.keylesspalace.tusky.components.domainblocks.DomainBlocksActivity import com.keylesspalace.tusky.components.filters.FiltersActivity import com.keylesspalace.tusky.components.followedtags.FollowedTagsActivity -import com.keylesspalace.tusky.components.instancemute.InstanceListActivity import com.keylesspalace.tusky.components.login.LoginActivity import com.keylesspalace.tusky.components.notifications.currentAccountNeedsMigration import com.keylesspalace.tusky.db.AccountManager @@ -156,7 +156,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { setTitle(R.string.title_domain_mutes) setIcon(R.drawable.ic_mute_24dp) setOnPreferenceClickListener { - val intent = Intent(context, InstanceListActivity::class.java) + val intent = Intent(context, DomainBlocksActivity::class.java) activity?.startActivity(intent) activity?.overridePendingTransition( R.anim.slide_from_right, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesActivity.kt index b47df15960..6c16e96875 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesActivity.kt @@ -34,6 +34,7 @@ import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.databinding.ActivityPreferencesBinding import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.settings.PrefKeys.APP_THEME import com.keylesspalace.tusky.util.APP_THEME_DEFAULT import com.keylesspalace.tusky.util.getNonNullString import com.keylesspalace.tusky.util.setAppNightMode @@ -145,8 +146,8 @@ class PreferencesActivity : override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) { when (key) { - "appTheme" -> { - val theme = sharedPreferences.getNonNullString("appTheme", APP_THEME_DEFAULT) + APP_THEME -> { + val theme = sharedPreferences.getNonNullString(APP_THEME, APP_THEME_DEFAULT) Log.d("activeTheme", theme) setAppNightMode(theme) @@ -157,9 +158,9 @@ class PreferencesActivity : restartActivitiesOnBackPressedCallback.isEnabled = true this.restartCurrentActivity() } - "statusTextSize", "absoluteTimeView", "showBotOverlay", "animateGifAvatars", "useBlurhash", - "showSelfUsername", "showCardsInTimelines", "confirmReblogs", "confirmFavourites", - "enableSwipeForTabs", "mainNavPosition", PrefKeys.HIDE_TOP_TOOLBAR, PrefKeys.SHOW_STATS_INLINE -> { + PrefKeys.STATUS_TEXT_SIZE, PrefKeys.ABSOLUTE_TIME_VIEW, PrefKeys.SHOW_BOT_OVERLAY, PrefKeys.ANIMATE_GIF_AVATARS, PrefKeys.USE_BLURHASH, + PrefKeys.SHOW_SELF_USERNAME, PrefKeys.SHOW_CARDS_IN_TIMELINES, PrefKeys.CONFIRM_REBLOGS, PrefKeys.CONFIRM_FAVOURITES, + PrefKeys.ENABLE_SWIPE_FOR_TABS, PrefKeys.MAIN_NAV_POSITION, PrefKeys.HIDE_TOP_TOOLBAR, PrefKeys.SHOW_STATS_INLINE -> { restartActivitiesOnBackPressedCallback.isEnabled = true } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/Screen.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/Screen.kt index fb0b15caed..e3c5c197d2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/Screen.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/Screen.kt @@ -20,5 +20,5 @@ enum class Screen { Note, Done, Back, - Finish + Finish, } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt index 0ee7ec5db6..21e1f51a02 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt @@ -103,15 +103,15 @@ class StatusViewHolder( shouldTrimStatus(viewdata.content), viewState.isCollapsed(viewdata.id, true), viewState.isContentShow(viewdata.id, viewdata.status.sensitive), - viewdata.spoilerText + viewdata.status.spoilerText ) - if (viewdata.spoilerText.isBlank()) { + if (viewdata.status.spoilerText.isBlank()) { setTextVisible(true, viewdata.content, viewdata.status.mentions, viewdata.status.tags, viewdata.status.emojis, adapterHandler) binding.statusContentWarningButton.hide() binding.statusContentWarningDescription.hide() } else { - val emojiSpoiler = viewdata.spoilerText.emojify(viewdata.status.emojis, binding.statusContentWarningDescription, statusDisplayOptions.animateEmojis) + val emojiSpoiler = viewdata.status.spoilerText.emojify(viewdata.status.emojis, binding.statusContentWarningDescription, statusDisplayOptions.animateEmojis) binding.statusContentWarningDescription.text = emojiSpoiler binding.statusContentWarningDescription.show() binding.statusContentWarningButton.show() diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt index 362680f6f4..6165d20f0d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt @@ -149,12 +149,12 @@ class ReportStatusesFragment : val statusDisplayOptions = StatusDisplayOptions( animateAvatars = false, mediaPreviewEnabled = accountManager.activeAccount?.mediaPreviewEnabled ?: true, - useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false), + useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false), showBotOverlay = false, - useBlurhash = preferences.getBoolean("useBlurhash", true), + useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true), cardViewMode = CardViewMode.NONE, - confirmReblogs = preferences.getBoolean("confirmReblogs", true), - confirmFavourites = preferences.getBoolean("confirmFavourites", false), + confirmReblogs = preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true), + confirmFavourites = preferences.getBoolean(PrefKeys.CONFIRM_FAVOURITES, false), hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), showStatsInline = preferences.getBoolean(PrefKeys.SHOW_STATS_INLINE, false), diff --git a/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusActivity.kt index a53c08928c..d7e72812cb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusActivity.kt @@ -127,7 +127,7 @@ class ScheduledStatusActivity : } override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { - menuInflater.inflate(R.menu.activity_announcements, menu) + menuInflater.inflate(R.menu.activity_scheduled_status, menu) menu.findItem(R.id.action_search)?.apply { icon = IconicsDrawable(this@ScheduledStatusActivity, GoogleMaterial.Icon.gmd_search).apply { sizeDp = 20 @@ -163,8 +163,8 @@ class ScheduledStatusActivity : visibility = item.params.visibility, scheduledAt = item.scheduledAt, sensitive = item.params.sensitive, - kind = ComposeActivity.ComposeKind.EDIT_SCHEDULED - ) + kind = ComposeActivity.ComposeKind.EDIT_SCHEDULED, + ), ) startActivity(intent) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchFragment.kt index 28790c2fb6..48a9c38aa6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchFragment.kt @@ -107,7 +107,7 @@ abstract class SearchFragment : } override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { - menuInflater.inflate(R.menu.fragment_timeline, menu) + menuInflater.inflate(R.menu.fragment_search, menu) menu.findItem(R.id.action_refresh)?.apply { icon = IconicsDrawable(requireContext(), GoogleMaterial.Icon.gmd_refresh).apply { sizeDp = 20 diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt index 17103f6d39..e3adadccba 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt @@ -78,14 +78,14 @@ class SearchStatusesFragment : SearchFragment(), Status override fun createAdapter(): PagingDataAdapter { val preferences = PreferenceManager.getDefaultSharedPreferences(binding.searchRecyclerView.context) val statusDisplayOptions = StatusDisplayOptions( - animateAvatars = preferences.getBoolean("animateGifAvatars", false), + animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), mediaPreviewEnabled = viewModel.mediaPreviewEnabled, - useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false), - showBotOverlay = preferences.getBoolean("showBotOverlay", true), - useBlurhash = preferences.getBoolean("useBlurhash", true), + useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false), + showBotOverlay = preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true), + useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true), cardViewMode = CardViewMode.NONE, - confirmReblogs = preferences.getBoolean("confirmReblogs", true), - confirmFavourites = preferences.getBoolean("confirmFavourites", false), + confirmReblogs = preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true), + confirmFavourites = preferences.getBoolean(PrefKeys.CONFIRM_FAVOURITES, false), hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), showStatsInline = preferences.getBoolean(PrefKeys.SHOW_STATS_INLINE, false), diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index f0094b8e3d..1d09418639 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -277,7 +277,7 @@ class TimelineFragment : if (actionButtonPresent()) { val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) - hideFab = preferences.getBoolean("fabHide", false) + hideFab = preferences.getBoolean(PrefKeys.FAB_HIDE, false) binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) { val composeButton = (activity as ActionButtonActivity).actionButton @@ -545,7 +545,8 @@ class TimelineFragment : when (kind) { TimelineViewModel.Kind.HOME, TimelineViewModel.Kind.PUBLIC_FEDERATED, - TimelineViewModel.Kind.PUBLIC_LOCAL -> adapter.refresh() + TimelineViewModel.Kind.PUBLIC_LOCAL, + TimelineViewModel.Kind.PUBLIC_TRENDING_STATUSES -> adapter.refresh() TimelineViewModel.Kind.USER, TimelineViewModel.Kind.USER_WITH_REPLIES -> if (status.account.id == viewModel.id) { adapter.refresh() diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/util/TimelineUtils.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/util/TimelineUtils.kt index c8d95fd812..4a1d75f925 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/util/TimelineUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/util/TimelineUtils.kt @@ -1,9 +1,10 @@ package com.keylesspalace.tusky.components.timeline.util +import com.google.gson.JsonParseException import retrofit2.HttpException import java.io.IOException -fun Throwable.isExpected() = this is IOException || this is HttpException +fun Throwable.isExpected() = this is IOException || this is HttpException || this is JsonParseException inline fun ifExpected( t: Throwable, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt index 89afefecda..2746b1acf8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt @@ -15,6 +15,7 @@ package com.keylesspalace.tusky.components.timeline.viewmodel +import android.util.Log import androidx.paging.ExperimentalPagingApi import androidx.paging.LoadType import androidx.paging.PagingState @@ -117,6 +118,7 @@ class CachedTimelineRemoteMediator( return MediatorResult.Success(endOfPaginationReached = statuses.isEmpty()) } catch (e: Exception) { return ifExpected(e) { + Log.w(TAG, "Failed to load timeline", e) MediatorResult.Error(e) } } @@ -175,4 +177,8 @@ class CachedTimelineRemoteMediator( } return overlappedStatuses } + + companion object { + private const val TAG = "CachedTimelineRM" + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt index 98da15beaa..a80ca95da8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt @@ -15,6 +15,7 @@ package com.keylesspalace.tusky.components.timeline.viewmodel +import android.util.Log import androidx.paging.ExperimentalPagingApi import androidx.paging.LoadType import androidx.paging.PagingState @@ -32,6 +33,14 @@ class NetworkTimelineRemoteMediator( private val viewModel: NetworkTimelineViewModel ) : RemoteMediator() { + private val statusIds = mutableSetOf() + + init { + if (viewModel.kind == TimelineViewModel.Kind.PUBLIC_TRENDING_STATUSES) { + statusIds.addAll(viewModel.statusData.map { it.id }) + } + } + override suspend fun load( loadType: LoadType, state: PagingState @@ -87,6 +96,10 @@ class NetworkTimelineRemoteMediator( false } + if (viewModel.kind == TimelineViewModel.Kind.PUBLIC_TRENDING_STATUSES) { + statusIds.addAll(data.map { it.id }) + } + viewModel.statusData.addAll(0, data) if (insertPlaceholder) { @@ -95,19 +108,35 @@ class NetworkTimelineRemoteMediator( } else { val linkHeader = statusResponse.headers()["Link"] val links = HttpHeaderLink.parse(linkHeader) - val nextId = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter("max_id") + val next = HttpHeaderLink.findByRelationType(links, "next") - viewModel.nextKey = nextId + var filteredData = data + if (viewModel.kind == TimelineViewModel.Kind.PUBLIC_TRENDING_STATUSES) { + // Trending statuses use offset for paging, not IDs. If a new status has been added to the remote + // feed after we performed the initial fetch, then the feed will have moved, but our offset won't. + // As a result, we'd get repeat statuses. This addresses that. + filteredData = data.filter { !statusIds.contains(it.id) } + statusIds.addAll(filteredData.map { it.id }) - viewModel.statusData.addAll(data) + viewModel.nextKey = next?.uri?.getQueryParameter("offset") + } else { + viewModel.nextKey = next?.uri?.getQueryParameter("max_id") + } + + viewModel.statusData.addAll(filteredData) } viewModel.currentSource?.invalidate() return MediatorResult.Success(endOfPaginationReached = statuses.isEmpty()) } catch (e: Exception) { return ifExpected(e) { + Log.w(TAG, "Failed to load timeline", e) MediatorResult.Error(e) } } } + + companion object { + private const val TAG = "NetworkTimelineRM" + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt index 06d0426239..5b66680159 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt @@ -315,6 +315,7 @@ class NetworkTimelineViewModel @Inject constructor( Kind.FAVOURITES -> api.favourites(fromId, uptoId, limit) Kind.BOOKMARKS -> api.bookmarks(fromId, uptoId, limit) Kind.LIST -> api.listTimeline(id!!, fromId, uptoId, limit) + Kind.PUBLIC_TRENDING_STATUSES -> api.trendingStatuses(limit = limit, offset = fromId) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt index 41a24ceeb7..7df0cb3322 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt @@ -335,12 +335,12 @@ abstract class TimelineViewModel( } enum class Kind { - HOME, PUBLIC_LOCAL, PUBLIC_FEDERATED, TAG, USER, USER_PINNED, USER_WITH_REPLIES, FAVOURITES, LIST, BOOKMARKS; + HOME, PUBLIC_LOCAL, PUBLIC_FEDERATED, TAG, USER, USER_PINNED, USER_WITH_REPLIES, FAVOURITES, LIST, BOOKMARKS, PUBLIC_TRENDING_STATUSES; fun toFilterKind(): Filter.Kind { return when (valueOf(name)) { HOME, LIST -> Filter.Kind.HOME - PUBLIC_FEDERATED, PUBLIC_LOCAL, TAG, FAVOURITES -> Filter.Kind.PUBLIC + PUBLIC_FEDERATED, PUBLIC_LOCAL, TAG, FAVOURITES, PUBLIC_TRENDING_STATUSES -> Filter.Kind.PUBLIC USER, USER_WITH_REPLIES, USER_PINNED -> Filter.Kind.ACCOUNT else -> Filter.Kind.PUBLIC } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt index 928eaea65a..ea12c91771 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt @@ -98,18 +98,18 @@ class ViewThreadFragment : val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) val statusDisplayOptions = StatusDisplayOptions( - animateAvatars = preferences.getBoolean("animateGifAvatars", false), + animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), mediaPreviewEnabled = accountManager.activeAccount!!.mediaPreviewEnabled, - useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false), - showBotOverlay = preferences.getBoolean("showBotOverlay", true), - useBlurhash = preferences.getBoolean("useBlurhash", true), - cardViewMode = if (preferences.getBoolean("showCardsInTimelines", false)) { + useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false), + showBotOverlay = preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true), + useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true), + cardViewMode = if (preferences.getBoolean(PrefKeys.SHOW_CARDS_IN_TIMELINES, false)) { CardViewMode.INDENTED } else { CardViewMode.NONE }, - confirmReblogs = preferences.getBoolean("confirmReblogs", true), - confirmFavourites = preferences.getBoolean("confirmFavourites", false), + confirmReblogs = preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true), + confirmFavourites = preferences.getBoolean(PrefKeys.CONFIRM_FAVOURITES, false), hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), showStatsInline = preferences.getBoolean(PrefKeys.SHOW_STATS_INLINE, false), diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt index 214ddcb487..f60c78f8a4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt @@ -29,11 +29,11 @@ import com.keylesspalace.tusky.components.account.AccountActivity import com.keylesspalace.tusky.components.accountlist.AccountListActivity import com.keylesspalace.tusky.components.announcements.AnnouncementsActivity import com.keylesspalace.tusky.components.compose.ComposeActivity +import com.keylesspalace.tusky.components.domainblocks.DomainBlocksActivity import com.keylesspalace.tusky.components.drafts.DraftsActivity import com.keylesspalace.tusky.components.filters.EditFilterActivity import com.keylesspalace.tusky.components.filters.FiltersActivity import com.keylesspalace.tusky.components.followedtags.FollowedTagsActivity -import com.keylesspalace.tusky.components.instancemute.InstanceListActivity import com.keylesspalace.tusky.components.login.LoginActivity import com.keylesspalace.tusky.components.login.LoginWebViewActivity import com.keylesspalace.tusky.components.preference.PreferencesActivity @@ -113,7 +113,7 @@ abstract class ActivitiesModule { abstract fun contributesReportActivity(): ReportActivity @ContributesAndroidInjector(modules = [FragmentBuildersModule::class]) - abstract fun contributesInstanceListActivity(): InstanceListActivity + abstract fun contributesInstanceListActivity(): DomainBlocksActivity @ContributesAndroidInjector abstract fun contributesScheduledStatusActivity(): ScheduledStatusActivity diff --git a/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt index 7629cff9f3..825eff6cfc 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt @@ -20,8 +20,7 @@ import com.keylesspalace.tusky.components.account.list.ListsForAccountFragment import com.keylesspalace.tusky.components.account.media.AccountMediaFragment import com.keylesspalace.tusky.components.accountlist.AccountListFragment import com.keylesspalace.tusky.components.conversation.ConversationsFragment -import com.keylesspalace.tusky.components.instancemute.fragment.InstanceListFragment -import com.keylesspalace.tusky.components.notifications.NotificationsFragment +import com.keylesspalace.tusky.components.domainblocks.DomainBlocksFragment import com.keylesspalace.tusky.components.preference.AccountPreferencesFragment import com.keylesspalace.tusky.components.preference.NotificationPreferencesFragment import com.keylesspalace.tusky.components.preference.PreferencesFragment @@ -35,6 +34,7 @@ import com.keylesspalace.tusky.components.timeline.TimelineFragment import com.keylesspalace.tusky.components.trending.TrendingTagsFragment import com.keylesspalace.tusky.components.viewthread.ViewThreadFragment import com.keylesspalace.tusky.components.viewthread.edits.ViewEditsFragment +import com.keylesspalace.tusky.fragment.NotificationsFragment import com.keylesspalace.tusky.fragment.ViewVideoFragment import dagger.Module import dagger.android.ContributesAndroidInjector @@ -81,7 +81,7 @@ abstract class FragmentBuildersModule { abstract fun reportDoneFragment(): ReportDoneFragment @ContributesAndroidInjector - abstract fun instanceListFragment(): InstanceListFragment + abstract fun instanceListFragment(): DomainBlocksFragment @ContributesAndroidInjector abstract fun searchStatusesFragment(): SearchStatusesFragment diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt index ae69e1dd9f..dc4d034492 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt @@ -27,12 +27,12 @@ import com.keylesspalace.tusky.components.account.media.AccountMediaViewModel import com.keylesspalace.tusky.components.announcements.AnnouncementsViewModel import com.keylesspalace.tusky.components.compose.ComposeViewModel import com.keylesspalace.tusky.components.conversation.ConversationsViewModel +import com.keylesspalace.tusky.components.domainblocks.DomainBlocksViewModel import com.keylesspalace.tusky.components.drafts.DraftsViewModel import com.keylesspalace.tusky.components.filters.EditFilterViewModel import com.keylesspalace.tusky.components.filters.FiltersViewModel import com.keylesspalace.tusky.components.followedtags.FollowedTagsViewModel import com.keylesspalace.tusky.components.login.LoginWebViewViewModel -import com.keylesspalace.tusky.components.notifications.NotificationsViewModel import com.keylesspalace.tusky.components.report.ReportViewModel import com.keylesspalace.tusky.components.scheduled.ScheduledStatusViewModel import com.keylesspalace.tusky.components.search.SearchViewModel @@ -165,11 +165,6 @@ abstract class ViewModelModule { @ViewModelKey(ListsForAccountViewModel::class) internal abstract fun listsForAccountViewModel(viewModel: ListsForAccountViewModel): ViewModel - @Binds - @IntoMap - @ViewModelKey(NotificationsViewModel::class) - internal abstract fun notificationsViewModel(viewModel: NotificationsViewModel): ViewModel - @Binds @IntoMap @ViewModelKey(TrendingTagsViewModel::class) @@ -185,5 +180,10 @@ abstract class ViewModelModule { @ViewModelKey(EditFilterViewModel::class) internal abstract fun editFilterViewModel(viewModel: EditFilterViewModel): ViewModel + @Binds + @IntoMap + @ViewModelKey(DomainBlocksViewModel::class) + internal abstract fun instanceMuteViewModel(viewModel: DomainBlocksViewModel): ViewModel + // Add more ViewModels here } diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt index a7007e57c4..05f992e164 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt @@ -110,6 +110,9 @@ data class Notification( } } + /** Helper for Java */ + fun copyWithStatus(status: Status?): Notification = copy(status = status) + // for Pleroma compatibility that uses Mention type fun rewriteToStatusTypeIfNeeded(accountId: String): Notification { if (type == Type.MENTION && status != null) { diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java new file mode 100644 index 0000000000..f6df6a8552 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java @@ -0,0 +1,1287 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.fragment; + +import static com.keylesspalace.tusky.util.StringUtils.isLessThan; +import static autodispose2.AutoDispose.autoDisposable; +import static autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from; + +import android.app.Activity; +import android.content.Context; +import android.content.DialogInterface; +import android.content.SharedPreferences; +import android.os.Bundle; +import android.util.Log; +import android.util.SparseBooleanArray; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.ListView; +import android.widget.PopupWindow; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.arch.core.util.Function; +import androidx.coordinatorlayout.widget.CoordinatorLayout; +import androidx.core.util.Pair; +import androidx.core.view.MenuProvider; +import androidx.lifecycle.Lifecycle; +import androidx.preference.PreferenceManager; +import androidx.recyclerview.widget.AsyncDifferConfig; +import androidx.recyclerview.widget.AsyncListDiffer; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.DividerItemDecoration; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.ListUpdateCallback; +import androidx.recyclerview.widget.RecyclerView; +import androidx.recyclerview.widget.SimpleItemAnimator; +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; + +import com.google.android.material.appbar.AppBarLayout; +import com.google.android.material.floatingactionbutton.FloatingActionButton; +import com.keylesspalace.tusky.R; +import com.keylesspalace.tusky.adapter.NotificationsAdapter; +import com.keylesspalace.tusky.adapter.StatusBaseViewHolder; +import com.keylesspalace.tusky.appstore.BlockEvent; +import com.keylesspalace.tusky.appstore.BookmarkEvent; +import com.keylesspalace.tusky.appstore.EventHub; +import com.keylesspalace.tusky.appstore.FavoriteEvent; +import com.keylesspalace.tusky.appstore.PinEvent; +import com.keylesspalace.tusky.appstore.PreferenceChangedEvent; +import com.keylesspalace.tusky.appstore.ReblogEvent; +import com.keylesspalace.tusky.components.notifications.NotificationHelper; +import com.keylesspalace.tusky.databinding.FragmentTimelineNotificationsBinding; +import com.keylesspalace.tusky.db.AccountEntity; +import com.keylesspalace.tusky.db.AccountManager; +import com.keylesspalace.tusky.di.Injectable; +import com.keylesspalace.tusky.entity.Notification; +import com.keylesspalace.tusky.entity.Poll; +import com.keylesspalace.tusky.entity.Relationship; +import com.keylesspalace.tusky.entity.Status; +import com.keylesspalace.tusky.interfaces.AccountActionListener; +import com.keylesspalace.tusky.interfaces.ActionButtonActivity; +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.Either; +import com.keylesspalace.tusky.util.HttpHeaderLink; +import com.keylesspalace.tusky.util.LinkHelper; +import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate; +import com.keylesspalace.tusky.util.ListUtils; +import com.keylesspalace.tusky.util.NotificationTypeConverterKt; +import com.keylesspalace.tusky.util.PairedList; +import com.keylesspalace.tusky.util.StatusDisplayOptions; +import com.keylesspalace.tusky.util.ViewDataUtils; +import com.keylesspalace.tusky.view.EndlessOnScrollListener; +import com.keylesspalace.tusky.viewdata.AttachmentViewData; +import com.keylesspalace.tusky.viewdata.NotificationViewData; +import com.keylesspalace.tusky.viewdata.StatusViewData; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +import javax.inject.Inject; + +import at.connyduck.sparkbutton.helpers.Utils; +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; +import io.reactivex.rxjava3.core.Observable; +import io.reactivex.rxjava3.core.Single; +import io.reactivex.rxjava3.disposables.CompositeDisposable; +import io.reactivex.rxjava3.disposables.Disposable; +import kotlin.Unit; +import kotlin.collections.CollectionsKt; +import kotlin.jvm.functions.Function1; + +public class NotificationsFragment extends SFragment implements + SwipeRefreshLayout.OnRefreshListener, + StatusActionListener, + NotificationsAdapter.NotificationActionListener, + AccountActionListener, + Injectable, + MenuProvider, + ReselectableFragment { + private static final String TAG = "NotificationF"; // logging tag + + private static final int LOAD_AT_ONCE = 30; + private int maxPlaceholderId = 0; + + private final Set notificationFilter = new HashSet<>(); + + private final CompositeDisposable disposables = new CompositeDisposable(); + + private enum FetchEnd { + TOP, + BOTTOM, + MIDDLE + } + + /** + * Placeholder for the notificationsEnabled. Consider moving to the separate class to hide constructor + * and reuse in different places as needed. + */ + private static final class Placeholder { + final long id; + + public static Placeholder getInstance(long id) { + return new Placeholder(id); + } + + private Placeholder(long id) { + this.id = id; + } + } + + @Inject + AccountManager accountManager; + @Inject + EventHub eventHub; + + private FragmentTimelineNotificationsBinding binding; + + private LinearLayoutManager layoutManager; + private EndlessOnScrollListener scrollListener; + private NotificationsAdapter adapter; + private boolean hideFab; + private boolean topLoading; + private boolean bottomLoading; + private String bottomId; + private boolean alwaysShowSensitiveMedia; + private boolean alwaysOpenSpoiler; + private boolean showNotificationsFilter; + private boolean showingError; + + // Each element is either a Notification for loading data or a Placeholder + private final PairedList, NotificationViewData> notifications + = new PairedList<>(new Function<>() { + @Override + public NotificationViewData apply(Either input) { + if (input.isRight()) { + Notification notification = input.asRight() + .rewriteToStatusTypeIfNeeded(accountManager.getActiveAccount().getAccountId()); + + boolean sensitiveStatus = notification.getStatus() != null && notification.getStatus().getActionableStatus().getSensitive(); + + return ViewDataUtils.notificationToViewData( + notification, + alwaysShowSensitiveMedia || !sensitiveStatus, + alwaysOpenSpoiler, + true + ); + } else { + return new NotificationViewData.Placeholder(input.asLeft().id, false); + } + } + }); + + public static NotificationsFragment newInstance() { + NotificationsFragment fragment = new NotificationsFragment(); + Bundle arguments = new Bundle(); + fragment.setArguments(arguments); + return fragment; + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + requireActivity().addMenuProvider(this, getViewLifecycleOwner(), Lifecycle.State.RESUMED); + + binding = FragmentTimelineNotificationsBinding.inflate(inflater, container, false); + + @NonNull Context context = inflater.getContext(); // from inflater to silence warning + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(requireContext()); + + boolean showNotificationsFilterSetting = preferences.getBoolean("showNotificationsFilter", true); + // Clear notifications on filter visibility change to force refresh + if (showNotificationsFilterSetting != showNotificationsFilter) + notifications.clear(); + showNotificationsFilter = showNotificationsFilterSetting; + + // Setup the SwipeRefreshLayout. + binding.swipeRefreshLayout.setOnRefreshListener(this); + binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue); + + loadNotificationsFilter(); + + // Setup the RecyclerView. + binding.recyclerView.setHasFixedSize(true); + layoutManager = new LinearLayoutManager(context); + binding.recyclerView.setLayoutManager(layoutManager); + binding.recyclerView.setAccessibilityDelegateCompat( + new ListStatusAccessibilityDelegate(binding.recyclerView, this, (pos) -> { + NotificationViewData notification = notifications.getPairedItemOrNull(pos); + // We support replies only for now + if (notification instanceof NotificationViewData.Concrete) { + return ((NotificationViewData.Concrete) notification).getStatusViewData(); + } else { + return null; + } + })); + + binding.recyclerView.addItemDecoration(new DividerItemDecoration(context, DividerItemDecoration.VERTICAL)); + + StatusDisplayOptions statusDisplayOptions = new StatusDisplayOptions( + preferences.getBoolean("animateGifAvatars", false), + accountManager.getActiveAccount().getMediaPreviewEnabled(), + preferences.getBoolean("absoluteTimeView", false), + preferences.getBoolean("showBotOverlay", true), + preferences.getBoolean("useBlurhash", true), + CardViewMode.NONE, + preferences.getBoolean("confirmReblogs", true), + preferences.getBoolean("confirmFavourites", false), + preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), + preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), + preferences.getBoolean(PrefKeys.SHOW_STATS_INLINE, false), + accountManager.getActiveAccount().getAlwaysShowSensitiveMedia(), + accountManager.getActiveAccount().getAlwaysOpenSpoiler() + ); + + adapter = new NotificationsAdapter(accountManager.getActiveAccount().getAccountId(), + dataSource, statusDisplayOptions, this, this, this); + alwaysShowSensitiveMedia = accountManager.getActiveAccount().getAlwaysShowSensitiveMedia(); + alwaysOpenSpoiler = accountManager.getActiveAccount().getAlwaysOpenSpoiler(); + binding.recyclerView.setAdapter(adapter); + + topLoading = false; + bottomLoading = false; + bottomId = null; + + updateAdapter(); + + binding.buttonClear.setOnClickListener(v -> confirmClearNotifications()); + binding.buttonFilter.setOnClickListener(v -> showFilterMenu()); + + if (notifications.isEmpty()) { + binding.swipeRefreshLayout.setEnabled(false); + sendFetchNotificationsRequest(null, null, FetchEnd.BOTTOM, -1); + } else { + binding.progressBar.setVisibility(View.GONE); + } + + ((SimpleItemAnimator) binding.recyclerView.getItemAnimator()).setSupportsChangeAnimations(false); + + updateFilterVisibility(); + + return binding.getRoot(); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + binding = null; + } + + @Override + public void onCreateMenu(@NonNull Menu menu, @NonNull MenuInflater menuInflater) { + menuInflater.inflate(R.menu.fragment_notifications, menu); + } + + @Override + public boolean onMenuItemSelected(@NonNull MenuItem menuItem) { + if (menuItem.getItemId() == R.id.action_refresh) { + binding.swipeRefreshLayout.setRefreshing(true); + onRefresh(); + return true; + } else if (menuItem.getItemId() == R.id.action_edit_notification_filter) { + showFilterMenu(); + return true; + } else if (menuItem.getItemId() == R.id.action_clear_notifications) { + confirmClearNotifications(); + return true; + } + + return false; + } + + private void updateFilterVisibility() { + CoordinatorLayout.LayoutParams params = + (CoordinatorLayout.LayoutParams) binding.swipeRefreshLayout.getLayoutParams(); + if (showNotificationsFilter && !showingError) { + binding.appBarOptions.setExpanded(true, false); + binding.appBarOptions.setVisibility(View.VISIBLE); + // Set content behaviour to hide filter on scroll + params.setBehavior(new AppBarLayout.ScrollingViewBehavior()); + } else { + binding.appBarOptions.setExpanded(false, false); + binding.appBarOptions.setVisibility(View.GONE); + // Clear behaviour to hide app bar + params.setBehavior(null); + } + } + + private void confirmClearNotifications() { + new AlertDialog.Builder(requireContext()) + .setMessage(R.string.notification_clear_text) + .setPositiveButton(android.R.string.ok, (DialogInterface dia, int which) -> clearNotifications()) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + Activity activity = getActivity(); + if (activity == null) throw new AssertionError("Activity is null"); + + // This is delayed until onActivityCreated solely because MainActivity.composeButton + // isn't guaranteed to be set until then. + // Use a modified scroll listener that both loads more notificationsEnabled as it + // goes, and hides the compose button on down-scroll. + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(activity); + hideFab = preferences.getBoolean("fabHide", false); + scrollListener = new EndlessOnScrollListener(layoutManager) { + @Override + public void onScrolled(@NonNull RecyclerView view, int dx, int dy) { + super.onScrolled(view, dx, dy); + + ActionButtonActivity activity = (ActionButtonActivity) getActivity(); + FloatingActionButton composeButton = activity.getActionButton(); + + if (composeButton != null) { + if (hideFab) { + if (dy > 0 && composeButton.isShown()) { + composeButton.hide(); // Hides the button if we're scrolling down + } else if (dy < 0 && !composeButton.isShown()) { + composeButton.show(); // Shows it if we are scrolling up + } + } else if (!composeButton.isShown()) { + composeButton.show(); + } + } + } + + @Override + public void onLoadMore(int totalItemsCount, @NonNull RecyclerView view) { + NotificationsFragment.this.onLoadMore(); + } + }; + + binding.recyclerView.addOnScrollListener(scrollListener); + + eventHub.getEventsObservable() + .observeOn(AndroidSchedulers.mainThread()) + .to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) + .subscribe(event -> { + if (event instanceof FavoriteEvent) { + setFavouriteForStatus(((FavoriteEvent) event).getStatusId(), ((FavoriteEvent) event).getFavourite()); + } else if (event instanceof BookmarkEvent) { + setBookmarkForStatus(((BookmarkEvent) event).getStatusId(), ((BookmarkEvent) event).getBookmark()); + } else if (event instanceof ReblogEvent) { + setReblogForStatus(((ReblogEvent) event).getStatusId(), ((ReblogEvent) event).getReblog()); + } else if (event instanceof PinEvent) { + setPinForStatus(((PinEvent) event).getStatusId(), ((PinEvent) event).getPinned()); + } else if (event instanceof BlockEvent) { + removeAllByAccountId(((BlockEvent) event).getAccountId()); + } else if (event instanceof PreferenceChangedEvent) { + onPreferenceChanged(((PreferenceChangedEvent) event).getPreferenceKey()); + } + }); + } + + @Override + public void onRefresh() { + binding.statusView.setVisibility(View.GONE); + this.showingError = false; + Either first = CollectionsKt.firstOrNull(this.notifications); + String topId; + if (first != null && first.isRight()) { + topId = first.asRight().getId(); + } else { + topId = null; + } + sendFetchNotificationsRequest(null, topId, FetchEnd.TOP, -1); + } + + @Override + public void onReply(int position) { + super.reply(notifications.get(position).asRight().getStatus()); + } + + @Override + public void onReblog(final boolean reblog, final int position) { + final Notification notification = notifications.get(position).asRight(); + final Status status = notification.getStatus(); + Objects.requireNonNull(status, "Reblog on notification without status"); + timelineCases.reblogOld(status.getId(), reblog) + .observeOn(AndroidSchedulers.mainThread()) + .to(autoDisposable(from(this))) + .subscribe( + (newStatus) -> setReblogForStatus(status.getId(), reblog), + (t) -> Log.d(getClass().getSimpleName(), + "Failed to reblog status: " + status.getId(), t) + ); + } + + private void setReblogForStatus(String statusId, boolean reblog) { + updateStatus(statusId, (s) -> s.copyWithReblogged(reblog)); + } + + @Override + public void onFavourite(final boolean favourite, final int position) { + final Notification notification = notifications.get(position).asRight(); + final Status status = notification.getStatus(); + + timelineCases.favouriteOld(status.getId(), favourite) + .observeOn(AndroidSchedulers.mainThread()) + .to(autoDisposable(from(this))) + .subscribe( + (newStatus) -> setFavouriteForStatus(status.getId(), favourite), + (t) -> Log.d(getClass().getSimpleName(), + "Failed to favourite status: " + status.getId(), t) + ); + } + + private void setFavouriteForStatus(String statusId, boolean favourite) { + updateStatus(statusId, (s) -> s.copyWithFavourited(favourite)); + } + + @Override + public void onBookmark(final boolean bookmark, final int position) { + final Notification notification = notifications.get(position).asRight(); + final Status status = notification.getStatus(); + + timelineCases.bookmarkOld(status.getActionableId(), bookmark) + .observeOn(AndroidSchedulers.mainThread()) + .to(autoDisposable(from(this))) + .subscribe( + (newStatus) -> setBookmarkForStatus(status.getId(), bookmark), + (t) -> Log.d(getClass().getSimpleName(), + "Failed to bookmark status: " + status.getId(), t) + ); + } + + private void setBookmarkForStatus(String statusId, boolean bookmark) { + updateStatus(statusId, (s) -> s.copyWithBookmarked(bookmark)); + } + + public void onVoteInPoll(int position, @NonNull List choices) { + final Notification notification = notifications.get(position).asRight(); + final Status status = notification.getStatus().getActionableStatus(); + timelineCases.voteInPollOld(status.getId(), status.getPoll().getId(), choices) + .observeOn(AndroidSchedulers.mainThread()) + .to(autoDisposable(from(this))) + .subscribe( + (newPoll) -> setVoteForPoll(status, newPoll), + (t) -> Log.d(TAG, + "Failed to vote in poll: " + status.getId(), t) + ); + } + + @Override + public void clearWarningAction(int position) { + + } + + private void setVoteForPoll(Status status, Poll poll) { + updateStatus(status.getId(), (s) -> s.copyWithPoll(poll)); + } + + @Override + public void onMore(@NonNull View view, int position) { + Notification notification = notifications.get(position).asRight(); + super.more(notification.getStatus(), view, position); + } + + @Override + public void onViewMedia(int position, int attachmentIndex, @Nullable View view) { + Notification notification = notifications.get(position).asRightOrNull(); + if (notification == null || notification.getStatus() == null) return; + Status status = notification.getStatus(); + super.viewMedia(attachmentIndex, AttachmentViewData.list(status, accountManager.getActiveAccount().getAlwaysShowSensitiveMedia()), view); + } + + @Override + public void onViewThread(int position) { + Notification notification = notifications.get(position).asRight(); + Status status = notification.getStatus(); + if (status == null) return; + super.viewThread(status.getActionableId(), status.getActionableStatus().getUrl()); + } + + @Override + public void onOpenReblog(int position) { + Notification notification = notifications.get(position).asRight(); + onViewAccount(notification.getAccount().getId()); + } + + @Override + public void onExpandedChange(boolean expanded, int position) { + updateViewDataAt(position, (vd) -> vd.copyWithExpanded(expanded)); + } + + @Override + public void onContentHiddenChange(boolean isShowing, int position) { + updateViewDataAt(position, (vd) -> vd.copyWithShowingContent(isShowing)); + } + + private void setPinForStatus(String statusId, boolean pinned) { + updateStatus(statusId, status -> status.copyWithPinned(pinned)); + } + + @Override + public void onLoadMore(int position) { + // Check bounds before accessing list, + if (notifications.size() >= position && position > 0) { + Notification previous = notifications.get(position - 1).asRightOrNull(); + Notification next = notifications.get(position + 1).asRightOrNull(); + if (previous == null || next == null) { + Log.e(TAG, "Failed to load more, invalid placeholder position: " + position); + return; + } + sendFetchNotificationsRequest(previous.getId(), next.getId(), FetchEnd.MIDDLE, position); + Placeholder placeholder = notifications.get(position).asLeft(); + NotificationViewData notificationViewData = + new NotificationViewData.Placeholder(placeholder.id, true); + notifications.setPairedItem(position, notificationViewData); + updateAdapter(); + } else { + Log.d(TAG, "error loading more"); + } + } + + @Override + public void onContentCollapsedChange(boolean isCollapsed, int position) { + updateViewDataAt(position, (vd) -> vd.copyWithCollapsed(isCollapsed)); + } + + private void updateStatus(String statusId, Function mapper) { + int index = CollectionsKt.indexOfFirst(this.notifications, (s) -> s.isRight() && + s.asRight().getStatus() != null && + s.asRight().getStatus().getId().equals(statusId)); + if (index == -1) return; + + // We have quite some graph here: + // + // Notification --------> Status + // ^ + // | + // StatusViewData + // ^ + // | + // NotificationViewData -----+ + // + // So if we have "new" status we need to update all references to be sure that data is + // up-to-date: + // 1. update status + // 2. update notification + // 3. update statusViewData + // 4. update notificationViewData + + Status oldStatus = notifications.get(index).asRight().getStatus(); + NotificationViewData.Concrete oldViewData = + (NotificationViewData.Concrete) this.notifications.getPairedItem(index); + Status newStatus = mapper.apply(oldStatus); + Notification newNotification = this.notifications.get(index).asRight() + .copyWithStatus(newStatus); + StatusViewData.Concrete newStatusViewData = + Objects.requireNonNull(oldViewData.getStatusViewData()).copyWithStatus(newStatus); + NotificationViewData.Concrete newViewData = oldViewData.copyWithStatus(newStatusViewData); + + notifications.set(index, new Either.Right<>(newNotification)); + notifications.setPairedItem(index, newViewData); + + updateAdapter(); + } + + private void updateViewDataAt(int position, + Function mapper) { + if (position < 0 || position >= notifications.size()) { + String message = String.format( + Locale.getDefault(), + "Tried to access out of bounds status position: %d of %d", + position, + notifications.size() - 1 + ); + Log.e(TAG, message); + return; + } + NotificationViewData someViewData = this.notifications.getPairedItem(position); + if (!(someViewData instanceof NotificationViewData.Concrete)) { + return; + } + NotificationViewData.Concrete oldViewData = (NotificationViewData.Concrete) someViewData; + StatusViewData.Concrete oldStatusViewData = oldViewData.getStatusViewData(); + if (oldStatusViewData == null) return; + + NotificationViewData.Concrete newViewData = + oldViewData.copyWithStatus(mapper.apply(oldStatusViewData)); + notifications.setPairedItem(position, newViewData); + + updateAdapter(); + } + + @Override + public void onNotificationContentCollapsedChange(boolean isCollapsed, int position) { + onContentCollapsedChange(isCollapsed, position); + } + + private void clearNotifications() { + // Cancel all ongoing requests + binding.swipeRefreshLayout.setRefreshing(false); + resetNotificationsLoad(); + + // Show friend elephant + binding.statusView.setVisibility(View.VISIBLE); + binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null); + updateFilterVisibility(); + + // Update adapter + updateAdapter(); + + // Execute clear notifications request + mastodonApi.clearNotificationsOld() + .observeOn(AndroidSchedulers.mainThread()) + .to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) + .subscribe( + response -> { + // Nothing to do + }, + throwable -> { + // Reload notifications on failure + fullyRefreshWithProgressBar(true); + }); + } + + private void resetNotificationsLoad() { + disposables.clear(); + bottomLoading = false; + topLoading = false; + + // Disable load more + bottomId = null; + + // Clear exists notifications + notifications.clear(); + } + + + private void showFilterMenu() { + List notificationsList = Notification.Type.Companion.getVisibleTypes(); + List list = new ArrayList<>(); + for (Notification.Type type : notificationsList) { + list.add(getNotificationText(type)); + } + + ArrayAdapter adapter = new ArrayAdapter<>(getContext(), android.R.layout.simple_list_item_multiple_choice, list); + PopupWindow window = new PopupWindow(getContext()); + View view = LayoutInflater.from(getContext()).inflate(R.layout.notifications_filter, (ViewGroup) getView(), false); + final ListView listView = view.findViewById(R.id.listView); + view.findViewById(R.id.buttonApply) + .setOnClickListener(v -> { + SparseBooleanArray checkedItems = listView.getCheckedItemPositions(); + Set excludes = new HashSet<>(); + for (int i = 0; i < notificationsList.size(); i++) { + if (!checkedItems.get(i, false)) + excludes.add(notificationsList.get(i)); + } + window.dismiss(); + applyFilterChanges(excludes); + + }); + + listView.setAdapter(adapter); + listView.setChoiceMode(ListView.CHOICE_MODE_MULTIPLE); + for (int i = 0; i < notificationsList.size(); i++) { + if (!notificationFilter.contains(notificationsList.get(i))) + listView.setItemChecked(i, true); + } + window.setContentView(view); + window.setFocusable(true); + window.setWidth(ViewGroup.LayoutParams.WRAP_CONTENT); + window.setHeight(ViewGroup.LayoutParams.WRAP_CONTENT); + window.showAsDropDown(binding.buttonFilter); + + } + + private String getNotificationText(Notification.Type type) { + switch (type) { + case MENTION: + return getString(R.string.notification_mention_name); + case FAVOURITE: + return getString(R.string.notification_favourite_name); + case REBLOG: + return getString(R.string.notification_boost_name); + case FOLLOW: + return getString(R.string.notification_follow_name); + case FOLLOW_REQUEST: + return getString(R.string.notification_follow_request_name); + case POLL: + return getString(R.string.notification_poll_name); + case STATUS: + return getString(R.string.notification_subscription_name); + case SIGN_UP: + return getString(R.string.notification_sign_up_name); + case UPDATE: + return getString(R.string.notification_update_name); + case REPORT: + return getString(R.string.notification_report_name); + default: + return "Unknown"; + } + } + + private void applyFilterChanges(Set newSet) { + List notifications = Notification.Type.Companion.getVisibleTypes(); + boolean isChanged = false; + for (Notification.Type type : notifications) { + if (notificationFilter.contains(type) && !newSet.contains(type)) { + notificationFilter.remove(type); + isChanged = true; + } else if (!notificationFilter.contains(type) && newSet.contains(type)) { + notificationFilter.add(type); + isChanged = true; + } + } + if (isChanged) { + saveNotificationsFilter(); + fullyRefreshWithProgressBar(true); + } + + } + + private void loadNotificationsFilter() { + AccountEntity account = accountManager.getActiveAccount(); + if (account != null) { + notificationFilter.clear(); + notificationFilter.addAll(NotificationTypeConverterKt.deserialize( + account.getNotificationsFilter())); + } + } + + private void saveNotificationsFilter() { + AccountEntity account = accountManager.getActiveAccount(); + if (account != null) { + account.setNotificationsFilter(NotificationTypeConverterKt.serialize(notificationFilter)); + accountManager.saveAccount(account); + } + } + + @Override + public void onViewTag(@NonNull String tag) { + super.viewTag(tag); + } + + @Override + public void onViewAccount(@NonNull String id) { + super.viewAccount(id); + } + + @Override + public void onMute(boolean mute, String id, int position, boolean notifications) { + // No muting from notifications yet + } + + @Override + public void onBlock(boolean block, String id, int position) { + // No blocking from notifications yet + } + + @Override + public void onRespondToFollowRequest(boolean accept, String id, int position) { + Single request = accept ? + mastodonApi.authorizeFollowRequest(id) : + mastodonApi.rejectFollowRequest(id); + request.observeOn(AndroidSchedulers.mainThread()) + .to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) + .subscribe( + (relationship) -> fullyRefreshWithProgressBar(true), + (error) -> Log.e(TAG, String.format("Failed to %s account id %s", accept ? "accept" : "reject", id)) + ); + } + + @Override + public void onViewStatusForNotificationId(String notificationId) { + for (Either either : notifications) { + Notification notification = either.asRightOrNull(); + if (notification != null && notification.getId().equals(notificationId)) { + Status status = notification.getStatus(); + if (status != null) { + super.viewThread(status.getActionableId(), status.getActionableStatus().getUrl()); + return; + } + } + } + Log.w(TAG, "Didn't find a notification for ID: " + notificationId); + } + + @Override + public void onViewReport(String reportId) { + LinkHelper.openLink(requireContext(), String.format("https://%s/admin/reports/%s", accountManager.getActiveAccount().getDomain(), reportId)); + } + + private void onPreferenceChanged(String key) { + switch (key) { + case "fabHide": { + hideFab = PreferenceManager.getDefaultSharedPreferences(requireContext()).getBoolean("fabHide", false); + break; + } + case "mediaPreviewEnabled": { + boolean enabled = accountManager.getActiveAccount().getMediaPreviewEnabled(); + if (enabled != adapter.isMediaPreviewEnabled()) { + adapter.setMediaPreviewEnabled(enabled); + fullyRefresh(); + } + break; + } + case "showNotificationsFilter": { + if (isAdded()) { + showNotificationsFilter = PreferenceManager.getDefaultSharedPreferences(requireContext()).getBoolean("showNotificationsFilter", true); + updateFilterVisibility(); + fullyRefreshWithProgressBar(true); + } + break; + } + } + } + + @Override + public void removeItem(int position) { + notifications.remove(position); + updateAdapter(); + } + + private void removeAllByAccountId(String accountId) { + // Using iterator to safely remove items while iterating + Iterator> iterator = notifications.iterator(); + while (iterator.hasNext()) { + Either notification = iterator.next(); + Notification maybeNotification = notification.asRightOrNull(); + if (maybeNotification != null && maybeNotification.getAccount().getId().equals(accountId)) { + iterator.remove(); + } + } + updateAdapter(); + } + + private void onLoadMore() { + if (bottomId == null) { + // Already loaded everything + return; + } + + // Check for out-of-bounds when loading + // This is required to allow full-timeline reloads of collapsible statuses when the settings + // change. + if (notifications.size() > 0) { + Either last = notifications.get(notifications.size() - 1); + if (last.isRight()) { + final Placeholder placeholder = newPlaceholder(); + notifications.add(new Either.Left<>(placeholder)); + NotificationViewData viewData = + new NotificationViewData.Placeholder(placeholder.id, true); + notifications.setPairedItem(notifications.size() - 1, viewData); + updateAdapter(); + } + } + + sendFetchNotificationsRequest(bottomId, null, FetchEnd.BOTTOM, -1); + } + + private Placeholder newPlaceholder() { + Placeholder placeholder = Placeholder.getInstance(maxPlaceholderId); + maxPlaceholderId--; + return placeholder; + } + + private void jumpToTop() { + if (isAdded()) { + //binding.appBarOptions.setExpanded(true, false); + layoutManager.scrollToPosition(0); + scrollListener.reset(); + } + } + + private void sendFetchNotificationsRequest(String fromId, String uptoId, + final FetchEnd fetchEnd, final int pos) { + // If there is a fetch already ongoing, record however many fetches are requested and + // fulfill them after it's complete. + if (fetchEnd == FetchEnd.TOP && topLoading) { + return; + } + if (fetchEnd == FetchEnd.BOTTOM && bottomLoading) { + return; + } + if (fetchEnd == FetchEnd.TOP) { + topLoading = true; + } + if (fetchEnd == FetchEnd.BOTTOM) { + bottomLoading = true; + } + + Disposable notificationCall = mastodonApi.notificationsOld(fromId, uptoId, LOAD_AT_ONCE, showNotificationsFilter ? notificationFilter : null) + .observeOn(AndroidSchedulers.mainThread()) + .to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) + .subscribe( + response -> { + if (response.isSuccessful()) { + String linkHeader = response.headers().get("Link"); + onFetchNotificationsSuccess(response.body(), linkHeader, fetchEnd, pos); + } else { + onFetchNotificationsFailure(new Exception(response.message()), fetchEnd, pos); + } + }, + throwable -> onFetchNotificationsFailure(throwable, fetchEnd, pos)); + disposables.add(notificationCall); + } + + private void onFetchNotificationsSuccess(List notifications, String linkHeader, + FetchEnd fetchEnd, int pos) { + List links = HttpHeaderLink.Companion.parse(linkHeader); + HttpHeaderLink next = HttpHeaderLink.Companion.findByRelationType(links, "next"); + String fromId = null; + if (next != null) { + fromId = next.getUri().getQueryParameter("max_id"); + } + + switch (fetchEnd) { + case TOP: { + update(notifications, this.notifications.isEmpty() ? fromId : null); + break; + } + case MIDDLE: { + replacePlaceholderWithNotifications(notifications, pos); + break; + } + case BOTTOM: { + + if (!this.notifications.isEmpty() + && !this.notifications.get(this.notifications.size() - 1).isRight()) { + this.notifications.remove(this.notifications.size() - 1); + updateAdapter(); + } + + if (adapter.getItemCount() > 1) { + addItems(notifications, fromId); + } else { + update(notifications, fromId); + } + + break; + } + } + + saveNewestNotificationId(notifications); + + if (fetchEnd == FetchEnd.TOP) { + topLoading = false; + } + if (fetchEnd == FetchEnd.BOTTOM) { + bottomLoading = false; + } + + if (notifications.size() == 0 && adapter.getItemCount() == 0) { + binding.statusView.setVisibility(View.VISIBLE); + binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null); + } + + updateFilterVisibility(); + binding.swipeRefreshLayout.setEnabled(true); + binding.swipeRefreshLayout.setRefreshing(false); + binding.progressBar.setVisibility(View.GONE); + } + + private void onFetchNotificationsFailure(Throwable throwable, FetchEnd fetchEnd, int position) { + binding.swipeRefreshLayout.setRefreshing(false); + if (fetchEnd == FetchEnd.MIDDLE && !notifications.get(position).isRight()) { + Placeholder placeholder = notifications.get(position).asLeft(); + NotificationViewData placeholderVD = + new NotificationViewData.Placeholder(placeholder.id, false); + notifications.setPairedItem(position, placeholderVD); + updateAdapter(); + } else if (this.notifications.isEmpty()) { + binding.statusView.setVisibility(View.VISIBLE); + binding.swipeRefreshLayout.setEnabled(false); + this.showingError = true; + if (throwable instanceof IOException) { + binding.statusView.setup(R.drawable.errorphant_offline, R.string.error_network, __ -> { + binding.progressBar.setVisibility(View.VISIBLE); + this.onRefresh(); + return Unit.INSTANCE; + }); + } else { + binding.statusView.setup(R.drawable.errorphant_error, R.string.error_generic, __ -> { + binding.progressBar.setVisibility(View.VISIBLE); + this.onRefresh(); + return Unit.INSTANCE; + }); + } + updateFilterVisibility(); + } + Log.e(TAG, "Fetch failure: " + throwable.getMessage()); + + if (fetchEnd == FetchEnd.TOP) { + topLoading = false; + } + if (fetchEnd == FetchEnd.BOTTOM) { + bottomLoading = false; + } + + binding.progressBar.setVisibility(View.GONE); + } + + private void saveNewestNotificationId(List notifications) { + + AccountEntity account = accountManager.getActiveAccount(); + if (account != null) { + String lastNotificationId = account.getLastNotificationId(); + + for (Notification noti : notifications) { + if (isLessThan(lastNotificationId, noti.getId())) { + lastNotificationId = noti.getId(); + } + } + + if (!account.getLastNotificationId().equals(lastNotificationId)) { + Log.d(TAG, "saving newest noti id: " + lastNotificationId); + account.setLastNotificationId(lastNotificationId); + accountManager.saveAccount(account); + } + } + } + + private void update(@Nullable List newNotifications, @Nullable String fromId) { + if (ListUtils.isEmpty(newNotifications)) { + updateAdapter(); + return; + } + if (fromId != null) { + bottomId = fromId; + } + List> liftedNew = + liftNotificationList(newNotifications); + if (notifications.isEmpty()) { + notifications.addAll(liftedNew); + } else { + int index = notifications.indexOf(liftedNew.get(newNotifications.size() - 1)); + if (index > 0) { + notifications.subList(0, index).clear(); + } + + int newIndex = liftedNew.indexOf(notifications.get(0)); + if (newIndex == -1) { + if (index == -1 && liftedNew.size() >= LOAD_AT_ONCE) { + liftedNew.add(new Either.Left<>(newPlaceholder())); + } + notifications.addAll(0, liftedNew); + } else { + notifications.addAll(0, liftedNew.subList(0, newIndex)); + } + } + updateAdapter(); + } + + private void addItems(List newNotifications, @Nullable String fromId) { + bottomId = fromId; + if (ListUtils.isEmpty(newNotifications)) { + return; + } + int end = notifications.size(); + List> liftedNew = liftNotificationList(newNotifications); + Either last = notifications.get(end - 1); + if (last != null && !liftedNew.contains(last)) { + notifications.addAll(liftedNew); + updateAdapter(); + } + } + + private void replacePlaceholderWithNotifications(List newNotifications, int pos) { + // Remove placeholder + notifications.remove(pos); + + if (ListUtils.isEmpty(newNotifications)) { + updateAdapter(); + return; + } + + List> liftedNew = liftNotificationList(newNotifications); + + // If we fetched less posts than in the limit, it means that the hole is not filled + // If we fetched at least as much it means that there are more posts to load and we should + // insert new placeholder + if (newNotifications.size() >= LOAD_AT_ONCE) { + liftedNew.add(new Either.Left<>(newPlaceholder())); + } + + notifications.addAll(pos, liftedNew); + updateAdapter(); + } + + private final Function1> notificationLifter = + Either.Right::new; + + private List> liftNotificationList(List list) { + return CollectionsKt.map(list, notificationLifter); + } + + private void fullyRefreshWithProgressBar(boolean isShow) { + resetNotificationsLoad(); + if (isShow) { + binding.progressBar.setVisibility(View.VISIBLE); + binding.statusView.setVisibility(View.GONE); + } + updateAdapter(); + sendFetchNotificationsRequest(null, null, FetchEnd.TOP, -1); + } + + private void fullyRefresh() { + fullyRefreshWithProgressBar(false); + } + + @Nullable + private Pair findReplyPosition(@NonNull String statusId) { + for (int i = 0; i < notifications.size(); i++) { + Notification notification = notifications.get(i).asRightOrNull(); + if (notification != null + && notification.getStatus() != null + && notification.getType() == Notification.Type.MENTION + && (statusId.equals(notification.getStatus().getId()) + || (notification.getStatus().getReblog() != null + && statusId.equals(notification.getStatus().getReblog().getId())))) { + return new Pair<>(i, notification); + } + } + return null; + } + + private void updateAdapter() { + differ.submitList(notifications.getPairedCopy()); + } + + private final ListUpdateCallback listUpdateCallback = new ListUpdateCallback() { + @Override + public void onInserted(int position, int count) { + if (isAdded()) { + adapter.notifyItemRangeInserted(position, count); + Context context = getContext(); + // scroll up when new items at the top are loaded while being at the start + // https://github.com/tuskyapp/Tusky/pull/1905#issuecomment-677819724 + if (position == 0 && context != null && adapter.getItemCount() != count) { + binding.recyclerView.scrollBy(0, Utils.dpToPx(context, -30)); + } + } + } + + @Override + public void onRemoved(int position, int count) { + adapter.notifyItemRangeRemoved(position, count); + } + + @Override + public void onMoved(int fromPosition, int toPosition) { + adapter.notifyItemMoved(fromPosition, toPosition); + } + + @Override + public void onChanged(int position, int count, Object payload) { + adapter.notifyItemRangeChanged(position, count, payload); + } + }; + + private final AsyncListDiffer + differ = new AsyncListDiffer<>(listUpdateCallback, + new AsyncDifferConfig.Builder<>(diffCallback).build()); + + private final NotificationsAdapter.AdapterDataSource dataSource = + new NotificationsAdapter.AdapterDataSource<>() { + @Override + public int getItemCount() { + return differ.getCurrentList().size(); + } + + @Override + public NotificationViewData getItemAt(int pos) { + return differ.getCurrentList().get(pos); + } + }; + + private static final DiffUtil.ItemCallback diffCallback + = new DiffUtil.ItemCallback<>() { + + @Override + public boolean areItemsTheSame(NotificationViewData oldItem, NotificationViewData newItem) { + return oldItem.getViewDataId() == newItem.getViewDataId(); + } + + @Override + public boolean areContentsTheSame(@NonNull NotificationViewData oldItem, @NonNull NotificationViewData newItem) { + return false; + } + + @Nullable + @Override + public Object getChangePayload(@NonNull NotificationViewData oldItem, @NonNull NotificationViewData newItem) { + if (oldItem.deepEquals(newItem)) { + // If items are equal - update timestamp only + return Collections.singletonList(StatusBaseViewHolder.Key.KEY_CREATED); + } else + // If items are different - update a whole view holder + return null; + } + }; + + @Override + public void onResume() { + super.onResume(); + + NotificationHelper.clearNotificationsForAccount(requireContext(), accountManager.getActiveAccount()); + + String rawAccountNotificationFilter = accountManager.getActiveAccount().getNotificationsFilter(); + Set accountNotificationFilter = NotificationTypeConverterKt.deserialize(rawAccountNotificationFilter); + if (!notificationFilter.equals(accountNotificationFilter)) { + loadNotificationsFilter(); + fullyRefreshWithProgressBar(true); + } + startUpdateTimestamp(); + } + + /** + * Start to update adapter every minute to refresh timestamp + * If setting absoluteTimeView is false + * Auto dispose observable on pause + */ + private void startUpdateTimestamp() { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(requireContext()); + boolean useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false); + if (!useAbsoluteTime) { + Observable.interval(0, 1, TimeUnit.MINUTES) + .observeOn(AndroidSchedulers.mainThread()) + .to(autoDisposable(from(this, Lifecycle.Event.ON_PAUSE))) + .subscribe( + interval -> updateAdapter() + ); + } + + } + + @Override + public void onReselect() { + jumpToTop(); + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.kt index b77af26aa4..6dc1010cfa 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.kt @@ -493,7 +493,7 @@ abstract class SFragment : Fragment(), Injectable { private fun requestDownloadAllMedia(status: Status) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { val permissions = arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE) - (activity as BaseActivity).requestPermissions(permissions) { _: Array?, grantResults: IntArray -> + (activity as BaseActivity).requestPermissions(permissions) { _, grantResults -> if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { downloadAllMedia(status) } else { diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewImageFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewImageFragment.kt index bbbfdf2193..2b7010329d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewImageFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewImageFragment.kt @@ -306,7 +306,7 @@ class ViewImageFragment : ViewMediaFragment() { override fun onLoadFailed( e: GlideException?, - model: Any, + model: Any?, target: Target, isFirstResource: Boolean ): Boolean { diff --git a/app/src/main/java/com/keylesspalace/tusky/interfaces/AccountActionListener.java b/app/src/main/java/com/keylesspalace/tusky/interfaces/AccountActionListener.kt similarity index 63% rename from app/src/main/java/com/keylesspalace/tusky/interfaces/AccountActionListener.java rename to app/src/main/java/com/keylesspalace/tusky/interfaces/AccountActionListener.kt index c353d0f3e8..654b50dd4a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/interfaces/AccountActionListener.java +++ b/app/src/main/java/com/keylesspalace/tusky/interfaces/AccountActionListener.kt @@ -12,12 +12,11 @@ * * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ +package com.keylesspalace.tusky.interfaces -package com.keylesspalace.tusky.interfaces; - -public interface AccountActionListener { - void onViewAccount(String id); - void onMute(final boolean mute, final String id, final int position, final boolean notifications); - void onBlock(final boolean block, final String id, final int position); - void onRespondToFollowRequest(final boolean accept, final String id, final int position); +interface AccountActionListener { + fun onViewAccount(id: String) + fun onMute(mute: Boolean, id: String, position: Int, notifications: Boolean) + fun onBlock(block: Boolean, id: String, position: Int) + fun onRespondToFollowRequest(accept: Boolean, id: String, position: Int) } diff --git a/app/src/main/java/com/keylesspalace/tusky/interfaces/PermissionRequester.java b/app/src/main/java/com/keylesspalace/tusky/interfaces/PermissionRequester.java deleted file mode 100644 index ca83e085ba..0000000000 --- a/app/src/main/java/com/keylesspalace/tusky/interfaces/PermissionRequester.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.keylesspalace.tusky.interfaces; - -public interface PermissionRequester { - void onRequestPermissionsResult(String[] permissions, int[] grantResults); -} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/interfaces/PermissionRequester.kt b/app/src/main/java/com/keylesspalace/tusky/interfaces/PermissionRequester.kt new file mode 100644 index 0000000000..d31bd1febf --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/interfaces/PermissionRequester.kt @@ -0,0 +1,5 @@ +package com.keylesspalace.tusky.interfaces + +fun interface PermissionRequester { + fun onRequestPermissionsResult(permissions: Array, grantResults: IntArray) +} diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt index c0fcd20630..07f10c9e2e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -90,6 +90,11 @@ interface MastodonApi { @GET("api/v1/filters") suspend fun getFiltersV1(): NetworkResult> + @GET("api/v2/filters/{filterId}") + suspend fun getFilter( + @Path("filterId") filterId: String + ): NetworkResult + @GET("api/v2/filters") suspend fun getFilters(): NetworkResult> @@ -140,6 +145,14 @@ interface MastodonApi { @Query("exclude_types[]") excludes: Set? = null ): Response> + @GET("api/v1/notifications") + fun notificationsOld( + @Query("max_id") maxId: String?, + @Query("since_id") sinceId: String?, + @Query("limit") limit: Int?, + @Query("exclude_types[]") excludes: Set? + ): Single>> + /** Fetch a single notification */ @GET("api/v1/notifications/{id}") suspend fun notification( @@ -173,6 +186,9 @@ interface MastodonApi { @POST("api/v1/notifications/clear") suspend fun clearNotifications(): Response + @POST("api/v1/notifications/clear") + fun clearNotificationsOld(): Single + @FormUrlEncoded @PUT("api/v1/media/{mediaId}") suspend fun updateMedia( @@ -280,6 +296,36 @@ interface MastodonApi { @Path("id") statusId: String ): NetworkResult + @POST("api/v1/statuses/{id}/reblog") + fun reblogStatusOld( + @Path("id") statusId: String + ): Single + + @POST("api/v1/statuses/{id}/unreblog") + fun unreblogStatusOld( + @Path("id") statusId: String + ): Single + + @POST("api/v1/statuses/{id}/favourite") + fun favouriteStatusOld( + @Path("id") statusId: String + ): Single + + @POST("api/v1/statuses/{id}/unfavourite") + fun unfavouriteStatusOld( + @Path("id") statusId: String + ): Single + + @POST("api/v1/statuses/{id}/bookmark") + fun bookmarkStatusOld( + @Path("id") statusId: String + ): Single + + @POST("api/v1/statuses/{id}/unbookmark") + fun unbookmarkStatusOld( + @Path("id") statusId: String + ): Single + @POST("api/v1/statuses/{id}/pin") suspend fun pinStatus( @Path("id") statusId: String @@ -300,6 +346,16 @@ interface MastodonApi { @Path("id") statusId: String ): NetworkResult + @POST("api/v1/statuses/{id}/mute") + fun muteConversationOld( + @Path("id") statusId: String + ): Single + + @POST("api/v1/statuses/{id}/unmute") + fun unmuteConversationOld( + @Path("id") statusId: String + ): Single + @GET("api/v1/scheduled_statuses") fun scheduledStatuses( @Query("limit") limit: Int? = null, @@ -458,11 +514,11 @@ interface MastodonApi { ): Response> @GET("api/v1/domain_blocks") - fun domainBlocks( + suspend fun domainBlocks( @Query("max_id") maxId: String? = null, @Query("since_id") sinceId: String? = null, @Query("limit") limit: Int? = null - ): Single>> + ): Response> @FormUrlEncoded @POST("api/v1/domain_blocks") @@ -671,6 +727,13 @@ interface MastodonApi { @Field("choices[]") choices: List ): NetworkResult + @FormUrlEncoded + @POST("api/v1/polls/{id}/votes") + fun voteInPollOld( + @Path("id") id: String, + @Field("choices[]") choices: List + ): Single + @GET("api/v1/announcements") suspend fun listAnnouncements( @Query("with_dismissed") withDismissed: Boolean = true @@ -791,4 +854,10 @@ interface MastodonApi { @GET("api/v1/trends/tags") suspend fun trendingTags(): NetworkResult> + + @GET("api/v1/trends/statuses") + suspend fun trendingStatuses( + @Query("limit") limit: Int? = null, + @Query("offset") offset: String? = null + ): Response> } diff --git a/app/src/main/java/com/keylesspalace/tusky/network/ProgressRequestBody.java b/app/src/main/java/com/keylesspalace/tusky/network/ProgressRequestBody.java deleted file mode 100644 index f499ed5ef4..0000000000 --- a/app/src/main/java/com/keylesspalace/tusky/network/ProgressRequestBody.java +++ /dev/null @@ -1,76 +0,0 @@ -/* Copyright 2017 Andrew Dawson - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . */ - -package com.keylesspalace.tusky.network; - -import androidx.annotation.NonNull; - -import java.io.IOException; -import java.io.InputStream; - -import okhttp3.MediaType; -import okhttp3.RequestBody; -import okio.BufferedSink; - -public final class ProgressRequestBody extends RequestBody { - private final InputStream content; - private final long contentLength; - private final UploadCallback uploadListener; - private final MediaType mediaType; - - private static final int DEFAULT_BUFFER_SIZE = 2048; - - public interface UploadCallback { - void onProgressUpdate(int percentage); - } - - public ProgressRequestBody(final InputStream content, long contentLength, final MediaType mediaType, final UploadCallback listener) { - this.content = content; - this.contentLength = contentLength; - this.mediaType = mediaType; - this.uploadListener = listener; - } - - @Override - public MediaType contentType() { - return mediaType; - } - - @Override - public long contentLength() { - return contentLength; - } - - @Override - public void writeTo(@NonNull BufferedSink sink) throws IOException { - - byte[] buffer = new byte[DEFAULT_BUFFER_SIZE]; - long uploaded = 0; - - try { - int read; - while ((read = content.read(buffer)) != -1) { - uploadListener.onProgressUpdate((int)(100 * uploaded / contentLength)); - - uploaded += read; - sink.write(buffer, 0, read); - } - - uploadListener.onProgressUpdate((int)(100 * uploaded / contentLength)); - } finally { - content.close(); - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/network/ProgressRequestBody.kt b/app/src/main/java/com/keylesspalace/tusky/network/ProgressRequestBody.kt new file mode 100644 index 0000000000..f74b0c1a26 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/network/ProgressRequestBody.kt @@ -0,0 +1,55 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ +package com.keylesspalace.tusky.network + +import okhttp3.MediaType +import okhttp3.RequestBody +import okio.BufferedSink +import java.io.IOException +import java.io.InputStream + +class ProgressRequestBody(private val content: InputStream, private val contentLength: Long, private val mediaType: MediaType, private val uploadListener: UploadCallback) : RequestBody() { + fun interface UploadCallback { + fun onProgressUpdate(percentage: Int) + } + + override fun contentType(): MediaType { + return mediaType + } + + override fun contentLength(): Long { + return contentLength + } + + @Throws(IOException::class) + override fun writeTo(sink: BufferedSink) { + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var uploaded: Long = 0 + + content.use { content -> + var read: Int + while (content.read(buffer).also { read = it } != -1) { + uploadListener.onProgressUpdate((100 * uploaded / contentLength).toInt()) + uploaded += read.toLong() + sink.write(buffer, 0, read) + } + uploadListener.onProgressUpdate((100 * uploaded / contentLength).toInt()) + } + } + + companion object { + private const val DEFAULT_BUFFER_SIZE = 2048 + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt b/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt index 3ab941e9df..861edae604 100644 --- a/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt +++ b/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt @@ -15,6 +15,7 @@ package com.keylesspalace.tusky.receiver +import android.annotation.SuppressLint import android.content.BroadcastReceiver import android.content.Context import android.content.Intent @@ -39,6 +40,7 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() { @Inject lateinit var accountManager: AccountManager + @SuppressLint("MissingPermission") override fun onReceive(context: Context, intent: Intent) { AndroidInjection.inject(this, context) diff --git a/app/src/main/java/com/keylesspalace/tusky/service/TuskyTileService.kt b/app/src/main/java/com/keylesspalace/tusky/service/TuskyTileService.kt index 2bf761f9f6..9d1b90a2fb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/service/TuskyTileService.kt +++ b/app/src/main/java/com/keylesspalace/tusky/service/TuskyTileService.kt @@ -15,7 +15,6 @@ package com.keylesspalace.tusky.service -import android.annotation.TargetApi import android.content.Intent import android.service.quicksettings.TileService import com.keylesspalace.tusky.MainActivity @@ -25,8 +24,6 @@ import com.keylesspalace.tusky.components.compose.ComposeActivity * Small Addition that adds in a QuickSettings tile * opens the Compose activity or shows an account selector when multiple accounts are present */ - -@TargetApi(24) class TuskyTileService : TileService() { override fun onClick() { diff --git a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt index 636c1fc698..4782601a56 100644 --- a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt +++ b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt @@ -5,7 +5,8 @@ enum class AppTheme(val value: String) { DAY("day"), BLACK("black"), AUTO("auto"), - AUTO_SYSTEM("auto_system"); + AUTO_SYSTEM("auto_system"), + AUTO_SYSTEM_BLACK("auto_system_black"); companion object { fun stringValues() = values().map { it.value }.toTypedArray() @@ -41,7 +42,10 @@ enum class AppTheme(val value: String) { * * - Adding a new preference that does not change the interpretation of an existing preference */ -const val SCHEMA_VERSION = 2023072401 +const val SCHEMA_VERSION = 2023082301 + +/** The schema version for fresh installs */ +const val NEW_INSTALL_SCHEMA_VERSION = 0 object PrefKeys { // Note: not all of these keys are actually used as SharedPreferences keys but we must give @@ -49,7 +53,6 @@ object PrefKeys { const val SCHEMA_VERSION: String = "schema_version" const val APP_THEME = "appTheme" - const val EMOJI = "selected_emoji_font" const val FAB_HIDE = "fabHide" const val LANGUAGE = "language" const val STATUS_TEXT_SIZE = "statusTextSize" diff --git a/app/src/main/java/com/keylesspalace/tusky/usecase/TimelineCases.kt b/app/src/main/java/com/keylesspalace/tusky/usecase/TimelineCases.kt index e28a107010..a9d1edb103 100644 --- a/app/src/main/java/com/keylesspalace/tusky/usecase/TimelineCases.kt +++ b/app/src/main/java/com/keylesspalace/tusky/usecase/TimelineCases.kt @@ -101,6 +101,50 @@ class TimelineCases @Inject constructor( } } + fun reblogOld(statusId: String, reblog: Boolean): Single { + val call = if (reblog) { + mastodonApi.reblogStatusOld(statusId) + } else { + mastodonApi.unreblogStatusOld(statusId) + } + return call.doAfterSuccess { + eventHub.dispatchOld(ReblogEvent(statusId, reblog)) + } + } + + fun favouriteOld(statusId: String, favourite: Boolean): Single { + val call = if (favourite) { + mastodonApi.favouriteStatusOld(statusId) + } else { + mastodonApi.unfavouriteStatusOld(statusId) + } + return call.doAfterSuccess { + eventHub.dispatchOld(FavoriteEvent(statusId, favourite)) + } + } + + fun bookmarkOld(statusId: String, bookmark: Boolean): Single { + val call = if (bookmark) { + mastodonApi.bookmarkStatusOld(statusId) + } else { + mastodonApi.unbookmarkStatusOld(statusId) + } + return call.doAfterSuccess { + eventHub.dispatchOld(BookmarkEvent(statusId, bookmark)) + } + } + + fun muteConversationOld(statusId: String, mute: Boolean): Single { + val call = if (mute) { + mastodonApi.muteConversationOld(statusId) + } else { + mastodonApi.unmuteConversationOld(statusId) + } + return call.doAfterSuccess { + eventHub.dispatchOld(MuteConversationEvent(statusId, mute)) + } + } + suspend fun mute(statusId: String, notifications: Boolean, duration: Int?) { try { mastodonApi.muteAccount(statusId, notifications, duration) @@ -149,6 +193,16 @@ class TimelineCases @Inject constructor( } } + fun voteInPollOld(statusId: String, pollId: String, choices: List): Single { + if (choices.isEmpty()) { + return Single.error(IllegalStateException()) + } + + return mastodonApi.voteInPollOld(pollId, choices).doAfterSuccess { + eventHub.dispatchOld(PollVoteEvent(statusId, it)) + } + } + fun acceptFollowRequest(accountId: String): Single { return mastodonApi.authorizeFollowRequest(accountId) } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/IOUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/IOUtils.kt index ece76bdfd4..3b5c49adb3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/IOUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/IOUtils.kt @@ -15,6 +15,7 @@ package com.keylesspalace.tusky.util +import android.annotation.SuppressLint import android.content.ContentResolver import android.net.Uri import java.io.Closeable @@ -34,6 +35,7 @@ fun Closeable?.closeQuietly() { } } +@SuppressLint("Recycle") // The linter can't tell that the stream gets closed by a helper method fun Uri.copyToFile( contentResolver: ContentResolver, file: File diff --git a/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt index 04176a11e7..0bf7878df7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt @@ -68,7 +68,7 @@ fun setClickableText(view: TextView, content: CharSequence, mentions: List (private val mapper: Function) : AbstractMutableList() { + private val main: MutableList = ArrayList() + private val synced: MutableList = ArrayList() + + val pairedCopy: List + get() = ArrayList(synced) + + fun getPairedItem(index: Int): V { + return synced[index] + } + + fun getPairedItemOrNull(index: Int): V? { + return synced.getOrNull(index) + } + + fun setPairedItem(index: Int, element: V) { + synced[index] = element + } + + override fun get(index: Int): T { + return main[index] + } + + override fun set(index: Int, element: T): T { + synced[index] = mapper.apply(element) + return main.set(index, element) + } + + override fun add(element: T): Boolean { + synced.add(mapper.apply(element)) + return main.add(element) + } + + override fun add(index: Int, element: T) { + synced.add(index, mapper.apply(element)) + main.add(index, element) + } + + override fun removeAt(index: Int): T { + synced.removeAt(index) + return main.removeAt(index) + } + + override val size: Int + get() = main.size +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/SmartLengthInputFilter.kt b/app/src/main/java/com/keylesspalace/tusky/util/SmartLengthInputFilter.kt index c7b583e571..9a9c19bfc5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/SmartLengthInputFilter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/SmartLengthInputFilter.kt @@ -15,6 +15,7 @@ package com.keylesspalace.tusky.util +import android.icu.text.BreakIterator import android.text.InputFilter import android.text.SpannableStringBuilder import android.text.Spanned @@ -72,20 +73,10 @@ object SmartLengthInputFilter : InputFilter { if (source[keep].isLetterOrDigit()) { var boundary: Int - // Android N+ offer a clone of the ICU APIs in Java for better internationalization and - // unicode support. Using the ICU version of BreakIterator grants better support for - // those without having to add the ICU4J library at a minimum Api trade-off. - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) { - val iterator = android.icu.text.BreakIterator.getWordInstance() - iterator.setText(source.toString()) - boundary = iterator.following(keep) - if (keep - boundary > RUNWAY) boundary = iterator.preceding(keep) - } else { - val iterator = java.text.BreakIterator.getWordInstance() - iterator.setText(source.toString()) - boundary = iterator.following(keep) - if (keep - boundary > RUNWAY) boundary = iterator.preceding(keep) - } + val iterator = BreakIterator.getWordInstance() + iterator.setText(source.toString()) + boundary = iterator.following(keep) + if (keep - boundary > RUNWAY) boundary = iterator.preceding(keep) keep = boundary } else { diff --git a/app/src/main/java/com/keylesspalace/tusky/util/StatusParsingHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/StatusParsingHelper.kt index b8c3c6a0d3..117a44e282 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/StatusParsingHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/StatusParsingHelper.kt @@ -18,7 +18,6 @@ package com.keylesspalace.tusky.util import android.text.Html.TagHandler -import android.text.SpannableStringBuilder import android.text.Spanned import androidx.core.text.parseAsHtml @@ -36,31 +35,3 @@ fun String.parseAsMastodonHtml(tagHandler: TagHandler? = null): Spanned { * most status contents do, so it should be trimmed. */ .trimTrailingWhitespace() } - -fun replaceCrashingCharacters(content: Spanned): Spanned { - return replaceCrashingCharacters(content as CharSequence) as Spanned -} - -fun replaceCrashingCharacters(content: CharSequence): CharSequence? { - var replacing = false - var builder: SpannableStringBuilder? = null - val length = content.length - for (index in 0 until length) { - val character = content[index] - - // If there are more than one or two, switch to a map - if (character == SOFT_HYPHEN) { - if (!replacing) { - replacing = true - builder = SpannableStringBuilder(content, 0, index) - } - builder!!.append(ASCII_HYPHEN) - } else if (replacing) { - builder!!.append(character) - } - } - return if (replacing) builder else content -} - -private const val SOFT_HYPHEN = '\u00ad' -private const val ASCII_HYPHEN = '-' diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ThemeUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/ThemeUtils.kt index a03a50260e..ae9847086f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ThemeUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ThemeUtils.kt @@ -17,6 +17,7 @@ package com.keylesspalace.tusky.util import android.content.Context +import android.content.res.Configuration import android.graphics.Color import android.graphics.PorterDuff import android.graphics.drawable.Drawable @@ -30,12 +31,13 @@ import com.google.android.material.color.MaterialColors * the ability to do so is not supported in resource files. */ -private const val THEME_NIGHT = "night" -private const val THEME_DAY = "day" -private const val THEME_BLACK = "black" -private const val THEME_AUTO = "auto" -private const val THEME_SYSTEM = "auto_system" -const val APP_THEME_DEFAULT = THEME_NIGHT +const val THEME_NIGHT = "night" +const val THEME_DAY = "day" +const val THEME_BLACK = "black" +const val THEME_AUTO = "auto" +const val THEME_SYSTEM = "auto_system" +const val THEME_SYSTEM_BLACK = "auto_system_black" +const val APP_THEME_DEFAULT = THEME_SYSTEM fun getDimension(context: Context, @AttrRes attribute: Int): Int { return context.obtainStyledAttributes(intArrayOf(attribute)).use { array -> @@ -59,9 +61,21 @@ fun setAppNightMode(flavor: String?) { THEME_AUTO -> AppCompatDelegate.setDefaultNightMode( AppCompatDelegate.MODE_NIGHT_AUTO_TIME ) - THEME_SYSTEM -> AppCompatDelegate.setDefaultNightMode( + THEME_SYSTEM, THEME_SYSTEM_BLACK -> AppCompatDelegate.setDefaultNightMode( AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM ) else -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) } } + +fun isBlack(config: Configuration, theme: String?): Boolean { + return when (theme) { + THEME_BLACK -> true + THEME_SYSTEM_BLACK -> when (config.uiMode and Configuration.UI_MODE_NIGHT_MASK) { + Configuration.UI_MODE_NIGHT_NO -> false + Configuration.UI_MODE_NIGHT_YES -> true + else -> false + } + else -> false + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt index f6ae9e7d79..5c1f3ebc27 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt @@ -55,12 +55,13 @@ fun Status.toViewData( ) } +@JvmName("notificationToViewData") fun Notification.toViewData( isShowingContent: Boolean, isExpanded: Boolean, isCollapsed: Boolean -): NotificationViewData { - return NotificationViewData( +): NotificationViewData.Concrete { + return NotificationViewData.Concrete( this.type, this.id, this.account, diff --git a/app/src/main/java/com/keylesspalace/tusky/view/BackgroundMessageView.kt b/app/src/main/java/com/keylesspalace/tusky/view/BackgroundMessageView.kt index e24d794133..c33533628f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/BackgroundMessageView.kt +++ b/app/src/main/java/com/keylesspalace/tusky/view/BackgroundMessageView.kt @@ -61,6 +61,7 @@ class BackgroundMessageView @JvmOverloads constructor( binding.imageView.setImageResource(imageRes) binding.button.setOnClickListener(clickListener) binding.button.visible(clickListener != null) + binding.helpText.visible(false) } fun showHelp(@StringRes helpRes: Int) { diff --git a/app/src/main/java/com/keylesspalace/tusky/view/MediaPreviewImageView.kt b/app/src/main/java/com/keylesspalace/tusky/view/MediaPreviewImageView.kt index 717bd14415..cf400a8f26 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/MediaPreviewImageView.kt +++ b/app/src/main/java/com/keylesspalace/tusky/view/MediaPreviewImageView.kt @@ -96,11 +96,11 @@ open class MediaPreviewImageView } } - override fun onLoadFailed(e: GlideException?, model: Any?, target: Target?, isFirstResource: Boolean): Boolean { + override fun onLoadFailed(e: GlideException?, model: Any?, target: Target, isFirstResource: Boolean): Boolean { return false } - override fun onResourceReady(resource: Drawable?, model: Any?, target: Target?, dataSource: DataSource?, isFirstResource: Boolean): Boolean { + override fun onResourceReady(resource: Drawable, model: Any, target: Target?, dataSource: DataSource, isFirstResource: Boolean): Boolean { recalculateMatrix(width, height, resource) return false } diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java b/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java new file mode 100644 index 0000000000..c70e2fc71e --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java @@ -0,0 +1,138 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.viewdata; + +import androidx.annotation.Nullable; + +import com.keylesspalace.tusky.entity.Notification; +import com.keylesspalace.tusky.entity.Report; +import com.keylesspalace.tusky.entity.TimelineAccount; + +import java.util.Objects; + +/** + * Created by charlag on 12/07/2017. + *

+ * Class to represent data required to display either a notification or a placeholder. + * It is either a {@link Placeholder} or a {@link Concrete}. + * It is modelled this way because close relationship between placeholder and concrete notification + * is fine in this case. Placeholder case is not modelled as a type of notification because + * invariants would be violated and because it would model domain incorrectly. It is preferable to + * {@link com.keylesspalace.tusky.util.Either} because class hierarchy is cheaper, faster and + * more native. + */ +public abstract class NotificationViewData { + private NotificationViewData() { + } + + public abstract long getViewDataId(); + + public abstract boolean deepEquals(NotificationViewData other); + + public static final class Concrete extends NotificationViewData { + private final Notification.Type type; + private final String id; + private final TimelineAccount account; + @Nullable + private final StatusViewData.Concrete statusViewData; + @Nullable + private final Report report; + + public Concrete(Notification.Type type, String id, TimelineAccount account, + @Nullable StatusViewData.Concrete statusViewData, @Nullable Report report) { + this.type = type; + this.id = id; + this.account = account; + this.statusViewData = statusViewData; + this.report = report; + } + + public Notification.Type getType() { + return type; + } + + public String getId() { + return id; + } + + public TimelineAccount getAccount() { + return account; + } + + @Nullable + public StatusViewData.Concrete getStatusViewData() { + return statusViewData; + } + + @Nullable + public Report getReport() { + return report; + } + + @Override + public long getViewDataId() { + return id.hashCode(); + } + + @Override + public boolean deepEquals(NotificationViewData o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Concrete concrete = (Concrete) o; + return type == concrete.type && + Objects.equals(id, concrete.id) && + account.getId().equals(concrete.account.getId()) && + (Objects.equals(statusViewData, concrete.statusViewData)) && + (Objects.equals(report, concrete.report)); + } + + @Override + public int hashCode() { + + return Objects.hash(type, id, account, statusViewData); + } + + public Concrete copyWithStatus(@Nullable StatusViewData.Concrete statusViewData) { + return new Concrete(type, id, account, statusViewData, report); + } + } + + public static final class Placeholder extends NotificationViewData { + private final long id; + private final boolean isLoading; + + public Placeholder(long id, boolean isLoading) { + this.id = id; + this.isLoading = isLoading; + } + + public boolean isLoading() { + return isLoading; + } + + @Override + public long getViewDataId() { + return id; + } + + @Override + public boolean deepEquals(NotificationViewData other) { + if (!(other instanceof Placeholder)) return false; + Placeholder that = (Placeholder) other; + return isLoading == that.isLoading && id == that.id; + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.kt b/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.kt deleted file mode 100644 index 759d633e28..0000000000 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.kt +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright 2023 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 . - */ - -/* - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . */ -package com.keylesspalace.tusky.viewdata - -import com.keylesspalace.tusky.entity.Notification -import com.keylesspalace.tusky.entity.Report -import com.keylesspalace.tusky.entity.TimelineAccount - -data class NotificationViewData( - val type: Notification.Type, - val id: String, - val account: TimelineAccount, - var statusViewData: StatusViewData.Concrete?, - val report: Report? -) diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt index b268e5b146..ae3515e340 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt @@ -14,12 +14,10 @@ * see . */ package com.keylesspalace.tusky.viewdata -import android.os.Build import android.text.Spanned import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.util.parseAsMastodonHtml -import com.keylesspalace.tusky.util.replaceCrashingCharacters import com.keylesspalace.tusky.util.shouldTrimStatus /** @@ -48,17 +46,15 @@ sealed class StatusViewData { override val id: String get() = status.id + val content: Spanned = status.actionableStatus.content.parseAsMastodonHtml() + /** * Specifies whether the content of this post is long enough to be automatically * collapsed or if it should show all content regardless. * * @return Whether the post is collapsible or never collapsed. */ - val isCollapsible: Boolean - - val content: Spanned - val spoilerText: String - val username: String + val isCollapsible: Boolean = shouldTrimStatus(this.content) val actionable: Status get() = status.actionableStatus @@ -92,6 +88,21 @@ sealed class StatusViewData { this.isCollapsible = shouldTrimStatus(this.content) } + /** Helper for Java */ + fun copyWithStatus(status: Status): Concrete { + return copy(status = status) + } + + /** Helper for Java */ + fun copyWithExpanded(isExpanded: Boolean): Concrete { + return copy(isExpanded = isExpanded) + } + + /** Helper for Java */ + fun copyWithShowingContent(isShowingContent: Boolean): Concrete { + return copy(isShowingContent = isShowingContent) + } + /** Helper for Java */ fun copyWithCollapsed(isCollapsed: Boolean): Concrete { return copy(isCollapsed = isCollapsed) diff --git a/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt index 21a8a01d71..55de04be43 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt @@ -42,7 +42,6 @@ import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.launch import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MultipartBody -import okhttp3.RequestBody import okhttp3.RequestBody.Companion.asRequestBody import okhttp3.RequestBody.Companion.toRequestBody import java.io.File @@ -51,6 +50,13 @@ import javax.inject.Inject private const val HEADER_FILE_NAME = "header.png" private const val AVATAR_FILE_NAME = "avatar.png" +internal data class ProfileDataInUi( + val displayName: String, + val note: String, + val locked: Boolean, + val fields: List +) + class EditProfileViewModel @Inject constructor( private val mastodonApi: MastodonApi, private val eventHub: EventHub, @@ -66,7 +72,7 @@ class EditProfileViewModel @Inject constructor( val instanceData: Flow = instanceInfoRepo::getInstanceInfo.asFlow() .shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1) - private var oldProfileData: Account? = null + private var apiProfileAccount: Account? = null fun obtainProfile() = viewModelScope.launch { if (profileData.value == null || profileData.value is Error) { @@ -74,7 +80,7 @@ class EditProfileViewModel @Inject constructor( mastodonApi.accountVerifyCredentials().fold( { profile -> - oldProfileData = profile + apiProfileAccount = profile profileData.postValue(Success(profile)) }, { @@ -96,68 +102,49 @@ class EditProfileViewModel @Inject constructor( headerData.value = getHeaderUri() } - fun save(newDisplayName: String, newNote: String, newLocked: Boolean, newFields: List) { + internal fun save(newProfileData: ProfileDataInUi) { if (saveData.value is Loading || profileData.value !is Success) { return } saveData.value = Loading() - val displayName = if (oldProfileData?.displayName == newDisplayName) { - null - } else { - newDisplayName.toRequestBody(MultipartBody.FORM) - } - - val note = if (oldProfileData?.source?.note == newNote) { - null - } else { - newNote.toRequestBody(MultipartBody.FORM) - } - - val locked = if (oldProfileData?.locked == newLocked) { - null - } else { - newLocked.toString().toRequestBody(MultipartBody.FORM) - } - - val avatar = if (avatarData.value != null) { - val avatarBody = getCacheFileForName(AVATAR_FILE_NAME).asRequestBody("image/png".toMediaTypeOrNull()) - MultipartBody.Part.createFormData("avatar", randomAlphanumericString(12), avatarBody) - } else { - null - } - - val header = if (headerData.value != null) { - val headerBody = getCacheFileForName(HEADER_FILE_NAME).asRequestBody("image/png".toMediaTypeOrNull()) - MultipartBody.Part.createFormData("header", randomAlphanumericString(12), headerBody) - } else { - null - } - - // when one field changed, all have to be sent or they unchanged ones would get overridden - val fieldsUnchanged = oldProfileData?.source?.fields == newFields - val field1 = calculateFieldToUpdate(newFields.getOrNull(0), fieldsUnchanged) - val field2 = calculateFieldToUpdate(newFields.getOrNull(1), fieldsUnchanged) - val field3 = calculateFieldToUpdate(newFields.getOrNull(2), fieldsUnchanged) - val field4 = calculateFieldToUpdate(newFields.getOrNull(3), fieldsUnchanged) - - if (displayName == null && note == null && locked == null && avatar == null && header == null && - field1 == null && field2 == null && field3 == null && field4 == null - ) { - /** if nothing has changed, there is no need to make a network request */ - saveData.postValue(Success()) + val diff = getProfileDiff(apiProfileAccount, newProfileData) + if (!diff.hasChanges()) { + // if nothing has changed, there is no need to make an api call + saveData.value = Success() return } viewModelScope.launch { + var avatarFileBody: MultipartBody.Part? = null + diff.avatarFile?.let { + avatarFileBody = MultipartBody.Part.createFormData("avatar", randomAlphanumericString(12), it.asRequestBody("image/png".toMediaTypeOrNull())) + } + + var headerFileBody: MultipartBody.Part? = null + diff.headerFile?.let { + headerFileBody = MultipartBody.Part.createFormData("header", randomAlphanumericString(12), it.asRequestBody("image/png".toMediaTypeOrNull())) + } + mastodonApi.accountUpdateCredentials( - displayName, note, locked, avatar, header, - field1?.first, field1?.second, field2?.first, field2?.second, field3?.first, field3?.second, field4?.first, field4?.second + diff.displayName?.toRequestBody(MultipartBody.FORM), + diff.note?.toRequestBody(MultipartBody.FORM), + diff.locked?.toString()?.toRequestBody(MultipartBody.FORM), + avatarFileBody, + headerFileBody, + diff.field1?.first?.toRequestBody(MultipartBody.FORM), + diff.field1?.second?.toRequestBody(MultipartBody.FORM), + diff.field2?.first?.toRequestBody(MultipartBody.FORM), + diff.field2?.second?.toRequestBody(MultipartBody.FORM), + diff.field3?.first?.toRequestBody(MultipartBody.FORM), + diff.field3?.second?.toRequestBody(MultipartBody.FORM), + diff.field4?.first?.toRequestBody(MultipartBody.FORM), + diff.field4?.second?.toRequestBody(MultipartBody.FORM) ).fold( - { newProfileData -> + { newAccountData -> saveData.postValue(Success()) - eventHub.dispatch(ProfileEditedEvent(newProfileData)) + eventHub.dispatch(ProfileEditedEvent(newAccountData)) }, { throwable -> saveData.postValue(Error(errorMessage = throwable.getServerErrorMessage())) @@ -167,30 +154,95 @@ class EditProfileViewModel @Inject constructor( } // cache activity state for rotation change - fun updateProfile(newDisplayName: String, newNote: String, newLocked: Boolean, newFields: List) { + internal fun updateProfile(newProfileData: ProfileDataInUi) { if (profileData.value is Success) { - val newProfileSource = profileData.value?.data?.source?.copy(note = newNote, fields = newFields) + val newProfileSource = profileData.value?.data?.source?.copy(note = newProfileData.note, fields = newProfileData.fields) val newProfile = profileData.value?.data?.copy( - displayName = newDisplayName, - locked = newLocked, + displayName = newProfileData.displayName, + locked = newProfileData.locked, source = newProfileSource ) - profileData.postValue(Success(newProfile)) + profileData.value = Success(newProfile) } } - private fun calculateFieldToUpdate(newField: StringField?, fieldsUnchanged: Boolean): Pair? { + internal fun hasUnsavedChanges(newProfileData: ProfileDataInUi): Boolean { + val diff = getProfileDiff(apiProfileAccount, newProfileData) + + return diff.hasChanges() + } + + private fun getProfileDiff(oldProfileAccount: Account?, newProfileData: ProfileDataInUi): DiffProfileData { + val displayName = if (oldProfileAccount?.displayName == newProfileData.displayName) { + null + } else { + newProfileData.displayName + } + + val note = if (oldProfileAccount?.source?.note == newProfileData.note) { + null + } else { + newProfileData.note + } + + val locked = if (oldProfileAccount?.locked == newProfileData.locked) { + null + } else { + newProfileData.locked + } + + val avatarFile = if (avatarData.value != null) { + getCacheFileForName(AVATAR_FILE_NAME) + } else { + null + } + + val headerFile = if (headerData.value != null) { + getCacheFileForName(HEADER_FILE_NAME) + } else { + null + } + + // when one field changed, all have to be sent or they unchanged ones would get overridden + val allFieldsUnchanged = oldProfileAccount?.source?.fields == newProfileData.fields + val field1 = calculateFieldToUpdate(newProfileData.fields.getOrNull(0), allFieldsUnchanged) + val field2 = calculateFieldToUpdate(newProfileData.fields.getOrNull(1), allFieldsUnchanged) + val field3 = calculateFieldToUpdate(newProfileData.fields.getOrNull(2), allFieldsUnchanged) + val field4 = calculateFieldToUpdate(newProfileData.fields.getOrNull(3), allFieldsUnchanged) + + return DiffProfileData( + displayName, note, locked, field1, field2, field3, field4, headerFile, avatarFile + ) + } + + private fun calculateFieldToUpdate(newField: StringField?, fieldsUnchanged: Boolean): Pair? { if (fieldsUnchanged || newField == null) { return null } return Pair( - newField.name.toRequestBody(MultipartBody.FORM), - newField.value.toRequestBody(MultipartBody.FORM) + newField.name, + newField.value ) } private fun getCacheFileForName(filename: String): File { return File(application.cacheDir, filename) } + + private data class DiffProfileData( + val displayName: String?, + val note: String?, + val locked: Boolean?, + val field1: Pair?, + val field2: Pair?, + val field3: Pair?, + val field4: Pair?, + val headerFile: File?, + val avatarFile: File? + ) { + fun hasChanges() = displayName != null || note != null || locked != null || + avatarFile != null || headerFile != null || field1 != null || field2 != null || + field3 != null || field4 != null + } } diff --git a/app/src/main/res/color-v24/launcher_shadow_gradient.xml b/app/src/main/res/color-v24/launcher_shadow_gradient.xml deleted file mode 100644 index 98ce833826..0000000000 --- a/app/src/main/res/color-v24/launcher_shadow_gradient.xml +++ /dev/null @@ -1,12 +0,0 @@ - - \ No newline at end of file diff --git a/app/src/main/res/drawable-v24/ic_notoemoji.xml b/app/src/main/res/drawable-v24/ic_notoemoji.xml deleted file mode 100644 index d016e35c0f..0000000000 --- a/app/src/main/res/drawable-v24/ic_notoemoji.xml +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/ic_blobmoji.xml b/app/src/main/res/drawable/ic_blobmoji.xml deleted file mode 100644 index be3332ce79..0000000000 --- a/app/src/main/res/drawable/ic_blobmoji.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/drawable/ic_emoji_34dp.xml b/app/src/main/res/drawable/ic_emoji_34dp.xml deleted file mode 100644 index b00cb96845..0000000000 --- a/app/src/main/res/drawable/ic_emoji_34dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_hot_24dp.xml b/app/src/main/res/drawable/ic_hot_24dp.xml new file mode 100644 index 0000000000..9d4e6643f3 --- /dev/null +++ b/app/src/main/res/drawable/ic_hot_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_notifications_off_24dp.xml b/app/src/main/res/drawable/ic_notifications_off_24dp.xml deleted file mode 100644 index 627eafd22f..0000000000 --- a/app/src/main/res/drawable/ic_notifications_off_24dp.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_notoemoji.xml b/app/src/main/res/drawable/ic_notoemoji.xml deleted file mode 100644 index 55628c0195..0000000000 --- a/app/src/main/res/drawable/ic_notoemoji.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/ic_twemoji.xml b/app/src/main/res/drawable/ic_twemoji.xml deleted file mode 100644 index 70c4b513bf..0000000000 --- a/app/src/main/res/drawable/ic_twemoji.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - diff --git a/app/src/main/res/layout/activity_account.xml b/app/src/main/res/layout/activity_account.xml index 9b6d4f8132..2e92acc08a 100644 --- a/app/src/main/res/layout/activity_account.xml +++ b/app/src/main/res/layout/activity_account.xml @@ -35,6 +35,7 @@ android:layout_alignTop="@+id/account_header_info" android:background="?attr/colorPrimaryDark" android:scaleType="centerCrop" + android:contentDescription="@string/label_header" app:layout_collapseMode="parallax" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" @@ -279,8 +280,8 @@ android:layout_height="wrap_content" android:layout_marginTop="8dp" android:drawablePadding="6dp" - android:drawableStart="@drawable/ic_briefcase" android:textSize="?attr/status_text_medium" + app:drawableStartCompat="@drawable/ic_briefcase" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" tools:text="Account has moved" /> @@ -471,6 +472,7 @@ android:layout_height="@dimen/account_activity_avatar_size" android:layout_marginStart="16dp" android:padding="3dp" + android:contentDescription="@string/label_avatar" app:layout_anchor="@+id/accountHeaderInfoContainer" app:layout_anchorGravity="top" app:layout_scrollFlags="scroll" diff --git a/app/src/main/res/layout/activity_compose.xml b/app/src/main/res/layout/activity_compose.xml index 367d88baa5..7e3bae8b39 100644 --- a/app/src/main/res/layout/activity_compose.xml +++ b/app/src/main/res/layout/activity_compose.xml @@ -311,7 +311,7 @@ android:layout_marginEnd="4dp" android:contentDescription="@string/action_toggle_visibility" android:padding="4dp" - android:tint="?android:attr/textColorTertiary" + app:tint="?android:attr/textColorTertiary" app:tooltipText="@string/action_toggle_visibility" tools:src="@drawable/ic_public_24dp" /> diff --git a/app/src/main/res/layout/activity_tab_preference.xml b/app/src/main/res/layout/activity_tab_preference.xml index f1ebf9d8ff..cd6bce8d20 100644 --- a/app/src/main/res/layout/activity_tab_preference.xml +++ b/app/src/main/res/layout/activity_tab_preference.xml @@ -66,7 +66,6 @@ android:layout_height="48dp" android:layout_gravity="bottom" android:background="?attr/colorPrimary" - android:drawableStart="@drawable/ic_plus_24dp" android:drawablePadding="12dp" android:ellipsize="end" android:gravity="center_vertical" @@ -75,7 +74,8 @@ android:paddingEnd="8dp" android:text="@string/action_add_tab" android:textColor="?attr/colorOnPrimary" - android:textSize="?attr/status_text_large" /> + android:textSize="?attr/status_text_large" + app:drawableStartCompat="@drawable/ic_plus_24dp" /> diff --git a/app/src/main/res/layout/dialog_filter.xml b/app/src/main/res/layout/dialog_filter.xml index 64cc620774..376167d9ad 100644 --- a/app/src/main/res/layout/dialog_filter.xml +++ b/app/src/main/res/layout/dialog_filter.xml @@ -11,6 +11,8 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:hint="@string/filter_add_description" + android:inputType="text" + android:importantForAutofill="no" app:layout_constraintTop_toTopOf="parent" /> diff --git a/app/src/main/res/layout/fragment_instance_list.xml b/app/src/main/res/layout/fragment_domain_blocks.xml similarity index 88% rename from app/src/main/res/layout/fragment_instance_list.xml rename to app/src/main/res/layout/fragment_domain_blocks.xml index 8270cee337..65fdf1d3e0 100644 --- a/app/src/main/res/layout/fragment_instance_list.xml +++ b/app/src/main/res/layout/fragment_domain_blocks.xml @@ -5,7 +5,7 @@ android:layout_height="match_parent"> @@ -17,9 +17,9 @@ - \ No newline at end of file + diff --git a/app/src/main/res/layout/fragment_report_note.xml b/app/src/main/res/layout/fragment_report_note.xml index 12d1fcbc7b..047fa9bd63 100644 --- a/app/src/main/res/layout/fragment_report_note.xml +++ b/app/src/main/res/layout/fragment_report_note.xml @@ -107,6 +107,7 @@