Merge tag 'v25.2' into develop

# Conflicts:
#	README.md
#	app/build.gradle
#	app/lint-baseline.xml
#	app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt
#	app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusNotificationViewHolder.kt
#	app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt
#	app/src/main/java/com/keylesspalace/tusky/util/ThemeUtils.kt
#	app/src/main/res/layout/activity_about.xml
#	app/src/main/res/layout/item_emoji_pref.xml
#	app/src/main/res/values-ar/strings.xml
#	app/src/main/res/values-bg/strings.xml
#	app/src/main/res/values-cy/strings.xml
#	app/src/main/res/values-de/strings.xml
#	app/src/main/res/values-fa/strings.xml
#	app/src/main/res/values-gd/strings.xml
#	app/src/main/res/values-gl/strings.xml
#	app/src/main/res/values-hu/strings.xml
#	app/src/main/res/values-is/strings.xml
#	app/src/main/res/values-it/strings.xml
#	app/src/main/res/values-ja/strings.xml
#	app/src/main/res/values-nl/strings.xml
#	app/src/main/res/values-oc/strings.xml
#	app/src/main/res/values-pt-rBR/strings.xml
#	app/src/main/res/values-pt-rPT/strings.xml
#	app/src/main/res/values-ru/strings.xml
#	app/src/main/res/values-si/strings.xml
#	app/src/main/res/values-sv/strings.xml
#	app/src/main/res/values-tr/strings.xml
#	app/src/main/res/values-uk/strings.xml
#	app/src/main/res/values-vi/strings.xml
#	app/src/main/res/values-zh-rCN/strings.xml
#	app/src/main/res/values/strings.xml
#	fastlane/metadata/android/ru/full_description.txt
#	fastlane/metadata/android/zh-Hans/full_description.txt
This commit is contained in:
Mike Barnes 2026-01-02 18:27:41 +11:00
commit 875013e47f
630 changed files with 22153 additions and 18732 deletions

View file

@ -7,11 +7,21 @@ indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.{java,kt}]
ij_kotlin_imports_layout = *
# 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
ktlint_code_style = android_studio
# Disable trailing comma
ktlint_standard_trailing-comma-on-call-site = disabled
ktlint_standard_trailing-comma-on-declaration-site = disabled
max_line_length = off
[*.{yml,yaml}]
indent_size = 2

41
.github/ISSUE_TEMPLATE/1.bug_report.yml vendored Normal file
View file

@ -0,0 +1,41 @@
name: Bug Report
description: If something isn't working as expected
labels: [bug]
body:
- type: markdown
attributes:
value: |
Make sure that you are submitting a new bug that was not previously reported or already fixed.
Please use a concise and distinct title for the issue.
If possible, attach screenshots, videos or links to posts to illustrate the problem.
- type: textarea
attributes:
label: Detailed description
validations:
required: false
- type: textarea
attributes:
label: Steps to reproduce the problem
description: What were you trying to do?
value: |
1.
2.
3.
...
validations:
required: false
- type: textarea
attributes:
label: Debug information
description: |
This info can be copied from the 'About' screen in Tusky 24+.
If you are on a lower version or can't access the screen, please provide us with the Tusky Version, Android Version, Device and the Mastodon instance this problem occurred on.
placeholder: |
Tusky Test 22.0-b814c2c0
Android 12
Fairphone 4
mastodon.social
validations:
required: true

View file

@ -0,0 +1,19 @@
name: Feature Request
description: I have a suggestion
labels: [enhancement]
body:
- type: markdown
attributes:
value: Please use a concise and distinct title for the issue.
- type: textarea
attributes:
label: Pitch
description: Describe your idea for a feature. Make sure it has not already been suggested/implemented/turned down before.
validations:
required: true
- type: textarea
attributes:
label: Motivation
description: Why do you think this feature is needed? Who would benefit from it?
validations:
required: true

1
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View file

@ -0,0 +1 @@
blank_issues_enabled: true

24
.github/ci-gradle.properties vendored Normal file
View file

@ -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 <http://www.gnu.org/licenses>.
#
# 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

44
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,44 @@
name: CI
on:
push:
tags:
- '*'
pull_request:
workflow_dispatch:
jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
- name: Gradle Wrapper Validation
uses: gradle/actions/wrapper-validation@v3
- 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@v3
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

View file

@ -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@v4
- uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
- name: Gradle Wrapper Validation
uses: gradle/actions/wrapper-validation@v3
- name: Copy CI gradle.properties
run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties
- uses: gradle/gradle-build-action@v3
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/develop' }}
- name: Run app:buildGreenDebug
run: ./gradlew app:buildGreenDebug

View file

@ -6,6 +6,94 @@
### Significant bug fixes
## v25.2
### Significant bug fixes
- Fixes a bug that could sometimes crash Tusky when rotating the screen while viewing an account list [PR#4430](https://github.com/tuskyapp/Tusky/pull/4430)
- Fixes a bug that could crash Tusky at startup under certain conditions [PR#4431](https://github.com/tuskyapp/Tusky/pull/4431)
- Fixes a bug that caused Tusky to crash when custom emojis with too large dimensions were loaded [PR#4429](https://github.com/tuskyapp/Tusky/pull/4429)
- Makes Tusky work again with Iceshrimp by working around a quirk in their API implementation [PR#4426](https://github.com/tuskyapp/Tusky/pull/4426)
- Fixes a bug that made translations not work on some servers [PR#4422](https://github.com/tuskyapp/Tusky/pull/4422)
## v25.1
### Significant bug fixes
- Fixed two crashes at startup introduced in 25.0 [PR#4415](https://github.com/tuskyapp/Tusky/pull/4415) [PR#4417](https://github.com/tuskyapp/Tusky/pull/4417)
## v25.0
### New features and other improvements
- Added support for the [Mastodon translation api](https://docs.joinmastodon.org/methods/statuses/#translate).
You can now find a new option "translate" in the three-dot-menu on posts that are not in your display language when your server supports the translation api.
Support is determined by checking the `configuration.translation.enabled` attribute of the `/api/v2/instance` endpoint.
[PR#4307](https://github.com/tuskyapp/Tusky/pull/4307)
- The language of a post is now shown in the metadata section of the detail post view, if it is available. [PR#4127](https://github.com/tuskyapp/Tusky/pull/4127)
- The transitions between screens have been changed to feel faster and align more with default Android transitions. [PR#4285](https://github.com/tuskyapp/Tusky/pull/4285)
- The post statistic section below the detail post view is now always shown to prevent layout shifts on the first like or boost.
[PR#4205](https://github.com/tuskyapp/Tusky/pull/4205) [PR#4260](https://github.com/tuskyapp/Tusky/pull/4260)
- The filters for boosts/replies/self-boosts in the home timeline have moved from general preferences to account specific preferences. [PR#4115](https://github.com/tuskyapp/Tusky/pull/4115)
- The json parsing library has been migrated from Gson to Moshi. This change will make Tusky no longer crash on unexpected server responses. [PR#4309](https://github.com/tuskyapp/Tusky/pull/4309)
- Small layout improvements to the header of the profile view [PR#4375](https://github.com/tuskyapp/Tusky/pull/4375) [PR#4371](https://github.com/tuskyapp/Tusky/pull/4371)
- support for Android 14 Upside Down Cake [PR#4224](https://github.com/tuskyapp/Tusky/pull/4224)
- Various internal refactorings to improve performance and maintainability.
[PR#4269](https://github.com/tuskyapp/Tusky/pull/4269)
[PR#4290](https://github.com/tuskyapp/Tusky/pull/4290)
[PR#4291](https://github.com/tuskyapp/Tusky/pull/4291)
[PR#4296](https://github.com/tuskyapp/Tusky/pull/4296)
[PR#4364](https://github.com/tuskyapp/Tusky/pull/4364)
[PR#4366](https://github.com/tuskyapp/Tusky/pull/4366)
[PR#4372](https://github.com/tuskyapp/Tusky/pull/4372)
[PR#4356](https://github.com/tuskyapp/Tusky/pull/4356)
[PR#4348](https://github.com/tuskyapp/Tusky/pull/4348)
[PR#4339](https://github.com/tuskyapp/Tusky/pull/4339)
[PR#4337](https://github.com/tuskyapp/Tusky/pull/4337)
[PR#4336](https://github.com/tuskyapp/Tusky/pull/4336)
[PR#4330](https://github.com/tuskyapp/Tusky/pull/4330)
[PR#4235](https://github.com/tuskyapp/Tusky/pull/4235)
[PR#4081](https://github.com/tuskyapp/Tusky/pull/4081)
### Significant bug fixes
- The setting to hide the notification filter bar that was accidentally removed is back. [PR#4225](https://github.com/tuskyapp/Tusky/pull/4225)
- The profile picture in the bottom navigation bar now has the correct content description. [PR#4400](https://github.com/tuskyapp/Tusky/pull/4400)
## v24.1
- The screen will stay on again while a video is playing. [PR#4168](https://github.com/tuskyapp/Tusky/pull/4168)
- A memory leak has been fixed. This should improve stability and performance. [PR#4150](https://github.com/tuskyapp/Tusky/pull/4150) [PR#4153](https://github.com/tuskyapp/Tusky/pull/4153)
- Emojis are now correctly counted as 1 character when composing a post. [PR#4152](https://github.com/tuskyapp/Tusky/pull/4152)
- Fixed a crash when text was selected on some devices. [PR#4166](https://github.com/tuskyapp/Tusky/pull/4166)
- The icons in the help texts of empty timelines will now always be correctly
aligned. [PR#4179](https://github.com/tuskyapp/Tusky/pull/4179)
- Fixed ANR caused by direct message badge [PR#4182](https://github.com/tuskyapp/Tusky/pull/4182)
## v24.0
### New features and other improvements
- The number of tabs that can be configured is no longer limited. [PR#4058](https://github.com/tuskyapp/Tusky/pull/4058)
- Blockquotes and code blocks in posts now look nicer [PR#4090](https://github.com/tuskyapp/Tusky/pull/4090) [PR#4091](https://github.com/tuskyapp/Tusky/pull/4091)
- The old behavior of the notification tab (pre Tusky 22.0) has been restored. [PR#4015](https://github.com/tuskyapp/Tusky/pull/4015)
- Role badges are now shown on profiles (Mastodon 4.2 feature). [PR#4029](https://github.com/tuskyapp/Tusky/pull/4029)
- The video player has been upgraded to Google Jetpack Media3; video compatibility should be improved, and you can now adjust playback speed. [PR#3857](https://github.com/tuskyapp/Tusky/pull/3857)
- New theme option to use the black theme when following the system design. [PR#3957](https://github.com/tuskyapp/Tusky/pull/3957)
- Following the system design is now the default theme setting. [PR#3813](https://github.com/tuskyapp/Tusky/pull/3957)
- A new view to see trending posts is available both in the menu and as custom tab. [PR#4007](https://github.com/tuskyapp/Tusky/pull/4007)
- A new option to hide self boosts has been added. [PR#4101](https://github.com/tuskyapp/Tusky/pull/4101)
- The `api/v2/instance` endpoint is now supported. [PR#4062](https://github.com/tuskyapp/Tusky/pull/4062)
- New settings for lists:
- Hide from the home timeline [PR#3932](https://github.com/tuskyapp/Tusky/pull/3932)
- Decide which replies should be shown in the list [PR#4072](https://github.com/tuskyapp/Tusky/pull/4072)
- The oldest supported Android version is now Android 7 Nougat [PR#4014](https://github.com/tuskyapp/Tusky/pull/4014)
### Significant bug fixes
- **Empty trends no longer causes Tusky to crash**, [PR#3853](https://github.com/tuskyapp/Tusky/pull/3853)
## v23.0
### New features and other improvements

View file

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

View file

@ -1,9 +0,0 @@
[Issue text goes here].
* * * *
- Tusky Version:
- Android Version:
- Android Device:
- Mastodon instance (if applicable):
- [ ] I searched or browsed the repos other issues to ensure this is not a duplicate.

View file

@ -12,10 +12,11 @@ It is derived from [Tusky](https://tusky.app) and has been modified to reflect C
## Features
- Material Design
- Most Mastodon APIs implemented
- Multi-Account support
- Respect device preferences for light/dark theming
- Drafts - compose posts and save them for later
- Choose between different emoji styles
- Choose between different emoji styles
- Optimized for all screen sizes
- Completely open-source - no non-free dependencies like Google services
@ -26,4 +27,4 @@ If you have any bug reports, feature requests or questions please open an issue
For translating Tusky into your language, visit https://weblate.tusky.app/
###
###

View file

@ -24,15 +24,15 @@ final def SUPPORT_ACCOUNT_URL = "https://social.chinwag.org/@ChinwagNews"
final def REGISTER_ACCOUNT_URL = "https://chinwag.org/auth/sign_up"
android {
compileSdk 33
compileSdk 34
namespace "com.keylesspalace.tusky"
defaultConfig {
applicationId APP_ID
namespace "com.keylesspalace.tusky"
minSdk 23
targetSdk 33
versionCode 90
versionName "23.0-CW0"
minSdk 24
targetSdk 34
versionCode 121
versionName "25.2-CW0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true
@ -44,10 +44,21 @@ android {
buildConfigField("String", "REGISTER_ACCOUNT_URL", "\"$REGISTER_ACCOUNT_URL\"")
}
buildTypes {
debug {
isDefault true
}
release {
minifyEnabled true
shrinkResources true
proguardFiles 'proguard-rules.pro'
kotlinOptions {
freeCompilerArgs = [
"-Xno-param-assertions",
"-Xno-call-assertions",
"-Xno-receiver-assertions"
]
}
}
}
@ -58,13 +69,13 @@ android {
resValue "string", "app_name", APP_NAME + " Test"
applicationIdSuffix ".test"
versionNameSuffix "-" + gitSha
isDefault true
}
}
lint {
lintConfig file("lint.xml")
// Regenerate by deleting app/lint-baseline.xml, then run:
// ./gradlew lintBlueDebug
// Regenerate by deleting the file and running `./gradlew app:lintGreenDebug`
baseline = file("lint-baseline.xml")
}
@ -103,12 +114,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}_" +
@ -119,6 +124,7 @@ android {
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
arg("room.generateKotlin", "true")
arg("room.incremental", "true")
}
@ -132,7 +138,6 @@ configurations {
// library versions are in PROJECT_ROOT/gradle/libs.versions.toml
dependencies {
implementation libs.kotlinx.coroutines.android
implementation libs.kotlinx.coroutines.rx3
implementation libs.bundles.androidx
implementation libs.bundles.room
@ -140,28 +145,26 @@ dependencies {
implementation libs.android.material
implementation libs.gson
implementation libs.bundles.moshi
ksp libs.moshi.kotlin.codegen
implementation libs.bundles.retrofit
implementation libs.networkresult.calladapter
implementation libs.bundles.okhttp
implementation libs.okio
implementation libs.conscrypt.android
implementation libs.bundles.glide
kapt libs.glide.compiler
implementation libs.bundles.rxjava3
implementation libs.bundles.autodispose
ksp libs.glide.compiler
implementation libs.bundles.dagger
kapt libs.bundles.dagger.processors
implementation libs.sparkbutton
implementation libs.photoview
implementation libs.touchimageview
implementation libs.bundles.material.drawer
implementation libs.material.typeface
@ -189,3 +192,20 @@ dependencies {
androidTestImplementation libs.androidx.room.testing
androidTestImplementation libs.androidx.test.junit
}
// 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"])
}

File diff suppressed because one or more lines are too long

View file

@ -29,17 +29,48 @@
Disable these for the time being. -->
<issue id="UnusedIds" severity="ignore" />
<issue id="UnusedResources" severity="ignore" />
<!-- Logs are stripped in release builds. -->
<issue id="LogConditional" severity="ignore" />
<!-- Ensure we are warned about errors in the baseline -->
<issue id="LintBaseline" severity="warning" />
<!-- Newer dependencies are handled by Renovate, and don't need a warning -->
<issue id="GradleDependency" severity="ignore" />
<issue id="NewerVersionAvailable" severity="ignore" />
<!-- Warn about typos. The typo database in lint is not exhaustive, and it's unclear
how to add to it when it's wrong. -->
<issue id="Typos" severity="warning" />
<!-- Typographical punctuation is not something we care about at the moment -->
<issue id="TypographyQuotes" severity="ignore" />
<issue id="TypographyDashes" severity="ignore" />
<issue id="TypographyEllipsis" severity="ignore" />
<!-- Mark all other lint issues as errors -->
<issue id="all" severity="error" />
<!-- Translations come from external parties -->
<issue id="MissingQuantity" severity="ignore" />
<issue id="ImpliedQuantity" severity="ignore" />
<!-- Most alleged typos are in translations -->
<issue id="Typos" severity="ignore" />
<!-- Basically all of our vectors are external -->
<issue id="VectorPath" severity="ignore" />
<issue id="Overdraw" severity="ignore" />
<!-- Irrelevant api version warnings -->
<issue id="OldTargetApi" severity="ignore" />
<issue id="UnusedAttribute" severity="ignore" />
<!-- We do not *want* all the text in the app to be selectable -->
<issue id="SelectableText" severity="ignore" />
<!-- This is heavily used by the viewbinding helper -->
<issue id="SyntheticAccessor" severity="ignore" />
<!-- Things we would actually question in a code review -->
<issue id="MissingPermission" severity="error" />
<issue id="InvalidPackage" severity="error" />
<issue id="UseCompatLoadingForDrawables" severity="error" />
<issue id="UseCompatTextViewDrawableXml" severity="error" />
<issue id="Recycle" severity="error" />
<issue id="KeyboardInaccessibleWidget" severity="error" />
<!-- Mark all other lint issues as warnings -->
<issue id="all" severity="warning" />
</lint>

View file

@ -1,83 +1,44 @@
# GENERAL OPTIONS
# turn on all optimizations except those that are known to cause problems on Android
-optimizations !code/simplification/cast,!field/*,!class/merging/*
-optimizationpasses 6
-allowaccessmodification
-dontpreverify
-dontusemixedcaseclassnames
-dontskipnonpubliclibraryclasses
-keepattributes *Annotation*
# Preserve some attributes that may be required for reflection.
-keepattributes RuntimeVisible*Annotations, AnnotationDefault
# For native methods, see http://proguard.sourceforge.net/manual/examples.html#native
-keepclasseswithmembernames class * {
native <methods>;
}
# keep setters in Views so that animations can still work.
# see http://proguard.sourceforge.net/manual/examples.html#beans
-keepclassmembers public class * extends android.view.View {
void set*(***);
*** get*();
}
# We want to keep methods in Activity that could be used in the XML attribute onClick
-keepclassmembers class * extends android.app.Activity {
public void *(android.view.View);
}
# For enumeration classes, see http://proguard.sourceforge.net/manual/examples.html#enumerations
-keepclassmembers enum * {
-keepclassmembers,allowoptimization enum * {
public static **[] values();
public static ** valueOf(java.lang.String);
}
-keepclassmembers class * implements android.os.Parcelable {
public static final ** CREATOR;
}
-keepclassmembers class **.R$* {
public static <fields>;
# Preserve annotated Javascript interface methods.
-keepclassmembers class * {
@android.webkit.JavascriptInterface <methods>;
}
# The support libraries contains references to newer platform versions.
# Don't warn about those in case this app is linking against an older
# platform version. We know about them, and they are safe.
-dontnote androidx.**
-dontwarn androidx.**
# This class is deprecated, but remains for backward compatibility.
-dontwarn android.util.FloatMath
# These classes are duplicated between android.jar and core-lambda-stubs.jar.
-dontnote java.lang.invoke.**
# TUSKY SPECIFIC OPTIONS
# keep members of our model classes, they are used in json de/serialization
-keepclassmembers class com.keylesspalace.tusky.entity.* { *; }
-keep public enum com.keylesspalace.tusky.entity.*$** {
**[] $VALUES;
public *;
}
-keepclassmembers class com.keylesspalace.tusky.components.conversation.ConversationAccountEntity { *; }
-keepclassmembers class com.keylesspalace.tusky.db.DraftAttachment { *; }
-keep enum com.keylesspalace.tusky.db.DraftAttachment$Type {
public *;
}
# https://github.com/google/gson/blob/master/examples/android-proguard-example/proguard.cfg
# Prevent proguard from stripping interface information from TypeAdapter, TypeAdapterFactory,
# JsonSerializer, JsonDeserializer instances (so they can be used in @JsonAdapter)
-keep class * extends com.google.gson.TypeAdapter
-keep class * implements com.google.gson.TypeAdapterFactory
-keep class * implements com.google.gson.JsonSerializer
-keep class * implements com.google.gson.JsonDeserializer
# Retain generic signatures of TypeToken and its subclasses with R8 version 3.0 and higher.
-keep,allowobfuscation,allowshrinking class com.google.gson.reflect.TypeToken
-keep,allowobfuscation,allowshrinking class * extends com.google.gson.reflect.TypeToken
# Retain generic signatures of classes used in MastodonApi so Retrofit works
-keep,allowobfuscation,allowshrinking class io.reactivex.rxjava3.core.Single
-keep,allowobfuscation,allowshrinking class retrofit2.Response
-keep,allowobfuscation,allowshrinking class kotlin.collections.List
-keep,allowobfuscation,allowshrinking class kotlin.collections.Map
-keep,allowobfuscation,allowshrinking class retrofit2.Call
# https://r8.googlesource.com/r8/+/refs/heads/master/compatibility-faq.md#retrofit
-keepattributes Signature
-keep class kotlin.coroutines.Continuation
# preserve line numbers for crash reporting
-keepattributes SourceFile,LineNumberTable
-renamesourcefileattribute SourceFile

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -19,7 +19,7 @@ class MigrationsTest {
@Rule
var helper: MigrationTestHelper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
AppDatabase::class.java.canonicalName,
AppDatabase::class.java.canonicalName!!,
FrameworkSQLiteOpenHelperFactory()
)

View file

@ -18,7 +18,8 @@
android:supportsRtl="true"
android:theme="@style/TuskyTheme"
android:usesCleartextTraffic="false"
android:localeConfig="@xml/locales_config">
android:localeConfig="@xml/locales_config"
android:enableOnBackInvokedCallback="true">
<activity
android:name=".SplashActivity"
@ -130,7 +131,7 @@
android:theme="@style/Base.Theme.AppCompat" />
<activity
android:name=".components.search.SearchActivity"
android:launchMode="singleTop"
android:launchMode="singleTask"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.SEARCH" />
@ -148,7 +149,7 @@
<activity
android:name=".components.report.ReportActivity"
android:windowSoftInputMode="stateAlwaysHidden|adjustResize" />
<activity android:name=".components.instancemute.InstanceListActivity" />
<activity android:name=".components.domainblocks.DomainBlocksActivity" />
<activity android:name=".components.scheduled.ScheduledStatusActivity" />
<activity android:name=".components.announcements.AnnouncementsActivity" />
<activity android:name=".components.drafts.DraftsActivity" />
@ -188,14 +189,14 @@
android:icon="@drawable/ic_chinwag_logo_simple"
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">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
</service>
<service android:name=".service.SendStatusService"
android:foregroundServiceType="shortService"
android:exported="false" />
<provider

View file

@ -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,13 +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 com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
import javax.inject.Inject
import kotlinx.coroutines.launch
class AboutActivity : BottomSheetActivity(), Injectable {
@Inject
lateinit var instanceInfoRepository: InstanceInfoRepository
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -32,13 +45,41 @@ 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.getUpdatedInstanceInfoOrFallback()
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()
}
binding.aboutLicenseInfoTextView.setClickableTextWithoutUnderlines(R.string.about_tusky_license)
binding.aboutWebsiteInfoTextView.setClickableTextWithoutUnderlines(R.string.about_project_site)
binding.aboutBugsFeaturesInfoTextView.setClickableTextWithoutUnderlines(R.string.about_bug_feature_request_site)
binding.aboutLicenseInfoTextView.setClickableTextWithoutUnderlines(
R.string.about_tusky_license
)
binding.aboutWebsiteInfoTextView.setClickableTextWithoutUnderlines(
R.string.about_project_site
)
binding.aboutBugsFeaturesInfoTextView.setClickableTextWithoutUnderlines(
R.string.about_bug_feature_request_site
)
binding.tuskyProfileButton.setOnClickListener {
viewUrl(BuildConfig.SUPPORT_ACCOUNT_URL)
@ -47,6 +88,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()
}
}
}
}

View file

@ -45,8 +45,8 @@ import com.keylesspalace.tusky.util.unsafeLazy
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel
import com.keylesspalace.tusky.viewmodel.State
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlinx.coroutines.launch
private typealias AccountInfo = Pair<TimelineAccount, Boolean>
@ -82,11 +82,18 @@ class AccountsInListFragment : DialogFragment(), Injectable {
super.onStart()
dialog?.apply {
// Stretch dialog to the window
window?.setLayout(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.MATCH_PARENT)
window?.setLayout(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.MATCH_PARENT
)
}
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_accounts_in_list, container, false)
}
@ -164,15 +171,27 @@ class AccountsInListFragment : DialogFragment(), Injectable {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: TimelineAccount, newItem: TimelineAccount): Boolean {
override fun areContentsTheSame(
oldItem: TimelineAccount,
newItem: TimelineAccount
): Boolean {
return oldItem == newItem
}
}
inner class Adapter : ListAdapter<TimelineAccount, BindingHolder<ItemFollowRequestBinding>>(AccountDiffer) {
inner class Adapter : ListAdapter<TimelineAccount, BindingHolder<ItemFollowRequestBinding>>(
AccountDiffer
) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemFollowRequestBinding> {
val binding = ItemFollowRequestBinding.inflate(LayoutInflater.from(parent.context), parent, false)
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): BindingHolder<ItemFollowRequestBinding> {
val binding = ItemFollowRequestBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
val holder = BindingHolder(binding)
binding.notificationTextView.hide()
@ -186,7 +205,10 @@ class AccountsInListFragment : DialogFragment(), Injectable {
return holder
}
override fun onBindViewHolder(holder: BindingHolder<ItemFollowRequestBinding>, position: Int) {
override fun onBindViewHolder(
holder: BindingHolder<ItemFollowRequestBinding>,
position: Int
) {
val account = getItem(position)
holder.binding.displayNameTextView.text = account.name.emojify(account.emojis, holder.binding.displayNameTextView, animateEmojis)
holder.binding.usernameTextView.text = account.username
@ -204,10 +226,19 @@ class AccountsInListFragment : DialogFragment(), Injectable {
}
}
inner class SearchAdapter : ListAdapter<AccountInfo, BindingHolder<ItemFollowRequestBinding>>(SearchDiffer) {
inner class SearchAdapter : ListAdapter<AccountInfo, BindingHolder<ItemFollowRequestBinding>>(
SearchDiffer
) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemFollowRequestBinding> {
val binding = ItemFollowRequestBinding.inflate(LayoutInflater.from(parent.context), parent, false)
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): BindingHolder<ItemFollowRequestBinding> {
val binding = ItemFollowRequestBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
val holder = BindingHolder(binding)
binding.notificationTextView.hide()
@ -224,7 +255,10 @@ class AccountsInListFragment : DialogFragment(), Injectable {
return holder
}
override fun onBindViewHolder(holder: BindingHolder<ItemFollowRequestBinding>, position: Int) {
override fun onBindViewHolder(
holder: BindingHolder<ItemFollowRequestBinding>,
position: Int
) {
val (account, inAList) = getItem(position)
holder.binding.displayNameTextView.text = account.name.emojify(account.emojis, holder.binding.displayNameTextView, animateEmojis)

View file

@ -47,7 +47,9 @@ 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.ActivityExtensions;
import com.keylesspalace.tusky.util.ThemeUtils;
import java.util.ArrayList;
@ -56,10 +58,17 @@ import java.util.List;
import javax.inject.Inject;
import static com.keylesspalace.tusky.settings.PrefKeys.APP_THEME;
import static com.keylesspalace.tusky.util.ActivityExtensions.supportsOverridingActivityTransitions;
public abstract class BaseActivity extends AppCompatActivity implements Injectable {
public static final String OPEN_WITH_SLIDE_IN = "OPEN_WITH_SLIDE_IN";
private static final String TAG = "BaseActivity";
@Inject
@NonNull
public AccountManager accountManager;
private static final int REQUESTER_NONE = Integer.MAX_VALUE;
@ -69,14 +78,19 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if (supportsOverridingActivityTransitions() && activityTransitionWasRequested()) {
overrideActivityTransition(OVERRIDE_TRANSITION_OPEN, R.anim.activity_open_enter, R.anim.activity_open_exit);
overrideActivityTransition(OVERRIDE_TRANSITION_CLOSE, R.anim.activity_close_enter, R.anim.activity_close_exit);
}
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this);
/* 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 +101,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()) {
@ -97,6 +111,10 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
requesters = new HashMap<>();
}
private boolean activityTransitionWasRequested() {
return getIntent().getBooleanExtra(OPEN_WITH_SLIDE_IN, false);
}
@Override
protected void attachBaseContext(Context newBase) {
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(newBase);
@ -162,13 +180,8 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
return style;
}
public void startActivityWithSlideInAnimation(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;
@ -179,11 +192,10 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
@Override
public void finish() {
super.finish();
overridePendingTransition(R.anim.slide_from_left, R.anim.slide_to_right);
}
public void finishWithoutSlideOutAnimation() {
super.finish();
// if this activity was opened with slide-in, close it with slide out
if (!supportsOverridingActivityTransitions() && activityTransitionWasRequested()) {
overridePendingTransition(R.anim.activity_close_enter, R.anim.activity_close_exit);
}
}
protected void redirectIfNotLoggedIn() {
@ -191,12 +203,12 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
if (account == null) {
Intent intent = new Intent(this, LoginActivity.class);
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK);
startActivityWithSlideInAnimation(intent);
ActivityExtensions.startActivityWithSlideInAnimation(this, intent);
finish();
}
}
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 +216,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<AccountEntity> accounts = accountManager.getAllAccountsOrderedByActive();
AccountEntity activeAccount = accountManager.getActiveAccount();
@ -231,9 +243,9 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
adapter.addAll(accounts);
new AlertDialog.Builder(this)
.setTitle(dialogTitle)
.setAdapter(adapter, (dialogInterface, index) -> listener.onAccountSelected(accounts.get(index)))
.show();
.setTitle(dialogTitle)
.setAdapter(adapter, (dialogInterface, index) -> listener.onAccountSelected(accounts.get(index)))
.show();
}
public @Nullable String getOpenAsText() {
@ -256,11 +268,10 @@ 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();
finish();
}
@Override
@ -272,7 +283,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<String> permissionsToRequest = new ArrayList<>();
for(String permission: permissions) {
if (ContextCompat.checkSelfPermission(this, permission) != PackageManager.PERMISSION_GRANTED) {

View file

@ -22,17 +22,17 @@ import android.view.View
import android.widget.LinearLayout
import android.widget.Toast
import androidx.annotation.VisibleForTesting
import androidx.lifecycle.Lifecycle
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider
import autodispose2.autoDispose
import androidx.lifecycle.lifecycleScope
import at.connyduck.calladapter.networkresult.fold
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.keylesspalace.tusky.components.account.AccountActivity
import com.keylesspalace.tusky.components.viewthread.ViewThreadActivity
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.looksLikeMastodonUrl
import com.keylesspalace.tusky.util.openLink
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
import javax.inject.Inject
import kotlinx.coroutines.launch
/** this is the base class for all activities that open links
* links are checked against the api if they are mastodon links so they can be opened in Tusky
@ -64,45 +64,48 @@ abstract class BottomSheetActivity : BaseActivity() {
})
}
open fun viewUrl(url: String, lookupFallbackBehavior: PostLookupFallbackBehavior = PostLookupFallbackBehavior.OPEN_IN_BROWSER) {
open fun viewUrl(
url: String,
lookupFallbackBehavior: PostLookupFallbackBehavior = PostLookupFallbackBehavior.OPEN_IN_BROWSER
) {
if (!looksLikeMastodonUrl(url)) {
openLink(url)
return
}
mastodonApi.searchObservable(
query = url,
resolve = true
).observeOn(AndroidSchedulers.mainThread())
.autoDispose(AndroidLifecycleScopeProvider.from(this, Lifecycle.Event.ON_DESTROY))
.subscribe(
{ (accounts, statuses) ->
lifecycleScope.launch {
mastodonApi.search(
query = url,
resolve = true
).fold(
onSuccess = { (accounts, statuses) ->
if (getCancelSearchRequested(url)) {
return@subscribe
return@launch
}
onEndSearch(url)
if (statuses.isNotEmpty()) {
viewThread(statuses[0].id, statuses[0].url)
return@subscribe
return@launch
}
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)
return@subscribe
return@launch
}
performUrlFallbackAction(url, lookupFallbackBehavior)
},
{
onFailure = {
if (!getCancelSearchRequested(url)) {
onEndSearch(url)
performUrlFallbackAction(url, lookupFallbackBehavior)
}
}
)
}
onBeginSearch(url)
}
@ -121,10 +124,17 @@ abstract class BottomSheetActivity : BaseActivity() {
startActivityWithSlideInAnimation(intent)
}
protected open fun performUrlFallbackAction(url: String, fallbackBehavior: PostLookupFallbackBehavior) {
protected open fun performUrlFallbackAction(
url: String,
fallbackBehavior: PostLookupFallbackBehavior
) {
when (fallbackBehavior) {
PostLookupFallbackBehavior.OPEN_IN_BROWSER -> openLink(url)
PostLookupFallbackBehavior.DISPLAY_ERROR -> Toast.makeText(this, getString(R.string.post_lookup_error_format, url), Toast.LENGTH_SHORT).show()
PostLookupFallbackBehavior.DISPLAY_ERROR -> Toast.makeText(
this,
getString(R.string.post_lookup_error_format, url),
Toast.LENGTH_SHORT
).show()
}
}

View file

@ -25,9 +25,11 @@ 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.core.widget.doAfterTextChanged
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.bumptech.glide.Glide
@ -46,15 +48,18 @@ 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
import com.mikepenz.iconics.utils.sizeDp
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class EditProfileActivity : BaseActivity(), Injectable {
@ -96,6 +101,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)
@ -114,9 +127,17 @@ class EditProfileActivity : BaseActivity(), Injectable {
binding.fieldList.layoutManager = LinearLayoutManager(this)
binding.fieldList.adapter = accountFieldEditAdapter
val plusDrawable = IconicsDrawable(this, GoogleMaterial.Icon.gmd_add).apply { sizeDp = 12; colorInt = Color.WHITE }
val plusDrawable = IconicsDrawable(this, GoogleMaterial.Icon.gmd_add).apply {
sizeDp = 12
colorInt = Color.WHITE
}
binding.addFieldButton.setCompoundDrawablesRelativeWithIntrinsicBounds(plusDrawable, null, null, null)
binding.addFieldButton.setCompoundDrawablesRelativeWithIntrinsicBounds(
plusDrawable,
null,
null,
null
)
binding.addFieldButton.setOnClickListener {
accountFieldEditAdapter.addField()
@ -131,52 +152,64 @@ class EditProfileActivity : BaseActivity(), Injectable {
viewModel.obtainProfile()
viewModel.profileData.observe(this) { profileRes ->
when (profileRes) {
is Success -> {
val me = profileRes.data
if (me != null) {
binding.displayNameEditText.setText(me.displayName)
binding.noteEditText.setText(me.source?.note)
binding.lockedCheckBox.isChecked = me.locked
lifecycleScope.launch {
viewModel.profileData.collect { profileRes ->
if (profileRes == null) return@collect
when (profileRes) {
is Success -> {
val me = profileRes.data
if (me != null) {
binding.displayNameEditText.setText(me.displayName)
binding.noteEditText.setText(me.source?.note)
binding.lockedCheckBox.isChecked = me.locked
accountFieldEditAdapter.setFields(me.source?.fields.orEmpty())
binding.addFieldButton.isVisible =
(me.source?.fields?.size ?: 0) < maxAccountFields
accountFieldEditAdapter.setFields(me.source?.fields.orEmpty())
binding.addFieldButton.isVisible =
(me.source?.fields?.size ?: 0) < maxAccountFields
if (viewModel.avatarData.value == null) {
Glide.with(this)
.load(me.avatar)
.placeholder(R.drawable.avatar_default)
.transform(
FitCenter(),
RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_80dp))
)
.into(binding.avatarPreview)
}
if (viewModel.avatarData.value == null) {
Glide.with(this@EditProfileActivity)
.load(me.avatar)
.placeholder(R.drawable.avatar_default)
.transform(
FitCenter(),
RoundedCorners(
resources.getDimensionPixelSize(R.dimen.avatar_radius_80dp)
)
)
.into(binding.avatarPreview)
}
if (viewModel.headerData.value == null) {
Glide.with(this)
.load(me.header)
.into(binding.headerPreview)
if (viewModel.headerData.value == null) {
Glide.with(this@EditProfileActivity)
.load(me.header)
.into(binding.headerPreview)
}
}
}
is Error -> {
Snackbar.make(
binding.avatarButton,
R.string.error_generic,
Snackbar.LENGTH_LONG
)
.setAction(R.string.action_retry) {
viewModel.obtainProfile()
}
.show()
}
is Loading -> { }
}
is Error -> {
Snackbar.make(binding.avatarButton, R.string.error_generic, Snackbar.LENGTH_LONG)
.setAction(R.string.action_retry) {
viewModel.obtainProfile()
}
.show()
}
is Loading -> { }
}
}
lifecycleScope.launch {
viewModel.instanceData.collect { instanceInfo ->
maxAccountFields = instanceInfo.maxFields
accountFieldEditAdapter.setFieldLimits(instanceInfo.maxFieldNameLength, instanceInfo.maxFieldValueLength)
accountFieldEditAdapter.setFieldLimits(
instanceInfo.maxFieldNameLength,
instanceInfo.maxFieldValueLength
)
binding.addFieldButton.isVisible =
accountFieldEditAdapter.itemCount < maxAccountFields
}
@ -185,60 +218,85 @@ class EditProfileActivity : BaseActivity(), Injectable {
observeImage(viewModel.avatarData, binding.avatarPreview, true)
observeImage(viewModel.headerData, binding.headerPreview, false)
viewModel.saveData.observe(
this
) {
when (it) {
is Success -> {
finish()
}
is Loading -> {
binding.saveProgressBar.visibility = View.VISIBLE
}
is Error -> {
onSaveFailure(it.errorMessage)
lifecycleScope.launch {
viewModel.saveData.collect {
if (it == null) return@collect
when (it) {
is Success -> {
finish()
}
is Loading -> {
binding.saveProgressBar.visibility = View.VISIBLE
}
is Error -> {
onSaveFailure(it.errorMessage)
}
}
}
}
binding.displayNameEditText.doAfterTextChanged {
viewModel.dataChanged(currentProfileData)
}
binding.displayNameEditText.doAfterTextChanged {
viewModel.dataChanged(currentProfileData)
}
binding.lockedCheckBox.setOnCheckedChangeListener { _, _ ->
viewModel.dataChanged(currentProfileData)
}
accountFieldEditAdapter.onFieldsChanged = {
viewModel.dataChanged(currentProfileData)
}
val onBackCallback = object : OnBackPressedCallback(enabled = false) {
override fun handleOnBackPressed() {
showUnsavedChangesDialog()
}
}
onBackPressedDispatcher.addCallback(this, onBackCallback)
lifecycleScope.launch {
viewModel.isChanged.collect { dataWasChanged ->
onBackCallback.isEnabled = dataWasChanged
}
}
}
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)
}
}
private fun observeImage(
liveData: LiveData<Uri>,
flow: StateFlow<Uri?>,
imageView: ImageView,
roundedCorners: Boolean
) {
liveData.observe(
this
) { imageUri ->
lifecycleScope.launch {
flow.collect { imageUri ->
// skipping all caches so we can always reuse the same uri
val glide = Glide.with(imageView)
.load(imageUri)
.skipMemoryCache(true)
.diskCacheStrategy(DiskCacheStrategy.NONE)
// skipping all caches so we can always reuse the same uri
val glide = Glide.with(imageView)
.load(imageUri)
.skipMemoryCache(true)
.diskCacheStrategy(DiskCacheStrategy.NONE)
if (roundedCorners) {
glide.transform(
FitCenter(),
RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_80dp))
).into(imageView)
} else {
glide.into(imageView)
if (roundedCorners) {
glide.transform(
FitCenter(),
RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_80dp))
).into(imageView)
} else {
glide.into(imageView)
}
imageView.show()
}
imageView.show()
}
}
@ -287,14 +345,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)
@ -304,6 +355,22 @@ class EditProfileActivity : BaseActivity(), Injectable {
private fun onPickFailure(throwable: Throwable?) {
Log.w("EditProfileActivity", "failed to pick media", throwable)
Snackbar.make(binding.avatarButton, R.string.error_media_upload_sending, Snackbar.LENGTH_LONG).show()
Snackbar.make(
binding.avatarButton,
R.string.error_media_upload_sending,
Snackbar.LENGTH_LONG
).show()
}
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)
}

View file

@ -19,11 +19,14 @@ import android.os.Bundle
import android.util.Log
import android.widget.TextView
import androidx.annotation.RawRes
import androidx.lifecycle.lifecycleScope
import com.keylesspalace.tusky.databinding.ActivityLicenseBinding
import com.keylesspalace.tusky.util.closeQuietly
import java.io.BufferedReader
import java.io.IOException
import java.io.InputStreamReader
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okio.buffer
import okio.source
class LicenseActivity : BaseActivity() {
@ -44,23 +47,15 @@ class LicenseActivity : BaseActivity() {
}
private fun loadFileIntoTextView(@RawRes fileId: Int, textView: TextView) {
val sb = StringBuilder()
val br = BufferedReader(InputStreamReader(resources.openRawResource(fileId)))
try {
var line: String? = br.readLine()
while (line != null) {
sb.append(line)
sb.append('\n')
line = br.readLine()
lifecycleScope.launch {
textView.text = withContext(Dispatchers.IO) {
try {
resources.openRawResource(fileId).source().buffer().use { it.readUtf8() }
} catch (e: IOException) {
Log.w("LicenseActivity", e)
""
}
}
} catch (e: IOException) {
Log.w("LicenseActivity", e)
}
br.closeQuietly()
textView.text = sb.toString()
}
}

View file

@ -1,4 +1,4 @@
/* Copyright 2017 Andrew Dawson
/* Copyright Tusky contributors
*
* This file is a part of Tusky.
*
@ -23,11 +23,7 @@ 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
import androidx.activity.viewModels
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
@ -37,16 +33,17 @@ import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import at.connyduck.sparkbutton.helpers.Utils
import 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.databinding.ItemListBinding
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.MastoList
import com.keylesspalace.tusky.util.BindingHolder
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible
import com.keylesspalace.tusky.viewmodel.ListsViewModel
@ -56,18 +53,12 @@ import com.keylesspalace.tusky.viewmodel.ListsViewModel.LoadingState.ERROR_OTHER
import com.keylesspalace.tusky.viewmodel.ListsViewModel.LoadingState.INITIAL
import com.keylesspalace.tusky.viewmodel.ListsViewModel.LoadingState.LOADED
import com.keylesspalace.tusky.viewmodel.ListsViewModel.LoadingState.LOADING
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizeDp
import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlinx.coroutines.launch
/**
* Created by charlag on 1/4/18.
*/
// TODO use the ListSelectionFragment (and/or its adapter or binding) here; but keep the LoadingState from here (?)
class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
@ -118,7 +109,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 +117,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 +129,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 +174,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 +192,7 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
R.string.message_empty,
null
)
binding.messageView.showHelp(R.string.help_empty_lists)
} else {
binding.messageView.hide()
}
@ -206,9 +207,9 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
).show()
}
private fun onListSelected(listId: String, listTitle: String) {
private fun onListSelected(list: MastoList) {
startActivityWithSlideInAnimation(
StatusListActivity.newListIntent(this, listId, listTitle)
StatusListActivity.newListIntent(this, list.id, list.title)
)
}
@ -226,7 +227,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
}
@ -247,51 +248,42 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
}
private inner class ListsAdapter :
ListAdapter<MastoList, ListsAdapter.ListViewHolder>(ListsDiffer) {
ListAdapter<MastoList, BindingHolder<ItemListBinding>>(ListsDiffer) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ListViewHolder {
return LayoutInflater.from(parent.context).inflate(R.layout.item_list, parent, false)
.let(this::ListViewHolder)
.apply {
val iconColor = MaterialColors.getColor(nameTextView, android.R.attr.textColorTertiary)
val context = nameTextView.context
val icon = IconicsDrawable(context, GoogleMaterial.Icon.gmd_list).apply { sizeDp = 20; colorInt = iconColor }
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): BindingHolder<ItemListBinding> {
return BindingHolder(ItemListBinding.inflate(LayoutInflater.from(parent.context), parent, false))
}
nameTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(icon, null, null, null)
override fun onBindViewHolder(holder: BindingHolder<ItemListBinding>, position: Int) {
val item = getItem(position)
holder.binding.listName.text = item.title
holder.binding.moreButton.apply {
visible(true)
setOnClickListener {
onMore(item, holder.binding.moreButton)
}
}
override fun onBindViewHolder(holder: ListViewHolder, position: Int) {
holder.nameTextView.text = getItem(position).title
}
private inner class ListViewHolder(view: View) :
RecyclerView.ViewHolder(view),
View.OnClickListener {
val nameTextView: TextView = view.findViewById(R.id.list_name_textview)
val moreButton: ImageButton = view.findViewById(R.id.editListButton)
init {
view.setOnClickListener(this)
moreButton.setOnClickListener(this)
}
override fun onClick(v: View) {
if (v == itemView) {
val list = getItem(bindingAdapterPosition)
onListSelected(list.id, list.title)
} else {
onMore(getItem(bindingAdapterPosition), v)
}
holder.itemView.setOnClickListener {
onListSelected(item)
}
}
}
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)
}
}

View file

@ -16,6 +16,8 @@
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
@ -26,6 +28,7 @@ import android.graphics.drawable.Animatable
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.text.TextUtils
import android.util.Log
@ -33,6 +36,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.widget.ImageView
import androidx.activity.OnBackPressedCallback
@ -41,15 +45,18 @@ import androidx.appcompat.content.res.AppCompatResources
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.drawerlayout.widget.DrawerLayout
import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceManager
import androidx.viewpager2.widget.MarginPageTransformer
import at.connyduck.calladapter.networkresult.fold
import com.bumptech.glide.Glide
import com.bumptech.glide.RequestManager
import com.bumptech.glide.load.resource.bitmap.RoundedCorners
import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.target.FixedSizeDrawable
@ -60,8 +67,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.ProfileEditedEvent
import com.keylesspalace.tusky.components.account.AccountActivity
import com.keylesspalace.tusky.components.accountlist.AccountListActivity
@ -81,8 +91,10 @@ import com.keylesspalace.tusky.components.trending.TrendingActivity
import com.keylesspalace.tusky.databinding.ActivityMainBinding
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.DraftsAlert
import com.keylesspalace.tusky.di.ApplicationScope
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.interfaces.AccountSelectionListener
import com.keylesspalace.tusky.interfaces.ActionButtonActivity
import com.keylesspalace.tusky.interfaces.FabFragment
@ -91,16 +103,17 @@ import com.keylesspalace.tusky.pager.MainPagerAdapter
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.usecase.DeveloperToolsUseCase
import com.keylesspalace.tusky.usecase.LogoutUsecase
import com.keylesspalace.tusky.util.ShareShortcutHelper
import com.keylesspalace.tusky.util.deleteStaleCachedMedia
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.getDimension
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.reduceSwipeSensitivity
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
import com.keylesspalace.tusky.util.supportsOverridingActivityTransitions
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
@ -131,9 +144,10 @@ import com.mikepenz.materialdrawer.widget.AccountHeaderView
import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
import de.c1710.filemojicompat_ui.helpers.EMOJI_PREFERENCE
import io.reactivex.rxjava3.schedulers.Schedulers
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInjector, MenuProvider {
@Inject
@ -154,21 +168,23 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
@Inject
lateinit var developerToolsUseCase: DeveloperToolsUseCase
@Inject
lateinit var shareShortcutHelper: ShareShortcutHelper
@Inject
@ApplicationScope
lateinit var externalScope: CoroutineScope
private val binding by viewBinding(ActivityMainBinding::inflate)
private lateinit var header: AccountHeaderView
private var notificationTabPosition = 0
private var onTabSelectedListener: OnTabSelectedListener? = null
private var unreadAnnouncementsCount = 0
private val preferences by unsafeLazy { PreferenceManager.getDefaultSharedPreferences(this) }
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
@ -178,37 +194,68 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
/** Adapter for the different timeline tabs */
private lateinit var tabAdapter: MainPagerAdapter
private var directMessageTab: TabLayout.Tab? = null
private val onBackPressedCallback = object : OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
when {
binding.mainDrawerLayout.isOpen -> {
binding.mainDrawerLayout.close()
}
binding.viewPager.currentItem != 0 -> {
binding.viewPager.currentItem = 0
}
}
}
}
@SuppressLint("RestrictedApi")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val activeAccount = accountManager.activeAccount
?: return // will be redirected to LoginActivity by BaseActivity
if (supportsOverridingActivityTransitions() && explodeAnimationWasRequested()) {
overrideActivityTransition(OVERRIDE_TRANSITION_OPEN, R.anim.explode, R.anim.activity_open_exit)
}
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(
@ -219,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)
}
}
@ -232,11 +279,14 @@ 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
@ -245,17 +295,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.composeButton.setOnClickListener {
val composeIntent = Intent(applicationContext, ComposeActivity::class.java)
startActivity(composeIntent)
}
// 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)
@ -266,7 +326,12 @@ 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
@ -291,47 +356,57 @@ 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 {
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)
}
}
}
}
}
Schedulers.io().scheduleDirect {
externalScope.launch(Dispatchers.IO) {
// Flush old media that was cached for sharing
deleteStaleCachedMedia(applicationContext.getExternalFilesDir("Tusky"))
}
selectedEmojiPack = preferences.getString(EMOJI_PREFERENCE, "")
onBackPressedDispatcher.addCallback(
this,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
when {
binding.mainDrawerLayout.isOpen -> {
binding.mainDrawerLayout.close()
}
binding.viewPager.currentItem != 0 -> {
binding.viewPager.currentItem = 0
}
else -> {
finish()
}
}
}
}
)
onBackPressedDispatcher.addCallback(this, onBackPressedCallback)
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),
@ -343,6 +418,22 @@ 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)
lifecycleScope.launch(Dispatchers.IO) {
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 {
@ -353,6 +444,20 @@ 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 -> {
@ -425,12 +530,23 @@ 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()
}
@ -438,13 +554,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
@ -468,17 +585,21 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
header.currentProfileName.ellipsize = TextUtils.TruncateAt.END
header.accountHeaderBackground.setColorFilter(getColor(R.color.headerBackgroundFilter))
header.accountHeaderBackground.setBackgroundColor(MaterialColors.getColor(header, R.attr.colorBackgroundAccent))
val animateAvatars = preferences.getBoolean("animateGifAvatars", false)
header.accountHeaderBackground.setBackgroundColor(
MaterialColors.getColor(header, R.attr.colorBackgroundAccent)
)
val animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false)
DrawerImageLoader.init(object : AbstractDrawerImageLoader() {
override fun set(imageView: ImageView, uri: Uri, placeholder: Drawable, tag: String?) {
if (animateAvatars) {
glide.load(uri)
Glide.with(imageView)
.load(uri)
.placeholder(placeholder)
.into(imageView)
} else {
glide.asBitmap()
Glide.with(imageView)
.asBitmap()
.load(uri)
.placeholder(placeholder)
.into(imageView)
@ -486,12 +607,12 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
}
override fun cancel(imageView: ImageView) {
glide.clear(imageView)
// nothing to do, Glide already handles cancellation automatically
}
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)
@ -499,12 +620,33 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
})
binding.mainDrawer.apply {
refreshMainDrawerItems(addSearchButton, addTrendingButton)
refreshMainDrawerItems(
addSearchButton = addSearchButton,
addTrendingTagsButton = addTrendingTagsButton,
addTrendingStatusesButton = addTrendingStatusesButton
)
setSavedInstance(savedInstanceState)
}
binding.mainDrawerLayout.addDrawerListener(object : DrawerLayout.DrawerListener {
override fun onDrawerSlide(drawerView: View, slideOffset: Float) { }
override fun onDrawerOpened(drawerView: View) {
onBackPressedCallback.isEnabled = true
}
override fun onDrawerClosed(drawerView: View) {
onBackPressedCallback.isEnabled = binding.tabLayout.selectedTabPosition > 0
}
override fun onDrawerStateChanged(newState: Int) { }
})
}
private fun refreshMainDrawerItems(addSearchButton: Boolean, addTrendingButton: Boolean) {
private fun refreshMainDrawerItems(
addSearchButton: Boolean,
addTrendingTagsButton: Boolean,
addTrendingStatusesButton: Boolean
) {
binding.mainDrawer.apply {
itemAdapter.clear()
tintStatusBar = true
@ -538,7 +680,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)
}
},
@ -621,7 +763,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
)
}
if (addTrendingButton) {
if (addTrendingTagsButton) {
binding.mainDrawer.addItemsAtPosition(
5,
primaryDrawerItem {
@ -633,6 +775,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) {
@ -702,6 +857,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)
@ -712,6 +869,12 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
LIST -> tabs[position].arguments[1]
else -> getString(tabs[position].text)
}
if (tabs[position].id == DIRECT) {
val badge = tab.orCreateBadge
badge.isVisible = accountManager.activeAccount?.hasDirectMessageBadge ?: false
badge.backgroundColor = MaterialColors.getColor(binding.mainDrawer, com.google.android.material.R.attr.colorPrimary)
directMessageTab = tab
}
}.also { it.attach() }
// Selected tab is either
@ -737,9 +900,22 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
onTabSelectedListener = object : OnTabSelectedListener {
override fun onTabSelected(tab: TabLayout.Tab) {
onBackPressedCallback.isEnabled = tab.position > 0 || binding.mainDrawerLayout.isOpen
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) {}
@ -756,10 +932,13 @@ 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()
(
tabAdapter.getFragment(
activeTabLayout.selectedTabPosition
) as? ReselectableFragment
)?.onReselect()
}
updateProfiles()
@ -790,7 +969,9 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
}
// open LoginActivity to add new account
if (profile.identifier == DRAWER_ITEM_ADD_ACCOUNT) {
startActivityWithSlideInAnimation(LoginActivity.getIntent(this, LoginActivity.MODE_ADDITIONAL_LOGIN))
startActivityWithSlideInAnimation(
LoginActivity.getIntent(this, LoginActivity.MODE_ADDITIONAL_LOGIN)
)
return false
}
// change Account
@ -802,15 +983,18 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
cacheUpdater.stop()
accountManager.setActiveAccount(newSelectedId)
val intent = Intent(this, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
intent.putExtra(OPEN_WITH_EXPLODE_ANIMATION, true)
if (forward != null) {
intent.type = forward.type
intent.action = forward.action
intent.putExtras(forward)
}
startActivity(intent)
finishWithoutSlideOutAnimation()
overridePendingTransition(R.anim.explode, R.anim.explode)
finish()
if (!supportsOverridingActivityTransitions()) {
@Suppress("DEPRECATION")
overridePendingTransition(R.anim.explode, R.anim.activity_open_exit)
}
}
private fun logout() {
@ -833,7 +1017,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
LoginActivity.getIntent(this@MainActivity, LoginActivity.MODE_DEFAULT)
}
startActivity(intent)
finishWithoutSlideOutAnimation()
finish()
}
}
.setNegativeButton(android.R.string.cancel, null)
@ -853,17 +1037,26 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
}
private fun onFetchUserInfoSuccess(me: Account) {
glide.asBitmap()
Glide.with(header.accountHeaderBackground)
.asBitmap()
.load(me.header)
.into(header.accountHeaderBackground)
loadDrawerAvatar(me.avatar, false)
accountManager.updateActiveAccount(me)
NotificationHelper.createNotificationChannelsForAccount(accountManager.activeAccount!!, this)
NotificationHelper.createNotificationChannelsForAccount(
accountManager.activeAccount!!,
this
)
// Setup push notifications
showMigrationNoticeIfNecessary(this, binding.mainCoordinatorLayout, binding.composeButton, accountManager)
showMigrationNoticeIfNecessary(
this,
binding.mainCoordinatorLayout,
binding.composeButton,
accountManager
)
if (NotificationHelper.areNotificationsEnabled(this, accountManager)) {
lifecycleScope.launch {
enablePushNotificationsWithFallback(this@MainActivity, mastodonApi, accountManager)
@ -872,122 +1065,94 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
disableAllNotifications(this, accountManager)
}
accountLocked = me.locked
updateProfiles()
updateShortcut(this, accountManager.activeAccount!!)
shareShortcutHelper.updateShortcuts()
}
@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.with(this)
.asDrawable()
.load(avatarUrl)
.transform(
RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp))
)
.apply {
if (showPlaceholder) placeholder(R.drawable.avatar_default)
}
.into(object : CustomTarget<Drawable>(navIconSize, navIconSize) {
override fun onLoadStarted(placeholder: Drawable?) {
placeholder?.let {
activeToolbar.navigationIcon = FixedSizeDrawable(it, navIconSize, navIconSize)
}
}
.into(object : CustomTarget<Drawable>(navIconSize, navIconSize) {
override fun onLoadStarted(placeholder: Drawable?) {
if (placeholder != null) {
binding.mainToolbar.navigationIcon =
FixedSizeDrawable(placeholder, navIconSize, navIconSize)
}
}
override fun onResourceReady(
resource: Drawable,
transition: Transition<in Drawable>?
) {
if (resource is Animatable) resource.start()
activeToolbar.navigationIcon = FixedSizeDrawable(resource, navIconSize, navIconSize)
}
override fun onResourceReady(
resource: Drawable,
transition: Transition<in Drawable>?
) {
if (resource is Animatable) {
resource.start()
}
binding.mainToolbar.navigationIcon =
FixedSizeDrawable(resource, navIconSize, navIconSize)
}
override fun onLoadCleared(placeholder: Drawable?) {
if (placeholder != null) {
binding.mainToolbar.navigationIcon =
FixedSizeDrawable(placeholder, navIconSize, navIconSize)
}
}
})
} else {
glide.asBitmap()
.load(avatarUrl)
.transform(
RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp))
)
.apply {
if (showPlaceholder) {
placeholder(R.drawable.avatar_default)
override fun onLoadCleared(placeholder: Drawable?) {
placeholder?.let {
activeToolbar.navigationIcon = FixedSizeDrawable(it, navIconSize, navIconSize)
}
}
.into(object : CustomTarget<Bitmap>(navIconSize, navIconSize) {
override fun onLoadStarted(placeholder: Drawable?) {
if (placeholder != null) {
binding.mainToolbar.navigationIcon =
FixedSizeDrawable(placeholder, navIconSize, navIconSize)
}
})
} else {
Glide.with(this)
.asBitmap()
.load(avatarUrl)
.transform(
RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp))
)
.apply {
if (showPlaceholder) placeholder(R.drawable.avatar_default)
}
.into(object : CustomTarget<Bitmap>(navIconSize, navIconSize) {
override fun onLoadStarted(placeholder: Drawable?) {
placeholder?.let {
activeToolbar.navigationIcon = FixedSizeDrawable(it, navIconSize, navIconSize)
}
}
override fun onResourceReady(
resource: Bitmap,
transition: Transition<in Bitmap>?
) {
binding.mainToolbar.navigationIcon = FixedSizeDrawable(
BitmapDrawable(resources, resource),
navIconSize,
navIconSize
)
}
override fun onResourceReady(
resource: Bitmap,
transition: Transition<in Bitmap>?
) {
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)
}
})
}
}
})
}
}
@ -1007,7 +1172,12 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
}
private fun updateAnnouncementsBadge() {
binding.mainDrawer.updateBadge(DRAWER_ITEM_ANNOUNCEMENTS, StringHolder(if (unreadAnnouncementsCount <= 0) null else unreadAnnouncementsCount.toString()))
binding.mainDrawer.updateBadge(
DRAWER_ITEM_ANNOUNCEMENTS,
StringHolder(
if (unreadAnnouncementsCount <= 0) null else unreadAnnouncementsCount.toString()
)
)
}
private fun updateProfiles() {
@ -1041,16 +1211,93 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
}
}
private fun explodeAnimationWasRequested(): Boolean {
return intent.getBooleanExtra(OPEN_WITH_EXPLODE_ANIMATION, false)
}
override fun getActionButton() = binding.composeButton
override fun androidInjector() = androidInjector
companion object {
const val OPEN_WITH_EXPLODE_ANIMATION = "explode"
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)
}
}
}
}

View file

@ -27,17 +27,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.entity.Filter
import com.keylesspalace.tusky.entity.FilterV1
import com.keylesspalace.tusky.util.isHttpNotFound
import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
import com.keylesspalace.tusky.util.viewBinding
import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
import kotlinx.coroutines.launch
import retrofit2.HttpException
import javax.inject.Inject
import kotlinx.coroutines.launch
class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
@ -47,7 +50,9 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
@Inject
lateinit var eventHub: EventHub
private val binding: ActivityStatuslistBinding by viewBinding(ActivityStatuslistBinding::inflate)
private val binding: ActivityStatuslistBinding by viewBinding(
ActivityStatuslistBinding::inflate
)
private lateinit var kind: Kind
private var hashtag: String? = null
private var followTagItem: MenuItem? = null
@ -74,6 +79,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
Kind.FAVOURITES -> getString(R.string.title_favourites)
Kind.BOOKMARKS -> getString(R.string.title_bookmarks)
Kind.TAG -> getString(R.string.title_tag).format(hashtag)
Kind.PUBLIC_TRENDING_STATUSES -> getString(R.string.title_public_trending_statuses)
else -> intent.getStringExtra(EXTRA_LIST_TITLE)
}
@ -132,9 +138,19 @@ 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()
Snackbar.make(
binding.root,
getString(R.string.error_following_hashtag_format, tag),
Snackbar.LENGTH_SHORT
).show()
Log.e(TAG, "Failed to follow #$tag", it)
}
)
@ -152,9 +168,19 @@ 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()
Snackbar.make(
binding.root,
getString(R.string.error_unfollowing_hashtag_format, tag),
Snackbar.LENGTH_SHORT
).show()
Log.e(TAG, "Failed to unfollow #$tag", it)
}
)
@ -169,6 +195,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
*/
private fun updateMuteTagMenuItems() {
val tag = hashtag ?: return
val hashedTag = "#$tag"
muteTagItem?.isVisible = true
muteTagItem?.isEnabled = false
@ -178,18 +205,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)
},
@ -221,6 +247,9 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
val tag = hashtag ?: return true
lifecycleScope.launch {
var filterCreateSuccess = false
val hashedTag = "#$tag"
mastodonApi.createFilter(
title = "#$tag",
context = listOf(FilterV1.HOME),
@ -228,19 +257,31 @@ 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()
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,
@ -248,20 +289,50 @@ 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()
Snackbar.make(
binding.root,
getString(R.string.error_muting_hashtag_format, tag),
Snackbar.LENGTH_SHORT
).show()
Log.e(TAG, "Failed to mute #$tag", throwable)
}
)
} else {
Snackbar.make(binding.root, getString(R.string.error_muting_hashtag_format, tag), Snackbar.LENGTH_SHORT).show()
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 (filterCreateSuccess) {
updateTagMuteState(true)
Snackbar.make(
binding.root,
getString(R.string.muting_hashtag_success_format, tag),
Snackbar.LENGTH_LONG
).apply {
setAction(R.string.action_view_filter) {
val intent = if (mutedFilter != null) {
Intent(this@StatusListActivity, EditFilterActivity::class.java).apply {
putExtra(EditFilterActivity.FILTER_TO_EDIT, mutedFilter)
}
} else {
Intent(this@StatusListActivity, FiltersActivity::class.java)
}
startActivityWithSlideInAnimation(intent)
}
show()
}
}
}
return true
@ -307,9 +378,19 @@ 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()
Snackbar.make(
binding.root,
getString(R.string.error_unmuting_hashtag_format, tag),
Snackbar.LENGTH_SHORT
).show()
Log.e(TAG, "Failed to unmute #$tag", throwable)
}
)
@ -351,5 +432,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)
}
}
}

View file

@ -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 java.util.Objects
/** this would be a good case for a sealed class, but that does not work nice with Room */
@ -33,9 +33,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"
data class TabData(
val id: String,
@ -52,9 +54,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)
@ -94,11 +94,21 @@ fun createTabDataFromId(id: String, arguments: List<String> = 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,
@ -106,16 +116,31 @@ fun createTabDataFromId(id: String, arguments: List<String> = emptyList()): TabD
icon = R.drawable.ic_hashtag,
fragment = { args -> TimelineFragment.newHashtagInstance(args) },
arguments = arguments,
title = { context -> arguments.joinToString(separator = " ") { context.getString(R.string.title_tag, it) } }
title = { context ->
arguments.joinToString(separator = " ") {
context.getString(R.string.title_tag, it)
}
}
)
LIST -> TabData(
id = LIST,
text = R.string.list,
icon = R.drawable.ic_list,
fragment = { args -> TimelineFragment.newInstance(TimelineViewModel.Kind.LIST, args.getOrNull(0).orEmpty()) },
fragment = { args ->
TimelineFragment.newInstance(
TimelineViewModel.Kind.LIST,
args.getOrNull(0).orEmpty()
)
},
arguments = arguments,
title = { arguments.getOrNull(1).orEmpty() }
)
BOOKMARKS -> TabData(
id = BOOKMARKS,
text = R.string.title_bookmarks,
icon = R.drawable.ic_bookmark_active_24dp,
fragment = { TimelineFragment.newInstance(TimelineViewModel.Kind.BOOKMARKS) }
)
else -> throw IllegalArgumentException("unknown tab type")
}
}

View file

@ -15,18 +15,10 @@
package com.keylesspalace.tusky
import android.content.Intent
import android.graphics.Color
import android.os.Bundle
import android.util.Log
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.TextView
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.AppCompatEditText
@ -38,34 +30,29 @@ import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.transition.TransitionManager
import at.connyduck.calladapter.networkresult.fold
import at.connyduck.sparkbutton.helpers.Utils
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.transition.MaterialArcMotion
import com.google.android.material.transition.MaterialContainerTransform
import com.keylesspalace.tusky.adapter.ItemInteractionListener
import com.keylesspalace.tusky.adapter.TabAdapter
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.MainTabsChangedEvent
import com.keylesspalace.tusky.components.account.list.ListSelectionFragment
import com.keylesspalace.tusky.databinding.ActivityTabPreferenceBinding
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.MastoList
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.getDimension
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.unsafeLazy
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
import java.util.regex.Pattern
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListener {
class TabPreferenceActivity : BaseActivity(), Injectable, HasAndroidInjector, ItemInteractionListener, ListSelectionFragment.ListSelectionListener {
@Inject
lateinit var mastodonApi: MastodonApi
@ -73,6 +60,9 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
@Inject
lateinit var eventHub: EventHub
@Inject
lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector<Any>
private val binding by viewBinding(ActivityTabPreferenceBinding::inflate)
private lateinit var currentTabs: MutableList<TabData>
@ -82,9 +72,13 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
private var tabsChanged = false
private val selectedItemElevation by unsafeLazy { resources.getDimension(R.dimen.selected_drag_item_elevation) }
private val selectedItemElevation by unsafeLazy {
resources.getDimension(R.dimen.selected_drag_item_elevation)
}
private val hashtagRegex by unsafeLazy { Pattern.compile("([\\w_]*[\\p{Alpha}_][\\w_]*)", Pattern.CASE_INSENSITIVE) }
private val hashtagRegex by unsafeLazy {
Pattern.compile("([\\w_]*[\\p{Alpha}_][\\w_]*)", Pattern.CASE_INSENSITIVE)
}
private val onFabDismissedCallback = object : OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
@ -109,14 +103,19 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
currentTabsAdapter = TabAdapter(currentTabs, false, this, currentTabs.size <= MIN_TAB_COUNT)
binding.currentTabsRecyclerView.adapter = currentTabsAdapter
binding.currentTabsRecyclerView.layoutManager = LinearLayoutManager(this)
binding.currentTabsRecyclerView.addItemDecoration(DividerItemDecoration(this, LinearLayoutManager.VERTICAL))
binding.currentTabsRecyclerView.addItemDecoration(
DividerItemDecoration(this, LinearLayoutManager.VERTICAL)
)
addTabAdapter = TabAdapter(listOf(createTabDataFromId(DIRECT)), true, this)
binding.addTabRecyclerView.adapter = addTabAdapter
binding.addTabRecyclerView.layoutManager = LinearLayoutManager(this)
touchHelper = ItemTouchHelper(object : ItemTouchHelper.Callback() {
override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int {
override fun getMovementFlags(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder
): Int {
return makeMovementFlags(ItemTouchHelper.UP or ItemTouchHelper.DOWN, ItemTouchHelper.END)
}
@ -128,7 +127,11 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
return MIN_TAB_COUNT < currentTabs.size
}
override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean {
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
val temp = currentTabs[viewHolder.bindingAdapterPosition]
currentTabs[viewHolder.bindingAdapterPosition] = currentTabs[target.bindingAdapterPosition]
currentTabs[target.bindingAdapterPosition] = temp
@ -148,7 +151,10 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
}
}
override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
override fun clearView(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder
) {
super.clearView(recyclerView, viewHolder)
viewHolder.itemView.elevation = 0f
}
@ -164,18 +170,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) {
@ -273,81 +273,24 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
editText.requestFocus()
}
private var listSelectDialog: ListSelectionFragment? = null
private fun showSelectListDialog() {
val adapter = object : ArrayAdapter<MastoList>(this, android.R.layout.simple_list_item_1) {
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val view = super.getView(position, convertView, parent)
getItem(position)?.let { item -> (view as TextView).text = item.title }
return view
}
}
listSelectDialog = ListSelectionFragment.newInstance(null)
listSelectDialog?.show(supportFragmentManager, null)
val statusLayout = LinearLayout(this)
statusLayout.gravity = Gravity.CENTER
val progress = ProgressBar(this)
val preferredPadding = getDimension(this, androidx.appcompat.R.attr.dialogPreferredPadding)
progress.setPadding(preferredPadding, 0, preferredPadding, 0)
progress.visible(false)
val noListsText = TextView(this)
noListsText.setPadding(preferredPadding, 0, preferredPadding, 0)
noListsText.text = getText(R.string.select_list_empty)
noListsText.visible(false)
statusLayout.addView(progress)
statusLayout.addView(noListsText)
val dialogBuilder = AlertDialog.Builder(this)
.setTitle(R.string.select_list_title)
.setNeutralButton(R.string.select_list_manage) { _, _ ->
val listIntent = Intent(applicationContext, ListsActivity::class.java)
startActivity(listIntent)
}
.setNegativeButton(android.R.string.cancel, null)
.setView(statusLayout)
.setAdapter(adapter) { _, position ->
adapter.getItem(position)?.let { item ->
val newTab = createTabDataFromId(LIST, listOf(item.id, item.title))
currentTabs.add(newTab)
currentTabsAdapter.notifyItemInserted(currentTabs.size - 1)
updateAvailableTabs()
saveTabs()
}
}
val showProgressBarJob = getProgressBarJob(progress, 500)
showProgressBarJob.start()
val dialog = dialogBuilder.show()
lifecycleScope.launch {
mastodonApi.getLists().fold(
{ lists ->
showProgressBarJob.cancel()
adapter.addAll(lists)
if (lists.isEmpty()) {
noListsText.show()
}
},
{ throwable ->
dialog.hide()
Log.e("TabPreferenceActivity", "failed to load lists", throwable)
Snackbar.make(binding.root, R.string.error_list_load, Snackbar.LENGTH_LONG).show()
}
)
}
return
}
private fun getProgressBarJob(progressView: View, delayMs: Long) = this.lifecycleScope.launch(
start = CoroutineStart.LAZY
) {
try {
delay(delayMs)
progressView.show()
awaitCancellation()
} finally {
progressView.hide()
}
override fun onListSelected(list: MastoList) {
listSelectDialog?.dismiss()
listSelectDialog = null
val newTab = createTabDataFromId(LIST, listOf(list.id, list.title))
currentTabs.add(newTab)
currentTabsAdapter.notifyItemInserted(currentTabs.size - 1)
updateAvailableTabs()
saveTabs()
}
private fun validateHashtag(input: CharSequence?): Boolean {
@ -378,17 +321,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(bookmarksTab)) {
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)
}
@ -419,8 +368,9 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
}
}
override fun androidInjector() = dispatchingAndroidInjector
companion object {
private const val MIN_TAB_COUNT = 2
private const val MAX_TAB_COUNT = 5
}
}

View file

@ -22,12 +22,13 @@ import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
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
@ -37,11 +38,10 @@ import dagger.android.HasAndroidInjector
import de.c1710.filemojicompat_defaults.DefaultEmojiPackList
import de.c1710.filemojicompat_ui.helpers.EmojiPackHelper
import de.c1710.filemojicompat_ui.helpers.EmojiPreference
import io.reactivex.rxjava3.plugins.RxJavaPlugins
import org.conscrypt.Conscrypt
import java.security.Security
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import org.conscrypt.Conscrypt
class TuskyApplication : Application(), HasAndroidInjector {
@Inject
@ -71,12 +71,13 @@ class TuskyApplication : Application(), HasAndroidInjector {
Security.insertProviderAt(Conscrypt.newProvider(), 1)
AutoDisposePlugins.setHideProxies(false) // a small performance optimization
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,15 +88,11 @@ 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()
RxJavaPlugins.setErrorHandler {
Log.w("RxJava", "undeliverable exception", it)
}
NotificationHelper.createWorkerNotificationChannel(this)
WorkManager.initialize(
@ -130,6 +127,27 @@ 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)
}
}
if (oldVersion < 2023112001) {
editor.remove(PrefKeys.TAB_FILTER_HOME_REPLIES)
editor.remove(PrefKeys.TAB_FILTER_HOME_BOOSTS)
editor.remove(PrefKeys.TAB_SHOW_HOME_SELF_BOOSTS)
}
editor.putInt(PrefKeys.SCHEMA_VERSION, newVersion)
editor.apply()
}

View file

@ -35,19 +35,17 @@ import android.util.Log
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.WindowManager
import android.webkit.MimeTypeMap
import android.widget.Toast
import androidx.core.app.ShareCompat
import androidx.core.content.FileProvider
import androidx.core.content.IntentCompat
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.viewpager2.widget.ViewPager2
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider
import autodispose2.autoDispose
import com.bumptech.glide.Glide
import com.bumptech.glide.request.FutureTarget
import com.keylesspalace.tusky.BuildConfig.APPLICATION_ID
import com.keylesspalace.tusky.components.viewthread.ViewThreadActivity
import com.keylesspalace.tusky.databinding.ActivityViewMediaBinding
@ -57,20 +55,30 @@ import com.keylesspalace.tusky.fragment.ViewVideoFragment
import com.keylesspalace.tusky.pager.ImagePagerAdapter
import com.keylesspalace.tusky.pager.SingleImagePagerAdapter
import com.keylesspalace.tusky.util.getTemporaryMediaFilename
import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
import com.keylesspalace.tusky.util.submitAsync
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.viewdata.AttachmentViewData
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
import java.io.File
import java.io.FileNotFoundException
import java.io.FileOutputStream
import java.io.IOException
import java.util.Locale
import javax.inject.Inject
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
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<Any>
private val binding by viewBinding(ActivityViewMediaBinding::inflate)
@ -97,7 +105,11 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
supportPostponeEnterTransition()
// Gather the parameters.
attachments = IntentCompat.getParcelableArrayListExtra(intent, EXTRA_ATTACHMENTS, AttachmentViewData::class.java)
attachments = IntentCompat.getParcelableArrayListExtra(
intent,
EXTRA_ATTACHMENTS,
AttachmentViewData::class.java
)
val initialPosition = intent.getIntExtra(EXTRA_ATTACHMENT_INDEX, 0)
// Adapter is actually of existential type PageAdapter & SharedElementsTransitionListener
@ -119,6 +131,7 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
binding.viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) {
binding.toolbar.title = getPageTitle(position)
adjustScreenWakefulness()
}
})
@ -150,6 +163,8 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
window.sharedElementEnterTransition.removeListener(this)
}
})
adjustScreenWakefulness()
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
@ -206,7 +221,11 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
private fun downloadMedia() {
val url = imageUrl ?: attachments!![binding.viewPager.currentItem].attachment.url
val filename = Uri.parse(url).lastPathSegment
Toast.makeText(applicationContext, resources.getString(R.string.download_image, filename), Toast.LENGTH_SHORT).show()
Toast.makeText(
applicationContext,
resources.getString(R.string.download_image, filename),
Toast.LENGTH_SHORT
).show()
val downloadManager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
val request = DownloadManager.Request(Uri.parse(url))
@ -216,8 +235,13 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
private fun requestDownloadMedia() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
requestPermissions(arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE)) { _, grantResults ->
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
requestPermissions(
arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE)
) { _, grantResults ->
if (
grantResults.isNotEmpty() &&
grantResults[0] == PackageManager.PERMISSION_GRANTED
) {
downloadMedia()
} else {
showErrorDialog(
@ -234,7 +258,9 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
private fun onOpenStatus() {
val attach = attachments!![binding.viewPager.currentItem]
startActivityWithSlideInAnimation(ViewThreadActivity.startIntent(this, attach.statusId, attach.statusUrl))
startActivityWithSlideInAnimation(
ViewThreadActivity.startIntent(this, attach.statusId, attach.statusUrl)
)
}
private fun copyLink() {
@ -267,7 +293,9 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
private fun shareFile(file: File, mimeType: String?) {
ShareCompat.IntentBuilder(this)
.setType(mimeType)
.addStream(FileProvider.getUriForFile(applicationContext, "$APPLICATION_ID.fileprovider", file))
.addStream(
FileProvider.getUriForFile(applicationContext, "$APPLICATION_ID.fileprovider", file)
)
.setChooserTitle(R.string.send_media_to)
.startChooser()
}
@ -278,46 +306,37 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
isCreating = true
binding.progressBarShare.visibility = View.VISIBLE
invalidateOptionsMenu()
val file = File(directory, getTemporaryMediaFilename("png"))
val futureTask: FutureTarget<Bitmap> =
Glide.with(applicationContext).asBitmap().load(Uri.parse(url)).submit()
Single.fromCallable {
val bitmap = futureTask.get()
try {
val stream = FileOutputStream(file)
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
stream.close()
return@fromCallable true
} catch (fnfe: FileNotFoundException) {
Log.e(TAG, "Error writing temporary media.")
} catch (ioe: IOException) {
Log.e(TAG, "Error writing temporary media.")
}
return@fromCallable false
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.doOnDispose {
futureTask.cancel(true)
}
.autoDispose(AndroidLifecycleScopeProvider.from(this, Lifecycle.Event.ON_DESTROY))
.subscribe(
{ result ->
Log.d(TAG, "Download image result: $result")
isCreating = false
invalidateOptionsMenu()
binding.progressBarShare.visibility = View.GONE
if (result) {
shareFile(file, "image/png")
lifecycleScope.launch {
val file = File(directory, getTemporaryMediaFilename("png"))
val result = try {
val bitmap =
Glide.with(applicationContext).asBitmap().load(Uri.parse(url)).submitAsync()
try {
FileOutputStream(file).use { stream ->
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
}
},
{ error ->
isCreating = false
invalidateOptionsMenu()
binding.progressBarShare.visibility = View.GONE
Log.e(TAG, "Failed to download image", error)
true
} catch (ioe: IOException) {
// FileNotFoundException is covered by IOException
Log.e(TAG, "Error writing temporary media.")
false
}.also { result -> Log.d(TAG, "Download image result: $result") }
} catch (error: Throwable) {
if (error is CancellationException) {
throw error
}
)
Log.e(TAG, "Failed to download image", error)
false
}
isCreating = false
invalidateOptionsMenu()
binding.progressBarShare.visibility = View.GONE
if (result) {
shareFile(file, "image/png")
}
}
}
private fun shareMediaFile(directory: File, url: String) {
@ -337,6 +356,19 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
shareFile(file, mimeType)
}
// Prevent this activity from dimming or sleeping the screen if, and only if, it is playing video or audio
private fun adjustScreenWakefulness() {
attachments?.run {
if (get(binding.viewPager.currentItem).attachment.type == Attachment.Type.IMAGE) {
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
}
override fun androidInjector() = androidInjector
companion object {
private const val EXTRA_ATTACHMENTS = "attachments"
private const val EXTRA_ATTACHMENT_INDEX = "index"
@ -344,7 +376,11 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
private const val TAG = "ViewMediaActivity"
@JvmStatic
fun newIntent(context: Context?, attachments: List<AttachmentViewData>, index: Int): Intent {
fun newIntent(
context: Context?,
attachments: List<AttachmentViewData>,
index: Int
): Intent {
val intent = Intent(context, ViewMediaActivity::class.java)
intent.putParcelableArrayListExtra(EXTRA_ATTACHMENTS, ArrayList(attachments))
intent.putExtra(EXTRA_ATTACHMENT_INDEX, index)

View file

@ -24,7 +24,9 @@ import com.keylesspalace.tusky.entity.StringField
import com.keylesspalace.tusky.util.BindingHolder
import com.keylesspalace.tusky.util.fixTextSelection
class AccountFieldEditAdapter : RecyclerView.Adapter<BindingHolder<ItemEditFieldBinding>>() {
class AccountFieldEditAdapter(
var onFieldsChanged: () -> Unit = { }
) : RecyclerView.Adapter<BindingHolder<ItemEditFieldBinding>>() {
private val fieldData = mutableListOf<MutableStringPair>()
private var maxNameLength: Int? = null
@ -62,8 +64,15 @@ class AccountFieldEditAdapter : RecyclerView.Adapter<BindingHolder<ItemEditField
override fun getItemCount() = fieldData.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemEditFieldBinding> {
val binding = ItemEditFieldBinding.inflate(LayoutInflater.from(parent.context), parent, false)
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): BindingHolder<ItemEditFieldBinding> {
val binding = ItemEditFieldBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
return BindingHolder(binding)
}
@ -83,10 +92,12 @@ class AccountFieldEditAdapter : RecyclerView.Adapter<BindingHolder<ItemEditField
holder.binding.accountFieldNameText.doAfterTextChanged { newText ->
fieldData.getOrNull(holder.bindingAdapterPosition)?.first = newText.toString()
onFieldsChanged()
}
holder.binding.accountFieldValueText.doAfterTextChanged { newText ->
fieldData.getOrNull(holder.bindingAdapterPosition)?.second = newText.toString()
onFieldsChanged()
}
// Ensure the textview contents are selectable

View file

@ -28,7 +28,10 @@ import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.loadAvatar
class AccountSelectionAdapter(context: Context) : ArrayAdapter<AccountEntity>(context, R.layout.item_autocomplete_account) {
class AccountSelectionAdapter(context: Context) : ArrayAdapter<AccountEntity>(
context,
R.layout.item_autocomplete_account
) {
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val binding = if (convertView == null) {
@ -47,7 +50,7 @@ class AccountSelectionAdapter(context: Context) : ArrayAdapter<AccountEntity>(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)
}

View file

@ -31,13 +31,20 @@ class EmojiAdapter(
private val animate: Boolean
) : RecyclerView.Adapter<BindingHolder<ItemEmojiButtonBinding>>() {
private val emojiList: List<Emoji> = emojiList.filter { emoji -> emoji.visibleInPicker == null || emoji.visibleInPicker }
private val emojiList: List<Emoji> = emojiList.filter { emoji -> emoji.visibleInPicker }
.sortedBy { it.shortcode.lowercase(Locale.ROOT) }
override fun getItemCount() = emojiList.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemEmojiButtonBinding> {
val binding = ItemEmojiButtonBinding.inflate(LayoutInflater.from(parent.context), parent, false)
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): BindingHolder<ItemEmojiButtonBinding> {
val binding = ItemEmojiButtonBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
return BindingHolder(binding)
}

View file

@ -16,17 +16,15 @@
package com.keylesspalace.tusky.adapter
import android.graphics.Typeface
import android.text.SpannableStringBuilder
import android.text.SpannableString
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,
@ -72,7 +49,7 @@ class FollowRequestViewHolder(
val wrappedName = account.name.unicodeWrap()
val emojifiedName: CharSequence = wrappedName.emojify(
account.emojis,
itemView,
binding.displayNameTextView,
animateEmojis
)
binding.displayNameTextView.text = emojifiedName
@ -81,17 +58,15 @@ class FollowRequestViewHolder(
R.string.notification_follow_request_format,
wrappedName
)
binding.notificationTextView.text = SpannableStringBuilder(wholeMessage).apply {
setSpan(
StyleSpan(Typeface.BOLD),
0,
wrappedName.length,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
}.emojify(account.emojis, itemView, animateEmojis)
binding.notificationTextView.text = SpannableString(wholeMessage).apply {
setSpan(StyleSpan(Typeface.BOLD), 0, wrappedName.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}.emojify(account.emojis, binding.notificationTextView, animateEmojis)
}
binding.notificationTextView.visible(showHeader)
val formattedUsername = itemView.context.getString(R.string.post_username_format, account.username)
val formattedUsername = itemView.context.getString(
R.string.post_username_format,
account.username
)
binding.usernameTextView.text = formattedUsername
if (account.note.isEmpty()) {
binding.accountNote.hide()
@ -102,7 +77,9 @@ class FollowRequestViewHolder(
.emojify(account.emojis, binding.accountNote, animateEmojis)
setClickableText(binding.accountNote, emojifiedNote, emptyList(), null, linkListener)
}
val avatarRadius = binding.avatar.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp)
val avatarRadius = binding.avatar.context.resources.getDimensionPixelSize(
R.dimen.avatar_radius_48dp
)
loadAvatar(account.avatar, binding.avatar, avatarRadius, animateAvatar)
binding.avatarBadge.visible(showBotOverlay && account.bot)
}

View file

@ -26,7 +26,11 @@ import com.keylesspalace.tusky.util.getTuskyDisplayName
import com.keylesspalace.tusky.util.modernLanguageCode
import java.util.Locale
class LocaleAdapter(context: Context, resource: Int, locales: List<Locale>) : ArrayAdapter<Locale>(context, resource, locales) {
class LocaleAdapter(context: Context, resource: Int, locales: List<Locale>) : ArrayAdapter<Locale>(
context,
resource,
locales
) {
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
return (super.getView(position, convertView, parent) as TextView).apply {
setTextColor(MaterialColors.getColor(this, android.R.attr.textColorTertiary))

View file

@ -0,0 +1,708 @@
/* Copyright 2021 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
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<RecyclerView.ViewHolder> implements LinkListener{
public interface AdapterDataSource<T> {
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<NotificationViewData> dataSource;
private final AbsoluteTimeFormatter absoluteTimeFormatter = new AbsoluteTimeFormatter();
public NotificationsAdapter(String accountId,
AdapterDataSource<NotificationViewData> 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<Object> payloads) {
bindViewHolder(viewHolder, position, payloads);
}
private void bindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position, @Nullable List<Object> payloads) {
Object payloadForHolder = payloads != null && !payloads.isEmpty() ? payloads.get(0) : null;
if (position < this.dataSource.getItemCount()) {
NotificationViewData notification = dataSource.getItemAt(position);
if (notification instanceof NotificationViewData.Placeholder) {
if (payloadForHolder == null) {
NotificationViewData.Placeholder placeholder = ((NotificationViewData.Placeholder) notification);
PlaceholderViewHolder holder = (PlaceholderViewHolder) viewHolder;
holder.setup(statusListener, placeholder.isLoading());
}
return;
}
NotificationViewData.Concrete concreteNotification =
(NotificationViewData.Concrete) notification;
switch (viewHolder.getItemViewType()) {
case VIEW_TYPE_STATUS: {
StatusViewHolder holder = (StatusViewHolder) viewHolder;
StatusViewData.Concrete status = concreteNotification.getStatusViewData();
if (status == null) {
/* in some very rare cases servers sends null status even though they should not,
* we have to handle it somehow */
holder.showStatusContent(false);
} else {
if (payloads == null) {
holder.showStatusContent(true);
}
holder.setupWithStatus(status, statusListener, statusDisplayOptions, payloadForHolder);
}
if (concreteNotification.getType() == Notification.Type.POLL) {
holder.setPollInfo(accountId.equals(concreteNotification.getAccount().getId()));
} else {
holder.hideStatusInfo();
}
break;
}
case VIEW_TYPE_STATUS_NOTIFICATION: {
StatusNotificationViewHolder holder = (StatusNotificationViewHolder) viewHolder;
StatusViewData.Concrete statusViewData = concreteNotification.getStatusViewData();
if (payloadForHolder == null) {
if (statusViewData == null) {
/* in some very rare cases servers sends null status even though they should not,
* we have to handle it somehow */
holder.showNotificationContent(false);
} else {
holder.showNotificationContent(true);
Status status = statusViewData.getActionable();
holder.setDisplayName(status.getAccount().getDisplayName(), status.getAccount().getEmojis());
holder.setUsername(status.getAccount().getUsername());
holder.setCreatedAt(status.getCreatedAt());
if (concreteNotification.getType() == Notification.Type.STATUS ||
concreteNotification.getType() == Notification.Type.UPDATE) {
holder.setAvatar(status.getAccount().getAvatar(), status.getAccount().getBot());
} else {
holder.setAvatars(status.getAccount().getAvatar(),
concreteNotification.getAccount().getAvatar());
}
}
holder.setMessage(concreteNotification, statusListener);
holder.setupButtons(notificationActionListener,
concreteNotification.getAccount().getId(),
concreteNotification.getId());
} else {
if (payloadForHolder instanceof List)
for (Object item : (List<?>) payloadForHolder) {
if (StatusBaseViewHolder.Key.KEY_CREATED.equals(item) && statusViewData != null) {
holder.setCreatedAt(statusViewData.getStatus().getActionableStatus().getCreatedAt());
}
}
}
break;
}
case VIEW_TYPE_FOLLOW: {
if (payloadForHolder == null) {
FollowViewHolder holder = (FollowViewHolder) viewHolder;
holder.setMessage(concreteNotification.getAccount(), concreteNotification.getType() == Notification.Type.SIGN_UP);
holder.setupButtons(notificationActionListener, concreteNotification.getAccount().getId());
}
break;
}
case VIEW_TYPE_FOLLOW_REQUEST: {
if (payloadForHolder == null) {
FollowRequestViewHolder holder = (FollowRequestViewHolder) viewHolder;
holder.setupWithAccount(concreteNotification.getAccount(), statusDisplayOptions.animateAvatars(), statusDisplayOptions.animateEmojis(), statusDisplayOptions.showBotOverlay());
holder.setupActionListener(accountActionListener, concreteNotification.getAccount().getId());
}
break;
}
case VIEW_TYPE_REPORT: {
if (payloadForHolder == null) {
ReportNotificationViewHolder holder = (ReportNotificationViewHolder) viewHolder;
holder.setupWithReport(concreteNotification.getAccount(), concreteNotification.getReport(), statusDisplayOptions.animateAvatars(), statusDisplayOptions.animateEmojis());
holder.setupActionListener(notificationActionListener, concreteNotification.getReport().getTargetAccount().getId(), concreteNotification.getAccount().getId(), concreteNotification.getReport().getId());
}
}
default:
}
}
}
@Override
public int getItemCount() {
return dataSource.getItemCount();
}
public void setMediaPreviewEnabled(boolean mediaPreviewEnabled) {
this.statusDisplayOptions = statusDisplayOptions.copy(
statusDisplayOptions.animateAvatars(),
mediaPreviewEnabled,
statusDisplayOptions.useAbsoluteTime(),
statusDisplayOptions.showBotOverlay(),
statusDisplayOptions.useBlurhash(),
CardViewMode.NONE,
statusDisplayOptions.confirmReblogs(),
statusDisplayOptions.confirmFavourites(),
statusDisplayOptions.hideStats(),
statusDisplayOptions.animateEmojis(),
statusDisplayOptions.showStatsInline(),
statusDisplayOptions.showSensitiveMedia(),
statusDisplayOptions.openSpoiler()
);
}
public boolean isMediaPreviewEnabled() {
return this.statusDisplayOptions.mediaPreviewEnabled();
}
@Override
public int getItemViewType(int position) {
NotificationViewData notification = dataSource.getItemAt(position);
if (notification instanceof NotificationViewData.Concrete) {
NotificationViewData.Concrete concrete = ((NotificationViewData.Concrete) notification);
switch (concrete.getType()) {
case MENTION:
case POLL: {
return VIEW_TYPE_STATUS;
}
case STATUS:
case FAVOURITE:
case REBLOG:
case UPDATE: {
return VIEW_TYPE_STATUS_NOTIFICATION;
}
case FOLLOW:
case SIGN_UP: {
return VIEW_TYPE_FOLLOW;
}
case FOLLOW_REQUEST: {
return VIEW_TYPE_FOLLOW_REQUEST;
}
case REPORT: {
return VIEW_TYPE_REPORT;
}
default: {
return VIEW_TYPE_UNKNOWN;
}
}
} else if (notification instanceof NotificationViewData.Placeholder) {
return VIEW_TYPE_PLACEHOLDER;
} else {
throw new AssertionError("Unknown notification type");
}
}
public interface NotificationActionListener {
void onViewAccount(String id);
void onViewStatusForNotificationId(String notificationId);
void onViewReport(String reportId);
void onExpandedChange(boolean expanded, int position);
/**
* Called when the status {@link android.widget.ToggleButton} responsible for collapsing long
* status content is interacted with.
*
* @param isCollapsed Whether the status content is shown in a collapsed state or fully.
* @param position The position of the status in the list.
*/
void onNotificationContentCollapsedChange(boolean isCollapsed, int position);
}
private static class FollowViewHolder extends RecyclerView.ViewHolder {
private final TextView message;
private final TextView usernameView;
private final TextView displayNameView;
private final ImageView avatar;
private final StatusDisplayOptions statusDisplayOptions;
FollowViewHolder(View itemView, StatusDisplayOptions statusDisplayOptions) {
super(itemView);
message = itemView.findViewById(R.id.notification_text);
usernameView = itemView.findViewById(R.id.notification_username);
displayNameView = itemView.findViewById(R.id.notification_display_name);
avatar = itemView.findViewById(R.id.notification_avatar);
this.statusDisplayOptions = statusDisplayOptions;
}
void setMessage(TimelineAccount account, Boolean isSignUp) {
Context context = message.getContext();
String format = context.getString(isSignUp ? R.string.notification_sign_up_format : R.string.notification_follow_format);
String wrappedDisplayName = StringUtils.unicodeWrap(account.getName());
String wholeMessage = String.format(format, wrappedDisplayName);
CharSequence emojifiedMessage = CustomEmojiHelper.emojify(
wholeMessage, account.getEmojis(), message, statusDisplayOptions.animateEmojis()
);
message.setText(emojifiedMessage);
String username = context.getString(R.string.post_username_format, account.getUsername());
usernameView.setText(username);
CharSequence emojifiedDisplayName = CustomEmojiHelper.emojify(
wrappedDisplayName, account.getEmojis(), usernameView, statusDisplayOptions.animateEmojis()
);
displayNameView.setText(emojifiedDisplayName);
int avatarRadius = avatar.getContext().getResources()
.getDimensionPixelSize(R.dimen.avatar_radius_42dp);
ImageLoadingHelper.loadAvatar(account.getAvatar(), avatar, avatarRadius,
statusDisplayOptions.animateAvatars(), null);
}
void setupButtons(final NotificationActionListener listener, final String accountId) {
itemView.setOnClickListener(v -> listener.onViewAccount(accountId));
}
}
private static class StatusNotificationViewHolder extends RecyclerView.ViewHolder
implements View.OnClickListener {
private final View container;
private final TextView message;
// private final View statusNameBar;
private final TextView displayName;
private final TextView username;
private final TextView timestampInfo;
private final TextView statusContent;
private final ImageView statusAvatar;
private final ImageView notificationAvatar;
private final TextView contentWarningDescriptionTextView;
private final Button contentWarningButton;
private final Button contentCollapseButton; // TODO: This code SHOULD be based on StatusBaseViewHolder
private final StatusDisplayOptions statusDisplayOptions;
private final AbsoluteTimeFormatter absoluteTimeFormatter;
private String accountId;
private String notificationId;
private NotificationActionListener notificationActionListener;
private StatusViewData.Concrete statusViewData;
private final int avatarRadius48dp;
private final int avatarRadius36dp;
private final int avatarRadius24dp;
StatusNotificationViewHolder(
View itemView,
StatusDisplayOptions statusDisplayOptions,
AbsoluteTimeFormatter absoluteTimeFormatter
) {
super(itemView);
message = itemView.findViewById(R.id.notification_top_text);
// statusNameBar = itemView.findViewById(R.id.status_name_bar);
displayName = itemView.findViewById(R.id.status_display_name);
username = itemView.findViewById(R.id.status_username);
timestampInfo = itemView.findViewById(R.id.status_meta_info);
statusContent = itemView.findViewById(R.id.notification_content);
statusAvatar = itemView.findViewById(R.id.notification_status_avatar);
notificationAvatar = itemView.findViewById(R.id.notification_notification_avatar);
contentWarningDescriptionTextView = itemView.findViewById(R.id.notification_content_warning_description);
contentWarningButton = itemView.findViewById(R.id.notification_content_warning_button);
contentCollapseButton = itemView.findViewById(R.id.button_toggle_notification_content);
container = itemView.findViewById(R.id.notification_container);
this.statusDisplayOptions = statusDisplayOptions;
this.absoluteTimeFormatter = absoluteTimeFormatter;
int darkerFilter = Color.rgb(123, 123, 123);
statusAvatar.setColorFilter(darkerFilter, PorterDuff.Mode.MULTIPLY);
notificationAvatar.setColorFilter(darkerFilter, PorterDuff.Mode.MULTIPLY);
itemView.setOnClickListener(this);
message.setOnClickListener(this);
statusContent.setOnClickListener(this);
this.avatarRadius48dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_48dp);
this.avatarRadius36dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_36dp);
this.avatarRadius24dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_24dp);
}
private void showNotificationContent(boolean show) {
// statusNameBar.setVisibility(show ? View.VISIBLE : View.GONE);
contentWarningDescriptionTextView.setVisibility(show ? View.VISIBLE : View.GONE);
contentWarningButton.setVisibility(show ? View.VISIBLE : View.GONE);
statusContent.setVisibility(show ? View.VISIBLE : View.GONE);
statusAvatar.setVisibility(show ? View.VISIBLE : View.GONE);
notificationAvatar.setVisibility(show ? View.VISIBLE : View.GONE);
}
private void setDisplayName(String name, List<Emoji> 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<Emoji> 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) {
}
}

View file

@ -67,7 +67,10 @@ class PollAdapter : RecyclerView.Adapter<BindingHolder<ItemPollBinding>>() {
.map { pollOptions.indexOf(it) }
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemPollBinding> {
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): BindingHolder<ItemPollBinding> {
val binding = ItemPollBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return BindingHolder(binding)
}

View file

@ -40,7 +40,11 @@ class PreviewPollOptionsAdapter : RecyclerView.Adapter<PreviewViewHolder>() {
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PreviewViewHolder {
return PreviewViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.item_poll_preview_option, parent, false))
return PreviewViewHolder(
LayoutInflater.from(
parent.context
).inflate(R.layout.item_poll_preview_option, parent, false)
)
}
override fun getItemCount() = options.size

View file

@ -20,76 +20,33 @@ 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) {
private val binding: ItemReportNotificationBinding
) : 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(
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)
val reporterName = reporter.name.unicodeWrap().emojify(reporter.emojis, binding.notificationTopText, animateEmojis)
val reporteeName = report.targetAccount.name.unicodeWrap().emojify(report.targetAccount.emojis, binding.notificationTopText, 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, System.currentTimeMillis()), report.statusIds?.size ?: 0)
binding.notificationCategory.text = getTranslatedCategory(itemView.context, report.category)
// Fancy avatar inset
@ -110,7 +67,7 @@ class ReportNotificationViewHolder(
)
}
private fun setupActionListener(
fun setupActionListener(
listener: NotificationActionListener,
reporteeId: String,
reporterId: String,

View file

@ -48,13 +48,16 @@ import com.keylesspalace.tusky.entity.Filter;
import com.keylesspalace.tusky.entity.FilterResult;
import com.keylesspalace.tusky.entity.HashTag;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.entity.Translation;
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;
import com.keylesspalace.tusky.util.LocaleUtilsKt;
import com.keylesspalace.tusky.util.NumberUtils;
import com.keylesspalace.tusky.util.StatusDisplayOptions;
import com.keylesspalace.tusky.util.TimestampUtils;
@ -65,10 +68,13 @@ import com.keylesspalace.tusky.viewdata.PollOptionViewData;
import com.keylesspalace.tusky.viewdata.PollViewData;
import com.keylesspalace.tusky.viewdata.PollViewDataKt;
import com.keylesspalace.tusky.viewdata.StatusViewData;
import com.keylesspalace.tusky.viewdata.TranslationViewData;
import java.text.NumberFormat;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import at.connyduck.sparkbutton.SparkButton;
import at.connyduck.sparkbutton.helpers.Utils;
@ -114,10 +120,13 @@ 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 TextView translationStatusView;
private final Button untranslateButton;
private final NumberFormat numberFormat = NumberFormat.getNumberInstance();
private final AbsoluteTimeFormatter absoluteTimeFormatter = new AbsoluteTimeFormatter();
@ -128,7 +137,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);
@ -149,10 +158,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
sensitiveMediaWarning = itemView.findViewById(R.id.status_sensitive_media_warning);
sensitiveMediaShow = itemView.findViewById(R.id.status_sensitive_media_button);
mediaLabels = new TextView[]{
itemView.findViewById(R.id.status_media_label_0),
itemView.findViewById(R.id.status_media_label_1),
itemView.findViewById(R.id.status_media_label_2),
itemView.findViewById(R.id.status_media_label_3)
itemView.findViewById(R.id.status_media_label_0),
itemView.findViewById(R.id.status_media_label_1),
itemView.findViewById(R.id.status_media_label_2),
itemView.findViewById(R.id.status_media_label_3)
};
mediaDescriptions = new CharSequence[mediaLabels.length];
contentWarningDescription = itemView.findViewById(R.id.status_content_warning_description);
@ -180,6 +189,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
pollOptions.setLayoutManager(new LinearLayoutManager(pollOptions.getContext()));
((DefaultItemAnimator) pollOptions.getItemAnimator()).setSupportsChangeAnimations(false);
translationStatusView = itemView.findViewById(R.id.status_translation_status);
untranslateButton = itemView.findViewById(R.id.status_button_untranslate);
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);
@ -189,14 +201,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<Emoji> customEmojis, StatusDisplayOptions statusDisplayOptions) {
protected void setDisplayName(@NonNull String name, @Nullable List<Emoji> customEmojis, @NonNull StatusDisplayOptions statusDisplayOptions) {
CharSequence emojifiedName = CustomEmojiHelper.emojify(
name, customEmojis, displayName, statusDisplayOptions.animateEmojis()
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);
@ -208,7 +220,7 @@ 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();
@ -219,7 +231,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
if (sensitive) {
CharSequence emojiSpoiler = CustomEmojiHelper.emojify(
spoilerText, emojis, contentWarningDescription, statusDisplayOptions.animateEmojis()
spoilerText, emojis, contentWarningDescription, statusDisplayOptions.animateEmojis()
);
contentWarningDescription.setText(emojiSpoiler);
contentWarningDescription.setVisibility(View.VISIBLE);
@ -269,9 +281,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
Status actionable = status.getActionable();
Spanned content = status.getContent();
List<Status.Mention> mentions = actionable.getMentions();
List<HashTag> tags =actionable.getTags();
List<HashTag> tags = actionable.getTags();
List<Emoji> emojis = actionable.getEmojis();
PollViewData poll = PollViewDataKt.toViewData(actionable.getPoll());
PollViewData poll = PollViewDataKt.toViewData(status.getPoll());
if (expanded) {
CharSequence emojifiedText = CustomEmojiHelper.emojify(content, emojis, this.content, statusDisplayOptions.animateEmojis());
@ -302,7 +314,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
}
private void setAvatar(String url,
@Nullable String rebloggedUrl,
@Nullable String rebloggedUrl,
boolean isBot,
StatusDisplayOptions statusDisplayOptions) {
@ -313,8 +325,8 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
if (statusDisplayOptions.showBotOverlay() && isBot) {
avatarInset.setVisibility(View.VISIBLE);
Glide.with(avatarInset)
.load(R.drawable.bot_badge)
.into(avatarInset);
.load(R.drawable.bot_badge)
.into(avatarInset);
} else {
avatarInset.setVisibility(View.GONE);
}
@ -328,17 +340,21 @@ 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());
ImageLoadingHelper.loadAvatar(
url,
avatar,
avatarRadius,
statusDisplayOptions.animateAvatars(),
Collections.singletonList(new CompositeWithOpaqueBackground(MaterialColors.getColor(avatar, android.R.attr.colorBackground)))
);
}
protected void setMetaData(StatusViewData.Concrete statusViewData, StatusDisplayOptions statusDisplayOptions, StatusActionListener listener) {
protected void setMetaData(@NonNull StatusViewData.Concrete statusViewData, @NonNull StatusDisplayOptions statusDisplayOptions, @NonNull StatusActionListener listener) {
Status status = statusViewData.getActionable();
Date createdAt = status.getCreatedAt();
@ -377,8 +393,8 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
long then = createdAt.getTime();
long now = System.currentTimeMillis();
return DateUtils.getRelativeTimeSpanString(then, now,
DateUtils.SECOND_IN_MILLIS,
DateUtils.FORMAT_ABBREV_RELATIVE);
DateUtils.SECOND_IN_MILLIS,
DateUtils.FORMAT_ABBREV_RELATIVE);
}
}
}
@ -461,9 +477,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
imageView.removeFocalPoint();
Glide.with(imageView)
.load(placeholder)
.centerInside()
.into(imageView);
.load(placeholder)
.centerInside()
.into(imageView);
} else {
Focus focus = meta != null ? meta.getFocus() : null;
@ -471,29 +487,29 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
imageView.setFocalPoint(focus);
Glide.with(imageView.getContext())
.load(previewUrl)
.placeholder(placeholder)
.centerInside()
.addListener(imageView)
.into(imageView);
.load(previewUrl)
.placeholder(placeholder)
.centerInside()
.addListener(imageView)
.into(imageView);
} else {
imageView.removeFocalPoint();
Glide.with(imageView)
.load(previewUrl)
.placeholder(placeholder)
.centerInside()
.into(imageView);
.load(previewUrl)
.placeholder(placeholder)
.centerInside()
.into(imageView);
}
}
}
protected void setMediaPreviews(
final List<Attachment> attachments,
boolean sensitive,
final StatusActionListener listener,
boolean showingContent,
boolean useBlurhash
final @NonNull List<Attachment> attachments,
boolean sensitive,
final @NonNull StatusActionListener listener,
boolean showingContent,
boolean useBlurhash
) {
mediaPreview.setVisibility(View.VISIBLE);
@ -512,10 +528,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
}
loadImage(
imageView,
showingContent ? previewUrl : null,
attachment.getMeta(),
useBlurhash ? attachment.getBlurhash() : null
imageView,
showingContent ? previewUrl : null,
attachment.getMeta(),
useBlurhash ? attachment.getBlurhash() : null
);
final Attachment.Type type = attachment.getType();
@ -577,13 +593,13 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
private void updateMediaLabel(int index, boolean sensitive, boolean showingContent) {
Context context = itemView.getContext();
CharSequence label = (sensitive && !showingContent) ?
context.getString(R.string.post_sensitive_media_title) :
mediaDescriptions[index];
context.getString(R.string.post_sensitive_media_title) :
mediaDescriptions[index];
mediaLabels[index].setText(label);
}
protected void setMediaLabel(List<Attachment> attachments, boolean sensitive,
final StatusActionListener listener, boolean showingContent) {
protected void setMediaLabel(@NonNull List<Attachment> 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];
@ -604,7 +620,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();
@ -628,10 +644,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
sensitiveMediaShow.setVisibility(View.GONE);
}
protected void setupButtons(final StatusActionListener listener,
final String accountId,
final String statusContent,
StatusDisplayOptions statusDisplayOptions) {
protected void setupButtons(final @NonNull StatusActionListener listener,
final @NonNull String accountId,
final @Nullable String statusContent,
@NonNull StatusDisplayOptions statusDisplayOptions) {
View.OnClickListener profileButtonClickListener = button -> listener.onViewAccount(accountId);
avatar.setOnClickListener(profileButtonClickListener);
@ -721,8 +737,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
}
popup.setOnMenuItemClickListener(item -> {
listener.onReblog(!buttonState, position);
if(!buttonState) {
if (!buttonState) {
reblogButton.playAnimation();
reblogButton.setChecked(true);
}
return true;
});
@ -742,16 +759,17 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
}
popup.setOnMenuItemClickListener(item -> {
listener.onFavourite(!buttonState, position);
if(!buttonState) {
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);
}
@ -762,16 +780,16 @@ 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());
setAvatar(actionable.getAccount().getAvatar(), status.getRebloggedAvatar(),
actionable.getAccount().getBot(), statusDisplayOptions);
actionable.getAccount().getBot(), statusDisplayOptions);
setReblogged(actionable.getReblogged());
setFavourited(actionable.getFavourited());
setBookmarked(actionable.getBookmarked());
List<Attachment> attachments = actionable.getAttachments();
List<Attachment> attachments = status.getAttachments();
boolean sensitive = actionable.getSensitive();
if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) {
setMediaPreviews(attachments, sensitive, listener, status.isShowingContent(), statusDisplayOptions.useBlurhash());
@ -793,8 +811,11 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
setupCard(status, status.isExpanded(), statusDisplayOptions.cardViewMode(), statusDisplayOptions, listener);
setupButtons(listener, actionable.getAccount().getId(), status.getContent().toString(),
statusDisplayOptions);
setRebloggingEnabled(actionable.rebloggingAllowed(), actionable.getVisibility());
statusDisplayOptions);
setTranslationStatus(status, listener);
setRebloggingEnabled(actionable.isRebloggingAllowed(), actionable.getVisibility());
setSpoilerAndContent(status, statusDisplayOptions, listener);
@ -819,6 +840,29 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
}
}
private void setTranslationStatus(StatusViewData.Concrete status, StatusActionListener listener) {
var translationViewData = status.getTranslation();
if (translationViewData != null) {
if (translationViewData instanceof TranslationViewData.Loaded) {
Translation translation = ((TranslationViewData.Loaded) translationViewData).getData();
translationStatusView.setVisibility(View.VISIBLE);
var langName = LocaleUtilsKt.localeNameForUntrustedISO639LangCode(translation.getDetectedSourceLanguage());
translationStatusView.setText(translationStatusView.getContext().getString(R.string.label_translated, langName, translation.getProvider()));
untranslateButton.setVisibility(View.VISIBLE);
untranslateButton.setOnClickListener((v) -> listener.onUntranslate(getBindingAdapterPosition()));
} else {
translationStatusView.setVisibility(View.VISIBLE);
translationStatusView.setText(R.string.label_translating);
untranslateButton.setVisibility(View.GONE);
untranslateButton.setOnClickListener(null);
}
} else {
translationStatusView.setVisibility(View.GONE);
untranslateButton.setVisibility(View.GONE);
untranslateButton.setOnClickListener(null);
}
}
private void setupFilterPlaceholder(StatusViewData.Concrete status, StatusActionListener listener, StatusDisplayOptions displayOptions) {
if (status.getFilterAction() != Filter.Action.WARN) {
showFilteredPlaceholder(false);
@ -838,12 +882,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<Attachment> attachments) {
protected static boolean hasPreviewableAttachment(@NonNull List<Attachment> attachments) {
for (Attachment attachment : attachments) {
if (attachment.getType() == Attachment.Type.AUDIO || attachment.getType() == Attachment.Type.UNKNOWN) {
return false;
@ -858,54 +900,84 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
Status actionable = status.getActionable();
String description = context.getString(R.string.description_status,
actionable.getAccount().getDisplayName(),
getContentWarningDescription(context, status),
(TextUtils.isEmpty(status.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.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) : "",
getMediaDescription(context, status),
getVisibilityDescription(context, actionable.getVisibility()),
getFavsText(context, actionable.getFavouritesCount()),
getReblogsText(context, actionable.getReblogsCount()),
getPollDescription(status, context, statusDisplayOptions)
// 1 display_name
actionable.getAccount().getDisplayName(),
// 2 CW?
getContentWarningDescription(context, status),
// 3 content?
(TextUtils.isEmpty(status.getSpoilerText()) || !actionable.getSensitive() || status.isExpanded() ? status.getContent() : ""),
// 4 date
getCreatedAtDescription(actionable.getCreatedAt(), statusDisplayOptions),
// 5 edited?
actionable.getEditedAt() != null ? context.getString(R.string.description_post_edited) : "",
// 6 reposted_by?
getReblogDescription(context, status),
// 7 username
actionable.getAccount().getUsername(),
// 8 reposted
actionable.getReblogged() ? context.getString(R.string.description_post_reblogged) : "",
// 9 favorited
actionable.getFavourited() ? context.getString(R.string.description_post_favourited) : "",
// 10 bookmarked
actionable.getBookmarked() ? context.getString(R.string.description_post_bookmarked) : "",
// 11 media
getMediaDescription(context, status),
// 12 visibility
getVisibilityDescription(context, actionable.getVisibility()),
// 13 fav_number
getFavsText(context, actionable.getFavouritesCount()),
// 14 reblog_number
getReblogsText(context, actionable.getReblogsCount()),
// 15 poll?
getPollDescription(status, context, statusDisplayOptions),
// 16 translated?
getTranslatedDescription(context, status.getTranslation())
);
itemView.setContentDescription(description);
}
private String getTranslatedDescription(Context context, TranslationViewData translationViewData) {
if (translationViewData == null) {
return "";
} else if (translationViewData instanceof TranslationViewData.Loading) {
return context.getString(R.string.label_translating);
} else {
Translation translation = ((TranslationViewData.Loaded) translationViewData).getData();
var langName = LocaleUtilsKt.localeNameForUntrustedISO639LangCode(translation.getDetectedSourceLanguage());
return context.getString(R.string.label_translated, langName, translation.getProvider());
}
}
private static CharSequence getReblogDescription(Context context,
@NonNull StatusViewData.Concrete status) {
@Nullable
Status reblog = status.getRebloggingStatus();
if (reblog != null) {
return context
.getString(R.string.post_boosted_format, reblog.getAccount().getUsername());
.getString(R.string.post_boosted_format, reblog.getAccount().getUsername());
} else {
return "";
}
}
private static CharSequence getMediaDescription(Context context,
@NonNull StatusViewData.Concrete status) {
if (status.getActionable().getAttachments().isEmpty()) {
@NonNull StatusViewData.Concrete viewData) {
if (viewData.getAttachments().isEmpty()) {
return "";
}
StringBuilder mediaDescriptions = CollectionsKt.fold(
status.getActionable().getAttachments(),
new StringBuilder(),
(builder, a) -> {
if (a.getDescription() == null) {
String placeholder =
context.getString(R.string.description_post_media_no_description_placeholder);
return builder.append(placeholder);
} else {
builder.append("; ");
return builder.append(a.getDescription());
}
});
viewData.getAttachments(),
new StringBuilder(),
(builder, a) -> {
if (a.getDescription() == null) {
String placeholder =
context.getString(R.string.description_post_media_no_description_placeholder);
return builder.append(placeholder);
} else {
builder.append("; ");
return builder.append(a.getDescription());
}
});
return context.getString(R.string.description_post_media, mediaDescriptions);
}
@ -918,7 +990,8 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
}
}
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 "";
@ -947,7 +1020,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
private CharSequence getPollDescription(@NonNull StatusViewData.Concrete status,
Context context,
StatusDisplayOptions statusDisplayOptions) {
PollViewData poll = PollViewDataKt.toViewData(status.getActionable().getPoll());
PollViewData poll = PollViewDataKt.toViewData(status.getPoll());
if (poll == null) {
return "";
} else {
@ -962,27 +1035,21 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
}
}
args[4] = getPollInfoText(System.currentTimeMillis(), poll, statusDisplayOptions,
context);
context);
return context.getString(R.string.description_poll, args);
}
}
protected CharSequence getFavsText(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);
} else {
return "";
}
@NonNull
protected CharSequence getFavsText(@NonNull Context context, int count) {
String countString = numberFormat.format(count);
return HtmlCompat.fromHtml(context.getResources().getQuantityString(R.plurals.favs, count, countString), HtmlCompat.FROM_HTML_MODE_LEGACY);
}
protected CharSequence getReblogsText(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);
} else {
return "";
}
@NonNull
protected CharSequence getReblogsText(@NonNull Context context, int count) {
String countString = numberFormat.format(count);
return HtmlCompat.fromHtml(context.getResources().getQuantityString(R.plurals.reblogs, count, countString), HtmlCompat.FROM_HTML_MODE_LEGACY);
}
private void setupPoll(PollViewData poll, List<Emoji> emojis,
@ -1005,26 +1072,26 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
}
};
pollAdapter.setup(
poll.getOptions(),
poll.getVotesCount(),
poll.getVotersCount(),
emojis,
PollAdapter.RESULT,
viewThreadListener,
statusDisplayOptions.animateEmojis()
poll.getOptions(),
poll.getVotesCount(),
poll.getVotersCount(),
emojis,
PollAdapter.RESULT,
viewThreadListener,
statusDisplayOptions.animateEmojis()
);
pollButton.setVisibility(View.GONE);
} else {
// voting possible
pollAdapter.setup(
poll.getOptions(),
poll.getVotesCount(),
poll.getVotersCount(),
emojis,
poll.getMultiple() ? PollAdapter.MULTIPLE : PollAdapter.SINGLE,
null,
statusDisplayOptions.animateEmojis()
poll.getOptions(),
poll.getVotesCount(),
poll.getVotersCount(),
emojis,
poll.getMultiple() ? PollAdapter.MULTIPLE : PollAdapter.SINGLE,
null,
statusDisplayOptions.animateEmojis()
);
pollButton.setVisibility(View.VISIBLE);
@ -1077,11 +1144,11 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
}
protected void setupCard(
final StatusViewData.Concrete status,
boolean expanded,
final CardViewMode cardViewMode,
final StatusDisplayOptions statusDisplayOptions,
final StatusActionListener listener
final @NonNull StatusViewData.Concrete status,
boolean expanded,
final @NonNull CardViewMode cardViewMode,
final @NonNull StatusDisplayOptions statusDisplayOptions,
final @NonNull StatusActionListener listener
) {
if (cardView == null) {
return;
@ -1095,7 +1162,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
actionable.getPoll() == null &&
card != null &&
!TextUtils.isEmpty(card.getUrl()) &&
(!actionable.getSensitive() || expanded) &&
(TextUtils.isEmpty(actionable.getSpoilerText()) || expanded) &&
(!status.isCollapsible() || !status.isCollapsed())) {
cardView.setVisibility(View.VISIBLE);
@ -1119,14 +1186,14 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
if (statusDisplayOptions.mediaPreviewEnabled() && !actionable.getSensitive() && !TextUtils.isEmpty(card.getImage())) {
int radius = cardImage.getContext().getResources()
.getDimensionPixelSize(R.dimen.card_radius);
.getDimensionPixelSize(R.dimen.card_radius);
ShapeAppearanceModel.Builder cardImageShape = ShapeAppearanceModel.builder();
if (card.getWidth() > card.getHeight()) {
cardView.setOrientation(LinearLayout.VERTICAL);
cardImage.getLayoutParams().height = cardImage.getContext().getResources()
.getDimensionPixelSize(R.dimen.card_image_vertical_height);
.getDimensionPixelSize(R.dimen.card_image_vertical_height);
cardImage.getLayoutParams().width = ViewGroup.LayoutParams.MATCH_PARENT;
cardInfo.getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT;
cardInfo.getLayoutParams().width = ViewGroup.LayoutParams.WRAP_CONTENT;
@ -1136,7 +1203,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
cardView.setOrientation(LinearLayout.HORIZONTAL);
cardImage.getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT;
cardImage.getLayoutParams().width = cardImage.getContext().getResources()
.getDimensionPixelSize(R.dimen.card_image_horizontal_width);
.getDimensionPixelSize(R.dimen.card_image_horizontal_width);
cardInfo.getLayoutParams().height = ViewGroup.LayoutParams.WRAP_CONTENT;
cardInfo.getLayoutParams().width = ViewGroup.LayoutParams.MATCH_PARENT;
cardImageShape.setTopLeftCorner(CornerFamily.ROUNDED, radius);
@ -1148,40 +1215,40 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
cardImage.setScaleType(ImageView.ScaleType.CENTER_CROP);
RequestBuilder<Drawable> builder = Glide.with(cardImage.getContext())
.load(card.getImage())
.dontTransform();
.load(card.getImage())
.dontTransform();
if (statusDisplayOptions.useBlurhash() && !TextUtils.isEmpty(card.getBlurhash())) {
builder = builder.placeholder(decodeBlurHash(card.getBlurhash()));
}
builder.into(cardImage);
} else if (statusDisplayOptions.useBlurhash() && !TextUtils.isEmpty(card.getBlurhash())) {
int radius = cardImage.getContext().getResources()
.getDimensionPixelSize(R.dimen.card_radius);
.getDimensionPixelSize(R.dimen.card_radius);
cardView.setOrientation(LinearLayout.HORIZONTAL);
cardImage.getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT;
cardImage.getLayoutParams().width = cardImage.getContext().getResources()
.getDimensionPixelSize(R.dimen.card_image_horizontal_width);
.getDimensionPixelSize(R.dimen.card_image_horizontal_width);
cardInfo.getLayoutParams().height = ViewGroup.LayoutParams.WRAP_CONTENT;
cardInfo.getLayoutParams().width = ViewGroup.LayoutParams.MATCH_PARENT;
ShapeAppearanceModel cardImageShape = ShapeAppearanceModel.builder()
.setTopLeftCorner(CornerFamily.ROUNDED, radius)
.setBottomLeftCorner(CornerFamily.ROUNDED, radius)
.build();
.setTopLeftCorner(CornerFamily.ROUNDED, radius)
.setBottomLeftCorner(CornerFamily.ROUNDED, radius)
.build();
cardImage.setShapeAppearanceModel(cardImageShape);
cardImage.setScaleType(ImageView.ScaleType.CENTER_CROP);
Glide.with(cardImage.getContext())
.load(decodeBlurHash(card.getBlurhash()))
.dontTransform()
.into(cardImage);
.load(decodeBlurHash(card.getBlurhash()))
.dontTransform()
.into(cardImage);
} else {
cardView.setOrientation(LinearLayout.HORIZONTAL);
cardImage.getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT;
cardImage.getLayoutParams().width = cardImage.getContext().getResources()
.getDimensionPixelSize(R.dimen.card_image_horizontal_width);
.getDimensionPixelSize(R.dimen.card_image_horizontal_width);
cardInfo.getLayoutParams().height = ViewGroup.LayoutParams.WRAP_CONTENT;
cardInfo.getLayoutParams().width = ViewGroup.LayoutParams.MATCH_PARENT;
@ -1190,8 +1257,8 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
cardImage.setScaleType(ImageView.ScaleType.CENTER);
Glide.with(cardImage.getContext())
.load(R.drawable.card_image_placeholder)
.into(cardImage);
.load(R.drawable.card_image_placeholder)
.into(cardImage);
}
View.OnClickListener visitLink = v -> listener.onViewUrl(card.getUrl());
@ -1199,8 +1266,8 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
cardView.setOnClickListener(visitLink);
// View embedded photos in our image viewer instead of opening the browser
cardImage.setOnClickListener(card.getType().equals(Card.TYPE_PHOTO) && !TextUtils.isEmpty(card.getEmbedUrl()) ?
v -> cardView.getContext().startActivity(ViewMediaActivity.newSingleImageIntent(cardView.getContext(), card.getEmbedUrl())) :
visitLink);
v -> cardView.getContext().startActivity(ViewMediaActivity.newSingleImageIntent(cardView.getContext(), card.getEmbedUrl())) :
visitLink);
cardView.setClipToOutline(true);
} else {

View file

@ -9,11 +9,13 @@ import android.text.method.LinkMovementMethod;
import android.text.style.DynamicDrawableSpan;
import android.text.style.ImageSpan;
import android.view.View;
import android.widget.Button;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.appcompat.widget.ViewUtils;
import androidx.recyclerview.widget.RecyclerView;
import com.keylesspalace.tusky.R;
@ -23,6 +25,7 @@ import com.keylesspalace.tusky.util.CardViewMode;
import com.keylesspalace.tusky.util.LinkHelper;
import com.keylesspalace.tusky.util.NoUnderlineURLSpan;
import com.keylesspalace.tusky.util.StatusDisplayOptions;
import com.keylesspalace.tusky.util.ViewExtensionsKt;
import com.keylesspalace.tusky.viewdata.StatusViewData;
import java.text.DateFormat;
@ -35,7 +38,7 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder {
private static final DateFormat dateFormat = DateFormat.getDateTimeInstance(DateFormat.DEFAULT, DateFormat.SHORT);
public StatusDetailedViewHolder(View view) {
public StatusDetailedViewHolder(@NonNull View view) {
super(view);
reblogs = view.findViewById(R.id.status_reblogs);
favourites = view.findViewById(R.id.status_favourites);
@ -43,7 +46,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();
@ -57,8 +60,8 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder {
if (visibilityIcon != null) {
ImageSpan visibilityIconSpan = new ImageSpan(
visibilityIcon,
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ? DynamicDrawableSpan.ALIGN_CENTER : DynamicDrawableSpan.ALIGN_BASELINE
visibilityIcon,
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ? DynamicDrawableSpan.ALIGN_CENTER : DynamicDrawableSpan.ALIGN_BASELINE
);
sb.setSpan(visibilityIconSpan, 0, visibilityString.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
@ -67,7 +70,6 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder {
Date createdAt = status.getCreatedAt();
if (createdAt != null) {
sb.append(" ");
sb.append(dateFormat.format(createdAt));
}
@ -95,10 +97,16 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder {
}
}
String language = status.getLanguage();
if (language != null) {
sb.append(metadataJoiner);
sb.append(language.toUpperCase());
}
Status.Application app = status.getApplication();
if (app != null) {
sb.append(metadataJoiner);
if (app.getWebsite() != null) {
@ -114,25 +122,8 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder {
}
private void setReblogAndFavCount(int reblogCount, int favCount, StatusActionListener listener) {
if (reblogCount > 0) {
reblogs.setText(getReblogsText(reblogs.getContext(), reblogCount));
reblogs.setVisibility(View.VISIBLE);
} else {
reblogs.setVisibility(View.GONE);
}
if (favCount > 0) {
favourites.setText(getFavsText(favourites.getContext(), favCount));
favourites.setVisibility(View.VISIBLE);
} else {
favourites.setVisibility(View.GONE);
}
if (reblogs.getVisibility() == View.GONE && favourites.getVisibility() == View.GONE) {
infoDivider.setVisibility(View.GONE);
} else {
infoDivider.setVisibility(View.VISIBLE);
}
reblogs.setText(getReblogsText(reblogs.getContext(), reblogCount));
favourites.setText(getFavsText(favourites.getContext(), favCount));
reblogs.setOnClickListener(v -> {
int position = getBindingAdapterPosition();
@ -155,8 +146,8 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder {
@Nullable Object payloads) {
// We never collapse statuses in the detail view
StatusViewData.Concrete uncollapsedStatus = (status.isCollapsible() && status.isCollapsed()) ?
status.copyWithCollapsed(false) :
status;
status.copyWithCollapsed(false) :
status;
super.setupWithStatus(uncollapsedStatus, listener, statusDisplayOptions, payloads);
setupCard(uncollapsedStatus, status.isExpanded(), CardViewMode.FULL_WIDTH, statusDisplayOptions, listener); // Always show card for detailed status
@ -165,7 +156,7 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder {
if (!statusDisplayOptions.hideStats()) {
setReblogAndFavCount(actionable.getReblogsCount(),
actionable.getFavouritesCount(), listener);
actionable.getFavouritesCount(), listener);
} else {
hideQuantitativeStats();
}
@ -197,7 +188,7 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder {
}
final Drawable visibilityDrawable = AppCompatResources.getDrawable(
this.metaInfo.getContext(), visibilityIcon
this.metaInfo.getContext(), visibilityIcon
);
if (visibilityDrawable == null) {
return null;
@ -205,10 +196,10 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder {
final int size = (int) this.metaInfo.getTextSize();
visibilityDrawable.setBounds(
0,
0,
size,
size
0,
0,
size,
size
);
visibilityDrawable.setTint(this.metaInfo.getCurrentTextColor());

View file

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

View file

@ -56,7 +56,11 @@ class TabAdapter(
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ViewBinding> {
val binding = if (small) {
ItemTabPreferenceSmallBinding.inflate(LayoutInflater.from(parent.context), parent, false)
ItemTabPreferenceSmallBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
} else {
ItemTabPreferenceBinding.inflate(LayoutInflater.from(parent.context), parent, false)
}

View file

@ -1,20 +1,18 @@
package com.keylesspalace.tusky.appstore
import com.google.gson.Gson
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import javax.inject.Inject
class CacheUpdater @Inject constructor(
eventHub: EventHub,
accountManager: AccountManager,
appDatabase: AppDatabase,
gson: Gson
appDatabase: AppDatabase
) {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@ -26,22 +24,20 @@ 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
)
}
is UnfollowEvent ->
timelineDao.removeAllByUser(accountId, event.accountId)
is StatusDeletedEvent ->
timelineDao.delete(accountId, event.statusId)
is PollVoteEvent -> {
val pollString = gson.toJson(event.poll)
timelineDao.setVoted(accountId, event.statusId, pollString)
timelineDao.setVoted(accountId, event.statusId, event.poll)
}
is PinEvent ->
timelineDao.setPinned(accountId, event.statusId, event.pinned)
}
}
}

View file

@ -2,24 +2,29 @@ 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.ScheduledStatus
import com.keylesspalace.tusky.entity.Status
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
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 StatusScheduledEvent(val scheduledStatus: ScheduledStatus) : Event
data class ProfileEditedEvent(val newProfileData: Account) : Event
data class PreferenceChangedEvent(val preferenceKey: String) : Event
data class MainTabsChangedEvent(val newTabs: List<TabData>) : 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<String>) : Event
data class NewNotificationsEvent(
val accountId: String,
val notifications: List<Notification>
) : Event
data class ConversationsLoadingEvent(val accountId: String) : Event
data class NotificationsLoadingEvent(val accountId: String) : Event

View file

@ -1,19 +1,33 @@
package com.keylesspalace.tusky.appstore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import java.util.function.Consumer
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
interface Event
@Singleton
class EventHub @Inject constructor() {
private val sharedEventFlow: MutableSharedFlow<Event> = MutableSharedFlow()
val events: Flow<Event> = sharedEventFlow
private val _events = MutableSharedFlow<Event>()
val events: SharedFlow<Event> = _events.asSharedFlow()
suspend fun dispatch(event: Event) {
sharedEventFlow.emit(event)
_events.emit(event)
}
// TODO remove as soon as NotificationsFragment is Kotlin
fun subscribe(lifecycleOwner: LifecycleOwner, consumer: Consumer<Event>) {
lifecycleOwner.lifecycleScope.launch {
events.collect { event ->
consumer.accept(event)
}
}
}
}

View file

@ -22,9 +22,11 @@ import android.content.Context
import android.content.Intent
import android.content.res.ColorStateList
import android.graphics.Color
import android.graphics.drawable.LayerDrawable
import android.graphics.Typeface
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 +34,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
@ -43,11 +47,13 @@ import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsCompat.Type.systemBars
import androidx.core.view.updatePadding
import androidx.core.widget.doAfterTextChanged
import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.viewpager2.widget.MarginPageTransformer
import com.bumptech.glide.Glide
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.chip.Chip
import com.google.android.material.color.MaterialColors
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.google.android.material.shape.MaterialShapeDrawable
@ -60,7 +66,7 @@ import com.keylesspalace.tusky.EditProfileActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.StatusListActivity
import com.keylesspalace.tusky.ViewMediaActivity
import com.keylesspalace.tusky.components.account.list.ListsForAccountFragment
import com.keylesspalace.tusky.components.account.list.ListSelectionFragment
import com.keylesspalace.tusky.components.accountlist.AccountListActivity
import com.keylesspalace.tusky.components.compose.ComposeActivity
import com.keylesspalace.tusky.components.report.ReportActivity
@ -86,6 +92,7 @@ import com.keylesspalace.tusky.util.parseAsMastodonHtml
import com.keylesspalace.tusky.util.reduceSwipeSensitivity
import com.keylesspalace.tusky.util.setClickableText
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
import com.keylesspalace.tusky.util.unsafeLazy
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible
@ -102,6 +109,7 @@ import java.text.SimpleDateFormat
import java.util.Locale
import javax.inject.Inject
import kotlin.math.abs
import kotlinx.coroutines.launch
class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider, HasAndroidInjector, LinkListener {
@ -173,9 +181,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()
@ -261,9 +269,18 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
binding.accountFragmentViewPager.adapter = adapter
binding.accountFragmentViewPager.offscreenPageLimit = 2
val pageTitles = arrayOf(getString(R.string.title_posts), getString(R.string.title_posts_with_replies), getString(R.string.title_posts_pinned), getString(R.string.title_media))
val pageTitles =
arrayOf(
getString(R.string.title_posts),
getString(R.string.title_posts_with_replies),
getString(R.string.title_posts_pinned),
getString(R.string.title_media)
)
TabLayoutMediator(binding.accountTabLayout, binding.accountFragmentViewPager) { tab, position ->
TabLayoutMediator(
binding.accountTabLayout,
binding.accountFragmentViewPager
) { tab, position ->
tab.text = pageTitles[position]
}.attach()
@ -295,7 +312,15 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
val right = insets.getInsets(systemBars()).right
val bottom = insets.getInsets(systemBars()).bottom
val left = insets.getInsets(systemBars()).left
binding.accountCoordinatorLayout.updatePadding(right = right, bottom = bottom, left = left)
binding.accountCoordinatorLayout.updatePadding(
right = right,
bottom = bottom,
left = left
)
binding.swipeToRefreshLayout.setProgressViewEndTarget(
false,
top + resources.getDimensionPixelSize(R.dimen.account_swiperefresh_distance)
)
WindowInsetsCompat.CONSUMED
}
@ -312,30 +337,24 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
val appBarElevation = resources.getDimension(R.dimen.actionbar_elevation)
val toolbarBackground = MaterialShapeDrawable.createWithElevationOverlay(this, appBarElevation)
val toolbarBackground = MaterialShapeDrawable.createWithElevationOverlay(
this,
appBarElevation
)
toolbarBackground.fillColor = ColorStateList.valueOf(Color.TRANSPARENT)
binding.accountToolbar.background = toolbarBackground
// Provide a non-transparent background to the navigation and overflow icons to ensure
// they remain visible over whatever the profile background image might be.
val backgroundCircle = AppCompatResources.getDrawable(this, R.drawable.background_circle)!!
backgroundCircle.alpha = 210 // Any lower than this and the backgrounds interfere
binding.accountToolbar.navigationIcon = LayerDrawable(
arrayOf(
backgroundCircle,
binding.accountToolbar.navigationIcon
)
)
binding.accountToolbar.overflowIcon = LayerDrawable(
arrayOf(
backgroundCircle,
binding.accountToolbar.overflowIcon
)
binding.accountToolbar.setNavigationIcon(R.drawable.ic_arrow_back_with_background)
binding.accountToolbar.setOverflowIcon(
AppCompatResources.getDrawable(this, R.drawable.ic_more_with_background)
)
binding.accountHeaderInfoContainer.background = MaterialShapeDrawable.createWithElevationOverlay(this, appBarElevation)
val avatarBackground = MaterialShapeDrawable.createWithElevationOverlay(this, appBarElevation).apply {
val avatarBackground = MaterialShapeDrawable.createWithElevationOverlay(
this,
appBarElevation
).apply {
fillColor = ColorStateList.valueOf(toolbarColor)
elevation = appBarElevation
shapeAppearanceModel = ShapeAppearanceModel.builder()
@ -375,11 +394,17 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
binding.accountAvatarImageView.visible(scaledAvatarSize > 0)
val transparencyPercent = (abs(verticalOffset) / titleVisibleHeight.toFloat()).coerceAtMost(1f)
val transparencyPercent = (abs(verticalOffset) / titleVisibleHeight.toFloat()).coerceAtMost(
1f
)
window.statusBarColor = argbEvaluator.evaluate(transparencyPercent, statusBarColorTransparent, statusBarColorOpaque) as Int
val evaluatedToolbarColor = argbEvaluator.evaluate(transparencyPercent, Color.TRANSPARENT, toolbarColor) as Int
val evaluatedToolbarColor = argbEvaluator.evaluate(
transparencyPercent,
Color.TRANSPARENT,
toolbarColor
) as Int
toolbarBackground.fillColor = ColorStateList.valueOf(evaluatedToolbarColor)
@ -397,31 +422,46 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
* Subscribe to data loaded at the view model
*/
private fun subscribeObservables() {
viewModel.accountData.observe(this) {
when (it) {
is Success -> onAccountChanged(it.data)
is Error -> {
Snackbar.make(binding.accountCoordinatorLayout, R.string.error_generic, Snackbar.LENGTH_LONG)
lifecycleScope.launch {
viewModel.accountData.collect {
if (it == null) return@collect
when (it) {
is Success -> onAccountChanged(it.data)
is Error -> {
Snackbar.make(
binding.accountCoordinatorLayout,
R.string.error_generic,
Snackbar.LENGTH_LONG
)
.setAction(R.string.action_retry) { viewModel.refresh() }
.show()
}
is Loading -> { }
}
}
}
lifecycleScope.launch {
viewModel.relationshipData.collect {
val relation = it?.data
if (relation != null) {
onRelationshipChanged(relation)
}
if (it is Error) {
Snackbar.make(
binding.accountCoordinatorLayout,
R.string.error_generic,
Snackbar.LENGTH_LONG
)
.setAction(R.string.action_retry) { viewModel.refresh() }
.show()
}
is Loading -> { }
}
}
viewModel.relationshipData.observe(this) {
val relation = it?.data
if (relation != null) {
onRelationshipChanged(relation)
lifecycleScope.launch {
viewModel.noteSaved.collect {
binding.saveNoteInfo.visible(it, View.INVISIBLE)
}
if (it is Error) {
Snackbar.make(binding.accountCoordinatorLayout, R.string.error_generic, Snackbar.LENGTH_LONG)
.setAction(R.string.action_retry) { viewModel.refresh() }
.show()
}
}
viewModel.noteSaved.observe(this) {
binding.saveNoteInfo.visible(it, View.INVISIBLE)
}
// "Post failed" dialog should display in this activity
@ -438,10 +478,10 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
*/
private fun setupRefreshLayout() {
binding.swipeToRefreshLayout.setOnRefreshListener { onRefresh() }
viewModel.isRefreshing.observe(
this
) { isRefreshing ->
binding.swipeToRefreshLayout.isRefreshing = isRefreshing == true
lifecycleScope.launch {
viewModel.isRefreshing.collect { isRefreshing ->
binding.swipeToRefreshLayout.isRefreshing = isRefreshing == true
}
}
binding.swipeToRefreshLayout.setColorSchemeResources(R.color.chinwag_green)
}
@ -460,25 +500,33 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
val fullUsername = getFullUsername(loadedAccount)
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText(null, fullUsername))
Snackbar.make(binding.root, getString(R.string.account_username_copied), Snackbar.LENGTH_SHORT)
Snackbar.make(
binding.root,
getString(R.string.account_username_copied),
Snackbar.LENGTH_SHORT
)
.show()
}
true
}
}
val emojifiedNote = account.note.parseAsMastodonHtml().emojify(account.emojis, binding.accountNoteTextView, animateEmojis)
val emojifiedNote = account.note.parseAsMastodonHtml().emojify(
account.emojis,
binding.accountNoteTextView,
animateEmojis
)
setClickableText(binding.accountNoteTextView, emojifiedNote, emptyList(), null, this)
accountFieldAdapter.fields = account.fields.orEmpty()
accountFieldAdapter.emojis = account.emojis.orEmpty()
accountFieldAdapter.fields = account.fields
accountFieldAdapter.emojis = account.emojis
accountFieldAdapter.notifyDataSetChanged()
binding.accountLockedImageView.visible(account.locked)
binding.accountBadgeTextView.visible(account.bot)
updateAccountAvatar()
updateToolbar()
updateBadges()
updateMovedAccount()
updateRemoteAccount()
updateAccountJoinedDate()
@ -491,6 +539,39 @@ 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 {
@ -579,7 +660,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
*/
private fun updateRemoteAccount() {
loadedAccount?.let { account ->
if (account.isRemote()) {
if (account.isRemote) {
binding.accountRemoveView.show()
binding.accountRemoveView.setOnClickListener {
openLink(account.url)
@ -772,13 +853,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)
}
}
@ -837,7 +921,9 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
} else {
AlertDialog.Builder(this)
.setMessage(getString(R.string.mute_domain_warning, instance))
.setPositiveButton(getString(R.string.mute_domain_warning_dialog_ok)) { _, _ -> viewModel.blockDomain(instance) }
.setPositiveButton(
getString(R.string.mute_domain_warning_dialog_ok)
) { _, _ -> viewModel.blockDomain(instance) }
.setNegativeButton(android.R.string.cancel, null)
.show()
}
@ -930,7 +1016,12 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
sendIntent.action = Intent.ACTION_SEND
sendIntent.putExtra(Intent.EXTRA_TEXT, url)
sendIntent.type = "text/plain"
startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_account_link_to)))
startActivity(
Intent.createChooser(
sendIntent,
resources.getText(R.string.send_account_link_to)
)
)
}
return true
}
@ -942,7 +1033,12 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
sendIntent.action = Intent.ACTION_SEND
sendIntent.putExtra(Intent.EXTRA_TEXT, fullUsername)
sendIntent.type = "text/plain"
startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_account_username_to)))
startActivity(
Intent.createChooser(
sendIntent,
resources.getText(R.string.send_account_username_to)
)
)
}
return true
}
@ -955,7 +1051,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
return true
}
R.id.action_add_or_remove_from_list -> {
ListsForAccountFragment.newInstance(viewModel.accountId).show(supportFragmentManager, null)
ListSelectionFragment.newInstance(viewModel.accountId).show(supportFragmentManager, null)
return true
}
R.id.action_mute_domain -> {
@ -973,7 +1069,9 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
}
R.id.action_report -> {
loadedAccount?.let { loadedAccount ->
startActivity(ReportActivity.getIntent(this, viewModel.accountId, loadedAccount.username))
startActivity(
ReportActivity.getIntent(this, viewModel.accountId, loadedAccount.username)
)
}
return true
}
@ -990,7 +1088,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
}
private fun getFullUsername(account: Account): String {
return if (account.isRemote()) {
return if (account.isRemote) {
"@" + account.username
} else {
val localUsername = account.localUsername
@ -1000,6 +1098,51 @@ 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 {

View file

@ -38,8 +38,15 @@ class AccountFieldAdapter(
override fun getItemCount() = fields.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemAccountFieldBinding> {
val binding = ItemAccountFieldBinding.inflate(LayoutInflater.from(parent.context), parent, false)
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): BindingHolder<ItemAccountFieldBinding> {
val binding = ItemAccountFieldBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
return BindingHolder(binding)
}
@ -51,11 +58,20 @@ class AccountFieldAdapter(
val emojifiedName = field.name.emojify(emojis, nameTextView, animateEmojis)
nameTextView.text = emojifiedName
val emojifiedValue = field.value.parseAsMastodonHtml().emojify(emojis, valueTextView, animateEmojis)
val emojifiedValue = field.value.parseAsMastodonHtml().emojify(
emojis,
valueTextView,
animateEmojis
)
setClickableText(valueTextView, emojifiedValue, emptyList(), null, linkListener)
if (field.verifiedAt != null) {
valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_check_circle, 0)
valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(
0,
0,
R.drawable.ic_check_circle,
0
)
} else {
valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0)
}

View file

@ -33,7 +33,11 @@ class AccountPagerAdapter(
override fun createFragment(position: Int): Fragment {
return when (position) {
0 -> TimelineFragment.newInstance(TimelineViewModel.Kind.USER, accountId, false)
1 -> TimelineFragment.newInstance(TimelineViewModel.Kind.USER_WITH_REPLIES, accountId, false)
1 -> TimelineFragment.newInstance(
TimelineViewModel.Kind.USER_WITH_REPLIES,
accountId,
false
)
2 -> TimelineFragment.newInstance(TimelineViewModel.Kind.USER_PINNED, accountId, false)
3 -> AccountMediaFragment.newInstance(accountId)
else -> throw AssertionError("Page $position is out of AccountPagerAdapter bounds")

View file

@ -1,7 +1,6 @@
package com.keylesspalace.tusky.components.account
import android.util.Log
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.connyduck.calladapter.networkresult.fold
@ -19,58 +18,83 @@ 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 kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import com.keylesspalace.tusky.util.getDomain
import javax.inject.Inject
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class AccountViewModel @Inject constructor(
private val mastodonApi: MastodonApi,
private val eventHub: EventHub,
private val accountManager: AccountManager
accountManager: AccountManager
) : ViewModel() {
val accountData = MutableLiveData<Resource<Account>>()
val relationshipData = MutableLiveData<Resource<Relationship>>()
private val _accountData = MutableStateFlow(null as Resource<Account>?)
val accountData: StateFlow<Resource<Account>?> = _accountData.asStateFlow()
val noteSaved = MutableLiveData<Boolean>()
private val _relationshipData = MutableStateFlow(null as Resource<Relationship>?)
val relationshipData: StateFlow<Resource<Relationship>?> = _relationshipData.asStateFlow()
private val _noteSaved = MutableStateFlow(false)
val noteSaved: StateFlow<Boolean> = _noteSaved.asStateFlow()
private val _isRefreshing = MutableSharedFlow<Boolean>(1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val isRefreshing: SharedFlow<Boolean> = _isRefreshing.asSharedFlow()
val isRefreshing = MutableLiveData<Boolean>().apply { value = false }
private var isDataLoading = false
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 ->
if (event is ProfileEditedEvent && event.newProfileData.id == accountData.value?.data?.id) {
accountData.postValue(Success(event.newProfileData))
if (event is ProfileEditedEvent && event.newProfileData.id == _accountData.value?.data?.id) {
_accountData.value = Success(event.newProfileData)
}
}
}
}
private fun obtainAccount(reload: Boolean = false) {
if (accountData.value == null || reload) {
if (_accountData.value == null || reload) {
isDataLoading = true
accountData.postValue(Loading())
_accountData.value = Loading()
viewModelScope.launch {
mastodonApi.account(accountId)
.fold(
{ account ->
accountData.postValue(Success(account))
domain = getDomain(account.url)
isFromOwnDomain = domain == activeAccount.domain
_accountData.value = Success(account)
isDataLoading = false
isRefreshing.postValue(false)
_isRefreshing.emit(false)
},
{ t ->
Log.w(TAG, "failed obtaining account", t)
accountData.postValue(Error(cause = t))
_accountData.value = Error(cause = t)
isDataLoading = false
isRefreshing.postValue(false)
_isRefreshing.emit(false)
}
)
}
@ -78,18 +102,25 @@ class AccountViewModel @Inject constructor(
}
private fun obtainRelationship(reload: Boolean = false) {
if (relationshipData.value == null || reload) {
relationshipData.postValue(Loading())
if (_relationshipData.value == null || reload) {
_relationshipData.value = Loading()
viewModelScope.launch {
mastodonApi.relationships(listOf(accountId))
.fold(
{ relationships ->
relationshipData.postValue(if (relationships.isNotEmpty()) Success(relationships[0]) else Error())
_relationshipData.value =
if (relationships.isNotEmpty()) {
Success(
relationships[0]
)
} else {
Error()
}
},
{ t ->
Log.w(TAG, "failed obtaining relationships", t)
relationshipData.postValue(Error(cause = t))
_relationshipData.value = Error(cause = t)
}
)
}
@ -97,7 +128,7 @@ class AccountViewModel @Inject constructor(
}
fun changeFollowState() {
val relationship = relationshipData.value?.data
val relationship = _relationshipData.value?.data
if (relationship?.following == true || relationship?.requested == true) {
changeRelationship(RelationShipAction.UNFOLLOW)
} else {
@ -106,7 +137,7 @@ class AccountViewModel @Inject constructor(
}
fun changeBlockState() {
if (relationshipData.value?.data?.blocking == true) {
if (_relationshipData.value?.data?.blocking == true) {
changeRelationship(RelationShipAction.UNBLOCK)
} else {
changeRelationship(RelationShipAction.BLOCK)
@ -122,9 +153,9 @@ class AccountViewModel @Inject constructor(
}
fun changeSubscribingState() {
val relationship = relationshipData.value?.data
if (relationship?.notifying == true || /* Mastodon 3.3.0rc1 */
relationship?.subscribing == true /* Pleroma */
val relationship = _relationshipData.value?.data
if (relationship?.notifying == true || // Mastodon 3.3.0rc1
relationship?.subscribing == true // Pleroma
) {
changeRelationship(RelationShipAction.UNSUBSCRIBE)
} else {
@ -136,9 +167,9 @@ class AccountViewModel @Inject constructor(
viewModelScope.launch {
mastodonApi.blockDomain(instance).fold({
eventHub.dispatch(DomainMuteEvent(instance))
val relation = relationshipData.value?.data
val relation = _relationshipData.value?.data
if (relation != null) {
relationshipData.postValue(Success(relation.copy(blockingDomain = true)))
_relationshipData.value = Success(relation.copy(blockingDomain = true))
}
}, { e ->
Log.e(TAG, "Error muting $instance", e)
@ -149,9 +180,9 @@ class AccountViewModel @Inject constructor(
fun unblockDomain(instance: String) {
viewModelScope.launch {
mastodonApi.unblockDomain(instance).fold({
val relation = relationshipData.value?.data
val relation = _relationshipData.value?.data
if (relation != null) {
relationshipData.postValue(Success(relation.copy(blockingDomain = false)))
_relationshipData.value = Success(relation.copy(blockingDomain = false))
}
}, { e ->
Log.e(TAG, "Error unmuting $instance", e)
@ -160,7 +191,7 @@ class AccountViewModel @Inject constructor(
}
fun changeShowReblogsState() {
if (relationshipData.value?.data?.showingReblogs == true) {
if (_relationshipData.value?.data?.showingReblogs == true) {
changeRelationship(RelationShipAction.FOLLOW, false)
} else {
changeRelationship(RelationShipAction.FOLLOW, true)
@ -175,9 +206,9 @@ class AccountViewModel @Inject constructor(
parameter: Boolean? = null,
duration: Int? = null
) = viewModelScope.launch {
val relation = relationshipData.value?.data
val account = accountData.value?.data
val isMastodon = relationshipData.value?.data?.notifying != null
val relation = _relationshipData.value?.data
val account = _accountData.value?.data
val isMastodon = _relationshipData.value?.data?.notifying != null
if (relation != null && account != null) {
// optimistically post new state for faster response
@ -210,7 +241,7 @@ class AccountViewModel @Inject constructor(
}
}
}
relationshipData.postValue(Loading(newRelation))
_relationshipData.value = Loading(newRelation)
}
val relationshipCall = when (relationshipAction) {
@ -245,7 +276,7 @@ class AccountViewModel @Inject constructor(
relationshipCall.fold(
{ relationship ->
relationshipData.postValue(Success(relationship))
_relationshipData.value = Success(relationship)
when (relationshipAction) {
RelationShipAction.UNFOLLOW -> eventHub.dispatch(UnfollowEvent(accountId))
@ -256,22 +287,22 @@ class AccountViewModel @Inject constructor(
},
{ t ->
Log.w(TAG, "failed loading relationship", t)
relationshipData.postValue(Error(relation, cause = t))
_relationshipData.value = Error(relation, cause = t)
}
)
}
fun noteChanged(newNote: String) {
noteSaved.postValue(false)
_noteSaved.value = false
noteUpdateJob?.cancel()
noteUpdateJob = viewModelScope.launch {
delay(1500)
mastodonApi.updateAccountNote(accountId, newNote)
.fold(
{
noteSaved.postValue(true)
_noteSaved.value = true
delay(4000)
noteSaved.postValue(false)
_noteSaved.value = false
},
{ t ->
Log.w(TAG, "Error updating note", t)
@ -298,12 +329,19 @@ class AccountViewModel @Inject constructor(
fun setAccountInfo(accountId: String) {
this.accountId = accountId
this.isSelf = accountManager.activeAccount?.accountId == accountId
this.isSelf = activeAccount.accountId == accountId
reload(false)
}
enum class RelationShipAction {
FOLLOW, UNFOLLOW, BLOCK, UNBLOCK, MUTE, UNMUTE, SUBSCRIBE, UNSUBSCRIBE
FOLLOW,
UNFOLLOW,
BLOCK,
UNBLOCK,
MUTE,
UNMUTE,
SUBSCRIBE,
UNSUBSCRIBE
}
companion object {

View file

@ -0,0 +1,260 @@
/* Copyright Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>.
*/
package com.keylesspalace.tusky.components.account.list
import android.app.Dialog
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.ListsActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.FragmentListsListBinding
import com.keylesspalace.tusky.databinding.ItemListBinding
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.MastoList
import com.keylesspalace.tusky.util.BindingHolder
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.visible
import javax.inject.Inject
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
class ListSelectionFragment : DialogFragment(), Injectable {
interface ListSelectionListener {
fun onListSelected(list: MastoList)
}
@Inject
lateinit var viewModelFactory: ViewModelFactory
private val viewModel: ListsForAccountViewModel by viewModels { viewModelFactory }
private var _binding: FragmentListsListBinding? = null
// This property is only valid between onCreateDialog and onDestroyView
private val binding get() = _binding!!
private val adapter = Adapter()
private var selectListener: ListSelectionListener? = null
private var accountId: String? = null
override fun onAttach(context: Context) {
super.onAttach(context)
selectListener = context as? ListSelectionListener
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(STYLE_NORMAL, R.style.TuskyDialogFragmentStyle)
accountId = requireArguments().getString(ARG_ACCOUNT_ID)
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val context = requireContext()
_binding = FragmentListsListBinding.inflate(layoutInflater)
binding.listsView.adapter = adapter
val dialogBuilder = AlertDialog.Builder(context)
.setView(binding.root)
.setTitle(R.string.select_list_title)
.setNeutralButton(R.string.select_list_manage) { _, _ ->
val listIntent = Intent(context, ListsActivity::class.java)
startActivity(listIntent)
}
.setNegativeButton(if (accountId != null) R.string.button_done else android.R.string.cancel, null)
val dialog = dialogBuilder.create()
val showProgressBarJob = getProgressBarJob(binding.progressBar, 500)
showProgressBarJob.start()
// TODO change this to a (single) LoadState like elsewhere?
lifecycleScope.launch {
viewModel.states.collectLatest { states ->
binding.progressBar.hide()
showProgressBarJob.cancel()
if (states.isEmpty()) {
binding.messageView.show()
binding.messageView.setup(R.drawable.elephant_friend_empty, R.string.no_lists)
} else {
binding.listsView.show()
adapter.submitList(states)
}
}
}
lifecycleScope.launch {
viewModel.loadError.collectLatest { error ->
Log.e(TAG, "failed to load lists", error)
binding.progressBar.hide()
showProgressBarJob.cancel()
binding.listsView.hide()
binding.messageView.apply {
show()
setup(error) { load() }
}
}
}
lifecycleScope.launch {
viewModel.actionError.collectLatest { error ->
when (error.type) {
ActionError.Type.ADD -> {
Snackbar.make(
binding.root,
R.string.failed_to_add_to_list,
Snackbar.LENGTH_LONG
)
.setAction(R.string.action_retry) {
viewModel.addAccountToList(accountId!!, error.listId)
}
.show()
}
ActionError.Type.REMOVE -> {
Snackbar.make(
binding.root,
R.string.failed_to_remove_from_list,
Snackbar.LENGTH_LONG
)
.setAction(R.string.action_retry) {
viewModel.removeAccountFromList(accountId!!, error.listId)
}
.show()
}
}
}
}
lifecycleScope.launch {
load()
}
return dialog
}
private fun getProgressBarJob(progressView: View, delayMs: Long) = this.lifecycleScope.launch(
start = CoroutineStart.LAZY
) {
try {
delay(delayMs)
progressView.show()
awaitCancellation()
} finally {
progressView.hide()
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
private fun load() {
binding.progressBar.show()
binding.listsView.hide()
binding.messageView.hide()
viewModel.load(accountId)
}
private object Differ : DiffUtil.ItemCallback<AccountListState>() {
override fun areItemsTheSame(
oldItem: AccountListState,
newItem: AccountListState
): Boolean {
return oldItem.list.id == newItem.list.id
}
override fun areContentsTheSame(
oldItem: AccountListState,
newItem: AccountListState
): Boolean {
return oldItem == newItem
}
}
inner class Adapter :
ListAdapter<AccountListState, BindingHolder<ItemListBinding>>(Differ) {
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): BindingHolder<ItemListBinding> {
return BindingHolder(ItemListBinding.inflate(LayoutInflater.from(parent.context), parent, false))
}
override fun onBindViewHolder(holder: BindingHolder<ItemListBinding>, position: Int) {
val item = getItem(position)
holder.binding.listName.text = item.list.title
accountId?.let { accountId ->
holder.binding.addButton.apply {
visible(!item.includesAccount)
setOnClickListener {
viewModel.addAccountToList(accountId, item.list.id)
}
}
holder.binding.removeButton.apply {
visible(item.includesAccount)
setOnClickListener {
viewModel.removeAccountFromList(accountId, item.list.id)
}
}
}
holder.itemView.setOnClickListener {
selectListener?.onListSelected(item.list)
accountId?.let { accountId ->
if (item.includesAccount) {
viewModel.removeAccountFromList(accountId, item.list.id)
} else {
viewModel.addAccountToList(accountId, item.list.id)
}
}
}
}
}
companion object {
private const val TAG = "ListsListFragment"
private const val ARG_ACCOUNT_ID = "accountId"
fun newInstance(accountId: String?): ListSelectionFragment {
val args = Bundle().apply {
putString(ARG_ACCOUNT_ID, accountId)
}
return ListSelectionFragment().apply { arguments = args }
}
}
}

View file

@ -1,200 +0,0 @@
/* Copyright 2022 kyori19
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>.
*/
package com.keylesspalace.tusky.components.account.list
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.ListAdapter
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.FragmentListsForAccountBinding
import com.keylesspalace.tusky.databinding.ItemAddOrRemoveFromListBinding
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.util.BindingHolder
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 ListsForAccountFragment : DialogFragment(), Injectable {
@Inject
lateinit var viewModelFactory: ViewModelFactory
private val viewModel: ListsForAccountViewModel by viewModels { viewModelFactory }
private val binding by viewBinding(FragmentListsForAccountBinding::bind)
private val adapter = Adapter()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(STYLE_NORMAL, R.style.TuskyDialogFragmentStyle)
viewModel.setup(requireArguments().getString(ARG_ACCOUNT_ID)!!)
}
override fun onStart() {
super.onStart()
dialog?.apply {
window?.setLayout(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.MATCH_PARENT
)
}
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_lists_for_account, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.listsView.layoutManager = LinearLayoutManager(view.context)
binding.listsView.adapter = adapter
viewLifecycleOwner.lifecycleScope.launch {
viewModel.states.collectLatest { states ->
binding.progressBar.hide()
if (states.isEmpty()) {
binding.messageView.show()
binding.messageView.setup(R.drawable.elephant_friend_empty, R.string.no_lists) {
load()
}
} else {
binding.listsView.show()
adapter.submitList(states)
}
}
}
viewLifecycleOwner.lifecycleScope.launch {
viewModel.loadError.collectLatest { error ->
binding.progressBar.hide()
binding.listsView.hide()
binding.messageView.apply {
show()
setup(error) { load() }
}
}
}
viewLifecycleOwner.lifecycleScope.launch {
viewModel.actionError.collectLatest { error ->
when (error.type) {
ActionError.Type.ADD -> {
Snackbar.make(binding.root, R.string.failed_to_add_to_list, Snackbar.LENGTH_LONG)
.setAction(R.string.action_retry) {
viewModel.addAccountToList(error.listId)
}
.show()
}
ActionError.Type.REMOVE -> {
Snackbar.make(binding.root, R.string.failed_to_remove_from_list, Snackbar.LENGTH_LONG)
.setAction(R.string.action_retry) {
viewModel.removeAccountFromList(error.listId)
}
.show()
}
}
}
}
binding.doneButton.setOnClickListener {
dismiss()
}
load()
}
private fun load() {
binding.progressBar.show()
binding.listsView.hide()
binding.messageView.hide()
viewModel.load()
}
private object Differ : DiffUtil.ItemCallback<AccountListState>() {
override fun areItemsTheSame(
oldItem: AccountListState,
newItem: AccountListState
): Boolean {
return oldItem.list.id == newItem.list.id
}
override fun areContentsTheSame(
oldItem: AccountListState,
newItem: AccountListState
): Boolean {
return oldItem == newItem
}
}
inner class Adapter :
ListAdapter<AccountListState, BindingHolder<ItemAddOrRemoveFromListBinding>>(Differ) {
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): BindingHolder<ItemAddOrRemoveFromListBinding> {
val binding =
ItemAddOrRemoveFromListBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return BindingHolder(binding)
}
override fun onBindViewHolder(holder: BindingHolder<ItemAddOrRemoveFromListBinding>, position: Int) {
val item = getItem(position)
holder.binding.listNameView.text = item.list.title
holder.binding.addButton.apply {
visible(!item.includesAccount)
setOnClickListener {
viewModel.addAccountToList(item.list.id)
}
}
holder.binding.removeButton.apply {
visible(item.includesAccount)
setOnClickListener {
viewModel.removeAccountFromList(item.list.id)
}
}
}
}
companion object {
private const val ARG_ACCOUNT_ID = "accountId"
fun newInstance(accountId: String): ListsForAccountFragment {
val args = Bundle().apply {
putString(ARG_ACCOUNT_ID, accountId)
}
return ListsForAccountFragment().apply { arguments = args }
}
}
}

View file

@ -24,14 +24,13 @@ import at.connyduck.calladapter.networkresult.onSuccess
import at.connyduck.calladapter.networkresult.runCatching
import com.keylesspalace.tusky.entity.MastoList
import com.keylesspalace.tusky.network.MastodonApi
import javax.inject.Inject
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
data class AccountListState(
val list: MastoList,
@ -54,35 +53,30 @@ class ListsForAccountViewModel @Inject constructor(
private val mastodonApi: MastodonApi
) : ViewModel() {
private lateinit var accountId: String
private val _states = MutableSharedFlow<List<AccountListState>>(1)
val states: SharedFlow<List<AccountListState>> = _states
val states: SharedFlow<List<AccountListState>> = _states.asSharedFlow()
private val _loadError = MutableSharedFlow<Throwable>(1)
val loadError: SharedFlow<Throwable> = _loadError
val loadError: SharedFlow<Throwable> = _loadError.asSharedFlow()
private val _actionError = MutableSharedFlow<ActionError>(1)
val actionError: SharedFlow<ActionError> = _actionError
val actionError: SharedFlow<ActionError> = _actionError.asSharedFlow()
fun setup(accountId: String) {
this.accountId = accountId
}
fun load() {
fun load(accountId: String?) {
_loadError.resetReplayCache()
viewModelScope.launch {
runCatching {
val (all, includes) = listOf(
async { mastodonApi.getLists() },
async { mastodonApi.getListsIncludesAccount(accountId) }
).awaitAll()
val all = mastodonApi.getLists().getOrThrow()
var includes: List<MastoList> = emptyList()
if (accountId != null) {
includes = mastodonApi.getListsIncludesAccount(accountId).getOrThrow()
}
_states.emit(
all.getOrThrow().map { list ->
all.map { listState ->
AccountListState(
list = list,
includesAccount = includes.getOrThrow().any { it.id == list.id }
list = listState,
includesAccount = includes.any { it.id == listState.id }
)
}
)
@ -93,7 +87,9 @@ class ListsForAccountViewModel @Inject constructor(
}
}
fun addAccountToList(listId: String) {
// TODO there is no "progress" visible for these
fun addAccountToList(accountId: String, listId: String) {
_actionError.resetReplayCache()
viewModelScope.launch {
mastodonApi.addAccountToList(listId, listOf(accountId))
@ -114,7 +110,7 @@ class ListsForAccountViewModel @Inject constructor(
}
}
fun removeAccountFromList(listId: String) {
fun removeAccountFromList(accountId: String, listId: String) {
_actionError.resetReplayCache()
viewModelScope.launch {
mastodonApi.deleteAccountFromList(listId, listOf(accountId))

View file

@ -49,9 +49,9 @@ 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 javax.inject.Inject
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Fragment with multiple columns of media previews for the specified account.
@ -82,22 +82,23 @@ 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
)
val columnCount = view.context.resources.getInteger(R.integer.profile_media_column_count)
val imageSpacing = view.context.resources.getDimensionPixelSize(R.dimen.profile_media_spacing)
val imageSpacing = view.context.resources.getDimensionPixelSize(
R.dimen.profile_media_spacing
)
binding.recyclerView.addItemDecoration(GridSpacingItemDecoration(columnCount, imageSpacing, 0))
binding.recyclerView.addItemDecoration(
GridSpacingItemDecoration(columnCount, imageSpacing, 0)
)
binding.recyclerView.layoutManager = GridLayoutManager(view.context, columnCount)
binding.recyclerView.adapter = adapter
@ -127,7 +128,11 @@ class AccountMediaFragment :
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.setup(
R.drawable.elephant_friend_empty,
R.string.message_empty,
null
)
}
}
is LoadState.Error -> {
@ -178,11 +183,19 @@ class AccountMediaFragment :
Attachment.Type.GIFV,
Attachment.Type.VIDEO,
Attachment.Type.AUDIO -> {
val intent = ViewMediaActivity.newIntent(context, attachmentsFromSameStatus, currentIndex)
val intent = ViewMediaActivity.newIntent(
context,
attachmentsFromSameStatus,
currentIndex
)
if (activity != null) {
val url = selected.attachment.url
ViewCompat.setTransitionName(view, url)
val options = ActivityOptionsCompat.makeSceneTransitionAnimation(requireActivity(), view, url)
val options = ActivityOptionsCompat.makeSceneTransitionAnimation(
requireActivity(),
view,
url
)
startActivity(intent, options.toBundle())
} else {
startActivity(intent)

View file

@ -21,36 +21,57 @@ import com.keylesspalace.tusky.util.getFormattedDescription
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.viewdata.AttachmentViewData
import java.util.Random
import kotlin.random.Random
class AccountMediaGridAdapter(
private val alwaysShowSensitiveMedia: Boolean,
private val useBlurhash: Boolean,
context: Context,
private val onAttachmentClickListener: (AttachmentViewData, View) -> Unit
) : PagingDataAdapter<AttachmentViewData, BindingHolder<ItemAccountMediaBinding>>(
object : DiffUtil.ItemCallback<AttachmentViewData>() {
override fun areItemsTheSame(oldItem: AttachmentViewData, newItem: AttachmentViewData): Boolean {
override fun areItemsTheSame(
oldItem: AttachmentViewData,
newItem: AttachmentViewData
): Boolean {
return oldItem.attachment.id == newItem.attachment.id
}
override fun areContentsTheSame(oldItem: AttachmentViewData, newItem: AttachmentViewData): Boolean {
override fun areContentsTheSame(
oldItem: AttachmentViewData,
newItem: AttachmentViewData
): Boolean {
return oldItem == newItem
}
}
) {
private val baseItemBackgroundColor = MaterialColors.getColor(context, com.google.android.material.R.attr.colorSurface, Color.BLACK)
private val videoIndicator = AppCompatResources.getDrawable(context, R.drawable.ic_play_indicator)
private val mediaHiddenDrawable = AppCompatResources.getDrawable(context, R.drawable.ic_hide_media_24dp)
private val baseItemBackgroundColor = MaterialColors.getColor(
context,
com.google.android.material.R.attr.colorSurface,
Color.BLACK
)
private val videoIndicator = AppCompatResources.getDrawable(
context,
R.drawable.ic_play_indicator
)
private val mediaHiddenDrawable = AppCompatResources.getDrawable(
context,
R.drawable.ic_hide_media_24dp
)
private val itemBgBaseHSV = FloatArray(3)
private val random = Random()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemAccountMediaBinding> {
val binding = ItemAccountMediaBinding.inflate(LayoutInflater.from(parent.context), parent, false)
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): BindingHolder<ItemAccountMediaBinding> {
val binding = ItemAccountMediaBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
Color.colorToHSV(baseItemBackgroundColor, itemBgBaseHSV)
itemBgBaseHSV[2] = itemBgBaseHSV[2] + random.nextFloat() / 3f - 1f / 6f
itemBgBaseHSV[2] = itemBgBaseHSV[2] + Random.nextFloat() / 3f - 1f / 6f
binding.root.setBackgroundColor(Color.HSVToColor(itemBgBaseHSV))
return BindingHolder(binding)
}
@ -72,7 +93,11 @@ class AccountMediaGridAdapter(
if (item.attachment.type == Attachment.Type.AUDIO) {
overlay.hide()
imageView.setPadding(context.resources.getDimensionPixelSize(R.dimen.profile_media_audio_icon_padding))
imageView.setPadding(
context.resources.getDimensionPixelSize(
R.dimen.profile_media_audio_icon_padding
)
)
Glide.with(imageView)
.load(R.drawable.ic_music_box_preview_24dp)
@ -80,7 +105,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)

View file

@ -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<String, AttachmentViewData>() {
override suspend fun load(
loadType: LoadType,
state: PagingState<String, AttachmentViewData>
@ -58,7 +59,7 @@ class AccountMediaRemoteMediator(
}
val attachments = statuses.flatMap { status ->
AttachmentViewData.list(status)
AttachmentViewData.list(status, activeAccount.alwaysShowSensitiveMedia)
}
if (loadType == LoadType.REFRESH) {

View file

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

View file

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

View file

@ -19,7 +19,6 @@ 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.preference.PreferenceManager
import androidx.recyclerview.widget.ConcatAdapter
@ -28,10 +27,7 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.SimpleItemAnimator
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.BaseActivity
import com.keylesspalace.tusky.BottomSheetActivity
import com.keylesspalace.tusky.PostLookupFallbackBehavior
import com.keylesspalace.tusky.R
@ -56,13 +52,13 @@ import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.HttpHeaderLink
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.view.EndlessOnScrollListener
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import javax.inject.Inject
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
import retrofit2.Response
import java.io.IOException
import javax.inject.Inject
class AccountListFragment :
Fragment(R.layout.fragment_account_list),
@ -97,7 +93,9 @@ class AccountListFragment :
val layoutManager = LinearLayoutManager(view.context)
binding.recyclerView.layoutManager = layoutManager
(binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
binding.recyclerView.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL))
binding.recyclerView.addItemDecoration(
DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL)
)
binding.swipeRefreshLayout.setOnRefreshListener { fetchAccounts() }
binding.swipeRefreshLayout.setColorSchemeResources(R.color.chinwag_green)
@ -107,15 +105,18 @@ 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)
val followRequestsAdapter =
FollowRequestsAdapter(this, this, animateAvatar, animateEmojis, showBotOverlay)
binding.recyclerView.adapter = ConcatAdapter(headerAdapter, followRequestsAdapter)
followRequestsAdapter
}
@ -140,15 +141,13 @@ class AccountListFragment :
}
override fun onViewTag(tag: String) {
(activity as BaseActivity?)
?.startActivityWithSlideInAnimation(StatusListActivity.newHashtagIntent(requireContext(), tag))
activity?.startActivityWithSlideInAnimation(
StatusListActivity.newHashtagIntent(requireContext(), tag)
)
}
override fun onViewAccount(id: String) {
(activity as BaseActivity?)?.let {
val intent = AccountActivity.getIntent(it, id)
it.startActivityWithSlideInAnimation(intent)
}
activity?.startActivityWithSlideInAnimation(AccountActivity.getIntent(requireContext(), id))
}
override fun onViewUrl(url: String) {
@ -224,7 +223,11 @@ class AccountListFragment :
val unblockedUser = blocksAdapter.removeItem(position)
if (unblockedUser != null) {
Snackbar.make(binding.recyclerView, R.string.confirmation_unblocked, Snackbar.LENGTH_LONG)
Snackbar.make(
binding.recyclerView,
R.string.confirmation_unblocked,
Snackbar.LENGTH_LONG
)
.setAction(R.string.action_undo) {
blocksAdapter.addItem(unblockedUser, position)
onBlock(true, id, position)
@ -242,22 +245,17 @@ class AccountListFragment :
Log.e(TAG, "Failed to $verb account accountId $accountId")
}
override fun onRespondToFollowRequest(
accept: Boolean,
accountId: String,
position: Int
) {
if (accept) {
api.authorizeFollowRequest(accountId)
} else {
api.rejectFollowRequest(accountId)
}.observeOn(AndroidSchedulers.mainThread())
.autoDispose(from(this, Lifecycle.Event.ON_DESTROY))
.subscribe(
{
override fun onRespondToFollowRequest(accept: Boolean, accountId: String, position: Int) {
viewLifecycleOwner.lifecycleScope.launch {
if (accept) {
api.authorizeFollowRequest(accountId)
} else {
api.rejectFollowRequest(accountId)
}.fold(
onSuccess = {
onRespondToFollowRequestSuccess(position)
},
{ throwable ->
onFailure = { throwable ->
val verb = if (accept) {
"accept"
} else {
@ -266,6 +264,7 @@ class AccountListFragment :
Log.e(TAG, "Failed to $verb account id $accountId.", throwable)
}
)
}
}
private fun onRespondToFollowRequestSuccess(position: Int) {
@ -330,7 +329,13 @@ class AccountListFragment :
val linkHeader = response.headers()["Link"]
onFetchAccountsSuccess(accountList, linkHeader)
} catch (exception: IOException) {
} catch (exception: Exception) {
if (exception is CancellationException) {
// Scope is cancelled, probably because the fragment is destroyed.
// We must not touch any views anymore, so rethrow the exception.
// (CancellationException in a cancelled scope is normal and will be ignored)
throw exception
}
onFetchAccountsFailure(exception)
}
}
@ -404,14 +409,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)
}
}
}

View file

@ -60,9 +60,7 @@ abstract class AccountAdapter<AVH : RecyclerView.ViewHolder> internal constructo
}
}
private fun createFooterViewHolder(
parent: ViewGroup
): RecyclerView.ViewHolder {
private fun createFooterViewHolder(parent: ViewGroup): RecyclerView.ViewHolder {
val binding = ItemFooterBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return BindingHolder(binding)
}

View file

@ -39,16 +39,27 @@ class BlocksAdapter(
) {
override fun createAccountViewHolder(parent: ViewGroup): BindingHolder<ItemBlockedUserBinding> {
val binding = ItemBlockedUserBinding.inflate(LayoutInflater.from(parent.context), parent, false)
val binding = ItemBlockedUserBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
return BindingHolder(binding)
}
override fun onBindAccountViewHolder(viewHolder: BindingHolder<ItemBlockedUserBinding>, position: Int) {
override fun onBindAccountViewHolder(
viewHolder: BindingHolder<ItemBlockedUserBinding>,
position: Int
) {
val account = accountList[position]
val binding = viewHolder.binding
val context = binding.root.context
val emojifiedName = account.name.emojify(account.emojis, binding.blockedUserDisplayName, animateEmojis)
val emojifiedName = account.name.emojify(
account.emojis,
binding.blockedUserDisplayName,
animateEmojis
)
binding.blockedUserDisplayName.text = emojifiedName
val formattedUsername = context.getString(R.string.post_username_format, account.username)
binding.blockedUserUsername.text = formattedUsername

View file

@ -44,7 +44,6 @@ class FollowRequestsAdapter(
)
return FollowRequestViewHolder(
binding,
accountActionListener,
linkListener,
showHeader = false
)

View file

@ -27,12 +27,22 @@ class FollowRequestsHeaderAdapter(
private val accountLocked: Boolean
) : RecyclerView.Adapter<BindingHolder<ItemFollowRequestsHeaderBinding>>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemFollowRequestsHeaderBinding> {
val binding = ItemFollowRequestsHeaderBinding.inflate(LayoutInflater.from(parent.context), parent, false)
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): BindingHolder<ItemFollowRequestsHeaderBinding> {
val binding = ItemFollowRequestsHeaderBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
return BindingHolder(binding)
}
override fun onBindViewHolder(viewHolder: BindingHolder<ItemFollowRequestsHeaderBinding>, position: Int) {
override fun onBindViewHolder(
viewHolder: BindingHolder<ItemFollowRequestsHeaderBinding>,
position: Int
) {
viewHolder.binding.root.text = viewHolder.binding.root.context.getString(R.string.follow_requests_info, instanceName)
}

View file

@ -42,18 +42,29 @@ class MutesAdapter(
private val mutingNotificationsMap = HashMap<String, Boolean>()
override fun createAccountViewHolder(parent: ViewGroup): BindingHolder<ItemMutedUserBinding> {
val binding = ItemMutedUserBinding.inflate(LayoutInflater.from(parent.context), parent, false)
val binding = ItemMutedUserBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
return BindingHolder(binding)
}
override fun onBindAccountViewHolder(viewHolder: BindingHolder<ItemMutedUserBinding>, position: Int) {
override fun onBindAccountViewHolder(
viewHolder: BindingHolder<ItemMutedUserBinding>,
position: Int
) {
val account = accountList[position]
val binding = viewHolder.binding
val context = binding.root.context
val mutingNotifications = mutingNotificationsMap[account.id]
val emojifiedName = account.name.emojify(account.emojis, binding.mutedUserDisplayName, animateEmojis)
val emojifiedName = account.name.emojify(
account.emojis,
binding.mutedUserDisplayName,
animateEmojis
)
binding.mutedUserDisplayName.text = emojifiedName
val formattedUsername = context.getString(R.string.post_username_format, account.username)

View file

@ -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,13 +30,13 @@ 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
import com.keylesspalace.tusky.util.parseAsMastodonHtml
import com.keylesspalace.tusky.util.setClickableText
import com.keylesspalace.tusky.util.visible
import java.lang.ref.WeakReference
interface AnnouncementActionListener : LinkListener {
fun openReactionPicker(announcementId: String, target: View)
@ -50,19 +51,35 @@ class AnnouncementAdapter(
private val animateEmojis: Boolean = false
) : RecyclerView.Adapter<BindingHolder<ItemAnnouncementBinding>>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemAnnouncementBinding> {
val binding = ItemAnnouncementBinding.inflate(LayoutInflater.from(parent.context), parent, false)
private val absoluteTimeFormatter = AbsoluteTimeFormatter()
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): BindingHolder<ItemAnnouncementBinding> {
val binding = ItemAnnouncementBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
return BindingHolder(binding)
}
@SuppressLint("SetTextI18n")
override fun onBindViewHolder(holder: BindingHolder<ItemAnnouncementBinding>, 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
val emojifiedText: CharSequence = item.content.parseAsMastodonHtml().emojify(item.emojis, text, animateEmojis)
val emojifiedText: CharSequence = item.content.parseAsMastodonHtml().emojify(
item.emojis,
text,
animateEmojis
)
setClickableText(text, emojifiedText, item.mentions, item.tags, listener)
@ -93,14 +110,20 @@ class AnnouncementAdapter(
// we set the EmojiSpan on a space, because otherwise the Chip won't have the right size
// https://github.com/tuskyapp/Tusky/issues/2308
val spanBuilder = SpannableStringBuilder(" ${reaction.count}")
val span = EmojiSpan(WeakReference(this))
val span = EmojiSpan(this)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
span.contentDescription = reaction.name
}
spanBuilder.setSpan(span, 0, 1, 0)
Glide.with(this)
.asDrawable()
.load(if (animateEmojis) { reaction.url } else { reaction.staticUrl })
.load(
if (animateEmojis) {
reaction.url
} else {
reaction.staticUrl
}
)
.into(span.getTarget(animateEmojis))
this.text = spanBuilder
}

View file

@ -26,6 +26,7 @@ import android.view.View
import android.widget.PopupWindow
import androidx.activity.viewModels
import androidx.core.view.MenuProvider
import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
@ -44,6 +45,7 @@ import com.keylesspalace.tusky.util.Loading
import com.keylesspalace.tusky.util.Success
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
import com.keylesspalace.tusky.util.unsafeLazy
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.view.EmojiPicker
@ -52,6 +54,7 @@ import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizeDp
import javax.inject.Inject
import kotlinx.coroutines.launch
class AnnouncementsActivity :
BottomSheetActivity(),
@ -110,35 +113,46 @@ class AnnouncementsActivity :
binding.announcementsList.adapter = adapter
viewModel.announcements.observe(this) {
when (it) {
is Success -> {
binding.progressBar.hide()
binding.swipeRefreshLayout.isRefreshing = false
if (it.data.isNullOrEmpty()) {
binding.errorMessageView.setup(R.drawable.elephant_friend_empty, R.string.no_announcements)
binding.errorMessageView.show()
} else {
lifecycleScope.launch {
viewModel.announcements.collect {
if (it == null) return@collect
when (it) {
is Success -> {
binding.progressBar.hide()
binding.swipeRefreshLayout.isRefreshing = false
if (it.data.isNullOrEmpty()) {
binding.errorMessageView.setup(
R.drawable.elephant_friend_empty,
R.string.no_announcements
)
binding.errorMessageView.show()
} else {
binding.errorMessageView.hide()
}
adapter.updateList(it.data ?: listOf())
}
is Loading -> {
binding.errorMessageView.hide()
}
adapter.updateList(it.data ?: listOf())
}
is Loading -> {
binding.errorMessageView.hide()
}
is Error -> {
binding.progressBar.hide()
binding.swipeRefreshLayout.isRefreshing = false
binding.errorMessageView.setup(R.drawable.elephant_error, R.string.error_generic) {
refreshAnnouncements()
is Error -> {
binding.progressBar.hide()
binding.swipeRefreshLayout.isRefreshing = false
binding.errorMessageView.setup(
R.drawable.errorphant_error,
R.string.error_generic
) {
refreshAnnouncements()
}
binding.errorMessageView.show()
}
binding.errorMessageView.show()
}
}
}
viewModel.emojis.observe(this) {
picker.adapter = EmojiAdapter(it, this, animateEmojis)
lifecycleScope.launch {
viewModel.emoji.collect {
picker.adapter = EmojiAdapter(it, this@AnnouncementsActivity, animateEmojis)
}
}
viewModel.load()

View file

@ -16,8 +16,6 @@
package com.keylesspalace.tusky.components.announcements
import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.connyduck.calladapter.networkresult.fold
@ -31,8 +29,11 @@ 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 kotlinx.coroutines.launch
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class AnnouncementsViewModel @Inject constructor(
private val instanceInfoRepo: InstanceInfoRepository,
@ -40,31 +41,33 @@ class AnnouncementsViewModel @Inject constructor(
private val eventHub: EventHub
) : ViewModel() {
private val announcementsMutable = MutableLiveData<Resource<List<Announcement>>>()
val announcements: LiveData<Resource<List<Announcement>>> = announcementsMutable
private val _announcements = MutableStateFlow(null as Resource<List<Announcement>>?)
val announcements: StateFlow<Resource<List<Announcement>>?> = _announcements.asStateFlow()
private val emojisMutable = MutableLiveData<List<Emoji>>()
val emojis: LiveData<List<Emoji>> = emojisMutable
private val _emoji = MutableStateFlow(emptyList<Emoji>())
val emoji: StateFlow<List<Emoji>> = _emoji.asStateFlow()
init {
viewModelScope.launch {
emojisMutable.postValue(instanceInfoRepo.getEmojis())
_emoji.value = instanceInfoRepo.getEmojis()
}
}
fun load() {
viewModelScope.launch {
announcementsMutable.postValue(Loading())
_announcements.value = Loading()
mastodonApi.listAnnouncements()
.fold(
{
announcementsMutable.postValue(Success(it))
_announcements.value = Success(it)
it.filter { announcement -> !announcement.read }
.forEach { announcement ->
mastodonApi.dismissAnnouncement(announcement.id)
.fold(
{
eventHub.dispatch(AnnouncementReadEvent(announcement.id))
eventHub.dispatch(
AnnouncementReadEvent(announcement.id)
)
},
{ throwable ->
Log.d(
@ -77,7 +80,7 @@ class AnnouncementsViewModel @Inject constructor(
}
},
{
announcementsMutable.postValue(Error(cause = it))
_announcements.value = Error(cause = it)
}
)
}
@ -88,9 +91,9 @@ class AnnouncementsViewModel @Inject constructor(
mastodonApi.addAnnouncementReaction(announcementId, name)
.fold(
{
announcementsMutable.postValue(
_announcements.value =
Success(
announcements.value!!.data!!.map { announcement ->
announcements.value?.data?.map { announcement ->
if (announcement.id == announcementId) {
announcement.copy(
reactions = if (announcement.reactions.find { reaction -> reaction.name == name } != null) {
@ -107,7 +110,7 @@ class AnnouncementsViewModel @Inject constructor(
} else {
listOf(
*announcement.reactions.toTypedArray(),
emojis.value!!.find { emoji -> emoji.shortcode == name }!!.run {
emoji.value.find { emoji -> emoji.shortcode == name }!!.run {
Announcement.Reaction(
name,
1,
@ -124,7 +127,6 @@ class AnnouncementsViewModel @Inject constructor(
}
}
)
)
},
{
Log.w(TAG, "Failed to add reaction to the announcement.", it)
@ -138,7 +140,7 @@ class AnnouncementsViewModel @Inject constructor(
mastodonApi.removeAnnouncementReaction(announcementId, name)
.fold(
{
announcementsMutable.postValue(
_announcements.value =
Success(
announcements.value!!.data!!.map { announcement ->
if (announcement.id == announcementId) {
@ -163,7 +165,6 @@ class AnnouncementsViewModel @Inject constructor(
}
}
)
)
},
{
Log.w(TAG, "Failed to remove reaction from the announcement.", it)

View file

@ -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
@ -26,6 +25,7 @@ import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.icu.text.BreakIterator
import android.net.Uri
import android.os.Build
import android.os.Bundle
@ -70,6 +70,7 @@ import com.canhub.cropper.CropImage
import com.canhub.cropper.CropImageContract
import com.canhub.cropper.options
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback
import com.google.android.material.color.MaterialColors
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.BaseActivity
@ -94,8 +95,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
@ -114,11 +116,6 @@ 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.flow.collect
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import java.io.File
import java.io.IOException
import java.text.DecimalFormat
@ -126,6 +123,11 @@ import java.util.Locale
import javax.inject.Inject
import kotlin.math.max
import kotlin.math.min
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
class ComposeActivity :
BaseActivity(),
@ -162,14 +164,23 @@ class ComposeActivity :
private var maxUploadMediaNumber = InstanceInfoRepository.DEFAULT_MAX_MEDIA_ATTACHMENTS
private val takePicture = registerForActivityResult(ActivityResultContracts.TakePicture()) { success ->
if (success) {
pickMedia(photoUploadUri!!)
private val takePicture =
registerForActivityResult(ActivityResultContracts.TakePicture()) { success ->
if (success) {
pickMedia(photoUploadUri!!)
}
}
}
private val pickMediaFile = registerForActivityResult(PickMediaFiles()) { uris ->
if (viewModel.media.value.size + uris.size > maxUploadMediaNumber) {
Toast.makeText(this, resources.getQuantityString(R.plurals.error_upload_max_media_reached, maxUploadMediaNumber, maxUploadMediaNumber), Toast.LENGTH_SHORT).show()
Toast.makeText(
this,
resources.getQuantityString(
R.plurals.error_upload_max_media_reached,
maxUploadMediaNumber,
maxUploadMediaNumber
),
Toast.LENGTH_SHORT
).show()
} else {
uris.forEach { uri ->
pickMedia(uri)
@ -190,7 +201,8 @@ class ComposeActivity :
uriNew,
size,
itemOld.description,
null, // Intentionally reset focus when cropping
// Intentionally reset focus when cropping
null,
itemOld
)
}
@ -204,27 +216,30 @@ class ComposeActivity :
viewModel.cropImageItemOld = null
}
private val onBackPressedCallback = object : OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
if (composeOptionsBehavior.state == BottomSheetBehavior.STATE_EXPANDED ||
addMediaBehavior.state == BottomSheetBehavior.STATE_EXPANDED ||
emojiBehavior.state == BottomSheetBehavior.STATE_EXPANDED ||
scheduleBehavior.state == BottomSheetBehavior.STATE_EXPANDED
) {
composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN
addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN
emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN
scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN
return
}
handleCloseButton()
}
}
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)
}
@ -236,7 +251,11 @@ class ComposeActivity :
val mediaAdapter = MediaPreviewAdapter(
this,
onAddCaption = { item ->
CaptionDialog.newInstance(item.localId, item.description, item.uri).show(supportFragmentManager, "caption_dialog")
CaptionDialog.newInstance(
item.localId,
item.description,
item.uri
).show(supportFragmentManager, "caption_dialog")
},
onAddFocus = { item ->
makeFocusDialog(item.focus, item.uri) { newFocus ->
@ -254,7 +273,11 @@ class ComposeActivity :
/* If the composer is started up as a reply to another post, override the "starting" state
* based on what the intent from the reply request passes. */
val composeOptions: ComposeOptions? = IntentCompat.getParcelableExtra(intent, COMPOSE_OPTIONS_EXTRA, ComposeOptions::class.java)
val composeOptions: ComposeOptions? = IntentCompat.getParcelableExtra(
intent,
COMPOSE_OPTIONS_EXTRA,
ComposeOptions::class.java
)
viewModel.setup(composeOptions)
setupButtons()
@ -280,7 +303,7 @@ class ComposeActivity :
binding.composeScheduleView.setDateTime(composeOptions?.scheduledAt)
}
setupLanguageSpinner(getInitialLanguages(composeOptions?.language, accountManager.activeAccount))
setupLanguageSpinner(getInitialLanguages(composeOptions?.language, activeAccount))
setupComposeField(preferences, viewModel.startingText)
setupContentWarningField(composeOptions?.contentWarning)
setupPollView()
@ -317,12 +340,20 @@ class ComposeActivity :
if (type.startsWith("image/") || type.startsWith("video/") || type.startsWith("audio/")) {
when (intent.action) {
Intent.ACTION_SEND -> {
IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)?.let { uri ->
IntentCompat.getParcelableExtra(
intent,
Intent.EXTRA_STREAM,
Uri::class.java
)?.let { uri ->
pickMedia(uri)
}
}
Intent.ACTION_SEND_MULTIPLE -> {
IntentCompat.getParcelableArrayListExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)?.forEach { uri ->
IntentCompat.getParcelableArrayListExtra(
intent,
Intent.EXTRA_STREAM,
Uri::class.java
)?.forEach { uri ->
pickMedia(uri)
}
}
@ -342,7 +373,13 @@ class ComposeActivity :
val end = binding.composeEditField.selectionEnd.coerceAtLeast(0)
val left = min(start, end)
val right = max(start, end)
binding.composeEditField.text.replace(left, right, shareBody, 0, shareBody.length)
binding.composeEditField.text.replace(
left,
right,
shareBody,
0,
shareBody.length
)
// move edittext cursor to first when shareBody parsed
binding.composeEditField.text.insert(0, "\n")
binding.composeEditField.setSelection(0)
@ -355,23 +392,48 @@ class ComposeActivity :
if (replyingStatusAuthor != null) {
binding.composeReplyView.show()
binding.composeReplyView.text = getString(R.string.replying_to, replyingStatusAuthor)
val arrowDownIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_arrow_drop_down).apply { sizeDp = 12 }
val arrowDownIcon = IconicsDrawable(
this,
GoogleMaterial.Icon.gmd_arrow_drop_down
).apply {
sizeDp = 12
}
setDrawableTint(this, arrowDownIcon, android.R.attr.textColorTertiary)
binding.composeReplyView.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, arrowDownIcon, null)
binding.composeReplyView.setCompoundDrawablesRelativeWithIntrinsicBounds(
null,
null,
arrowDownIcon,
null
)
binding.composeReplyView.setOnClickListener {
TransitionManager.beginDelayedTransition(binding.composeReplyContentView.parent as ViewGroup)
TransitionManager.beginDelayedTransition(
binding.composeReplyContentView.parent as ViewGroup
)
if (binding.composeReplyContentView.isVisible) {
binding.composeReplyContentView.hide()
binding.composeReplyView.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, arrowDownIcon, null)
binding.composeReplyView.setCompoundDrawablesRelativeWithIntrinsicBounds(
null,
null,
arrowDownIcon,
null
)
} else {
binding.composeReplyContentView.show()
val arrowUpIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_arrow_drop_up).apply { sizeDp = 12 }
val arrowUpIcon = IconicsDrawable(
this,
GoogleMaterial.Icon.gmd_arrow_drop_up
).apply { sizeDp = 12 }
setDrawableTint(this, arrowUpIcon, android.R.attr.textColorTertiary)
binding.composeReplyView.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, arrowUpIcon, null)
binding.composeReplyView.setCompoundDrawablesRelativeWithIntrinsicBounds(
null,
null,
arrowUpIcon,
null
)
}
}
}
@ -382,13 +444,21 @@ class ComposeActivity :
if (startingContentWarning != null) {
binding.composeContentWarningField.setText(startingContentWarning)
}
binding.composeContentWarningField.doOnTextChanged { _, _, _, _ -> updateVisibleCharactersLeft() }
binding.composeContentWarningField.doOnTextChanged { newContentWarning, _, _, _ ->
updateVisibleCharactersLeft()
viewModel.updateContentWarning(newContentWarning?.toString())
}
}
private fun setupComposeField(preferences: SharedPreferences, startingText: String?) {
binding.composeEditField.setOnReceiveContentListener(this)
binding.composeEditField.setOnKeyListener { _, keyCode, event -> this.onKeyDown(keyCode, event) }
binding.composeEditField.setOnKeyListener { _, keyCode, event ->
this.onKeyDown(
keyCode,
event
)
}
binding.composeEditField.setAdapter(
ComposeAutoCompleteAdapter(
@ -408,6 +478,7 @@ class ComposeActivity :
binding.composeEditField.doAfterTextChanged { editable ->
highlightSpans(editable!!, mentionColour)
updateVisibleCharactersLeft()
viewModel.updateContent(editable.toString())
}
// work around Android platform bug -> https://issuetracker.google.com/issues/67102093
@ -433,7 +504,9 @@ class ComposeActivity :
}
lifecycleScope.launch {
viewModel.showContentWarning.combine(viewModel.markMediaAsSensitive) { showContentWarning, markSensitive ->
viewModel.showContentWarning.combine(
viewModel.markMediaAsSensitive
) { showContentWarning, markSensitive ->
updateSensitiveMediaToggle(markSensitive, showContentWarning)
showContentWarning(showContentWarning)
}.collect()
@ -448,7 +521,10 @@ class ComposeActivity :
mediaAdapter.submitList(media)
binding.composeMediaPreviewBar.visible(media.isNotEmpty())
updateSensitiveMediaToggle(viewModel.markMediaAsSensitive.value, viewModel.showContentWarning.value)
updateSensitiveMediaToggle(
viewModel.markMediaAsSensitive.value,
viewModel.showContentWarning.value
)
}
}
@ -485,10 +561,21 @@ 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
)
)
}
}
}
lifecycleScope.launch {
viewModel.closeConfirmation.collect {
updateOnBackPressedCallbackState()
}
}
}
private fun setupButtons() {
@ -499,6 +586,17 @@ class ComposeActivity :
scheduleBehavior = BottomSheetBehavior.from(binding.composeScheduleView)
emojiBehavior = BottomSheetBehavior.from(binding.emojiView)
val bottomSheetCallback = object : BottomSheetCallback() {
override fun onStateChanged(bottomSheet: View, newState: Int) {
updateOnBackPressedCallbackState()
}
override fun onSlide(bottomSheet: View, slideOffset: Float) { }
}
composeOptionsBehavior.addBottomSheetCallback(bottomSheetCallback)
addMediaBehavior.addBottomSheetCallback(bottomSheetCallback)
scheduleBehavior.addBottomSheetCallback(bottomSheetCallback)
emojiBehavior.addBottomSheetCallback(bottomSheetCallback)
enableButton(binding.composeEmojiButton, clickable = false, colorActive = false)
// Setup the interface buttons.
@ -519,46 +617,58 @@ class ComposeActivity :
val textColor = MaterialColors.getColor(binding.root, android.R.attr.textColorTertiary)
val cameraIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_camera_alt).apply { colorInt = textColor; sizeDp = 18 }
binding.actionPhotoTake.setCompoundDrawablesRelativeWithIntrinsicBounds(cameraIcon, null, null, null)
val cameraIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_camera_alt).apply {
colorInt = textColor
sizeDp = 18
}
binding.actionPhotoTake.setCompoundDrawablesRelativeWithIntrinsicBounds(
cameraIcon,
null,
null,
null
)
val imageIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_image).apply { colorInt = textColor; sizeDp = 18 }
binding.actionPhotoPick.setCompoundDrawablesRelativeWithIntrinsicBounds(imageIcon, null, null, null)
val imageIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_image).apply {
colorInt = textColor
sizeDp = 18
}
binding.actionPhotoPick.setCompoundDrawablesRelativeWithIntrinsicBounds(
imageIcon,
null,
null,
null
)
val pollIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_poll).apply { colorInt = textColor; sizeDp = 18 }
binding.addPollTextActionTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(pollIcon, null, null, null)
val pollIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_poll).apply {
colorInt = textColor
sizeDp = 18
}
binding.addPollTextActionTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(
pollIcon,
null,
null,
null
)
binding.actionPhotoTake.visible(Intent(MediaStore.ACTION_IMAGE_CAPTURE).resolveActivity(packageManager) != null)
binding.actionPhotoTake.visible(
Intent(MediaStore.ACTION_IMAGE_CAPTURE).resolveActivity(packageManager) != null
)
binding.actionPhotoTake.setOnClickListener { initiateCameraApp() }
binding.actionPhotoPick.setOnClickListener { onMediaPick() }
binding.addPollTextActionTextView.setOnClickListener { openPollDialog() }
onBackPressedDispatcher.addCallback(
this,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
if (composeOptionsBehavior.state == BottomSheetBehavior.STATE_EXPANDED ||
addMediaBehavior.state == BottomSheetBehavior.STATE_EXPANDED ||
emojiBehavior.state == BottomSheetBehavior.STATE_EXPANDED ||
scheduleBehavior.state == BottomSheetBehavior.STATE_EXPANDED
) {
composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN
addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN
emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN
scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN
return
}
handleCloseButton()
}
}
)
onBackPressedDispatcher.addCallback(this, onBackPressedCallback)
}
private fun setupLanguageSpinner(initialLanguages: List<String>) {
binding.composePostLanguageButton.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) {
override fun onItemSelected(
parent: AdapterView<*>,
view: View?,
position: Int,
id: Long
) {
viewModel.postLanguage = (parent.adapter.getItem(position) as Locale).modernLanguageCode
}
@ -588,7 +698,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,
@ -601,10 +711,23 @@ class ComposeActivity :
)
}
private fun updateOnBackPressedCallbackState() {
val confirmation = viewModel.closeConfirmation.value
onBackPressedCallback.isEnabled = confirmation != ConfirmationKind.NONE ||
composeOptionsBehavior.state != BottomSheetBehavior.STATE_HIDDEN ||
addMediaBehavior.state != BottomSheetBehavior.STATE_HIDDEN ||
emojiBehavior.state != BottomSheetBehavior.STATE_HIDDEN ||
scheduleBehavior.state != BottomSheetBehavior.STATE_HIDDEN
}
private fun replaceTextAtCaret(text: CharSequence) {
// If you select "backward" in an editable, you get SelectionStart > SelectionEnd
val start = binding.composeEditField.selectionStart.coerceAtMost(binding.composeEditField.selectionEnd)
val end = binding.composeEditField.selectionStart.coerceAtLeast(binding.composeEditField.selectionEnd)
val start = binding.composeEditField.selectionStart.coerceAtMost(
binding.composeEditField.selectionEnd
)
val end = binding.composeEditField.selectionStart.coerceAtLeast(
binding.composeEditField.selectionEnd
)
val textToInsert = if (start > 0 && !binding.composeEditField.text[start - 1].isWhitespace()) {
" $text"
} else {
@ -618,8 +741,12 @@ class ComposeActivity :
fun prependSelectedWordsWith(text: CharSequence) {
// If you select "backward" in an editable, you get SelectionStart > SelectionEnd
val start = binding.composeEditField.selectionStart.coerceAtMost(binding.composeEditField.selectionEnd)
val end = binding.composeEditField.selectionStart.coerceAtLeast(binding.composeEditField.selectionEnd)
val start = binding.composeEditField.selectionStart.coerceAtMost(
binding.composeEditField.selectionEnd
)
val end = binding.composeEditField.selectionStart.coerceAtLeast(
binding.composeEditField.selectionEnd
)
val editorText = binding.composeEditField.text
if (start == end) {
@ -687,7 +814,10 @@ class ComposeActivity :
this.viewModel.toggleMarkSensitive()
}
private fun updateSensitiveMediaToggle(markMediaSensitive: Boolean, contentWarningShown: Boolean) {
private fun updateSensitiveMediaToggle(
markMediaSensitive: Boolean,
contentWarningShown: Boolean
) {
if (viewModel.media.value.isEmpty()) {
binding.composeHideMediaButton.hide()
binding.descriptionMissingWarningButton.hide()
@ -704,7 +834,10 @@ class ComposeActivity :
getColor(R.color.chinwag_green)
} else {
binding.composeHideMediaButton.setImageResource(R.drawable.ic_eye_24dp)
MaterialColors.getColor(binding.composeHideMediaButton, android.R.attr.textColorTertiary)
MaterialColors.getColor(
binding.composeHideMediaButton,
android.R.attr.textColorTertiary
)
}
}
binding.composeHideMediaButton.drawable.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN)
@ -726,7 +859,10 @@ class ComposeActivity :
enableButton(binding.composeScheduleButton, clickable = false, colorActive = false)
} else {
@ColorInt val color = if (binding.composeScheduleView.time == null) {
MaterialColors.getColor(binding.composeScheduleButton, android.R.attr.textColorTertiary)
MaterialColors.getColor(
binding.composeScheduleButton,
android.R.attr.textColorTertiary
)
} else {
getColor(R.color.chinwag_green)
}
@ -757,7 +893,11 @@ class ComposeActivity :
binding.composeToggleVisibilityButton.setImageResource(iconRes)
if (viewModel.editing) {
// Can't update visibility on published status
enableButton(binding.composeToggleVisibilityButton, clickable = false, colorActive = false)
enableButton(
binding.composeToggleVisibilityButton,
clickable = false,
colorActive = false
)
}
}
@ -794,7 +934,11 @@ class ComposeActivity :
private fun showEmojis() {
binding.emojiView.adapter?.let {
if (it.itemCount == 0) {
val errorMessage = getString(R.string.error_no_custom_emojis, accountManager.activeAccount!!.domain)
val errorMessage =
getString(
R.string.error_no_custom_emojis,
accountManager.activeAccount!!.domain
)
displayTransientMessage(errorMessage)
} else {
if (emojiBehavior.state == BottomSheetBehavior.STATE_HIDDEN || emojiBehavior.state == BottomSheetBehavior.STATE_COLLAPSED) {
@ -822,7 +966,7 @@ class ComposeActivity :
private fun onMediaPick() {
addMediaBehavior.addBottomSheetCallback(
object : BottomSheetBehavior.BottomSheetCallback() {
object : BottomSheetCallback() {
override fun onStateChanged(bottomSheet: View, newState: Int) {
// Wait until bottom sheet is not collapsed and show next screen after
if (newState == BottomSheetBehavior.STATE_COLLAPSED) {
@ -861,9 +1005,14 @@ class ComposeActivity :
private fun setupPollView() {
val margin = resources.getDimensionPixelSize(R.dimen.compose_media_preview_margin)
val marginBottom = resources.getDimensionPixelSize(R.dimen.compose_media_preview_margin_bottom)
val marginBottom = resources.getDimensionPixelSize(
R.dimen.compose_media_preview_margin_bottom
)
val layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
val layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
layoutParams.setMargins(margin, margin, margin, marginBottom)
binding.pollPreview.layoutParams = layoutParams
@ -885,13 +1034,13 @@ class ComposeActivity :
}
private fun removePoll() {
viewModel.poll.value = null
viewModel.updatePoll(null)
binding.pollPreview.hide()
}
override fun onVisibilityChanged(visibility: Status.Visibility) {
composeOptionsBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
viewModel.statusVisibility.value = visibility
viewModel.changeStatusVisibility(visibility)
}
@VisibleForTesting
@ -914,7 +1063,10 @@ class ComposeActivity :
val textColor = if (remainingLength < 0) {
getColor(R.color.tusky_red)
} else {
MaterialColors.getColor(binding.composeCharactersLeftView, android.R.attr.textColorTertiary)
MaterialColors.getColor(
binding.composeCharactersLeftView,
android.R.attr.textColorTertiary
)
}
binding.composeCharactersLeftView.setTextColor(textColor)
}
@ -926,7 +1078,9 @@ class ComposeActivity :
}
private fun verifyScheduledTime(): Boolean {
return binding.composeScheduleView.verifyScheduledTime(binding.composeScheduleView.getDateTime(viewModel.scheduledAt.value))
return binding.composeScheduleView.verifyScheduledTime(
binding.composeScheduleView.getDateTime(viewModel.scheduledAt.value)
)
}
private fun onSendClicked() {
@ -976,7 +1130,11 @@ class ComposeActivity :
}
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray) {
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE) {
@ -1051,14 +1209,20 @@ class ComposeActivity :
val tempFile = createNewImageFile(this, if (isPng) ".png" else ".jpg")
// "Authority" must be the same as the android:authorities string in AndroidManifest.xml
val uriNew = FileProvider.getUriForFile(this, BuildConfig.APPLICATION_ID + ".fileprovider", tempFile)
val uriNew = FileProvider.getUriForFile(
this,
BuildConfig.APPLICATION_ID + ".fileprovider",
tempFile
)
viewModel.cropImageItemOld = item
cropImage.launch(
options(uri = item.uri) {
setOutputUri(uriNew)
setOutputCompressFormat(if (isPng) Bitmap.CompressFormat.PNG else Bitmap.CompressFormat.JPEG)
setOutputCompressFormat(
if (isPng) Bitmap.CompressFormat.PNG else Bitmap.CompressFormat.JPEG
)
}
)
}
@ -1067,9 +1231,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) {
val 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.##")
@ -1077,7 +1260,9 @@ class ComposeActivity :
val formattedSize = decimalFormat.format(allowedSizeInMb)
getString(R.string.error_multimedia_size_limit, formattedSize)
}
is VideoOrImageException -> getString(R.string.error_media_upload_image_or_video)
is VideoOrImageException -> getString(
R.string.error_media_upload_image_or_video
)
else -> getString(R.string.error_media_upload_opening)
}
displayTransientMessage(errorString)
@ -1086,16 +1271,23 @@ class ComposeActivity :
}
private fun showContentWarning(show: Boolean) {
TransitionManager.beginDelayedTransition(binding.composeContentWarningBar.parent as ViewGroup)
TransitionManager.beginDelayedTransition(
binding.composeContentWarningBar.parent as ViewGroup
)
@ColorInt val color = if (show) {
binding.composeContentWarningBar.show()
binding.composeContentWarningField.setSelection(binding.composeContentWarningField.text.length)
binding.composeContentWarningField.setSelection(
binding.composeContentWarningField.text.length
)
binding.composeContentWarningField.requestFocus()
getColor(R.color.chinwag_green)
} else {
binding.composeContentWarningBar.hide()
binding.composeEditField.requestFocus()
MaterialColors.getColor(binding.composeContentWarningButton, android.R.attr.textColorTertiary)
MaterialColors.getColor(
binding.composeContentWarningButton,
android.R.attr.textColorTertiary
)
}
binding.composeContentWarningButton.drawable.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN)
}
@ -1130,10 +1322,10 @@ class ComposeActivity :
private fun handleCloseButton() {
val contentText = binding.composeEditField.text.toString()
val contentWarning = binding.composeContentWarningField.text.toString()
when (viewModel.handleCloseButton(contentText, contentWarning)) {
when (viewModel.closeConfirmation.value) {
ConfirmationKind.NONE -> {
viewModel.stopUploads()
finishWithoutSlideOutAnimation()
finish()
}
ConfirmationKind.SAVE_OR_DISCARD ->
getSaveAsDraftOrDiscardDialog(contentText, contentWarning).show()
@ -1149,7 +1341,10 @@ class ComposeActivity :
/**
* User is editing a new post, and can either save the changes as a draft or discard them.
*/
private fun getSaveAsDraftOrDiscardDialog(contentText: String, contentWarning: String): AlertDialog.Builder {
private fun getSaveAsDraftOrDiscardDialog(
contentText: String,
contentWarning: String
): AlertDialog.Builder {
val warning = if (viewModel.media.value.isNotEmpty()) {
R.string.compose_save_draft_loses_media
} else {
@ -1172,7 +1367,10 @@ class ComposeActivity :
* User is editing an existing draft, and can either update the draft with the new changes or
* discard them.
*/
private fun getUpdateDraftOrDiscardDialog(contentText: String, contentWarning: String): AlertDialog.Builder {
private fun getUpdateDraftOrDiscardDialog(
contentText: String,
contentWarning: String
): AlertDialog.Builder {
val warning = if (viewModel.media.value.isNotEmpty()) {
R.string.compose_save_draft_loses_media
} else {
@ -1187,7 +1385,7 @@ class ComposeActivity :
}
.setNegativeButton(R.string.action_discard) { _, _ ->
viewModel.stopUploads()
finishWithoutSlideOutAnimation()
finish()
}
}
@ -1203,7 +1401,7 @@ class ComposeActivity :
}
.setNegativeButton(R.string.action_discard) { _, _ ->
viewModel.stopUploads()
finishWithoutSlideOutAnimation()
finish()
}
}
@ -1217,7 +1415,7 @@ class ComposeActivity :
.setPositiveButton(R.string.action_delete) { _, _ ->
viewModel.deleteDraft()
viewModel.stopUploads()
finishWithoutSlideOutAnimation()
finish()
}
.setNegativeButton(R.string.action_continue_edit) { _, _ ->
// Do nothing, dialog will dismiss, user can continue editing
@ -1226,7 +1424,7 @@ class ComposeActivity :
private fun deleteDraftAndFinish() {
viewModel.deleteDraft()
finishWithoutSlideOutAnimation()
finish()
}
private fun saveDraftAndFinish(contentText: String, contentWarning: String) {
@ -1244,7 +1442,7 @@ class ComposeActivity :
}
viewModel.saveDraft(contentText, contentWarning)
dialog?.cancel()
finishWithoutSlideOutAnimation()
finish()
}
}
@ -1276,10 +1474,15 @@ class ComposeActivity :
val state: State
) {
enum class Type {
IMAGE, VIDEO, AUDIO;
IMAGE,
VIDEO,
AUDIO
}
enum class State {
UPLOADING, UNPROCESSED, PROCESSED, PUBLISHED
UPLOADING,
UNPROCESSED,
PROCESSED,
PUBLISHED
}
}
@ -1350,8 +1553,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"
@ -1359,26 +1560,12 @@ 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
): Intent {
fun startIntent(context: Context, 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)
}
}
}
@ -1410,7 +1597,7 @@ class ComposeActivity :
*/
@JvmStatic
fun statusLength(body: Spanned, contentWarning: Spanned?, urlLength: Int): Int {
var length = body.length - body.getSpans(0, body.length, URLSpan::class.java)
var length = body.toString().perceivedCharacterLength() - body.getSpans(0, body.length, URLSpan::class.java)
.fold(0) { acc, span ->
// Accumulate a count of characters to be *ignored* in the final length
acc + when (span) {
@ -1423,15 +1610,25 @@ class ComposeActivity :
}
else -> {
// Expected to be negative if the URL length < maxUrlLength
span.url.length - urlLength
span.url.perceivedCharacterLength() - urlLength
}
}
}
// Content warning text is treated as is, URLs or mentions there are not special
contentWarning?.let { length += it.length }
contentWarning?.let { length += it.toString().perceivedCharacterLength() }
return length
}
// String.length would count emojis as multiple characters but Mastodon counts them as 1, so we need this workaround
private fun String.perceivedCharacterLength(): Int {
val breakIterator = BreakIterator.getCharacterInstance()
breakIterator.setText(this)
var count = 0
while (breakIterator.next() != BreakIterator.DONE) {
count++
}
return count
}
}
}

View file

@ -108,7 +108,9 @@ class ComposeAutoCompleteAdapter(
val account = accountResult.account
binding.username.text = context.getString(R.string.post_username_format, account.username)
binding.displayName.text = account.name.emojify(account.emojis, binding.displayName, animateEmojis)
val avatarRadius = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_42dp)
val avatarRadius = context.resources.getDimensionPixelSize(
R.dimen.avatar_radius_42dp
)
loadAvatar(
account.avatar,
binding.avatar,
@ -143,12 +145,12 @@ class ComposeAutoCompleteAdapter(
}
}
sealed class AutocompleteResult {
class AccountResult(val account: TimelineAccount) : AutocompleteResult()
sealed interface AutocompleteResult {
class AccountResult(val account: TimelineAccount) : AutocompleteResult
class HashtagResult(val hashtag: String) : AutocompleteResult()
class HashtagResult(val hashtag: String) : AutocompleteResult
class EmojiResult(val emoji: Emoji) : AutocompleteResult()
class EmojiResult(val emoji: Emoji) : AutocompleteResult
}
interface AutocompletionProvider {

View file

@ -38,22 +38,24 @@ import com.keylesspalace.tusky.service.MediaToSend
import com.keylesspalace.tusky.service.ServiceClient
import com.keylesspalace.tusky.service.StatusToSend
import com.keylesspalace.tusky.util.randomAlphanumericString
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import javax.inject.Inject
@OptIn(FlowPreview::class)
class ComposeViewModel @Inject constructor(
private val api: MastodonApi,
private val accountManager: AccountManager,
@ -78,34 +80,61 @@ class ComposeViewModel @Inject constructor(
private var modifiedInitialState: Boolean = false
private var hasScheduledTimeChanged: Boolean = false
val instanceInfo: SharedFlow<InstanceInfo> = instanceInfoRepo::getInstanceInfo.asFlow()
private var currentContent: String? = ""
private var currentContentWarning: String? = ""
val instanceInfo: SharedFlow<InstanceInfo> = instanceInfoRepo::getUpdatedInstanceInfoOrFallback.asFlow()
.shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1)
val emoji: SharedFlow<List<Emoji>> = instanceInfoRepo::getEmojis.asFlow()
.shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1)
val markMediaAsSensitive: MutableStateFlow<Boolean> =
private val _markMediaAsSensitive =
MutableStateFlow(accountManager.activeAccount?.defaultMediaSensitivity ?: false)
val markMediaAsSensitive: StateFlow<Boolean> = _markMediaAsSensitive.asStateFlow()
val statusVisibility: MutableStateFlow<Status.Visibility> = MutableStateFlow(Status.Visibility.UNKNOWN)
val showContentWarning: MutableStateFlow<Boolean> = MutableStateFlow(false)
val poll: MutableStateFlow<NewPoll?> = MutableStateFlow(null)
val scheduledAt: MutableStateFlow<String?> = MutableStateFlow(null)
private val _statusVisibility = MutableStateFlow(Status.Visibility.UNKNOWN)
val statusVisibility: StateFlow<Status.Visibility> = _statusVisibility.asStateFlow()
val media: MutableStateFlow<List<QueuedMedia>> = MutableStateFlow(emptyList())
val uploadError = MutableSharedFlow<Throwable>(replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
private val _showContentWarning = MutableStateFlow(false)
val showContentWarning: StateFlow<Boolean> = _showContentWarning.asStateFlow()
lateinit var composeKind: ComposeKind
private val _poll = MutableStateFlow(null as NewPoll?)
val poll: StateFlow<NewPoll?> = _poll.asStateFlow()
private val _scheduledAt = MutableStateFlow(null as String?)
val scheduledAt: StateFlow<String?> = _scheduledAt.asStateFlow()
private val _media = MutableStateFlow(emptyList<QueuedMedia>())
val media: StateFlow<List<QueuedMedia>> = _media.asStateFlow()
private val _uploadError = MutableSharedFlow<Throwable>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val uploadError: SharedFlow<Throwable> = _uploadError.asSharedFlow()
private val _closeConfirmation = MutableStateFlow(ConfirmationKind.NONE)
val closeConfirmation: StateFlow<ConfirmationKind> = _closeConfirmation.asStateFlow()
private lateinit var composeKind: ComposeKind
// Used in ComposeActivity to pass state to result function when cropImage contract inflight
var cropImageItemOld: QueuedMedia? = null
private var setupComplete = false
suspend fun pickMedia(mediaUri: Uri, description: String? = null, focus: Attachment.Focus? = null): Result<QueuedMedia> = withContext(Dispatchers.IO) {
suspend fun pickMedia(
mediaUri: Uri,
description: String? = null,
focus: Attachment.Focus? = null
): Result<QueuedMedia> = withContext(
Dispatchers.IO
) {
try {
val (type, uri, size) = mediaUploader.prepareMedia(mediaUri, instanceInfo.first())
val mediaItems = media.value
val mediaItems = _media.value
if (type != QueuedMedia.Type.IMAGE &&
mediaItems.isNotEmpty() &&
mediaItems[0].type == QueuedMedia.Type.IMAGE
@ -130,7 +159,7 @@ class ComposeViewModel @Inject constructor(
): QueuedMedia {
var stashMediaItem: QueuedMedia? = null
media.update { mediaList ->
_media.update { mediaList ->
val mediaItem = QueuedMedia(
localId = mediaUploader.getNewLocalMediaId(),
uri = uri,
@ -157,7 +186,7 @@ class ComposeViewModel @Inject constructor(
mediaUploader
.uploadMedia(mediaItem, instanceInfo.first())
.collect { event ->
val item = media.value.find { it.localId == mediaItem.localId }
val item = _media.value.find { it.localId == mediaItem.localId }
?: return@collect
val newMediaItem = when (event) {
is UploadEvent.ProgressEvent ->
@ -166,15 +195,19 @@ class ComposeViewModel @Inject constructor(
item.copy(
id = event.mediaId,
uploadPercent = -1,
state = if (event.processed) { QueuedMedia.State.PROCESSED } else { QueuedMedia.State.UNPROCESSED }
state = if (event.processed) {
QueuedMedia.State.PROCESSED
} else {
QueuedMedia.State.UNPROCESSED
}
)
is UploadEvent.ErrorEvent -> {
media.update { mediaList -> mediaList.filter { it.localId != mediaItem.localId } }
uploadError.emit(event.error)
_media.update { mediaList -> mediaList.filter { it.localId != mediaItem.localId } }
_uploadError.emit(event.error)
return@collect
}
}
media.update { mediaList ->
_media.update { mediaList ->
mediaList.map { mediaItem ->
if (mediaItem.localId == newMediaItem.localId) {
newMediaItem
@ -185,11 +218,22 @@ class ComposeViewModel @Inject constructor(
}
}
}
updateCloseConfirmation()
return mediaItem
}
private fun addUploadedMedia(id: String, type: QueuedMedia.Type, uri: Uri, description: String?, focus: Attachment.Focus?) {
media.update { mediaList ->
fun changeStatusVisibility(visibility: Status.Visibility) {
_statusVisibility.value = visibility
}
private fun addUploadedMedia(
id: String,
type: QueuedMedia.Type,
uri: Uri,
description: String?,
focus: Attachment.Focus?
) {
_media.update { mediaList ->
val mediaItem = QueuedMedia(
localId = mediaUploader.getNewLocalMediaId(),
uri = uri,
@ -207,22 +251,38 @@ class ComposeViewModel @Inject constructor(
fun removeMediaFromQueue(item: QueuedMedia) {
mediaUploader.cancelUploadScope(item.localId)
media.update { mediaList -> mediaList.filter { it.localId != item.localId } }
_media.update { mediaList -> mediaList.filter { it.localId != item.localId } }
updateCloseConfirmation()
}
fun toggleMarkSensitive() {
this.markMediaAsSensitive.value = this.markMediaAsSensitive.value != true
this._markMediaAsSensitive.value = this._markMediaAsSensitive.value != true
}
fun handleCloseButton(contentText: String?, contentWarning: String?): ConfirmationKind {
return if (didChange(contentText, contentWarning)) {
fun updateContent(newContent: String?) {
currentContent = newContent
updateCloseConfirmation()
}
fun updateContentWarning(newContentWarning: String?) {
currentContentWarning = newContentWarning
updateCloseConfirmation()
}
private fun updateCloseConfirmation() {
val contentWarning = if (_showContentWarning.value) {
currentContentWarning
} else {
""
}
this._closeConfirmation.value = if (didChange(currentContent, contentWarning)) {
when (composeKind) {
ComposeKind.NEW -> if (isEmpty(contentText, contentWarning)) {
ComposeKind.NEW -> if (isEmpty(currentContent, contentWarning)) {
ConfirmationKind.NONE
} else {
ConfirmationKind.SAVE_OR_DISCARD
}
ComposeKind.EDIT_DRAFT -> if (isEmpty(contentText, contentWarning)) {
ComposeKind.EDIT_DRAFT -> if (isEmpty(currentContent, contentWarning)) {
ConfirmationKind.CONTINUE_EDITING_OR_DISCARD_DRAFT
} else {
ConfirmationKind.UPDATE_OR_DISCARD
@ -238,20 +298,21 @@ class ComposeViewModel @Inject constructor(
private fun didChange(content: String?, contentWarning: String?): Boolean {
val textChanged = content.orEmpty() != startingText.orEmpty()
val contentWarningChanged = contentWarning.orEmpty() != startingContentWarning
val mediaChanged = media.value.isNotEmpty()
val pollChanged = poll.value != null
val mediaChanged = _media.value.isNotEmpty()
val pollChanged = _poll.value != null
val didScheduledTimeChange = hasScheduledTimeChanged
return modifiedInitialState || textChanged || contentWarningChanged || mediaChanged || pollChanged || didScheduledTimeChange
}
private fun isEmpty(content: String?, contentWarning: String?): Boolean {
return !modifiedInitialState && (content.isNullOrBlank() && contentWarning.isNullOrBlank() && media.value.isEmpty() && poll.value == null)
return !modifiedInitialState && (content.isNullOrBlank() && contentWarning.isNullOrBlank() && _media.value.isEmpty() && _poll.value == null)
}
fun contentWarningChanged(value: Boolean) {
showContentWarning.value = value
_showContentWarning.value = value
contentWarningStateChanged = true
updateCloseConfirmation()
}
fun deleteDraft() {
@ -263,12 +324,12 @@ class ComposeViewModel @Inject constructor(
}
fun stopUploads() {
mediaUploader.cancelUploadScope(*media.value.map { it.localId }.toIntArray())
mediaUploader.cancelUploadScope(*_media.value.map { it.localId }.toIntArray())
}
fun shouldShowSaveDraftDialog(): Boolean {
// if any of the media files need to be downloaded first it could take a while, so show a loading dialog
return media.value.any { mediaValue ->
return _media.value.any { mediaValue ->
mediaValue.uri.scheme == "https"
}
}
@ -277,7 +338,7 @@ class ComposeViewModel @Inject constructor(
val mediaUris: MutableList<String> = mutableListOf()
val mediaDescriptions: MutableList<String?> = mutableListOf()
val mediaFocus: MutableList<Attachment.Focus?> = mutableListOf()
media.value.forEach { item ->
for (item in _media.value) {
mediaUris.add(item.uri.toString())
mediaDescriptions.add(item.description)
mediaFocus.add(item.focus)
@ -289,15 +350,15 @@ class ComposeViewModel @Inject constructor(
inReplyToId = inReplyToId,
content = content,
contentWarning = contentWarning,
sensitive = markMediaAsSensitive.value,
visibility = statusVisibility.value,
sensitive = _markMediaAsSensitive.value,
visibility = _statusVisibility.value,
mediaUris = mediaUris,
mediaDescriptions = mediaDescriptions,
mediaFocus = mediaFocus,
poll = poll.value,
poll = _poll.value,
failedToSend = false,
failedToSendAlert = false,
scheduledAt = scheduledAt.value,
scheduledAt = _scheduledAt.value,
language = postLanguage,
statusId = originalStatusId
)
@ -307,16 +368,12 @@ class ComposeViewModel @Inject constructor(
* Send status to the server.
* Uses current state plus provided arguments.
*/
suspend fun sendStatus(
content: String,
spoilerText: String,
accountId: Long
) {
suspend fun sendStatus(content: String, spoilerText: String, accountId: Long) {
if (!scheduledTootId.isNullOrEmpty()) {
api.deleteScheduledStatus(scheduledTootId!!)
}
val attachedMedia = media.value.map { item ->
val attachedMedia = _media.value.map { item ->
MediaToSend(
localId = item.localId,
id = item.id,
@ -329,12 +386,12 @@ class ComposeViewModel @Inject constructor(
val tootToSend = StatusToSend(
text = content,
warningText = spoilerText,
visibility = statusVisibility.value.serverString(),
sensitive = attachedMedia.isNotEmpty() && (markMediaAsSensitive.value || showContentWarning.value),
visibility = _statusVisibility.value.serverString,
sensitive = attachedMedia.isNotEmpty() && (_markMediaAsSensitive.value || _showContentWarning.value),
media = attachedMedia,
scheduledAt = scheduledAt.value,
scheduledAt = _scheduledAt.value,
inReplyToId = inReplyToId,
poll = poll.value,
poll = _poll.value,
replyingStatusContent = null,
replyingStatusAuthorUsername = null,
accountId = accountId,
@ -349,7 +406,7 @@ class ComposeViewModel @Inject constructor(
}
private fun updateMediaItem(localId: Int, mutator: (QueuedMedia) -> QueuedMedia) {
media.update { mediaList ->
_media.update { mediaList ->
mediaList.map { mediaItem ->
if (mediaItem.localId == localId) {
mutator(mediaItem)
@ -373,9 +430,9 @@ class ComposeViewModel @Inject constructor(
}
fun searchAutocompleteSuggestions(token: String): List<AutocompleteResult> {
when (token[0]) {
'@' -> {
return api.searchAccountsSync(query = token.substring(1), limit = 10)
return when (token[0]) {
'@' -> runBlocking {
api.searchAccounts(query = token.substring(1), limit = 10)
.fold({ accounts ->
accounts.map { AutocompleteResult.AccountResult(it) }
}, { e ->
@ -383,8 +440,12 @@ class ComposeViewModel @Inject constructor(
emptyList()
})
}
'#' -> {
return api.searchSync(query = token, type = SearchType.Hashtag.apiParameter, limit = 10)
'#' -> runBlocking {
api.search(
query = token,
type = SearchType.Hashtag.apiParameter,
limit = 10
)
.fold({ searchResult ->
searchResult.hashtags.map { AutocompleteResult.HashtagResult(it.name) }
}, { e ->
@ -396,7 +457,7 @@ class ComposeViewModel @Inject constructor(
val emojiList = emoji.replayCache.firstOrNull() ?: return emptyList()
val incomplete = token.substring(1)
return emojiList.filter { emoji ->
emojiList.filter { emoji ->
emoji.shortcode.contains(incomplete, ignoreCase = true)
}.sortedBy { emoji ->
emoji.shortcode.indexOf(incomplete, ignoreCase = true)
@ -406,7 +467,7 @@ class ComposeViewModel @Inject constructor(
}
else -> {
Log.w(TAG, "Unexpected autocompletion token: $token")
return emptyList()
emptyList()
}
}
}
@ -434,7 +495,7 @@ class ComposeViewModel @Inject constructor(
startingContentWarning = contentWarning
}
if (!contentWarningStateChanged) {
showContentWarning.value = !contentWarning.isNullOrBlank()
_showContentWarning.value = !contentWarning.isNullOrBlank()
}
// recreate media list
@ -468,7 +529,7 @@ class ComposeViewModel @Inject constructor(
if (tootVisibility.num != Status.Visibility.UNKNOWN.num) {
startingVisibility = tootVisibility
}
statusVisibility.value = startingVisibility
_statusVisibility.value = startingVisibility
val mentionedUsernames = composeOptions?.mentionedUsernames
if (mentionedUsernames != null) {
val builder = StringBuilder()
@ -480,30 +541,33 @@ class ComposeViewModel @Inject constructor(
startingText = builder.toString()
}
scheduledAt.value = composeOptions?.scheduledAt
_scheduledAt.value = composeOptions?.scheduledAt
composeOptions?.sensitive?.let { markMediaAsSensitive.value = it }
composeOptions?.sensitive?.let { _markMediaAsSensitive.value = it }
val poll = composeOptions?.poll
if (poll != null && composeOptions.mediaAttachments.isNullOrEmpty()) {
this.poll.value = poll
this._poll.value = poll
}
replyingStatusContent = composeOptions?.replyingStatusContent
replyingStatusAuthor = composeOptions?.replyingStatusAuthor
updateCloseConfirmation()
setupComplete = true
}
fun updatePoll(newPoll: NewPoll) {
poll.value = newPoll
fun updatePoll(newPoll: NewPoll?) {
_poll.value = newPoll
updateCloseConfirmation()
}
fun updateScheduledAt(newScheduledAt: String?) {
if (newScheduledAt != scheduledAt.value) {
if (newScheduledAt != _scheduledAt.value) {
hasScheduledTimeChanged = true
}
scheduledAt.value = newScheduledAt
_scheduledAt.value = newScheduledAt
}
val editing: Boolean

View file

@ -42,7 +42,7 @@ fun downsizeImage(
tempFile: File
): Boolean {
val decodeBoundsInputStream = try {
contentResolver.openInputStream(uri)
contentResolver.openInputStream(uri) ?: return false
} catch (e: FileNotFoundException) {
return false
}
@ -54,10 +54,10 @@ fun downsizeImage(
// Get EXIF data, for orientation info.
val orientation = getImageOrientation(uri, contentResolver)
/* Unfortunately, there isn't a determined worst case compression ratio for image
* formats. So, the only way to tell if they're too big is to compress them and
* test, and keep trying at smaller sizes. The initial estimate should be good for
* many cases, so it should only iterate once, but the loop is used to be absolutely
* sure it gets downsized to below the limit. */
* formats. So, the only way to tell if they're too big is to compress them and
* test, and keep trying at smaller sizes. The initial estimate should be good for
* many cases, so it should only iterate once, but the loop is used to be absolutely
* sure it gets downsized to below the limit. */
var scaledImageSize = 1024
do {
val outputStream = try {
@ -66,7 +66,7 @@ fun downsizeImage(
return false
}
val decodeBitmapInputStream = try {
contentResolver.openInputStream(uri)
contentResolver.openInputStream(uri) ?: return false
} catch (e: FileNotFoundException) {
return false
}

View file

@ -113,11 +113,17 @@ class MediaPreviewAdapter(
private val differ = AsyncListDiffer(
this,
object : DiffUtil.ItemCallback<ComposeActivity.QueuedMedia>() {
override fun areItemsTheSame(oldItem: ComposeActivity.QueuedMedia, newItem: ComposeActivity.QueuedMedia): Boolean {
override fun areItemsTheSame(
oldItem: ComposeActivity.QueuedMedia,
newItem: ComposeActivity.QueuedMedia
): Boolean {
return oldItem.localId == newItem.localId
}
override fun areContentsTheSame(oldItem: ComposeActivity.QueuedMedia, newItem: ComposeActivity.QueuedMedia): Boolean {
override fun areContentsTheSame(
oldItem: ComposeActivity.QueuedMedia,
newItem: ComposeActivity.QueuedMedia
): Boolean {
return oldItem == newItem
}
}

View file

@ -30,12 +30,16 @@ import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfo
import com.keylesspalace.tusky.network.MediaUploadApi
import com.keylesspalace.tusky.network.ProgressRequestBody
import com.keylesspalace.tusky.network.asRequestBody
import com.keylesspalace.tusky.util.MEDIA_SIZE_UNKNOWN
import com.keylesspalace.tusky.util.getImageSquarePixels
import com.keylesspalace.tusky.util.getMediaSize
import com.keylesspalace.tusky.util.getServerErrorMessage
import com.keylesspalace.tusky.util.randomAlphanumericString
import java.io.File
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -52,21 +56,20 @@ import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.shareIn
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okio.buffer
import okio.sink
import okio.source
import retrofit2.HttpException
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.IOException
import java.util.Date
import javax.inject.Inject
import javax.inject.Singleton
sealed interface FinalUploadEvent
sealed class UploadEvent {
data class ProgressEvent(val percentage: Int) : UploadEvent()
data class FinishedEvent(val mediaId: String, val processed: Boolean) : UploadEvent(), FinalUploadEvent
data class ErrorEvent(val error: Throwable) : UploadEvent(), FinalUploadEvent
sealed interface UploadEvent {
data class ProgressEvent(val percentage: Int) : UploadEvent
data class FinishedEvent(
val mediaId: String,
val processed: Boolean
) : UploadEvent, FinalUploadEvent
data class ErrorEvent(val error: Throwable) : UploadEvent, FinalUploadEvent
}
data class UploadData(
@ -79,11 +82,7 @@ fun createNewImageFile(context: Context, suffix: String = ".jpg"): File {
val randomId = randomAlphanumericString(12)
val imageFileName = "Tusky_${randomId}_"
val storageDir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)
return File.createTempFile(
imageFileName, /* prefix */
suffix, /* suffix */
storageDir /* directory */
)
return File.createTempFile(imageFileName, suffix, storageDir)
}
data class PreparedMedia(val type: QueuedMedia.Type, val uri: Uri, val size: Long)
@ -163,22 +162,22 @@ class MediaUploader @Inject constructor(
val suffix = "." + MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType ?: "tmp")
contentResolver.openInputStream(inUri).use { input ->
contentResolver.openInputStream(inUri)?.source().use { input ->
if (input == null) {
Log.w(TAG, "Media input is null")
uri = inUri
return@use
}
val file = File.createTempFile("randomTemp1", suffix, context.cacheDir)
FileOutputStream(file.absoluteFile).use { out ->
input.copyTo(out)
uri = FileProvider.getUriForFile(
context,
BuildConfig.APPLICATION_ID + ".fileprovider",
file
)
mediaSize = getMediaSize(contentResolver, uri)
file.absoluteFile.sink().buffer().use { out ->
out.writeAll(input)
}
uri = FileProvider.getUriForFile(
context,
BuildConfig.APPLICATION_ID + ".fileprovider",
file
)
mediaSize = getMediaSize(contentResolver, uri)
}
}
ContentResolver.SCHEME_FILE -> {
@ -191,17 +190,18 @@ class MediaUploader @Inject constructor(
val suffix = inputFile.name.substringAfterLast('.', "tmp")
mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(suffix)
val file = File.createTempFile("randomTemp1", ".$suffix", context.cacheDir)
val input = FileInputStream(inputFile)
FileOutputStream(file.absoluteFile).use { out ->
input.copyTo(out)
uri = FileProvider.getUriForFile(
context,
BuildConfig.APPLICATION_ID + ".fileprovider",
file
)
mediaSize = getMediaSize(contentResolver, uri)
inputFile.source().use { input ->
file.absoluteFile.sink().buffer().use { out ->
out.writeAll(input)
}
}
uri = FileProvider.getUriForFile(
context,
BuildConfig.APPLICATION_ID + ".fileprovider",
file
)
mediaSize = getMediaSize(contentResolver, uri)
}
else -> {
Log.w(TAG, "Unknown uri scheme $uri")
@ -254,9 +254,9 @@ class MediaUploader @Inject constructor(
// .m4a files. See https://github.com/tuskyapp/Tusky/issues/3189 for details.
// Sniff the content of the file to determine the actual type.
if (mimeType != null && (
mimeType.startsWith("audio/", ignoreCase = true) ||
mimeType.startsWith("video/", ignoreCase = true)
)
mimeType.startsWith("audio/", ignoreCase = true) ||
mimeType.startsWith("video/", ignoreCase = true)
)
) {
val retriever = MediaMetadataRetriever()
retriever.setDataSource(context, media.uri)
@ -264,22 +264,20 @@ class MediaUploader @Inject constructor(
}
val map = MimeTypeMap.getSingleton()
val fileExtension = map.getExtensionFromMimeType(mimeType)
val filename = "%s_%s_%s.%s".format(
val filename = "%s_%d_%s.%s".format(
context.getString(R.string.app_name),
Date().time.toString(),
System.currentTimeMillis(),
randomAlphanumericString(10),
fileExtension
)
val stream = contentResolver.openInputStream(media.uri)
if (mimeType == null) mimeType = "multipart/form-data"
var lastProgress = -1
val fileBody = ProgressRequestBody(
stream!!,
media.mediaSize,
mimeType.toMediaTypeOrNull()!!
val fileBody = media.uri.asRequestBody(
contentResolver,
requireNotNull(mimeType.toMediaTypeOrNull()) { "Invalid Content Type" },
media.mediaSize
) { percentage ->
if (percentage != lastProgress) {
trySend(UploadEvent.ProgressEvent(percentage))

View file

@ -60,7 +60,9 @@ fun showAddPollDialog(
binding.pollChoices.adapter = adapter
var durations = context.resources.getIntArray(R.array.poll_duration_values).toList()
val durationLabels = context.resources.getStringArray(R.array.poll_duration_names).filterIndexed { index, _ -> durations[index] in minDuration..maxDuration }
val durationLabels = context.resources.getStringArray(
R.array.poll_duration_names
).filterIndexed { index, _ -> durations[index] in minDuration..maxDuration }
binding.pollDurationSpinner.adapter = ArrayAdapter(context, android.R.layout.simple_spinner_item, durationLabels).apply {
setDropDownViewResource(androidx.appcompat.R.layout.support_simple_spinner_dropdown_item)
}
@ -75,8 +77,8 @@ fun showAddPollDialog(
}
}
val DAY_SECONDS = 60 * 60 * 24
val desiredDuration = poll?.expiresIn ?: DAY_SECONDS
val secondsInADay = 60 * 60 * 24
val desiredDuration = poll?.expiresIn ?: secondsInADay
val pollDurationId = durations.indexOfLast {
it <= desiredDuration
}
@ -105,5 +107,7 @@ fun showAddPollDialog(
dialog.show()
// make the dialog focusable so the keyboard does not stay behind it
dialog.window?.clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM)
dialog.window?.clearFlags(
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM
)
}

View file

@ -41,8 +41,15 @@ class AddPollOptionsAdapter(
notifyItemInserted(options.size - 1)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemAddPollOptionBinding> {
val binding = ItemAddPollOptionBinding.inflate(LayoutInflater.from(parent.context), parent, false)
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): BindingHolder<ItemAddPollOptionBinding> {
val binding = ItemAddPollOptionBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
val holder = BindingHolder(binding)
binding.optionEditText.filters = arrayOf(InputFilter.LengthFilter(maxOptionLength))
@ -75,10 +82,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)
}
}

View file

@ -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
@ -25,8 +24,7 @@ import android.view.LayoutInflater
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,45 +34,56 @@ 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
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)
}
input = binding.imageDescriptionText
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View = inflater.inflate(R.layout.dialog_image_description, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val imageView = binding.imageDescriptionView
imageView.maximumScale = 6f
imageView.maxZoom = 6f
input.hint = resources.getQuantityString(
binding.imageDescriptionText.hint = resources.getQuantityString(
R.plurals.hint_describe_for_visually_impaired,
MEDIA_DESCRIPTION_CHARACTER_LIMIT,
MEDIA_DESCRIPTION_CHARACTER_LIMIT
)
input.filters = arrayOf(InputFilter.LengthFilter(MEDIA_DESCRIPTION_CHARACTER_LIMIT))
input.setText(arguments?.getString(EXISTING_DESCRIPTION_ARG))
binding.imageDescriptionText.filters = arrayOf(InputFilter.LengthFilter(MEDIA_DESCRIPTION_CHARACTER_LIMIT))
binding.imageDescriptionText.setText(arguments?.getString(EXISTING_DESCRIPTION_ARG))
savedInstanceState?.getCharSequence(DESCRIPTION_KEY)?.let {
binding.imageDescriptionText.setText(it)
}
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, binding.imageDescriptionText.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,27 +99,30 @@ 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) {
outState.putString(DESCRIPTION_KEY, input.text.toString())
outState.putCharSequence(DESCRIPTION_KEY, binding.imageDescriptionText.text)
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")
@ -121,17 +133,14 @@ class CaptionDialog : DialogFragment() {
}
companion object {
fun newInstance(
localId: Int,
existingDescription: String?,
previewUri: Uri
) = CaptionDialog().apply {
arguments = bundleOf(
LOCAL_ID_ARG to localId,
EXISTING_DESCRIPTION_ARG to existingDescription,
PREVIEW_URI_ARG to previewUri
)
}
fun newInstance(localId: Int, existingDescription: String?, previewUri: Uri) =
CaptionDialog().apply {
arguments = bundleOf(
LOCAL_ID_ARG to localId,
EXISTING_DESCRIPTION_ARG to existingDescription,
PREVIEW_URI_ARG to previewUri
)
}
private const val DESCRIPTION_KEY = "description"
private const val EXISTING_DESCRIPTION_ARG = "existing_description"

View file

@ -49,12 +49,23 @@ fun <T> T.makeFocusDialog(
.load(previewUri)
.downsample(DownsampleStrategy.CENTER_INSIDE)
.listener(object : RequestListener<Drawable> {
override fun onLoadFailed(p0: GlideException?, p1: Any?, p2: Target<Drawable?>?, p3: Boolean): Boolean {
override fun onLoadFailed(
p0: GlideException?,
p1: Any?,
p2: Target<Drawable?>,
p3: Boolean
): Boolean {
return false
}
override fun onResourceReady(resource: Drawable?, model: Any?, target: Target<Drawable?>?, dataSource: DataSource?, isFirstResource: Boolean): Boolean {
val width = resource!!.intrinsicWidth
override fun onResourceReady(
resource: Drawable,
model: Any,
target: Target<Drawable?>?,
dataSource: DataSource,
isFirstResource: Boolean
): Boolean {
val width = resource.intrinsicWidth
val height = resource.intrinsicHeight
dialogBinding.focusIndicator.setImageSize(width, height)

View file

@ -21,7 +21,10 @@ import android.widget.RadioGroup
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.entity.Status
class ComposeOptionsView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : RadioGroup(context, attrs) {
class ComposeOptionsView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : RadioGroup(
context,
attrs
) {
var listener: ComposeOptionsListener? = null

View file

@ -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,31 +199,32 @@ 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)
}
}
}
companion object {
var MINIMUM_SCHEDULED_SECONDS = 330 // Minimum is 5 minutes, pad 30 seconds for posting
// Minimum is 5 minutes, pad 30 seconds for posting
private const val MINIMUM_SCHEDULED_SECONDS = 330
fun calendar(): Calendar = Calendar.getInstance(TimeZone.getDefault())
}
}

View file

@ -68,7 +68,9 @@ class FocusIndicatorView
return offset.toFloat() + ((value + 1.0f) / 2.0f) * innerLimit.toFloat() // From range -1..1
}
@SuppressLint("ClickableViewAccessibility") // Android Studio wants us to implement PerformClick for accessibility, but that unfortunately cannot be made meaningful for this widget.
@SuppressLint(
"ClickableViewAccessibility"
) // Android Studio wants us to implement PerformClick for accessibility, but that unfortunately cannot be made meaningful for this widget.
override fun onTouchEvent(event: MotionEvent): Boolean {
if (event.actionMasked == MotionEvent.ACTION_CANCEL) {
return false
@ -112,7 +114,13 @@ class FocusIndicatorView
curtainPath.reset() // Draw a flood fill with a hole cut out of it
curtainPath.fillType = Path.FillType.WINDING
curtainPath.addRect(0.0f, 0.0f, this.width.toFloat(), this.height.toFloat(), Path.Direction.CW)
curtainPath.addRect(
0.0f,
0.0f,
this.width.toFloat(),
this.height.toFloat(),
Path.Direction.CW
)
curtainPath.addCircle(x, y, circleRadius, Path.Direction.CCW)
canvas.drawPath(curtainPath, curtainPaint)

View file

@ -60,7 +60,10 @@ class TootButton
Status.Visibility.PRIVATE,
Status.Visibility.DIRECT -> {
setText(R.string.action_send)
IconicsDrawable(context, GoogleMaterial.Icon.gmd_lock).apply { sizeDp = 18; colorInt = Color.WHITE }
IconicsDrawable(context, GoogleMaterial.Icon.gmd_lock).apply {
sizeDp = 18
colorInt = Color.WHITE
}
}
else -> {
null

View file

@ -38,7 +38,9 @@ class ConversationAdapter(
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ConversationViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_conversation, parent, false)
val view = LayoutInflater.from(
parent.context
).inflate(R.layout.item_conversation, parent, false)
return ConversationViewHolder(view, statusDisplayOptions, listener)
}
@ -58,15 +60,24 @@ class ConversationAdapter(
companion object {
val CONVERSATION_COMPARATOR = object : DiffUtil.ItemCallback<ConversationViewData>() {
override fun areItemsTheSame(oldItem: ConversationViewData, newItem: ConversationViewData): Boolean {
override fun areItemsTheSame(
oldItem: ConversationViewData,
newItem: ConversationViewData
): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: ConversationViewData, newItem: ConversationViewData): Boolean {
override fun areContentsTheSame(
oldItem: ConversationViewData,
newItem: ConversationViewData
): Boolean {
return false // Items are different always. It allows to refresh timestamp on every view holder update
}
override fun getChangePayload(oldItem: ConversationViewData, newItem: ConversationViewData): Any? {
override fun getChangePayload(
oldItem: ConversationViewData,
newItem: ConversationViewData
): Any? {
return if (oldItem == newItem) {
// If items are equal - update timestamp only
listOf(StatusBaseViewHolder.Key.KEY_CREATED)

View file

@ -27,6 +27,7 @@ import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.viewdata.StatusViewData
import com.squareup.moshi.JsonClass
import java.util.Date
@Entity(primaryKeys = ["id", "accountId"])
@ -50,6 +51,7 @@ data class ConversationEntity(
}
}
@JsonClass(generateAdapter = true)
data class ConversationAccountEntity(
val id: String,
val localUsername: String,
@ -131,7 +133,7 @@ data class ConversationStatusEntity(
poll = poll,
card = null,
language = language,
filtered = null
filtered = emptyList()
),
isExpanded = expanded,
isShowingContent = showingHiddenContent,
@ -140,21 +142,16 @@ data class ConversationStatusEntity(
}
}
fun TimelineAccount.toEntity() =
ConversationAccountEntity(
id = id,
localUsername = localUsername,
username = username,
displayName = name,
avatar = avatar,
emojis = emojis.orEmpty()
)
fun TimelineAccount.toEntity() = ConversationAccountEntity(
id = id,
localUsername = localUsername,
username = username,
displayName = name,
avatar = avatar,
emojis = emojis.orEmpty()
)
fun Status.toEntity(
expanded: Boolean,
contentShowing: Boolean,
contentCollapsed: Boolean
) =
fun Status.toEntity(expanded: Boolean, contentShowing: Boolean, contentCollapsed: Boolean) =
ConversationStatusEntity(
id = id,
url = url,
@ -177,7 +174,7 @@ fun Status.toEntity(
showingHiddenContent = contentShowing,
expanded = expanded,
collapsed = contentCollapsed,
muted = muted ?: false,
muted = muted,
poll = poll,
language = language
)
@ -188,16 +185,15 @@ fun Conversation.toEntity(
expanded: Boolean,
contentShowing: Boolean,
contentCollapsed: Boolean
) =
ConversationEntity(
accountId = accountId,
id = id,
order = order,
accounts = accounts.map { it.toEntity() },
unread = unread,
lastStatus = lastStatus!!.toEntity(
expanded = expanded,
contentShowing = contentShowing,
contentCollapsed = contentCollapsed
)
) = ConversationEntity(
accountId = accountId,
id = id,
order = order,
accounts = accounts.map { it.toEntity() },
unread = unread,
lastStatus = lastStatus!!.toEntity(
expanded = expanded,
contentShowing = contentShowing,
contentCollapsed = contentCollapsed
)
)

View file

@ -27,7 +27,10 @@ class ConversationLoadStateAdapter(
private val retryCallback: () -> Unit
) : LoadStateAdapter<BindingHolder<ItemNetworkStateBinding>>() {
override fun onBindViewHolder(holder: BindingHolder<ItemNetworkStateBinding>, loadState: LoadState) {
override fun onBindViewHolder(
holder: BindingHolder<ItemNetworkStateBinding>,
loadState: LoadState
) {
val binding = holder.binding
binding.progressBar.visible(loadState == LoadState.Loading)
binding.retryButton.visible(loadState is LoadState.Error)
@ -47,7 +50,11 @@ class ConversationLoadStateAdapter(
parent: ViewGroup,
loadState: LoadState
): BindingHolder<ItemNetworkStateBinding> {
val binding = ItemNetworkStateBinding.inflate(LayoutInflater.from(parent.context), parent, false)
val binding = ItemNetworkStateBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
return BindingHolder(binding)
}
}

View file

@ -29,7 +29,7 @@ data class ConversationViewData(
accountId: Long,
favourited: Boolean = lastStatus.status.favourited,
bookmarked: Boolean = lastStatus.status.bookmarked,
muted: Boolean = lastStatus.status.muted ?: false,
muted: Boolean = lastStatus.status.muted,
poll: Poll? = lastStatus.status.poll,
expanded: Boolean = lastStatus.isExpanded,
collapsed: Boolean = lastStatus.isCollapsed,
@ -57,7 +57,7 @@ data class ConversationViewData(
fun StatusViewData.Concrete.toConversationStatusEntity(
favourited: Boolean = status.favourited,
bookmarked: Boolean = status.bookmarked,
muted: Boolean = status.muted ?: false,
muted: Boolean = status.muted,
poll: Poll? = status.poll,
expanded: Boolean = isExpanded,
collapsed: Boolean = isCollapsed,

View file

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

View file

@ -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
@ -54,6 +55,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
@ -61,12 +63,12 @@ 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.collectLatest
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlin.time.DurationUnit
import kotlin.time.toDuration
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
class ConversationsFragment :
SFragment(),
@ -89,7 +91,11 @@ class ConversationsFragment :
private var hideFab = false
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_timeline, container, false)
}
@ -99,14 +105,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),
@ -128,18 +134,37 @@ class ConversationsFragment :
binding.statusView.hide()
binding.progressBar.hide()
if (loadState.isAnyLoading()) {
lifecycleScope.launch {
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.setup(
R.drawable.elephant_friend_empty,
R.string.message_empty,
null
)
binding.statusView.showHelp(R.string.help_empty_conversations)
}
}
is LoadState.Error -> {
binding.statusView.show()
binding.statusView.setup((loadState.refresh as LoadState.Error).error) { refreshContent() }
binding.statusView.setup(
(loadState.refresh as LoadState.Error).error
) { refreshContent() }
}
is LoadState.Loading -> {
binding.progressBar.show()
}
@ -223,6 +248,7 @@ class ConversationsFragment :
refreshContent()
true
}
else -> false
}
}
@ -231,11 +257,14 @@ class ConversationsFragment :
binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.layoutManager = LinearLayoutManager(context)
binding.recyclerView.addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL))
binding.recyclerView.addItemDecoration(
DividerItemDecoration(context, DividerItemDecoration.VERTICAL)
)
(binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
binding.recyclerView.adapter = adapter.withLoadStateFooter(ConversationLoadStateAdapter(adapter::retry))
binding.recyclerView.adapter =
adapter.withLoadStateFooter(ConversationLoadStateAdapter(adapter::retry))
}
private fun refreshContent() {
@ -263,13 +292,15 @@ class ConversationsFragment :
}
}
override val onMoreTranslate: ((translate: Boolean, position: Int) -> Unit)? = null
override fun onMore(view: View, position: Int) {
adapter.peek(position)?.let { conversation ->
val popup = PopupMenu(requireContext(), view)
popup.inflate(R.menu.conversation_more)
if (conversation.lastStatus.status.muted == true) {
if (conversation.lastStatus.status.muted) {
popup.menu.removeItem(R.id.status_mute_conversation)
} else {
popup.menu.removeItem(R.id.status_unmute_conversation)
@ -289,7 +320,11 @@ class ConversationsFragment :
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
adapter.peek(position)?.let { conversation ->
viewMedia(attachmentIndex, AttachmentViewData.list(conversation.lastStatus.status), view)
viewMedia(
attachmentIndex,
AttachmentViewData.list(conversation.lastStatus.status),
view
)
}
}
@ -361,6 +396,10 @@ class ConversationsFragment :
}
}
override fun onUntranslate(position: Int) {
// not needed
}
private fun deleteConversation(conversation: ConversationViewData) {
AlertDialog.Builder(requireContext())
.setMessage(R.string.dialog_delete_conversation_warning)
@ -377,6 +416,7 @@ class ConversationsFragment :
PrefKeys.FAB_HIDE -> {
hideFab = sharedPreferences.getBoolean(PrefKeys.FAB_HIDE, false)
}
PrefKeys.MEDIA_PREVIEW_ENABLED -> {
val enabled = accountManager.activeAccount!!.mediaPreviewEnabled
val oldMediaPreviewEnabled = adapter.mediaPreviewEnabled

View file

@ -38,7 +38,10 @@ class ConversationsRemoteMediator(
}
try {
val conversationsResponse = api.getConversations(maxId = nextKey, limit = state.config.pageSize)
val conversationsResponse = api.getConversations(
maxId = nextKey,
limit = state.config.pageSize
)
val conversations = conversationsResponse.body()
if (!conversationsResponse.isSuccessful || conversations == null) {

View file

@ -29,9 +29,9 @@ import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.usecase.TimelineCases
import com.keylesspalace.tusky.util.EmptyPagingSource
import javax.inject.Inject
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import javax.inject.Inject
class ConversationsViewModel @Inject constructor(
private val timelineCases: TimelineCases,
@ -91,7 +91,11 @@ class ConversationsViewModel @Inject constructor(
fun voteInPoll(choices: List<Int>, conversation: ConversationViewData) {
viewModelScope.launch {
timelineCases.voteInPoll(conversation.lastStatus.id, conversation.lastStatus.status.poll?.id!!, choices)
timelineCases.voteInPoll(
conversation.lastStatus.id,
conversation.lastStatus.status.poll?.id!!,
choices
)
.fold({ poll ->
val newConversation = conversation.toEntity(
accountId = accountManager.activeAccount!!.id,
@ -155,12 +159,12 @@ class ConversationsViewModel @Inject constructor(
try {
timelineCases.muteConversation(
conversation.lastStatus.id,
!(conversation.lastStatus.status.muted ?: false)
!conversation.lastStatus.status.muted
)
val newConversation = conversation.toEntity(
accountId = accountManager.activeAccount!!.id,
muted = !(conversation.lastStatus.status.muted ?: false)
muted = !conversation.lastStatus.status.muted
)
database.conversationDao().insert(newConversation)

View file

@ -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<Any>
@ -28,7 +27,7 @@ class InstanceListActivity : BaseActivity(), HasAndroidInjector {
supportFragmentManager
.beginTransaction()
.replace(R.id.fragment_container, InstanceListFragment())
.replace(R.id.fragment_container, DomainBlocksFragment())
.commit()
}

View file

@ -0,0 +1,34 @@
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, BindingHolder<ItemBlockedDomainBinding>>(STRING_COMPARATOR) {
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): BindingHolder<ItemBlockedDomainBinding> {
val binding = ItemBlockedDomainBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
return BindingHolder(binding)
}
override fun onBindViewHolder(holder: BindingHolder<ItemBlockedDomainBinding>, position: Int) {
getItem(position)?.let { instance ->
holder.binding.blockedDomain.text = instance
holder.binding.blockedDomainUnblock.setOnClickListener {
onUnmute(instance)
}
}
}
}

View file

@ -0,0 +1,96 @@
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 javax.inject.Inject
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
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"
}
}

View file

@ -0,0 +1,19 @@
package com.keylesspalace.tusky.components.domainblocks
import androidx.paging.PagingSource
import androidx.paging.PagingState
class DomainBlocksPagingSource(
private val domains: List<String>,
private val nextKey: String?
) : PagingSource<String, String>() {
override fun getRefreshKey(state: PagingState<String, String>): String? = null
override suspend fun load(params: LoadParams<String>): LoadResult<String, String> {
return if (params is LoadParams.Refresh) {
LoadResult.Page(domains, null, nextKey)
} else {
LoadResult.Page(emptyList(), null, null)
}
}
}

View file

@ -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<String, String>() {
override suspend fun load(
loadType: LoadType,
state: PagingState<String, String>
): 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<List<String>>? {
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<List<String>>): 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)
}
}

View file

@ -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 <http://www.gnu.org/licenses>.
*/
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<String> = 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<Unit> {
return api.blockDomain(domain).onSuccess {
domains.add(domain)
factory.invalidate()
}
}
suspend fun unblock(domain: String): NetworkResult<Unit> {
return api.unblockDomain(domain).onSuccess {
domains.remove(domain)
factory.invalidate()
}
}
companion object {
private const val PAGE_SIZE = 20
}
}

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