diff --git a/.editorconfig b/.editorconfig index 47d830da0..499ac15ad 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/.github/ci-gradle.properties b/.github/ci-gradle.properties new file mode 100644 index 000000000..b27d29785 --- /dev/null +++ b/.github/ci-gradle.properties @@ -0,0 +1,24 @@ +# +# 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 . +# + +# CI build workers are ephemeral, so don't benefit from the Gradle daemon +org.gradle.daemon=false +org.gradle.parallel=true +org.gradle.workers.max=2 + +kotlin.incremental=false +kotlin.compiler.execution.strategy=in-process diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 904cceca5..107f4dfef 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -18,7 +18,7 @@ jobs: - uses: actions/setup-java@v1 with: - java-version: 11 + java-version: 17 - run: | chmod +x ./gradlew @@ -35,7 +35,7 @@ jobs: - uses: actions/setup-java@v1 with: - java-version: 11 + java-version: 17 - run: | chmod +x ./gradlew diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c9f5ac74f..63afec2db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,25 +17,28 @@ jobs: - uses: actions/setup-java@v3 with: - java-version: '11' + java-version: '17' distribution: 'temurin' - name: Gradle Wrapper Validation uses: gradle/wrapper-validation-action@v1 + - name: Copy CI gradle.properties + run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties + - name: Gradle Build Action uses: gradle/gradle-build-action@v2 with: cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/develop' }} - + - name: ktlint run: ./gradlew clean ktlintCheck - + - name: Regular lint run: ./gradlew app:lintGreenDebug - + - name: Test run: ./gradlew app:testGreenDebugUnitTest - + - name: Build run: ./gradlew app:buildGreenDebug diff --git a/.github/workflows/populate-gradle-build-cache.yml b/.github/workflows/populate-gradle-build-cache.yml new file mode 100644 index 000000000..4eeb8dd0c --- /dev/null +++ b/.github/workflows/populate-gradle-build-cache.yml @@ -0,0 +1,34 @@ +# Build the app on each push to `develop`, populating the build cache to speed +# up CI on PRs. + +name: Populate build cache + +on: + push: + branches: + - develop + +jobs: + build: + name: app:buildGreenDebug + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + + - name: Gradle Wrapper Validation + uses: gradle/wrapper-validation-action@v1 + + - name: Copy CI gradle.properties + run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties + + - uses: gradle/gradle-build-action@v2 + with: + cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/develop' }} + + - name: Run app:buildGreenDebug + run: ./gradlew app:buildGreenDebug diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9aa5cb0d8..3e368e3ae 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,7 +15,7 @@ jobs: - uses: actions/setup-java@v1 with: - java-version: 11 + java-version: 17 - run: | chmod +x ./gradlew diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0960c91eb..0bf4d3aa6 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. @@ -42,12 +43,15 @@ All icons are from the Material iconset, find new icons [here](https://fonts.goo We try to make Tusky as accessible as possible for as many people as possible. Please make sure that all touch targets are at least 48dpx48dp in size, Text has sufficient contrast and images or icons have a image description. See [this guide](https://developer.android.com/guide/topics/ui/accessibility/apps) for more information. ### Supported servers -Tusky is primarily a Mastodon client and aims to always support the newest Mastodon version. Other platforms implementing the Mastodon Api, e.g. Akkoma, GoToSocial or Pixelfed should also work with Tusky but no special effort is made to support their quirks or additional features. +Tusky is primarily a Mastodon client and aims to always support the newest Mastodon version. Other platforms implementing the Mastodon API, e.g. Akkoma, GoToSocial or Pixelfed should also work with Tusky, but no special effort is made to support their quirks or additional features. + +### Payment Policy +Our payment policy may be viewed [here](https://github.com/tuskyapp/Tusky/blob/develop/doc/PaymentPolicy.md). ## Troubleshooting / FAQ -- Tusky should be built with the newest version of Android Studio +- Tusky should be built with the newest version of Android Studio. - Tusky comes with two sets of build variants, "blue" and "green", which can be installed simultaneously and are distinguished by the colors of their icons. Green is intended for local development and testing, whereas blue is for releases. ## Resources -- [Mastodon Api documentation](https://docs.joinmastodon.org/api/) +- [Mastodon API documentation](https://docs.joinmastodon.org/api/) diff --git a/app/build.gradle b/app/build.gradle index f508232d3..34cd5ca20 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 56 versionName '4.6.0' @@ -68,8 +68,7 @@ android { lint { lintConfig file("lint.xml") - // Regenerate by deleting app/lint-baseline.xml, then run: - // ./gradlew lintBlueDebug + // Regenerate by running `./gradlew app:newLintBaseline` baseline = file("lint-baseline.xml") } @@ -108,12 +107,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_11 - targetCompatibility JavaVersion.VERSION_11 - } applicationVariants.configureEach { variant -> variant.outputs.configureEach { outputFileName = "Tusky_${variant.versionName}_${variant.versionCode}_${gitSha}_" + @@ -166,7 +159,7 @@ dependencies { implementation libs.sparkbutton - implementation libs.photoview + implementation libs.touchimageview implementation libs.bundles.material.drawer implementation libs.material.typeface @@ -197,3 +190,20 @@ dependencies { implementation libs.accelfeaster implementation libs.jsoup } + +// 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).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", + "--add-opens", "jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED", + "--add-opens", "jdk.compiler/com.sun.tools.javac.jvm=ALL-UNNAMED", + "--add-opens", "jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED", + "--add-opens", "jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED", + "--add-opens", "jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED", + "--add-opens", "jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED", + "--add-opens", "jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED", + "--add-opens", "jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED"]) +} diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml index 056acea99..15757da0d 100644 --- a/app/lint-baseline.xml +++ b/app/lint-baseline.xml @@ -1,47 +1,36 @@ - + + id="CheckResult" + message="The result of `placeholder` is not used" + errorLine1=" if (showPlaceholder) placeholder(R.drawable.avatar_default)" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/java/com/keylesspalace/tusky/MainActivity.kt" + line="937" + column="42"/> + errorLine1=" if (showPlaceholder) placeholder(R.drawable.avatar_default)" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> - - - - + line="964" + column="42"/> @@ -72,41 +61,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -828,7 +89,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -839,7 +100,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -861,535 +122,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -1625,4619 +358,69 @@ + 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"/> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + column="13"/> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + id="ObsoleteSdkInt" + message="Unnecessary; SDK_INT is never < 24" + errorLine1=" if (Build.VERSION.SDK_INT <= 23) {" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="284" + column="13"/> + id="ObsoleteSdkInt" + message="Unnecessary; SDK_INT is always >= 24" + errorLine1=" if (Build.VERSION.SDK_INT > 23) {" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~"> + 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"/> + + + + - - @@ -6698,7 +883,7 @@ errorLine2=" ~~~~~~"> @@ -6709,7 +894,7 @@ errorLine2=" ~~~~~~~~"> @@ -6720,7 +905,7 @@ errorLine2=" ~~~~"> @@ -6731,7 +916,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -6742,7 +927,7 @@ errorLine2=" ~~~~~~~~~~~~"> @@ -6753,7 +938,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -6764,7 +949,7 @@ errorLine2=" ~~~~~~~~"> @@ -6775,7 +960,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -6786,7 +971,7 @@ errorLine2=" ~~~~~~~"> @@ -6797,7 +982,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -6808,7 +993,7 @@ errorLine2=" ~~~~~~~~~~~~"> @@ -6819,7 +1004,7 @@ errorLine2=" ~~~~~~~~~~~~~"> @@ -6830,7 +1015,7 @@ errorLine2=" ~~~~~~~"> @@ -6841,7 +1026,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -6852,7 +1037,7 @@ errorLine2=" ~~~~~~~~~~~~~"> @@ -6863,7 +1048,7 @@ errorLine2=" ~~~~~~~"> @@ -6874,7 +1059,7 @@ errorLine2=" ~~~~~~~"> @@ -6885,7 +1070,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -6896,10 +1081,285 @@ errorLine2=" ~~~~~~~~~~~~~"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - @@ -7028,7 +1444,7 @@ errorLine2=" ~~~~~~"> @@ -7039,7 +1455,7 @@ errorLine2=" ~~~~~~~~~~~"> @@ -7050,7 +1466,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -7061,7 +1477,7 @@ errorLine2=" ~~~~~~"> @@ -7072,7 +1488,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -7083,7 +1499,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~"> @@ -7094,7 +1510,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -7105,7 +1521,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -7116,7 +1532,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~"> @@ -7127,7 +1543,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -7138,7 +1554,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~"> @@ -7149,7 +1565,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -7160,7 +1576,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -7171,7 +1587,7 @@ errorLine2=" ~~~~~~"> @@ -7182,7 +1598,7 @@ errorLine2=" ~~~~~~"> @@ -7193,7 +1609,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -7204,7 +1620,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~"> @@ -7215,7 +1631,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -7226,7 +1642,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> diff --git a/app/lint.xml b/app/lint.xml index 1ccf867d1..e11ac95f3 100644 --- a/app/lint.xml +++ b/app/lint.xml @@ -29,20 +29,47 @@ Disable these for the time being. --> + - - + + - - + + + + - - + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/52.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/52.json new file mode 100644 index 000000000..14d04ed8c --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/52.json @@ -0,0 +1,1015 @@ +{ + "formatVersion": 1, + "database": { + "version": 52, + "identityHash": "233a8680f540e9a89950da21532ce85d", + "entities": [ + { + "tableName": "DraftEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountId` INTEGER NOT NULL, `inReplyToId` TEXT, `content` TEXT, `contentWarning` TEXT, `sensitive` INTEGER NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `poll` TEXT, `failedToSend` INTEGER NOT NULL, `failedToSendNew` INTEGER NOT NULL, `scheduledAt` TEXT, `language` TEXT, `statusId` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentWarning", + "columnName": "contentWarning", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "failedToSend", + "columnName": "failedToSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "failedToSendNew", + "columnName": "failedToSendNew", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scheduledAt", + "columnName": "scheduledAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "statusId", + "columnName": "statusId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `clientId` TEXT, `clientSecret` TEXT, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsSubscriptions` INTEGER NOT NULL, `notificationsSignUps` INTEGER NOT NULL, `notificationsUpdates` INTEGER NOT NULL, `notificationsReports` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `defaultPostLanguage` TEXT NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `notificationMarkerId` TEXT NOT NULL DEFAULT '0', `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL, `oauthScopes` TEXT NOT NULL, `unifiedPushUrl` TEXT NOT NULL, `pushPubKey` TEXT NOT NULL, `pushPrivKey` TEXT NOT NULL, `pushAuth` TEXT NOT NULL, `pushServerKey` TEXT NOT NULL, `lastVisibleHomeTimelineStatusId` TEXT, `locked` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "clientSecret", + "columnName": "clientSecret", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePictureUrl", + "columnName": "profilePictureUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsEnabled", + "columnName": "notificationsEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsMentioned", + "columnName": "notificationsMentioned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowed", + "columnName": "notificationsFollowed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowRequested", + "columnName": "notificationsFollowRequested", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReblogged", + "columnName": "notificationsReblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFavorited", + "columnName": "notificationsFavorited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsPolls", + "columnName": "notificationsPolls", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSubscriptions", + "columnName": "notificationsSubscriptions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSignUps", + "columnName": "notificationsSignUps", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsUpdates", + "columnName": "notificationsUpdates", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReports", + "columnName": "notificationsReports", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationSound", + "columnName": "notificationSound", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationVibration", + "columnName": "notificationVibration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationLight", + "columnName": "notificationLight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostPrivacy", + "columnName": "defaultPostPrivacy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultMediaSensitivity", + "columnName": "defaultMediaSensitivity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostLanguage", + "columnName": "defaultPostLanguage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "alwaysShowSensitiveMedia", + "columnName": "alwaysShowSensitiveMedia", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysOpenSpoiler", + "columnName": "alwaysOpenSpoiler", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaPreviewEnabled", + "columnName": "mediaPreviewEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastNotificationId", + "columnName": "lastNotificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationMarkerId", + "columnName": "notificationMarkerId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'0'" + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabPreferences", + "columnName": "tabPreferences", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsFilter", + "columnName": "notificationsFilter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "oauthScopes", + "columnName": "oauthScopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unifiedPushUrl", + "columnName": "unifiedPushUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPubKey", + "columnName": "pushPubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPrivKey", + "columnName": "pushPrivKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushAuth", + "columnName": "pushAuth", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushServerKey", + "columnName": "pushServerKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastVisibleHomeTimelineStatusId", + "columnName": "lastVisibleHomeTimelineStatusId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_AccountEntity_domain_accountId", + "unique": true, + "columnNames": [ + "domain", + "accountId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "InstanceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `minPollDuration` INTEGER, `maxPollDuration` INTEGER, `charactersReservedPerUrl` INTEGER, `version` TEXT, `videoSizeLimit` INTEGER, `imageSizeLimit` INTEGER, `imageMatrixLimit` INTEGER, `maxMediaAttachments` INTEGER, `maxFields` INTEGER, `maxFieldNameLength` INTEGER, `maxFieldValueLength` INTEGER, PRIMARY KEY(`instance`))", + "fields": [ + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojiList", + "columnName": "emojiList", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maximumTootCharacters", + "columnName": "maximumTootCharacters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptions", + "columnName": "maxPollOptions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptionLength", + "columnName": "maxPollOptionLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "minPollDuration", + "columnName": "minPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollDuration", + "columnName": "maxPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "charactersReservedPerUrl", + "columnName": "charactersReservedPerUrl", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "videoSizeLimit", + "columnName": "videoSizeLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "imageSizeLimit", + "columnName": "imageSizeLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "imageMatrixLimit", + "columnName": "imageMatrixLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMediaAttachments", + "columnName": "maxMediaAttachments", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFields", + "columnName": "maxFields", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFieldNameLength", + "columnName": "maxFieldNameLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFieldValueLength", + "columnName": "maxFieldValueLength", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "instance" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `editedAt` INTEGER, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `repliesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT, `mentions` TEXT, `tags` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `card` TEXT, `language` TEXT, `filtered` TEXT, `quote` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "editedAt", + "columnName": "editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogsCount", + "columnName": "reblogsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favouritesCount", + "columnName": "favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "repliesCount", + "columnName": "repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reblogged", + "columnName": "reblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarked", + "columnName": "bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favourited", + "columnName": "favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogServerId", + "columnName": "reblogServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "muted", + "columnName": "muted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "expanded", + "columnName": "expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentCollapsed", + "columnName": "contentCollapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentShowing", + "columnName": "contentShowing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pinned", + "columnName": "pinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "card", + "columnName": "card", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filtered", + "columnName": "filtered", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "quote", + "columnName": "quote", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "timelineUserId" + ], + "referencedColumns": [ + "serverId", + "timelineUserId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUsername", + "columnName": "localUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `order` INTEGER NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_editedAt` INTEGER, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_repliesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_tags` TEXT, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, `s_language` TEXT, PRIMARY KEY(`id`, `accountId`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accounts", + "columnName": "accounts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.id", + "columnName": "s_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.url", + "columnName": "s_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToId", + "columnName": "s_inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToAccountId", + "columnName": "s_inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.account", + "columnName": "s_account", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.content", + "columnName": "s_content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.createdAt", + "columnName": "s_createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.editedAt", + "columnName": "s_editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastStatus.emojis", + "columnName": "s_emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.favouritesCount", + "columnName": "s_favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.repliesCount", + "columnName": "s_repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.favourited", + "columnName": "s_favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.bookmarked", + "columnName": "s_bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.sensitive", + "columnName": "s_sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.spoilerText", + "columnName": "s_spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.attachments", + "columnName": "s_attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.mentions", + "columnName": "s_mentions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.tags", + "columnName": "s_tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.showingHiddenContent", + "columnName": "s_showingHiddenContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.expanded", + "columnName": "s_expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsed", + "columnName": "s_collapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.muted", + "columnName": "s_muted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.poll", + "columnName": "s_poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.language", + "columnName": "s_language", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "accountId" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '233a8680f540e9a89950da21532ce85d')" + ] + } +} diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/53.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/53.json new file mode 100644 index 000000000..e18aebd32 --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/53.json @@ -0,0 +1,1015 @@ +{ + "formatVersion": 1, + "database": { + "version": 53, + "identityHash": "233a8680f540e9a89950da21532ce85d", + "entities": [ + { + "tableName": "DraftEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountId` INTEGER NOT NULL, `inReplyToId` TEXT, `content` TEXT, `contentWarning` TEXT, `sensitive` INTEGER NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `poll` TEXT, `failedToSend` INTEGER NOT NULL, `failedToSendNew` INTEGER NOT NULL, `scheduledAt` TEXT, `language` TEXT, `statusId` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentWarning", + "columnName": "contentWarning", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "failedToSend", + "columnName": "failedToSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "failedToSendNew", + "columnName": "failedToSendNew", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scheduledAt", + "columnName": "scheduledAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "statusId", + "columnName": "statusId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `clientId` TEXT, `clientSecret` TEXT, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsSubscriptions` INTEGER NOT NULL, `notificationsSignUps` INTEGER NOT NULL, `notificationsUpdates` INTEGER NOT NULL, `notificationsReports` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `defaultPostLanguage` TEXT NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `notificationMarkerId` TEXT NOT NULL DEFAULT '0', `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL, `oauthScopes` TEXT NOT NULL, `unifiedPushUrl` TEXT NOT NULL, `pushPubKey` TEXT NOT NULL, `pushPrivKey` TEXT NOT NULL, `pushAuth` TEXT NOT NULL, `pushServerKey` TEXT NOT NULL, `lastVisibleHomeTimelineStatusId` TEXT, `locked` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "clientSecret", + "columnName": "clientSecret", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePictureUrl", + "columnName": "profilePictureUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsEnabled", + "columnName": "notificationsEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsMentioned", + "columnName": "notificationsMentioned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowed", + "columnName": "notificationsFollowed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowRequested", + "columnName": "notificationsFollowRequested", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReblogged", + "columnName": "notificationsReblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFavorited", + "columnName": "notificationsFavorited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsPolls", + "columnName": "notificationsPolls", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSubscriptions", + "columnName": "notificationsSubscriptions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSignUps", + "columnName": "notificationsSignUps", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsUpdates", + "columnName": "notificationsUpdates", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReports", + "columnName": "notificationsReports", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationSound", + "columnName": "notificationSound", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationVibration", + "columnName": "notificationVibration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationLight", + "columnName": "notificationLight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostPrivacy", + "columnName": "defaultPostPrivacy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultMediaSensitivity", + "columnName": "defaultMediaSensitivity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostLanguage", + "columnName": "defaultPostLanguage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "alwaysShowSensitiveMedia", + "columnName": "alwaysShowSensitiveMedia", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysOpenSpoiler", + "columnName": "alwaysOpenSpoiler", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaPreviewEnabled", + "columnName": "mediaPreviewEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastNotificationId", + "columnName": "lastNotificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationMarkerId", + "columnName": "notificationMarkerId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'0'" + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabPreferences", + "columnName": "tabPreferences", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsFilter", + "columnName": "notificationsFilter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "oauthScopes", + "columnName": "oauthScopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unifiedPushUrl", + "columnName": "unifiedPushUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPubKey", + "columnName": "pushPubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPrivKey", + "columnName": "pushPrivKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushAuth", + "columnName": "pushAuth", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushServerKey", + "columnName": "pushServerKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastVisibleHomeTimelineStatusId", + "columnName": "lastVisibleHomeTimelineStatusId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_AccountEntity_domain_accountId", + "unique": true, + "columnNames": [ + "domain", + "accountId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "InstanceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `minPollDuration` INTEGER, `maxPollDuration` INTEGER, `charactersReservedPerUrl` INTEGER, `version` TEXT, `videoSizeLimit` INTEGER, `imageSizeLimit` INTEGER, `imageMatrixLimit` INTEGER, `maxMediaAttachments` INTEGER, `maxFields` INTEGER, `maxFieldNameLength` INTEGER, `maxFieldValueLength` INTEGER, PRIMARY KEY(`instance`))", + "fields": [ + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojiList", + "columnName": "emojiList", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maximumTootCharacters", + "columnName": "maximumTootCharacters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptions", + "columnName": "maxPollOptions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptionLength", + "columnName": "maxPollOptionLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "minPollDuration", + "columnName": "minPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollDuration", + "columnName": "maxPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "charactersReservedPerUrl", + "columnName": "charactersReservedPerUrl", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "videoSizeLimit", + "columnName": "videoSizeLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "imageSizeLimit", + "columnName": "imageSizeLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "imageMatrixLimit", + "columnName": "imageMatrixLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMediaAttachments", + "columnName": "maxMediaAttachments", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFields", + "columnName": "maxFields", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFieldNameLength", + "columnName": "maxFieldNameLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFieldValueLength", + "columnName": "maxFieldValueLength", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "instance" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `editedAt` INTEGER, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `repliesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT, `mentions` TEXT, `tags` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `card` TEXT, `language` TEXT, `filtered` TEXT, `quote` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "editedAt", + "columnName": "editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogsCount", + "columnName": "reblogsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favouritesCount", + "columnName": "favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "repliesCount", + "columnName": "repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reblogged", + "columnName": "reblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarked", + "columnName": "bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favourited", + "columnName": "favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogServerId", + "columnName": "reblogServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "muted", + "columnName": "muted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "expanded", + "columnName": "expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentCollapsed", + "columnName": "contentCollapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentShowing", + "columnName": "contentShowing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pinned", + "columnName": "pinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "card", + "columnName": "card", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filtered", + "columnName": "filtered", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "quote", + "columnName": "quote", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "timelineUserId" + ], + "referencedColumns": [ + "serverId", + "timelineUserId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUsername", + "columnName": "localUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `order` INTEGER NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_editedAt` INTEGER, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_repliesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_tags` TEXT, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, `s_language` TEXT, PRIMARY KEY(`id`, `accountId`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accounts", + "columnName": "accounts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.id", + "columnName": "s_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.url", + "columnName": "s_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToId", + "columnName": "s_inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToAccountId", + "columnName": "s_inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.account", + "columnName": "s_account", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.content", + "columnName": "s_content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.createdAt", + "columnName": "s_createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.editedAt", + "columnName": "s_editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastStatus.emojis", + "columnName": "s_emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.favouritesCount", + "columnName": "s_favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.repliesCount", + "columnName": "s_repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.favourited", + "columnName": "s_favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.bookmarked", + "columnName": "s_bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.sensitive", + "columnName": "s_sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.spoilerText", + "columnName": "s_spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.attachments", + "columnName": "s_attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.mentions", + "columnName": "s_mentions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.tags", + "columnName": "s_tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.showingHiddenContent", + "columnName": "s_showingHiddenContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.expanded", + "columnName": "s_expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsed", + "columnName": "s_collapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.muted", + "columnName": "s_muted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.poll", + "columnName": "s_poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.language", + "columnName": "s_language", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "accountId" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '233a8680f540e9a89950da21532ce85d')" + ] + } +} diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/54.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/54.json new file mode 100644 index 000000000..18c1c1025 --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/54.json @@ -0,0 +1,1022 @@ +{ + "formatVersion": 1, + "database": { + "version": 54, + "identityHash": "9e9b7fcfb0d1bf1a51abc7e8813bd44b", + "entities": [ + { + "tableName": "DraftEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountId` INTEGER NOT NULL, `inReplyToId` TEXT, `content` TEXT, `contentWarning` TEXT, `sensitive` INTEGER NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `poll` TEXT, `failedToSend` INTEGER NOT NULL, `failedToSendNew` INTEGER NOT NULL, `scheduledAt` TEXT, `language` TEXT, `statusId` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentWarning", + "columnName": "contentWarning", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "failedToSend", + "columnName": "failedToSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "failedToSendNew", + "columnName": "failedToSendNew", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scheduledAt", + "columnName": "scheduledAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "statusId", + "columnName": "statusId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `clientId` TEXT, `clientSecret` TEXT, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsSubscriptions` INTEGER NOT NULL, `notificationsSignUps` INTEGER NOT NULL, `notificationsUpdates` INTEGER NOT NULL, `notificationsReports` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `defaultPostLanguage` TEXT NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `notificationMarkerId` TEXT NOT NULL DEFAULT '0', `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL, `oauthScopes` TEXT NOT NULL, `unifiedPushUrl` TEXT NOT NULL, `pushPubKey` TEXT NOT NULL, `pushPrivKey` TEXT NOT NULL, `pushAuth` TEXT NOT NULL, `pushServerKey` TEXT NOT NULL, `lastVisibleHomeTimelineStatusId` TEXT, `locked` INTEGER NOT NULL DEFAULT 0, `hasDirectMessageBadge` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "clientSecret", + "columnName": "clientSecret", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePictureUrl", + "columnName": "profilePictureUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsEnabled", + "columnName": "notificationsEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsMentioned", + "columnName": "notificationsMentioned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowed", + "columnName": "notificationsFollowed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowRequested", + "columnName": "notificationsFollowRequested", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReblogged", + "columnName": "notificationsReblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFavorited", + "columnName": "notificationsFavorited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsPolls", + "columnName": "notificationsPolls", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSubscriptions", + "columnName": "notificationsSubscriptions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSignUps", + "columnName": "notificationsSignUps", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsUpdates", + "columnName": "notificationsUpdates", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReports", + "columnName": "notificationsReports", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationSound", + "columnName": "notificationSound", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationVibration", + "columnName": "notificationVibration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationLight", + "columnName": "notificationLight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostPrivacy", + "columnName": "defaultPostPrivacy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultMediaSensitivity", + "columnName": "defaultMediaSensitivity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostLanguage", + "columnName": "defaultPostLanguage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "alwaysShowSensitiveMedia", + "columnName": "alwaysShowSensitiveMedia", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysOpenSpoiler", + "columnName": "alwaysOpenSpoiler", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaPreviewEnabled", + "columnName": "mediaPreviewEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastNotificationId", + "columnName": "lastNotificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationMarkerId", + "columnName": "notificationMarkerId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'0'" + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabPreferences", + "columnName": "tabPreferences", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsFilter", + "columnName": "notificationsFilter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "oauthScopes", + "columnName": "oauthScopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unifiedPushUrl", + "columnName": "unifiedPushUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPubKey", + "columnName": "pushPubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPrivKey", + "columnName": "pushPrivKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushAuth", + "columnName": "pushAuth", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushServerKey", + "columnName": "pushServerKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastVisibleHomeTimelineStatusId", + "columnName": "lastVisibleHomeTimelineStatusId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "hasDirectMessageBadge", + "columnName": "hasDirectMessageBadge", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_AccountEntity_domain_accountId", + "unique": true, + "columnNames": [ + "domain", + "accountId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "InstanceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `minPollDuration` INTEGER, `maxPollDuration` INTEGER, `charactersReservedPerUrl` INTEGER, `version` TEXT, `videoSizeLimit` INTEGER, `imageSizeLimit` INTEGER, `imageMatrixLimit` INTEGER, `maxMediaAttachments` INTEGER, `maxFields` INTEGER, `maxFieldNameLength` INTEGER, `maxFieldValueLength` INTEGER, PRIMARY KEY(`instance`))", + "fields": [ + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojiList", + "columnName": "emojiList", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maximumTootCharacters", + "columnName": "maximumTootCharacters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptions", + "columnName": "maxPollOptions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptionLength", + "columnName": "maxPollOptionLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "minPollDuration", + "columnName": "minPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollDuration", + "columnName": "maxPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "charactersReservedPerUrl", + "columnName": "charactersReservedPerUrl", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "videoSizeLimit", + "columnName": "videoSizeLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "imageSizeLimit", + "columnName": "imageSizeLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "imageMatrixLimit", + "columnName": "imageMatrixLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMediaAttachments", + "columnName": "maxMediaAttachments", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFields", + "columnName": "maxFields", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFieldNameLength", + "columnName": "maxFieldNameLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFieldValueLength", + "columnName": "maxFieldValueLength", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "instance" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `editedAt` INTEGER, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `repliesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT, `mentions` TEXT, `tags` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `card` TEXT, `language` TEXT, `filtered` TEXT, `quote` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "editedAt", + "columnName": "editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogsCount", + "columnName": "reblogsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favouritesCount", + "columnName": "favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "repliesCount", + "columnName": "repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reblogged", + "columnName": "reblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarked", + "columnName": "bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favourited", + "columnName": "favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogServerId", + "columnName": "reblogServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "muted", + "columnName": "muted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "expanded", + "columnName": "expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentCollapsed", + "columnName": "contentCollapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentShowing", + "columnName": "contentShowing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pinned", + "columnName": "pinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "card", + "columnName": "card", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filtered", + "columnName": "filtered", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "quote", + "columnName": "quote", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "timelineUserId" + ], + "referencedColumns": [ + "serverId", + "timelineUserId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUsername", + "columnName": "localUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `order` INTEGER NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_editedAt` INTEGER, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_repliesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_tags` TEXT, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, `s_language` TEXT, PRIMARY KEY(`id`, `accountId`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accounts", + "columnName": "accounts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.id", + "columnName": "s_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.url", + "columnName": "s_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToId", + "columnName": "s_inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToAccountId", + "columnName": "s_inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.account", + "columnName": "s_account", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.content", + "columnName": "s_content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.createdAt", + "columnName": "s_createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.editedAt", + "columnName": "s_editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastStatus.emojis", + "columnName": "s_emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.favouritesCount", + "columnName": "s_favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.repliesCount", + "columnName": "s_repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.favourited", + "columnName": "s_favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.bookmarked", + "columnName": "s_bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.sensitive", + "columnName": "s_sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.spoilerText", + "columnName": "s_spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.attachments", + "columnName": "s_attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.mentions", + "columnName": "s_mentions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.tags", + "columnName": "s_tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.showingHiddenContent", + "columnName": "s_showingHiddenContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.expanded", + "columnName": "s_expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsed", + "columnName": "s_collapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.muted", + "columnName": "s_muted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.poll", + "columnName": "s_poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.language", + "columnName": "s_language", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "accountId" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9e9b7fcfb0d1bf1a51abc7e8813bd44b')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1ce708fd6..591c5ecb6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -131,7 +131,7 @@ android:theme="@style/Base.Theme.AppCompat" /> @@ -149,7 +149,7 @@ - + @@ -189,8 +189,7 @@ android:icon="@mipmap/ic_launcher" 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/AboutActivity.kt b/app/src/main/java/com/keylesspalace/tusky/AboutActivity.kt index ef995f770..f013726e4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/AboutActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/AboutActivity.kt @@ -1,6 +1,10 @@ package com.keylesspalace.tusky +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context import android.content.Intent +import android.os.Build import android.os.Bundle import android.text.SpannableString import android.text.SpannableStringBuilder @@ -8,14 +12,22 @@ import android.text.method.LinkMovementMethod import android.text.style.URLSpan import android.text.util.Linkify import android.widget.TextView +import android.widget.Toast import androidx.annotation.StringRes +import androidx.lifecycle.lifecycleScope +import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository import com.keylesspalace.tusky.databinding.ActivityAboutBinding import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.util.NoUnderlineURLSpan import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.show +import kotlinx.coroutines.launch +import javax.inject.Inject import net.accelf.yuito.AccessTokenLoginActivity class AboutActivity : BottomSheetActivity(), Injectable { + @Inject + lateinit var instanceInfoRepository: InstanceInfoRepository override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -37,6 +49,28 @@ class AboutActivity : BottomSheetActivity(), Injectable { binding.versionTextView.text = getString(R.string.about_app_version, getString(R.string.app_name), BuildConfig.VERSION_NAME) + binding.deviceInfo.text = getString( + R.string.about_device_info, + Build.MANUFACTURER, + Build.MODEL, + Build.VERSION.RELEASE, + Build.VERSION.SDK_INT + ) + + lifecycleScope.launch { + accountManager.activeAccount?.let { account -> + val instanceInfo = instanceInfoRepository.getInstanceInfo() + binding.accountInfo.text = getString( + R.string.about_account_info, + account.username, + account.domain, + instanceInfo.version + ) + binding.accountInfoTitle.show() + binding.accountInfo.show() + } + } + if (BuildConfig.CUSTOM_INSTANCE.isBlank()) { binding.aboutPoweredByTusky.hide() } @@ -53,6 +87,16 @@ class AboutActivity : BottomSheetActivity(), Injectable { binding.aboutLicensesButton.setOnClickListener { startActivityWithSlideInAnimation(Intent(this, LicenseActivity::class.java)) } + + binding.copyDeviceInfo.setOnClickListener { + val text = "${binding.versionTextView.text}\n\nDevice:\n\n${binding.deviceInfo.text}\n\nAccount:\n\n${binding.accountInfo.text}" + val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("Tusky version information", text) + clipboard.setPrimaryClip(clip) + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) { + Toast.makeText(this, getString(R.string.about_copied), Toast.LENGTH_SHORT).show() + } + } } private fun onEasterEggExecute() { diff --git a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java index 62709d7c3..a8944ce9b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java @@ -47,6 +47,7 @@ import com.keylesspalace.tusky.db.AccountManager; import com.keylesspalace.tusky.di.Injectable; import com.keylesspalace.tusky.interfaces.AccountSelectionListener; import com.keylesspalace.tusky.interfaces.PermissionRequester; +import com.keylesspalace.tusky.settings.AppTheme; import com.keylesspalace.tusky.settings.PrefKeys; import com.keylesspalace.tusky.util.ThemeUtils; @@ -56,10 +57,13 @@ import java.util.List; 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 +78,9 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab /* 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, AppTheme.DEFAULT.getValue()); Log.d("activeTheme", theme); - if (theme.equals("black")) { + if (ThemeUtils.isBlack(getResources().getConfiguration(), theme)) { setTheme(R.style.TuskyBlackTheme); } @@ -87,7 +91,7 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab 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 +166,13 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab 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 +200,7 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab } } - 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 +208,7 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab } } - 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(); @@ -256,9 +260,8 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab public void openAsAccount(@NonNull String url, @NonNull AccountEntity account) { accountManager.setActiveAccount(account.getId()); - Intent intent = new Intent(this, MainActivity.class); - intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); - intent.putExtra(MainActivity.REDIRECT_URL, url); + Intent intent = MainActivity.redirectIntent(this, account.getId(), url); + startActivity(intent); finishWithoutSlideOutAnimation(); } @@ -272,7 +275,7 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab } } - 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/BottomSheetActivity.kt b/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt index 08bd222a7..db585fec3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt @@ -26,6 +26,7 @@ import androidx.lifecycle.Lifecycle import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider import autodispose2.autoDispose import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.components.account.AccountActivity import com.keylesspalace.tusky.components.viewthread.ViewThreadActivity import com.keylesspalace.tusky.network.MastodonApi @@ -94,7 +95,7 @@ abstract class BottomSheetActivity : BaseActivity() { viewThread(statuses[0].id, statuses[0].url) return@subscribe } - accounts.firstOrNull { it.url == url }?.let { account -> + accounts.firstOrNull { it.url.equals(url, ignoreCase = true) }?.let { account -> // Some servers return (unrelated) accounts for url searches (#2804) // Verify that the account's url matches the query viewAccount(account.id) diff --git a/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt b/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt index 674b51bff..0aea2723c 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 ee0187986..955c2bd5f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt @@ -23,8 +23,6 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.EditText -import android.widget.FrameLayout import android.widget.ImageButton import android.widget.PopupMenu import android.widget.TextView @@ -38,10 +36,10 @@ import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView -import at.connyduck.sparkbutton.helpers.Utils import com.google.android.material.color.MaterialColors import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.databinding.ActivityListsBinding +import com.keylesspalace.tusky.databinding.DialogListBinding import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.entity.MastoList @@ -118,7 +116,7 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { viewModel.events.collect { event -> when (event) { Event.CREATE_ERROR -> showMessage(R.string.error_create_list) - Event.RENAME_ERROR -> showMessage(R.string.error_rename_list) + Event.UPDATE_ERROR -> showMessage(R.string.error_rename_list) Event.DELETE_ERROR -> showMessage(R.string.error_delete_list) } } @@ -126,16 +124,11 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { } private fun showlistNameDialog(list: MastoList?) { - val layout = FrameLayout(this) - val editText = EditText(this) - editText.setHint(R.string.hint_list_name) - layout.addView(editText) - val margin = Utils.dpToPx(this, 8) - (editText.layoutParams as ViewGroup.MarginLayoutParams) - .setMargins(margin, margin, margin, 0) - + val binding = DialogListBinding.inflate(layoutInflater).apply { + replyPolicySpinner.setSelection(MastoList.ReplyPolicy.from(list?.repliesPolicy).ordinal) + } val dialog = AlertDialog.Builder(this) - .setView(layout) + .setView(binding.root) .setPositiveButton( if (list == null) { R.string.action_create_list @@ -143,17 +136,31 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { R.string.action_rename_list } ) { _, _ -> - onPickedDialogName(editText.text, list?.id) + onPickedDialogName( + binding.nameText.text.toString(), + list?.id, + binding.exclusiveCheckbox.isChecked, + MastoList.ReplyPolicy.entries[binding.replyPolicySpinner.selectedItemPosition].policy + ) } .setNegativeButton(android.R.string.cancel, null) .show() - val positiveButton = dialog.getButton(Dialog.BUTTON_POSITIVE) - editText.doOnTextChanged { s, _, _, _ -> - positiveButton.isEnabled = s?.isNotBlank() == true + binding.nameText.let { editText -> + editText.doOnTextChanged { s, _, _, _ -> + dialog.getButton(Dialog.BUTTON_POSITIVE).isEnabled = s?.isNotBlank() == true + } + editText.setText(list?.title) + editText.text?.let { editText.setSelection(it.length) } + } + + list?.let { + if (it.exclusive == null) { + binding.exclusiveCheckbox.visible(false) + } else { + binding.exclusiveCheckbox.isChecked = it.exclusive + } } - editText.setText(list?.title) - editText.text?.let { editText.setSelection(it.length) } } private fun showListDeleteDialog(list: MastoList) { @@ -174,13 +181,13 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { INITIAL, LOADING -> binding.messageView.hide() ERROR_NETWORK -> { binding.messageView.show() - binding.messageView.setup(R.drawable.elephant_offline, R.string.error_network) { + binding.messageView.setup(R.drawable.errorphant_offline, R.string.error_network) { viewModel.retryLoading() } } ERROR_OTHER -> { binding.messageView.show() - binding.messageView.setup(R.drawable.elephant_error, R.string.error_generic) { + binding.messageView.setup(R.drawable.errorphant_error, R.string.error_generic) { viewModel.retryLoading() } } @@ -192,6 +199,7 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { R.string.message_empty, null ) + binding.messageView.showHelp(R.string.help_empty_lists) } else { binding.messageView.hide() } @@ -226,7 +234,7 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { setOnMenuItemClickListener { item -> when (item.itemId) { R.id.list_edit -> openListSettings(list) - R.id.list_rename -> renameListDialog(list) + R.id.list_update -> renameListDialog(list) R.id.list_delete -> showListDeleteDialog(list) else -> return@setOnMenuItemClickListener false } @@ -287,11 +295,11 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { } } - private fun onPickedDialogName(name: CharSequence, listId: String?) { + private fun onPickedDialogName(name: String, listId: String?, exclusive: Boolean, replyPolicy: String) { if (listId == null) { - viewModel.createNewList(name.toString()) + viewModel.createNewList(name, exclusive, replyPolicy) } else { - viewModel.renameList(listId, name.toString()) + viewModel.updateList(listId, name, exclusive, replyPolicy) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt index ec0990fab..36014e28a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt @@ -17,6 +17,7 @@ package com.keylesspalace.tusky import android.Manifest import android.annotation.SuppressLint +import android.app.NotificationManager import android.content.Context import android.content.DialogInterface import android.content.Intent @@ -30,6 +31,7 @@ import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.Drawable import android.graphics.drawable.InsetDrawable import android.net.Uri +import android.os.Build import android.os.Bundle import android.text.TextUtils import android.util.Log @@ -38,6 +40,7 @@ import android.view.KeyEvent import android.view.Menu import android.view.MenuInflater import android.view.MenuItem +import android.view.MenuItem.SHOW_AS_ACTION_NEVER import android.view.View import android.view.WindowManager import android.widget.ImageView @@ -50,9 +53,12 @@ import androidx.appcompat.widget.PopupMenu import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat +import androidx.core.content.IntentCompat import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.view.GravityCompat import androidx.core.view.MenuProvider +import androidx.core.view.forEach +import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope import androidx.preference.PreferenceManager import androidx.viewpager2.widget.MarginPageTransformer @@ -69,8 +75,11 @@ import com.google.android.material.tabs.TabLayout.OnTabSelectedListener import com.google.android.material.tabs.TabLayoutMediator import com.keylesspalace.tusky.appstore.AnnouncementReadEvent import com.keylesspalace.tusky.appstore.CacheUpdater +import com.keylesspalace.tusky.appstore.ConversationsLoadingEvent import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.MainTabsChangedEvent +import com.keylesspalace.tusky.appstore.NewNotificationsEvent +import com.keylesspalace.tusky.appstore.NotificationsLoadingEvent import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.appstore.ProfileEditedEvent import com.keylesspalace.tusky.components.account.AccountActivity @@ -81,7 +90,6 @@ import com.keylesspalace.tusky.components.compose.ComposeActivity.Companion.canH import com.keylesspalace.tusky.components.drafts.DraftsActivity import com.keylesspalace.tusky.components.login.LoginActivity import com.keylesspalace.tusky.components.notifications.NotificationHelper -import com.keylesspalace.tusky.components.notifications.NotificationsFragment import com.keylesspalace.tusky.components.notifications.disableAllNotifications import com.keylesspalace.tusky.components.notifications.enablePushNotificationsWithFallback import com.keylesspalace.tusky.components.notifications.showMigrationNoticeIfNecessary @@ -96,6 +104,8 @@ import com.keylesspalace.tusky.db.DraftsAlert import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.fragment.NotificationsFragment import com.keylesspalace.tusky.interfaces.AccountSelectionListener import com.keylesspalace.tusky.interfaces.ActionButtonActivity import com.keylesspalace.tusky.interfaces.FabFragment @@ -115,7 +125,6 @@ import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.unsafeLazy import com.keylesspalace.tusky.util.updateShortcut import com.keylesspalace.tusky.util.viewBinding -import com.keylesspalace.tusky.util.visible import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.utils.colorInt @@ -186,7 +195,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje private lateinit var header: AccountHeaderView - private var notificationTabPosition = 0 private var onTabSelectedListener: OnTabSelectedListener? = null private var unreadAnnouncementsCount = 0 @@ -195,8 +203,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje private lateinit var glide: RequestManager - private var accountLocked: Boolean = false - // We need to know if the emoji pack has been changed private var selectedEmojiPack: String? = null @@ -206,6 +212,10 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje /** Adapter for the different timeline tabs */ private lateinit var tabAdapter: MainPagerAdapter + private var directMessageTab: TabLayout.Tab? = null + + @Suppress("DEPRECATION") + @SuppressLint("RestrictedApi") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -213,30 +223,39 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje ?: return // will be redirected to LoginActivity by BaseActivity var showNotificationTab = false - if (intent != null) { + + // check for savedInstanceState in order to not handle intent events more than once + if (intent != null && savedInstanceState == null) { + val notificationId = intent.getIntExtra(NOTIFICATION_ID, -1) + if (notificationId != -1) { + // opened from a notification action, cancel the notification + val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager + notificationManager.cancel(intent.getStringExtra(NOTIFICATION_TAG), notificationId) + } + /** there are two possibilities the accountId can be passed to MainActivity: - * - from our code as long 'account_id' + * - from our code as Long Intent Extra TUSKY_ACCOUNT_ID * - from share shortcuts as String 'android.intent.extra.shortcut.ID' */ - var accountId = intent.getLongExtra(NotificationHelper.ACCOUNT_ID, -1) - if (accountId == -1L) { + var tuskyAccountId = intent.getLongExtra(TUSKY_ACCOUNT_ID, -1) + if (tuskyAccountId == -1L) { val accountIdString = intent.getStringExtra(ShortcutManagerCompat.EXTRA_SHORTCUT_ID) if (accountIdString != null) { - accountId = accountIdString.toLong() + tuskyAccountId = accountIdString.toLong() } } - val accountRequested = accountId != -1L - if (accountRequested && accountId != activeAccount.id) { - accountManager.setActiveAccount(accountId) + val accountRequested = tuskyAccountId != -1L + if (accountRequested && tuskyAccountId != activeAccount.id) { + accountManager.setActiveAccount(tuskyAccountId) } val openDrafts = intent.getBooleanExtra(OPEN_DRAFTS, false) - if (canHandleMimeType(intent.type)) { + if (canHandleMimeType(intent.type) || intent.hasExtra(COMPOSE_OPTIONS)) { // Sharing to Tusky from an external app if (accountRequested) { // The correct account is already active - forwardShare(intent) + forwardToComposeActivity(intent) } else { // No account was provided, show the chooser showAccountChooserDialog( @@ -247,10 +266,10 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje val requestedId = account.id if (requestedId == activeAccount.id) { // The correct account is already active - forwardShare(intent) + forwardToComposeActivity(intent) } else { // A different account was requested, restart the activity - intent.putExtra(NotificationHelper.ACCOUNT_ID, requestedId) + intent.putExtra(TUSKY_ACCOUNT_ID, requestedId) changeAccount(requestedId, intent) } } @@ -260,11 +279,11 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje } else if (openDrafts) { val intent = DraftsActivity.newIntent(this) startActivity(intent) - } else if (accountRequested && savedInstanceState == null) { + } else if (accountRequested && intent.hasExtra(NOTIFICATION_TYPE)) { // user clicked a notification, show follow requests for type FOLLOW_REQUEST, // otherwise show notification tab - if (intent.getStringExtra(NotificationHelper.TYPE) == Notification.Type.FOLLOW_REQUEST.name) { - val intent = AccountListActivity.newIntent(this, AccountListActivity.Type.FOLLOW_REQUESTS, accountLocked = true) + if (intent.getStringExtra(NOTIFICATION_TYPE) == Notification.Type.FOLLOW_REQUEST.name) { + val intent = AccountListActivity.newIntent(this, AccountListActivity.Type.FOLLOW_REQUESTS) startActivityWithSlideInAnimation(intent) } else { showNotificationTab = true @@ -273,15 +292,27 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje } window.statusBarColor = Color.TRANSPARENT // don't draw a status bar, the DrawerLayout and the MaterialDrawerLayout have their own setContentView(binding.root) - setSupportActionBar(binding.mainToolbar) glide = Glide.with(this) binding.viewQuickToot.attachViewModel(quickTootViewModel, this) binding.composeButton.setOnClickListener(binding.viewQuickToot::onFABClicked) + // Determine which of the three toolbars should be the supportActionBar (which hosts + // the options menu). val hideTopToolbar = preferences.getBoolean(PrefKeys.HIDE_TOP_TOOLBAR, false) - binding.mainToolbar.visible(!hideTopToolbar) + if (hideTopToolbar) { + when (preferences.getString(PrefKeys.MAIN_NAV_POSITION, "top")) { + "top" -> setSupportActionBar(binding.topNav) + "bottom" -> setSupportActionBar(binding.bottomNav) + } + binding.mainToolbar.hide() + // There's not enough space in the top/bottom bars to show the title as well. + supportActionBar?.setDisplayShowTitleEnabled(false) + } else { + setSupportActionBar(binding.mainToolbar) + binding.mainToolbar.show() + } loadDrawerAvatar(activeAccount.profilePictureUrl, true) @@ -292,7 +323,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje setupDrawer( savedInstanceState, addSearchButton = hideTopToolbar, - addTrendingButton = !accountManager.activeAccount!!.tabPreferences.hasTab(TRENDING) + 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 @@ -321,16 +353,38 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje is MainTabsChangedEvent -> { refreshMainDrawerItems( addSearchButton = hideTopToolbar, - addTrendingButton = !event.newTabs.hasTab(TRENDING) + addTrendingTagsButton = !event.newTabs.hasTab(TRENDING_TAGS), + addTrendingStatusesButton = !event.newTabs.hasTab(TRENDING_STATUSES), ) setupTabs(false) } - is AnnouncementReadEvent -> { unreadAnnouncementsCount-- updateAnnouncementsBadge() } + is NewNotificationsEvent -> { + directMessageTab?.let { tab -> + if (event.accountId == activeAccount.accountId) { + val hasDirectMessageNotification = + event.notifications.any { it.type == Notification.Type.MENTION && it.status?.visibility == Status.Visibility.DIRECT } + + if (hasDirectMessageNotification) { + showDirectMessageBadge(true) + } + } + } + } + is NotificationsLoadingEvent -> { + if (event.accountId == activeAccount.accountId) { + showDirectMessageBadge(false) + } + } + is ConversationsLoadingEvent -> { + if (event.accountId == activeAccount.accountId) { + showDirectMessageBadge(false) + } + } } binding.viewQuickToot.handleEvent(event) } @@ -362,7 +416,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), @@ -374,6 +431,20 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje draftsAlert.observeInContext(this, true) } + private fun showDirectMessageBadge(showBadge: Boolean) { + directMessageTab?.let { tab -> + tab.badge?.isVisible = showBadge + + // TODO a bit cumbersome (also for resetting) + accountManager.activeAccount?.let { + if (it.hasDirectMessageBadge != showBadge) { + it.hasDirectMessageBadge = showBadge + accountManager.saveAccount(it) + } + } + } + } + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.activity_main, menu) menu.findItem(R.id.action_search)?.apply { @@ -384,6 +455,14 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje } } + override fun onPrepareMenu(menu: Menu) { + super.onPrepareMenu(menu) + + // If the main toolbar is hidden then there's no space in the top/bottomNav to show + // the menu items as icons, so forceably disable them + if (!binding.mainToolbar.isVisible) menu.forEach { it.setShowAsAction(SHOW_AS_ACTION_NEVER) } + } + override fun onMenuItemSelected(item: MenuItem): Boolean { return when (item.itemId) { R.id.action_search -> { @@ -456,12 +535,19 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje } } - private fun forwardShare(intent: Intent) { - val composeIntent = Intent(this, ComposeActivity::class.java) - composeIntent.action = intent.action - composeIntent.type = intent.type - composeIntent.putExtras(intent) - composeIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + private fun forwardToComposeActivity(intent: Intent) { + val composeOptions = IntentCompat.getParcelableExtra(intent, COMPOSE_OPTIONS, ComposeActivity.ComposeOptions::class.java) + + val composeIntent = if (composeOptions != null) { + ComposeActivity.startIntent(this, composeOptions) + } else { + Intent(this, ComposeActivity::class.java).apply { + action = intent.action + type = intent.type + putExtras(intent) + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + } startActivity(composeIntent) finish() } @@ -469,13 +555,14 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje private fun setupDrawer( savedInstanceState: Bundle?, addSearchButton: Boolean, - addTrendingButton: Boolean + addTrendingTagsButton: Boolean, + addTrendingStatusesButton: Boolean, ) { val drawerOpenClickListener = View.OnClickListener { binding.mainDrawerLayout.open() } binding.mainToolbar.setNavigationOnClickListener(drawerOpenClickListener) - binding.topNavAvatar.setOnClickListener(drawerOpenClickListener) - binding.bottomNavAvatar.setOnClickListener(drawerOpenClickListener) + binding.topNav.setNavigationOnClickListener(drawerOpenClickListener) + binding.bottomNav.setNavigationOnClickListener(drawerOpenClickListener) header = AccountHeaderView(this).apply { headerBackgroundScaleType = ImageView.ScaleType.CENTER_CROP @@ -500,7 +587,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?) { @@ -522,7 +609,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) @@ -530,12 +617,20 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje }) binding.mainDrawer.apply { - refreshMainDrawerItems(addSearchButton, addTrendingButton) + refreshMainDrawerItems( + addSearchButton = addSearchButton, + addTrendingTagsButton = addTrendingTagsButton, + addTrendingStatusesButton = addTrendingStatusesButton, + ) setSavedInstance(savedInstanceState) } } - private fun refreshMainDrawerItems(addSearchButton: Boolean, addTrendingButton: Boolean) { + private fun refreshMainDrawerItems( + addSearchButton: Boolean, + addTrendingTagsButton: Boolean, + addTrendingStatusesButton: Boolean, + ) { binding.mainDrawer.apply { itemAdapter.clear() tintStatusBar = true @@ -569,7 +664,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje nameRes = R.string.action_view_follow_requests iconicsIcon = GoogleMaterial.Icon.gmd_person_add onClick = { - val intent = AccountListActivity.newIntent(context, AccountListActivity.Type.FOLLOW_REQUESTS, accountLocked = accountLocked) + val intent = AccountListActivity.newIntent(context, AccountListActivity.Type.FOLLOW_REQUESTS) startActivityWithSlideInAnimation(intent) } }, @@ -657,7 +752,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje ) } - if (addTrendingButton) { + if (addTrendingTagsButton) { binding.mainDrawer.addItemsAtPosition( 5, primaryDrawerItem { @@ -669,6 +764,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) { @@ -747,6 +855,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje // Detach any existing mediator before changing tab contents and attaching a new mediator tabLayoutMediator?.detach() + directMessageTab = null + tabAdapter.tabs = tabs tabAdapter.notifyItemRangeChanged(0, tabs.size) @@ -763,6 +873,11 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje if (popups[position] != null) { return@TabLayoutMediator } + if (tabs[position].id == DIRECT) { + tab.orCreateBadge + tab.badge?.isVisible = accountManager.activeAccount?.hasDirectMessageBadge ?: false + directMessageTab = tab + } val popup = PopupMenu(this, tab.view) popup.menuInflater.inflate(R.menu.view_tab_action, popup.menu) @@ -859,6 +974,17 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje binding.mainToolbar.title = tab.contentDescription refreshComposeButtonState(tabAdapter, tab.position) + + if (tab == directMessageTab) { + tab.badge?.isVisible = false + + accountManager.activeAccount?.let { + if (it.hasDirectMessageBadge) { + it.hasDirectMessageBadge = false + accountManager.saveAccount(it) + } + } + } } override fun onTabUnselected(tab: TabLayout.Tab) {} @@ -870,8 +996,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje activeTabLayout.addOnTabSelectedListener(it) } - val activeTabPosition = if (selectNotificationTab) notificationTabPosition else 0 - supportActionBar?.title = tabs[activeTabPosition].title(this@MainActivity) + supportActionBar?.title = tabs[position].title(this@MainActivity) binding.mainToolbar.setOnClickListener { (tabAdapter.getFragment(activeTabLayout.selectedTabPosition) as? ReselectableFragment)?.onReselect() } @@ -998,122 +1123,84 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje disableAllNotifications(this, accountManager) } - accountLocked = me.locked - updateProfiles() 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) - if (hideTopToolbar) { - val navOnBottom = preferences.getString("mainNavPosition", "top") == "bottom" - - val avatarView = if (navOnBottom) { - binding.bottomNavAvatar.show() - binding.bottomNavAvatar + val activeToolbar = if (hideTopToolbar) { + val navOnBottom = preferences.getString(PrefKeys.MAIN_NAV_POSITION, "top") == "bottom" + if (navOnBottom) { + binding.bottomNav } else { - binding.topNavAvatar.show() - binding.topNavAvatar - } - - if (animateAvatars) { - Glide.with(this) - .load(avatarUrl) - .placeholder(R.drawable.avatar_default) - .into(avatarView) - } else { - Glide.with(this) - .asBitmap() - .load(avatarUrl) - .placeholder(R.drawable.avatar_default) - .into(avatarView) + binding.topNav } } else { - binding.bottomNavAvatar.hide() - binding.topNavAvatar.hide() + binding.mainToolbar + } - val navIconSize = resources.getDimensionPixelSize(R.dimen.avatar_toolbar_nav_icon_size) + val navIconSize = resources.getDimensionPixelSize(R.dimen.avatar_toolbar_nav_icon_size) - if (animateAvatars) { - glide.asDrawable() - .load(avatarUrl) - .transform( - RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp)) - ) - .apply { - if (showPlaceholder) { - placeholder(R.drawable.avatar_default) + if (animateAvatars) { + glide.asDrawable().load(avatarUrl).transform(RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp))) + .apply { + if (showPlaceholder) placeholder(R.drawable.avatar_default) + } + .into(object : CustomTarget(navIconSize, navIconSize) { + + override fun onLoadStarted(placeholder: Drawable?) { + placeholder?.let { + activeToolbar.navigationIcon = FixedSizeDrawable(it, navIconSize, navIconSize) } } - .into(object : CustomTarget(navIconSize, navIconSize) { - override fun onLoadStarted(placeholder: Drawable?) { - if (placeholder != null) { - binding.mainToolbar.navigationIcon = - FixedSizeDrawable(placeholder, navIconSize, navIconSize) - } - } + override fun onResourceReady( + resource: Drawable, + transition: Transition? + ) { + if (resource is Animatable) resource.start() + activeToolbar.navigationIcon = FixedSizeDrawable(resource, navIconSize, navIconSize) + } - override fun onResourceReady( - resource: Drawable, - transition: Transition? - ) { - if (resource is Animatable) { - resource.start() - } - binding.mainToolbar.navigationIcon = - FixedSizeDrawable(resource, navIconSize, navIconSize) - } - - override fun onLoadCleared(placeholder: Drawable?) { - if (placeholder != null) { - binding.mainToolbar.navigationIcon = - FixedSizeDrawable(placeholder, navIconSize, navIconSize) - } - } - }) - } else { - glide.asBitmap() - .load(avatarUrl) - .transform( - RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp)) - ) - .apply { - if (showPlaceholder) { - placeholder(R.drawable.avatar_default) + override fun onLoadCleared(placeholder: Drawable?) { + placeholder?.let { + activeToolbar.navigationIcon = FixedSizeDrawable(it, navIconSize, navIconSize) } } - .into(object : CustomTarget(navIconSize, navIconSize) { - - override fun onLoadStarted(placeholder: Drawable?) { - if (placeholder != null) { - binding.mainToolbar.navigationIcon = - FixedSizeDrawable(placeholder, navIconSize, navIconSize) - } + }) + } else { + glide.asBitmap().load(avatarUrl).transform(RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp))) + .apply { + if (showPlaceholder) placeholder(R.drawable.avatar_default) + } + .into(object : CustomTarget(navIconSize, navIconSize) { + override fun onLoadStarted(placeholder: Drawable?) { + placeholder?.let { + activeToolbar.navigationIcon = FixedSizeDrawable(it, navIconSize, navIconSize) } + } - override fun onResourceReady( - resource: Bitmap, - transition: Transition? - ) { - binding.mainToolbar.navigationIcon = FixedSizeDrawable( - BitmapDrawable(resources, resource), - navIconSize, - navIconSize - ) - } + override fun onResourceReady( + resource: Bitmap, + transition: Transition? + ) { + activeToolbar.navigationIcon = FixedSizeDrawable( + BitmapDrawable(resources, resource), + navIconSize, + navIconSize + ) + } - override fun onLoadCleared(placeholder: Drawable?) { - if (placeholder != null) { - binding.mainToolbar.navigationIcon = - FixedSizeDrawable(placeholder, navIconSize, navIconSize) - } + override fun onLoadCleared(placeholder: Drawable?) { + placeholder?.let { + activeToolbar.navigationIcon = FixedSizeDrawable(it, navIconSize, navIconSize) } - }) - } + } + }) } } @@ -1175,8 +1262,75 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje private const val TAG = "MainActivity" // logging tag private const val DRAWER_ITEM_ADD_ACCOUNT: Long = -13 private const val DRAWER_ITEM_ANNOUNCEMENTS: Long = 14 - const val REDIRECT_URL = "redirectUrl" - const val OPEN_DRAFTS = "draft" + private const val REDIRECT_URL = "redirectUrl" + private const val OPEN_DRAFTS = "draft" + private const val TUSKY_ACCOUNT_ID = "tuskyAccountId" + private const val COMPOSE_OPTIONS = "composeOptions" + private const val NOTIFICATION_TYPE = "notificationType" + private const val NOTIFICATION_TAG = "notificationTag" + private const val NOTIFICATION_ID = "notificationId" + + /** + * Switches the active account to the provided accountId and then stays on MainActivity + */ + @JvmStatic + fun accountSwitchIntent(context: Context, tuskyAccountId: Long): Intent { + return Intent(context, MainActivity::class.java).apply { + putExtra(TUSKY_ACCOUNT_ID, tuskyAccountId) + } + } + + /** + * Switches the active account to the accountId and takes the user to the correct place according to the notification they clicked + */ + @JvmStatic + fun openNotificationIntent(context: Context, tuskyAccountId: Long, type: Notification.Type): Intent { + return accountSwitchIntent(context, tuskyAccountId).apply { + putExtra(NOTIFICATION_TYPE, type.name) + } + } + + /** + * Switches the active account to the accountId and then opens ComposeActivity with the provided options + * @param tuskyAccountId the id of the Tusky account to open the screen with. Set to -1 for current account. + * @param notificationId optional id of the notification that should be cancelled when this intent is opened + * @param notificationTag optional tag of the notification that should be cancelled when this intent is opened + */ + @JvmStatic + fun composeIntent( + context: Context, + options: ComposeActivity.ComposeOptions, + tuskyAccountId: Long = -1, + notificationTag: String? = null, + notificationId: Int = -1 + ): Intent { + return accountSwitchIntent(context, tuskyAccountId).apply { + action = Intent.ACTION_SEND // so it can be opened via shortcuts + putExtra(COMPOSE_OPTIONS, options) + putExtra(NOTIFICATION_TAG, notificationTag) + putExtra(NOTIFICATION_ID, notificationId) + } + } + + /** + * switches the active account to the accountId and then tries to resolve and show the provided url + */ + @JvmStatic + fun redirectIntent(context: Context, tuskyAccountId: Long, url: String): Intent { + return accountSwitchIntent(context, tuskyAccountId).apply { + putExtra(REDIRECT_URL, url) + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + } + + /** + * switches the active account to the provided accountId and then opens drafts + */ + fun draftIntent(context: Context, tuskyAccountId: Long): Intent { + return accountSwitchIntent(context, tuskyAccountId).apply { + putExtra(OPEN_DRAFTS, true) + } + } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt b/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt index 3244cd21b..69e971ce5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt @@ -28,18 +28,20 @@ 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 import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.FilterV1 +import com.keylesspalace.tusky.util.isHttpNotFound import com.keylesspalace.tusky.util.viewBinding import dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector import kotlinx.coroutines.launch import net.accelf.yuito.QuickTootViewModel -import retrofit2.HttpException import javax.inject.Inject class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { @@ -81,6 +83,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) } @@ -146,6 +149,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() @@ -166,6 +171,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() @@ -183,6 +190,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { */ private fun updateMuteTagMenuItems() { val tag = hashtag ?: return + val hashedTag = "#$tag" muteTagItem?.isVisible = true muteTagItem?.isEnabled = false @@ -192,18 +200,17 @@ 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) }, { throwable -> - if (throwable is HttpException && throwable.code() == 404) { + if (throwable.isHttpNotFound()) { 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) }, @@ -235,6 +242,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), @@ -242,19 +252,22 @@ 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") } }, { throwable -> - if (throwable is HttpException && throwable.code() == 404) { + if (throwable.isHttpNotFound()) { mastodonApi.createFilterV1( - tag, + hashedTag, listOf(FilterV1.HOME), irreversible = false, wholeWord = true, @@ -262,8 +275,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() @@ -276,6 +289,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 @@ -321,6 +352,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() @@ -365,5 +398,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 b5276cb65..84aaae404 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.TrendingFragment +import com.keylesspalace.tusky.components.trending.TrendingTagsFragment +import com.keylesspalace.tusky.fragment.NotificationsFragment import net.accelf.yuito.streaming.StreamType import net.accelf.yuito.streaming.Subscription import java.util.Objects @@ -35,9 +35,11 @@ const val NOTIFICATIONS = "Notifications" const val LOCAL = "Local" const val FEDERATED = "Federated" const val DIRECT = "Direct" -const val TRENDING = "Trending" +const val TRENDING_TAGS = "TrendingTags" +const val TRENDING_STATUSES = "TrendingStatuses" const val HASHTAG = "Hashtag" const val LIST = "List" +const val BOOKMARKS = "Bookmarks" const val STREAMING = "STR" @@ -71,9 +73,7 @@ data class TabData( other as TabData if (id != other.id) return false - if (arguments != other.arguments) return false - - return true + return arguments == other.arguments } override fun hashCode() = Objects.hash(id, arguments) @@ -117,11 +117,17 @@ fun createTabDataFromId(id: String, arguments: List = emptyList()): TabD icon = R.drawable.ic_reblog_direct_24dp, fragment = { ConversationsFragment.newInstance() } ) - TRENDING -> TabData( - id = TRENDING, + TRENDING_TAGS -> TabData( + id = TRENDING_TAGS, text = R.string.title_public_trending_hashtags, icon = R.drawable.ic_trending_up_24px, - fragment = { TrendingFragment.newInstance() } + 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, @@ -140,6 +146,12 @@ fun createTabDataFromId(id: String, arguments: List = emptyList()): TabD title = { arguments.getOrNull(1).orEmpty() }, enableStreaming = enableStreaming, ) + 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 3c8ecb239..fda25644f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt @@ -164,18 +164,12 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene toggleFab(false) } - binding.maxTabsInfo.text = resources.getQuantityString(R.plurals.max_tab_number_reached, MAX_TAB_COUNT, MAX_TAB_COUNT) - updateAvailableTabs() onBackPressedDispatcher.addCallback(onFabDismissedCallback) } override fun onTabAdded(tab: TabData) { - if (currentTabs.size >= MAX_TAB_COUNT) { - return - } - toggleFab(false) if (tab.id == HASHTAG) { @@ -386,17 +380,23 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene if (!currentTabs.contains(directMessagesTab)) { addableTabs.add(directMessagesTab) } - val trendingTab = createTabDataFromId(TRENDING) - if (!currentTabs.contains(trendingTab)) { - addableTabs.add(trendingTab) + val trendingTagsTab = createTabDataFromId(TRENDING_TAGS) + 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)) addTabAdapter.updateData(addableTabs) - - binding.maxTabsInfo.visible(addableTabs.size == 0 || currentTabs.size >= MAX_TAB_COUNT) currentTabsAdapter.setRemoveButtonVisible(currentTabs.size > MIN_TAB_COUNT) } @@ -429,6 +429,5 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene companion object { private const val MIN_TAB_COUNT = 2 - private const val MAX_TAB_COUNT = 9 } } diff --git a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt index e7c646991..37efe1dbc 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt @@ -25,9 +25,11 @@ 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.AppTheme +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.setAppNightMode import com.keylesspalace.tusky.worker.PruneCacheWorker @@ -76,7 +78,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 +89,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, AppTheme.DEFAULT.value) setAppNightMode(theme) localeManager.setLocale() @@ -130,6 +132,20 @@ class TuskyApplication : Application(), HasAndroidInjector { editor.remove(PrefKeys.MEDIA_PREVIEW_ENABLED) } + if (oldVersion < 2023072401) { + // The notifications filter / clear options are shown on a menu, not a separate bar, + // the preference to display them is not needed. + 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, AppTheme.NIGHT.value) + } + } editor.putInt(PrefKeys.SCHEMA_VERSION, newVersion) editor.apply() } diff --git a/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt b/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt index 1e017827c..88b330c84 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt @@ -59,6 +59,8 @@ import com.keylesspalace.tusky.pager.SingleImagePagerAdapter import com.keylesspalace.tusky.util.getTemporaryMediaFilename import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.viewdata.AttachmentViewData +import dagger.android.DispatchingAndroidInjector +import dagger.android.HasAndroidInjector import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.schedulers.Schedulers @@ -67,10 +69,13 @@ import java.io.FileNotFoundException import java.io.FileOutputStream import java.io.IOException import java.util.Locale +import javax.inject.Inject typealias ToolbarVisibilityListener = (isVisible: Boolean) -> Unit -class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener, ViewVideoFragment.VideoActionsListener { +class ViewMediaActivity : BaseActivity(), HasAndroidInjector, ViewImageFragment.PhotoActionsListener, ViewVideoFragment.VideoActionsListener { + @Inject + lateinit var androidInjector: DispatchingAndroidInjector private val binding by viewBinding(ActivityViewMediaBinding::inflate) @@ -337,6 +342,8 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener shareFile(file, mimeType) } + override fun androidInjector() = androidInjector + companion object { private const val EXTRA_ATTACHMENTS = "attachments" private const val EXTRA_ATTACHMENT_INDEX = "index" 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 0b0115c2a..cdb81fe0a 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 446536e6e..2bbdf44e8 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 000000000..dbd22af9b --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java @@ -0,0 +1,709 @@ +/* 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(), + statusDisplayOptions.quoteEnabled() + ); + } + + 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, @NonNull String text) { + + } +} 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 7502c24e9..db2f79a99 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 b63554883..f80f9d76d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java @@ -58,6 +58,7 @@ import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.util.AbsoluteTimeFormatter; import com.keylesspalace.tusky.util.AttachmentHelper; import com.keylesspalace.tusky.util.CardViewMode; +import com.keylesspalace.tusky.util.CompositeWithOpaqueBackground; import com.keylesspalace.tusky.util.CustomEmojiHelper; import com.keylesspalace.tusky.util.ImageLoadingHelper; import com.keylesspalace.tusky.util.LinkHelper; @@ -73,6 +74,7 @@ import com.keylesspalace.tusky.viewdata.PollViewDataKt; import com.keylesspalace.tusky.viewdata.StatusViewData; import java.text.NumberFormat; +import java.util.Collections; import java.util.Date; import java.util.List; @@ -125,10 +127,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { private final TextView cardDescription; private final TextView cardUrl; private final PollAdapter pollAdapter; - protected LinearLayout filteredPlaceholder; - protected TextView filteredPlaceholderLabel; - protected Button filteredPlaceholderShowButton; - protected ConstraintLayout statusContainer; + protected final LinearLayout filteredPlaceholder; + protected final TextView filteredPlaceholderLabel; + protected final Button filteredPlaceholderShowButton; + protected final ConstraintLayout statusContainer; private final NumberFormat numberFormat = NumberFormat.getNumberInstance(); private final AbsoluteTimeFormatter absoluteTimeFormatter = new AbsoluteTimeFormatter(); @@ -139,7 +141,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { 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); @@ -203,14 +205,14 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { 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); @@ -222,10 +224,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { 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); @@ -342,17 +344,17 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { avatarInset.setVisibility(View.VISIBLE); avatarInset.setBackground(null); ImageLoadingHelper.loadAvatar(rebloggedUrl, avatarInset, avatarRadius24dp, - statusDisplayOptions.animateAvatars()); + statusDisplayOptions.animateAvatars(), null); avatarRadius = avatarRadius36dp; } ImageLoadingHelper.loadAvatar(url, avatar, avatarRadius, - statusDisplayOptions.animateAvatars()); - + statusDisplayOptions.animateAvatars(), + 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(); @@ -591,9 +593,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } protected void setMediaPreviews( - final List attachments, + final @NonNull List attachments, boolean sensitive, - final StatusActionListener listener, + final @NonNull StatusActionListener listener, boolean showingContent, boolean useBlurhash ) { @@ -684,8 +686,8 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { 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]; @@ -706,7 +708,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } } - 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(); @@ -730,11 +732,11 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { sensitiveMediaShow.setVisibility(View.GONE); } - protected void setupButtons(final StatusActionListener listener, - final String accountId, - final String statusContent, - final boolean isNotestock, - StatusDisplayOptions statusDisplayOptions) { + protected void setupButtons(final @NonNull StatusActionListener listener, + final @NonNull String accountId, + final @NonNull String statusContent, + final @NonNull boolean isNotestock, + @NonNull StatusDisplayOptions statusDisplayOptions) { View.OnClickListener profileButtonClickListener = button -> { if (isNotestock) { listener.onViewUrl(accountId, accountId); @@ -851,6 +853,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { listener.onReblog(!buttonState, position); if(!buttonState) { reblogButton.playAnimation(); + reblogButton.setChecked(true); } return true; }); @@ -872,14 +875,15 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { listener.onFavourite(!buttonState, position); if(!buttonState) { favouriteButton.playAnimation(); + favouriteButton.setChecked(true); } return true; }); 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); } @@ -890,7 +894,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { 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()); @@ -968,12 +972,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } filteredPlaceholderLabel.setText(itemView.getContext().getString(R.string.status_filter_placeholder_label_format, matchedFilter.getTitle())); - filteredPlaceholderShowButton.setOnClickListener(view -> { - listener.clearWarningAction(getBindingAdapterPosition()); - }); + 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; @@ -990,11 +992,11 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { String description = context.getString(R.string.description_status, actionable.getAccount().getName(), 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) : "", @@ -1041,14 +1043,15 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { 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 ""; @@ -1097,7 +1100,8 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } } - 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); @@ -1106,7 +1110,8 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } } - 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); @@ -1207,11 +1212,11 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } 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 db99dc0a1..1647d01f1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java @@ -36,7 +36,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); @@ -44,7 +44,7 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder { } @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 304cf93a5..327f7cfbb 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/CacheUpdater.kt b/app/src/main/java/com/keylesspalace/tusky/appstore/CacheUpdater.kt index 9515457b3..b44a392bf 100644 --- a/app/src/main/java/com/keylesspalace/tusky/appstore/CacheUpdater.kt +++ b/app/src/main/java/com/keylesspalace/tusky/appstore/CacheUpdater.kt @@ -26,12 +26,14 @@ class CacheUpdater @Inject constructor( eventHub.events.collect { event -> val accountId = accountManager.activeAccount?.id ?: return@collect when (event) { - is FavoriteEvent -> - timelineDao.setFavourited(accountId, event.statusId, event.favourite) - is ReblogEvent -> - timelineDao.setReblogged(accountId, event.statusId, event.reblog) - is BookmarkEvent -> - timelineDao.setBookmarked(accountId, event.statusId, event.bookmark) + is StatusChangedEvent -> { + val status = event.status + timelineDao.update( + accountId = accountId, + status = status, + gson = gson + ) + } is UnfollowEvent -> timelineDao.removeAllByUser(accountId, event.accountId) is StatusDeletedEvent -> @@ -40,8 +42,6 @@ class CacheUpdater @Inject constructor( val pollString = gson.toJson(event.poll) timelineDao.setVoted(accountId, event.statusId, pollString) } - is PinEvent -> - timelineDao.setPinned(accountId, event.statusId, event.pinned) } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt b/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt index c9935bd9b..973d78ef1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt +++ b/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt @@ -2,13 +2,12 @@ package com.keylesspalace.tusky.appstore import com.keylesspalace.tusky.TabData import com.keylesspalace.tusky.entity.Account +import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Status import net.accelf.yuito.streaming.Subscription -data class FavoriteEvent(val statusId: String, val favourite: Boolean) : Event -data class ReblogEvent(val statusId: String, val reblog: Boolean) : Event -data class BookmarkEvent(val statusId: String, val bookmark: Boolean) : Event +data class StatusChangedEvent(val status: Status) : Event data class MuteConversationEvent(val statusId: String, val mute: Boolean) : Event data class UnfollowEvent(val accountId: String) : Event data class BlockEvent(val accountId: String) : Event @@ -16,13 +15,15 @@ data class MuteEvent(val accountId: String) : Event data class StatusDeletedEvent(val statusId: String) : Event data class StatusComposedEvent(val status: Status) : Event data class StatusScheduledEvent(val status: Status) : Event -data class StatusEditedEvent(val originalId: String, val status: Status) : Event data class ProfileEditedEvent(val newProfileData: Account) : Event data class PreferenceChangedEvent(val preferenceKey: String) : Event data class MainTabsChangedEvent(val newTabs: List) : Event data class PollVoteEvent(val statusId: String, val poll: Poll) : Event data class DomainMuteEvent(val instance: String) : Event data class AnnouncementReadEvent(val announcementId: String) : Event -data class PinEvent(val statusId: String, val pinned: Boolean) : Event +data class FilterUpdatedEvent(val filterContext: List) : Event +data class NewNotificationsEvent(val accountId: String, val notifications: List) : Event +data class ConversationsLoadingEvent(val accountId: String) : Event +data class NotificationsLoadingEvent(val accountId: String) : Event data class QuickReplyEvent(val status: Status) : Event data class StreamUpdateEvent(val status: Status, val subscription: Subscription, val streamId: Int) : Event 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 4030b116f..9fb4a3c82 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 23edd5e25..623431c8c 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 @@ -22,9 +22,12 @@ import android.content.Context import android.content.Intent import android.content.res.ColorStateList import android.graphics.Color +import android.graphics.Typeface import android.graphics.drawable.LayerDrawable import android.os.Bundle +import android.text.SpannableStringBuilder import android.text.TextWatcher +import android.text.style.StyleSpan import android.view.Menu import android.view.MenuInflater import android.view.MenuItem @@ -32,10 +35,12 @@ import android.view.View import android.view.ViewGroup import androidx.activity.viewModels import androidx.annotation.ColorInt +import androidx.annotation.DrawableRes import androidx.annotation.Px import androidx.appcompat.app.AlertDialog import androidx.appcompat.content.res.AppCompatResources import androidx.core.app.ActivityOptionsCompat +import androidx.core.graphics.ColorUtils import androidx.core.view.MenuProvider import androidx.core.view.ViewCompat import androidx.core.view.WindowCompat @@ -48,6 +53,7 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.viewpager2.widget.MarginPageTransformer import com.bumptech.glide.Glide import com.google.android.material.appbar.AppBarLayout +import com.google.android.material.chip.Chip import com.google.android.material.color.MaterialColors import com.google.android.material.floatingactionbutton.FloatingActionButton import com.google.android.material.shape.MaterialShapeDrawable @@ -173,9 +179,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() @@ -475,10 +481,10 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide accountFieldAdapter.notifyDataSetChanged() binding.accountLockedImageView.visible(account.locked) - binding.accountBadgeTextView.visible(account.bot) updateAccountAvatar() updateToolbar() + updateBadges() updateMovedAccount() updateRemoteAccount() updateAccountJoinedDate() @@ -491,6 +497,33 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide } } + private fun updateBadges() { + binding.accountBadgeContainer.removeAllViews() + + val isLight = resources.getBoolean(R.bool.lightNavigationBar) + + if (loadedAccount?.bot == true) { + val badgeView = getBadge(getColor(R.color.tusky_grey_50), R.drawable.ic_bot_24dp, getString(R.string.profile_badge_bot_text), isLight) + binding.accountBadgeContainer.addView(badgeView) + } + + loadedAccount?.roles?.forEach { role -> + val badgeColor = if (role.color.isNotBlank()) { + Color.parseColor(role.color) + } else { + // sometimes the color is not set for a role, in this case fall back to our default blue + getColor(R.color.tusky_blue) + } + + val sb = SpannableStringBuilder("${role.name} ${viewModel.domain}") + sb.setSpan(StyleSpan(Typeface.BOLD), 0, role.name.length, 0) + + val badgeView = getBadge(badgeColor, R.drawable.profile_badge_person_24dp, sb, isLight) + + binding.accountBadgeContainer.addView(badgeView) + } + } + private fun updateAccountJoinedDate() { loadedAccount?.let { account -> try { @@ -766,13 +799,16 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide loadedAccount?.let { loadedAccount -> val muteDomain = menu.findItem(R.id.action_mute_domain) domain = getDomain(loadedAccount.url) - if (domain.isEmpty()) { + when { // If we can't get the domain, there's no way we can mute it anyway... - menu.removeItem(R.id.action_mute_domain) - } else { - if (blockingDomain) { + // If the account is from our own domain, muting it is no-op + domain.isEmpty() || viewModel.isFromOwnDomain -> { + menu.removeItem(R.id.action_mute_domain) + } + blockingDomain -> { muteDomain.title = getString(R.string.action_unmute_domain, domain) - } else { + } + else -> { muteDomain.title = getString(R.string.action_mute_domain, domain) } } @@ -994,6 +1030,46 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide } } + private fun getBadge( + @ColorInt baseColor: Int, + @DrawableRes icon: Int, + text: CharSequence, + isLight: Boolean + ): Chip { + val badge = Chip(this) + + // text color with maximum contrast + val textColor = if (isLight) Color.BLACK else Color.WHITE + // badge color with 50% transparency so it blends in with the theme background + val backgroundColor = Color.argb(128, Color.red(baseColor), Color.green(baseColor), Color.blue(baseColor)) + // a color between the text color and the badge color + val outlineColor = ColorUtils.blendARGB(textColor, baseColor, 0.7f) + + // configure the badge + badge.text = text + badge.setTextColor(textColor) + badge.chipStrokeWidth = resources.getDimension(R.dimen.profile_badge_stroke_width) + badge.chipStrokeColor = ColorStateList.valueOf(outlineColor) + badge.setChipIconResource(icon) + badge.isChipIconVisible = true + badge.chipIconSize = resources.getDimension(R.dimen.profile_badge_icon_size) + badge.chipIconTint = ColorStateList.valueOf(outlineColor) + badge.chipBackgroundColor = ColorStateList.valueOf(backgroundColor) + + // badge isn't clickable, so disable all related behavior + badge.isClickable = false + badge.isFocusable = false + badge.setEnsureMinTouchTargetSize(false) + + // reset some chip defaults so it looks better for our badge usecase + badge.iconStartPadding = resources.getDimension(R.dimen.profile_badge_icon_start_padding) + badge.iconEndPadding = resources.getDimension(R.dimen.profile_badge_icon_end_padding) + badge.minHeight = resources.getDimensionPixelSize(R.dimen.profile_badge_min_height) + badge.chipMinHeight = resources.getDimension(R.dimen.profile_badge_min_height) + badge.updatePadding(top = 0, bottom = 0) + return badge + } + override fun androidInjector() = dispatchingAndroidInjector companion object { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountViewModel.kt index 5b32e3404..2cd4171a3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountViewModel.kt @@ -19,6 +19,7 @@ import com.keylesspalace.tusky.util.Error import com.keylesspalace.tusky.util.Loading import com.keylesspalace.tusky.util.Resource import com.keylesspalace.tusky.util.Success +import com.keylesspalace.tusky.util.getDomain import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -27,7 +28,7 @@ import javax.inject.Inject class AccountViewModel @Inject constructor( private val mastodonApi: MastodonApi, private val eventHub: EventHub, - private val accountManager: AccountManager + accountManager: AccountManager ) : ViewModel() { val accountData = MutableLiveData>() @@ -41,8 +42,16 @@ class AccountViewModel @Inject constructor( lateinit var accountId: String var isSelf = false + /** the domain of the viewed account **/ + var domain = "" + + /** True if the viewed account has the same domain as the active account */ + var isFromOwnDomain = false + private var noteUpdateJob: Job? = null + private val activeAccount = accountManager.activeAccount!! + init { viewModelScope.launch { eventHub.events.collect { event -> @@ -62,6 +71,9 @@ class AccountViewModel @Inject constructor( mastodonApi.account(accountId) .fold( { account -> + domain = getDomain(account.url) + isFromOwnDomain = domain == activeAccount.domain + accountData.postValue(Success(account)) isDataLoading = false isRefreshing.postValue(false) @@ -298,7 +310,7 @@ class AccountViewModel @Inject constructor( fun setAccountInfo(accountId: String) { this.accountId = accountId - this.isSelf = accountManager.activeAccount?.accountId == accountId + this.isSelf = activeAccount.accountId == accountId reload(false) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaFragment.kt index b39a8b5b4..5a81c418e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaFragment.kt @@ -82,13 +82,10 @@ class AccountMediaFragment : override fun onViewCreated(view: View, savedInstanceState: Bundle?) { requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) - val alwaysShowSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia - val preferences = PreferenceManager.getDefaultSharedPreferences(view.context) val useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true) adapter = AccountMediaGridAdapter( - alwaysShowSensitiveMedia = alwaysShowSensitiveMedia, useBlurhash = useBlurhash, context = view.context, onAttachmentClickListener = ::onAttachmentClick diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaGridAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaGridAdapter.kt index 48b3a21da..aecbeb0bb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaGridAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaGridAdapter.kt @@ -24,7 +24,6 @@ import com.keylesspalace.tusky.viewdata.AttachmentViewData import java.util.Random class AccountMediaGridAdapter( - private val alwaysShowSensitiveMedia: Boolean, private val useBlurhash: Boolean, context: Context, private val onAttachmentClickListener: (AttachmentViewData, View) -> Unit @@ -80,7 +79,7 @@ class AccountMediaGridAdapter( .into(imageView) imageView.contentDescription = item.attachment.getFormattedDescription(context) - } else if (item.sensitive && !item.isRevealed && !alwaysShowSensitiveMedia) { + } else if (item.sensitive && !item.isRevealed) { overlay.show() overlay.setImageDrawable(mediaHiddenDrawable) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaRemoteMediator.kt index 315b0380c..52535a6a5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaRemoteMediator.kt @@ -20,6 +20,7 @@ import androidx.paging.LoadType import androidx.paging.PagingState import androidx.paging.RemoteMediator import com.keylesspalace.tusky.components.timeline.util.ifExpected +import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.viewdata.AttachmentViewData import retrofit2.HttpException @@ -27,9 +28,9 @@ import retrofit2.HttpException @OptIn(ExperimentalPagingApi::class) class AccountMediaRemoteMediator( private val api: MastodonApi, + private val activeAccount: AccountEntity, private val viewModel: AccountMediaViewModel ) : RemoteMediator() { - override suspend fun load( loadType: LoadType, state: PagingState @@ -58,7 +59,7 @@ class AccountMediaRemoteMediator( } val attachments = statuses.flatMap { status -> - AttachmentViewData.list(status) + AttachmentViewData.list(status, activeAccount.alwaysShowSensitiveMedia ?: false) } if (loadType == LoadType.REFRESH) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaViewModel.kt index ddbcb71d0..ee5ffd011 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaViewModel.kt @@ -21,11 +21,13 @@ import androidx.paging.ExperimentalPagingApi import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.cachedIn +import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.viewdata.AttachmentViewData import javax.inject.Inject class AccountMediaViewModel @Inject constructor( + private val accountManager: AccountManager, api: MastodonApi ) : ViewModel() { @@ -35,6 +37,8 @@ class AccountMediaViewModel @Inject constructor( var currentSource: AccountMediaPagingSource? = null + val activeAccount = accountManager.activeAccount!! + @OptIn(ExperimentalPagingApi::class) val media = Pager( config = PagingConfig( @@ -48,7 +52,7 @@ class AccountMediaViewModel @Inject constructor( currentSource = source } }, - remoteMediator = AccountMediaRemoteMediator(api, this) + remoteMediator = AccountMediaRemoteMediator(api, activeAccount, this) ).flow .cachedIn(viewModelScope) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListActivity.kt index f5981c0bc..2419dc915 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListActivity.kt @@ -48,7 +48,6 @@ class AccountListActivity : BottomSheetActivity(), HasAndroidInjector { val type = intent.getSerializableExtra(EXTRA_TYPE) as Type val id: String? = intent.getStringExtra(EXTRA_ID) - val accountLocked: Boolean = intent.getBooleanExtra(EXTRA_ACCOUNT_LOCKED, false) setSupportActionBar(binding.includedToolbar.toolbar) supportActionBar?.apply { @@ -66,7 +65,7 @@ class AccountListActivity : BottomSheetActivity(), HasAndroidInjector { } supportFragmentManager.commit { - replace(R.id.fragment_container, AccountListFragment.newInstance(type, id, accountLocked)) + replace(R.id.fragment_container, AccountListFragment.newInstance(type, id)) } } @@ -75,13 +74,11 @@ class AccountListActivity : BottomSheetActivity(), HasAndroidInjector { companion object { private const val EXTRA_TYPE = "type" private const val EXTRA_ID = "id" - private const val EXTRA_ACCOUNT_LOCKED = "acc_locked" - fun newIntent(context: Context, type: Type, id: String? = null, accountLocked: Boolean = false): Intent { + fun newIntent(context: Context, type: Type, id: String? = null): Intent { return Intent(context, AccountListActivity::class.java).apply { putExtra(EXTRA_TYPE, type) putExtra(EXTRA_ID, id) - putExtra(EXTRA_ACCOUNT_LOCKED, accountLocked) } } } 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 7a19a4ebd..9038652e8 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 : @@ -107,13 +106,15 @@ class AccountListFragment : val animateEmojis = pm.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) val showBotOverlay = pm.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true) + val activeAccount = accountManager.activeAccount!! + adapter = when (type) { Type.BLOCKS -> BlocksAdapter(this, animateAvatar, animateEmojis, showBotOverlay) Type.MUTES -> MutesAdapter(this, animateAvatar, animateEmojis, showBotOverlay) Type.FOLLOW_REQUESTS -> { val headerAdapter = FollowRequestsHeaderAdapter( - instanceName = accountManager.activeAccount!!.domain, - accountLocked = arguments?.getBoolean(ARG_ACCOUNT_LOCKED) == true + instanceName = activeAccount.domain, + accountLocked = activeAccount.locked ) val followRequestsAdapter = FollowRequestsAdapter(this, this, animateAvatar, animateEmojis, showBotOverlay) binding.recyclerView.adapter = ConcatAdapter(headerAdapter, followRequestsAdapter) @@ -330,7 +331,7 @@ class AccountListFragment : val linkHeader = response.headers()["Link"] onFetchAccountsSuccess(accountList, linkHeader) - } catch (exception: IOException) { + } catch (exception: Exception) { onFetchAccountsFailure(exception) } } @@ -404,14 +405,12 @@ class AccountListFragment : private const val TAG = "AccountList" // logging tag private const val ARG_TYPE = "type" private const val ARG_ID = "id" - private const val ARG_ACCOUNT_LOCKED = "acc_locked" - fun newInstance(type: Type, id: String? = null, accountLocked: Boolean = false): AccountListFragment { + fun newInstance(type: Type, id: String? = null): AccountListFragment { return AccountListFragment().apply { arguments = Bundle(3).apply { putSerializable(ARG_TYPE, type) putString(ARG_ID, id) - putBoolean(ARG_ACCOUNT_LOCKED, accountLocked) } } } 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 fc860e59e..cdce38142 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 8f30c5e49..4c903ee81 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 @@ -29,6 +30,7 @@ import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ItemAnnouncementBinding import com.keylesspalace.tusky.entity.Announcement import com.keylesspalace.tusky.interfaces.LinkListener +import com.keylesspalace.tusky.util.AbsoluteTimeFormatter import com.keylesspalace.tusky.util.BindingHolder import com.keylesspalace.tusky.util.EmojiSpan import com.keylesspalace.tusky.util.emojify @@ -50,14 +52,19 @@ class AnnouncementAdapter( private val animateEmojis: Boolean = false ) : RecyclerView.Adapter>() { + private val absoluteTimeFormatter = AbsoluteTimeFormatter() + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder { val binding = ItemAnnouncementBinding.inflate(LayoutInflater.from(parent.context), parent, false) return BindingHolder(binding) } + @SuppressLint("SetTextI18n") override fun onBindViewHolder(holder: BindingHolder, position: Int) { val item = items[position] + holder.binding.announcementDate.text = absoluteTimeFormatter.format(item.publishedAt, false) + val text = holder.binding.text val chips = holder.binding.chipGroup val addReactionChip = holder.binding.addReactionChip diff --git a/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsActivity.kt index 733b2fec8..1775c4048 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsActivity.kt @@ -129,7 +129,7 @@ class AnnouncementsActivity : is Error -> { binding.progressBar.hide() binding.swipeRefreshLayout.isRefreshing = false - binding.errorMessageView.setup(R.drawable.elephant_error, R.string.error_generic) { + binding.errorMessageView.setup(R.drawable.errorphant_error, R.string.error_generic) { refreshAnnouncements() } binding.errorMessageView.show() 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 918de7b25..1a580de19 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt @@ -16,7 +16,6 @@ package com.keylesspalace.tusky.components.compose import android.Manifest -import android.app.NotificationManager import android.app.ProgressDialog import android.content.ClipData import android.content.Context @@ -97,8 +96,9 @@ import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.NewPoll import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.settings.AppTheme import com.keylesspalace.tusky.settings.PrefKeys -import com.keylesspalace.tusky.util.APP_THEME_DEFAULT +import com.keylesspalace.tusky.settings.PrefKeys.APP_THEME import com.keylesspalace.tusky.util.MentionSpan import com.keylesspalace.tusky.util.PickMediaFiles import com.keylesspalace.tusky.util.getInitialLanguages @@ -212,24 +212,9 @@ class ComposeActivity : public override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val notificationId = intent.getIntExtra(NOTIFICATION_ID_EXTRA, -1) - if (notificationId != -1) { - // ComposeActivity was opened from a notification, delete the notification - val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager - notificationManager.cancel(notificationId) - } + activeAccount = accountManager.activeAccount ?: return - // If started from an intent then compose as the account ID from the intent. - // Otherwise use the active account. If null then the user is not logged in, - // and return from the activity. - val intentAccountId = intent.getLongExtra(ACCOUNT_ID_EXTRA, -1) - activeAccount = if (intentAccountId != -1L) { - accountManager.getAccountById(intentAccountId) - } else { - accountManager.activeAccount - } ?: return - - val theme = preferences.getString("appTheme", APP_THEME_DEFAULT) + val theme = preferences.getString(APP_THEME, AppTheme.DEFAULT.value) if (theme == "black") { setTheme(R.style.TuskyDialogActivityBlackTheme) } @@ -286,7 +271,7 @@ class ComposeActivity : binding.composeScheduleView.setDateTime(composeOptions?.scheduledAt) } - setupLanguageSpinner(getInitialLanguages(composeOptions?.language, accountManager.activeAccount)) + setupLanguageSpinner(getInitialLanguages(composeOptions?.language, activeAccount)) setupComposeField(preferences, viewModel.startingText) setupDefaultTagViews(preferences) setupContentWarningField(composeOptions?.contentWarning) @@ -536,7 +521,12 @@ class ComposeActivity : if (throwable is UploadServerError) { displayTransientMessage(throwable.errorMessage) } else { - displayTransientMessage(R.string.error_media_upload_sending) + displayTransientMessage( + getString( + R.string.error_media_upload_sending_fmt, + throwable.message + ) + ) } } } @@ -662,7 +652,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, @@ -1148,9 +1138,28 @@ class ComposeActivity : viewModel.removeMediaFromQueue(item) } + private fun sanitizePickMediaDescription(description: String?): String? { + if (description == null) { + return null + } + + // The Gboard android keyboard attaches this text whenever the user + // pastes something from the keyboard's suggestion bar. + // Due to different end user locales, the exact text may vary, but at + // least in version 13.4.08, all of the translations contained the + // string "Gboard". + if ("Gboard" in description) { + return null + } + + return description + } + private fun pickMedia(uri: Uri, description: String? = null) { + var sanitizedDescription = sanitizePickMediaDescription(description) + lifecycleScope.launch { - viewModel.pickMedia(uri, description).onFailure { throwable -> + viewModel.pickMedia(uri, sanitizedDescription).onFailure { throwable -> val errorString = when (throwable) { is FileSizeException -> { val decimalFormat = DecimalFormat("0.##") @@ -1435,8 +1444,6 @@ class ComposeActivity : private const val PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE = 1 internal const val COMPOSE_OPTIONS_EXTRA = "COMPOSE_OPTIONS" - private const val NOTIFICATION_ID_EXTRA = "NOTIFICATION_ID" - private const val ACCOUNT_ID_EXTRA = "ACCOUNT_ID" private const val PHOTO_UPLOAD_URI_KEY = "PHOTO_UPLOAD_URI" private const val VISIBILITY_KEY = "VISIBILITY" private const val SCHEDULED_TIME_KEY = "SCHEDULE" @@ -1450,26 +1457,15 @@ class ComposeActivity : /** * @param options ComposeOptions to configure the ComposeActivity - * @param notificationId the id of the notification that starts the Activity - * @param accountId the id of the account to compose with, null for the current account * @return an Intent to start the ComposeActivity */ @JvmStatic - @JvmOverloads fun startIntent( context: Context, - options: ComposeOptions, - notificationId: Int? = null, - accountId: Long? = null + options: ComposeOptions ): Intent { return Intent(context, ComposeActivity::class.java).apply { putExtra(COMPOSE_OPTIONS_EXTRA, options) - if (notificationId != null) { - putExtra(NOTIFICATION_ID_EXTRA, notificationId) - } - if (accountId != null) { - putExtra(ACCOUNT_ID_EXTRA, accountId) - } } } 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 44e4b7766..4abda2a6c 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 @@ -37,7 +37,6 @@ import com.keylesspalace.tusky.service.ServiceClient import com.keylesspalace.tusky.service.StatusToSend import com.keylesspalace.tusky.util.randomAlphanumericString import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -51,7 +50,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import javax.inject.Inject -@OptIn(FlowPreview::class) class ComposeViewModel @Inject constructor( private val api: MastodonApi, private val accountManager: AccountManager, @@ -106,7 +104,7 @@ class ComposeViewModel @Inject constructor( val domain = accountManager.activeAccount?.domain!! - lateinit var composeKind: ComposeKind + private lateinit var composeKind: ComposeKind // Used in ComposeActivity to pass state to result function when cropImage contract inflight var cropImageItemOld: QueuedMedia? = null @@ -288,7 +286,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 0420f43d7..18fde7304 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/AddPollOptionsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/AddPollOptionsAdapter.kt index ded1b7cfd..4d7ecdca8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/AddPollOptionsAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/AddPollOptionsAdapter.kt @@ -75,10 +75,6 @@ class AddPollOptionsAdapter( } private fun validateInput(): Boolean { - if (options.contains("") || options.distinct().size != options.size) { - return false - } - - return true + return !(options.contains("") || options.distinct().size != options.size) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/CaptionDialog.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/CaptionDialog.kt index a1dc032cd..3b6399d19 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/CaptionDialog.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/CaptionDialog.kt @@ -15,7 +15,6 @@ package com.keylesspalace.tusky.components.compose.dialog -import android.app.Dialog import android.content.Context import android.graphics.drawable.Drawable import android.net.Uri @@ -26,7 +25,7 @@ import android.view.View import android.view.ViewGroup import android.view.WindowManager import android.widget.EditText -import androidx.appcompat.app.AlertDialog +import android.widget.LinearLayout import androidx.core.os.BundleCompat import androidx.core.os.bundleOf import androidx.fragment.app.DialogFragment @@ -36,6 +35,8 @@ import com.bumptech.glide.request.target.CustomTarget import com.bumptech.glide.request.transition.Transition import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.DialogImageDescriptionBinding +import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.viewBinding // https://github.com/tootsuite/mastodon/blob/c6904c0d3766a2ea8a81ab025c127169ecb51373/app/models/media_attachment.rb#L32 private const val MEDIA_DESCRIPTION_CHARACTER_LIMIT = 1500 @@ -44,14 +45,29 @@ class CaptionDialog : DialogFragment() { private lateinit var listener: Listener private lateinit var input: EditText - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val context = requireContext() + private val binding by viewBinding(DialogImageDescriptionBinding::bind) - val binding = DialogImageDescriptionBinding.inflate(layoutInflater) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setStyle(STYLE_NORMAL, R.style.TuskyDialogFragmentStyle) + } + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + savedInstanceState?.getString(DESCRIPTION_KEY)?.let { + input.setText(it) + } + + return inflater.inflate(R.layout.dialog_image_description, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { input = binding.imageDescriptionText val imageView = binding.imageDescriptionView - imageView.maximumScale = 6f + imageView.maxZoom = 6f input.hint = resources.getQuantityString( R.plurals.hint_describe_for_visually_impaired, @@ -61,20 +77,19 @@ class CaptionDialog : DialogFragment() { input.filters = arrayOf(InputFilter.LengthFilter(MEDIA_DESCRIPTION_CHARACTER_LIMIT)) input.setText(arguments?.getString(EXISTING_DESCRIPTION_ARG)) + binding.cancelButton.setOnClickListener { + dismiss() + } val localId = arguments?.getInt(LOCAL_ID_ARG) ?: error("Missing localId") - val dialog = AlertDialog.Builder(context) - .setView(binding.root) - .setPositiveButton(android.R.string.ok) { _, _ -> - listener.onUpdateDescription(localId, input.text.toString()) - } - .setNegativeButton(android.R.string.cancel, null) - .create() + binding.okButton.setOnClickListener { + listener.onUpdateDescription(localId, input.text.toString()) + dismiss() + } isCancelable = true - val window = dialog.window - window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) val previewUri = BundleCompat.getParcelable(requireArguments(), PREVIEW_URI_ARG, Uri::class.java) ?: error("Preview Uri is null") + // Load the image and manually set it into the ImageView because it doesn't have a fixed size. Glide.with(this) .load(previewUri) @@ -90,9 +105,23 @@ class CaptionDialog : DialogFragment() { ) { imageView.setImageDrawable(resource) } - }) - return dialog + override fun onLoadFailed(errorDrawable: Drawable?) { + super.onLoadFailed(errorDrawable) + imageView.hide() + } + }) + } + + override fun onStart() { + super.onStart() + dialog?.apply { + window?.setLayout( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.MATCH_PARENT + ) + window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) + } } override fun onSaveInstanceState(outState: Bundle) { @@ -100,17 +129,6 @@ class CaptionDialog : DialogFragment() { super.onSaveInstanceState(outState) } - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - savedInstanceState?.getString(DESCRIPTION_KEY)?.let { - input.setText(it) - } - return super.onCreateView(inflater, container, savedInstanceState) - } - override fun onAttach(context: Context) { super.onAttach(context) listener = context as? Listener ?: error("Activity is not ComposeCaptionDialog.Listener") 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 93c99ee6f..e6abea84e 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/compose/view/ComposeScheduleView.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ComposeScheduleView.kt index f2e00d341..2f6fad162 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ComposeScheduleView.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ComposeScheduleView.kt @@ -57,7 +57,9 @@ class ComposeScheduleView ).apply { timeZone = TimeZone.getTimeZone("UTC") } - private var scheduleDateTime: Calendar? = null + + /** The date/time the user has chosen to schedule the status, in UTC */ + private var scheduleDateTimeUtc: Calendar? = null init { binding.scheduledDateTime.setOnClickListener { openPickDateDialog() } @@ -71,13 +73,13 @@ class ComposeScheduleView } private fun updateScheduleUi() { - if (scheduleDateTime == null) { + if (scheduleDateTimeUtc == null) { binding.scheduledDateTime.text = "" binding.invalidScheduleWarning.visibility = GONE return } - val scheduled = scheduleDateTime!!.time + val scheduled = scheduleDateTimeUtc!!.time binding.scheduledDateTime.text = String.format( "%s %s", dateFormat.format(scheduled), @@ -98,21 +100,37 @@ class ComposeScheduleView } fun resetSchedule() { - scheduleDateTime = null + scheduleDateTimeUtc = null updateScheduleUi() } fun openPickDateDialog() { - val yesterday = Calendar.getInstance().timeInMillis - 24 * 60 * 60 * 1000 + // The earliest point in time the calendar should display. Start with current date/time + val earliest = calendar().apply { + // Add the minimum scheduling interval. This may roll the calendar over to the + // next day (e.g. if the current time is 23:57). + add(Calendar.SECOND, MINIMUM_SCHEDULED_SECONDS) + // Clear out the time components, so it's midnight + set(Calendar.HOUR_OF_DAY, 0) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + } val calendarConstraints = CalendarConstraints.Builder() - .setValidator( - DateValidatorPointForward.from(yesterday) - ) + .setValidator(DateValidatorPointForward.from(earliest.timeInMillis)) .build() initializeSuggestedTime() + + // Work around a misfeature in MaterialDatePicker. The `selection` is treated as + // millis-from-epoch, in UTC, which is good. However, it is also *displayed* in UTC + // instead of converting to the user's local timezone. + // + // So we have to add the TZ offset before setting it in the picker + val tzOffset = TimeZone.getDefault().getOffset(scheduleDateTimeUtc!!.timeInMillis) + val picker = MaterialDatePicker.Builder .datePicker() - .setSelection(scheduleDateTime!!.timeInMillis) + .setSelection(scheduleDateTimeUtc!!.timeInMillis + tzOffset) .setCalendarConstraints(calendarConstraints) .build() picker.addOnPositiveButtonClickListener { selection: Long -> onDateSet(selection) } @@ -129,11 +147,12 @@ class ComposeScheduleView private fun openPickTimeDialog() { val pickerBuilder = MaterialTimePicker.Builder() - scheduleDateTime?.let { + scheduleDateTimeUtc?.let { pickerBuilder.setHour(it[Calendar.HOUR_OF_DAY]) .setMinute(it[Calendar.MINUTE]) } + pickerBuilder.setTitleText(dateFormat.format(scheduleDateTimeUtc!!.timeInMillis)) pickerBuilder.setTimeFormat(getTimeFormat(context)) val picker = pickerBuilder.build() @@ -154,7 +173,7 @@ class ComposeScheduleView fun setDateTime(scheduledAt: String?) { val date = getDateTime(scheduledAt) ?: return initializeSuggestedTime() - scheduleDateTime!!.time = date + scheduleDateTimeUtc!!.time = date updateScheduleUi() } @@ -180,24 +199,24 @@ class ComposeScheduleView // see https://github.com/material-components/material-components-android/issues/882 newDate.timeZone = TimeZone.getTimeZone("UTC") newDate.timeInMillis = selection - scheduleDateTime!![newDate[Calendar.YEAR], newDate[Calendar.MONTH]] = newDate[Calendar.DATE] + scheduleDateTimeUtc!![newDate[Calendar.YEAR], newDate[Calendar.MONTH]] = newDate[Calendar.DATE] openPickTimeDialog() } private fun onTimeSet(hourOfDay: Int, minute: Int) { initializeSuggestedTime() - scheduleDateTime?.set(Calendar.HOUR_OF_DAY, hourOfDay) - scheduleDateTime?.set(Calendar.MINUTE, minute) + scheduleDateTimeUtc?.set(Calendar.HOUR_OF_DAY, hourOfDay) + scheduleDateTimeUtc?.set(Calendar.MINUTE, minute) updateScheduleUi() listener?.onTimeSet(time) } val time: String? - get() = scheduleDateTime?.time?.let { iso8601.format(it) } + get() = scheduleDateTimeUtc?.time?.let { iso8601.format(it) } private fun initializeSuggestedTime() { - if (scheduleDateTime == null) { - scheduleDateTime = calendar().apply { + if (scheduleDateTimeUtc == null) { + scheduleDateTimeUtc = calendar().apply { add(Calendar.MINUTE, 15) } } 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 4b4c2c5e9..430a5d711 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 @@ public class ConversationViewHolder extends StatusBaseViewHolder { 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()); @@ -144,7 +144,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder { ImageView avatarView = avatars[i]; if (i < accounts.size()) { ImageLoadingHelper.loadAvatar(accounts.get(i).getAvatar(), avatarView, - avatarRadius48dp, statusDisplayOptions.animateAvatars()); + avatarRadius48dp, statusDisplayOptions.animateAvatars(), null); avatarView.setVisibility(View.VISIBLE); } else { avatarView.setVisibility(View.GONE); 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 4e548139f..326f99fcf 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 @@ -40,6 +40,7 @@ import com.google.android.material.color.MaterialColors import com.keylesspalace.tusky.R import com.keylesspalace.tusky.StatusListActivity import com.keylesspalace.tusky.adapter.StatusBaseViewHolder +import com.keylesspalace.tusky.appstore.ConversationsLoadingEvent import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.components.account.AccountActivity @@ -55,6 +56,7 @@ import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.util.CardViewMode import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.isAnyLoading import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.viewdata.AttachmentViewData @@ -65,6 +67,7 @@ import com.mikepenz.iconics.utils.sizeDp import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import javax.inject.Inject import kotlin.time.DurationUnit import kotlin.time.toDuration @@ -100,14 +103,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), @@ -130,12 +133,19 @@ class ConversationsFragment : binding.statusView.hide() binding.progressBar.hide() + if (loadState.isAnyLoading()) { + runBlocking { + eventHub.dispatch(ConversationsLoadingEvent(accountManager.activeAccount?.accountId ?: "")) + } + } + if (adapter.itemCount == 0) { when (loadState.refresh) { is LoadState.NotLoading -> { 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 667360c53..618174907 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 000000000..e37aa917c --- /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 000000000..896e81ead --- /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 000000000..0438a268f --- /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 000000000..09f99044e --- /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 000000000..bdc9b9367 --- /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 000000000..6458977f0 --- /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/drafts/DraftsActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsActivity.kt index 6d9a2aa16..14dc21003 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsActivity.kt @@ -35,11 +35,11 @@ import com.keylesspalace.tusky.databinding.ActivityDraftsBinding import com.keylesspalace.tusky.db.DraftEntity import com.keylesspalace.tusky.db.DraftsAlert import com.keylesspalace.tusky.di.ViewModelFactory +import com.keylesspalace.tusky.util.isHttpNotFound import com.keylesspalace.tusky.util.parseAsMastodonHtml import com.keylesspalace.tusky.util.visible import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -import retrofit2.HttpException import javax.inject.Inject class DraftsActivity : BaseActivity(), DraftActionListener { @@ -131,7 +131,7 @@ class DraftsActivity : BaseActivity(), DraftActionListener { Log.w(TAG, "failed loading reply information", throwable) - if (throwable is HttpException && throwable.code() == 404) { + if (throwable.isHttpNotFound()) { // the original status to which a reply was drafted has been deleted // let's open the ComposeActivity without reply information Toast.makeText(context, getString(R.string.drafts_post_reply_removed), Toast.LENGTH_LONG).show() diff --git a/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterActivity.kt index 2ef7902cc..6737acd3b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterActivity.kt @@ -1,6 +1,7 @@ package com.keylesspalace.tusky.components.filters import android.content.Context +import android.content.DialogInterface.BUTTON_POSITIVE import android.os.Bundle import android.view.View import android.widget.AdapterView @@ -18,16 +19,17 @@ import com.google.android.material.switchmaterial.SwitchMaterial import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.appstore.FilterUpdatedEvent import com.keylesspalace.tusky.databinding.ActivityEditFilterBinding import com.keylesspalace.tusky.databinding.DialogFilterBinding import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.FilterKeyword import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.isHttpNotFound import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible import kotlinx.coroutines.launch -import retrofit2.HttpException import java.util.Date import javax.inject.Inject @@ -81,7 +83,11 @@ class EditFilterActivity : BaseActivity() { binding.actionChip.setOnClickListener { showAddKeywordDialog() } binding.filterSaveButton.setOnClickListener { saveChanges() } - binding.filterDeleteButton.setOnClickListener { deleteFilter() } + binding.filterDeleteButton.setOnClickListener { + lifecycleScope.launch { + if (showDeleteFilterDialog(filter.title) == BUTTON_POSITIVE) deleteFilter() + } + } binding.filterDeleteButton.visible(originalFilter != null) for (switch in contextSwitches.keys) { @@ -259,6 +265,9 @@ class EditFilterActivity : BaseActivity() { lifecycleScope.launch { if (viewModel.saveChanges(this@EditFilterActivity)) { finish() + // Possibly affected contexts: any context affected by the original filter OR any context affected by the updated filter + val affectedContexts = viewModel.contexts.value.map { it.kind }.union(originalFilter?.context ?: listOf()).distinct() + eventHub.dispatch(FilterUpdatedEvent(affectedContexts)) } else { Snackbar.make(binding.root, "Error saving filter '${viewModel.title.value}'", Snackbar.LENGTH_SHORT).show() } @@ -273,7 +282,7 @@ class EditFilterActivity : BaseActivity() { finish() }, { throwable -> - if (throwable is HttpException && throwable.code() == 404) { + if (throwable.isHttpNotFound()) { api.deleteFilterV1(filter.id).fold( { finish() diff --git a/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterViewModel.kt index d33031d65..d055c3177 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterViewModel.kt @@ -8,9 +8,9 @@ import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.FilterKeyword import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.isHttpNotFound import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.withContext -import retrofit2.HttpException import javax.inject.Inject class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub: EventHub) : ViewModel() { @@ -108,7 +108,7 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub }, { throwable -> return ( - throwable is HttpException && throwable.code() == 404 && + throwable.isHttpNotFound() && // Endpoint not found, fall back to v1 api createFilterV1(contexts, expiresInSeconds) ) @@ -141,7 +141,7 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub return results.none { it.isFailure } }, { throwable -> - if (throwable is HttpException && throwable.code() == 404) { + if (throwable.isHttpNotFound()) { // Endpoint not found, fall back to v1 api if (updateFilterV1(contexts, expiresInSeconds)) { return true diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/filters/FilterExtensions.kt similarity index 50% rename from app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateAdapter.kt rename to app/src/main/java/com/keylesspalace/tusky/components/filters/FilterExtensions.kt index 0a281ccd9..2a6c69d21 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/filters/FilterExtensions.kt @@ -15,24 +15,15 @@ * see . */ -package com.keylesspalace.tusky.components.notifications +package com.keylesspalace.tusky.components.filters -import android.view.ViewGroup -import androidx.paging.LoadState -import androidx.paging.LoadStateAdapter +import android.app.Activity +import androidx.appcompat.app.AlertDialog +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.util.await -/** 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) - } -} +internal suspend fun Activity.showDeleteFilterDialog(filterTitle: String) = AlertDialog.Builder(this) + .setMessage(getString(R.string.dialog_delete_filter_text, filterTitle)) + .setCancelable(true) + .create() + .await(R.string.dialog_delete_filter_positive_action, android.R.string.cancel) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersActivity.kt index 9230fdae2..d66fb7ad2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersActivity.kt @@ -1,5 +1,6 @@ package com.keylesspalace.tusky.components.filters +import android.content.DialogInterface.BUTTON_POSITIVE import android.content.Intent import android.os.Bundle import androidx.activity.viewModels @@ -60,18 +61,19 @@ class FiltersActivity : BaseActivity(), FiltersListener { when (state.loadingState) { FiltersViewModel.LoadingState.INITIAL, FiltersViewModel.LoadingState.LOADING -> binding.messageView.hide() FiltersViewModel.LoadingState.ERROR_NETWORK -> { - binding.messageView.setup(R.drawable.elephant_offline, R.string.error_network) { + binding.messageView.setup(R.drawable.errorphant_offline, R.string.error_network) { loadFilters() } binding.messageView.show() } FiltersViewModel.LoadingState.ERROR_OTHER -> { - binding.messageView.setup(R.drawable.elephant_error, R.string.error_generic) { + binding.messageView.setup(R.drawable.errorphant_error, R.string.error_generic) { loadFilters() } binding.messageView.show() } FiltersViewModel.LoadingState.LOADED -> { + binding.filtersList.adapter = FiltersAdapter(this@FiltersActivity, state.filters) if (state.filters.isEmpty()) { binding.messageView.setup( R.drawable.elephant_friend_empty, @@ -81,7 +83,6 @@ class FiltersActivity : BaseActivity(), FiltersListener { binding.messageView.show() } else { binding.messageView.hide() - binding.filtersList.adapter = FiltersAdapter(this@FiltersActivity, state.filters) } } } @@ -104,7 +105,11 @@ class FiltersActivity : BaseActivity(), FiltersListener { } override fun deleteFilter(filter: Filter) { - viewModel.deleteFilter(filter, binding.root) + lifecycleScope.launch { + if (showDeleteFilterDialog(filter.title) == BUTTON_POSITIVE) { + viewModel.deleteFilter(filter, binding.root) + } + } } override fun updateFilter(updatedFilter: Filter) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersViewModel.kt index e28d251b8..5baade6b5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersViewModel.kt @@ -9,10 +9,10 @@ import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.isHttpNotFound import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch -import retrofit2.HttpException import javax.inject.Inject class FiltersViewModel @Inject constructor( @@ -38,14 +38,13 @@ class FiltersViewModel @Inject constructor( this@FiltersViewModel._state.value = State(filters, LoadingState.LOADED) }, { throwable -> - if (throwable is HttpException && throwable.code() == 404) { + if (throwable.isHttpNotFound()) { api.getFiltersV1().fold( { filters -> this@FiltersViewModel._state.value = State(filters.map { it.toFilter() }, LoadingState.LOADED) }, - { throwable -> + { _ -> // TODO log errors (also below) - this@FiltersViewModel._state.value = _state.value.copy(loadingState = LoadingState.ERROR_OTHER) } ) @@ -68,7 +67,7 @@ class FiltersViewModel @Inject constructor( } }, { throwable -> - if (throwable is HttpException && throwable.code() == 404) { + if (throwable.isHttpNotFound()) { api.deleteFilterV1(filter.id).fold( { this@FiltersViewModel._state.value = State(this@FiltersViewModel._state.value.filters.filter { it.id != filter.id }, LoadingState.LOADED) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfo.kt b/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfo.kt index bb97621c1..582df02e8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfo.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfo.kt @@ -28,5 +28,6 @@ data class InstanceInfo( val maxMediaAttachments: Int, val maxFields: Int, val maxFieldNameLength: Int?, - val maxFieldValueLength: Int? + val maxFieldValueLength: Int?, + val version: String? ) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfoRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfoRepository.kt index 3085263c6..6e6eec0bc 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfoRepository.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfoRepository.kt @@ -25,6 +25,7 @@ import com.keylesspalace.tusky.db.EmojisEntity import com.keylesspalace.tusky.db.InstanceInfoEntity import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.isHttpNotFound import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import javax.inject.Inject @@ -62,6 +63,41 @@ class InstanceInfoRepository @Inject constructor( */ suspend fun getInstanceInfo(): InstanceInfo = withContext(Dispatchers.IO) { api.getInstance() + .fold( + { instance -> + val instanceEntity = InstanceInfoEntity( + instance = instanceName, + maximumTootCharacters = instance.configuration.statuses?.maxCharacters ?: DEFAULT_CHARACTER_LIMIT, + maxPollOptions = instance.configuration.polls?.maxOptions ?: DEFAULT_MAX_OPTION_COUNT, + maxPollOptionLength = instance.configuration.polls?.maxCharactersPerOption ?: DEFAULT_MAX_OPTION_LENGTH, + minPollDuration = instance.configuration.polls?.minExpirationSeconds ?: DEFAULT_MIN_POLL_DURATION, + maxPollDuration = instance.configuration.polls?.maxExpirationSeconds ?: DEFAULT_MAX_POLL_DURATION, + charactersReservedPerUrl = instance.configuration.statuses?.charactersReservedPerUrl ?: DEFAULT_CHARACTERS_RESERVED_PER_URL, + version = instance.version, + videoSizeLimit = instance.configuration.mediaAttachments?.videoSizeLimitBytes?.toInt() ?: DEFAULT_VIDEO_SIZE_LIMIT, + imageSizeLimit = instance.configuration.mediaAttachments?.imageSizeLimitBytes?.toInt() ?: DEFAULT_IMAGE_SIZE_LIMIT, + imageMatrixLimit = instance.configuration.mediaAttachments?.imagePixelCountLimit?.toInt() ?: DEFAULT_IMAGE_MATRIX_LIMIT, + maxMediaAttachments = instance.configuration.statuses?.maxMediaAttachments ?: DEFAULT_MAX_MEDIA_ATTACHMENTS, + maxFields = instance.pleroma?.metadata?.fieldLimits?.maxFields, + maxFieldNameLength = instance.pleroma?.metadata?.fieldLimits?.nameLength, + maxFieldValueLength = instance.pleroma?.metadata?.fieldLimits?.valueLength, + ) + dao.upsert(instanceEntity) + instanceEntity + }, + { throwable -> + if (throwable.isHttpNotFound()) { + getInstanceInfoV1() + } else { + Log.w(TAG, "failed to instance, falling back to cache and default values", throwable) + getCachedInstanceInfoEntity() + } + } + ).toInstanceInfo() + } + + private suspend fun getInstanceInfoV1(): InstanceInfoEntity? = withContext(Dispatchers.IO) { + api.getInstanceV1() .fold( { instance -> val instanceEntity = InstanceInfoEntity( @@ -79,7 +115,7 @@ class InstanceInfoRepository @Inject constructor( maxMediaAttachments = instance.configuration?.statuses?.maxMediaAttachments ?: instance.maxMediaAttachments, maxFields = instance.pleroma?.metadata?.fieldLimits?.maxFields, maxFieldNameLength = instance.pleroma?.metadata?.fieldLimits?.nameLength, - maxFieldValueLength = instance.pleroma?.metadata?.fieldLimits?.valueLength + maxFieldValueLength = instance.pleroma?.metadata?.fieldLimits?.valueLength, ) dao.upsert(instanceEntity) instanceEntity @@ -88,7 +124,7 @@ class InstanceInfoRepository @Inject constructor( Log.w(TAG, "failed to instance, falling back to cache and default values", throwable) getCachedInstanceInfoEntity() } - ).toInstanceInfo() + ) } private suspend fun getCachedInstanceInfoEntity(): InstanceInfoEntity? = @@ -149,6 +185,7 @@ class InstanceInfoRepository @Inject constructor( maxFields = this?.maxFields ?: DEFAULT_MAX_ACCOUNT_FIELDS, maxFieldNameLength = this?.maxFieldNameLength, maxFieldValueLength = this?.maxFieldValueLength, + version = this?.version, ) } } 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 13d8f2d83..000000000 --- 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 1da0a2b7d..000000000 --- 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 9b88ad966..000000000 --- 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/login/LoginWebViewActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewActivity.kt index be7e323ad..ed5744165 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewActivity.kt @@ -99,7 +99,7 @@ sealed class LoginResult : Parcelable { data class Err(val errorMessage: String) : LoginResult() @Parcelize - object Cancel : LoginResult() + data object Cancel : LoginResult() } /** Activity to do Oauth process using WebView. */ diff --git a/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewViewModel.kt index 39dd311aa..cf3c6a6bd 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewViewModel.kt @@ -20,6 +20,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import at.connyduck.calladapter.networkresult.fold import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.isHttpNotFound import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import javax.inject.Inject @@ -36,11 +37,25 @@ class LoginWebViewViewModel @Inject constructor( if (this.domain == null) { this.domain = domain viewModelScope.launch { - api.getInstance(domain).fold({ instance -> - instanceRules.value = instance.rules?.map { rule -> rule.text }.orEmpty() - }, { throwable -> - Log.w("LoginWebViewViewModel", "failed to load instance info", throwable) - }) + api.getInstance().fold( + { instance -> + instanceRules.value = instance.rules.map { rule -> rule.text } + }, + { throwable -> + if (throwable.isHttpNotFound()) { + api.getInstanceV1(domain).fold( + { instance -> + instanceRules.value = instance.rules?.map { rule -> rule.text }.orEmpty() + }, + { throwable -> + Log.w("LoginWebViewViewModel", "failed to load instance info", throwable) + } + ) + } else { + Log.w("LoginWebViewViewModel", "failed to load instance info", throwable) + } + } + ) } } } 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 70f564c0c..000000000 --- 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 633ca08f7..d735af3c1 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 @@ -4,18 +4,38 @@ import android.app.NotificationManager import android.content.Context import android.util.Log import androidx.annotation.WorkerThread +import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.appstore.NewNotificationsEvent import com.keylesspalace.tusky.components.notifications.NotificationHelper.filterNotification import com.keylesspalace.tusky.db.AccountEntity 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. * @@ -28,7 +48,8 @@ import kotlin.time.Duration.Companion.milliseconds class NotificationFetcher @Inject constructor( private val mastodonApi: MastodonApi, private val accountManager: AccountManager, - private val context: Context + private val context: Context, + private val eventHub: EventHub ) { suspend fun fetchAndShow() { for (account in accountManager.getAllAccountsOrderedByActive()) { @@ -42,6 +63,10 @@ class NotificationFetcher @Inject constructor( .sortedWith(compareBy({ it.id.length }, { it.id })) // oldest notifications first .toMutableList() + // TODO do this before filter above? But one could argue that (for example) a tab badge is also a notification + // (and should therefore adhere to the notification config). + eventHub.dispatch(NewNotificationsEvent(account.accountId, notifications)) + // There's a maximum limit on the number of notifications an Android app // can display. If the total number of notifications (current notifications, // plus new ones) exceeds this then some newer notifications will be dropped. @@ -65,23 +90,29 @@ class NotificationFetcher @Inject constructor( } } + val notificationsByType = notifications.groupBy { it.type } + // Make and send the new notifications // TODO: Use the batch notification API available in NotificationManagerCompat // 1.11 and up (https://developer.android.com/jetpack/androidx/releases/core#1.11.0-alpha01) // when it is released. - notifications.forEachIndexed { index, notification -> - val androidNotification = NotificationHelper.make( - context, - notificationManager, - notification, - account, - index == 0 - ) - notificationManager.notify(notification.id, account.id.toInt(), androidNotification) - // Android will rate limit / drop notifications if they're posted too - // quickly. There is no indication to the user that this happened. - // See https://github.com/tuskyapp/Tusky/pull/3626#discussion_r1192963664 - delay(1000.milliseconds) + + notificationsByType.forEach { notificationsGroup -> + notificationsGroup.value.forEach { notification -> + val androidNotification = NotificationHelper.make( + context, + notificationManager, + notification, + account, + notificationsGroup.value.size == 1 + ) + notificationManager.notify(notification.id, account.id.toInt(), androidNotification) + + // Android will rate limit / drop notifications if they're posted too + // quickly. There is no indication to the user that this happened. + // See https://github.com/tuskyapp/Tusky/pull/3626#discussion_r1192963664 + delay(1000.milliseconds) + } } NotificationHelper.updateSummaryNotifications( 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 6a42e8977..14012c6e8 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 @@ -85,13 +85,6 @@ public class NotificationHelper { /** Dynamic notification IDs start here */ private static int notificationId = NOTIFICATION_ID_PRUNE_CACHE + 1; - /** - * constants used in Intents - */ - public static final String ACCOUNT_ID = "account_id"; - - public static final String TYPE = APPLICATION_ID + ".notification.type"; - private static final String TAG = "NotificationHelper"; public static final String REPLY_ACTION = "REPLY_ACTION"; @@ -156,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 isOnlyOneInGroup) { body = body.rewriteToStatusTypeIfNeeded(account.getAccountId()); String mastodonNotificationId = body.getId(); int accountId = (int) account.getId(); @@ -208,8 +201,7 @@ public class NotificationHelper { 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(); @@ -245,11 +237,11 @@ public class NotificationHelper { 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.putString(EXTRA_NOTIFICATION_TYPE, body.getType().name()); builder.addExtras(extras); // Only alert for the first notification of a batch to avoid multiple alerts at once - if(!isFirstOfBatch) { + if(!isOnlyOneInGroup) { builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY); } @@ -278,14 +270,14 @@ public class NotificationHelper { * @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<>(); int accountId = (int) account.getId(); // Initialise the map with all channel IDs. - for (Notification.Type ty : Notification.Type.values()) { + for (Notification.Type ty : Notification.Type.getEntries()) { channelGroups.put(getChannelId(account, ty), new ArrayList<>()); } @@ -325,11 +317,11 @@ public class NotificationHelper { // Create a notification that summarises the other notifications in this group // All notifications in this group have the same type, so get it from the first. - String notificationType = members.get(0).getNotification().extras.getString(EXTRA_NOTIFICATION_TYPE); + String typeName = members.get(0).getNotification().extras.getString(EXTRA_NOTIFICATION_TYPE, Notification.Type.UNKNOWN.name()); + Notification.Type notificationType = Notification.Type.valueOf(typeName); + + Intent summaryResultIntent = MainActivity.openNotificationIntent(context, accountId, notificationType); - Intent summaryResultIntent = new Intent(context, MainActivity.class); - summaryResultIntent.putExtra(ACCOUNT_ID, (long) accountId); - summaryResultIntent.putExtra(TYPE, notificationType); TaskStackBuilder summaryStackBuilder = TaskStackBuilder.create(context); summaryStackBuilder.addParentStack(MainActivity.class); summaryStackBuilder.addNextIntent(summaryResultIntent); @@ -373,10 +365,8 @@ public class NotificationHelper { private static NotificationCompat.Builder newAndroidNotification(Context context, Notification body, AccountEntity account) { - // we have to switch account here - Intent eventResultIntent = new Intent(context, MainActivity.class); - eventResultIntent.putExtra(ACCOUNT_ID, account.getId()); - eventResultIntent.putExtra(TYPE, body.getType().name()); + Intent eventResultIntent = MainActivity.openNotificationIntent(context, account.getId(), body.getType()); + TaskStackBuilder eventStackBuilder = TaskStackBuilder.create(context); eventStackBuilder.addParentStack(MainActivity.class); eventStackBuilder.addNextIntent(eventResultIntent); @@ -464,12 +454,7 @@ public class NotificationHelper { composeOptions.setLanguage(actionableStatus.getLanguage()); composeOptions.setKind(ComposeActivity.ComposeKind.NEW); - Intent composeIntent = ComposeActivity.startIntent( - context, - composeOptions, - notificationId, - account.getId() - ); + Intent composeIntent = MainActivity.composeIntent(context, composeOptions, account.getId(), body.getId(), (int)account.getId()); composeIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); @@ -624,7 +609,7 @@ public class NotificationHelper { } - public static void enablePullNotifications(Context context) { + public static void enablePullNotifications(@NonNull Context context) { WorkManager workManager = WorkManager.getInstance(context); workManager.cancelAllWorkByTag(NOTIFICATION_PULL_TAG); @@ -652,7 +637,7 @@ public class NotificationHelper { 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"); } @@ -668,7 +653,7 @@ public class NotificationHelper { } } - 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()); } @@ -874,7 +859,7 @@ public class NotificationHelper { 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 index 73626146c..e69de29bb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt @@ -1,697 +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.coordinatorlayout.widget.CoordinatorLayout -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.appbar.AppBarLayout.ScrollingViewBehavior -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 updateFilterVisibility(showFilter: Boolean) { - val params = binding.swipeRefreshLayout.layoutParams as CoordinatorLayout.LayoutParams - if (showFilter) { - binding.appBarOptions.setExpanded(true, false) - binding.appBarOptions.visibility = View.VISIBLE - // Set content behaviour to hide filter on scroll - params.behavior = ScrollingViewBehavior() - } else { - binding.appBarOptions.setExpanded(false, false) - binding.appBarOptions.visibility = View.GONE - // Clear behaviour to hide app bar - params.behavior = null - } - } - - 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.buttonClear.setOnClickListener { confirmClearNotifications() } - binding.buttonFilter.setOnClickListener { showFilterDialog() } - (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 != RecyclerView.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 */ - } - } - } - } - - // Update filter option visibility from uiState - launch { - viewModel.uiState.collectLatest { updateFilterVisibility(it.showFilterOptions) } - } - - // 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.elephant_offline, - R.string.error_network - ) { adapter.retry() } - } - else -> { - binding.statusView.setup( - R.drawable.elephant_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) - menu.findItem(R.id.action_refresh)?.apply { - icon = IconicsDrawable(requireContext(), GoogleMaterial.Icon.gmd_refresh).apply { - sizeDp = 20 - colorInt = MaterialColors.getColor(binding.root, android.R.attr.textColorPrimary) - } - } - } - - 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 - } - 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 onQuote(position: Int) { - val status = adapter.peek(position)?.statusViewData?.status ?: return - super.quote(status) - } - - override fun onBookmark(bookmark: Boolean, position: Int) { - val statusViewData = adapter.peek(position)?.statusViewData ?: return - viewModel.accept(StatusAction.Bookmark(bookmark, statusViewData)) - } - - 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), 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) { - binding.appBarOptions.setExpanded(true, false) - 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/NotificationsLoadStateViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateViewHolder.kt deleted file mode 100644 index f3c006d32..000000000 --- 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 faa1aefce..000000000 --- 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.values()[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 b754989d0..000000000 --- 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 4bec1aa32..000000000 --- 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 d1d41a947..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt +++ /dev/null @@ -1,557 +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.FlowPreview -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 -import kotlin.time.ExperimentalTime - -data class UiState( - /** Filtered notification types */ - val activeFilter: Set = emptySet(), - - /** True if the UI to filter and clear notifications should be shown */ - val showFilterOptions: Boolean = false, - - /** True if the FAB should be shown while scrolling */ - val showFabWhileScrolling: Boolean = true -) - -/** Preferences the UI reacts to */ -data class UiPrefs( - val showFabWhileScrolling: Boolean, - val showFilter: Boolean -) { - companion object { - /** Relevant preference keys. Changes to any of these trigger a display update */ - val prefKeys = setOf( - PrefKeys.FAB_HIDE, - PrefKeys.SHOW_NOTIFICATIONS_FILTER - ) - } -} - -/** 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 */ - 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. - 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 */ - object Block : UiSuccess() - - /** A user was muted */ - object Mute : UiSuccess() - - /** A conversation was muted */ - 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, FlowPreview::class, ExperimentalTime::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, - showFilterOptions = prefs.showFilter, - 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), - showFilter = preferences.getBoolean(PrefKeys.SHOW_NOTIFICATIONS_FILTER, true) - ) - - 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 bc8aef8cd..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusNotificationViewHolder.kt +++ /dev/null @@ -1,404 +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.util.visible -import com.keylesspalace.tusky.viewdata.NotificationViewData -import com.keylesspalace.tusky.viewdata.StatusViewData -import net.accelf.yuito.QuoteInlineHelper -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) - } - - setQuoteContainer(statusViewData.quoteViewData, statusDisplayOptions) - } - 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 - } - - private fun setQuoteContainer(quote: StatusViewData.Concrete?, statusDisplayOptions: StatusDisplayOptions) { - binding.statusQuoteInlineContainer.root.visible(quote != null) - if (quote != null) { - QuoteInlineHelper( - binding.statusQuoteInlineContainer, - statusActionListener, - avatarRadius24dp, - statusDisplayOptions, - ) - .setupQuoteContainer(quote) - } - } - - 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 c719c084a..000000000 --- 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 b0fedb2b1..629c3709d 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 a94f56210..c0ff2bf94 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 @@ -33,8 +33,9 @@ import com.keylesspalace.tusky.R import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.databinding.ActivityPreferencesBinding +import com.keylesspalace.tusky.settings.AppTheme import com.keylesspalace.tusky.settings.PrefKeys -import com.keylesspalace.tusky.util.APP_THEME_DEFAULT +import com.keylesspalace.tusky.settings.PrefKeys.APP_THEME import com.keylesspalace.tusky.util.getNonNullString import com.keylesspalace.tusky.util.setAppNightMode import dagger.android.DispatchingAndroidInjector @@ -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, AppTheme.DEFAULT.value) 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, "viewPagerOffScreenLimit" -> { + 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, "viewPagerOffScreenLimit" -> { restartActivitiesOnBackPressedCallback.isEnabled = true } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt index bd30e0c38..48c0a17e8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt @@ -81,7 +81,7 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable { preferenceCategory(R.string.pref_title_appearance_settings) { listPreference { - setDefaultValue(AppTheme.NIGHT.value) + setDefaultValue(AppTheme.DEFAULT.value) setEntries(R.array.app_theme_names) entryValues = AppTheme.stringValues() key = PrefKeys.APP_THEME @@ -214,13 +214,6 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable { isSingleLineTitle = false } - switchPreference { - setDefaultValue(true) - key = PrefKeys.SHOW_NOTIFICATIONS_FILTER - setTitle(R.string.pref_title_show_notifications_filter) - isSingleLineTitle = false - } - switchPreference { setDefaultValue(true) key = PrefKeys.CONFIRM_REBLOGS diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/TabFilterPreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/TabFilterPreferencesFragment.kt index 023905291..15697f644 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/TabFilterPreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/TabFilterPreferencesFragment.kt @@ -19,9 +19,9 @@ import android.os.Bundle import androidx.preference.PreferenceFragmentCompat import com.keylesspalace.tusky.R import com.keylesspalace.tusky.settings.PrefKeys -import com.keylesspalace.tusky.settings.checkBoxPreference import com.keylesspalace.tusky.settings.makePreferenceScreen import com.keylesspalace.tusky.settings.preferenceCategory +import com.keylesspalace.tusky.settings.switchPreference class TabFilterPreferencesFragment : PreferenceFragmentCompat() { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { @@ -29,19 +29,27 @@ class TabFilterPreferencesFragment : PreferenceFragmentCompat() { preferenceCategory(R.string.title_home) { category -> category.isIconSpaceReserved = false - checkBoxPreference { + switchPreference { setTitle(R.string.pref_title_show_boosts) key = PrefKeys.TAB_FILTER_HOME_BOOSTS setDefaultValue(true) isIconSpaceReserved = false } - checkBoxPreference { + switchPreference { setTitle(R.string.pref_title_show_replies) key = PrefKeys.TAB_FILTER_HOME_REPLIES setDefaultValue(true) isIconSpaceReserved = false } + + switchPreference { + setTitle(R.string.pref_title_show_self_boosts) + setSummary(R.string.pref_title_show_self_boosts_description) + key = PrefKeys.TAB_SHOW_HOME_SELF_BOOSTS + setDefaultValue(true) + isIconSpaceReserved = false + }.apply { dependency = PrefKeys.TAB_FILTER_HOME_BOOSTS } } } } 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 fb0b15cae..e3c5c197d 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 0a028a9c0..1408ed58f 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 896ff50a0..348994e84 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 @@ -150,12 +150,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 a53c08928..d7e72812c 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 e4e99870a..44c53f176 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 da93bd075..2ffa15dfd 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 a8c344d55..42e4d672d 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 @@ -46,7 +46,6 @@ import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.appstore.QuickReplyEvent import com.keylesspalace.tusky.appstore.StatusComposedEvent -import com.keylesspalace.tusky.appstore.StatusEditedEvent import com.keylesspalace.tusky.components.accountlist.AccountListActivity import com.keylesspalace.tusky.components.accountlist.AccountListActivity.Companion.newIntent import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository.Companion.CAN_USE_QUOTE_ID @@ -293,7 +292,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 @@ -322,9 +321,6 @@ class TimelineFragment : val status = event.status handleStatusComposeEvent(status) } - is StatusEditedEvent -> { - handleStatusComposeEvent(event.status) - } } } } @@ -567,7 +563,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 c8d95fd81..4a1d75f92 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 89afefecd..2746b1acf 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/CachedTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt index 9007adfb7..a6581a4eb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt @@ -27,11 +27,7 @@ import androidx.paging.filter import androidx.paging.map import androidx.room.withTransaction import com.google.gson.Gson -import com.keylesspalace.tusky.appstore.BookmarkEvent import com.keylesspalace.tusky.appstore.EventHub -import com.keylesspalace.tusky.appstore.FavoriteEvent -import com.keylesspalace.tusky.appstore.PinEvent -import com.keylesspalace.tusky.appstore.ReblogEvent import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder.NEWEST_FIRST import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder.OLDEST_FIRST import com.keylesspalace.tusky.components.timeline.Placeholder @@ -254,19 +250,7 @@ class CachedTimelineViewModel @Inject constructor( .insertStatus(Placeholder(placeholderId, loading = false).toEntity(activeAccount.id)) } - override fun handleReblogEvent(reblogEvent: ReblogEvent) { - // handled by CacheUpdater - } - - override fun handleFavEvent(favEvent: FavoriteEvent) { - // handled by CacheUpdater - } - - override fun handleBookmarkEvent(bookmarkEvent: BookmarkEvent) { - // handled by CacheUpdater - } - - override fun handlePinEvent(pinEvent: PinEvent) { + override fun handleStatusChangedEvent(status: Status) { // handled by CacheUpdater } 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 98da15bea..a80ca95da 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 fc68149b2..af2704222 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 @@ -23,11 +23,7 @@ import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.cachedIn import androidx.paging.filter -import com.keylesspalace.tusky.appstore.BookmarkEvent import com.keylesspalace.tusky.appstore.EventHub -import com.keylesspalace.tusky.appstore.FavoriteEvent -import com.keylesspalace.tusky.appstore.PinEvent -import com.keylesspalace.tusky.appstore.ReblogEvent import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Filter @@ -219,27 +215,13 @@ class NetworkTimelineViewModel @Inject constructor( currentSource?.invalidate() } - override fun handleReblogEvent(reblogEvent: ReblogEvent) { - updateStatusById(reblogEvent.statusId) { - it.copy(status = it.status.copy(reblogged = reblogEvent.reblog)) - } - } - - override fun handleFavEvent(favEvent: FavoriteEvent) { - updateActionableStatusById(favEvent.statusId) { - it.copy(favourited = favEvent.favourite) - } - } - - override fun handleBookmarkEvent(bookmarkEvent: BookmarkEvent) { - updateActionableStatusById(bookmarkEvent.statusId) { - it.copy(bookmarked = bookmarkEvent.bookmark) - } - } - - override fun handlePinEvent(pinEvent: PinEvent) { - updateActionableStatusById(pinEvent.statusId) { - it.copy(pinned = pinEvent.pinned) + override fun handleStatusChangedEvent(status: Status) { + updateStatusById(status.id) { oldViewData -> + status.toViewData( + isShowingContent = oldViewData.isShowingContent, + isExpanded = oldViewData.isExpanded, + isCollapsed = oldViewData.isCollapsed + ) } } @@ -272,7 +254,7 @@ class NetworkTimelineViewModel @Inject constructor( } override fun clearWarning(status: StatusViewData.Concrete) { - updateActionableStatusById(status.actionableId) { + updateActionableStatusById(status.id) { it.copy(filtered = null) } } @@ -330,6 +312,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 cc01bdcb2..1dccf3bb9 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 @@ -24,16 +24,14 @@ import at.connyduck.calladapter.networkresult.fold import at.connyduck.calladapter.networkresult.getOrElse import at.connyduck.calladapter.networkresult.getOrThrow import com.keylesspalace.tusky.appstore.BlockEvent -import com.keylesspalace.tusky.appstore.BookmarkEvent import com.keylesspalace.tusky.appstore.DomainMuteEvent import com.keylesspalace.tusky.appstore.Event import com.keylesspalace.tusky.appstore.EventHub -import com.keylesspalace.tusky.appstore.FavoriteEvent +import com.keylesspalace.tusky.appstore.FilterUpdatedEvent import com.keylesspalace.tusky.appstore.MuteConversationEvent import com.keylesspalace.tusky.appstore.MuteEvent -import com.keylesspalace.tusky.appstore.PinEvent import com.keylesspalace.tusky.appstore.PreferenceChangedEvent -import com.keylesspalace.tusky.appstore.ReblogEvent +import com.keylesspalace.tusky.appstore.StatusChangedEvent import com.keylesspalace.tusky.appstore.StatusDeletedEvent import com.keylesspalace.tusky.appstore.StreamUpdateEvent import com.keylesspalace.tusky.appstore.UnfollowEvent @@ -48,13 +46,13 @@ import com.keylesspalace.tusky.network.FilterModel import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.usecase.TimelineCases +import com.keylesspalace.tusky.util.isHttpNotFound import com.keylesspalace.tusky.viewdata.StatusViewData import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch import net.accelf.yuito.streaming.StreamType import net.accelf.yuito.streaming.Subscription -import retrofit2.HttpException abstract class TimelineViewModel( private val timelineCases: TimelineCases, @@ -79,6 +77,7 @@ abstract class TimelineViewModel( private var alwaysOpenSpoilers = false private var filterRemoveReplies = false private var filterRemoveReblogs = false + private var filterRemoveSelfReblogs = false protected var readingOrder: ReadingOrder = ReadingOrder.OLDEST_FIRST val shouldReplyInQuick by lazy { @@ -89,6 +88,7 @@ abstract class TimelineViewModel( Kind.TAG, Kind.FAVOURITES, Kind.LIST, + Kind.PUBLIC_TRENDING_STATUSES, -> true Kind.BOOKMARKS, Kind.USER, @@ -111,6 +111,7 @@ abstract class TimelineViewModel( Kind.USER_WITH_REPLIES, Kind.FAVOURITES, Kind.BOOKMARKS, + Kind.PUBLIC_TRENDING_STATUSES, -> { throw NotImplementedError("streaming not implemented for this type") } @@ -135,6 +136,8 @@ abstract class TimelineViewModel( !sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_REPLIES, true) filterRemoveReblogs = !sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_BOOSTS, true) + filterRemoveSelfReblogs = + !sharedPreferences.getBoolean(PrefKeys.TAB_SHOW_HOME_SELF_BOOSTS, true) } readingOrder = ReadingOrder.from(sharedPreferences.getString(PrefKeys.READING_ORDER, null)) @@ -213,13 +216,7 @@ abstract class TimelineViewModel( abstract fun loadMore(placeholderId: String) - abstract fun handleReblogEvent(reblogEvent: ReblogEvent) - - abstract fun handleFavEvent(favEvent: FavoriteEvent) - - abstract fun handleBookmarkEvent(bookmarkEvent: BookmarkEvent) - - abstract fun handlePinEvent(pinEvent: PinEvent) + abstract fun handleStatusChangedEvent(status: Status) abstract fun handleStreamUpdateEvent(status: Status, streamId: Int) @@ -237,7 +234,8 @@ abstract class TimelineViewModel( val status = statusViewData.asStatusOrNull()?.status ?: return Filter.Action.NONE return if ( (status.inReplyToId != null && filterRemoveReplies) || - (status.reblog != null && filterRemoveReblogs) + (status.reblog != null && filterRemoveReblogs) || + ((status.account.id == status.reblog?.account?.id) && filterRemoveSelfReblogs) ) { return Filter.Action.HIDE } else { @@ -264,6 +262,14 @@ abstract class TimelineViewModel( fullReload() } } + PrefKeys.TAB_SHOW_HOME_SELF_BOOSTS -> { + val filter = sharedPreferences.getBoolean(PrefKeys.TAB_SHOW_HOME_SELF_BOOSTS, true) + val oldRemoveSelfReblogs = filterRemoveSelfReblogs + filterRemoveSelfReblogs = kind == Kind.HOME && !filter + if (oldRemoveSelfReblogs != filterRemoveSelfReblogs) { + fullReload() + } + } FilterV1.HOME, FilterV1.NOTIFICATIONS, FilterV1.THREAD, FilterV1.PUBLIC, FilterV1.ACCOUNT -> { if (filterContextMatchesKind(kind, listOf(key))) { reloadFilters() @@ -282,10 +288,6 @@ abstract class TimelineViewModel( private fun handleEvent(event: Event) { when (event) { - is FavoriteEvent -> handleFavEvent(event) - is ReblogEvent -> handleReblogEvent(event) - is BookmarkEvent -> handleBookmarkEvent(event) - is PinEvent -> handlePinEvent(event) is StreamUpdateEvent -> { if (isStreamingEnabled && event.subscription == subscription) { handleStreamUpdateEvent(event.status, event.streamId) @@ -324,6 +326,11 @@ abstract class TimelineViewModel( is PreferenceChangedEvent -> { onPreferenceChanged(event.preferenceKey) } + is FilterUpdatedEvent -> { + if (filterContextMatchesKind(kind, event.filterContext)) { + fullReload() + } + } } } @@ -336,7 +343,7 @@ abstract class TimelineViewModel( invalidate() }, { throwable -> - if (throwable is HttpException && throwable.code() == 404) { + if (throwable.isHttpNotFound()) { // Fallback to client-side filter code val filters = api.getFiltersV1().getOrElse { Log.e(TAG, "Failed to fetch filters", it) @@ -371,12 +378,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/trending/TrendingActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingActivity.kt index 3270e9c26..bdf2cdc79 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingActivity.kt @@ -48,7 +48,7 @@ class TrendingActivity : BaseActivity(), HasAndroidInjector { if (supportFragmentManager.findFragmentById(R.id.fragmentContainer) == null) { supportFragmentManager.commit { - val fragment = TrendingFragment.newInstance() + val fragment = TrendingTagsFragment.newInstance() replace(R.id.fragmentContainer, fragment) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingTagsAdapter.kt similarity index 99% rename from app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingAdapter.kt rename to app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingTagsAdapter.kt index b40d67670..4137d200e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingTagsAdapter.kt @@ -24,7 +24,7 @@ import com.keylesspalace.tusky.databinding.ItemTrendingCellBinding import com.keylesspalace.tusky.databinding.ItemTrendingDateBinding import com.keylesspalace.tusky.viewdata.TrendingViewData -class TrendingAdapter( +class TrendingTagsAdapter( private val onViewTag: (String) -> Unit ) : ListAdapter(TrendingDifferCallback) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingTagsFragment.kt similarity index 85% rename from app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingFragment.kt rename to app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingTagsFragment.kt index bcb22d050..c04caee6d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingTagsFragment.kt @@ -33,8 +33,8 @@ import at.connyduck.sparkbutton.helpers.Utils import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.StatusListActivity -import com.keylesspalace.tusky.components.trending.viewmodel.TrendingViewModel -import com.keylesspalace.tusky.databinding.FragmentTrendingBinding +import com.keylesspalace.tusky.components.trending.viewmodel.TrendingTagsViewModel +import com.keylesspalace.tusky.databinding.FragmentTrendingTagsBinding import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.interfaces.ActionButtonActivity @@ -48,8 +48,8 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import javax.inject.Inject -class TrendingFragment : - Fragment(R.layout.fragment_trending), +class TrendingTagsFragment : + Fragment(R.layout.fragment_trending_tags), OnRefreshListener, Injectable, ReselectableFragment, @@ -58,11 +58,11 @@ class TrendingFragment : @Inject lateinit var viewModelFactory: ViewModelFactory - private val viewModel: TrendingViewModel by viewModels { viewModelFactory } + private val viewModel: TrendingTagsViewModel by viewModels { viewModelFactory } - private val binding by viewBinding(FragmentTrendingBinding::bind) + private val binding by viewBinding(FragmentTrendingTagsBinding::bind) - private val adapter = TrendingAdapter(::onViewTag) + private val adapter = TrendingTagsAdapter(::onViewTag) override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) @@ -111,8 +111,8 @@ class TrendingFragment : spanSizeLookup = object : SpanSizeLookup() { override fun getSpanSize(position: Int): Int { return when (adapter.getItemViewType(position)) { - TrendingAdapter.VIEW_TYPE_HEADER -> columnCount - TrendingAdapter.VIEW_TYPE_TAG -> 1 + TrendingTagsAdapter.VIEW_TYPE_HEADER -> columnCount + TrendingTagsAdapter.VIEW_TYPE_TAG -> 1 else -> -1 } } @@ -139,15 +139,15 @@ class TrendingFragment : (requireActivity() as BaseActivity).startActivityWithSlideInAnimation(StatusListActivity.newHashtagIntent(requireContext(), tag)) } - private fun processViewState(uiState: TrendingViewModel.TrendingUiState) { + private fun processViewState(uiState: TrendingTagsViewModel.TrendingTagsUiState) { Log.d(TAG, uiState.loadingState.name) when (uiState.loadingState) { - TrendingViewModel.LoadingState.INITIAL -> clearLoadingState() - TrendingViewModel.LoadingState.LOADING -> applyLoadingState() - TrendingViewModel.LoadingState.REFRESHING -> applyRefreshingState() - TrendingViewModel.LoadingState.LOADED -> applyLoadedState(uiState.trendingViewData) - TrendingViewModel.LoadingState.ERROR_NETWORK -> networkError() - TrendingViewModel.LoadingState.ERROR_OTHER -> otherError() + TrendingTagsViewModel.LoadingState.INITIAL -> clearLoadingState() + TrendingTagsViewModel.LoadingState.LOADING -> applyLoadingState() + TrendingTagsViewModel.LoadingState.REFRESHING -> applyRefreshingState() + TrendingTagsViewModel.LoadingState.LOADED -> applyLoadedState(uiState.trendingViewData) + TrendingTagsViewModel.LoadingState.ERROR_NETWORK -> networkError() + TrendingTagsViewModel.LoadingState.ERROR_OTHER -> otherError() } } @@ -194,7 +194,7 @@ class TrendingFragment : binding.swipeRefreshLayout.isRefreshing = false binding.messageView.setup( - R.drawable.elephant_offline, + R.drawable.errorphant_offline, R.string.error_network ) { refreshContent() } } @@ -206,7 +206,7 @@ class TrendingFragment : binding.swipeRefreshLayout.isRefreshing = false binding.messageView.setup( - R.drawable.elephant_error, + R.drawable.errorphant_error, R.string.error_generic ) { refreshContent() } } @@ -247,8 +247,8 @@ class TrendingFragment : } companion object { - private const val TAG = "TrendingFragment" + private const val TAG = "TrendingTagsFragment" - fun newInstance() = TrendingFragment() + fun newInstance() = TrendingTagsFragment() } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/trending/viewmodel/TrendingViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/trending/viewmodel/TrendingTagsViewModel.kt similarity index 84% rename from app/src/main/java/com/keylesspalace/tusky/components/trending/viewmodel/TrendingViewModel.kt rename to app/src/main/java/com/keylesspalace/tusky/components/trending/viewmodel/TrendingTagsViewModel.kt index 67447b308..92f0a8c46 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/trending/viewmodel/TrendingViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/trending/viewmodel/TrendingTagsViewModel.kt @@ -35,7 +35,7 @@ import kotlinx.coroutines.launch import java.io.IOException import javax.inject.Inject -class TrendingViewModel @Inject constructor( +class TrendingTagsViewModel @Inject constructor( private val mastodonApi: MastodonApi, private val eventHub: EventHub ) : ViewModel() { @@ -43,13 +43,13 @@ class TrendingViewModel @Inject constructor( INITIAL, LOADING, REFRESHING, LOADED, ERROR_NETWORK, ERROR_OTHER } - data class TrendingUiState( + data class TrendingTagsUiState( val trendingViewData: List, val loadingState: LoadingState ) - val uiState: Flow get() = _uiState - private val _uiState = MutableStateFlow(TrendingUiState(listOf(), LoadingState.INITIAL)) + val uiState: Flow get() = _uiState + private val _uiState = MutableStateFlow(TrendingTagsUiState(listOf(), LoadingState.INITIAL)) init { invalidate() @@ -73,9 +73,9 @@ class TrendingViewModel @Inject constructor( */ fun invalidate(refresh: Boolean = false) = viewModelScope.launch { if (refresh) { - _uiState.value = TrendingUiState(emptyList(), LoadingState.REFRESHING) + _uiState.value = TrendingTagsUiState(emptyList(), LoadingState.REFRESHING) } else { - _uiState.value = TrendingUiState(emptyList(), LoadingState.LOADING) + _uiState.value = TrendingTagsUiState(emptyList(), LoadingState.LOADING) } val deferredFilters = async { mastodonApi.getFilters() } @@ -85,7 +85,7 @@ class TrendingViewModel @Inject constructor( val firstTag = tagResponse.firstOrNull() _uiState.value = if (firstTag == null) { - TrendingUiState(emptyList(), LoadingState.LOADED) + TrendingTagsUiState(emptyList(), LoadingState.LOADED) } else { val homeFilters = deferredFilters.await().getOrNull()?.filter { filter -> filter.context.contains(Filter.Kind.HOME.kind) @@ -100,15 +100,15 @@ class TrendingViewModel @Inject constructor( .toViewData() val header = TrendingViewData.Header(firstTag.start(), firstTag.end()) - TrendingUiState(listOf(header) + tags, LoadingState.LOADED) + TrendingTagsUiState(listOf(header) + tags, LoadingState.LOADED) } }, { error -> Log.w(TAG, "failed loading trending tags", error) if (error is IOException) { - _uiState.value = TrendingUiState(emptyList(), LoadingState.ERROR_NETWORK) + _uiState.value = TrendingTagsUiState(emptyList(), LoadingState.ERROR_NETWORK) } else { - _uiState.value = TrendingUiState(emptyList(), LoadingState.ERROR_OTHER) + _uiState.value = TrendingTagsUiState(emptyList(), LoadingState.ERROR_OTHER) } } ) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ConversationLineItemDecoration.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ConversationLineItemDecoration.kt index 36e36d10f..ffd1b9c83 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ConversationLineItemDecoration.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ConversationLineItemDecoration.kt @@ -24,36 +24,34 @@ import androidx.core.view.forEach import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R -class ConversationLineItemDecoration(private val context: Context) : RecyclerView.ItemDecoration() { - +class ConversationLineItemDecoration(context: Context) : RecyclerView.ItemDecoration() { private val divider: Drawable = ContextCompat.getDrawable(context, R.drawable.conversation_thread_line)!! - override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) { - val dividerStart = parent.paddingStart + context.resources.getDimensionPixelSize(R.dimen.status_line_margin_start) - val dividerEnd = dividerStart + divider.intrinsicWidth + private val avatarTopMargin = context.resources.getDimensionPixelSize(R.dimen.account_avatar_margin) + private val halfAvatarHeight = context.resources.getDimensionPixelSize(R.dimen.timeline_status_avatar_height) / 2 + private val statusLineMarginStart = context.resources.getDimensionPixelSize(R.dimen.status_line_margin_start) - val avatarMargin = context.resources.getDimensionPixelSize(R.dimen.account_avatar_margin) + override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) { + val dividerStart = parent.paddingStart + statusLineMarginStart + val dividerEnd = dividerStart + divider.intrinsicWidth val items = (parent.adapter as ThreadAdapter).currentList - parent.forEach { child -> + parent.forEach { statusItemView -> + val position = parent.getChildAdapterPosition(statusItemView) - val position = parent.getChildAdapterPosition(child) - - val current = items.getOrNull(position) - - if (current != null) { + items.getOrNull(position)?.let { current -> val above = items.getOrNull(position - 1) val dividerTop = if (above != null && above.id == current.status.inReplyToId) { - child.top + statusItemView.top } else { - child.top + avatarMargin + statusItemView.top + avatarTopMargin + halfAvatarHeight } val below = items.getOrNull(position + 1) val dividerBottom = if (below != null && current.id == below.status.inReplyToId && !current.isDetailed) { - child.bottom + statusItemView.bottom } else { - child.top + avatarMargin + statusItemView.top + avatarTopMargin + halfAvatarHeight } if (parent.layoutDirection == View.LAYOUT_DIRECTION_LTR) { 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 ce8fd54f6..55e24790d 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 @@ -99,18 +99,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), @@ -342,7 +342,11 @@ class ViewThreadFragment : override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { val status = adapter.currentList[position].status - super.viewMedia(attachmentIndex, list(status), view) + super.viewMedia( + attachmentIndex, + list(status, alwaysShowSensitiveMedia), + view + ) } override fun onViewThread(position: Int) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt index f4b94400b..f33bf0792 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt @@ -23,14 +23,10 @@ import at.connyduck.calladapter.networkresult.getOrElse import at.connyduck.calladapter.networkresult.getOrThrow import com.google.gson.Gson import com.keylesspalace.tusky.appstore.BlockEvent -import com.keylesspalace.tusky.appstore.BookmarkEvent import com.keylesspalace.tusky.appstore.EventHub -import com.keylesspalace.tusky.appstore.FavoriteEvent -import com.keylesspalace.tusky.appstore.PinEvent -import com.keylesspalace.tusky.appstore.ReblogEvent +import com.keylesspalace.tusky.appstore.StatusChangedEvent import com.keylesspalace.tusky.appstore.StatusComposedEvent import com.keylesspalace.tusky.appstore.StatusDeletedEvent -import com.keylesspalace.tusky.appstore.StatusEditedEvent import com.keylesspalace.tusky.components.timeline.toViewData import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.db.AccountManager @@ -41,6 +37,7 @@ import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.FilterModel import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.usecase.TimelineCases +import com.keylesspalace.tusky.util.isHttpNotFound import com.keylesspalace.tusky.util.toViewData import com.keylesspalace.tusky.viewdata.StatusViewData import kotlinx.coroutines.Job @@ -51,7 +48,6 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import retrofit2.HttpException import javax.inject.Inject class ViewThreadViewModel @Inject constructor( @@ -59,7 +55,7 @@ class ViewThreadViewModel @Inject constructor( private val filterModel: FilterModel, private val timelineCases: TimelineCases, eventHub: EventHub, - accountManager: AccountManager, + private val accountManager: AccountManager, private val db: AppDatabase, private val gson: Gson ) : ViewModel() { @@ -86,14 +82,10 @@ class ViewThreadViewModel @Inject constructor( eventHub.events .collect { event -> when (event) { - is FavoriteEvent -> handleFavEvent(event) - is ReblogEvent -> handleReblogEvent(event) - is BookmarkEvent -> handleBookmarkEvent(event) - is PinEvent -> handlePinEvent(event) + is StatusChangedEvent -> handleStatusChangedEvent(event.status) is BlockEvent -> removeAllByAccountId(event.accountId) is StatusComposedEvent -> handleStatusComposedEvent(event) is StatusDeletedEvent -> handleStatusDeletedEvent(event) - is StatusEditedEvent -> handleStatusEditedEvent(event) } } } @@ -107,7 +99,7 @@ class ViewThreadViewModel @Inject constructor( viewModelScope.launch { Log.d(TAG, "Finding status with: $id") val contextCall = async { api.statusContext(id) } - val timelineStatus = db.timelineDao().getStatus(id) + val timelineStatus = db.timelineDao().getStatus(accountManager.activeAccount!!.id, id) var detailedStatus = if (timelineStatus != null) { Log.d(TAG, "Loaded status from local timeline") @@ -144,8 +136,14 @@ class ViewThreadViewModel @Inject constructor( // for the status. Ignore errors, the user still has a functioning UI if the fetch // failed. if (timelineStatus != null) { - val viewData = api.status(id).getOrNull()?.toViewData(isDetailed = true) - if (viewData != null) { detailedStatus = viewData } + api.status(id).getOrNull()?.let { result -> + db.timelineDao().update( + accountId = accountManager.activeAccount!!.id, + status = result, + gson = gson + ) + detailedStatus = result.toViewData(isDetailed = true) + } } val contextResult = contextCall.await() @@ -277,27 +275,14 @@ class ViewThreadViewModel @Inject constructor( } } - private fun handleFavEvent(event: FavoriteEvent) { - updateStatus(event.statusId) { status -> - status.copy(favourited = event.favourite) - } - } - - private fun handleReblogEvent(event: ReblogEvent) { - updateStatus(event.statusId) { status -> - status.copy(reblogged = event.reblog) - } - } - - private fun handleBookmarkEvent(event: BookmarkEvent) { - updateStatus(event.statusId) { status -> - status.copy(bookmarked = event.bookmark) - } - } - - private fun handlePinEvent(event: PinEvent) { - updateStatus(event.statusId) { status -> - status.copy(pinned = event.pinned) + private fun handleStatusChangedEvent(status: Status) { + updateStatusViewData(status.id) { viewData -> + status.toViewData( + isShowingContent = viewData.isShowingContent, + isExpanded = viewData.isExpanded, + isCollapsed = viewData.isCollapsed, + isDetailed = viewData.isDetailed + ) } } @@ -329,20 +314,6 @@ class ViewThreadViewModel @Inject constructor( } } - private fun handleStatusEditedEvent(event: StatusEditedEvent) { - updateSuccess { uiState -> - uiState.copy( - statusViewData = uiState.statusViewData.map { status -> - if (status.actionableId == event.originalId) { - event.status.toViewData() - } else { - status - } - } - ) - } - } - private fun handleStatusDeletedEvent(event: StatusDeletedEvent) { updateSuccess { uiState -> uiState.copy( @@ -420,7 +391,7 @@ class ViewThreadViewModel @Inject constructor( updateStatuses() }, { throwable -> - if (throwable is HttpException && throwable.code() == 404) { + if (throwable.isHttpNotFound()) { val filters = api.getFiltersV1().getOrElse { Log.w(TAG, "Failed to fetch filters", it) return@launch @@ -516,7 +487,7 @@ class ViewThreadViewModel @Inject constructor( sealed interface ThreadUiState { /** The initial load of the detailed status for this thread */ - object Loading : ThreadUiState + data object Loading : ThreadUiState /** Loading the detailed status has completed, now loading ancestors/descendants */ data class LoadingThread( @@ -535,7 +506,7 @@ sealed interface ThreadUiState { ) : ThreadUiState /** Refreshing the thread with a swipe */ - object Refreshing : ThreadUiState + data object Refreshing : ThreadUiState } enum class RevealButtonState { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsAdapter.kt index 33085f82b..3866bde59 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsAdapter.kt @@ -5,10 +5,7 @@ import android.graphics.Typeface.DEFAULT_BOLD import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Drawable import android.text.Editable -import android.text.Html -import android.text.Spannable import android.text.SpannableStringBuilder -import android.text.Spanned import android.text.TextPaint import android.text.style.CharacterStyle import android.util.TypedValue @@ -29,6 +26,7 @@ import com.keylesspalace.tusky.entity.StatusEdit import com.keylesspalace.tusky.interfaces.LinkListener import com.keylesspalace.tusky.util.AbsoluteTimeFormatter import com.keylesspalace.tusky.util.BindingHolder +import com.keylesspalace.tusky.util.TuskyTagHandler import com.keylesspalace.tusky.util.aspectRatios import com.keylesspalace.tusky.util.decodeBlurHash import com.keylesspalace.tusky.util.emojify @@ -51,10 +49,10 @@ class ViewEditsAdapter( private val absoluteTimeFormatter = AbsoluteTimeFormatter() /** Size of large text in this theme, in px */ - var largeTextSizePx: Float = 0f + private var largeTextSizePx: Float = 0f /** Size of medium text in this theme, in px */ - var mediumTextSizePx: Float = 0f + private var mediumTextSizePx: Float = 0f override fun onCreateViewHolder( parent: ViewGroup, @@ -118,7 +116,7 @@ class ViewEditsAdapter( val emojifiedText = edit .content - .parseAsMastodonHtml(TuskyTagHandler(context)) + .parseAsMastodonHtml(EditsTagHandler(context)) .emojify(edit.emojis, binding.statusEditContent, animateEmojis) setClickableText(binding.statusEditContent, emojifiedText, emptyList(), emptyList(), listener) @@ -224,7 +222,7 @@ class ViewEditsAdapter( * Handle XML tags created by [ViewEditsViewModel] and create custom spans to display inserted or * deleted text. */ -class TuskyTagHandler(val context: Context) : Html.TagHandler { +class EditsTagHandler(val context: Context) : TuskyTagHandler() { /** Class to mark the start of a span of deleted text */ class Del @@ -255,34 +253,7 @@ class TuskyTagHandler(val context: Context) : Html.TagHandler { ) } } - } - } - - /** @return the last span in [text] of type [kind], or null if that kind is not in text */ - private fun getLast(text: Spanned, kind: Class): Any? { - val spans = text.getSpans(0, text.length, kind) - return spans?.get(spans.size - 1) - } - - /** - * Mark the start of a span of [text] with [mark] so it can be discovered later by [end]. - */ - private fun start(text: SpannableStringBuilder, mark: Any) { - val len = text.length - text.setSpan(mark, len, len, Spannable.SPAN_MARK_MARK) - } - - /** - * Set a [span] over the [text] most from the point recently marked with [mark] to the end - * of the text. - */ - private fun end(text: SpannableStringBuilder, mark: Class, span: Any) { - val len = text.length - val obj = getLast(text, mark) - val where = text.getSpanStart(obj) - text.removeSpan(obj) - if (where != len) { - text.setSpan(span, where, len, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + else -> super.handleTag(opening, tag, output, xmlReader) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsViewModel.kt index 93f663587..d1f0bcae4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsViewModel.kt @@ -18,8 +18,8 @@ package com.keylesspalace.tusky.components.viewthread.edits import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import at.connyduck.calladapter.networkresult.getOrElse -import com.keylesspalace.tusky.components.viewthread.edits.TuskyTagHandler.Companion.DELETED_TEXT_EL -import com.keylesspalace.tusky.components.viewthread.edits.TuskyTagHandler.Companion.INSERTED_TEXT_EL +import com.keylesspalace.tusky.components.viewthread.edits.EditsTagHandler.Companion.DELETED_TEXT_EL +import com.keylesspalace.tusky.components.viewthread.edits.EditsTagHandler.Companion.INSERTED_TEXT_EL import com.keylesspalace.tusky.entity.StatusEdit import com.keylesspalace.tusky.network.MastodonApi import kotlinx.coroutines.Dispatchers @@ -132,12 +132,12 @@ class ViewEditsViewModel @Inject constructor(private val api: MastodonApi) : Vie } sealed interface EditsUiState { - object Initial : EditsUiState - object Loading : EditsUiState + data object Initial : EditsUiState + data object Loading : EditsUiState // "Refreshing" state is necessary, otherwise a refresh state transition is Success -> Success, // and state flows don't emit repeated states, so the UI never updates. - object Refreshing : EditsUiState + data object Refreshing : EditsUiState class Error(val throwable: Throwable) : EditsUiState data class Success( val edits: List diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt index cdde765f7..ee3ad6cd5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt @@ -100,7 +100,14 @@ data class AccountEntity( * ID of the status at the top of the visible list in the home timeline when the * user navigated away. */ - var lastVisibleHomeTimelineStatusId: String? = null + var lastVisibleHomeTimelineStatusId: String? = null, + + /** true if the connected Mastodon account is locked (has to manually approve all follow requests **/ + @ColumnInfo(defaultValue = "0") + var locked: Boolean = false, + + @ColumnInfo(defaultValue = "0") + var hasDirectMessageBadge: Boolean = false ) { val identifier: String @@ -125,9 +132,7 @@ data class AccountEntity( other as AccountEntity if (id == other.id) return true - if (domain == other.domain && accountId == other.accountId) return true - - return false + return domain == other.domain && accountId == other.accountId } override fun hashCode(): Int { diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt b/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt index 61ce076a5..9c7999fd9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt @@ -156,6 +156,7 @@ class AccountManager @Inject constructor(db: AppDatabase) { it.defaultPostLanguage = account.source?.language.orEmpty() it.defaultMediaSensitivity = account.source?.sensitive ?: false it.emojis = account.emojis.orEmpty() + it.locked = account.locked Log.d(TAG, "updateActiveAccount: saving account with id " + it.id) accountDao.insertOrReplace(it) diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java index 1fbf090b2..28cc37378 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java +++ b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java @@ -42,11 +42,13 @@ import java.io.File; TimelineAccountEntity.class, ConversationEntity.class }, - version = 51, + version = 54, autoMigrations = { @AutoMigration(from = 48, to = 49), @AutoMigration(from = 49, to = 50, spec = AppDatabase.MIGRATION_49_50.class), - @AutoMigration(from = 50, to = 51) + @AutoMigration(from = 50, to = 51), + @AutoMigration(from = 51, to = 52), + @AutoMigration(from = 53, to = 54) // hasDirectMessageBadge in AccountEntity } ) public abstract class AppDatabase extends RoomDatabase { @@ -674,4 +676,15 @@ public abstract class AppDatabase extends RoomDatabase { @DeleteColumn(tableName = "AccountEntity", columnName = "activeNotifications") static class MIGRATION_49_50 implements AutoMigrationSpec { } + + /** + * TabData.TRENDING was renamed to TabData.TRENDING_TAGS, and the text + * representation was changed from "Trending" to "TrendingTags". + */ + public static final Migration MIGRATION_52_53 = new Migration(52, 53) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL("UPDATE `AccountEntity` SET `tabpreferences` = REPLACE(tabpreferences, 'Trending:', 'TrendingTags:')"); + } + }; } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt index 0928b4ce0..adf9864a2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt @@ -20,6 +20,10 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy.Companion.REPLACE import androidx.room.Query +import androidx.room.TypeConverters +import com.google.gson.Gson +import com.keylesspalace.tusky.entity.FilterResult +import com.keylesspalace.tusky.entity.Status @Dao abstract class TimelineDao { @@ -74,9 +78,10 @@ FROM TimelineStatusEntity s LEFT JOIN TimelineAccountEntity a ON (s.timelineUserId = a.timelineUserId AND s.authorServerId = a.serverId) LEFT JOIN TimelineAccountEntity rb ON (s.timelineUserId = rb.timelineUserId AND s.reblogAccountId = rb.serverId) WHERE (s.serverId = :statusId OR s.reblogServerId = :statusId) -AND s.authorServerId IS NOT NULL""" +AND s.authorServerId IS NOT NULL +AND s.timelineUserId = :accountId""" ) - abstract suspend fun getStatus(statusId: String): TimelineStatusWithAccount? + abstract suspend fun getStatus(accountId: Long, statusId: String): TimelineStatusWithAccount? @Query( """DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND @@ -87,11 +92,85 @@ AND ) abstract suspend fun deleteRange(accountId: Long, minId: String, maxId: String): Int + suspend fun update(accountId: Long, status: Status, gson: Gson) { + update( + accountId = accountId, + statusId = status.id, + content = status.content, + editedAt = status.editedAt?.time, + emojis = gson.toJson(status.emojis), + reblogsCount = status.reblogsCount, + favouritesCount = status.favouritesCount, + repliesCount = status.repliesCount, + reblogged = status.reblogged, + bookmarked = status.bookmarked, + favourited = status.favourited, + sensitive = status.sensitive, + spoilerText = status.spoilerText, + visibility = status.visibility, + attachments = gson.toJson(status.attachments), + mentions = gson.toJson(status.mentions), + tags = gson.toJson(status.tags), + poll = gson.toJson(status.poll), + muted = status.muted, + pinned = status.pinned ?: false, + card = gson.toJson(status.card), + language = status.language, + filtered = status.filtered + ) + } + @Query( - """UPDATE TimelineStatusEntity SET favourited = :favourited -WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""" + """UPDATE TimelineStatusEntity + SET content = :content, + editedAt = :editedAt, + emojis = :emojis, + reblogsCount = :reblogsCount, + favouritesCount = :favouritesCount, + repliesCount = :repliesCount, + reblogged = :reblogged, + bookmarked = :bookmarked, + favourited = :favourited, + sensitive = :sensitive, + spoilerText = :spoilerText, + visibility = :visibility, + attachments = :attachments, + mentions = :mentions, + tags = :tags, + poll = :poll, + muted = :muted, + pinned = :pinned, + card = :card, + language = :language, + filtered = :filtered + WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""" + ) + @TypeConverters(Converters::class) + abstract suspend fun update( + accountId: Long, + statusId: String, + content: String?, + editedAt: Long?, + emojis: String?, + reblogsCount: Int, + favouritesCount: Int, + repliesCount: Int, + reblogged: Boolean, + bookmarked: Boolean, + favourited: Boolean, + sensitive: Boolean, + spoilerText: String, + visibility: Status.Visibility, + attachments: String?, + mentions: String?, + tags: String?, + poll: String?, + muted: Boolean?, + pinned: Boolean, + card: String?, + language: String?, + filtered: List? ) - abstract suspend fun setFavourited(accountId: Long, statusId: String, favourited: Boolean) @Query( """UPDATE TimelineStatusEntity SET bookmarked = :bookmarked 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 34664ff39..e884b2eb5 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 @@ -95,7 +95,7 @@ abstract class ActivitiesModule { @ContributesAndroidInjector(modules = [FragmentBuildersModule::class]) abstract fun contributesPreferencesActivity(): PreferencesActivity - @ContributesAndroidInjector + @ContributesAndroidInjector(modules = [FragmentBuildersModule::class]) abstract fun contributesViewMediaActivity(): ViewMediaActivity @ContributesAndroidInjector @@ -114,7 +114,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/AppModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt index 7e710bd78..ffbaaba27 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt @@ -74,7 +74,7 @@ class AppModule { AppDatabase.MIGRATION_38_39, AppDatabase.MIGRATION_39_40, AppDatabase.MIGRATION_40_41, AppDatabase.MIGRATION_41_42, AppDatabase.MIGRATION_42_43, AppDatabase.MIGRATION_43_44, AppDatabase.MIGRATION_44_45, AppDatabase.MIGRATION_45_46, AppDatabase.MIGRATION_46_47, - AppDatabase.MIGRATION_47_48 + AppDatabase.MIGRATION_47_48, AppDatabase.MIGRATION_52_53 ) .build() } 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 8e44c4792..5837fdbd3 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 @@ -33,16 +32,14 @@ import com.keylesspalace.tusky.components.search.fragments.SearchHashtagsFragmen import com.keylesspalace.tusky.components.search.fragments.SearchNotestockFragment import com.keylesspalace.tusky.components.search.fragments.SearchStatusesFragment import com.keylesspalace.tusky.components.timeline.TimelineFragment -import com.keylesspalace.tusky.components.trending.TrendingFragment +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 -/** - * Created by charlag on 3/24/18. - */ - @Module abstract class FragmentBuildersModule { @ContributesAndroidInjector @@ -85,7 +82,7 @@ abstract class FragmentBuildersModule { abstract fun reportDoneFragment(): ReportDoneFragment @ContributesAndroidInjector - abstract fun instanceListFragment(): InstanceListFragment + abstract fun instanceListFragment(): DomainBlocksFragment @ContributesAndroidInjector abstract fun searchStatusesFragment(): SearchStatusesFragment @@ -103,7 +100,10 @@ abstract class FragmentBuildersModule { abstract fun listsForAccountFragment(): ListsForAccountFragment @ContributesAndroidInjector - abstract fun trendingFragment(): TrendingFragment + abstract fun trendingTagsFragment(): TrendingTagsFragment + + @ContributesAndroidInjector + abstract fun viewVideoFragment(): ViewVideoFragment @ContributesAndroidInjector abstract fun searchNotestockFragment(): SearchNotestockFragment 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 136e40009..1d2d8347e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt @@ -27,18 +27,18 @@ 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 import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineViewModel import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel -import com.keylesspalace.tusky.components.trending.viewmodel.TrendingViewModel +import com.keylesspalace.tusky.components.trending.viewmodel.TrendingTagsViewModel import com.keylesspalace.tusky.components.viewthread.ViewThreadViewModel import com.keylesspalace.tusky.components.viewthread.edits.ViewEditsViewModel import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel @@ -168,13 +168,8 @@ abstract class ViewModelModule { @Binds @IntoMap - @ViewModelKey(NotificationsViewModel::class) - internal abstract fun notificationsViewModel(viewModel: NotificationsViewModel): ViewModel - - @Binds - @IntoMap - @ViewModelKey(TrendingViewModel::class) - internal abstract fun trendingViewModel(viewModel: TrendingViewModel): ViewModel + @ViewModelKey(TrendingTagsViewModel::class) + internal abstract fun trendingTagsViewModel(viewModel: TrendingTagsViewModel): ViewModel @Binds @IntoMap @@ -186,6 +181,11 @@ 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 + @Binds @IntoMap @ViewModelKey(QuickTootViewModel::class) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt index 8b4df2635..ac668f654 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt @@ -37,7 +37,7 @@ data class Account( val emojis: List? = emptyList(), // nullable for backward compatibility val fields: List? = emptyList(), // nullable for backward compatibility val moved: Account? = null, - + val roles: List? = emptyList() ) { val name: String @@ -75,3 +75,8 @@ data class StringField( val name: String, val value: String ) + +data class Role( + val name: String, + val color: String +) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt index 36188a2e1..0bfa78f01 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt @@ -1,98 +1,72 @@ -/* Copyright 2018 Levi Bard - * - * 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.entity import com.google.gson.annotations.SerializedName data class Instance( - val uri: String, + val domain: String, val title: String, - // val description: String, - // val email: String, val version: String, - // val urls: Map, - // val stats: Map?, - // val thumbnail: String?, - // val languages: List, - // @SerializedName("contact_account") val contactAccount: Account, - @SerializedName("max_toot_chars") val maxTootChars: Int?, - @SerializedName("poll_limits") val pollConfiguration: PollConfiguration?, - val configuration: InstanceConfiguration?, - @SerializedName("max_media_attachments") val maxMediaAttachments: Int?, +// @SerializedName("source_url") val sourceUrl: String, +// val description: String, +// val usage: Usage, +// val thumbnail: Thumbnail, +// val languages: List, + val configuration: Configuration, +// val registrations: Registrations, +// val contact: Contact, + val rules: List, val pleroma: PleromaConfiguration?, - @SerializedName("upload_limit") val uploadLimit: Int?, - val rules: List? ) { - override fun hashCode(): Int { - return uri.hashCode() + data class Usage(val users: Users) { + data class Users(@SerializedName("active_month") val activeMonth: Int) } - - override fun equals(other: Any?): Boolean { - if (other !is Instance) { - return false - } - val instance = other as Instance? - return instance?.uri.equals(uri) + data class Thumbnail( + val url: String, + val blurhash: String?, + val versions: Versions?, + ) { + data class Versions( + @SerializedName("@1x") val at1x: String?, + @SerializedName("@2x") val at2x: String?, + ) } + data class Configuration( + val urls: Urls?, + val accounts: Accounts?, + val statuses: Statuses?, + @SerializedName("media_attachments") val mediaAttachments: MediaAttachments?, + val polls: Polls?, + val translation: Translation?, + ) { + data class Urls(@SerializedName("streaming_api") val streamingApi: String) + data class Accounts(@SerializedName("max_featured_tags") val maxFeaturedTags: Int) + data class Statuses( + @SerializedName("max_characters") val maxCharacters: Int, + @SerializedName("max_media_attachments") val maxMediaAttachments: Int, + @SerializedName("characters_reserved_per_url") val charactersReservedPerUrl: Int, + ) + data class MediaAttachments( + // Warning: This is an array in mastodon and a dictionary in friendica + // @SerializedName("supported_mime_types") val supportedMimeTypes: List, + @SerializedName("image_size_limit") val imageSizeLimitBytes: Long, + @SerializedName("image_matrix_limit") val imagePixelCountLimit: Long, + @SerializedName("video_size_limit") val videoSizeLimitBytes: Long, + @SerializedName("video_matrix_limit") val videoPixelCountLimit: Long, + @SerializedName("video_frame_rate_limit") val videoFrameRateLimit: Int, + ) + data class Polls( + @SerializedName("max_options") val maxOptions: Int, + @SerializedName("max_characters_per_option") val maxCharactersPerOption: Int, + @SerializedName("min_expiration") val minExpirationSeconds: Int, + @SerializedName("max_expiration") val maxExpirationSeconds: Int, + ) + data class Translation(val enabled: Boolean) + } + data class Registrations( + val enabled: Boolean, + @SerializedName("approval_required") val approvalRequired: Boolean, + val message: String?, + ) + data class Contact(val email: String, val account: Account) + data class Rule(val id: String, val text: String) } - -data class PollConfiguration( - @SerializedName("max_options") val maxOptions: Int?, - @SerializedName("max_option_chars") val maxOptionChars: Int?, - @SerializedName("max_characters_per_option") val maxCharactersPerOption: Int?, - @SerializedName("min_expiration") val minExpiration: Int?, - @SerializedName("max_expiration") val maxExpiration: Int? -) - -data class InstanceConfiguration( - val statuses: StatusConfiguration?, - @SerializedName("media_attachments") val mediaAttachments: MediaAttachmentConfiguration?, - val polls: PollConfiguration? -) - -data class StatusConfiguration( - @SerializedName("max_characters") val maxCharacters: Int?, - @SerializedName("max_media_attachments") val maxMediaAttachments: Int?, - @SerializedName("characters_reserved_per_url") val charactersReservedPerUrl: Int? -) - -data class MediaAttachmentConfiguration( - @SerializedName("supported_mime_types") val supportedMimeTypes: List?, - @SerializedName("image_size_limit") val imageSizeLimit: Int?, - @SerializedName("image_matrix_limit") val imageMatrixLimit: Int?, - @SerializedName("video_size_limit") val videoSizeLimit: Int?, - @SerializedName("video_frame_rate_limit") val videoFrameRateLimit: Int?, - @SerializedName("video_matrix_limit") val videoMatrixLimit: Int? -) - -data class PleromaConfiguration( - val metadata: PleromaMetadata? -) - -data class PleromaMetadata( - @SerializedName("fields_limits") val fieldLimits: PleromaFieldLimits -) - -data class PleromaFieldLimits( - @SerializedName("max_fields") val maxFields: Int?, - @SerializedName("name_length") val nameLength: Int?, - @SerializedName("value_length") val valueLength: Int? -) - -data class InstanceRules( - val id: String, - val text: String -) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/InstanceV1.kt b/app/src/main/java/com/keylesspalace/tusky/entity/InstanceV1.kt new file mode 100644 index 000000000..2b8807580 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/InstanceV1.kt @@ -0,0 +1,98 @@ +/* Copyright 2018 Levi Bard + * + * 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.entity + +import com.google.gson.annotations.SerializedName + +data class InstanceV1( + val uri: String, + val title: String, + // val description: String, + // val email: String, + val version: String, + // val urls: Map, + // val stats: Map?, + // val thumbnail: String?, + // val languages: List, + // @SerializedName("contact_account") val contactAccount: Account, + @SerializedName("max_toot_chars") val maxTootChars: Int?, + @SerializedName("poll_limits") val pollConfiguration: PollConfiguration?, + val configuration: InstanceConfiguration?, + @SerializedName("max_media_attachments") val maxMediaAttachments: Int?, + val pleroma: PleromaConfiguration?, + @SerializedName("upload_limit") val uploadLimit: Int?, + val rules: List? +) { + override fun hashCode(): Int { + return uri.hashCode() + } + + override fun equals(other: Any?): Boolean { + if (other !is InstanceV1) { + return false + } + val instance = other as InstanceV1? + return instance?.uri.equals(uri) + } +} + +data class PollConfiguration( + @SerializedName("max_options") val maxOptions: Int?, + @SerializedName("max_option_chars") val maxOptionChars: Int?, + @SerializedName("max_characters_per_option") val maxCharactersPerOption: Int?, + @SerializedName("min_expiration") val minExpiration: Int?, + @SerializedName("max_expiration") val maxExpiration: Int? +) + +data class InstanceConfiguration( + val statuses: StatusConfiguration?, + @SerializedName("media_attachments") val mediaAttachments: MediaAttachmentConfiguration?, + val polls: PollConfiguration? +) + +data class StatusConfiguration( + @SerializedName("max_characters") val maxCharacters: Int?, + @SerializedName("max_media_attachments") val maxMediaAttachments: Int?, + @SerializedName("characters_reserved_per_url") val charactersReservedPerUrl: Int? +) + +data class MediaAttachmentConfiguration( + @SerializedName("supported_mime_types") val supportedMimeTypes: List?, + @SerializedName("image_size_limit") val imageSizeLimit: Int?, + @SerializedName("image_matrix_limit") val imageMatrixLimit: Int?, + @SerializedName("video_size_limit") val videoSizeLimit: Int?, + @SerializedName("video_frame_rate_limit") val videoFrameRateLimit: Int?, + @SerializedName("video_matrix_limit") val videoMatrixLimit: Int? +) + +data class PleromaConfiguration( + val metadata: PleromaMetadata? +) + +data class PleromaMetadata( + @SerializedName("fields_limits") val fieldLimits: PleromaFieldLimits +) + +data class PleromaFieldLimits( + @SerializedName("max_fields") val maxFields: Int?, + @SerializedName("name_length") val nameLength: Int?, + @SerializedName("value_length") val valueLength: Int? +) + +data class InstanceRules( + val id: String, + val text: String +) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/MastoList.kt b/app/src/main/java/com/keylesspalace/tusky/entity/MastoList.kt index bfec7cc52..e510f4d2a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/MastoList.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/MastoList.kt @@ -16,11 +16,25 @@ package com.keylesspalace.tusky.entity +import com.google.gson.annotations.SerializedName + /** * Created by charlag on 1/4/18. */ data class MastoList( val id: String, - val title: String -) + val title: String, + val exclusive: Boolean?, + @SerializedName("replies_policy") val repliesPolicy: String?, +) { + enum class ReplyPolicy(val policy: String) { + NONE("none"), + LIST("list"), + FOLLOWED("followed"); + + companion object { + fun from(policy: String?): ReplyPolicy = values().firstOrNull { it.policy == policy } ?: LIST + } + } +} 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 a7007e57c..05f992e16 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 000000000..428db41bf --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java @@ -0,0 +1,1288 @@ +/* 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.components.instanceinfo.InstanceInfoRepository.CAN_USE_QUOTE_ID; +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.EventHub; +import com.keylesspalace.tusky.appstore.PreferenceChangedEvent; +import com.keylesspalace.tusky.appstore.StatusChangedEvent; +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 org.bouncycastle.util.Arrays; + +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(), + List.of(CAN_USE_QUOTE_ID).contains(accountManager.getActiveAccount().getDomain()) + ); + + 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 StatusChangedEvent) { + Status updatedStatus = ((StatusChangedEvent) event).getStatus(); + updateStatus(updatedStatus.getActionableId(), s -> updatedStatus); + } 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 onQuote(int position) { + super.quote(notifications.get(position).asRight().getStatus()); + } + + @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 4d2574d64..4997f3acc 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.kt @@ -501,7 +501,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 24c8feb03..2b7010329 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewImageFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewImageFragment.kt @@ -19,25 +19,31 @@ import android.animation.Animator import android.animation.AnimatorListenerAdapter import android.annotation.SuppressLint import android.content.Context +import android.graphics.PointF import android.graphics.drawable.Drawable import android.os.Bundle +import android.view.GestureDetector import android.view.LayoutInflater import android.view.MotionEvent import android.view.View import android.view.ViewGroup import android.widget.ImageView import androidx.core.os.BundleCompat +import androidx.core.view.GestureDetectorCompat import com.bumptech.glide.Glide import com.bumptech.glide.load.DataSource import com.bumptech.glide.load.engine.GlideException import com.bumptech.glide.request.RequestListener import com.bumptech.glide.request.target.Target -import com.github.chrisbanes.photoview.PhotoViewAttacher +import com.keylesspalace.tusky.R import com.keylesspalace.tusky.ViewMediaActivity import com.keylesspalace.tusky.databinding.FragmentViewImageBinding import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible +import com.ortiz.touchview.OnTouchCoordinatesListener +import com.ortiz.touchview.TouchImageView import io.reactivex.rxjava3.subjects.BehaviorSubject import kotlin.math.abs @@ -48,10 +54,8 @@ class ViewImageFragment : ViewMediaFragment() { fun onPhotoTap() } - private var _binding: FragmentViewImageBinding? = null - private val binding get() = _binding!! + private val binding by viewBinding(FragmentViewImageBinding::bind) - private lateinit var attacher: PhotoViewAttacher private lateinit var photoActionsListener: PhotoActionsListener private lateinit var toolbar: View private var transition = BehaviorSubject.create() @@ -84,8 +88,7 @@ class ViewImageFragment : ViewMediaFragment() { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { toolbar = (requireActivity() as ViewMediaActivity).toolbar this.transition = BehaviorSubject.create() - _binding = FragmentViewImageBinding.inflate(inflater, container, false) - return binding.root + return inflater.inflate(R.layout.fragment_view_image, container, false) } @SuppressLint("ClickableViewAccessibility") @@ -108,95 +111,139 @@ class ViewImageFragment : ViewMediaFragment() { } } - attacher = PhotoViewAttacher(binding.photoView).apply { - // This prevents conflicts with ViewPager - setAllowParentInterceptOnEdge(true) + val singleTapDetector = GestureDetectorCompat( + requireContext(), + object : GestureDetector.SimpleOnGestureListener() { + override fun onDown(e: MotionEvent) = true + override fun onSingleTapConfirmed(e: MotionEvent): Boolean { + photoActionsListener.onPhotoTap() + return false + } + } + ) - // Clicking outside the photo closes the viewer. - setOnOutsidePhotoTapListener { photoActionsListener.onDismiss() } - setOnClickListener { onMediaTap() } + binding.photoView.setOnTouchCoordinatesListener(object : OnTouchCoordinatesListener { + /** Y coordinate of the last single-finger drag */ + var lastDragY: Float? = null - /* A vertical swipe motion also closes the viewer. This is especially useful when the photo - * mostly fills the screen so clicking outside is difficult. */ - setOnSingleFlingListener { _, _, velocityX, velocityY -> - var result = false - if (abs(velocityY) > abs(velocityX)) { + override fun onTouchCoordinate(view: View, event: MotionEvent, bitmapPoint: PointF) { + singleTapDetector.onTouchEvent(event) + + // Two fingers have gone down after a single finger drag. Finish the drag + if (event.pointerCount == 2 && lastDragY != null) { + onGestureEnd(view) + lastDragY = null + } + + // Stop the parent view from handling touches if either (a) the user has 2+ + // fingers on the screen, or (b) the image has been zoomed in, and can be scrolled + // horizontally in both directions. + // + // This stops things like ViewPager2 from trying to intercept a left/right swipe + // and ensures that the image does not appear to "stick" to the screen as different + // views fight over who should be handling the swipe. + // + // If the view can be scrolled in one direction it's OK to let the parent intercept, + // which allows the user to swipe between images even if one or more of them have + // been zoomed in. + if (event.pointerCount >= 2 || view.canScrollHorizontally(1) && view.canScrollHorizontally(-1)) { + when (event.action) { + MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> { + view.parent.requestDisallowInterceptTouchEvent(true) + } + + MotionEvent.ACTION_UP -> { + view.parent.requestDisallowInterceptTouchEvent(false) + } + } + return + } + + // The user is dragging the image around + if (event.pointerCount == 1) { + // If the image is zoomed then the swipe-to-dismiss functionality is disabled + if ((view as TouchImageView).isZoomed) return + + // The user's finger just went down, start recording where they are dragging from + if (event.action == MotionEvent.ACTION_DOWN) { + lastDragY = event.rawY + return + } + + // The user is dragging the un-zoomed image to possibly fling it up or down + // to dismiss. + if (event.action == MotionEvent.ACTION_MOVE) { + // lastDragY may be null; e.g., the user was performing a two-finger drag, + // and has lifted one finger. In this case do nothing + lastDragY ?: return + + // Compute the Y offset of the drag, and scale/translate the photoview + // accordingly. + val diff = event.rawY - lastDragY!! + if (view.translationY != 0f || abs(diff) > 40) { + // Drag has definitely started, stop the parent from interfering + view.parent.requestDisallowInterceptTouchEvent(true) + view.translationY += diff + val scale = (-abs(view.translationY) / 720 + 1).coerceAtLeast(0.5f) + view.scaleY = scale + view.scaleX = scale + lastDragY = event.rawY + } + return + } + + // The user has finished dragging. Allow the parent to handle touch events if + // appropriate, and end the gesture. + if (event.action == MotionEvent.ACTION_UP || event.action == MotionEvent.ACTION_CANCEL) { + view.parent.requestDisallowInterceptTouchEvent(false) + if (lastDragY != null) onGestureEnd(view) + lastDragY = null + return + } + } + } + + /** + * Handle the end of the user's gesture. + * + * If the user was previously dragging, and the image has been dragged a sufficient + * distance then we are done. Otherwise, animate the image back to its starting position. + */ + private fun onGestureEnd(view: View) { + if (abs(view.translationY) > 180) { photoActionsListener.onDismiss() - result = true + } else { + view.animate().translationY(0f).scaleX(1f).start() } - result } - } - - var lastY = 0f - - binding.photoView.setOnTouchListener { v, event -> - // This part is for scaling/translating on vertical move. - // We use raw coordinates to get the correct ones during scaling - - if (event.action == MotionEvent.ACTION_DOWN) { - lastY = event.rawY - } else if (event.pointerCount == 1 && - attacher.scale == 1f && - event.action == MotionEvent.ACTION_MOVE - ) { - val diff = event.rawY - lastY - // This code is to prevent transformations during page scrolling - // If we are already translating or we reached the threshold, then transform. - if (binding.photoView.translationY != 0f || abs(diff) > 40) { - binding.photoView.translationY += (diff) - val scale = (-abs(binding.photoView.translationY) / 720 + 1).coerceAtLeast(0.5f) - binding.photoView.scaleY = scale - binding.photoView.scaleX = scale - lastY = event.rawY - return@setOnTouchListener true - } - } else if (event.action == MotionEvent.ACTION_UP || event.action == MotionEvent.ACTION_CANCEL) { - onGestureEnd() - } - attacher.onTouch(v, event) - } + }) finalizeViewSetup(url, attachment?.previewUrl, description) } - private fun onGestureEnd() { - if (_binding == null) { - return - } - if (abs(binding.photoView.translationY) > 180) { - photoActionsListener.onDismiss() - } else { - binding.photoView.animate().translationY(0f).scaleX(1f).scaleY(1f).start() - } - } - - private fun onMediaTap() { - photoActionsListener.onPhotoTap() - } - override fun onToolbarVisibilityChange(visible: Boolean) { - if (_binding == null || !userVisibleHint) { - return - } + if (!userVisibleHint) return + isDescriptionVisible = showingDescription && visible val alpha = if (isDescriptionVisible) 1.0f else 0.0f binding.captionSheet.animate().alpha(alpha) .setListener(object : AnimatorListenerAdapter() { override fun onAnimationEnd(animation: Animator) { - if (_binding != null) { - binding.captionSheet.visible(isDescriptionVisible) - } + view ?: return + binding.captionSheet.visible(isDescriptionVisible) animation.removeListener(this) } }) .start() } - override fun onDestroyView() { + override fun onStop() { + super.onStop() Glide.with(this).clear(binding.photoView) + } + + override fun onDestroyView() { transition.onComplete() - _binding = null super.onDestroyView() } @@ -259,7 +306,7 @@ class ViewImageFragment : ViewMediaFragment() { override fun onLoadFailed( e: GlideException?, - model: Any, + model: Any?, target: Target, isFirstResource: Boolean ): Boolean { @@ -270,7 +317,7 @@ class ViewImageFragment : ViewMediaFragment() { photoActionsListener.onBringUp() } // Hide progress bar only on fail request from internet - if (!isCacheRequest && _binding != null) binding.progressBar.hide() + if (!isCacheRequest) binding.progressBar.hide() // We don't want to overwrite preview with null when main image fails to load return !isCacheRequest } @@ -283,9 +330,7 @@ class ViewImageFragment : ViewMediaFragment() { dataSource: DataSource, isFirstResource: Boolean ): Boolean { - if (_binding != null) { - binding.progressBar.hide() // Always hide the progress bar on success - } + binding.progressBar.hide() // Always hide the progress bar on success if (!startedTransition || !shouldStartTransition) { // Set this right away so that we don't have to concurrent post() requests @@ -303,10 +348,6 @@ class ViewImageFragment : ViewMediaFragment() { .take(1) .subscribe { target.onResourceReady(resource, null) - // It's needed. Don't ask why, I don't know, setImageDrawable() should - // do it by itself but somehow it doesn't work automatically. - // Just do it. If you don't, image will jump around when touched. - attacher.update() } } return true diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewMediaFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewMediaFragment.kt index 2f8aaf1d9..3125e556e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewMediaFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewMediaFragment.kt @@ -17,7 +17,9 @@ package com.keylesspalace.tusky.fragment import android.os.Bundle import android.text.TextUtils +import androidx.annotation.OptIn import androidx.fragment.app.Fragment +import androidx.media3.common.util.UnstableApi import com.keylesspalace.tusky.ViewMediaActivity import com.keylesspalace.tusky.entity.Attachment @@ -47,6 +49,7 @@ abstract class ViewMediaFragment : Fragment() { protected val ARG_SINGLE_IMAGE_URL = "singleImageUrl" @JvmStatic + @OptIn(UnstableApi::class) fun newInstance(attachment: Attachment, shouldStartPostponedTransition: Boolean): ViewMediaFragment { val arguments = Bundle(2) arguments.putParcelable(ARG_ATTACHMENT, attachment) diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt index 68dc6687a..80fe55add 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt @@ -19,33 +19,59 @@ import android.animation.Animator import android.animation.AnimatorListenerAdapter import android.annotation.SuppressLint import android.content.Context +import android.graphics.drawable.Drawable +import android.os.Build import android.os.Bundle import android.os.Handler import android.os.Looper import android.text.method.ScrollingMovementMethod import android.view.GestureDetector -import android.view.KeyEvent +import android.view.Gravity import android.view.LayoutInflater import android.view.MotionEvent import android.view.View import android.view.ViewGroup -import android.widget.MediaController +import android.widget.FrameLayout +import android.widget.LinearLayout +import androidx.annotation.OptIn import androidx.core.view.GestureDetectorCompat +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DefaultDataSource +import androidx.media3.datasource.okhttp.OkHttpDataSource +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.exoplayer.util.EventLogger +import androidx.media3.ui.AspectRatioFrameLayout +import com.bumptech.glide.Glide +import com.bumptech.glide.request.target.CustomTarget +import com.bumptech.glide.request.transition.Transition +import com.google.android.material.snackbar.Snackbar +import com.keylesspalace.tusky.BuildConfig +import com.keylesspalace.tusky.R import com.keylesspalace.tusky.ViewMediaActivity import com.keylesspalace.tusky.databinding.FragmentViewVideoBinding +import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible -import com.keylesspalace.tusky.view.ExposedPlayPauseVideoView +import okhttp3.OkHttpClient +import javax.inject.Inject import kotlin.math.abs -class ViewVideoFragment : ViewMediaFragment() { +@UnstableApi +class ViewVideoFragment : ViewMediaFragment(), Injectable { interface VideoActionsListener { fun onDismiss() } - private var _binding: FragmentViewVideoBinding? = null - private val binding get() = _binding!! + @Inject + lateinit var okHttpClient: OkHttpClient + + private val binding by viewBinding(FragmentViewVideoBinding::bind) private lateinit var videoActionsListener: VideoActionsListener private lateinit var toolbar: View @@ -54,39 +80,293 @@ class ViewVideoFragment : ViewMediaFragment() { // Hoist toolbar hiding to activity so it can track state across different fragments // This is explicitly stored as runnable so that we pass it to the handler later for cancellation mediaActivity.onPhotoTap() - mediaController.hide() } private lateinit var mediaActivity: ViewMediaActivity - private lateinit var mediaController: MediaController + private lateinit var mediaPlayerListener: Player.Listener private var isAudio = false - companion object { - private const val TOOLBAR_HIDE_DELAY_MS = 3000L - } + private lateinit var mediaAttachment: Attachment + + private var player: ExoPlayer? = null + + /** The saved seek position, if the fragment is being resumed */ + private var savedSeekPosition: Long = 0 + + private lateinit var mediaSourceFactory: DefaultMediaSourceFactory + + /** Have we received at least one "READY" event? */ + private var haveStarted = false + + /** Is there a pending autohide? (We can't rely on Android's tracking because that clears on suspend.) */ + private var pendingHideToolbar = false + + /** Prevent the next play start from queueing a toolbar hide. */ + private var suppressNextHideToolbar = false override fun onAttach(context: Context) { super.onAttach(context) + + mediaSourceFactory = DefaultMediaSourceFactory(context) + .setDataSourceFactory(DefaultDataSource.Factory(context, OkHttpDataSource.Factory(okHttpClient))) + videoActionsListener = context as VideoActionsListener } + @SuppressLint("PrivateResource", "MissingInflatedId") + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + mediaActivity = activity as ViewMediaActivity + toolbar = mediaActivity.toolbar + val rootView = inflater.inflate(R.layout.fragment_view_video, container, false) + + // Move the controls to the bottom of the screen, with enough bottom margin to clear the seekbar + val controls = rootView.findViewById(androidx.media3.ui.R.id.exo_center_controls) + val layoutParams = controls.layoutParams as FrameLayout.LayoutParams + layoutParams.gravity = Gravity.CENTER_HORIZONTAL or Gravity.BOTTOM + layoutParams.bottomMargin = rootView.context.resources.getDimension(androidx.media3.ui.R.dimen.exo_styled_bottom_bar_height) + .toInt() + controls.layoutParams = layoutParams + + return rootView + } + + @SuppressLint("ClickableViewAccessibility") + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val attachment = arguments?.getParcelable(ARG_ATTACHMENT) + ?: throw IllegalArgumentException("attachment has to be set") + + val url = attachment.url + isAudio = attachment.type == Attachment.Type.AUDIO + + /** + * Handle single taps, flings, and dragging + */ + val touchListener = object : View.OnTouchListener { + var lastY = 0f + + /** The view that contains the playing content */ + // binding.videoView is fullscreen, and includes the controls, so don't use that + // when scaling in response to the user dragging on the screen + val contentFrame = binding.videoView.findViewById(androidx.media3.ui.R.id.exo_content_frame) + + /** Handle taps and flings */ + val simpleGestureDetector = GestureDetectorCompat( + requireContext(), + object : GestureDetector.SimpleOnGestureListener() { + override fun onDown(e: MotionEvent) = true + + /** A single tap should show/hide the media description */ + override fun onSingleTapUp(e: MotionEvent): Boolean { + mediaActivity.onPhotoTap() + return true // Do not pass gestures through to media3 + } + + /** A fling up/down should dismiss the fragment */ + override fun onFling( + e1: MotionEvent, + e2: MotionEvent, + velocityX: Float, + velocityY: Float + ): Boolean { + if (abs(velocityY) > abs(velocityX)) { + videoActionsListener.onDismiss() + return true + } + return true // Do not pass gestures through to media3 + } + } + ) + + @SuppressLint("ClickableViewAccessibility") + override fun onTouch(v: View?, event: MotionEvent): Boolean { + // Track movement, and scale / translate the video display accordingly + if (event.action == MotionEvent.ACTION_DOWN) { + lastY = event.rawY + } else if (event.pointerCount == 1 && event.action == MotionEvent.ACTION_MOVE) { + val diff = event.rawY - lastY + if (contentFrame.translationY != 0f || abs(diff) > 40) { + contentFrame.translationY += diff + val scale = (-abs(contentFrame.translationY) / 720 + 1).coerceAtLeast(0.5f) + contentFrame.scaleY = scale + contentFrame.scaleX = scale + lastY = event.rawY + } + } else if (event.action == MotionEvent.ACTION_UP || event.action == MotionEvent.ACTION_CANCEL) { + if (abs(contentFrame.translationY) > 180) { + videoActionsListener.onDismiss() + } else { + contentFrame.animate().translationY(0f).scaleX(1f).scaleY(1f).start() + } + } + + simpleGestureDetector.onTouchEvent(event) + + // Do not pass gestures through to media3 + // We have to do this because otherwise taps to hide will be double-handled and media3 will re-show itself + // media3 has a property to disable "hide on tap" but "show on tap" is unconditional + return true + } + } + + mediaPlayerListener = object : Player.Listener { + @SuppressLint("ClickableViewAccessibility", "SyntheticAccessor") + @OptIn(UnstableApi::class) + override fun onPlaybackStateChanged(playbackState: Int) { + when (playbackState) { + Player.STATE_READY -> { + if (!haveStarted) { + // Wait until the media is loaded before accepting taps as we don't want toolbar to + // be hidden until then. + binding.videoView.setOnTouchListener(touchListener) + + binding.progressBar.hide() + binding.videoView.useController = true + binding.videoView.showController() + haveStarted = true + } else { + // This isn't a real "done loading"; this is a resume event after backgrounding. + if (mediaActivity.isToolbarVisible) { + // Before suspend, the toolbar/description were visible, so description is visible already. + // But media3 will have automatically hidden the video controls on suspend, so we need to match the description state. + binding.videoView.showController() + if (!pendingHideToolbar) { + suppressNextHideToolbar = true // The user most recently asked us to show the toolbar, so don't hide it when play starts. + } + } else { + mediaActivity.onPhotoTap() + } + } + } + else -> { /* do nothing */ } + } + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + if (isAudio) return + if (isPlaying) { + if (suppressNextHideToolbar) { + suppressNextHideToolbar = false + } else { + hideToolbarAfterDelay(TOOLBAR_HIDE_DELAY_MS) + } + } else { + handler.removeCallbacks(hideToolbar) + } + } + + @SuppressLint("SyntheticAccessor") + override fun onPlayerError(error: PlaybackException) { + binding.progressBar.hide() + val message = getString( + R.string.error_media_playback, + error.cause?.message ?: error.message + ) + Snackbar.make(binding.root, message, Snackbar.LENGTH_INDEFINITE) + .setTextMaxLines(10) + .setAction(R.string.action_retry) { player?.prepare() } + .show() + } + } + + savedSeekPosition = savedInstanceState?.getLong(SEEK_POSITION) ?: 0 + + mediaAttachment = attachment + + finalizeViewSetup(url, attachment.previewUrl, attachment.description) + } + + override fun onStart() { + super.onStart() + if (Build.VERSION.SDK_INT > 23) { + initializePlayer() + binding.videoView.onResume() + } + } + override fun onResume() { super.onResume() - if (_binding != null) { - if (mediaActivity.isToolbarVisible && !isAudio) { - hideToolbarAfterDelay(TOOLBAR_HIDE_DELAY_MS) - } - binding.videoView.start() + if (Build.VERSION.SDK_INT <= 23 || player == null) { + initializePlayer() + + binding.videoView.onResume() + } + } + + private fun releasePlayer() { + player?.let { + savedSeekPosition = it.currentPosition + it.release() + player = null + binding.videoView.player = null } } override fun onPause() { super.onPause() - if (_binding != null) { + // If <= API 23 then multi-window mode is not available, so this is a good time to + // pause everything + if (Build.VERSION.SDK_INT <= 23) { + binding.videoView.onPause() + releasePlayer() handler.removeCallbacks(hideToolbar) - binding.videoView.pause() - mediaController.hide() + } + } + + override fun onStop() { + super.onStop() + + // If > API 23 then this might be multi-window, and definitely wasn't paused in onPause, + // so pause everything now. + if (Build.VERSION.SDK_INT > 23) { + binding.videoView.onPause() + releasePlayer() + handler.removeCallbacks(hideToolbar) + } + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putLong(SEEK_POSITION, savedSeekPosition) + } + + private fun initializePlayer() { + ExoPlayer.Builder(requireContext()) + .setMediaSourceFactory(mediaSourceFactory) + .build().apply { + if (BuildConfig.DEBUG) addAnalyticsListener(EventLogger("$TAG:ExoPlayer")) + setMediaItem(MediaItem.fromUri(mediaAttachment.url)) + addListener(mediaPlayerListener) + repeatMode = Player.REPEAT_MODE_ONE + playWhenReady = true + seekTo(savedSeekPosition) + prepare() + player = this + } + + binding.videoView.player = player + + // Audio-only files might have a preview image. If they do, set it as the artwork + if (isAudio) { + mediaAttachment.previewUrl?.let { url -> + Glide.with(this).load(url).into(object : CustomTarget() { + @SuppressLint("SyntheticAccessor") + override fun onResourceReady( + resource: Drawable, + transition: Transition? + ) { + view ?: return + binding.videoView.defaultArtwork = resource + } + + @SuppressLint("SyntheticAccessor") + override fun onLoadCleared(placeholder: Drawable?) { + view ?: return + binding.videoView.defaultArtwork = null + } + }) + } } } @@ -105,153 +385,21 @@ class ViewVideoFragment : ViewMediaFragment() { binding.mediaDescription.elevation = binding.videoView.elevation + 1 binding.videoView.transitionName = url - binding.videoView.setVideoPath(url) - mediaController = object : MediaController(mediaActivity) { - override fun show(timeout: Int) { - // We're doing manual auto-close management. - // Also, take focus back from the pause button so we can use the back button. - super.show(0) - mediaController.requestFocus() - } - override fun dispatchKeyEvent(event: KeyEvent?): Boolean { - if (event?.keyCode == KeyEvent.KEYCODE_BACK) { - if (event.action == KeyEvent.ACTION_UP) { - hide() - activity?.supportFinishAfterTransition() - } - return true - } - return super.dispatchKeyEvent(event) - } - } - - mediaController.setMediaPlayer(binding.videoView) - binding.videoView.setMediaController(mediaController) binding.videoView.requestFocus() - binding.videoView.setPlayPauseListener(object : ExposedPlayPauseVideoView.PlayPauseListener { - override fun onPlay() { - if (!isAudio) { - hideToolbarAfterDelay(TOOLBAR_HIDE_DELAY_MS) - } - } - - override fun onPause() { - if (!isAudio) { - handler.removeCallbacks(hideToolbar) - } - } - }) - binding.videoView.setOnPreparedListener { mp -> - val containerWidth = binding.videoContainer.measuredWidth.toFloat() - val containerHeight = binding.videoContainer.measuredHeight.toFloat() - val videoWidth = mp.videoWidth.toFloat() - val videoHeight = mp.videoHeight.toFloat() - - if (isAudio) { - binding.videoView.layoutParams.height = 1 - binding.videoView.layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT - } else if (containerWidth / containerHeight > videoWidth / videoHeight) { - binding.videoView.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT - binding.videoView.layoutParams.width = ViewGroup.LayoutParams.WRAP_CONTENT - } else { - binding.videoView.layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT - binding.videoView.layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT - } - - // Wait until the media is loaded before accepting taps as we don't want toolbar to - // be hidden until then. - binding.videoView.setOnTouchListener { _, _ -> - mediaActivity.onPhotoTap() - false - } - - // Audio doesn't cause the controller to show automatically - if (isAudio) { - mediaController.show() - } - - binding.progressBar.hide() - mp.isLooping = true - } if (requireArguments().getBoolean(ARG_START_POSTPONED_TRANSITION)) { mediaActivity.onBringUp() } } - private fun hideToolbarAfterDelay(delayMilliseconds: Long) { - handler.postDelayed(hideToolbar, delayMilliseconds) - } - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - mediaActivity = activity as ViewMediaActivity - toolbar = mediaActivity.toolbar - _binding = FragmentViewVideoBinding.inflate(inflater, container, false) - return binding.root - } - - @SuppressLint("ClickableViewAccessibility") - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - val attachment = arguments?.getParcelable(ARG_ATTACHMENT) - ?: throw IllegalArgumentException("attachment has to be set") - - val url = attachment.url - isAudio = attachment.type == Attachment.Type.AUDIO - - val gestureDetector = GestureDetectorCompat( - requireContext(), - object : GestureDetector.SimpleOnGestureListener() { - override fun onDown(event: MotionEvent): Boolean { - return true - } - - override fun onFling( - e1: MotionEvent, - e2: MotionEvent, - velocityX: Float, - velocityY: Float - ): Boolean { - if (abs(velocityY) > abs(velocityX)) { - videoActionsListener.onDismiss() - return true - } - return false - } - } - ) - - var lastY = 0f - binding.root.setOnTouchListener { _, event -> - if (event.action == MotionEvent.ACTION_DOWN) { - lastY = event.rawY - } else if (event.pointerCount == 1 && event.action == MotionEvent.ACTION_MOVE) { - val diff = event.rawY - lastY - if (binding.videoView.translationY != 0f || abs(diff) > 40) { - binding.videoView.translationY += diff - val scale = (-abs(binding.videoView.translationY) / 720 + 1).coerceAtLeast(0.5f) - binding.videoView.scaleY = scale - binding.videoView.scaleX = scale - lastY = event.rawY - return@setOnTouchListener true - } - } else if (event.action == MotionEvent.ACTION_UP || event.action == MotionEvent.ACTION_CANCEL) { - if (abs(binding.videoView.translationY) > 180) { - videoActionsListener.onDismiss() - } else { - binding.videoView.animate().translationY(0f).scaleX(1f).scaleY(1f).start() - } - } - - gestureDetector.onTouchEvent(event) - } - - finalizeViewSetup(url, attachment.previewUrl, attachment.description) + private fun hideToolbarAfterDelay(delayMilliseconds: Int) { + pendingHideToolbar = true + handler.postDelayed(hideToolbar, delayMilliseconds.toLong()) } override fun onToolbarVisibilityChange(visible: Boolean) { - if (_binding == null || !userVisibleHint) { + if (!userVisibleHint) { return } @@ -265,27 +413,33 @@ class ViewVideoFragment : ViewMediaFragment() { binding.mediaDescription.animate().alpha(alpha) .setListener(object : AnimatorListenerAdapter() { + @SuppressLint("SyntheticAccessor") override fun onAnimationEnd(animation: Animator) { - if (_binding != null) { - binding.mediaDescription.visible(isDescriptionVisible) - } + view ?: return + binding.mediaDescription.visible(isDescriptionVisible) animation.removeListener(this) } }) .start() - if (visible && binding.videoView.isPlaying && !isAudio) { - hideToolbarAfterDelay(TOOLBAR_HIDE_DELAY_MS) + // media3 controls bar + if (visible) { + binding.videoView.showController() } else { - handler.removeCallbacks(hideToolbar) + binding.videoView.hideController() } + + // Either the user just requested toolbar display, or we just hid it. + // Either way, any pending hides are no longer appropriate. + pendingHideToolbar = false + handler.removeCallbacks(hideToolbar) } - override fun onTransitionEnd() { - } + override fun onTransitionEnd() { } - override fun onDestroyView() { - super.onDestroyView() - _binding = null + companion object { + private const val TAG = "ViewVideoFragment" + private const val TOOLBAR_HIDE_DELAY_MS = 4_000 + private const val SEEK_POSITION = "seekPosition" } } 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 c353d0f3e..654b50dd4 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 ca83e085b..000000000 --- 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 000000000..d31bd1feb --- /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 a50ca7964..1554aa6ac 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -29,6 +29,7 @@ import com.keylesspalace.tusky.entity.FilterKeyword import com.keylesspalace.tusky.entity.FilterV1 import com.keylesspalace.tusky.entity.HashTag import com.keylesspalace.tusky.entity.Instance +import com.keylesspalace.tusky.entity.InstanceV1 import com.keylesspalace.tusky.entity.Marker import com.keylesspalace.tusky.entity.MastoList import com.keylesspalace.tusky.entity.MediaUploadResult @@ -84,11 +85,19 @@ interface MastodonApi { suspend fun getCustomEmojis(): NetworkResult> @GET("api/v1/instance") - suspend fun getInstance(@Header(DOMAIN_HEADER) domain: String? = null): NetworkResult + suspend fun getInstanceV1(@Header(DOMAIN_HEADER) domain: String? = null): NetworkResult + + @GET("api/v2/instance") + suspend fun getInstance(): NetworkResult @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> @@ -139,6 +148,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( @@ -172,6 +189,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( @@ -274,6 +294,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 @@ -294,6 +344,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, @@ -452,11 +512,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") @@ -538,14 +598,18 @@ interface MastodonApi { @FormUrlEncoded @POST("api/v1/lists") suspend fun createList( - @Field("title") title: String + @Field("title") title: String, + @Field("exclusive") exclusive: Boolean?, + @Field("replies_policy") replyPolicy: String, ): NetworkResult @FormUrlEncoded @PUT("api/v1/lists/{listId}") suspend fun updateList( @Path("listId") listId: String, - @Field("title") title: String + @Field("title") title: String, + @Field("exclusive") exclusive: Boolean?, + @Field("replies_policy") replyPolicy: String, ): NetworkResult @DELETE("api/v1/lists/{listId}") @@ -663,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 @@ -783,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 f499ed5ef..000000000 --- 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 000000000..f74b0c1a2 --- /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 45dd34563..9a3abccd1 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/SendStatusService.kt b/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt index 7f9668b62..7790b9012 100644 --- a/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt +++ b/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt @@ -21,8 +21,8 @@ import at.connyduck.calladapter.networkresult.fold import com.keylesspalace.tusky.MainActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.appstore.StatusChangedEvent import com.keylesspalace.tusky.appstore.StatusComposedEvent -import com.keylesspalace.tusky.appstore.StatusEditedEvent import com.keylesspalace.tusky.appstore.StatusScheduledEvent import com.keylesspalace.tusky.components.compose.MediaUploader import com.keylesspalace.tusky.components.compose.UploadEvent @@ -254,7 +254,7 @@ class SendStatusService : Service(), Injectable { if (scheduled) { eventHub.dispatch(StatusScheduledEvent(sentStatus)) } else if (!isNew) { - eventHub.dispatch(StatusEditedEvent(statusToSend.statusId!!, sentStatus)) + eventHub.dispatch(StatusChangedEvent(sentStatus)) } else { eventHub.dispatch(StatusComposedEvent(sentStatus)) } @@ -380,9 +380,7 @@ class SendStatusService : Service(), Injectable { accountId: Long, statusId: Int ): Notification { - val intent = Intent(this, MainActivity::class.java) - intent.putExtra(NotificationHelper.ACCOUNT_ID, accountId) - intent.putExtra(MainActivity.OPEN_DRAFTS, true) + val intent = MainActivity.draftIntent(this, accountId) val pendingIntent = PendingIntent.getActivity( this, 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 1e170da84..9d1b90a2f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/service/TuskyTileService.kt +++ b/app/src/main/java/com/keylesspalace/tusky/service/TuskyTileService.kt @@ -15,25 +15,20 @@ package com.keylesspalace.tusky.service -import android.annotation.TargetApi import android.content.Intent import android.service.quicksettings.TileService import com.keylesspalace.tusky.MainActivity +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() { - val intent = Intent(this, MainActivity::class.java).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - action = Intent.ACTION_SEND - type = "text/plain" - } + val intent = MainActivity.composeIntent(this, ComposeActivity.ComposeOptions()) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) startActivityAndCollapse(intent) } } 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 c83d26b5b..1ebb588e7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt +++ b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt @@ -5,10 +5,14 @@ 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() + + @JvmField + val DEFAULT = AUTO_SYSTEM } } @@ -41,7 +45,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 = 2023022701 +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 +56,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" @@ -61,7 +67,6 @@ object PrefKeys { const val ANIMATE_GIF_AVATARS = "animateGifAvatars" const val USE_BLURHASH = "useBlurhash" const val SHOW_SELF_USERNAME = "showSelfUsername" - const val SHOW_NOTIFICATIONS_FILTER = "showNotificationsFilter" const val SHOW_CARDS_IN_TIMELINES = "showCardsInTimelines" const val CONFIRM_REBLOGS = "confirmReblogs" const val CONFIRM_FAVOURITES = "confirmFavourites" @@ -111,7 +116,13 @@ object PrefKeys { const val TAB_FILTER_HOME_REPLIES = "tabFilterHomeReplies_v2" // This was changed once to reset an unintentionally set default. const val TAB_FILTER_HOME_BOOSTS = "tabFilterHomeBoosts" + const val TAB_SHOW_HOME_SELF_BOOSTS = "tabShowHomeSelfBoosts" /** UI text scaling factor, stored as float, 100 = 100% = no scaling */ const val UI_TEXT_SCALE_RATIO = "uiTextScaleRatio" + + /** Keys that are no longer used (e.g., the preference has been removed */ + object Deprecated { + const val SHOW_NOTIFICATIONS_FILTER = "showNotificationsFilter" + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsDSL.kt b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsDSL.kt index 720dc817f..cb8b13e67 100644 --- a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsDSL.kt +++ b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsDSL.kt @@ -6,7 +6,6 @@ import androidx.activity.result.ActivityResultRegistryOwner import androidx.annotation.StringRes import androidx.core.widget.doAfterTextChanged import androidx.lifecycle.LifecycleOwner -import androidx.preference.CheckBoxPreference import androidx.preference.EditTextPreference import androidx.preference.ListPreference import androidx.preference.Preference @@ -86,15 +85,6 @@ inline fun PreferenceParent.validatedEditTextPreference( return pref } -inline fun PreferenceParent.checkBoxPreference( - builder: CheckBoxPreference.() -> Unit -): CheckBoxPreference { - val pref = CheckBoxPreference(context) - builder(pref) - addPref(pref) - return pref -} - inline fun PreferenceParent.preferenceCategory( @StringRes title: Int? = null, builder: PreferenceParent.(PreferenceCategory) -> Unit 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 fc6ccbf3d..5239578a0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/usecase/TimelineCases.kt +++ b/app/src/main/java/com/keylesspalace/tusky/usecase/TimelineCases.kt @@ -21,14 +21,11 @@ import at.connyduck.calladapter.networkresult.fold import at.connyduck.calladapter.networkresult.onFailure import at.connyduck.calladapter.networkresult.onSuccess import com.keylesspalace.tusky.appstore.BlockEvent -import com.keylesspalace.tusky.appstore.BookmarkEvent import com.keylesspalace.tusky.appstore.EventHub -import com.keylesspalace.tusky.appstore.FavoriteEvent import com.keylesspalace.tusky.appstore.MuteConversationEvent import com.keylesspalace.tusky.appstore.MuteEvent -import com.keylesspalace.tusky.appstore.PinEvent import com.keylesspalace.tusky.appstore.PollVoteEvent -import com.keylesspalace.tusky.appstore.ReblogEvent +import com.keylesspalace.tusky.appstore.StatusChangedEvent import com.keylesspalace.tusky.appstore.StatusDeletedEvent import com.keylesspalace.tusky.entity.DeletedStatus import com.keylesspalace.tusky.entity.Poll @@ -53,8 +50,14 @@ class TimelineCases @Inject constructor( mastodonApi.reblogStatus(statusId) } else { mastodonApi.unreblogStatus(statusId) - }.onSuccess { - eventHub.dispatch(ReblogEvent(statusId, reblog)) + }.onSuccess { status -> + if (status.reblog != null) { + // when reblogging, the Mastodon Api does not return the reblogged status directly + // but the newly created status with reblog set to the reblogged status + eventHub.dispatch(StatusChangedEvent(status.reblog)) + } else { + eventHub.dispatch(StatusChangedEvent(status)) + } } } @@ -63,8 +66,8 @@ class TimelineCases @Inject constructor( mastodonApi.favouriteStatus(statusId) } else { mastodonApi.unfavouriteStatus(statusId) - }.onSuccess { - eventHub.dispatch(FavoriteEvent(statusId, favourite)) + }.onSuccess { status -> + eventHub.dispatch(StatusChangedEvent(status)) } } @@ -73,8 +76,8 @@ class TimelineCases @Inject constructor( mastodonApi.bookmarkStatus(statusId) } else { mastodonApi.unbookmarkStatus(statusId) - }.onSuccess { - eventHub.dispatch(BookmarkEvent(statusId, bookmark)) + }.onSuccess { status -> + eventHub.dispatch(StatusChangedEvent(status)) } } @@ -88,6 +91,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 { status -> + eventHub.dispatchOld(StatusChangedEvent(status)) + } + } + + fun favouriteOld(statusId: String, favourite: Boolean): Single { + val call = if (favourite) { + mastodonApi.favouriteStatusOld(statusId) + } else { + mastodonApi.unfavouriteStatusOld(statusId) + } + return call.doAfterSuccess { status -> + eventHub.dispatchOld(StatusChangedEvent(status)) + } + } + + fun bookmarkOld(statusId: String, bookmark: Boolean): Single { + val call = if (bookmark) { + mastodonApi.bookmarkStatusOld(statusId) + } else { + mastodonApi.unbookmarkStatusOld(statusId) + } + return call.doAfterSuccess { status -> + eventHub.dispatchOld(StatusChangedEvent(status)) + } + } + + 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) @@ -118,7 +165,7 @@ class TimelineCases @Inject constructor( } else { mastodonApi.unpinStatus(statusId) }.fold({ status -> - eventHub.dispatch(PinEvent(statusId, pin)) + eventHub.dispatch(StatusChangedEvent(status)) NetworkResult.success(status) }, { e -> Log.w(TAG, "Failed to change pin state", e) @@ -136,6 +183,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/AlertDialogExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/AlertDialogExtensions.kt new file mode 100644 index 000000000..b79671a53 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/AlertDialogExtensions.kt @@ -0,0 +1,63 @@ +/* + * 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.util + +import android.content.DialogInterface +import androidx.annotation.StringRes +import androidx.appcompat.app.AlertDialog +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.suspendCancellableCoroutine + +/** + * Wait for the alert dialog buttons to be clicked, return the ID of the clicked button + * + * @param positiveText Text to show on the positive button + * @param negativeText Optional text to show on the negative button + * @param neutralText Optional text to show on the neutral button + */ +@OptIn(ExperimentalCoroutinesApi::class) +suspend fun AlertDialog.await( + positiveText: String, + negativeText: String? = null, + neutralText: String? = null +) = suspendCancellableCoroutine { cont -> + val listener = DialogInterface.OnClickListener { _, which -> + cont.resume(which) { dismiss() } + } + + setButton(AlertDialog.BUTTON_POSITIVE, positiveText, listener) + negativeText?.let { setButton(AlertDialog.BUTTON_NEGATIVE, it, listener) } + neutralText?.let { setButton(AlertDialog.BUTTON_NEUTRAL, it, listener) } + + setOnCancelListener { cont.cancel() } + cont.invokeOnCancellation { dismiss() } + show() +} + +/** + * @see [AlertDialog.await] + */ +suspend fun AlertDialog.await( + @StringRes positiveTextResource: Int, + @StringRes negativeTextResource: Int? = null, + @StringRes neutralTextResource: Int? = null +) = await( + context.getString(positiveTextResource), + negativeTextResource?.let { context.getString(it) }, + neutralTextResource?.let { context.getString(it) } +) diff --git a/app/src/main/java/com/keylesspalace/tusky/util/CompositeWithOpaqueBackground.kt b/app/src/main/java/com/keylesspalace/tusky/util/CompositeWithOpaqueBackground.kt new file mode 100644 index 000000000..0aaef6d5c --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/CompositeWithOpaqueBackground.kt @@ -0,0 +1,166 @@ +/* + * 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.util + +import android.graphics.Bitmap +import android.graphics.BitmapShader +import android.graphics.Canvas +import android.graphics.ColorMatrix +import android.graphics.ColorMatrixColorFilter +import android.graphics.Paint +import android.graphics.Shader +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable +import android.util.TypedValue +import android.view.View +import androidx.annotation.AttrRes +import androidx.core.content.ContextCompat +import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool +import com.bumptech.glide.load.resource.bitmap.BitmapTransformation +import com.bumptech.glide.util.Util +import java.nio.ByteBuffer +import java.nio.charset.Charset +import java.security.MessageDigest + +/** + * Set an opaque background behind the non-transparent areas of a bitmap. + * + * Profile images may have areas that are partially transparent (i.e., alpha value >= 1 and < 255). + * + * Displaying those can be a problem if there is anything drawn under them, as it will show + * through the image. + * + * Fix this, by: + * + * - Creating a mask that matches the partially transparent areas of the image + * - Creating a new bitmap that, in the areas that match the mask, contains the same background + * drawable as the [ImageView]. + * - Composite the original image over the top + * + * So the partially transparent areas on the original image are composited over the original + * background, the fully transparent areas on the original image are left transparent. + */ +class CompositeWithOpaqueBackground(val view: View) : BitmapTransformation() { + override fun equals(other: Any?): Boolean { + if (other is CompositeWithOpaqueBackground) { + return other.view == view + } + return false + } + + override fun hashCode() = Util.hashCode(ID.hashCode(), view.hashCode()) + override fun updateDiskCacheKey(messageDigest: MessageDigest) { + messageDigest.update(ID_BYTES) + messageDigest.update(ByteBuffer.allocate(4).putInt(view.hashCode()).array()) + } + + override fun transform( + pool: BitmapPool, + toTransform: Bitmap, + outWidth: Int, + outHeight: Int + ): Bitmap { + // If the input bitmap has no alpha channel then there's nothing to do + if (!toTransform.hasAlpha()) return toTransform + + // Get the background drawable for this view, falling back to the given attribute + val backgroundDrawable = view.getFirstNonNullBackgroundOrAttr(android.R.attr.colorBackground) + backgroundDrawable ?: return toTransform + + // Convert the background to a bitmap. + val backgroundBitmap = pool.get(outWidth, outHeight, Bitmap.Config.ARGB_8888) + when (backgroundDrawable) { + is ColorDrawable -> backgroundBitmap.eraseColor(backgroundDrawable.color) + else -> { + val backgroundCanvas = Canvas(backgroundBitmap) + backgroundDrawable.setBounds(0, 0, outWidth, outHeight) + backgroundDrawable.draw(backgroundCanvas) + } + } + + // Convert the alphaBitmap (where the alpha channel has 8bpp) to a mask of 1bpp + // TODO: toTransform.extractAlpha(paint, ...) could be used here, but I can't find any + // useful documentation covering paints and mask filters. + val maskBitmap = pool.get(outWidth, outHeight, Bitmap.Config.ALPHA_8).apply { + val canvas = Canvas(this) + canvas.drawBitmap(toTransform, 0f, 0f, EXTRACT_MASK_PAINT) + } + + val shader = BitmapShader(backgroundBitmap, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT) + val paintShader = Paint() + paintShader.isAntiAlias = true + paintShader.shader = shader + paintShader.style = Paint.Style.FILL_AND_STROKE + + // Write the background to a new bitmap, masked to just the non-transparent areas of the + // original image + val dest = pool.get(outWidth, outHeight, toTransform.config) + val canvas = Canvas(dest) + canvas.drawBitmap(maskBitmap, 0f, 0f, paintShader) + + // Finally, write the original bitmap over the top + canvas.drawBitmap(toTransform, 0f, 0f, null) + + // Clean up intermediate bitmaps + pool.put(maskBitmap) + pool.put(backgroundBitmap) + + return dest + } + + companion object { + @Suppress("unused") + private const val TAG = "CompositeWithOpaqueBackground" + private val ID = CompositeWithOpaqueBackground::class.qualifiedName!! + private val ID_BYTES = ID.toByteArray(Charset.forName("UTF-8")) + + /** Paint with a color filter that converts 8bpp alpha images to a 1bpp mask */ + private val EXTRACT_MASK_PAINT = Paint().apply { + colorFilter = ColorMatrixColorFilter( + ColorMatrix( + floatArrayOf( + 0f, 0f, 0f, 0f, 0f, + 0f, 0f, 0f, 0f, 0f, + 0f, 0f, 0f, 0f, 0f, + 0f, 0f, 0f, 255f, 0f + ) + ) + ) + isAntiAlias = false + } + + /** + * @param attr attribute reference for the default drawable if no background is set on + * this view or any of its ancestors. + * @return The first non-null background drawable from this view, or its ancestors, + * falling back to the attribute resource given by `attr` if none of the views have a + * background. + */ + fun View.getFirstNonNullBackgroundOrAttr(@AttrRes attr: Int): Drawable? = + background ?: (parent as? View)?.getFirstNonNullBackgroundOrAttr(attr) ?: run { + val v = TypedValue() + context.theme.resolveAttribute(attr, v, true) + // TODO: On API 29 can use v.isColorType here + if (v.type >= TypedValue.TYPE_FIRST_COLOR_INT && v.type <= TypedValue.TYPE_LAST_COLOR_INT) { + ColorDrawable(v.data) + } else { + ContextCompat.getDrawable(context, v.resourceId) + } + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/FlowExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/FlowExtensions.kt index 7fcf77359..8de7129ce 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/FlowExtensions.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/FlowExtensions.kt @@ -20,7 +20,6 @@ package com.keylesspalace.tusky.util import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlin.time.Duration -import kotlin.time.ExperimentalTime import kotlin.time.TimeMark import kotlin.time.TimeSource @@ -54,7 +53,6 @@ import kotlin.time.TimeSource * @param timeout Emissions within this duration of the last emission are filtered * @param timeSource Used to measure elapsed time. Normally only overridden in tests */ -@OptIn(ExperimentalTime::class) fun Flow.throttleFirst( timeout: Duration, timeSource: TimeSource = TimeSource.Monotonic 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 ece76bdfd..3b5c49adb 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/ImageLoadingHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/ImageLoadingHelper.kt index 1430801c2..0979f5964 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ImageLoadingHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ImageLoadingHelper.kt @@ -3,39 +3,50 @@ package com.keylesspalace.tusky.util import android.content.Context +import android.graphics.Bitmap import android.graphics.drawable.BitmapDrawable import android.widget.ImageView import androidx.annotation.Px import com.bumptech.glide.Glide +import com.bumptech.glide.load.MultiTransformation +import com.bumptech.glide.load.Transformation import com.bumptech.glide.load.resource.bitmap.CenterCrop import com.bumptech.glide.load.resource.bitmap.RoundedCorners import com.keylesspalace.tusky.R private val centerCropTransformation = CenterCrop() -fun loadAvatar(url: String?, imageView: ImageView, @Px radius: Int, animate: Boolean) { +fun loadAvatar( + url: String?, + imageView: ImageView, + @Px radius: Int, + animate: Boolean, + transforms: List>? = null +) { if (url.isNullOrBlank()) { Glide.with(imageView) .load(R.drawable.avatar_default) .into(imageView) } else { + val multiTransformation = MultiTransformation( + buildList { + transforms?.let { this.addAll(it) } + add(centerCropTransformation) + add(RoundedCorners(radius)) + } + ) + if (animate) { Glide.with(imageView) .load(url) - .transform( - centerCropTransformation, - RoundedCorners(radius) - ) + .transform(multiTransformation) .placeholder(R.drawable.avatar_default) .into(imageView) } else { Glide.with(imageView) .asBitmap() .load(url) - .transform( - centerCropTransformation, - RoundedCorners(radius) - ) + .transform(multiTransformation) .placeholder(R.drawable.avatar_default) .into(imageView) } 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 074bef79b..044a70fef 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt @@ -22,11 +22,14 @@ import android.content.Context import android.content.Intent import android.graphics.Color import android.net.Uri +import android.os.Build import android.text.Spannable import android.text.SpannableStringBuilder import android.text.Spanned import android.text.method.LinkMovementMethod import android.text.style.ClickableSpan +import android.text.style.ForegroundColorSpan +import android.text.style.QuoteSpan import android.text.style.URLSpan import android.util.Log import android.view.MotionEvent @@ -34,15 +37,19 @@ import android.view.MotionEvent.ACTION_UP import android.view.View import android.widget.TextView import androidx.annotation.VisibleForTesting +import androidx.appcompat.content.res.AppCompatResources import androidx.browser.customtabs.CustomTabColorSchemeParams import androidx.browser.customtabs.CustomTabsIntent import androidx.core.net.toUri +import androidx.core.text.getSpans import androidx.preference.PreferenceManager +import at.connyduck.sparkbutton.helpers.Utils import com.google.android.material.color.MaterialColors import com.keylesspalace.tusky.R import com.keylesspalace.tusky.entity.HashTag import com.keylesspalace.tusky.entity.Status.Mention import com.keylesspalace.tusky.interfaces.LinkListener +import java.lang.ref.WeakReference import java.net.URI import java.net.URISyntaxException @@ -65,18 +72,19 @@ fun getDomain(urlString: String?): String { * @param listener to notify about particular spans that are clicked */ fun setClickableText(view: TextView, content: CharSequence, mentions: List, tags: List?, listener: LinkListener) { - val spannableContent = markupHiddenUrls(view.context, content) + val spannableContent = markupHiddenUrls(view, content) view.text = spannableContent.apply { - getSpans(0, content.length, URLSpan::class.java).forEach { - setClickableText(it, this, mentions, tags, listener) + styleQuoteSpans(view) + getSpans(0, spannableContent.length, URLSpan::class.java).forEach { span -> + setClickableText(span, this, mentions, tags, listener) } } view.movementMethod = NoTrailingSpaceLinkMovementMethod.getInstance() } @VisibleForTesting -fun markupHiddenUrls(context: Context, content: CharSequence): SpannableStringBuilder { +fun markupHiddenUrls(view: TextView, content: CharSequence): SpannableStringBuilder { val spannableContent = SpannableStringBuilder(content) val originalSpans = spannableContent.getSpans(0, content.length, URLSpan::class.java) val obscuredLinkSpans = originalSpans.filter { @@ -99,8 +107,22 @@ fun markupHiddenUrls(context: Context, content: CharSequence): SpannableStringBu val start = spannableContent.getSpanStart(span) val end = spannableContent.getSpanEnd(span) val originalText = spannableContent.subSequence(start, end) - val replacementText = context.getString(R.string.url_domain_notifier, originalText, getDomain(span.url)) + val replacementText = view.context.getString(R.string.url_domain_notifier, originalText, getDomain(span.url)) spannableContent.replace(start, end, replacementText) // this also updates the span locations + + val linkDrawable = AppCompatResources.getDrawable(view.context, R.drawable.ic_link)!! + // ImageSpan does not always align the icon correctly in the line, let's use our custom emoji span for this + val linkDrawableSpan = EmojiSpan(WeakReference(view)) + linkDrawableSpan.imageDrawable = linkDrawable + + val placeholderIndex = replacementText.indexOf("🔗") + + spannableContent.setSpan( + linkDrawableSpan, + start + placeholderIndex, + start + placeholderIndex + "🔗".length, + 0 + ) } return spannableContent @@ -161,6 +183,32 @@ private fun getCustomSpanForMentionUrl(url: String, mentionId: String, listener: } } +private fun SpannableStringBuilder.styleQuoteSpans(view: TextView) { + getSpans(0, length, QuoteSpan::class.java).forEach { span -> + val start = getSpanStart(span) + val end = getSpanEnd(span) + val flags = getSpanFlags(span) + + val quoteColor = MaterialColors.getColor(view, android.R.attr.textColorTertiary) + + val newQuoteSpan = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + QuoteSpan( + quoteColor, + Utils.dpToPx(view.context, 3), + Utils.dpToPx(view.context, 8) + ) + } else { + QuoteSpan(quoteColor) + } + + val quoteColorSpan = ForegroundColorSpan(quoteColor) + + removeSpan(span) + setSpan(newQuoteSpan, start, end, flags) + setSpan(quoteColorSpan, start, end, flags) + } +} + /** * Put mentions in a piece of text and makes them clickable, associating them with callbacks to * notify when they're clicked. @@ -284,6 +332,8 @@ fun openLinkInCustomTab(uri: Uri, context: Context) { // https://gts.foo.bar/@goblin/statuses/01GH9XANCJ0TA8Y95VE9H3Y0Q2 // https://gts.foo.bar/@goblin // https://foo.microblog.pub/o/5b64045effd24f48a27d7059f6cb38f5 +// https://bookwyrm.foo.bar/user/User +// https://bookwyrm.foo.bar/user/User/comment/123456 fun looksLikeMastodonUrl(urlString: String): Boolean { val uri: URI try { @@ -304,6 +354,8 @@ fun looksLikeMastodonUrl(urlString: String): Boolean { it.matches("^/@[^/]+/\\d+$".toRegex()) || it.matches("^/users/[^/]+/statuses/\\d+$".toRegex()) || it.matches("^/users/\\w+$".toRegex()) || + it.matches("^/user/[^/]+/comment/\\d+$".toRegex()) || + it.matches("^/user/\\w+$".toRegex()) || it.matches("^/notice/[a-zA-Z0-9]+$".toRegex()) || it.matches("^/objects/[-a-f0-9]+$".toRegex()) || it.matches("^/notes/[a-z0-9]+$".toRegex()) || diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt b/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt index 9402edd03..077ec0085 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt @@ -48,7 +48,7 @@ class ListStatusAccessibilityDelegate( val pos = recyclerView.getChildAdapterPosition(host) val status = statusProvider.getStatus(pos) ?: return if (status is StatusViewData.Concrete) { - if (status.spoilerText.isNotEmpty()) { + if (status.status.spoilerText.isNotEmpty()) { info.addAction(if (status.isExpanded) collapseCwAction else expandCwAction) } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/NoUnderlineURLSpan.kt b/app/src/main/java/com/keylesspalace/tusky/util/NoUnderlineURLSpan.kt index 12be84d8a..59d8bbabe 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/NoUnderlineURLSpan.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/NoUnderlineURLSpan.kt @@ -19,7 +19,7 @@ import android.text.TextPaint import android.text.style.URLSpan import android.view.View -open class NoUnderlineURLSpan constructor(val url: String) : URLSpan(url) { +open class NoUnderlineURLSpan(val url: String) : URLSpan(url) { // This should not be necessary. But if you don't do this the [StatusLengthTest] tests // fail. Without this, accessing the `url` property, or calling `getUrl()` (which should diff --git a/app/src/main/java/com/keylesspalace/tusky/util/PairedList.kt b/app/src/main/java/com/keylesspalace/tusky/util/PairedList.kt new file mode 100644 index 000000000..39a47cc70 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/PairedList.kt @@ -0,0 +1,74 @@ +package com.keylesspalace.tusky.util + +import androidx.arch.core.util.Function + +/** + * This list implementation can help to keep two lists in sync - like real models and view models. + * + * Every operation on the main list triggers update of the supplementary list (but not vice versa). + * + * This makes sure that the main list is always the source of truth. + * + * Main list is projected to the supplementary list by the passed mapper function. + * + * Paired list is newer actually exposed and clients are provided with `getPairedCopy()`, + * `getPairedItem()` and `setPairedItem()`. This prevents modifications of the + * supplementary list size so lists are always have the same length. + * + * This implementation will not try to recover from exceptional cases so lists may be out of sync + * after the exception. + * + * It is most useful with immutable data because we cannot track changes inside stored objects. + * + * @param T type of elements in the main list + * @param V type of elements in supplementary list + * @param mapper Function, which will be used to translate items from the main list to the + * supplementary one. + * @constructor + */ +class PairedList (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/ShareShortcutHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/ShareShortcutHelper.kt index 9d8e4b238..800671ead 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ShareShortcutHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ShareShortcutHelper.kt @@ -29,7 +29,6 @@ import androidx.core.graphics.drawable.IconCompat import com.bumptech.glide.Glide import com.keylesspalace.tusky.MainActivity import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.components.notifications.NotificationHelper import com.keylesspalace.tusky.db.AccountEntity import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.schedulers.Schedulers @@ -72,7 +71,7 @@ fun updateShortcut(context: Context, account: AccountEntity) { val intent = Intent(context, MainActivity::class.java).apply { action = Intent.ACTION_SEND type = "text/plain" - putExtra(NotificationHelper.ACCOUNT_ID, account.id) + putExtra(ShortcutManagerCompat.EXTRA_SHORTCUT_ID, account.id.toString()) } val shortcutInfo = ShortcutInfoCompat.Builder(context, account.id.toString()) 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 c7b583e57..9a9c19bfc 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/SpanUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/SpanUtils.kt index f89f5fea5..4b1235148 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/SpanUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/SpanUtils.kt @@ -128,7 +128,7 @@ private fun findPattern(string: String, fromIndex: Int): FindCharsResult { val result = FindCharsResult() for (i in fromIndex..string.lastIndex) { val c = string[i] - for (matchType in FoundMatchType.values()) { + for (matchType in FoundMatchType.entries) { val finder = finders[matchType] if (finder!!.searchCharacter == c && ( 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 76af25471..b8819cf91 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/StatusParsingHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/StatusParsingHelper.kt @@ -17,20 +17,25 @@ package com.keylesspalace.tusky.util +import android.text.Editable import android.text.Html.TagHandler +import android.text.Spannable import android.text.SpannableStringBuilder import android.text.Spanned +import android.text.style.TypefaceSpan import androidx.core.text.parseAsHtml import org.jsoup.Jsoup.parse +import org.xml.sax.XMLReader /** * parse a String containing html from the Mastodon api to Spanned */ @JvmOverloads -fun String.parseAsMastodonHtml(tagHandler: TagHandler? = null): Spanned { +fun String.parseAsMastodonHtml(tagHandler: TagHandler? = tuskyTagHandler): Spanned { return this.replace("
", "
 ") .replace("
", "
 ") .replace("
", "
 ") + .replace("\n", "
") .replace(" ", "  ") .let { parse(it) } .apply { @@ -43,30 +48,53 @@ fun String.parseAsMastodonHtml(tagHandler: TagHandler? = null): Spanned { .trimTrailingWhitespace() } -fun replaceCrashingCharacters(content: Spanned): Spanned { - return replaceCrashingCharacters(content as CharSequence) as Spanned -} +val tuskyTagHandler = TuskyTagHandler() -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] +open class TuskyTagHandler : TagHandler { - // 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) + class Code + + override fun handleTag(opening: Boolean, tag: String, output: Editable, xmlReader: XMLReader) { + when (tag) { + "code" -> { + if (opening) { + start(output as SpannableStringBuilder, Code()) + } else { + end( + output as SpannableStringBuilder, + Code::class.java, + TypefaceSpan("monospace") + ) + } } - 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 = '-' + /** @return the last span in [text] of type [kind], or null if that kind is not in text */ + protected fun getLast(text: Spanned, kind: Class): Any? { + val spans = text.getSpans(0, text.length, kind) + return spans?.get(spans.size - 1) + } + + /** + * Mark the start of a span of [text] with [mark] so it can be discovered later by [end]. + */ + protected fun start(text: SpannableStringBuilder, mark: Any) { + val len = text.length + text.setSpan(mark, len, len, Spannable.SPAN_MARK_MARK) + } + + /** + * Set a [span] over the [text] from the point recently marked with [mark] to the end + * of the text. + */ + protected fun end(text: SpannableStringBuilder, mark: Class, span: Any) { + val len = text.length + val obj = getLast(text, mark) + val where = text.getSpanStart(obj) + text.removeSpan(obj) + if (where != len) { + text.setSpan(span, where, len, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + } + } +} 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 a03a50260..fc7df19df 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 @@ -24,19 +25,13 @@ import androidx.annotation.AttrRes import androidx.appcompat.app.AppCompatDelegate import androidx.core.content.res.use import com.google.android.material.color.MaterialColors +import com.keylesspalace.tusky.settings.AppTheme /** * Provides runtime compatibility to obtain theme information and re-theme views, especially where * 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 - fun getDimension(context: Context, @AttrRes attribute: Int): Int { return context.obtainStyledAttributes(intArrayOf(attribute)).use { array -> array.getDimensionPixelSize(0, -1) @@ -52,16 +47,28 @@ fun setDrawableTint(context: Context, drawable: Drawable, @AttrRes attribute: In fun setAppNightMode(flavor: String?) { when (flavor) { - THEME_NIGHT, THEME_BLACK -> AppCompatDelegate.setDefaultNightMode( + AppTheme.NIGHT.value, AppTheme.BLACK.value -> AppCompatDelegate.setDefaultNightMode( AppCompatDelegate.MODE_NIGHT_YES ) - THEME_DAY -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) - THEME_AUTO -> AppCompatDelegate.setDefaultNightMode( + AppTheme.DAY.value -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) + AppTheme.AUTO.value -> AppCompatDelegate.setDefaultNightMode( AppCompatDelegate.MODE_NIGHT_AUTO_TIME ) - THEME_SYSTEM -> AppCompatDelegate.setDefaultNightMode( + AppTheme.AUTO_SYSTEM.value, AppTheme.AUTO_SYSTEM_BLACK.value -> AppCompatDelegate.setDefaultNightMode( AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM ) - else -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) + else -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) + } +} + +fun isBlack(config: Configuration, theme: String?): Boolean { + return when (theme) { + AppTheme.BLACK.value -> true + AppTheme.AUTO_SYSTEM_BLACK.value -> 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/ThrowableExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/ThrowableExtensions.kt index a3811a358..c837fc3ea 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ThrowableExtensions.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ThrowableExtensions.kt @@ -30,9 +30,9 @@ fun Throwable.getServerErrorMessage(): String? { /** @return A drawable resource to accompany the error message for this throwable */ fun Throwable.getDrawableRes(): Int = when (this) { - is IOException -> R.drawable.elephant_offline - is HttpException -> R.drawable.elephant_offline - else -> R.drawable.elephant_error + is IOException -> R.drawable.errorphant_offline + is HttpException -> R.drawable.errorphant_offline + else -> R.drawable.errorphant_error } /** @return A string error message for this throwable */ @@ -40,3 +40,5 @@ fun Throwable.getErrorString(context: Context): String = getServerErrorMessage() is IOException -> context.getString(R.string.error_network) else -> context.getString(R.string.error_generic) } + +fun Throwable.isHttpNotFound(): Boolean = (this as? HttpException)?.code() == 404 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 f6ae9e7d7..17e814161 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt @@ -33,6 +33,8 @@ * see . */ package com.keylesspalace.tusky.util +import androidx.paging.CombinedLoadStates +import androidx.paging.LoadState import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.TrendingTag @@ -55,12 +57,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, @@ -86,3 +89,7 @@ fun List.toViewData(): List { ) } } + +fun CombinedLoadStates.isAnyLoading(): Boolean { + return this.refresh == LoadState.Loading || this.append == LoadState.Loading || this.prepend == LoadState.Loading +} diff --git a/app/src/main/java/com/keylesspalace/tusky/view/AdaptiveTabLayout.kt b/app/src/main/java/com/keylesspalace/tusky/view/AdaptiveTabLayout.kt new file mode 100644 index 000000000..800165f90 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/view/AdaptiveTabLayout.kt @@ -0,0 +1,41 @@ +package com.keylesspalace.tusky.view + +import android.content.Context +import android.util.AttributeSet +import android.view.ViewGroup +import com.google.android.material.tabs.TabLayout + +/** + * Workaround for "auto" mode not behaving as expected. + * + * Switches the tab display mode depending on available size: start out with "scrollable" but + * if there is enough room switch to "fixed" (and re-measure). + * + * Idea taken from https://stackoverflow.com/a/44894143 + */ +class AdaptiveTabLayout @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : TabLayout(context, attrs, defStyleAttr) { + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + tabMode = MODE_SCROLLABLE // make sure to only measure the "minimum width" + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + + if (tabCount < 2) { + return + } + + val tabLayout = getChildAt(0) as ViewGroup + var widthOfAllTabs = 0 + for (i in 0 until tabLayout.childCount) { + widthOfAllTabs += tabLayout.getChildAt(i).measuredWidth + } + if (widthOfAllTabs <= measuredWidth) { + // fill all space if there is enough room + tabMode = MODE_FIXED + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + } + } +} 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 650d92ccb..c33533628 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/BackgroundMessageView.kt +++ b/app/src/main/java/com/keylesspalace/tusky/view/BackgroundMessageView.kt @@ -33,7 +33,7 @@ class BackgroundMessageView @JvmOverloads constructor( orientation = VERTICAL if (isInEditMode) { - setup(R.drawable.elephant_offline, R.string.error_network) {} + setup(R.drawable.errorphant_offline, R.string.error_network) {} } } @@ -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/ExposedPlayPauseVideoView.kt b/app/src/main/java/com/keylesspalace/tusky/view/ExposedPlayPauseVideoView.kt deleted file mode 100644 index 95605b18f..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/view/ExposedPlayPauseVideoView.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.keylesspalace.tusky.view - -import android.content.Context -import android.util.AttributeSet -import android.widget.VideoView - -class ExposedPlayPauseVideoView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0 -) : - VideoView(context, attrs, defStyleAttr) { - - private var listener: PlayPauseListener? = null - private var playing = false - - fun setPlayPauseListener(listener: PlayPauseListener) { - this.listener = listener - } - - override fun start() { - super.start() - if (!playing) { - playing = true - listener?.onPlay() - } - } - - override fun pause() { - super.pause() - if (playing) { - playing = false - listener?.onPause() - } - } - - interface PlayPauseListener { - fun onPlay() - fun onPause() - } -} 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 717bd1441..cf400a8f2 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/view/SliderPreference.kt b/app/src/main/java/com/keylesspalace/tusky/view/SliderPreference.kt index 742a2cd76..12b0b990e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/SliderPreference.kt +++ b/app/src/main/java/com/keylesspalace/tusky/view/SliderPreference.kt @@ -4,7 +4,6 @@ import android.content.Context import android.content.res.TypedArray import android.graphics.drawable.Drawable import android.util.AttributeSet -import android.view.View.VISIBLE import androidx.appcompat.content.res.AppCompatResources import androidx.preference.Preference import androidx.preference.PreferenceViewHolder @@ -12,6 +11,8 @@ import com.google.android.material.slider.LabelFormatter.LABEL_GONE import com.google.android.material.slider.Slider import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.PrefSliderBinding +import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.show import java.lang.Float.max import java.lang.Float.min @@ -130,6 +131,8 @@ class SliderPreference @JvmOverloads constructor( binding.root.isClickable = false + binding.slider.clearOnChangeListeners() + binding.slider.clearOnSliderTouchListeners() binding.slider.addOnChangeListener(this) binding.slider.addOnSliderTouchListener(this) binding.slider.value = value // sliderValue @@ -141,24 +144,24 @@ class SliderPreference @JvmOverloads constructor( binding.slider.labelBehavior = LABEL_GONE binding.slider.isEnabled = isEnabled - binding.summary.visibility = VISIBLE + binding.summary.show() binding.summary.text = formatter(value) decrementIcon?.let { icon -> binding.decrement.icon = icon - binding.decrement.visibility = VISIBLE + binding.decrement.show() binding.decrement.setOnClickListener { value -= stepSize } - } + } ?: binding.decrement.hide() incrementIcon?.let { icon -> binding.increment.icon = icon - binding.increment.visibility = VISIBLE + binding.increment.show() binding.increment.setOnClickListener { value += stepSize } - } + } ?: binding.increment.hide() } override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) { diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/AttachmentViewData.kt b/app/src/main/java/com/keylesspalace/tusky/viewdata/AttachmentViewData.kt index ae24cebe1..998cc96a4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/AttachmentViewData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/AttachmentViewData.kt @@ -35,7 +35,7 @@ data class AttachmentViewData( companion object { @JvmStatic - fun list(status: Status): List { + fun list(status: Status, alwaysShowSensitiveMedia: Boolean = false): List { val actionable = status.actionableStatus return actionable.attachments.map { attachment -> AttachmentViewData( @@ -43,7 +43,7 @@ data class AttachmentViewData( statusId = actionable.id, statusUrl = actionable.url!!, sensitive = actionable.sensitive, - isRevealed = !actionable.sensitive + isRevealed = alwaysShowSensitiveMedia || !actionable.sensitive ) } } 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 000000000..c70e2fc71 --- /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 759d633e2..000000000 --- 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 d0f4c5dd8..8c48b0271 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 @@ -79,20 +75,19 @@ sealed class StatusViewData { val quoteViewData = status.quote?.let { Concrete(it, isExpanded, isShowingContent, isCollapsed) } - init { - if (Build.VERSION.SDK_INT == 23) { - // https://github.com/tuskyapp/Tusky/issues/563 - this.content = replaceCrashingCharacters(status.actionableStatus.content.parseAsMastodonHtml()) - this.spoilerText = - replaceCrashingCharacters(status.actionableStatus.spoilerText).toString() - this.username = - replaceCrashingCharacters(status.actionableStatus.account.username).toString() - } else { - this.content = status.actionableStatus.content.parseAsMastodonHtml() - this.spoilerText = status.actionableStatus.spoilerText - this.username = status.actionableStatus.account.username - } - 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 */ 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 a5ee50365..81a475d02 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt @@ -35,7 +35,6 @@ import com.keylesspalace.tusky.util.Resource import com.keylesspalace.tusky.util.Success import com.keylesspalace.tusky.util.getServerErrorMessage import com.keylesspalace.tusky.util.randomAlphanumericString -import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asFlow @@ -43,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 @@ -52,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, @@ -64,11 +69,10 @@ class EditProfileViewModel @Inject constructor( val headerData = MutableLiveData() val saveData = MutableLiveData>() - @OptIn(FlowPreview::class) 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) { @@ -76,7 +80,7 @@ class EditProfileViewModel @Inject constructor( mastodonApi.accountVerifyCredentials().fold( { profile -> - oldProfileData = profile + apiProfileAccount = profile profileData.postValue(Success(profile)) }, { @@ -98,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?.intentionallyUseDisplayName == 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())) @@ -169,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?.intentionallyUseDisplayName == 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/java/com/keylesspalace/tusky/viewmodel/ListsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/viewmodel/ListsViewModel.kt index f701847c6..0dd05dafe 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewmodel/ListsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewmodel/ListsViewModel.kt @@ -38,7 +38,7 @@ internal class ListsViewModel @Inject constructor(private val api: MastodonApi) } enum class Event { - CREATE_ERROR, DELETE_ERROR, RENAME_ERROR + CREATE_ERROR, DELETE_ERROR, UPDATE_ERROR } data class State(val lists: List, val loadingState: LoadingState) @@ -84,9 +84,9 @@ internal class ListsViewModel @Inject constructor(private val api: MastodonApi) } } - fun createNewList(listName: String) { + fun createNewList(listName: String, exclusive: Boolean, replyPolicy: String) { viewModelScope.launch { - api.createList(listName).fold( + api.createList(listName, exclusive, replyPolicy).fold( { list -> updateState { copy(lists = lists + list) @@ -99,16 +99,16 @@ internal class ListsViewModel @Inject constructor(private val api: MastodonApi) } } - fun renameList(listId: String, listName: String) { + fun updateList(listId: String, listName: String, exclusive: Boolean, replyPolicy: String) { viewModelScope.launch { - api.updateList(listId, listName).fold( + api.updateList(listId, listName, exclusive, replyPolicy).fold( { list -> updateState { copy(lists = lists.replacedFirstWhich(list) { it.id == listId }) } }, { - sendEvent(Event.RENAME_ERROR) + sendEvent(Event.UPDATE_ERROR) } ) } diff --git a/app/src/main/java/net/accelf/yuito/FooterDrawerItem.kt b/app/src/main/java/net/accelf/yuito/FooterDrawerItem.kt index f82329797..cc7bfcac9 100644 --- a/app/src/main/java/net/accelf/yuito/FooterDrawerItem.kt +++ b/app/src/main/java/net/accelf/yuito/FooterDrawerItem.kt @@ -40,7 +40,7 @@ class FooterDrawerItem : AbstractDrawerItem) { instance .onSuccess { - binding.instanceData.text = listOf(it.title, it.uri, it.version).joinToString("\n") + binding.instanceData.text = listOf(it.title, it.domain, it.version).joinToString("\n") } .onFailure { binding.instanceData.text = binding.root.context.getString(R.string.instance_data_failed) diff --git a/app/src/main/java/net/accelf/yuito/QuoteInlineHelper.kt b/app/src/main/java/net/accelf/yuito/QuoteInlineHelper.kt index 314e0c995..1f3eb0821 100644 --- a/app/src/main/java/net/accelf/yuito/QuoteInlineHelper.kt +++ b/app/src/main/java/net/accelf/yuito/QuoteInlineHelper.kt @@ -106,10 +106,10 @@ class QuoteInlineHelper( ) setAvatar(account.avatar, avatarRadius24dp, statusDisplayOptions) setOnClickListener(account.id, actionable.url) - if (quote.spoilerText.isEmpty()) { + if (quote.status.spoilerText.isEmpty()) { hideSpoilerText() } else { - setSpoilerText(quote.spoilerText, actionable.emojis) + setSpoilerText(quote.status.spoilerText, actionable.emojis) } val viewMedia = binding.statusQuoteInlineMedia if (actionable.attachments.size == 0) { 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 98ce83382..000000000 --- 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 d016e35c0..000000000 --- a/app/src/main/res/drawable-v24/ic_notoemoji.xml +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/elephant_error.xml b/app/src/main/res/drawable/errorphant_error.xml similarity index 100% rename from app/src/main/res/drawable/elephant_error.xml rename to app/src/main/res/drawable/errorphant_error.xml diff --git a/app/src/main/res/drawable/elephant_offline.xml b/app/src/main/res/drawable/errorphant_offline.xml similarity index 100% rename from app/src/main/res/drawable/elephant_offline.xml rename to app/src/main/res/drawable/errorphant_offline.xml 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 be3332ce7..000000000 --- a/app/src/main/res/drawable/ic_blobmoji.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/drawable/ic_content_copy_24.xml b/app/src/main/res/drawable/ic_content_copy_24.xml new file mode 100644 index 000000000..bac0f6001 --- /dev/null +++ b/app/src/main/res/drawable/ic_content_copy_24.xml @@ -0,0 +1,5 @@ + + + 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 b00cb9684..000000000 --- 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 000000000..9d4e6643f --- /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_link.xml b/app/src/main/res/drawable/ic_link.xml new file mode 100644 index 000000000..5bb6b6862 --- /dev/null +++ b/app/src/main/res/drawable/ic_link.xml @@ -0,0 +1,9 @@ + + + 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 627eafd22..000000000 --- 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 55628c019..000000000 --- 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 70c4b513b..000000000 --- a/app/src/main/res/drawable/ic_twemoji.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - diff --git a/app/src/main/res/drawable/profile_badge_background.xml b/app/src/main/res/drawable/profile_badge_background.xml deleted file mode 100644 index be4bcf3ef..000000000 --- a/app/src/main/res/drawable/profile_badge_background.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/profile_badge_person_24dp.xml b/app/src/main/res/drawable/profile_badge_person_24dp.xml new file mode 100644 index 000000000..628c29de0 --- /dev/null +++ b/app/src/main/res/drawable/profile_badge_person_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout-sw640dp/fragment_timeline.xml b/app/src/main/res/layout-sw640dp/fragment_timeline.xml index 6d014e702..af49280d9 100644 --- a/app/src/main/res/layout-sw640dp/fragment_timeline.xml +++ b/app/src/main/res/layout-sw640dp/fragment_timeline.xml @@ -46,7 +46,7 @@ app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" app:layout_constraintTop_toTopOf="parent" - tools:src="@drawable/elephant_error" + tools:src="@drawable/errorphant_error" tools:visibility="visible" /> diff --git a/app/src/main/res/layout/activity_about.xml b/app/src/main/res/layout/activity_about.xml index 6feb85d75..a1d773aa4 100644 --- a/app/src/main/res/layout/activity_about.xml +++ b/app/src/main/res/layout/activity_about.xml @@ -21,122 +21,213 @@ android:layout_gravity="center" android:textDirection="anyRtl"> - + + + + + + + android:textAppearance="@style/TextAppearance.AppCompat.Medium" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/versionTextView" + tools:text="Your device" /> - + + + + + + + - + android:textAppearance="@style/TextAppearance.AppCompat.Subhead" + app:layout_constraintEnd_toEndOf="@+id/copyDeviceInfo" + app:layout_constraintStart_toStartOf="@+id/deviceInfo" + app:layout_constraintTop_toBottomOf="@+id/accountInfo" /> - + app:layout_constraintEnd_toEndOf="@+id/aboutWebsiteInfoTextView" + app:layout_constraintStart_toStartOf="@+id/aboutWebsiteInfoTextView" + app:layout_constraintTop_toBottomOf="@id/aboutWebsiteInfoTextView" />