Merge tag 'v28.0' into develop

# Conflicts:
#	README.md
#	app/build.gradle
#	app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt
#	app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java
#	app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt
#	app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaFragment.kt
#	app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListFragment.kt
#	app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsActivity.kt
#	app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt
#	app/src/main/java/com/keylesspalace/tusky/components/compose/view/ProgressImageView.kt
#	app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt
#	app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersActivity.kt
#	app/src/main/java/com/keylesspalace/tusky/components/login/LoginActivity.kt
#	app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt
#	app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusActivity.kt
#	app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchFragment.kt
#	app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt
#	app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingTagsFragment.kt
#	app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt
#	app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsFragment.kt
#	app/src/main/java/com/keylesspalace/tusky/view/GraphView.kt
#	app/src/main/res/color/compound_button_color.xml
#	app/src/main/res/color/text_input_layout_box_stroke_color.xml
#	app/src/main/res/drawable/ic_check_circle.xml
#	app/src/main/res/drawable/ic_flag_24dp.xml
#	app/src/main/res/drawable/ic_person_add_24dp.xml
#	app/src/main/res/drawable/ic_play_indicator.xml
#	app/src/main/res/drawable/ic_poll_24dp.xml
#	app/src/main/res/drawable/ic_reblog_active_24dp.xml
#	app/src/main/res/drawable/ic_reblog_private_active_24dp.xml
#	app/src/main/res/drawable/report_success_background.xml
#	app/src/main/res/layout-land/item_trending_cell.xml
#	app/src/main/res/layout/activity_account.xml
#	app/src/main/res/layout/activity_edit_filter.xml
#	app/src/main/res/layout/card_license.xml
#	app/src/main/res/layout/item_announcement.xml
#	app/src/main/res/layout/item_status.xml
#	app/src/main/res/layout/item_status_detailed.xml
#	app/src/main/res/layout/item_tab_preference.xml
#	app/src/main/res/layout/item_trending_cell.xml
#	app/src/main/res/values-cs/strings.xml
#	app/src/main/res/values-de/strings.xml
#	app/src/main/res/values-es/strings.xml
#	app/src/main/res/values-eu/strings.xml
#	app/src/main/res/values-fr/strings.xml
#	app/src/main/res/values-kab/strings.xml
#	app/src/main/res/values-lv/strings.xml
#	app/src/main/res/values-nb-rNO/strings.xml
#	app/src/main/res/values-night/theme_colors.xml
#	app/src/main/res/values/colors.xml
#	app/src/main/res/values/strings.xml
#	app/src/main/res/values/styles.xml
#	app/src/main/res/values/theme_colors.xml
This commit is contained in:
Mike Barnes 2026-01-03 09:57:39 +11:00
commit a66f7bb515
614 changed files with 52429 additions and 19916 deletions

18
.github/actions/setup/action.yml vendored Normal file
View file

@ -0,0 +1,18 @@
name: 'Setup build environment'
description: 'Sets up an environment for building Tusky'
runs:
using: "composite"
steps:
- uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
- name: Copy CI gradle.properties
shell: bash
run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties
- name: Gradle Build Action
uses: gradle/actions/setup-gradle@v4
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/develop' }}

View file

@ -7,11 +7,9 @@
{
"groupName": "Kotlin",
"groupSlug": "kotlin",
"matchPackagePrefixes": [
"com.google.devtools.ksp"
],
"matchPackagePatterns": [
"org.jetbrains.kotlin.*"
"matchPackageNames": [
"com.google.devtools.ksp{/,}**",
"/org.jetbrains.kotlin.*/"
]
}
]

27
.github/workflows/check-and-build.yml vendored Normal file
View file

@ -0,0 +1,27 @@
name: Check and build
on:
pull_request:
workflow_call:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup
uses: ./.github/actions/setup
- 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

@ -1,44 +0,0 @@
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

53
.github/workflows/deploy-release.yml vendored Normal file
View file

@ -0,0 +1,53 @@
# When a tag is created, create a release build and upload it to Google Play
name: Deploy release to Google Play
on:
push:
tags:
- '*'
jobs:
check-and-build:
uses: ./.github/workflows/check-and-build.yml
deploy:
runs-on: ubuntu-latest
needs: check-and-build
environment: Release
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup
uses: ./.github/actions/setup
- name: Build Blue aab
run: ./gradlew app:bundleBlueRelease
- uses: r0adkll/sign-android-release@f30bdd30588842ac76044ecdbd4b6d0e3e813478
name: Sign Tusky Blue aab
id: sign_aab
with:
releaseDirectory: app/build/outputs/bundle/blueRelease
signingKeyBase64: ${{ secrets.KEYSTORE }}
alias: ${{ secrets.KEY_ALIAS }}
keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
- name: Generate whatsnew
id: generate-whatsnew
run: |
mkdir whatsnew
cp $(find fastlane/metadata/android/en-US/changelogs | sort -n -k6 -t/ | tail -n 1) whatsnew/whatsnew-en-US
- name: Upload AAB to Google Play
id: upload-release-asset-aab
uses: r0adkll/upload-google-play@v1.1.3
with:
serviceAccountJsonPlainText: ${{ secrets.SERVICE_ACCOUNT_JSON }}
packageName: com.keylesspalace.tusky
releaseFiles: ${{steps.sign_aab.outputs.signedReleaseFile}}
track: internal
whatsNewDirectory: whatsnew
status: completed
mappingFile: app/build/outputs/mapping/blueRelease/mapping.txt

64
.github/workflows/deploy-test.yml vendored Normal file
View file

@ -0,0 +1,64 @@
# Deploy Tusky Nightly on each push to develop
name: Deploy Tusky Nightly to Google Play
on:
push:
branches:
- develop
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: true
jobs:
check-and-build:
uses: ./.github/workflows/check-and-build.yml
deploy:
runs-on: ubuntu-latest
needs: check-and-build
environment: Test
env:
BUILD_NUMBER: ${{ github.run_number }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup
uses: ./.github/actions/setup
- name: Set versionCode
run: |
export VERSION_CODE=$(( ${BUILD_NUMBER} + 11000 ))
sed -i'.original' -e "s/^\([[:space:]]*versionCode[[:space:]]*\)[0-9]*/\\1$VERSION_CODE/" app/build.gradle
- name: Build Green aab
run: ./gradlew app:bundleGreenRelease
- uses: r0adkll/sign-android-release@f30bdd30588842ac76044ecdbd4b6d0e3e813478
name: Sign Tusky Green aab
id: sign_aab
with:
releaseDirectory: app/build/outputs/bundle/greenRelease
signingKeyBase64: ${{ secrets.KEYSTORE }}
alias: ${{ secrets.KEY_ALIAS }}
keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
- name: Generate whatsnew
id: generate-whatsnew
run: |
mkdir whatsnew
git log -3 --pretty=%B | head -c 500 > whatsnew/whatsnew-en-US
- name: Upload AAB to Google Play
id: upload-release-asset-aab
uses: r0adkll/upload-google-play@v1.1.3
with:
serviceAccountJsonPlainText: ${{ secrets.SERVICE_ACCOUNT_JSON }}
packageName: com.keylesspalace.tusky.test
releaseFiles: ${{steps.sign_aab.outputs.signedReleaseFile}}
track: production
whatsNewDirectory: whatsnew
status: completed
mappingFile: app/build/outputs/mapping/greenRelease/mapping.txt

View file

@ -1,34 +0,0 @@
# 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

3
.gitignore vendored
View file

@ -7,4 +7,5 @@ build
/captures
.externalNativeBuild
app/release
app-release.apk
app-release.apk
.kotlin

View file

@ -6,6 +6,141 @@
### Significant bug fixes
## v28.0
### New features and other improvements
- Support for Android 15 and edge-to-edge mode https://github.com/tuskyapp/Tusky/pull/4897
- Improves the reliability of push notifications https://github.com/tuskyapp/Tusky/pull/4896 https://github.com/tuskyapp/Tusky/pull/4883
- Replies in timeline are now clearly marked as such by a text above them https://github.com/tuskyapp/Tusky/pull/4834
- Several improvements to how notifications are rendered in the notifications tab https://github.com/tuskyapp/Tusky/pull/4929
- support for the new Mastodon 4.3 notification types `severed_relationships` and `moderation_warning`
- The "unknown notification type" notification now shows the unknown type and a info dialog when you click it
- The account note is now shown again for follow request and follow notifications
- The icon for the "<user> just posted" notification is now a bell instead of a home
- Adds a text above mention notifications that indicates if it is a (private) reply or (private) mention
- Follow requests won't be filtered by default in the notification tab. This change will only affect new logins and not existing ones.
- Link Preview Cards got a new design and now support the fediverse:creator feature https://github.com/tuskyapp/Tusky/pull/4782
- The possible selections for mute durations are now 1 hour, 6 hours, 1 day, 7 days, 30 days and 180 days https://github.com/tuskyapp/Tusky/pull/4943
- The rendering of trending tags has been improved https://github.com/tuskyapp/Tusky/pull/4889 https://github.com/tuskyapp/Tusky/pull/4924
- The app will no longer make database queries on the main thread, which improves performance https://github.com/tuskyapp/Tusky/pull/4786
- Wellbeing mode will no longer hide the "follows you" badge on profiles https://github.com/tuskyapp/Tusky/pull/4940
- It is now possible to select boost visibility when the "Show confirmation before boosting" option is active https://github.com/tuskyapp/Tusky/pull/4944
### Significant bug fixes
- Fixes a bug where more than 4 profile fields could not be edited (on instances that allow more than 4) https://github.com/tuskyapp/Tusky/commit/1157be18cf3bbd44426f4cdaae35e69b9f3cecca
- Fixes a bug where a dropdown was partially hidden by the keyboard https://github.com/tuskyapp/Tusky/pull/4913
- Tusky side timeline filters apply to own posts again https://github.com/tuskyapp/Tusky/pull/4879
- Fixes a bug where media previews would flicker when interacting with a post https://github.com/tuskyapp/Tusky/pull/4971
- Tusky now ignores invalid publishing dates of preview cards that caused some posts not to load https://github.com/tuskyapp/Tusky/pull/4993
## v27.2
### Significant bug fixes
- The title of a hashtag tab now shows the actual hashtags again (instead of just "Hashtags") https://github.com/tuskyapp/Tusky/pull/4868
- Makes sure the background color of a dialogs is correct https://github.com/tuskyapp/Tusky/pull/4864
- Fixes an issue where Tusky would freeze while loading a timeline gap https://github.com/tuskyapp/Tusky/pull/4865
## v27.1
### New features and other improvements
- The width of the tab indicator has been increased https://github.com/tuskyapp/Tusky/pull/4849
### Significant bug fixes
- Improves rendering of some animated custom emojis https://github.com/tuskyapp/Tusky/pull/4281
- Fixes an issue where the input field for media descriptions was too small in some cases https://github.com/tuskyapp/Tusky/pull/4831
- Fixes an issue where hashtags at the end of posts were duplicated https://github.com/tuskyapp/Tusky/pull/4845
- Fixes an issue that prevented lists from being edited https://github.com/tuskyapp/Tusky/pull/4851
## v27.0
### New features and other improvements
- Tusky has been redesigned with Material 3 https://github.com/tuskyapp/Tusky/pull/4637 https://github.com/tuskyapp/Tusky/pull/4673
- Support for Notification Policies (Mastodon 4.3 feature) https://github.com/tuskyapp/Tusky/pull/4768
- Hashtags at the end of posts are now shown in a separate bar https://github.com/tuskyapp/Tusky/pull/4761
- Full support for folding devices https://github.com/tuskyapp/Tusky/pull/4689
- Improved post rendering in some edge cases https://github.com/tuskyapp/Tusky/pull/4650 https://github.com/tuskyapp/Tusky/pull/4672 https://github.com/tuskyapp/Tusky/pull/4723
- Descriptions can now be added to audio attachments https://github.com/tuskyapp/Tusky/pull/4711
- The screen keyboard now pops up automatically when opening a dialog that contains a textfield https://github.com/tuskyapp/Tusky/pull/4667
### Significant bug fixes
- fixes a bug where Tusky would drop your draft when switching apps https://github.com/tuskyapp/Tusky/pull/4685 https://github.com/tuskyapp/Tusky/pull/4813 https://github.com/tuskyapp/Tusky/pull/4818
- fixes a bug where Tusky would drop media that is being added to a post https://github.com/tuskyapp/Tusky/pull/4662
- fixes a bug that caused the login to fail in some cases https://github.com/tuskyapp/Tusky/pull/4704
## v26.2
### Significant bug fixes
- Fixes a bug where Tusky would not correctly switch between accounts https://github.com/tuskyapp/Tusky/pull/4636
- Fixes a crash when a status in a notification contains a reblog (happens when subscribed to a Friendica group) https://github.com/tuskyapp/Tusky/pull/4638
- Long video descriptions can no longer cover the video controls https://github.com/tuskyapp/Tusky/pull/4632
- Fixes a bug where Tusky's URL detection algorithm was different from Mastodon's https://github.com/tuskyapp/Tusky/pull/4642
## v26.1
### New features and other improvements
- The "Reply privacy" account preference now has two additional options: "Match default post privacy" and "Direct". "Match default post privacy" is the default for new accounts. https://github.com/tuskyapp/Tusky/pull/4568
- Tusky now includes ISRG root certificates to keep working on Android 7 and servers that use Let's Encrypt. https://github.com/tuskyapp/Tusky/pull/4609
- The soft keyboard will now be hidden after performing a search. https://github.com/tuskyapp/Tusky/pull/4578
### Significant bug fixes
- Fixes a bug where Tusky sometimes mixes up timelines and/or notifications of accounts. https://github.com/tuskyapp/Tusky/pull/4577 https://github.com/tuskyapp/Tusky/pull/4599
- Fixes two bugs where Tusky would not provide the translation option even though the server is configured correctly. https://github.com/tuskyapp/Tusky/pull/4560 https://github.com/tuskyapp/Tusky/pull/4590
- Fixes a rare bug where Tusky would sometimes randomly crash on startup. https://github.com/tuskyapp/Tusky/pull/4569
- Fixes a bug where the timeline would randomly jump to the position of the last clicked "show more" placeholder when "Reading order" was set to "Oldest first". https://github.com/tuskyapp/Tusky/pull/4619
## v26.0
### New features and other improvements
- The blue primary color that previously was the same for all themes is now slightly lighter in the dark theme and darker in the light theme for better contrast.
Consequently, the color that is used on top of the primary color (e.g. on buttons) is now dark instead of white in the dark theme. [PR#3921](https://github.com/tuskyapp/Tusky/pull/3921) [PR#4507](https://github.com/tuskyapp/Tusky/pull/4507)
- New account preference "default reply privacy".
Note that in contrast to the "default post privacy" this setting will not be synced with the server as Mastodon does not have this feature. [PR#4496](https://github.com/tuskyapp/Tusky/pull/4496)
- New preference "Show confirmation before following" [PR#4445](https://github.com/tuskyapp/Tusky/pull/4445)
- The notification tab is now cached on the device for better offline behavior.
Since it shares the cache with the home timeline, interactions with posts will now sync between those tabs more often than before. [PR#4026](https://github.com/tuskyapp/Tusky/pull/4026)
- Tusky will now only make one call to the server to check which version of the filters api is supported and cache the result instead of everytime filters are needed. [PR#4539](https://github.com/tuskyapp/Tusky/pull/4539)
- The "Hide compose button while scrolling" preference, which had the main purpose of making content behind the button accessible, has been removed and bottom padding added to all lists that could be obscured by buttons. [PR#4486](https://github.com/tuskyapp/Tusky/pull/4486)
- When viewing media of a translated post the media descriptions will now also be translated [PR#4463](https://github.com/tuskyapp/Tusky/pull/4463)
- The custom emojis in the emoji picker are now sorted by category [PR#4533](https://github.com/tuskyapp/Tusky/pull/4533)
- Various internal refactorings to improve performance and maintainability.
[PR#4515](https://github.com/tuskyapp/Tusky/pull/4515)
[PR#4502](https://github.com/tuskyapp/Tusky/pull/4502)
[PR#4472](https://github.com/tuskyapp/Tusky/pull/4472)
[PR#4470](https://github.com/tuskyapp/Tusky/pull/4470)
[PR#4443](https://github.com/tuskyapp/Tusky/pull/4443)
[PR#4441](https://github.com/tuskyapp/Tusky/pull/4441)
[PR#4461](https://github.com/tuskyapp/Tusky/pull/4461)
[PR#4447](https://github.com/tuskyapp/Tusky/pull/4447)
[PR#4411](https://github.com/tuskyapp/Tusky/pull/4411)
[PR#4413](https://github.com/tuskyapp/Tusky/pull/4413)
### Significant bug fixes
- Posts with null media focus values will no longer cause Tusky to show an error [PR#4462](https://github.com/tuskyapp/Tusky/pull/4462)
- A lot of other bugfixes, mostly smaller display bugs
[PR#4536](https://github.com/tuskyapp/Tusky/pull/4536)
[PR#4537](https://github.com/tuskyapp/Tusky/pull/4537)
[PR#4527](https://github.com/tuskyapp/Tusky/pull/4527)
[PR#4521](https://github.com/tuskyapp/Tusky/pull/4521)
[PR#4525](https://github.com/tuskyapp/Tusky/pull/4525)
[PR#4518](https://github.com/tuskyapp/Tusky/pull/4518)
[PR#4514](https://github.com/tuskyapp/Tusky/pull/4514)
[PR#4491](https://github.com/tuskyapp/Tusky/pull/4491)
[PR#4490](https://github.com/tuskyapp/Tusky/pull/4490)
[PR#4474](https://github.com/tuskyapp/Tusky/pull/4474)
[PR#4436](https://github.com/tuskyapp/Tusky/pull/4436)
## v25.2
### Significant bug fixes

View file

@ -1,8 +1,8 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.google.ksp)
alias(libs.plugins.hilt.android)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.kapt)
alias(libs.plugins.kotlin.parcelize)
}
@ -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 34
compileSdk 35
namespace "com.keylesspalace.tusky"
defaultConfig {
applicationId APP_ID
namespace "com.keylesspalace.tusky"
minSdk 24
targetSdk 35
versionCode 93
versionName "25.2-CW0"
versionCode 94
versionName "28.0-CW0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true
@ -84,6 +84,7 @@ android {
resValues true
viewBinding true
}
testOptions {
unitTests {
returnDefaultValues = true
@ -95,13 +96,9 @@ android {
}
}
sourceSets {
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
}
packagingOptions {
jniLibs {
useLegacyPackaging true
}
// workaround to have migrations available in unit tests
// https://github.com/robolectric/robolectric/issues/3928#issuecomment-395309991
debug.assets.srcDirs += files("$projectDir/schemas".toString())
}
// Exclude unneeded files added by libraries
@ -110,6 +107,12 @@ android {
'LICENSE_UNICODE',
]
packagingOptions {
jniLibs {
useLegacyPackaging true
}
}
bundle {
language {
// bundle all languages in every apk so the dynamic language switching works
@ -165,8 +168,10 @@ dependencies {
implementation libs.bundles.glide
ksp libs.glide.compiler
implementation libs.bundles.dagger
kapt libs.bundles.dagger.processors
implementation libs.hilt.android
ksp libs.hilt.compiler
implementation libs.androidx.hilt.work
ksp libs.androidx.hilt.compiler
implementation libs.sparkbutton
@ -189,29 +194,8 @@ dependencies {
testImplementation libs.bundles.mockito
testImplementation libs.mockwebserver
testImplementation libs.androidx.core.testing
testImplementation libs.androidx.room.testing
testImplementation libs.kotlinx.coroutines.test
testImplementation libs.androidx.work.testing
testImplementation libs.truth
testImplementation libs.turbine
androidTestImplementation libs.espresso.core
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"])
}

View file

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<issues format="6" by="lint 8.3.1" type="baseline" client="gradle" dependencies="false" name="AGP (8.3.1)" variant="all" version="8.3.1">
<issues format="6" by="lint 8.6.0" type="baseline" client="gradle" dependencies="false" name="AGP (8.6.0)" variant="all" version="8.6.0">
<issue
id="GestureBackNavigation"
@ -8,7 +8,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt"
line="1314"
line="1288"
column="28"/>
</issue>
@ -19,7 +19,7 @@
errorLine2=" ~~~~~~~~~~~">
<location
file="src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java"
line="104"
line="101"
column="32"/>
</issue>
@ -53,14 +53,14 @@
<issue
id="PrivateResource"
message="Overriding `@layout/exo_player_control_view` which is marked as private in androidx.media3:media3-ui:1.3.0. If deliberate, use tools:override=&quot;true&quot;, otherwise pick a different name.">
message="Overriding `@layout/exo_player_control_view` which is marked as private in androidx.media3:media3-ui:1.4.1. If deliberate, use tools:override=&quot;true&quot;, otherwise pick a different name.">
<location
file="src/main/res/layout/exo_player_control_view.xml"/>
</issue>
<issue
id="PrivateResource"
message="The resource `@color/exo_bottom_bar_background` is marked as private in androidx.media3:media3-ui:1.3.0"
message="The resource `@color/exo_bottom_bar_background` is marked as private in androidx.media3:media3-ui:1.4.1"
errorLine1=" android:background=&quot;@color/exo_bottom_bar_background&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
@ -71,7 +71,7 @@
<issue
id="PrivateResource"
message="The resource `@dimen/exo_styled_controls_padding` is marked as private in androidx.media3:media3-ui:1.3.0"
message="The resource `@dimen/exo_styled_controls_padding` is marked as private in androidx.media3:media3-ui:1.4.1"
errorLine1=" android:padding=&quot;@dimen/exo_styled_controls_padding&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
@ -82,7 +82,7 @@
<issue
id="PrivateResource"
message="The resource `@layout/exo_player_control_rewind_button` is marked as private in androidx.media3:media3-ui:1.3.0"
message="The resource `@layout/exo_player_control_rewind_button` is marked as private in androidx.media3:media3-ui:1.4.1"
errorLine1=" &lt;include layout=&quot;@layout/exo_player_control_rewind_button&quot; />"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
@ -93,7 +93,7 @@
<issue
id="PrivateResource"
message="The resource `@layout/exo_player_control_ffwd_button` is marked as private in androidx.media3:media3-ui:1.3.0"
message="The resource `@layout/exo_player_control_ffwd_button` is marked as private in androidx.media3:media3-ui:1.4.1"
errorLine1=" &lt;include layout=&quot;@layout/exo_player_control_ffwd_button&quot; />"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
@ -104,7 +104,7 @@
<issue
id="PrivateResource"
message="The resource `@dimen/exo_styled_bottom_bar_height` is marked as private in androidx.media3:media3-ui:1.3.0"
message="The resource `@dimen/exo_styled_bottom_bar_height` is marked as private in androidx.media3:media3-ui:1.4.1"
errorLine1=" android:layout_height=&quot;@dimen/exo_styled_bottom_bar_height&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
@ -115,7 +115,7 @@
<issue
id="PrivateResource"
message="The resource `@dimen/exo_styled_bottom_bar_margin_top` is marked as private in androidx.media3:media3-ui:1.3.0"
message="The resource `@dimen/exo_styled_bottom_bar_margin_top` is marked as private in androidx.media3:media3-ui:1.4.1"
errorLine1=" android:layout_marginTop=&quot;@dimen/exo_styled_bottom_bar_margin_top&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
@ -126,7 +126,7 @@
<issue
id="PrivateResource"
message="The resource `@color/exo_bottom_bar_background` is marked as private in androidx.media3:media3-ui:1.3.0"
message="The resource `@color/exo_bottom_bar_background` is marked as private in androidx.media3:media3-ui:1.4.1"
errorLine1=" android:background=&quot;@color/exo_bottom_bar_background&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
@ -137,7 +137,7 @@
<issue
id="PrivateResource"
message="The resource `@dimen/exo_styled_bottom_bar_time_padding` is marked as private in androidx.media3:media3-ui:1.3.0"
message="The resource `@dimen/exo_styled_bottom_bar_time_padding` is marked as private in androidx.media3:media3-ui:1.4.1"
errorLine1=" android:paddingStart=&quot;@dimen/exo_styled_bottom_bar_time_padding&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
@ -148,7 +148,7 @@
<issue
id="PrivateResource"
message="The resource `@dimen/exo_styled_bottom_bar_time_padding` is marked as private in androidx.media3:media3-ui:1.3.0"
message="The resource `@dimen/exo_styled_bottom_bar_time_padding` is marked as private in androidx.media3:media3-ui:1.4.1"
errorLine1=" android:paddingEnd=&quot;@dimen/exo_styled_bottom_bar_time_padding&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
@ -159,7 +159,7 @@
<issue
id="PrivateResource"
message="The resource `@dimen/exo_styled_bottom_bar_time_padding` is marked as private in androidx.media3:media3-ui:1.3.0"
message="The resource `@dimen/exo_styled_bottom_bar_time_padding` is marked as private in androidx.media3:media3-ui:1.4.1"
errorLine1=" android:paddingLeft=&quot;@dimen/exo_styled_bottom_bar_time_padding&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
@ -170,7 +170,7 @@
<issue
id="PrivateResource"
message="The resource `@dimen/exo_styled_bottom_bar_time_padding` is marked as private in androidx.media3:media3-ui:1.3.0"
message="The resource `@dimen/exo_styled_bottom_bar_time_padding` is marked as private in androidx.media3:media3-ui:1.4.1"
errorLine1=" android:paddingRight=&quot;@dimen/exo_styled_bottom_bar_time_padding&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
@ -181,7 +181,7 @@
<issue
id="PrivateResource"
message="The resource `@dimen/exo_styled_progress_layout_height` is marked as private in androidx.media3:media3-ui:1.3.0"
message="The resource `@dimen/exo_styled_progress_layout_height` is marked as private in androidx.media3:media3-ui:1.4.1"
errorLine1=" android:layout_height=&quot;@dimen/exo_styled_progress_layout_height&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
@ -192,7 +192,7 @@
<issue
id="PrivateResource"
message="The resource `@dimen/exo_styled_progress_margin_bottom` is marked as private in androidx.media3:media3-ui:1.3.0"
message="The resource `@dimen/exo_styled_progress_margin_bottom` is marked as private in androidx.media3:media3-ui:1.4.1"
errorLine1=" android:layout_marginBottom=&quot;@dimen/exo_styled_progress_margin_bottom&quot;/>"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
@ -203,7 +203,7 @@
<issue
id="PrivateResource"
message="The resource `@dimen/exo_styled_minimal_controls_margin_bottom` is marked as private in androidx.media3:media3-ui:1.3.0"
message="The resource `@dimen/exo_styled_minimal_controls_margin_bottom` is marked as private in androidx.media3:media3-ui:1.4.1"
errorLine1=" android:layout_marginBottom=&quot;@dimen/exo_styled_minimal_controls_margin_bottom&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
@ -215,22 +215,22 @@
<issue
id="PluralsCandidate"
message="Formatting %d followed by words (&quot;posts&quot;): This should probably be a plural rather than a string"
errorLine1=" &lt;string name=&quot;notification_summary_report_format&quot;>%s · %d posts attached&lt;/string>"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
errorLine1=" &lt;string name=&quot;notification_summary_report_format&quot;>%1$s · %2$d posts attached&lt;/string>"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="109"
line="111"
column="5"/>
</issue>
<issue
id="PluralsCandidate"
message="Formatting %d followed by words (&quot;and&quot;): This should probably be a plural rather than a string"
errorLine1=" &lt;string name=&quot;pref_title_http_proxy_port_message&quot;>Port should be between %d and %d&lt;/string>"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
errorLine1=" &lt;string name=&quot;pref_title_http_proxy_port_message&quot;>Port should be between %1$d and %2$d&lt;/string>"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="331"
line="336"
column="5"/>
</issue>
@ -241,7 +241,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="389"
line="398"
column="5"/>
</issue>
@ -252,7 +252,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="570"
line="579"
column="5"/>
</issue>
@ -263,7 +263,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="790"
line="801"
column="5"/>
</issue>
@ -384,7 +384,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt"
line="532"
line="478"
column="9"/>
</issue>
@ -428,7 +428,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementAdapter.kt"
line="157"
line="175"
column="9"/>
</issue>
@ -498,28 +498,6 @@
column="13"/>
</issue>
<issue
id="StringFormatTrivial"
message="This formatting string is trivial. Rather than using `String.format` to create your String, it will be more performant to concatenate your arguments with `+`. "
errorLine1=" (error) -> Log.e(TAG, String.format(&quot;Failed to %s account id %s&quot;, accept ? &quot;accept&quot; : &quot;reject&quot;, id))"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java"
line="808"
column="49"/>
</issue>
<issue
id="StringFormatTrivial"
message="This formatting string is trivial. Rather than using `String.format` to create your String, it will be more performant to concatenate your arguments with `+`. "
errorLine1=" LinkHelper.openLink(requireContext(), String.format(&quot;https://%s/admin/reports/%s&quot;, accountManager.getActiveAccount().getDomain(), reportId));"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java"
line="829"
column="61"/>
</issue>
<issue
id="SmallSp"
message="Avoid using sizes smaller than `11sp`: `8sp`"
@ -567,11 +545,11 @@
<issue
id="ReportShortcutUsage"
message="Calling this method indicates use of dynamic shortcuts, but there are no calls to methods that track shortcut usage, such as `pushDynamicShortcut` or `reportShortcutUsed`. Calling these methods is recommended, as they track shortcut usage and allow launchers to adjust which shortcuts appear based on activation history. Please see https://developer.android.com/develop/ui/views/launch/shortcuts/managing-shortcuts#track-usage"
errorLine1=" ShortcutManagerCompat.addDynamicShortcuts(context, listOf(shortcutInfo))"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
errorLine1=" ShortcutManagerCompat.setDynamicShortcuts(context, shortcuts)"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/keylesspalace/tusky/util/ShareShortcutHelper.kt"
line="96"
line="101"
column="13"/>
</issue>
@ -747,7 +725,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/layout/activity_edit_profile.xml"
line="129"
line="128"
column="21"/>
</issue>
@ -780,7 +758,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/layout/item_report_notification.xml"
line="23"
line="22"
column="9"/>
</issue>
@ -872,312 +850,4 @@
column="9"/>
</issue>
<issue
id="RtlHardcoded"
message="Consider replacing `android:layout_marginLeft` with `android:layout_marginStart=&quot;8dp&quot;` to better support right-to-left layouts"
errorLine1=" android:layout_marginLeft=&quot;8dp&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/layout/item_list.xml"
line="37"
column="9"/>
</issue>
<issue
id="RtlHardcoded"
message="Consider replacing `android:layout_marginLeft` with `android:layout_marginStart=&quot;8dp&quot;` to better support right-to-left layouts"
errorLine1=" android:layout_marginLeft=&quot;8dp&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/layout/item_list.xml"
line="48"
column="9"/>
</issue>
<issue
id="RtlHardcoded"
message="Consider replacing `android:layout_marginLeft` with `android:layout_marginStart=&quot;8dp&quot;` to better support right-to-left layouts"
errorLine1=" android:layout_marginLeft=&quot;8dp&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/layout/item_list.xml"
line="59"
column="9"/>
</issue>
<issue
id="UnknownNullness"
message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://developer.android.com/kotlin/interop#nullability_annotations"
errorLine1=" public abstract boolean deepEquals(NotificationViewData other);"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java"
line="43"
column="40"/>
</issue>
<issue
id="UnknownNullness"
message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://developer.android.com/kotlin/interop#nullability_annotations"
errorLine1=" public Concrete(Notification.Type type, String id, TimelineAccount account,"
errorLine2=" ~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java"
line="54"
column="25"/>
</issue>
<issue
id="UnknownNullness"
message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://developer.android.com/kotlin/interop#nullability_annotations"
errorLine1=" public Concrete(Notification.Type type, String id, TimelineAccount account,"
errorLine2=" ~~~~~~">
<location
file="src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java"
line="54"
column="49"/>
</issue>
<issue
id="UnknownNullness"
message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://developer.android.com/kotlin/interop#nullability_annotations"
errorLine1=" public Concrete(Notification.Type type, String id, TimelineAccount account,"
errorLine2=" ~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java"
line="54"
column="60"/>
</issue>
<issue
id="UnknownNullness"
message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://developer.android.com/kotlin/interop#nullability_annotations"
errorLine1=" public Notification.Type getType() {"
errorLine2=" ~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java"
line="63"
column="16"/>
</issue>
<issue
id="UnknownNullness"
message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://developer.android.com/kotlin/interop#nullability_annotations"
errorLine1=" public String getId() {"
errorLine2=" ~~~~~~">
<location
file="src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java"
line="67"
column="16"/>
</issue>
<issue
id="UnknownNullness"
message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://developer.android.com/kotlin/interop#nullability_annotations"
errorLine1=" public TimelineAccount getAccount() {"
errorLine2=" ~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java"
line="71"
column="16"/>
</issue>
<issue
id="UnknownNullness"
message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://developer.android.com/kotlin/interop#nullability_annotations"
errorLine1=" public boolean deepEquals(NotificationViewData o) {"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java"
line="91"
column="35"/>
</issue>
<issue
id="UnknownNullness"
message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://developer.android.com/kotlin/interop#nullability_annotations"
errorLine1=" public Concrete copyWithStatus(@Nullable StatusViewData.Concrete statusViewData) {"
errorLine2=" ~~~~~~~~">
<location
file="src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java"
line="108"
column="16"/>
</issue>
<issue
id="UnknownNullness"
message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://developer.android.com/kotlin/interop#nullability_annotations"
errorLine1=" public boolean deepEquals(NotificationViewData other) {"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java"
line="132"
column="35"/>
</issue>
<issue
id="UnknownNullness"
message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://developer.android.com/kotlin/interop#nullability_annotations"
errorLine1=" public NotificationsAdapter(String accountId,"
errorLine2=" ~~~~~~">
<location
file="src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java"
line="98"
column="33"/>
</issue>
<issue
id="UnknownNullness"
message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://developer.android.com/kotlin/interop#nullability_annotations"
errorLine1=" AdapterDataSource&lt;NotificationViewData> dataSource,"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java"
line="99"
column="33"/>
</issue>
<issue
id="UnknownNullness"
message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://developer.android.com/kotlin/interop#nullability_annotations"
errorLine1=" StatusDisplayOptions statusDisplayOptions,"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java"
line="100"
column="33"/>
</issue>
<issue
id="UnknownNullness"
message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://developer.android.com/kotlin/interop#nullability_annotations"
errorLine1=" StatusActionListener statusListener,"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java"
line="101"
column="33"/>
</issue>
<issue
id="UnknownNullness"
message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://developer.android.com/kotlin/interop#nullability_annotations"
errorLine1=" NotificationActionListener notificationActionListener,"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java"
line="102"
column="33"/>
</issue>
<issue
id="UnknownNullness"
message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://developer.android.com/kotlin/interop#nullability_annotations"
errorLine1=" AccountActionListener accountActionListener) {"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java"
line="103"
column="33"/>
</issue>
<issue
id="UnknownNullness"
message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://developer.android.com/kotlin/interop#nullability_annotations"
errorLine1=" void onViewAccount(String id);"
errorLine2=" ~~~~~~">
<location
file="src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java"
line="340"
column="28"/>
</issue>
<issue
id="UnknownNullness"
message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://developer.android.com/kotlin/interop#nullability_annotations"
errorLine1=" void onViewStatusForNotificationId(String notificationId);"
errorLine2=" ~~~~~~">
<location
file="src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java"
line="342"
column="44"/>
</issue>
<issue
id="UnknownNullness"
message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://developer.android.com/kotlin/interop#nullability_annotations"
errorLine1=" void onViewReport(String reportId);"
errorLine2=" ~~~~~~">
<location
file="src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java"
line="344"
column="27"/>
</issue>
<issue
id="UnknownNullness"
message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://developer.android.com/kotlin/interop#nullability_annotations"
errorLine1=" public static NotificationsFragment newInstance() {"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java"
line="196"
column="19"/>
</issue>
<issue
id="UnknownNullness"
message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://developer.android.com/kotlin/interop#nullability_annotations"
errorLine1=" public void onMute(boolean mute, String id, int position, boolean notifications) {"
errorLine2=" ~~~~~~">
<location
file="src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java"
line="791"
column="38"/>
</issue>
<issue
id="UnknownNullness"
message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://developer.android.com/kotlin/interop#nullability_annotations"
errorLine1=" public void onBlock(boolean block, String id, int position) {"
errorLine2=" ~~~~~~">
<location
file="src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java"
line="796"
column="40"/>
</issue>
<issue
id="UnknownNullness"
message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://developer.android.com/kotlin/interop#nullability_annotations"
errorLine1=" public void onRespondToFollowRequest(boolean accept, String id, int position) {"
errorLine2=" ~~~~~~">
<location
file="src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java"
line="801"
column="58"/>
</issue>
<issue
id="UnknownNullness"
message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://developer.android.com/kotlin/interop#nullability_annotations"
errorLine1=" public void onViewStatusForNotificationId(String notificationId) {"
errorLine2=" ~~~~~~">
<location
file="src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java"
line="813"
column="47"/>
</issue>
<issue
id="UnknownNullness"
message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://developer.android.com/kotlin/interop#nullability_annotations"
errorLine1=" public void onViewReport(String reportId) {"
errorLine2=" ~~~~~~">
<location
file="src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java"
line="828"
column="30"/>
</issue>
</issues>

View file

@ -71,6 +71,12 @@
<issue id="Recycle" severity="error" />
<issue id="KeyboardInaccessibleWidget" severity="error" />
<!-- these three don't work with Kotlin 2.1 for some reason
https://github.com/tuskyapp/Tusky/pull/4774 -->
<issue id="StateFlowValueCalledInComposition" severity="ignore" />
<issue id="CoroutineCreationDuringComposition" severity="ignore" />
<issue id="FlowOperatorInvokedInComposition" severity="ignore" />
<!-- Mark all other lint issues as warnings -->
<issue id="all" severity="warning" />
</lint>

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

@ -1,68 +0,0 @@
package com.keylesspalace.tusky
import androidx.room.testing.MigrationTestHelper
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.keylesspalace.tusky.db.AppDatabase
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
const val TEST_DB = "migration_test"
@RunWith(AndroidJUnit4::class)
class MigrationsTest {
@JvmField
@Rule
var helper: MigrationTestHelper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
AppDatabase::class.java.canonicalName!!,
FrameworkSQLiteOpenHelperFactory()
)
@Test
fun migrateTo11() {
val db = helper.createDatabase(TEST_DB, 10)
val id = 1
val domain = "domain.site"
val token = "token"
val active = true
val accountId = "accountId"
val username = "username"
val values = arrayOf(
id, domain, token, active, accountId, username, "Display Name",
"https://picture.url", true, true, true, true, true, true, true,
true, "1000", "[]", "[{\"shortcode\": \"emoji\", \"url\": \"yes\"}]", 0, false,
false, true
)
db.execSQL(
"INSERT OR REPLACE INTO `AccountEntity`(`id`,`domain`,`accessToken`,`isActive`," +
"`accountId`,`username`,`displayName`,`profilePictureUrl`,`notificationsEnabled`," +
"`notificationsMentioned`,`notificationsFollowed`,`notificationsReblogged`," +
"`notificationsFavorited`,`notificationSound`,`notificationVibration`," +
"`notificationLight`,`lastNotificationId`,`activeNotifications`,`emojis`," +
"`defaultPostPrivacy`,`defaultMediaSensitivity`,`alwaysShowSensitiveMedia`," +
"`mediaPreviewEnabled`) " +
"VALUES (nullif(?, 0),?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
values
)
db.close()
val newDb = helper.runMigrationsAndValidate(TEST_DB, 11, true, AppDatabase.MIGRATION_10_11)
val cursor = newDb.query("SELECT * FROM AccountEntity")
cursor.moveToFirst()
assertEquals(id, cursor.getInt(0))
assertEquals(domain, cursor.getString(1))
assertEquals(token, cursor.getString(2))
assertEquals(active, cursor.getInt(3) != 0)
assertEquals(accountId, cursor.getString(4))
assertEquals(username, cursor.getString(5))
}
}

View file

@ -19,24 +19,9 @@
android:theme="@style/TuskyTheme"
android:usesCleartextTraffic="false"
android:localeConfig="@xml/locales_config"
android:enableOnBackInvokedCallback="true">
android:enableOnBackInvokedCallback="true"
android:networkSecurityConfig="@xml/network_security_config">
<activity
android:name=".SplashActivity"
android:theme="@style/SplashTheme"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/share_shortcuts" />
</activity>
<activity
android:name=".components.login.LoginActivity"
android:windowSoftInputMode="adjustResize"
@ -55,9 +40,16 @@
<activity android:name=".components.login.LoginWebViewActivity" />
<activity
android:name=".MainActivity"
android:configChanges="orientation|screenSize|keyboardHidden|screenLayout|smallestScreenSize"
android:exported="true">
android:exported="true"
android:alwaysRetainTaskState="true"
android:maxRecents="1"
android:theme="@style/SplashTheme">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
@ -101,15 +93,21 @@
<data android:mimeType="audio/*" />
</intent-filter>
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/share_shortcuts" />
<meta-data
android:name="android.service.chooser.chooser_target_service"
android:value="androidx.sharetarget.ChooserTargetServiceCompat" />
</activity>
<!-- When content is shared to Tusky ComposeActivity can be the only active activity,
so it must be excluded from recents or the sharing process can be restarted from recents
which would be very unexpected -->
<activity
android:name=".components.compose.ComposeActivity"
android:theme="@style/TuskyDialogActivityTheme"
android:windowSoftInputMode="stateVisible|adjustResize" />
android:alwaysRetainTaskState="true" />
<activity
android:name=".components.viewthread.ViewThreadActivity"
android:configChanges="orientation|screenSize" />
@ -117,10 +115,8 @@
android:name=".ViewMediaActivity"
android:theme="@style/TuskyBaseTheme"
android:configChanges="orientation|screenSize|keyboardHidden|screenLayout|smallestScreenSize" />
<activity
android:name=".components.account.AccountActivity"
android:configChanges="orientation|screenSize|keyboardHidden|screenLayout|smallestScreenSize" />
<activity android:name=".EditProfileActivity" />
<activity android:name=".components.account.AccountActivity" />
<activity android:name=".EditProfileActivity"/>
<activity android:name=".components.preference.PreferencesActivity" />
<activity android:name=".StatusListActivity" />
<activity android:name=".components.accountlist.AccountListActivity" />
@ -155,6 +151,9 @@
<activity android:name=".components.drafts.DraftsActivity" />
<activity android:name="com.keylesspalace.tusky.components.filters.EditFilterActivity"
android:windowSoftInputMode="adjustResize" />
<activity android:name=".components.preference.notificationpolicies.NotificationPoliciesActivity"/>
<activity android:name=".components.notifications.requests.NotificationRequestsActivity"/>
<activity android:name=".components.notifications.requests.details.NotificationRequestDetailsActivity"/>
<receiver
android:name=".receiver.SendStatusBroadcastReceiver"

View file

@ -1,8 +1,5 @@
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
@ -12,20 +9,24 @@ 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.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat.Type.systemBars
import androidx.core.view.updatePadding
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.copyToClipboard
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.launch
class AboutActivity : BottomSheetActivity(), Injectable {
@AndroidEntryPoint
class AboutActivity : BottomSheetActivity() {
@Inject
lateinit var instanceInfoRepository: InstanceInfoRepository
@ -43,6 +44,13 @@ class AboutActivity : BottomSheetActivity(), Injectable {
setTitle(R.string.about_title_activity)
ViewCompat.setOnApplyWindowInsetsListener(binding.scrollView) { scrollView, insets ->
val systemBarInsets = insets.getInsets(systemBars())
scrollView.updatePadding(bottom = systemBarInsets.bottom)
insets.inset(0, 0, 0, systemBarInsets.bottom)
}
binding.versionTextView.text = getString(R.string.about_app_version, getString(R.string.app_name), BuildConfig.VERSION_NAME)
binding.deviceInfo.text = getString(
@ -90,13 +98,11 @@ class AboutActivity : BottomSheetActivity(), Injectable {
}
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()
}
copyToClipboard(
"${binding.versionTextView.text}\n\nDevice:\n\n${binding.deviceInfo.text}\n\nAccount:\n\n${binding.accountInfo.text}",
getString(R.string.about_copied),
"Tusky version information",
)
}
}
}

View file

@ -16,6 +16,7 @@
package com.keylesspalace.tusky
import android.content.SharedPreferences
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
@ -25,52 +26,48 @@ import androidx.appcompat.widget.SearchView
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.ListAdapter
import com.keylesspalace.tusky.databinding.FragmentAccountsInListBinding
import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.BindingHolder
import com.keylesspalace.tusky.util.Either
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.loadAvatar
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.unsafeLazy
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible
import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel
import com.keylesspalace.tusky.viewmodel.State
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.launch
private typealias AccountInfo = Pair<TimelineAccount, Boolean>
class AccountsInListFragment : DialogFragment(), Injectable {
@AndroidEntryPoint
class AccountsInListFragment : DialogFragment() {
@Inject
lateinit var viewModelFactory: ViewModelFactory
lateinit var preferences: SharedPreferences
private val viewModel: AccountsInListViewModel by viewModels { viewModelFactory }
private val binding by viewBinding(FragmentAccountsInListBinding::bind)
private val viewModel: AccountsInListViewModel by viewModels()
private lateinit var binding: FragmentAccountsInListBinding
private lateinit var listId: String
private lateinit var listName: String
private val adapter = Adapter()
private val searchAdapter = SearchAdapter()
private val radius by unsafeLazy { resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp) }
private val pm by unsafeLazy { PreferenceManager.getDefaultSharedPreferences(requireContext()) }
private val animateAvatar by unsafeLazy { pm.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false) }
private val animateEmojis by unsafeLazy { pm.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) }
private val animateAvatar by unsafeLazy { preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false) }
private val animateEmojis by unsafeLazy { preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) }
private val showBotOverlay by unsafeLazy { preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true) }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(STYLE_NORMAL, R.style.TuskyDialogFragmentStyle)
val args = requireArguments()
listId = args.getString(LIST_ID_ARG)!!
listName = args.getString(LIST_NAME_ARG)!!
@ -78,6 +75,8 @@ class AccountsInListFragment : DialogFragment(), Injectable {
viewModel.load(listId)
}
override fun getTheme() = R.style.TuskyDialogFragment
override fun onStart() {
super.onStart()
dialog?.apply {
@ -89,31 +88,27 @@ class AccountsInListFragment : DialogFragment(), Injectable {
}
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_accounts_in_list, container, false)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
binding = FragmentAccountsInListBinding.inflate(layoutInflater)
val adapter = Adapter()
val searchAdapter = SearchAdapter()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.accountsRecycler.layoutManager = LinearLayoutManager(view.context)
binding.accountsRecycler.layoutManager = LinearLayoutManager(binding.root.context)
binding.accountsRecycler.adapter = adapter
binding.accountsSearchRecycler.layoutManager = LinearLayoutManager(view.context)
binding.accountsSearchRecycler.layoutManager = LinearLayoutManager(binding.root.context)
binding.accountsSearchRecycler.adapter = searchAdapter
viewLifecycleOwner.lifecycleScope.launch {
lifecycleScope.launch {
viewModel.state.collect { state ->
adapter.submitList(state.accounts.asRightOrNull() ?: listOf())
adapter.submitList(state.accounts.getOrDefault(emptyList()))
when (state.accounts) {
is Either.Right -> binding.messageView.hide()
is Either.Left -> handleError(state.accounts.value)
}
state.accounts.fold(
onSuccess = { binding.messageView.hide() },
onFailure = { handleError(it) }
)
setupSearchView(state)
setupSearchView(searchAdapter, state)
}
}
@ -132,15 +127,16 @@ class AccountsInListFragment : DialogFragment(), Injectable {
return true
}
})
return binding.root
}
private fun setupSearchView(state: State) {
private fun setupSearchView(searchAdapter: SearchAdapter, state: State) {
if (state.searchResult == null) {
searchAdapter.submitList(listOf())
binding.accountsSearchRecycler.hide()
binding.accountsRecycler.show()
} else {
val listAccounts = state.accounts.asRightOrNull() ?: listOf()
val listAccounts = state.accounts.getOrDefault(emptyList())
val newList = state.searchResult.map { acc ->
acc to listAccounts.contains(acc)
}
@ -212,6 +208,7 @@ class AccountsInListFragment : DialogFragment(), Injectable {
val account = getItem(position)
holder.binding.displayNameTextView.text = account.name.emojify(account.emojis, holder.binding.displayNameTextView, animateEmojis)
holder.binding.usernameTextView.text = account.username
holder.binding.avatarBadge.visible(showBotOverlay && account.bot)
loadAvatar(account.avatar, holder.binding.avatar, radius, animateAvatar)
}
}
@ -263,6 +260,7 @@ class AccountsInListFragment : DialogFragment(), Injectable {
holder.binding.displayNameTextView.text = account.name.emojify(account.emojis, holder.binding.displayNameTextView, animateEmojis)
holder.binding.usernameTextView.text = account.username
holder.binding.avatarBadge.visible(showBotOverlay && account.bot)
loadAvatar(account.avatar, holder.binding.avatar, radius, animateAvatar)
holder.binding.rejectButton.apply {

View file

@ -1,4 +1,4 @@
/* Copyright 2017 Andrew Dawson
/* Copyright 2024 Tusky Contributors
*
* This file is a part of Tusky.
*
@ -12,117 +12,167 @@
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky
package com.keylesspalace.tusky;
import android.app.ActivityManager.TaskDescription
import android.content.Context
import android.content.DialogInterface
import android.content.Intent
import android.content.SharedPreferences
import android.graphics.BitmapFactory
import android.graphics.Color
import android.os.Build
import android.os.Bundle
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.annotation.StyleRes
import androidx.appcompat.app.AppCompatActivity
import androidx.core.graphics.Insets
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsCompat.Type.displayCutout
import androidx.core.view.WindowInsetsCompat.Type.systemBars
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import androidx.lifecycle.ViewModelProvider.Factory
import androidx.lifecycle.lifecycleScope
import com.google.android.material.R as materialR
import com.google.android.material.color.MaterialColors
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.keylesspalace.tusky.MainActivity.Companion.redirectIntent
import com.keylesspalace.tusky.adapter.AccountSelectionAdapter
import com.keylesspalace.tusky.components.login.LoginActivity
import com.keylesspalace.tusky.components.login.LoginActivity.Companion.getIntent
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.entity.AccountEntity
import com.keylesspalace.tusky.di.PreferencesEntryPoint
import com.keylesspalace.tusky.interfaces.AccountSelectionListener
import com.keylesspalace.tusky.settings.AppTheme
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.ActivityConstants
import com.keylesspalace.tusky.util.isBlack
import com.keylesspalace.tusky.util.overrideActivityTransitionCompat
import dagger.hilt.EntryPoints
import javax.inject.Inject
import kotlinx.coroutines.launch
import android.app.ActivityManager;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.content.res.Configuration;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Color;
import android.os.Bundle;
import android.util.Log;
import android.view.MenuItem;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
import androidx.preference.PreferenceManager;
import com.google.android.material.color.MaterialColors;
import com.google.android.material.snackbar.Snackbar;
import com.keylesspalace.tusky.adapter.AccountSelectionAdapter;
import com.keylesspalace.tusky.components.login.LoginActivity;
import com.keylesspalace.tusky.db.AccountEntity;
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;
import java.util.HashMap;
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";
/**
* All activities inheriting from BaseActivity must be annotated with @AndroidEntryPoint
*/
abstract class BaseActivity : AppCompatActivity() {
@Inject
lateinit var accountManager: AccountManager
@Inject
@NonNull
public AccountManager accountManager;
lateinit var preferences: SharedPreferences
private static final int REQUESTER_NONE = Integer.MAX_VALUE;
private HashMap<Integer, PermissionRequester> requesters;
/**
* Allows overriding the default ViewModelProvider.Factory for testing purposes.
*/
var viewModelProviderFactory: Factory? = null
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
override fun onCreate(savedInstanceState: Bundle?) {
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);
if (activityTransitionWasRequested()) {
overrideActivityTransitionCompat(
ActivityConstants.OVERRIDE_TRANSITION_OPEN,
R.anim.activity_open_enter,
R.anim.activity_open_exit
)
overrideActivityTransitionCompat(
ActivityConstants.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(APP_THEME, AppTheme.DEFAULT.getValue());
Log.d("activeTheme", theme);
if (ThemeUtils.isBlack(getResources().getConfiguration(), theme)) {
setTheme(R.style.TuskyBlackTheme);
val theme = preferences.getString(PrefKeys.APP_THEME, AppTheme.DEFAULT.value)
if (isBlack(resources.configuration, theme)) {
setTheme(R.style.TuskyBlackTheme)
} else if (this is MainActivity) {
// Replace the SplashTheme of MainActivity
setTheme(R.style.TuskyTheme)
}
/* set the taskdescription programmatically, the theme would turn it blue */
String appName = getString(R.string.app_name);
Bitmap appIcon = BitmapFactory.decodeResource(getResources(), R.mipmap.ic_launcher);
int recentsBackgroundColor = MaterialColors.getColor(this, com.google.android.material.R.attr.colorSurface, Color.BLACK);
/* Set the taskdescription programmatically - by default the primary color is used.
* On newer Android versions (or launchers?) this doesn't seem to have an effect. */
val appName = getString(R.string.app_name)
val recentsBackgroundColor = MaterialColors.getColor(
this,
materialR.attr.colorSurface,
Color.BLACK
)
setTaskDescription(new ActivityManager.TaskDescription(appName, appIcon, recentsBackgroundColor));
int style = textStyle(preferences.getString(PrefKeys.STATUS_TEXT_SIZE, "medium"));
getTheme().applyStyle(style, true);
if(requiresLogin()) {
redirectIfNotLoggedIn();
val taskDescription = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
TaskDescription.Builder()
.setLabel(appName)
.setIcon(R.mipmap.ic_launcher)
.setPrimaryColor(recentsBackgroundColor)
.build()
} else {
val appIcon = BitmapFactory.decodeResource(resources, R.mipmap.ic_launcher)
@Suppress("DEPRECATION")
TaskDescription(appName, appIcon, recentsBackgroundColor)
}
requesters = new HashMap<>();
setTaskDescription(taskDescription)
val style = textStyle(preferences.getString(PrefKeys.STATUS_TEXT_SIZE, "medium"))
getTheme().applyStyle(style, true)
if (requiresLogin()) {
redirectIfNotLoggedIn()
}
}
private boolean activityTransitionWasRequested() {
return getIntent().getBooleanExtra(OPEN_WITH_SLIDE_IN, false);
override fun onPostCreate(savedInstanceState: Bundle?) {
super.onPostCreate(savedInstanceState)
// currently only ComposeActivity on tablets is floating
if (!window.isFloating) {
window.decorView.setBackgroundColor(Color.BLACK)
val contentView: View = findViewById(android.R.id.content)
contentView.setBackgroundColor(MaterialColors.getColor(contentView, android.R.attr.colorBackground))
// handle left/right insets. This is relevant for edge-to-edge mode in landscape orientation
ViewCompat.setOnApplyWindowInsetsListener(contentView) { _, insets ->
val systemBarInsets = insets.getInsets(systemBars())
val displayCutoutInsets = insets.getInsets(displayCutout())
// use padding for system bar insets so they get our background color and margin for cutout insets to turn them black
contentView.updatePadding(left = systemBarInsets.left, right = systemBarInsets.right)
contentView.updateLayoutParams<ViewGroup.MarginLayoutParams> {
leftMargin = displayCutoutInsets.left
rightMargin = displayCutoutInsets.right
}
WindowInsetsCompat.Builder(insets)
.setInsets(systemBars(), Insets.of(0, systemBarInsets.top, 0, systemBarInsets.bottom))
.setInsets(displayCutout(), Insets.of(0, displayCutoutInsets.top, 0, displayCutoutInsets.bottom))
.build()
}
}
}
@Override
protected void attachBaseContext(Context newBase) {
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(newBase);
private fun activityTransitionWasRequested(): Boolean {
return intent.getBooleanExtra(OPEN_WITH_SLIDE_IN, false)
}
override fun attachBaseContext(newBase: Context) {
// injected preferences not yet available at this point of the lifecycle
val preferences =
EntryPoints.get(newBase.applicationContext, PreferencesEntryPoint::class.java)
.preferences()
// Scale text in the UI from PrefKeys.UI_TEXT_SCALE_RATIO
float uiScaleRatio = preferences.getFloat(PrefKeys.UI_TEXT_SCALE_RATIO, 100F);
val uiScaleRatio = preferences.getFloat(PrefKeys.UI_TEXT_SCALE_RATIO, 100f)
Configuration configuration = newBase.getResources().getConfiguration();
val configuration = newBase.resources.configuration
// Adjust `fontScale` in the configuration.
//
@ -134,7 +184,7 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
// changes to the base context. It does contain contain any changes to the font scale from
// "Settings > Display > Font size" in the device settings, so scaling performed here
// is in addition to any scaling in the device settings.
Configuration appConfiguration = newBase.getApplicationContext().getResources().getConfiguration();
val appConfiguration = newBase.applicationContext.resources.configuration
// This only adjusts the fonts, anything measured in `dp` is unaffected by this.
// You can try to adjust `densityDpi` as shown in the commented out code below. This
@ -146,163 +196,116 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
//
// val displayMetrics = appContext.resources.displayMetrics
// configuration.densityDpi = ((displayMetrics.densityDpi * uiScaleRatio).toInt())
configuration.fontScale = appConfiguration.fontScale * uiScaleRatio / 100F;
configuration.fontScale = appConfiguration.fontScale * uiScaleRatio / 100f
Context fontScaleContext = newBase.createConfigurationContext(configuration);
val fontScaleContext = newBase.createConfigurationContext(configuration)
super.attachBaseContext(fontScaleContext);
super.attachBaseContext(fontScaleContext)
}
protected boolean requiresLogin() {
return true;
}
override val defaultViewModelProviderFactory: Factory
get() = viewModelProviderFactory ?: super.defaultViewModelProviderFactory
private static int textStyle(String name) {
int style;
switch (name) {
case "smallest":
style = R.style.TextSizeSmallest;
break;
case "small":
style = R.style.TextSizeSmall;
break;
case "medium":
default:
style = R.style.TextSizeMedium;
break;
case "large":
style = R.style.TextSizeLarge;
break;
case "largest":
style = R.style.TextSizeLargest;
break;
protected open fun requiresLogin(): Boolean = true
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == android.R.id.home) {
onBackPressedDispatcher.onBackPressed()
return true
}
return style;
return super.onOptionsItemSelected(item)
}
@Override
public boolean onOptionsItemSelected(@NonNull MenuItem item) {
if (item.getItemId() == android.R.id.home) {
getOnBackPressedDispatcher().onBackPressed();
return true;
}
return super.onOptionsItemSelected(item);
}
private fun redirectIfNotLoggedIn() {
val currentAccounts = accountManager.accounts
@Override
public void finish() {
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);
if (currentAccounts.isEmpty()) {
val intent = getIntent(this@BaseActivity, LoginActivity.MODE_DEFAULT)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(intent)
finish()
}
}
protected void redirectIfNotLoggedIn() {
AccountEntity account = accountManager.getActiveAccount();
if (account == null) {
Intent intent = new Intent(this, LoginActivity.class);
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK);
ActivityExtensions.startActivityWithSlideInAnimation(this, intent);
finish();
}
}
fun showAccountChooserDialog(
dialogTitle: CharSequence?,
showActiveAccount: Boolean,
listener: AccountSelectionListener
) {
val accounts = accountManager.accounts.toMutableList()
val activeAccount = accountManager.activeAccount
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);
bar.show();
}
}
public void showAccountChooserDialog(@Nullable CharSequence dialogTitle, boolean showActiveAccount, @NonNull AccountSelectionListener listener) {
List<AccountEntity> accounts = accountManager.getAllAccountsOrderedByActive();
AccountEntity activeAccount = accountManager.getActiveAccount();
switch(accounts.size()) {
case 1:
listener.onAccountSelected(activeAccount);
return;
case 2:
if (!showActiveAccount) {
for (AccountEntity account : accounts) {
if (activeAccount != account) {
listener.onAccountSelected(account);
return;
}
when (accounts.size) {
1 -> {
listener.onAccountSelected(activeAccount!!)
return
}
2 -> if (!showActiveAccount) {
for (account in accounts) {
if (activeAccount !== account) {
listener.onAccountSelected(account)
return
}
}
break;
}
if (!showActiveAccount && activeAccount != null) {
accounts.remove(activeAccount);
}
AccountSelectionAdapter adapter = new AccountSelectionAdapter(this);
adapter.addAll(accounts);
new AlertDialog.Builder(this)
.setTitle(dialogTitle)
.setAdapter(adapter, (dialogInterface, index) -> listener.onAccountSelected(accounts.get(index)))
.show();
}
public @Nullable String getOpenAsText() {
List<AccountEntity> accounts = accountManager.getAllAccountsOrderedByActive();
switch (accounts.size()) {
case 0:
case 1:
return null;
case 2:
for (AccountEntity account : accounts) {
if (account != accountManager.getActiveAccount()) {
return String.format(getString(R.string.action_open_as), account.getFullName());
}
}
return null;
default:
return String.format(getString(R.string.action_open_as), "");
}
}
public void openAsAccount(@NonNull String url, @NonNull AccountEntity account) {
accountManager.setActiveAccount(account.getId());
Intent intent = MainActivity.redirectIntent(this, account.getId(), url);
startActivity(intent);
finish();
}
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
if (requesters.containsKey(requestCode)) {
PermissionRequester requester = requesters.remove(requestCode);
requester.onRequestPermissionsResult(permissions, grantResults);
}
}
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) {
permissionsToRequest.add(permission);
}
}
if (permissionsToRequest.isEmpty()) {
int[] permissionsAlreadyGranted = new int[permissions.length];
requester.onRequestPermissionsResult(permissions, permissionsAlreadyGranted);
return;
if (!showActiveAccount && activeAccount != null) {
accounts.remove(activeAccount)
}
val adapter = AccountSelectionAdapter(
this,
preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false),
preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
)
adapter.addAll(accounts)
MaterialAlertDialogBuilder(this)
.setTitle(dialogTitle)
.setAdapter(adapter) { _: DialogInterface?, index: Int ->
listener.onAccountSelected(accounts[index])
}
.show()
}
val openAsText: String?
get() {
val accounts = accountManager.accounts
when (accounts.size) {
0, 1 -> return null
2 -> {
for (account in accounts) {
if (account !== accountManager.activeAccount) {
return getString(R.string.action_open_as, account.fullName)
}
}
return null
}
else -> return getString(R.string.action_open_as, "")
}
}
int newKey = requester == null ? REQUESTER_NONE : requesters.size();
if (newKey != REQUESTER_NONE) {
requesters.put(newKey, requester);
}
String[] permissionsCopy = new String[permissionsToRequest.size()];
permissionsToRequest.toArray(permissionsCopy);
ActivityCompat.requestPermissions(this, permissionsCopy, newKey);
fun openAsAccount(url: String, account: AccountEntity) {
lifecycleScope.launch {
accountManager.setActiveAccount(account.id)
val intent = redirectIntent(this@BaseActivity, account.id, url)
startActivity(intent)
finish()
}
}
companion object {
const val OPEN_WITH_SLIDE_IN = "OPEN_WITH_SLIDE_IN"
@StyleRes
private fun textStyle(name: String?): Int = when (name) {
"smallest" -> R.style.TextSizeSmallest
"small" -> R.style.TextSizeSmall
"medium" -> R.style.TextSizeMedium
"large" -> R.style.TextSizeLarge
"largest" -> R.style.TextSizeLargest
else -> R.style.TextSizeMedium
}
}
}

View file

@ -22,6 +22,10 @@ import android.view.View
import android.widget.LinearLayout
import android.widget.Toast
import androidx.annotation.VisibleForTesting
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat.Type.ime
import androidx.core.view.WindowInsetsCompat.Type.systemBars
import androidx.core.view.updatePadding
import androidx.lifecycle.lifecycleScope
import at.connyduck.calladapter.networkresult.fold
import com.google.android.material.bottomsheet.BottomSheetBehavior
@ -62,6 +66,14 @@ abstract class BottomSheetActivity : BaseActivity() {
override fun onSlide(bottomSheet: View, slideOffset: Float) {}
})
ViewCompat.setOnApplyWindowInsetsListener(bottomSheetLayout) { _, insets ->
val systemBarsInsets = insets.getInsets(systemBars() or ime())
val bottomInsets = systemBarsInsets.bottom
bottomSheetLayout.updatePadding(bottom = bottomInsets)
insets
}
}
open fun viewUrl(

View file

@ -17,7 +17,6 @@ package com.keylesspalace.tusky
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Color
import android.net.Uri
import android.os.Bundle
import android.util.Log
@ -28,7 +27,13 @@ import android.widget.ImageView
import androidx.activity.OnBackPressedCallback
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.core.graphics.Insets
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsCompat.Type.ime
import androidx.core.view.WindowInsetsCompat.Type.systemBars
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import androidx.core.widget.doAfterTextChanged
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
@ -39,12 +44,13 @@ import com.bumptech.glide.load.resource.bitmap.RoundedCorners
import com.canhub.cropper.CropImage
import com.canhub.cropper.CropImageContract
import com.canhub.cropper.options
import com.google.android.material.R as materialR
import com.google.android.material.color.MaterialColors
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.adapter.AccountFieldEditAdapter
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
import com.keylesspalace.tusky.databinding.ActivityEditProfileBinding
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.util.Error
import com.keylesspalace.tusky.util.Loading
import com.keylesspalace.tusky.util.Success
@ -57,11 +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 javax.inject.Inject
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class EditProfileActivity : BaseActivity(), Injectable {
@AndroidEntryPoint
class EditProfileActivity : BaseActivity() {
companion object {
const val AVATAR_SIZE = 400
@ -69,10 +76,7 @@ class EditProfileActivity : BaseActivity(), Injectable {
const val HEADER_HEIGHT = 500
}
@Inject
lateinit var viewModelFactory: ViewModelFactory
private val viewModel: EditProfileViewModel by viewModels { viewModelFactory }
private val viewModel: EditProfileViewModel by viewModels()
private val binding by viewBinding(ActivityEditProfileBinding::inflate)
@ -121,6 +125,25 @@ class EditProfileActivity : BaseActivity(), Injectable {
setDisplayShowHomeEnabled(true)
}
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { scrollView, insets ->
// if keyboard visible -> set inset on the root to push the scrollview up
// if keyboard hidden -> set inset on the scrollview so last element does not get obscured by navigation bar
// scrollview has clipToPadding set to false so it draws behind the navigation bar in edge-to-edge mode
val imeInsets = insets.getInsets(ime())
val systemBarsInsets = insets.getInsets(systemBars())
binding.root.updatePadding(bottom = imeInsets.bottom)
val scrollViewPadding = if (imeInsets.bottom == 0) {
systemBarsInsets.bottom
} else {
0
}
binding.scrollView.updatePadding(bottom = scrollViewPadding)
WindowInsetsCompat.Builder(insets)
.setInsets(ime(), Insets.of(imeInsets.left, imeInsets.top, imeInsets.right, 0))
.setInsets(systemBars(), Insets.of(systemBarsInsets.left, systemBarsInsets.top, imeInsets.right, 0))
.build()
}
binding.avatarButton.setOnClickListener { pickMedia(PickType.AVATAR) }
binding.headerButton.setOnClickListener { pickMedia(PickType.HEADER) }
@ -129,7 +152,7 @@ class EditProfileActivity : BaseActivity(), Injectable {
val plusDrawable = IconicsDrawable(this, GoogleMaterial.Icon.gmd_add).apply {
sizeDp = 12
colorInt = Color.WHITE
colorInt = MaterialColors.getColor(binding.addFieldButton, materialR.attr.colorOnPrimary)
}
binding.addFieldButton.setCompoundDrawablesRelativeWithIntrinsicBounds(
@ -369,7 +392,7 @@ class EditProfileActivity : BaseActivity(), Injectable {
}
}
private suspend fun launchSaveDialog() = AlertDialog.Builder(this)
private suspend fun launchSaveDialog() = MaterialAlertDialogBuilder(this)
.setMessage(getString(R.string.dialog_save_profile_changes_message))
.create()
.await(R.string.action_save, R.string.action_discard)

View file

@ -19,8 +19,12 @@ import android.os.Bundle
import android.util.Log
import android.widget.TextView
import androidx.annotation.RawRes
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat.Type.systemBars
import androidx.core.view.updatePadding
import androidx.lifecycle.lifecycleScope
import com.keylesspalace.tusky.databinding.ActivityLicenseBinding
import dagger.hilt.android.AndroidEntryPoint
import java.io.IOException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@ -28,6 +32,7 @@ import kotlinx.coroutines.withContext
import okio.buffer
import okio.source
@AndroidEntryPoint
class LicenseActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@ -43,6 +48,13 @@ class LicenseActivity : BaseActivity() {
setTitle(R.string.title_licenses)
ViewCompat.setOnApplyWindowInsetsListener(binding.scrollView) { scrollView, insets ->
val systemBarInsets = insets.getInsets(systemBars())
scrollView.updatePadding(bottom = systemBarInsets.bottom)
insets.inset(0, 0, 0, systemBarInsets.bottom)
}
loadFileIntoTextView(R.raw.apache, binding.licenseApacheTextView)
}

View file

@ -23,24 +23,25 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import android.widget.PopupMenu
import androidx.activity.viewModels
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
import androidx.core.widget.doOnTextChanged
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.ListAdapter
import com.google.android.material.dialog.MaterialAlertDialogBuilder
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.ensureBottomMargin
import com.keylesspalace.tusky.util.ensureBottomPadding
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
@ -53,22 +54,15 @@ 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 dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
import javax.inject.Inject
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
// TODO use the ListSelectionFragment (and/or its adapter or binding) here; but keep the LoadingState from here (?)
class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
@AndroidEntryPoint
class ListsActivity : BaseActivity() {
@Inject
lateinit var viewModelFactory: ViewModelFactory
@Inject
lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector<Any>
private val viewModel: ListsViewModel by viewModels { viewModelFactory }
private val viewModel: ListsViewModel by viewModels()
private val binding by viewBinding(ActivityListsBinding::inflate)
@ -86,6 +80,9 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
setDisplayShowHomeEnabled(true)
}
binding.addListButton.ensureBottomMargin()
binding.listsRecycler.ensureBottomPadding(fab = true)
binding.listsRecycler.adapter = adapter
binding.listsRecycler.layoutManager = LinearLayoutManager(this)
binding.listsRecycler.addItemDecoration(
@ -93,7 +90,6 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
)
binding.swipeRefreshLayout.setOnRefreshListener { viewModel.retryLoading() }
binding.swipeRefreshLayout.setColorSchemeResources(R.color.chinwag_green)
lifecycleScope.launch {
viewModel.state.collect(this@ListsActivity::update)
@ -117,11 +113,23 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
}
private fun showlistNameDialog(list: MastoList?) {
var selectedReplyPolicyIndex = MastoList.ReplyPolicy.from(list?.repliesPolicy).ordinal
val replyPolicies = resources.getStringArray(R.array.list_reply_policies_display)
val binding = DialogListBinding.inflate(layoutInflater).apply {
replyPolicySpinner.setSelection(MastoList.ReplyPolicy.from(list?.repliesPolicy).ordinal)
replyPolicyDropDown.setText(replyPolicies[selectedReplyPolicyIndex])
replyPolicyDropDown.setSimpleItems(replyPolicies)
replyPolicyDropDown.setOnItemClickListener { _, _, position, _ ->
selectedReplyPolicyIndex = position
}
}
val dialog = AlertDialog.Builder(this)
val inset = resources.getDimensionPixelSize(R.dimen.dialog_inset)
val dialog = MaterialAlertDialogBuilder(this)
.setView(binding.root)
.setBackgroundInsetTop(inset)
.setBackgroundInsetEnd(inset)
.setBackgroundInsetBottom(inset)
.setBackgroundInsetStart(inset)
.setPositiveButton(
if (list == null) {
R.string.action_create_list
@ -133,17 +141,23 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
binding.nameText.text.toString(),
list?.id,
binding.exclusiveCheckbox.isChecked,
MastoList.ReplyPolicy.entries[binding.replyPolicySpinner.selectedItemPosition].policy
MastoList.ReplyPolicy.entries[selectedReplyPolicyIndex].policy
)
}
.setNegativeButton(android.R.string.cancel, null)
.show()
// yes, SOFT_INPUT_ADJUST_RESIZE is deprecated, but without it the dropdown can get behind the keyboard
dialog.window?.setSoftInputMode(
WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE or WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE
)
binding.nameText.let { editText ->
editText.doOnTextChanged { s, _, _, _ ->
dialog.getButton(Dialog.BUTTON_POSITIVE).isEnabled = s?.isNotBlank() == true
}
editText.setText(list?.title)
editText.requestFocus()
editText.text?.let { editText.setSelection(it.length) }
}
@ -157,7 +171,7 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
}
private fun showListDeleteDialog(list: MastoList) {
AlertDialog.Builder(this)
MaterialAlertDialogBuilder(this)
.setMessage(getString(R.string.dialog_delete_list_warning, list.title))
.setPositiveButton(R.string.action_delete) { _, _ ->
viewModel.deleteList(list.id)
@ -287,8 +301,6 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
}
}
override fun androidInjector() = dispatchingAndroidInjector
companion object {
fun newIntent(context: Context) = Intent(context, ListsActivity::class.java)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,201 @@
/* Copyright 2024 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
import android.content.Context
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.connyduck.calladapter.networkresult.fold
import com.keylesspalace.tusky.appstore.AnnouncementReadEvent
import com.keylesspalace.tusky.appstore.ConversationsLoadingEvent
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.NewNotificationsEvent
import com.keylesspalace.tusky.appstore.NotificationsLoadingEvent
import com.keylesspalace.tusky.components.systemnotifications.NotificationService
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.entity.AccountEntity
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.ShareShortcutHelper
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@HiltViewModel
class MainViewModel @Inject constructor(
@ApplicationContext private val context: Context,
private val api: MastodonApi,
private val eventHub: EventHub,
private val accountManager: AccountManager,
private val shareShortcutHelper: ShareShortcutHelper,
private val notificationService: NotificationService,
) : ViewModel() {
private val activeAccount = accountManager.activeAccount!!
val accounts: StateFlow<List<AccountViewData>> = accountManager.accountsFlow
.map { accounts ->
accounts.map { account ->
AccountViewData(
id = account.id,
domain = account.domain,
username = account.username,
displayName = account.displayName,
profilePictureUrl = account.profilePictureUrl,
profileHeaderUrl = account.profileHeaderUrl,
emojis = account.emojis
)
}
}
.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
val tabs: StateFlow<List<TabData>> = accountManager.activeAccount(viewModelScope)
.mapNotNull { account -> account?.tabPreferences }
.stateIn(viewModelScope, SharingStarted.Eagerly, activeAccount.tabPreferences)
private val _unreadAnnouncementsCount = MutableStateFlow(0)
val unreadAnnouncementsCount: StateFlow<Int> = _unreadAnnouncementsCount.asStateFlow()
val showDirectMessagesBadge: StateFlow<Boolean> = accountManager.activeAccount(viewModelScope)
.map { account -> account?.hasDirectMessageBadge == true }
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
init {
loadAccountData()
fetchAnnouncements()
collectEvents()
}
private fun loadAccountData() {
viewModelScope.launch {
api.accountVerifyCredentials().fold(
{ userInfo ->
accountManager.updateAccount(activeAccount, userInfo)
shareShortcutHelper.updateShortcuts()
setupNotifications(activeAccount)
},
{ throwable ->
Log.e(TAG, "Failed to fetch user info.", throwable)
}
)
}
}
private fun fetchAnnouncements() {
viewModelScope.launch {
api.announcements()
.fold(
{ announcements ->
_unreadAnnouncementsCount.value = announcements.count { !it.read }
},
{ throwable ->
Log.w(TAG, "Failed to fetch announcements.", throwable)
}
)
}
}
private fun collectEvents() {
viewModelScope.launch {
eventHub.events.collect { event ->
when (event) {
is AnnouncementReadEvent -> {
_unreadAnnouncementsCount.value--
}
is NewNotificationsEvent -> {
if (event.accountId == activeAccount.accountId) {
val hasDirectMessageNotification =
event.notifications.any {
it.type == Notification.Type.Mention && it.status?.visibility == Status.Visibility.DIRECT
}
if (hasDirectMessageNotification) {
accountManager.updateAccount(activeAccount) { copy(hasDirectMessageBadge = true) }
}
}
}
is NotificationsLoadingEvent -> {
if (event.accountId == activeAccount.accountId) {
accountManager.updateAccount(activeAccount) { copy(hasDirectMessageBadge = false) }
}
}
is ConversationsLoadingEvent -> {
if (event.accountId == activeAccount.accountId) {
accountManager.updateAccount(activeAccount) { copy(hasDirectMessageBadge = false) }
}
}
}
}
}
}
fun dismissDirectMessagesBadge() {
viewModelScope.launch {
accountManager.updateAccount(activeAccount) { copy(hasDirectMessageBadge = false) }
}
}
fun setupNotifications(account: AccountEntity? = null) {
// TODO this is only called on full app (re) start; so changes in-between (push distributor uninstalled/subscription changed, or
// notifications fully disabled) will get unnoticed; and also an app restart cannot be easily triggered by the user.
if (account != null) {
// TODO it's quite odd to separate channel creation (for an account) from the "is enabled by channels" question below
notificationService.createNotificationChannelsForAccount(account)
}
if (notificationService.areNotificationsEnabledBySystem()) {
viewModelScope.launch {
notificationService.setupNotifications(account)
}
} else {
viewModelScope.launch {
notificationService.disableAllNotifications()
}
}
}
companion object {
private const val TAG = "MainViewModel"
}
}
data class AccountViewData(
val id: Long,
val domain: String,
val username: String,
val displayName: String,
val profilePictureUrl: String,
val profileHeaderUrl: String,
val emojis: List<Emoji>
) {
val fullName: String
get() = "@$username@$domain"
}

View file

@ -1,46 +0,0 @@
/* Copyright 2018 Conny Duck
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import com.keylesspalace.tusky.components.login.LoginActivity
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.di.Injectable
import javax.inject.Inject
@SuppressLint("CustomSplashScreen")
class SplashActivity : AppCompatActivity(), Injectable {
@Inject
lateinit var accountManager: AccountManager
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
/** Determine whether the user is currently logged in, and if so go ahead and load the
* timeline. Otherwise, start the activity_login screen. */
val intent = if (accountManager.activeAccount != null) {
Intent(this, MainActivity::class.java)
} else {
LoginActivity.getIntent(this, LoginActivity.MODE_DEFAULT)
}
startActivity(intent)
finish()
}
}

View file

@ -26,8 +26,9 @@ import androidx.lifecycle.lifecycleScope
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.appstore.FilterUpdatedEvent
import com.keylesspalace.tusky.components.filters.EditFilterActivity
import com.keylesspalace.tusky.components.filters.FilterExpiration
import com.keylesspalace.tusky.components.filters.FiltersActivity
import com.keylesspalace.tusky.components.timeline.TimelineFragment
import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel.Kind
@ -37,15 +38,12 @@ 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 dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.launch
class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
@Inject
lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector<Any>
@AndroidEntryPoint
class StatusListActivity : BottomSheetActivity() {
@Inject
lateinit var eventHub: EventHub
@ -78,7 +76,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
val title = when (kind) {
Kind.FAVOURITES -> getString(R.string.title_favourites)
Kind.BOOKMARKS -> getString(R.string.title_bookmarks)
Kind.TAG -> getString(R.string.title_tag).format(hashtag)
Kind.TAG -> getString(R.string.hashtag_format, hashtag)
Kind.PUBLIC_TRENDING_STATUSES -> getString(R.string.title_public_trending_statuses)
else -> intent.getStringExtra(EXTRA_LIST_TITLE)
}
@ -215,12 +213,12 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
mastodonApi.getFiltersV1().fold(
{ filters ->
mutedFilterV1 = filters.firstOrNull { filter ->
hashedTag == filter.phrase && filter.context.contains(FilterV1.HOME)
hashedTag == filter.phrase && filter.context.contains(Filter.Kind.HOME.kind)
}
updateTagMuteState(mutedFilterV1 != null)
},
{ throwable ->
Log.e(TAG, "Error getting filters: $throwable")
{ throwable2 ->
Log.e(TAG, "Error getting filters: $throwable2")
}
)
} else {
@ -252,9 +250,9 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
mastodonApi.createFilter(
title = "#$tag",
context = listOf(FilterV1.HOME),
context = listOf(Filter.Kind.HOME.kind),
filterAction = Filter.Action.WARN.action,
expiresInSeconds = null
expiresIn = FilterExpiration.never
).fold(
{ filter ->
if (mastodonApi.addFilterKeyword(
@ -266,8 +264,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
// 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]))
eventHub.dispatch(FilterUpdatedEvent(filter.context))
filterCreateSuccess = true
} else {
Snackbar.make(
@ -282,23 +279,23 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
if (throwable.isHttpNotFound()) {
mastodonApi.createFilterV1(
hashedTag,
listOf(FilterV1.HOME),
listOf(Filter.Kind.HOME.kind),
irreversible = false,
wholeWord = true,
expiresInSeconds = null
expiresIn = FilterExpiration.never
).fold(
{ filter ->
mutedFilterV1 = filter
eventHub.dispatch(PreferenceChangedEvent(filter.context[0]))
eventHub.dispatch(FilterUpdatedEvent(filter.context))
filterCreateSuccess = true
},
{ throwable ->
{ throwable2 ->
Snackbar.make(
binding.root,
getString(R.string.error_muting_hashtag_format, tag),
Snackbar.LENGTH_SHORT
).show()
Log.e(TAG, "Failed to mute #$tag", throwable)
Log.e(TAG, "Failed to mute #$tag", throwable2)
}
)
} else {
@ -359,10 +356,10 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
mastodonApi.updateFilterV1(
id = filter.id,
phrase = filter.phrase,
context = filter.context.filter { it != FilterV1.HOME },
context = filter.context.filter { it != Filter.Kind.HOME.kind },
irreversible = null,
wholeWord = null,
expiresInSeconds = null
expiresIn = FilterExpiration.never
)
} else {
mastodonApi.deleteFilterV1(filter.id)
@ -375,7 +372,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
result?.fold(
{
updateTagMuteState(false)
eventHub.dispatch(PreferenceChangedEvent(Filter.Kind.HOME.kind))
eventHub.dispatch(FilterUpdatedEvent(listOf(Filter.Kind.HOME.kind)))
mutedFilterV1 = null
mutedFilter = null
@ -399,8 +396,6 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
return true
}
override fun androidInjector() = dispatchingAndroidInjector
companion object {
private const val EXTRA_KIND = "kind"

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.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 */
@ -60,7 +60,7 @@ data class TabData(
override fun hashCode() = Objects.hash(id, arguments)
}
fun List<TabData>.hasTab(id: String): Boolean = this.find { it.id == id } != null
fun List<TabData>.hasTab(id: String): Boolean = this.any { it.id == id }
fun createTabDataFromId(id: String, arguments: List<String> = emptyList()): TabData {
return when (id) {
@ -118,7 +118,7 @@ fun createTabDataFromId(id: String, arguments: List<String> = emptyList()): TabD
arguments = arguments,
title = { context ->
arguments.joinToString(separator = " ") {
context.getString(R.string.title_tag, it)
context.getString(R.string.hashtag_format, it)
}
}
)

View file

@ -18,41 +18,37 @@ package com.keylesspalace.tusky
import android.graphics.Color
import android.os.Bundle
import android.view.View
import android.widget.FrameLayout
import android.view.ViewGroup
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.AppCompatEditText
import androidx.core.view.updatePadding
import androidx.core.widget.doOnTextChanged
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat.Type.systemBars
import androidx.core.view.updateLayoutParams
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.transition.TransitionManager
import at.connyduck.sparkbutton.helpers.Utils
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.ensureBottomPadding
import com.keylesspalace.tusky.util.unsafeLazy
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible
import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
import java.util.regex.Pattern
import com.keylesspalace.tusky.view.showHashtagPickerDialog
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class TabPreferenceActivity : BaseActivity(), Injectable, HasAndroidInjector, ItemInteractionListener, ListSelectionFragment.ListSelectionListener {
@AndroidEntryPoint
class TabPreferenceActivity : BaseActivity(), ItemInteractionListener, ListSelectionFragment.ListSelectionListener {
@Inject
lateinit var mastodonApi: MastodonApi
@ -60,9 +56,6 @@ class TabPreferenceActivity : BaseActivity(), Injectable, HasAndroidInjector, It
@Inject
lateinit var eventHub: EventHub
@Inject
lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector<Any>
private val binding by viewBinding(ActivityTabPreferenceBinding::inflate)
private lateinit var currentTabs: MutableList<TabData>
@ -70,16 +63,10 @@ class TabPreferenceActivity : BaseActivity(), Injectable, HasAndroidInjector, It
private lateinit var touchHelper: ItemTouchHelper
private lateinit var addTabAdapter: TabAdapter
private var tabsChanged = false
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 onFabDismissedCallback = object : OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
toggleFab(false)
@ -99,6 +86,19 @@ class TabPreferenceActivity : BaseActivity(), Injectable, HasAndroidInjector, It
setDisplayShowHomeEnabled(true)
}
binding.currentTabsRecyclerView.ensureBottomPadding(fab = true)
ViewCompat.setOnApplyWindowInsetsListener(binding.actionButton) { _, insets ->
val bottomInset = insets.getInsets(systemBars()).bottom
val actionButtonMargin = resources.getDimensionPixelSize(R.dimen.fabMargin)
binding.actionButton.updateLayoutParams<ViewGroup.MarginLayoutParams> {
bottomMargin = bottomInset + actionButtonMargin
}
binding.sheet.updateLayoutParams<ViewGroup.MarginLayoutParams> {
bottomMargin = bottomInset + actionButtonMargin
}
insets.inset(0, 0, 0, bottomInset)
}
currentTabs = accountManager.activeAccount?.tabPreferences.orEmpty().toMutableList()
currentTabsAdapter = TabAdapter(currentTabs, false, this, currentTabs.size <= MIN_TAB_COUNT)
binding.currentTabsRecyclerView.adapter = currentTabsAdapter
@ -233,44 +233,21 @@ class TabPreferenceActivity : BaseActivity(), Injectable, HasAndroidInjector, It
}
private fun showAddHashtagDialog(tab: TabData? = null, tabPosition: Int = 0) {
val frameLayout = FrameLayout(this)
val padding = Utils.dpToPx(this, 8)
frameLayout.updatePadding(left = padding, right = padding)
showHashtagPickerDialog(mastodonApi, R.string.add_hashtag_title) { hashtag ->
if (tab == null) {
val newTab = createTabDataFromId(HASHTAG, listOf(hashtag))
currentTabs.add(newTab)
currentTabsAdapter.notifyItemInserted(currentTabs.size - 1)
} else {
val newTab = tab.copy(arguments = tab.arguments + hashtag)
currentTabs[tabPosition] = newTab
val editText = AppCompatEditText(this)
editText.setHint(R.string.edit_hashtag_hint)
editText.setText("")
frameLayout.addView(editText)
val dialog = AlertDialog.Builder(this)
.setTitle(R.string.add_hashtag_title)
.setView(frameLayout)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.action_save) { _, _ ->
val input = editText.text.toString().trim()
if (tab == null) {
val newTab = createTabDataFromId(HASHTAG, listOf(input))
currentTabs.add(newTab)
currentTabsAdapter.notifyItemInserted(currentTabs.size - 1)
} else {
val newTab = tab.copy(arguments = tab.arguments + input)
currentTabs[tabPosition] = newTab
currentTabsAdapter.notifyItemChanged(tabPosition)
}
updateAvailableTabs()
saveTabs()
currentTabsAdapter.notifyItemChanged(tabPosition)
}
.create()
editText.doOnTextChanged { s, _, _, _ ->
dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = validateHashtag(s)
updateAvailableTabs()
saveTabs()
}
dialog.show()
dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = validateHashtag(editText.text)
editText.requestFocus()
}
private var listSelectDialog: ListSelectionFragment? = null
@ -293,11 +270,6 @@ class TabPreferenceActivity : BaseActivity(), Injectable, HasAndroidInjector, It
saveTabs()
}
private fun validateHashtag(input: CharSequence?): Boolean {
val trimmedInput = input?.trim() ?: ""
return trimmedInput.isNotEmpty() && hashtagRegex.matcher(trimmedInput).matches()
}
private fun updateAvailableTabs() {
val addableTabs: MutableList<TabData> = mutableListOf()
@ -351,25 +323,12 @@ class TabPreferenceActivity : BaseActivity(), Injectable, HasAndroidInjector, It
private fun saveTabs() {
accountManager.activeAccount?.let {
lifecycleScope.launch(Dispatchers.IO) {
it.tabPreferences = currentTabs
accountManager.saveAccount(it)
}
}
tabsChanged = true
}
override fun onPause() {
super.onPause()
if (tabsChanged) {
lifecycleScope.launch {
eventHub.dispatch(MainTabsChangedEvent(currentTabs))
accountManager.updateAccount(it) { copy(tabPreferences = currentTabs) }
}
}
}
override fun androidInjector() = dispatchingAndroidInjector
companion object {
private const val MIN_TAB_COUNT = 2
}

View file

@ -16,14 +16,16 @@
package com.keylesspalace.tusky
import android.app.Application
import android.app.NotificationManager
import android.content.SharedPreferences
import android.os.Build
import android.util.Log
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
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
@ -32,9 +34,7 @@ import com.keylesspalace.tusky.settings.SCHEMA_VERSION
import com.keylesspalace.tusky.util.LocaleManager
import com.keylesspalace.tusky.util.setAppNightMode
import com.keylesspalace.tusky.worker.PruneCacheWorker
import com.keylesspalace.tusky.worker.WorkerFactory
import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
import dagger.hilt.android.HiltAndroidApp
import de.c1710.filemojicompat_defaults.DefaultEmojiPackList
import de.c1710.filemojicompat_ui.helpers.EmojiPackHelper
import de.c1710.filemojicompat_ui.helpers.EmojiPreference
@ -43,18 +43,20 @@ import java.util.concurrent.TimeUnit
import javax.inject.Inject
import org.conscrypt.Conscrypt
class TuskyApplication : Application(), HasAndroidInjector {
@Inject
lateinit var androidInjector: DispatchingAndroidInjector<Any>
@HiltAndroidApp
class TuskyApplication : Application(), Configuration.Provider {
@Inject
lateinit var workerFactory: WorkerFactory
lateinit var workerFactory: HiltWorkerFactory
@Inject
lateinit var localeManager: LocaleManager
@Inject
lateinit var sharedPreferences: SharedPreferences
lateinit var preferences: SharedPreferences
@Inject
lateinit var notificationManager: NotificationManager
override fun onCreate() {
// Uncomment me to get StrictMode violation logs
@ -71,14 +73,27 @@ class TuskyApplication : Application(), HasAndroidInjector {
Security.insertProviderAt(Conscrypt.newProvider(), 1)
AppInjector.init(this)
val workManager = WorkManager.getInstance(this)
// Migrate shared preference keys and defaults from version to version.
val oldVersion = sharedPreferences.getInt(
val oldVersion = preferences.getInt(
PrefKeys.SCHEMA_VERSION,
NEW_INSTALL_SCHEMA_VERSION
)
if (oldVersion != SCHEMA_VERSION) {
if (oldVersion < 2025021701) {
// A new periodic work request is enqueued by unique name (and not tag anymore): stop the old one
workManager.cancelAllWorkByTag("pullNotifications")
}
if (oldVersion < 2025022001 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// delete old now unused notification channels
for (channel in notificationManager.notificationChannels) {
if (channel.id.startsWith("CHANNEL_SIGN_UP") || channel.id.startsWith("CHANNEL_REPORT")) {
notificationManager.deleteNotificationChannel(channel.id)
}
}
}
upgradeSharedPreferences(oldVersion, SCHEMA_VERSION)
}
@ -88,36 +103,30 @@ class TuskyApplication : Application(), HasAndroidInjector {
EmojiPackHelper.init(this, DefaultEmojiPackList.get(this), allowPackImports = false)
// init night mode
val theme = sharedPreferences.getString(APP_THEME, AppTheme.DEFAULT.value)
val theme = preferences.getString(APP_THEME, AppTheme.DEFAULT.value)
setAppNightMode(theme)
localeManager.setLocale()
NotificationHelper.createWorkerNotificationChannel(this)
WorkManager.initialize(
this,
androidx.work.Configuration.Builder()
.setWorkerFactory(workerFactory)
.build()
)
// Prune the database every ~ 12 hours when the device is idle.
val pruneCacheWorker = PeriodicWorkRequestBuilder<PruneCacheWorker>(12, TimeUnit.HOURS)
.setConstraints(Constraints.Builder().setRequiresDeviceIdle(true).build())
.build()
WorkManager.getInstance(this).enqueueUniquePeriodicWork(
workManager.enqueueUniquePeriodicWork(
PruneCacheWorker.PERIODIC_WORK_TAG,
ExistingPeriodicWorkPolicy.KEEP,
pruneCacheWorker
)
}
override fun androidInjector() = androidInjector
override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)
.build()
private fun upgradeSharedPreferences(oldVersion: Int, newVersion: Int) {
Log.d(TAG, "Upgrading shared preferences: $oldVersion -> $newVersion")
val editor = sharedPreferences.edit()
val editor = preferences.edit()
if (oldVersion < 2023022701) {
// These preferences are (now) handled in AccountPreferenceHandler. Remove them from shared for clarity.
@ -127,17 +136,11 @@ 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)) {
if (!preferences.contains(APP_THEME)) {
editor.putString(APP_THEME, AppTheme.NIGHT.value)
}
}
@ -148,6 +151,10 @@ class TuskyApplication : Application(), HasAndroidInjector {
editor.remove(PrefKeys.TAB_SHOW_HOME_SELF_BOOSTS)
}
if (oldVersion < 2024060201) {
editor.remove(PrefKeys.Deprecated.FAB_HIDE)
}
editor.putInt(PrefKeys.SCHEMA_VERSION, newVersion)
editor.apply()
}

View file

@ -19,11 +19,8 @@ import android.Manifest
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.app.DownloadManager
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.Color
import android.net.Uri
@ -38,14 +35,15 @@ import android.view.View
import android.view.WindowManager
import android.webkit.MimeTypeMap
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.app.ShareCompat
import androidx.core.content.FileProvider
import androidx.core.content.IntentCompat
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.lifecycleScope
import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.viewpager2.widget.ViewPager2
import com.bumptech.glide.Glide
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.BuildConfig.APPLICATION_ID
import com.keylesspalace.tusky.components.viewthread.ViewThreadActivity
import com.keylesspalace.tusky.databinding.ActivityViewMediaBinding
@ -54,32 +52,29 @@ import com.keylesspalace.tusky.fragment.ViewImageFragment
import com.keylesspalace.tusky.fragment.ViewVideoFragment
import com.keylesspalace.tusky.pager.ImagePagerAdapter
import com.keylesspalace.tusky.pager.SingleImagePagerAdapter
import com.keylesspalace.tusky.util.copyToClipboard
import com.keylesspalace.tusky.util.getParcelableArrayListExtraCompat
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 dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
import dagger.hilt.android.AndroidEntryPoint
import java.io.File
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
@AndroidEntryPoint
class ViewMediaActivity :
BaseActivity(),
HasAndroidInjector,
ViewImageFragment.PhotoActionsListener,
ViewVideoFragment.VideoActionsListener {
@Inject
lateinit var androidInjector: DispatchingAndroidInjector<Any>
private val binding by viewBinding(ActivityViewMediaBinding::inflate)
val toolbar: View
@ -92,6 +87,17 @@ class ViewMediaActivity :
private val toolbarVisibilityListeners = mutableListOf<ToolbarVisibilityListener>()
private var imageUrl: String? = null
private val requestDownloadMediaPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
if (isGranted) {
downloadMedia()
} else {
Snackbar.make(binding.toolbar, getString(R.string.error_media_download_permission), Snackbar.LENGTH_SHORT)
.setAction(R.string.action_retry) { requestDownloadMedia() }
.show()
}
}
fun addToolbarVisibilityListener(listener: ToolbarVisibilityListener): Function0<Boolean> {
this.toolbarVisibilityListeners.add(listener)
listener(isToolbarVisible)
@ -105,11 +111,7 @@ class ViewMediaActivity :
supportPostponeEnterTransition()
// Gather the parameters.
attachments = IntentCompat.getParcelableArrayListExtra(
intent,
EXTRA_ATTACHMENTS,
AttachmentViewData::class.java
)
attachments = intent.getParcelableArrayListExtraCompat(EXTRA_ATTACHMENTS)
val initialPosition = intent.getIntExtra(EXTRA_ATTACHMENT_INDEX, 0)
// Adapter is actually of existential type PageAdapter & SharedElementsTransitionListener
@ -154,9 +156,13 @@ class ViewMediaActivity :
true
}
// yes it is deprecated, but it looks cool so it stays for now
window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LOW_PROFILE
window.statusBarColor = Color.BLACK
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM) {
@Suppress("DEPRECATION")
window.statusBarColor = Color.BLACK
}
window.sharedElementEnterTransition.addListener(object : NoopTransitionListener {
override fun onTransitionEnd(transition: Transition) {
adapter.onTransitionEnd(binding.viewPager.currentItem)
@ -235,22 +241,7 @@ class ViewMediaActivity :
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
) {
downloadMedia()
} else {
showErrorDialog(
binding.toolbar,
R.string.error_media_download_permission,
R.string.action_retry
) { requestDownloadMedia() }
}
}
requestDownloadMediaPermissionLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
} else {
downloadMedia()
}
@ -264,9 +255,10 @@ class ViewMediaActivity :
}
private fun copyLink() {
val url = imageUrl ?: attachments!![binding.viewPager.currentItem].attachment.url
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText(null, url))
copyToClipboard(
imageUrl ?: attachments!![binding.viewPager.currentItem].attachment.url,
getString(R.string.url_copied),
)
}
private fun shareMedia() {
@ -367,8 +359,6 @@ class ViewMediaActivity :
}
}
override fun androidInjector() = androidInjector
companion object {
private const val EXTRA_ATTACHMENTS = "attachments"
private const val EXTRA_ATTACHMENT_INDEX = "index"

View file

@ -20,15 +20,17 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import androidx.preference.PreferenceManager
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemAutocompleteAccountBinding
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.db.entity.AccountEntity
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.loadAvatar
class AccountSelectionAdapter(context: Context) : ArrayAdapter<AccountEntity>(
class AccountSelectionAdapter(
context: Context,
private val animateAvatars: Boolean,
private val animateEmojis: Boolean
) : ArrayAdapter<AccountEntity>(
context,
R.layout.item_autocomplete_account
) {
@ -42,17 +44,13 @@ class AccountSelectionAdapter(context: Context) : ArrayAdapter<AccountEntity>(
val account = getItem(position)
if (account != null) {
val pm = PreferenceManager.getDefaultSharedPreferences(binding.avatar.context)
val animateEmojis = pm.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
binding.username.text = account.fullName
binding.displayName.text = account.displayName.emojify(account.emojis, binding.displayName, animateEmojis)
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(PrefKeys.ANIMATE_GIF_AVATARS, false)
loadAvatar(account.profilePictureUrl, binding.avatar, avatarRadius, animateAvatar)
loadAvatar(account.profilePictureUrl, binding.avatar, avatarRadius, animateAvatars)
}
return binding.root

View file

@ -33,6 +33,7 @@ class EmojiAdapter(
private val emojiList: List<Emoji> = emojiList.filter { emoji -> emoji.visibleInPicker }
.sortedBy { it.shortcode.lowercase(Locale.ROOT) }
.sortedBy { it.category?.lowercase(Locale.ROOT) ?: "" }
override fun getItemCount() = emojiList.size

View file

@ -0,0 +1,61 @@
/* Copyright 2024 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 androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.notifications.NotificationsViewHolder
import com.keylesspalace.tusky.databinding.ItemStatusFilteredBinding
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.FilterResult
import com.keylesspalace.tusky.interfaces.StatusActionListener
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.viewdata.NotificationViewData
import com.keylesspalace.tusky.viewdata.StatusViewData
class FilteredStatusViewHolder(
private val binding: ItemStatusFilteredBinding,
listener: StatusActionListener
) : NotificationsViewHolder, RecyclerView.ViewHolder(binding.root) {
init {
binding.statusFilterShowAnyway.setOnClickListener {
listener.clearWarningAction(bindingAdapterPosition)
}
}
override fun bind(
viewData: NotificationViewData.Concrete,
payloads: List<*>,
statusDisplayOptions: StatusDisplayOptions
) {
if (payloads.isEmpty()) {
bind(viewData.statusViewData!!)
}
}
fun bind(viewData: StatusViewData.Concrete) {
val matchedFilterResult: FilterResult? = viewData.actionable.filtered.orEmpty().find { filterResult ->
filterResult.filter.action == Filter.Action.WARN
}
val matchedFilterTitle = matchedFilterResult?.filter?.title.orEmpty()
binding.statusFilterLabel.text = itemView.context.getString(
R.string.status_filter_placeholder_label_format,
matchedFilterTitle
)
}
}

View file

@ -21,10 +21,12 @@ 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.NotificationsViewHolder
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
@ -33,12 +35,31 @@ 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 accountListener: AccountActionListener,
private val linkListener: LinkListener,
private val showHeader: Boolean
) : RecyclerView.ViewHolder(binding.root) {
) : RecyclerView.ViewHolder(binding.root), NotificationsViewHolder {
override fun bind(
viewData: NotificationViewData.Concrete,
payloads: List<*>,
statusDisplayOptions: StatusDisplayOptions
) {
if (payloads.isNotEmpty()) {
return
}
setupWithAccount(
viewData.account,
statusDisplayOptions.animateAvatars,
statusDisplayOptions.animateEmojis,
statusDisplayOptions.showBotOverlay
)
setupActionListener(accountListener, viewData.account.id)
}
fun setupWithAccount(
account: TimelineAccount,
@ -98,5 +119,6 @@ class FollowRequestViewHolder(
}
}
itemView.setOnClickListener { listener.onViewAccount(accountId) }
binding.accountNote.setOnClickListener { listener.onViewAccount(accountId) }
}
}

View file

@ -1,708 +0,0 @@
/* 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.chinwag_green);
format = context.getString(R.string.notification_subscription_format);
break;
}
case UPDATE: {
icon = getIconWithColor(context, R.drawable.ic_edit_24dp, R.color.tusky_green);
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

@ -14,54 +14,33 @@
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.adapter
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.button.MaterialButton
import com.google.android.material.progressindicator.CircularProgressIndicatorSpec
import com.google.android.material.progressindicator.IndeterminateDrawable
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemStatusPlaceholderBinding
import com.keylesspalace.tusky.interfaces.StatusActionListener
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.visible
/**
* Placeholder for different timelines.
* Placeholder for missing parts in timelines.
*
* Displays a "Load more" button for a particular status ID, or a
* circular progress wheel if the status' page is being loaded.
*
* The user can only have one "Load more" operation in progress at
* a time (determined by the adapter), so the contents of the view
* and the enabled state is driven by that.
* Displays a "Load more" button to load the gap, or a
* circular progress bar if the missing page is being loaded.
*/
class PlaceholderViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val loadMoreButton: MaterialButton = itemView.findViewById(R.id.button_load_more)
private val drawable = IndeterminateDrawable.createCircularDrawable(
itemView.context,
CircularProgressIndicatorSpec(itemView.context, null)
)
class PlaceholderViewHolder(
private val binding: ItemStatusPlaceholderBinding,
listener: StatusActionListener
) : RecyclerView.ViewHolder(binding.root) {
fun setup(listener: StatusActionListener, loading: Boolean) {
itemView.isEnabled = !loading
loadMoreButton.isEnabled = !loading
if (loading) {
loadMoreButton.text = ""
loadMoreButton.icon = drawable
return
}
loadMoreButton.text = itemView.context.getString(R.string.load_more_placeholder_text)
loadMoreButton.icon = null
// To allow the user to click anywhere in the layout to load more content set the click
// listener on the parent layout instead of loadMoreButton.
//
// See the comments in item_status_placeholder.xml for more details.
itemView.setOnClickListener {
itemView.isEnabled = false
loadMoreButton.isEnabled = false
loadMoreButton.icon = drawable
loadMoreButton.text = ""
init {
binding.loadMoreButton.setOnClickListener {
binding.loadMoreButton.hide()
binding.loadMoreProgressBar.show()
listener.onLoadMore(bindingAdapterPosition)
}
}
fun setup(loading: Boolean) {
binding.loadMoreButton.visible(!loading)
binding.loadMoreProgressBar.visible(loading)
}
}

View file

@ -19,7 +19,6 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.core.widget.TextViewCompat
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R
@ -58,7 +57,7 @@ class PreviewPollOptionsAdapter : RecyclerView.Adapter<PreviewViewHolder>() {
R.drawable.ic_radio_button_unchecked_18dp
}
TextViewCompat.setCompoundDrawablesRelativeWithIntrinsicBounds(textView, iconId, 0, 0, 0)
textView.setCompoundDrawablesRelativeWithIntrinsicBounds(iconId, 0, 0, 0)
textView.text = options[position]

View file

@ -6,9 +6,11 @@ import android.content.Context;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.format.DateUtils;
import android.view.Gravity;
import android.view.Menu;
import android.view.View;
import android.view.ViewGroup;
@ -17,14 +19,14 @@ import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.appcompat.widget.PopupMenu;
import androidx.appcompat.widget.TooltipCompat;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.core.content.ContextCompat;
import androidx.core.text.HtmlCompat;
import androidx.recyclerview.widget.DefaultItemAnimator;
import androidx.recyclerview.widget.LinearLayoutManager;
@ -33,6 +35,7 @@ import androidx.recyclerview.widget.RecyclerView;
import com.bumptech.glide.Glide;
import com.bumptech.glide.RequestBuilder;
import com.google.android.material.button.MaterialButton;
import com.google.android.material.card.MaterialCardView;
import com.google.android.material.color.MaterialColors;
import com.google.android.material.imageview.ShapeableImageView;
import com.google.android.material.shape.CornerFamily;
@ -42,16 +45,16 @@ import com.keylesspalace.tusky.ViewMediaActivity;
import com.keylesspalace.tusky.entity.Attachment;
import com.keylesspalace.tusky.entity.Attachment.Focus;
import com.keylesspalace.tusky.entity.Attachment.MetaData;
import com.keylesspalace.tusky.entity.Card;
import com.keylesspalace.tusky.entity.PreviewCard;
import com.keylesspalace.tusky.entity.Emoji;
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.TimelineAccount;
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.BlurhashDrawable;
import com.keylesspalace.tusky.util.CardViewMode;
import com.keylesspalace.tusky.util.CompositeWithOpaqueBackground;
import com.keylesspalace.tusky.util.CustomEmojiHelper;
@ -74,7 +77,6 @@ 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;
@ -95,7 +97,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
private final SparkButton favouriteButton;
private final SparkButton bookmarkButton;
private final ImageButton moreButton;
private final ConstraintLayout mediaContainer;
protected final ConstraintLayout mediaContainer;
protected final MediaPreviewLayout mediaPreview;
private final TextView sensitiveMediaWarning;
private final View sensitiveMediaShow;
@ -113,19 +115,19 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
private final TextView pollDescription;
private final Button pollButton;
private final LinearLayout cardView;
private final LinearLayout cardInfo;
private final MaterialCardView cardView;
private final LinearLayout cardLayout;
private final ShapeableImageView cardImage;
private final TextView cardTitle;
private final TextView cardDescription;
private final TextView cardUrl;
private final TextView cardMetadata;
private final TextView cardAuthor;
private final TextView cardAuthorButton;
private final PollAdapter pollAdapter;
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 TextView trailingHashtagView;
private final NumberFormat numberFormat = NumberFormat.getNumberInstance();
@ -173,15 +175,13 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
pollButton = itemView.findViewById(R.id.status_poll_button);
cardView = itemView.findViewById(R.id.status_card_view);
cardInfo = itemView.findViewById(R.id.card_info);
cardLayout = itemView.findViewById(R.id.status_card_layout);
cardImage = itemView.findViewById(R.id.card_image);
cardTitle = itemView.findViewById(R.id.card_title);
cardDescription = itemView.findViewById(R.id.card_description);
cardUrl = itemView.findViewById(R.id.card_link);
cardMetadata = itemView.findViewById(R.id.card_metadata);
cardAuthor = itemView.findViewById(R.id.card_author);
cardAuthorButton = itemView.findViewById(R.id.card_author_button);
filteredPlaceholder = itemView.findViewById(R.id.status_filtered_placeholder);
filteredPlaceholderLabel = itemView.findViewById(R.id.status_filter_label);
filteredPlaceholderShowButton = itemView.findViewById(R.id.status_filter_show_anyway);
statusContainer = itemView.findViewById(R.id.status_container);
pollAdapter = new PollAdapter();
@ -191,6 +191,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
translationStatusView = itemView.findViewById(R.id.status_translation_status);
untranslateButton = itemView.findViewById(R.id.status_button_untranslate);
trailingHashtagView = itemView.findViewById(R.id.status_trailing_hashtags_content);
this.avatarRadius48dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_48dp);
this.avatarRadius36dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_36dp);
@ -201,7 +202,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
TouchDelegateHelper.expandTouchSizeToFillRow((ViewGroup) itemView, CollectionsKt.listOfNotNull(replyButton, reblogButton, favouriteButton, bookmarkButton, moreButton));
}
protected void setDisplayName(@NonNull String name, @Nullable List<Emoji> customEmojis, @NonNull StatusDisplayOptions statusDisplayOptions) {
protected void setDisplayName(@NonNull String name, @NonNull List<Emoji> customEmojis, @NonNull StatusDisplayOptions statusDisplayOptions) {
CharSequence emojifiedName = CustomEmojiHelper.emojify(
name, customEmojis, displayName, statusDisplayOptions.animateEmojis()
);
@ -235,9 +236,14 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
);
contentWarningDescription.setText(emojiSpoiler);
contentWarningDescription.setVisibility(View.VISIBLE);
contentWarningButton.setVisibility(View.VISIBLE);
setContentWarningButtonText(expanded);
contentWarningButton.setOnClickListener(view -> toggleExpandedState(true, !expanded, status, statusDisplayOptions, listener));
boolean hasContent = !TextUtils.isEmpty(status.getContent());
if (hasContent) {
contentWarningButton.setVisibility(View.VISIBLE);
setContentWarningButtonText(expanded);
contentWarningButton.setOnClickListener(view -> toggleExpandedState(true, !expanded, status, statusDisplayOptions, listener));
} else {
contentWarningButton.setVisibility(View.GONE);
}
this.setTextVisible(true, expanded, status, statusDisplayOptions, listener);
} else {
contentWarningDescription.setVisibility(View.GONE);
@ -287,7 +293,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
if (expanded) {
CharSequence emojifiedText = CustomEmojiHelper.emojify(content, emojis, this.content, statusDisplayOptions.animateEmojis());
LinkHelper.setClickableText(this.content, emojifiedText, mentions, tags, listener);
LinkHelper.setClickableText(this.content, emojifiedText, mentions, tags, listener, this.trailingHashtagView);
if (trailingHashtagView != null && status.isCollapsible() && status.isCollapsed()) {
trailingHashtagView.setVisibility(View.GONE);
}
for (int i = 0; i < mediaLabels.length; ++i) {
updateMediaLabel(i, sensitive, true);
}
@ -298,6 +307,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
}
} else {
hidePoll();
if (trailingHashtagView != null) {
trailingHashtagView.setVisibility(View.GONE);
}
LinkHelper.setClickableMentions(this.content, mentions, listener);
}
if (TextUtils.isEmpty(this.content.getText())) {
@ -399,13 +411,12 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
}
}
protected void setIsReply(boolean isReply) {
protected void setReplyButtonImage(boolean isReply) {
if (isReply) {
replyButton.setImageResource(R.drawable.ic_reply_all_24dp);
} else {
replyButton.setImageResource(R.drawable.ic_reply_24dp);
}
}
protected void setReplyCount(int repliesCount, boolean fullStats) {
@ -463,7 +474,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
}
private BitmapDrawable decodeBlurHash(String blurhash) {
return ImageLoadingHelper.decodeBlurHash(this.avatar.getContext(), blurhash);
return new BlurhashDrawable(this.avatar.getContext(), blurhash);
}
private void loadImage(MediaPreviewImageView imageView,
@ -536,12 +547,14 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
final Attachment.Type type = attachment.getType();
if (showingContent && (type == Attachment.Type.VIDEO || type == Attachment.Type.GIFV)) {
imageView.setForeground(ContextCompat.getDrawable(itemView.getContext(), R.drawable.play_indicator_overlay));
imageView.setForegroundGravity(Gravity.CENTER);
imageView.setForeground(AppCompatResources.getDrawable(itemView.getContext(), R.drawable.ic_play_indicator));
} else {
imageView.setForeground(null);
}
setAttachmentClickListener(imageView, listener, i, attachment, true);
final CharSequence formattedDescription = AttachmentHelper.getFormattedDescription(attachment, imageView.getContext());
setAttachmentClickListener(imageView, listener, i, formattedDescription, true);
if (sensitive) {
sensitiveMediaWarning.setText(R.string.post_sensitive_media_title);
@ -611,17 +624,17 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
// Set the icon next to the label.
int drawableId = getLabelIcon(attachments.get(0).getType());
mediaLabel.setCompoundDrawablesWithIntrinsicBounds(drawableId, 0, 0, 0);
mediaLabel.setCompoundDrawablesRelativeWithIntrinsicBounds(drawableId, 0, 0, 0);
setAttachmentClickListener(mediaLabel, listener, i, attachment, false);
setAttachmentClickListener(mediaLabel, listener, i, mediaDescriptions[i], false);
} else {
mediaLabel.setVisibility(View.GONE);
}
}
}
private void setAttachmentClickListener(View view, @NonNull StatusActionListener listener,
int index, Attachment attachment, boolean animateTransition) {
private void setAttachmentClickListener(@NonNull View view, @NonNull StatusActionListener listener,
int index, CharSequence description, boolean animateTransition) {
view.setOnClickListener(v -> {
int position = getBindingAdapterPosition();
if (position != RecyclerView.NO_POSITION) {
@ -632,11 +645,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
}
}
});
view.setOnLongClickListener(v -> {
CharSequence description = AttachmentHelper.getFormattedDescription(attachment, view.getContext());
Toast.makeText(view.getContext(), description, Toast.LENGTH_LONG).show();
return true;
});
TooltipCompat.setTooltipText(view, description);
}
protected void hideSensitiveMediaWarning() {
@ -670,7 +679,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
showConfirmReblog(listener, buttonState, position);
return false;
} else {
listener.onReblog(!buttonState, position);
listener.onReblog(!buttonState, position, Status.Visibility.PUBLIC);
return true;
}
} else {
@ -731,13 +740,25 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
popup.inflate(R.menu.status_reblog);
Menu menu = popup.getMenu();
if (buttonState) {
menu.findItem(R.id.menu_action_reblog).setVisible(false);
menu.setGroupVisible(R.id.menu_action_reblog_group, false);
} else {
menu.findItem(R.id.menu_action_unreblog).setVisible(false);
}
popup.setOnMenuItemClickListener(item -> {
listener.onReblog(!buttonState, position);
if (!buttonState) {
if (buttonState) {
listener.onReblog(false, position, Status.Visibility.PUBLIC);
} else {
Status.Visibility visibility;
if (item.getItemId() == R.id.menu_action_reblog_public) {
visibility = Status.Visibility.PUBLIC;
} else if (item.getItemId() == R.id.menu_action_reblog_unlisted) {
visibility = Status.Visibility.UNLISTED;
} else if (item.getItemId() == R.id.menu_action_reblog_private) {
visibility = Status.Visibility.PRIVATE;
} else {
visibility = Status.Visibility.PUBLIC;
}
listener.onReblog(true, position, visibility);
reblogButton.playAnimation();
reblogButton.setChecked(true);
}
@ -768,21 +789,17 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
popup.show();
}
public void setupWithStatus(@NonNull StatusViewData.Concrete status, final @NonNull StatusActionListener listener,
@NonNull StatusDisplayOptions statusDisplayOptions) {
this.setupWithStatus(status, listener, statusDisplayOptions, null);
}
public void setupWithStatus(@NonNull StatusViewData.Concrete status,
@NonNull final StatusActionListener listener,
@NonNull StatusDisplayOptions statusDisplayOptions,
@Nullable Object payloads) {
if (payloads == null) {
@NonNull List<Object> payloads,
final boolean showStatusInfo) {
if (payloads.isEmpty()) {
Status actionable = status.getActionable();
setDisplayName(actionable.getAccount().getName(), actionable.getAccount().getEmojis(), statusDisplayOptions);
setUsername(actionable.getAccount().getUsername());
setMetaData(status, statusDisplayOptions, listener);
setIsReply(actionable.getInReplyToId() != null);
setReplyButtonImage(actionable.isReply());
setReplyCount(actionable.getRepliesCount(), statusDisplayOptions.showStatsInline());
setAvatar(actionable.getAccount().getAvatar(), status.getRebloggedAvatar(),
actionable.getAccount().getBot(), statusDisplayOptions);
@ -791,10 +808,14 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
setBookmarked(actionable.getBookmarked());
List<Attachment> attachments = status.getAttachments();
boolean sensitive = actionable.getSensitive();
if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) {
if (attachments.isEmpty()) {
mediaContainer.setVisibility(View.GONE);
} else if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) {
mediaContainer.setVisibility(View.VISIBLE);
setMediaPreviews(attachments, sensitive, listener, status.isShowingContent(), statusDisplayOptions.useBlurhash());
if (attachments.size() == 0) {
if (attachments.isEmpty()) {
hideSensitiveMediaWarning();
}
// Hide the unused label.
@ -802,6 +823,8 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
mediaLabel.setVisibility(View.GONE);
}
} else {
mediaContainer.setVisibility(View.VISIBLE);
setMediaLabel(attachments, sensitive, listener, status.isShowingContent());
// Hide all unused views.
mediaPreview.setVisibility(View.GONE);
@ -819,8 +842,6 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
setSpoilerAndContent(status, statusDisplayOptions, listener);
setupFilterPlaceholder(status, listener, statusDisplayOptions);
setDescriptionForStatus(status, statusDisplayOptions);
// Workaround for RecyclerView 1.0.0 / androidx.core 1.0.0
@ -830,13 +851,16 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
// and let RecyclerView ask for a new delegate.
itemView.setAccessibilityDelegate(null);
} else {
if (payloads instanceof List)
for (Object item : (List<?>) payloads) {
if (Key.KEY_CREATED.equals(item)) {
setMetaData(status, statusDisplayOptions, listener);
for (Object item : payloads) {
if (Key.KEY_CREATED.equals(item)) {
setMetaData(status, statusDisplayOptions, listener);
if (status.getStatus().getCard() != null && status.getStatus().getCard().getPublishedAt() != null) {
// there is a preview card showing the published time, we need to refresh it as well
setupCard(status, status.isExpanded(), statusDisplayOptions.cardViewMode(), statusDisplayOptions, listener);
}
break;
}
}
}
}
@ -863,28 +887,6 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
}
}
private void setupFilterPlaceholder(StatusViewData.Concrete status, StatusActionListener listener, StatusDisplayOptions displayOptions) {
if (status.getFilterAction() != Filter.Action.WARN) {
showFilteredPlaceholder(false);
return;
}
showFilteredPlaceholder(true);
Filter matchedFilter = null;
for (FilterResult result : status.getActionable().getFiltered()) {
Filter filter = result.getFilter();
if (filter.getAction() == Filter.Action.WARN) {
matchedFilter = filter;
break;
}
}
filteredPlaceholderLabel.setText(itemView.getContext().getString(R.string.status_filter_placeholder_label_format, matchedFilter.getTitle()));
filteredPlaceholderShowButton.setOnClickListener(view -> listener.clearWarningAction(getBindingAdapterPosition()));
}
protected static boolean hasPreviewableAttachment(@NonNull List<Attachment> attachments) {
for (Attachment attachment : attachments) {
if (attachment.getType() == Attachment.Type.AUDIO || attachment.getType() == Attachment.Type.UNKNOWN) {
@ -1154,11 +1156,13 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
return;
}
final Context context = cardView.getContext();
final Status actionable = status.getActionable();
final Card card = actionable.getCard();
final PreviewCard card = actionable.getCard();
if (cardViewMode != CardViewMode.NONE &&
actionable.getAttachments().size() == 0 &&
actionable.getAttachments().isEmpty() &&
actionable.getPoll() == null &&
card != null &&
!TextUtils.isEmpty(card.getUrl()) &&
@ -1167,45 +1171,79 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
cardView.setVisibility(View.VISIBLE);
cardTitle.setText(card.getTitle());
if (TextUtils.isEmpty(card.getDescription()) && TextUtils.isEmpty(card.getAuthorName())) {
cardDescription.setVisibility(View.GONE);
String providerName = card.getProviderName();
if (TextUtils.isEmpty(providerName)) {
providerName = Uri.parse(card.getUrl()).getHost();
}
if (TextUtils.isEmpty(providerName)) {
cardMetadata.setVisibility(View.GONE);
} else {
cardDescription.setVisibility(View.VISIBLE);
if (TextUtils.isEmpty(card.getDescription())) {
cardDescription.setText(card.getAuthorName());
cardMetadata.setVisibility(View.VISIBLE);
if (card.getPublishedAt() == null) {
cardMetadata.setText(providerName);
} else {
cardDescription.setText(card.getDescription());
String metadataJoiner = context.getString(R.string.metadata_joiner);
cardMetadata.setText(providerName + metadataJoiner + TimestampUtils.getRelativeTimeSpanString(context, card.getPublishedAt().getTime(), System.currentTimeMillis()));
}
}
cardUrl.setText(card.getUrl());
String cardAuthorName;
final TimelineAccount cardAuthorAccount;
if (card.getAuthors().isEmpty()) {
cardAuthorAccount = null;
cardAuthorName = card.getAuthorName();
} else {
cardAuthorName = card.getAuthors().get(0).getName();
cardAuthorAccount = card.getAuthors().get(0).getAccount();
if (cardAuthorAccount != null) {
cardAuthorName = cardAuthorAccount.getName();
}
}
final boolean hasNoAuthorName = TextUtils.isEmpty(cardAuthorName);
if (hasNoAuthorName && TextUtils.isEmpty(card.getDescription())) {
cardAuthor.setVisibility(View.GONE);
cardAuthorButton.setVisibility(View.GONE);
} else if (hasNoAuthorName) {
cardAuthor.setVisibility(View.VISIBLE);
cardAuthor.setText(card.getDescription());
cardAuthorButton.setVisibility(View.GONE);
} else if (cardAuthorAccount == null) {
cardAuthor.setVisibility(View.VISIBLE);
cardAuthor.setText(context.getString(R.string.preview_card_by_author, cardAuthorName));
cardAuthorButton.setVisibility(View.GONE);
} else {
cardAuthorButton.setVisibility(View.VISIBLE);
final String buttonText = context.getString(R.string.preview_card_more_by_author, cardAuthorName);
final CharSequence emojifiedButtonText = CustomEmojiHelper.emojify(buttonText, cardAuthorAccount.getEmojis(), cardAuthorButton, statusDisplayOptions.animateEmojis());
cardAuthorButton.setText(emojifiedButtonText);
cardAuthorButton.setOnClickListener(v-> listener.onViewAccount(cardAuthorAccount.getId()));
cardAuthor.setVisibility(View.GONE);
}
// Statuses from other activitypub sources can be marked sensitive even if there's no media,
// so let's blur the preview in that case
// If media previews are disabled, show placeholder for cards as well
if (statusDisplayOptions.mediaPreviewEnabled() && !actionable.getSensitive() && !TextUtils.isEmpty(card.getImage())) {
int radius = cardImage.getContext().getResources()
.getDimensionPixelSize(R.dimen.card_radius);
int radius = context.getResources().getDimensionPixelSize(R.dimen.inner_card_radius);
ShapeAppearanceModel.Builder cardImageShape = ShapeAppearanceModel.builder();
if (card.getWidth() > card.getHeight()) {
cardView.setOrientation(LinearLayout.VERTICAL);
cardLayout.setOrientation(LinearLayout.VERTICAL);
cardImage.getLayoutParams().height = cardImage.getContext().getResources()
.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;
cardImageShape.setTopLeftCorner(CornerFamily.ROUNDED, radius);
cardImageShape.setTopRightCorner(CornerFamily.ROUNDED, radius);
} else {
cardView.setOrientation(LinearLayout.HORIZONTAL);
cardLayout.setOrientation(LinearLayout.HORIZONTAL);
cardImage.getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT;
cardImage.getLayoutParams().width = cardImage.getContext().getResources()
.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);
cardImageShape.setBottomLeftCorner(CornerFamily.ROUNDED, radius);
}
@ -1215,22 +1253,20 @@ 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());
if (statusDisplayOptions.useBlurhash() && !TextUtils.isEmpty(card.getBlurhash())) {
builder = builder.placeholder(decodeBlurHash(card.getBlurhash()));
}
builder.into(cardImage);
builder.centerInside()
.into(cardImage);
} else if (statusDisplayOptions.useBlurhash() && !TextUtils.isEmpty(card.getBlurhash())) {
int radius = cardImage.getContext().getResources()
.getDimensionPixelSize(R.dimen.card_radius);
.getDimensionPixelSize(R.dimen.inner_card_radius);
cardView.setOrientation(LinearLayout.HORIZONTAL);
cardLayout.setOrientation(LinearLayout.HORIZONTAL);
cardImage.getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT;
cardImage.getLayoutParams().width = cardImage.getContext().getResources()
.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)
@ -1242,15 +1278,12 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
Glide.with(cardImage.getContext())
.load(decodeBlurHash(card.getBlurhash()))
.dontTransform()
.into(cardImage);
} else {
cardView.setOrientation(LinearLayout.HORIZONTAL);
cardLayout.setOrientation(LinearLayout.HORIZONTAL);
cardImage.getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT;
cardImage.getLayoutParams().width = cardImage.getContext().getResources()
.getDimensionPixelSize(R.dimen.card_image_horizontal_width);
cardInfo.getLayoutParams().height = ViewGroup.LayoutParams.WRAP_CONTENT;
cardInfo.getLayoutParams().width = ViewGroup.LayoutParams.MATCH_PARENT;
cardImage.setShapeAppearanceModel(new ShapeAppearanceModel());
@ -1265,11 +1298,9 @@ 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()) ?
cardImage.setOnClickListener(card.getType().equals(PreviewCard.TYPE_PHOTO) && !TextUtils.isEmpty(card.getEmbedUrl()) ?
v -> cardView.getContext().startActivity(ViewMediaActivity.newSingleImageIntent(cardView.getContext(), card.getEmbedUrl())) :
visitLink);
cardView.setClipToOutline(true);
} else {
cardView.setVisibility(View.GONE);
}
@ -1296,13 +1327,4 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
bookmarkButton.setVisibility(visibility);
moreButton.setVisibility(visibility);
}
public void showFilteredPlaceholder(boolean show) {
if (statusContainer != null) {
statusContainer.setVisibility(show ? View.GONE : View.VISIBLE);
}
if (filteredPlaceholder != null) {
filteredPlaceholder.setVisibility(show ? View.VISIBLE : View.GONE);
}
}
}

View file

@ -9,13 +9,11 @@ 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;
@ -25,11 +23,11 @@ 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;
import java.util.Date;
import java.util.List;
public class StatusDetailedViewHolder extends StatusBaseViewHolder {
private final TextView reblogs;
@ -143,15 +141,16 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder {
public void setupWithStatus(@NonNull final StatusViewData.Concrete status,
@NonNull final StatusActionListener listener,
@NonNull StatusDisplayOptions statusDisplayOptions,
@Nullable Object payloads) {
@NonNull List<Object> payloads,
final boolean showStatusInfo) {
// We never collapse statuses in the detail view
StatusViewData.Concrete uncollapsedStatus = (status.isCollapsible() && status.isCollapsed()) ?
status.copyWithCollapsed(false) :
status;
super.setupWithStatus(uncollapsedStatus, listener, statusDisplayOptions, payloads);
super.setupWithStatus(uncollapsedStatus, listener, statusDisplayOptions, payloads, showStatusInfo);
setupCard(uncollapsedStatus, status.isExpanded(), CardViewMode.FULL_WIDTH, statusDisplayOptions, listener); // Always show card for detailed status
if (payloads == null) {
if (payloads.isEmpty()) {
Status actionable = uncollapsedStatus.getActionable();
if (!statusDisplayOptions.hideStats()) {

View file

@ -23,13 +23,12 @@ import android.widget.Button;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.entity.Emoji;
import com.keylesspalace.tusky.entity.Filter;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.entity.TimelineAccount;
import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.util.CustomEmojiHelper;
import com.keylesspalace.tusky.util.NumberUtils;
@ -38,10 +37,9 @@ import com.keylesspalace.tusky.util.StatusDisplayOptions;
import com.keylesspalace.tusky.util.StringUtils;
import com.keylesspalace.tusky.viewdata.StatusViewData;
import java.util.Collections;
import java.util.List;
import at.connyduck.sparkbutton.helpers.Utils;
public class StatusViewHolder extends StatusBaseViewHolder {
private static final InputFilter[] COLLAPSE_INPUT_FILTER = new InputFilter[]{SmartLengthInputFilter.INSTANCE};
private static final InputFilter[] NO_INPUT_FILTER = new InputFilter[0];
@ -63,24 +61,37 @@ public class StatusViewHolder extends StatusBaseViewHolder {
public void setupWithStatus(@NonNull StatusViewData.Concrete status,
@NonNull final StatusActionListener listener,
@NonNull StatusDisplayOptions statusDisplayOptions,
@Nullable Object payloads) {
if (payloads == null) {
@NonNull List<Object> payloads,
final boolean showStatusInfo) {
if (payloads.isEmpty()) {
boolean sensitive = !TextUtils.isEmpty(status.getActionable().getSpoilerText());
boolean expanded = status.isExpanded();
setupCollapsedState(sensitive, expanded, status, listener);
Status reblogging = status.getRebloggingStatus();
if (reblogging == null || status.getFilterAction() == Filter.Action.WARN) {
if (!showStatusInfo || status.getFilterAction() == Filter.Action.WARN) {
hideStatusInfo();
} else {
String rebloggedByDisplayName = reblogging.getAccount().getName();
setRebloggedByDisplayName(rebloggedByDisplayName,
reblogging.getAccount().getEmojis(), statusDisplayOptions);
statusInfo.setOnClickListener(v -> listener.onOpenReblog(getBindingAdapterPosition()));
}
Status rebloggingStatus = status.getRebloggingStatus();
boolean isReplyOnly = rebloggingStatus == null && status.isReply();
boolean isReplySelf = isReplyOnly && status.isSelfReply();
boolean hasStatusInfo = rebloggingStatus != null | isReplyOnly;
TimelineAccount statusInfoAccount = rebloggingStatus != null ? rebloggingStatus.getAccount() : status.getRepliedToAccount();
if (!hasStatusInfo) {
hideStatusInfo();
} else {
setStatusInfoContent(statusInfoAccount, isReplyOnly, isReplySelf, statusDisplayOptions);
}
if (isReplyOnly) {
statusInfo.setOnClickListener(null);
} else {
statusInfo.setOnClickListener(v -> listener.onOpenReblog(getBindingAdapterPosition()));
}
}
}
reblogsCountLabel.setVisibility(statusDisplayOptions.showStatsInline() ? View.VISIBLE : View.INVISIBLE);
@ -88,28 +99,38 @@ public class StatusViewHolder extends StatusBaseViewHolder {
setFavouritedCount(status.getActionable().getFavouritesCount());
setReblogsCount(status.getActionable().getReblogsCount());
super.setupWithStatus(status, listener, statusDisplayOptions, payloads);
super.setupWithStatus(status, listener, statusDisplayOptions, payloads, showStatusInfo);
}
private void setRebloggedByDisplayName(final CharSequence name,
final List<Emoji> accountEmoji,
final StatusDisplayOptions statusDisplayOptions) {
private void setStatusInfoContent(final TimelineAccount account,
final boolean isReply,
final boolean isSelfReply,
final StatusDisplayOptions statusDisplayOptions) {
Context context = statusInfo.getContext();
CharSequence wrappedName = StringUtils.unicodeWrap(name);
CharSequence boostedText = context.getString(R.string.post_boosted_format, wrappedName);
CharSequence accountName = account != null ? account.getName() : "";
CharSequence wrappedName = StringUtils.unicodeWrap(accountName);
CharSequence translatedText = "";
if (!isReply) {
translatedText = context.getString(R.string.post_boosted_format, wrappedName);
} else if (isSelfReply) {
translatedText = context.getString(R.string.post_replied_self);
} else {
if (account != null && accountName.length() > 0) {
translatedText = context.getString(R.string.post_replied_format, wrappedName);
} else {
translatedText = context.getString(R.string.post_replied);
}
}
CharSequence emojifiedText = CustomEmojiHelper.emojify(
boostedText, accountEmoji, statusInfo, statusDisplayOptions.animateEmojis()
translatedText,
account != null ? account.getEmojis() : Collections.emptyList(),
statusInfo,
statusDisplayOptions.animateEmojis()
);
statusInfo.setText(emojifiedText);
statusInfo.setVisibility(View.VISIBLE);
}
// don't use this on the same ViewHolder as setRebloggedByDisplayName, will cause recycling issues as paddings are changed
protected void setPollInfo(final boolean ownPoll) {
statusInfo.setText(ownPoll ? R.string.poll_ended_created : R.string.poll_ended_voted);
statusInfo.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_poll_24dp, 0, 0, 0);
statusInfo.setCompoundDrawablePadding(Utils.dpToPx(statusInfo.getContext(), 10));
statusInfo.setPaddingRelative(Utils.dpToPx(statusInfo.getContext(), 28), 0, 0, 0);
statusInfo.setCompoundDrawablesWithIntrinsicBounds(isReply ? R.drawable.ic_reply_18dp : R.drawable.ic_reblog_18dp, 0, 0, 0);
statusInfo.setVisibility(View.VISIBLE);
}
@ -125,6 +146,10 @@ public class StatusViewHolder extends StatusBaseViewHolder {
statusInfo.setVisibility(View.GONE);
}
protected TextView getStatusInfo() {
return statusInfo;
}
private void setupCollapsedState(boolean sensitive,
boolean expanded,
final StatusViewData.Concrete status,

View file

@ -2,6 +2,9 @@ package com.keylesspalace.tusky.appstore
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.entity.Poll
import com.squareup.moshi.Moshi
import com.squareup.moshi.adapter
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -9,40 +12,64 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
/**
* Updates the database cache in response to events.
* This is important for the home timeline and notifications to be up to date.
*/
@OptIn(ExperimentalStdlibApi::class)
class CacheUpdater @Inject constructor(
eventHub: EventHub,
accountManager: AccountManager,
appDatabase: AppDatabase
appDatabase: AppDatabase,
moshi: Moshi
) {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
init {
val timelineDao = appDatabase.timelineDao()
private val timelineDao = appDatabase.timelineDao()
private val statusDao = appDatabase.timelineStatusDao()
private val notificationsDao = appDatabase.notificationsDao()
init {
scope.launch {
eventHub.events.collect { event ->
val accountId = accountManager.activeAccount?.id ?: return@collect
val tuskyAccountId = accountManager.activeAccount?.id ?: return@collect
when (event) {
is StatusChangedEvent -> {
val status = event.status
timelineDao.update(
accountId = accountId,
status = status
)
is StatusChangedEvent -> statusDao.update(
tuskyAccountId = tuskyAccountId,
status = event.status,
moshi = moshi
)
is UnfollowEvent -> timelineDao.removeStatusesAndReblogsByUser(tuskyAccountId, event.accountId)
is BlockEvent -> removeAllByUser(tuskyAccountId, event.accountId)
is MuteEvent -> removeAllByUser(tuskyAccountId, event.accountId)
is DomainMuteEvent -> {
timelineDao.deleteAllFromInstance(tuskyAccountId, event.instance)
notificationsDao.deleteAllFromInstance(tuskyAccountId, event.instance)
}
is UnfollowEvent ->
timelineDao.removeAllByUser(accountId, event.accountId)
is StatusDeletedEvent ->
timelineDao.delete(accountId, event.statusId)
is StatusDeletedEvent -> {
timelineDao.deleteAllWithStatus(tuskyAccountId, event.statusId)
notificationsDao.deleteAllWithStatus(tuskyAccountId, event.statusId)
}
is PollVoteEvent -> {
timelineDao.setVoted(accountId, event.statusId, event.poll)
val pollString = moshi.adapter<Poll>().toJson(event.poll)
statusDao.setVoted(tuskyAccountId, event.statusId, pollString)
}
}
}
}
}
private suspend fun removeAllByUser(tuskyAccountId: Long, accountId: String) {
timelineDao.removeAllByUser(tuskyAccountId, accountId)
notificationsDao.removeAllByUser(tuskyAccountId, accountId)
}
fun stop() {
this.scope.cancel()
}

View file

@ -1,23 +1,19 @@
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 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 scheduledStatus: ScheduledStatus) : Event
data class StatusScheduledEvent(val scheduledStatusId: String) : 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

View file

@ -1,14 +1,10 @@
package com.keylesspalace.tusky.appstore
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
@ -21,13 +17,4 @@ class EventHub @Inject constructor() {
suspend fun dispatch(event: 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

@ -16,13 +16,12 @@
package com.keylesspalace.tusky.components.account
import android.animation.ArgbEvaluator
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.content.res.ColorStateList
import android.graphics.Color
import android.graphics.Typeface
import android.os.Build
import android.os.Bundle
import android.text.SpannableStringBuilder
import android.text.TextWatcher
@ -36,25 +35,25 @@ 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
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsCompat.Type.systemBars
import androidx.core.view.updateLayoutParams
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.R as materialR
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.dialog.MaterialAlertDialogBuilder
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.google.android.material.shape.MaterialShapeDrawable
import com.google.android.material.shape.ShapeAppearanceModel
@ -71,9 +70,8 @@ import com.keylesspalace.tusky.components.accountlist.AccountListActivity
import com.keylesspalace.tusky.components.compose.ComposeActivity
import com.keylesspalace.tusky.components.report.ReportActivity
import com.keylesspalace.tusky.databinding.ActivityAccountBinding
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.DraftsAlert
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.db.entity.AccountEntity
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Relationship
import com.keylesspalace.tusky.interfaces.AccountSelectionListener
@ -84,7 +82,9 @@ import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.Error
import com.keylesspalace.tusky.util.Loading
import com.keylesspalace.tusky.util.Success
import com.keylesspalace.tusky.util.copyToClipboard
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.ensureBottomMargin
import com.keylesspalace.tusky.util.getDomain
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.loadAvatar
@ -93,16 +93,10 @@ 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
import com.keylesspalace.tusky.view.showMuteAccountDialog
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 dagger.hilt.android.AndroidEntryPoint
import java.text.NumberFormat
import java.text.ParseException
import java.text.SimpleDateFormat
@ -111,25 +105,18 @@ import javax.inject.Inject
import kotlin.math.abs
import kotlinx.coroutines.launch
class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider, HasAndroidInjector, LinkListener {
@Inject
lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector<Any>
@Inject
lateinit var viewModelFactory: ViewModelFactory
@AndroidEntryPoint
class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider, LinkListener {
@Inject
lateinit var draftsAlert: DraftsAlert
private val viewModel: AccountViewModel by viewModels { viewModelFactory }
private val viewModel: AccountViewModel by viewModels()
private val binding: ActivityAccountBinding by viewBinding(ActivityAccountBinding::inflate)
private lateinit var accountFieldAdapter: AccountFieldAdapter
private val preferences by unsafeLazy { PreferenceManager.getDefaultSharedPreferences(this) }
private var followState: FollowState = FollowState.NOT_FOLLOWING
private var blocking: Boolean = false
private var muting: Boolean = false
@ -141,8 +128,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
private var animateAvatar: Boolean = false
private var animateEmojis: Boolean = false
// fields for scroll animation
private var hideFab: Boolean = false
// for scroll animation
private var oldOffset: Int = 0
@ColorInt
@ -180,10 +166,8 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
// Obtain information to fill out the profile.
viewModel.setAccountInfo(intent.getStringExtra(KEY_ACCOUNT_ID)!!)
val sharedPrefs = PreferenceManager.getDefaultSharedPreferences(this)
animateAvatar = sharedPrefs.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false)
animateEmojis = sharedPrefs.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
hideFab = sharedPrefs.getBoolean(PrefKeys.FAB_HIDE, false)
animateAvatar = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false)
animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
handleWindowInsets()
setupToolbar()
@ -204,9 +188,9 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
* Load colors and dimensions from resources
*/
private fun loadResources() {
toolbarColor = MaterialColors.getColor(this, com.google.android.material.R.attr.colorSurface, Color.BLACK)
toolbarColor = MaterialColors.getColor(binding.accountToolbar, materialR.attr.colorSurface)
statusBarColorTransparent = getColor(R.color.transparent_statusbar_background)
statusBarColorOpaque = MaterialColors.getColor(this, androidx.appcompat.R.attr.colorPrimaryDark, Color.BLACK)
statusBarColorOpaque = MaterialColors.getColor(binding.accountToolbar, materialR.attr.colorPrimaryDark)
avatarSize = resources.getDimension(R.dimen.account_activity_avatar_size)
titleVisibleHeight = resources.getDimensionPixelSize(R.dimen.account_activity_scroll_title_visible_height)
}
@ -248,7 +232,6 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
}
// If wellbeing mode is enabled, follow stats and posts count should be hidden
val preferences = PreferenceManager.getDefaultSharedPreferences(this)
val wellbeingEnabled = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_PROFILE, false)
if (wellbeingEnabled) {
@ -304,25 +287,21 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
}
private fun handleWindowInsets() {
binding.accountFloatingActionButton.ensureBottomMargin()
ViewCompat.setOnApplyWindowInsetsListener(binding.accountCoordinatorLayout) { _, insets ->
val top = insets.getInsets(systemBars()).top
val toolbarParams = binding.accountToolbar.layoutParams as ViewGroup.MarginLayoutParams
toolbarParams.topMargin = top
val systemBarInsets = insets.getInsets(systemBars())
val top = systemBarInsets.top
binding.accountToolbar.updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = top
}
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.swipeToRefreshLayout.setProgressViewEndTarget(
false,
top + resources.getDimensionPixelSize(R.dimen.account_swiperefresh_distance)
)
WindowInsetsCompat.CONSUMED
insets.inset(0, top, 0, 0)
}
}
@ -335,28 +314,13 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
setDisplayShowTitleEnabled(false)
}
val appBarElevation = resources.getDimension(R.dimen.actionbar_elevation)
val toolbarBackground = MaterialShapeDrawable.createWithElevationOverlay(
this,
appBarElevation
)
toolbarBackground.fillColor = ColorStateList.valueOf(Color.TRANSPARENT)
binding.accountToolbar.background = toolbarBackground
binding.accountToolbar.setBackgroundColor(Color.TRANSPARENT)
binding.accountToolbar.setNavigationIcon(R.drawable.ic_arrow_back_with_background)
binding.accountToolbar.setOverflowIcon(
AppCompatResources.getDrawable(this, R.drawable.ic_more_with_background)
)
binding.accountToolbar.overflowIcon = 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().apply {
fillColor = ColorStateList.valueOf(toolbarColor)
elevation = appBarElevation
shapeAppearanceModel = ShapeAppearanceModel.builder()
.setAllCornerSizes(resources.getDimension(R.dimen.account_avatar_background_radius))
.build()
@ -378,15 +342,6 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
supportActionBar?.setDisplayShowTitleEnabled(false)
}
if (hideFab && !blocking) {
if (verticalOffset > oldOffset) {
binding.accountFloatingActionButton.show()
}
if (verticalOffset < oldOffset) {
binding.accountFloatingActionButton.hide()
}
}
val scaledAvatarSize = (avatarSize + verticalOffset) / avatarSize
binding.accountAvatarImageView.scaleX = scaledAvatarSize
@ -398,7 +353,10 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
1f
)
window.statusBarColor = argbEvaluator.evaluate(transparencyPercent, statusBarColorTransparent, statusBarColorOpaque) as Int
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM) {
@Suppress("DEPRECATION")
window.statusBarColor = argbEvaluator.evaluate(transparencyPercent, statusBarColorTransparent, statusBarColorOpaque) as Int
}
val evaluatedToolbarColor = argbEvaluator.evaluate(
transparencyPercent,
@ -406,7 +364,8 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
toolbarColor
) as Int
toolbarBackground.fillColor = ColorStateList.valueOf(evaluatedToolbarColor)
binding.accountToolbar.setBackgroundColor(evaluatedToolbarColor)
binding.accountStatusBarScrim.setBackgroundColor(evaluatedToolbarColor)
binding.swipeToRefreshLayout.isEnabled = verticalOffset == 0
}
@ -415,7 +374,10 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
private fun makeNotificationBarTransparent() {
WindowCompat.setDecorFitsSystemWindows(window, false)
window.statusBarColor = statusBarColorTransparent
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM) {
@Suppress("DEPRECATION")
window.statusBarColor = statusBarColorTransparent
}
}
/**
@ -426,7 +388,10 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
viewModel.accountData.collect {
if (it == null) return@collect
when (it) {
is Success -> onAccountChanged(it.data)
is Success -> {
onAccountChanged(it.data)
binding.swipeToRefreshLayout.isEnabled = true
}
is Error -> {
Snackbar.make(
binding.accountCoordinatorLayout,
@ -435,6 +400,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
)
.setAction(R.string.action_retry) { viewModel.refresh() }
.show()
binding.swipeToRefreshLayout.isEnabled = true
}
is Loading -> { }
}
@ -477,13 +443,13 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
* Setup swipe to refresh layout
*/
private fun setupRefreshLayout() {
binding.swipeToRefreshLayout.isEnabled = false // will only be enabled after the first load completed
binding.swipeToRefreshLayout.setOnRefreshListener { onRefresh() }
lifecycleScope.launch {
viewModel.isRefreshing.collect { isRefreshing ->
binding.swipeToRefreshLayout.isRefreshing = isRefreshing == true
viewModel.isRefreshing.collect {
binding.swipeToRefreshLayout.isRefreshing = it
}
}
binding.swipeToRefreshLayout.setColorSchemeResources(R.color.chinwag_green)
}
private fun onAccountChanged(account: Account?) {
@ -497,15 +463,10 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
for (view in listOf(binding.accountUsernameTextView, binding.accountDisplayNameTextView)) {
view.setOnLongClickListener {
loadedAccount?.let { loadedAccount ->
val fullUsername = getFullUsername(loadedAccount)
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText(null, fullUsername))
Snackbar.make(
binding.root,
copyToClipboard(
getFullUsername(loadedAccount),
getString(R.string.account_username_copied),
Snackbar.LENGTH_SHORT
)
.show()
}
true
}
@ -682,6 +643,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
binding.accountFloatingActionButton.setOnClickListener { mention() }
binding.accountFollowButton.setOnClickListener {
val confirmFollows = preferences.getBoolean(PrefKeys.CONFIRM_FOLLOWS, false)
if (viewModel.isSelf) {
val intent = Intent(this@AccountActivity, EditProfileActivity::class.java)
startActivity(intent)
@ -695,7 +657,11 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
when (followState) {
FollowState.NOT_FOLLOWING -> {
viewModel.changeFollowState()
if (confirmFollows) {
showFollowWarningDialog()
} else {
viewModel.changeFollowState()
}
}
FollowState.REQUESTED -> {
showFollowRequestPendingDialog()
@ -721,11 +687,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
blockingDomain = relation.blockingDomain
showingReblogs = relation.showingReblogs
// If wellbeing mode is enabled, "follows you" text should not be visible
val preferences = PreferenceManager.getDefaultSharedPreferences(this)
val wellbeingEnabled = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_PROFILE, false)
binding.accountFollowsYouTextView.visible(relation.followedBy && !wellbeingEnabled)
binding.accountFollowsYouTextView.visible(relation.followedBy)
// because subscribing is Pleroma extension, enable it __only__ when we have non-null subscribing field
// it's also now supported in Mastodon 3.3.0rc but called notifying and use different API call
@ -890,17 +852,10 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
if (!viewModel.isSelf && followState != FollowState.FOLLOWING) {
menu.removeItem(R.id.action_add_or_remove_from_list)
}
menu.findItem(R.id.action_search)?.apply {
icon = IconicsDrawable(this@AccountActivity, GoogleMaterial.Icon.gmd_search).apply {
sizeDp = 20
colorInt = MaterialColors.getColor(binding.collapsingToolbar, android.R.attr.textColorPrimary)
}
}
}
private fun showFollowRequestPendingDialog() {
AlertDialog.Builder(this)
MaterialAlertDialogBuilder(this)
.setMessage(R.string.dialog_message_cancel_follow_request)
.setPositiveButton(android.R.string.ok) { _, _ -> viewModel.changeFollowState() }
.setNegativeButton(android.R.string.cancel, null)
@ -908,18 +863,26 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
}
private fun showUnfollowWarningDialog() {
AlertDialog.Builder(this)
MaterialAlertDialogBuilder(this)
.setMessage(R.string.dialog_unfollow_warning)
.setPositiveButton(android.R.string.ok) { _, _ -> viewModel.changeFollowState() }
.setNegativeButton(android.R.string.cancel, null)
.show()
}
private fun showFollowWarningDialog() {
MaterialAlertDialogBuilder(this)
.setMessage(R.string.dialog_follow_warning)
.setPositiveButton(android.R.string.ok) { _, _ -> viewModel.changeFollowState() }
.setNegativeButton(android.R.string.cancel, null)
.show()
}
private fun toggleBlockDomain(instance: String) {
if (blockingDomain) {
viewModel.unblockDomain(instance)
} else {
AlertDialog.Builder(this)
MaterialAlertDialogBuilder(this)
.setMessage(getString(R.string.mute_domain_warning, instance))
.setPositiveButton(
getString(R.string.mute_domain_warning_dialog_ok)
@ -931,7 +894,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
private fun toggleBlock() {
if (viewModel.relationshipData.value?.data?.blocking != true) {
AlertDialog.Builder(this)
MaterialAlertDialogBuilder(this)
.setMessage(getString(R.string.dialog_block_warning, loadedAccount?.username))
.setPositiveButton(android.R.string.ok) { _, _ -> viewModel.changeBlockState() }
.setNegativeButton(android.R.string.cancel, null)
@ -1133,6 +1096,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
badge.isClickable = false
badge.isFocusable = false
badge.setEnsureMinTouchTargetSize(false)
badge.isCloseIconVisible = false
// reset some chip defaults so it looks better for our badge usecase
badge.iconStartPadding = resources.getDimension(R.dimen.profile_badge_icon_start_padding)
@ -1143,8 +1107,6 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
return badge
}
override fun androidInjector() = dispatchingAndroidInjector
companion object {
private const val KEY_ACCOUNT_ID = "id"

View file

@ -19,18 +19,16 @@ import com.keylesspalace.tusky.util.Loading
import com.keylesspalace.tusky.util.Resource
import com.keylesspalace.tusky.util.Success
import com.keylesspalace.tusky.util.getDomain
import dagger.hilt.android.lifecycle.HiltViewModel
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
@HiltViewModel
class AccountViewModel @Inject constructor(
private val mastodonApi: MastodonApi,
private val eventHub: EventHub,
@ -46,10 +44,8 @@ class AccountViewModel @Inject constructor(
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()
private var isDataLoading = false
private val _isRefreshing = MutableStateFlow(false)
val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow()
lateinit var accountId: String
var isSelf = false
@ -76,7 +72,9 @@ class AccountViewModel @Inject constructor(
private fun obtainAccount(reload: Boolean = false) {
if (_accountData.value == null || reload) {
isDataLoading = true
if (reload) {
_isRefreshing.value = true
}
_accountData.value = Loading()
viewModelScope.launch {
@ -87,14 +85,12 @@ class AccountViewModel @Inject constructor(
isFromOwnDomain = domain == activeAccount.domain
_accountData.value = Success(account)
isDataLoading = false
_isRefreshing.emit(false)
_isRefreshing.value = false
},
{ t ->
Log.w(TAG, "failed obtaining account", t)
_accountData.value = Error(cause = t)
isDataLoading = false
_isRefreshing.emit(false)
_isRefreshing.value = false
}
)
}
@ -316,7 +312,7 @@ class AccountViewModel @Inject constructor(
}
private fun reload(isReload: Boolean = false) {
if (isDataLoading) {
if (_isRefreshing.value) {
return
}
accountId.let {

View file

@ -24,48 +24,37 @@ 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.dialog.MaterialAlertDialogBuilder
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 dagger.hilt.android.AndroidEntryPoint
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 {
@AndroidEntryPoint
class ListSelectionFragment : DialogFragment() {
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 val viewModel: ListsForAccountViewModel by viewModels()
private var selectListener: ListSelectionListener? = null
private var accountId: String? = null
@ -77,17 +66,17 @@ class ListSelectionFragment : DialogFragment(), Injectable {
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)
val binding = FragmentListsListBinding.inflate(layoutInflater)
val adapter = Adapter()
binding.listsView.adapter = adapter
val dialogBuilder = AlertDialog.Builder(context)
val dialogBuilder = MaterialAlertDialogBuilder(context)
.setView(binding.root)
.setTitle(R.string.select_list_title)
.setNeutralButton(R.string.select_list_manage) { _, _ ->
@ -124,7 +113,7 @@ class ListSelectionFragment : DialogFragment(), Injectable {
binding.listsView.hide()
binding.messageView.apply {
show()
setup(error) { load() }
setup(error) { load(binding) }
}
}
}
@ -159,7 +148,7 @@ class ListSelectionFragment : DialogFragment(), Injectable {
}
lifecycleScope.launch {
load()
load(binding)
}
return dialog
@ -177,12 +166,7 @@ class ListSelectionFragment : DialogFragment(), Injectable {
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
private fun load() {
private fun load(binding: FragmentListsListBinding) {
binding.progressBar.show()
binding.listsView.hide()
binding.messageView.hide()

View file

@ -24,6 +24,7 @@ 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 dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
@ -48,6 +49,7 @@ data class ActionError(
}
}
@HiltViewModel
@OptIn(ExperimentalCoroutinesApi::class)
class ListsForAccountViewModel @Inject constructor(
private val mastodonApi: MastodonApi

View file

@ -15,6 +15,7 @@
package com.keylesspalace.tusky.components.account.media
import android.content.SharedPreferences
import android.os.Bundle
import android.view.Menu
import android.view.MenuInflater
@ -28,15 +29,12 @@ import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.paging.LoadState
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.GridLayoutManager
import com.google.android.material.color.MaterialColors
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.ViewMediaActivity
import com.keylesspalace.tusky.databinding.FragmentTimelineBinding
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.interfaces.RefreshableFragment
import com.keylesspalace.tusky.settings.PrefKeys
@ -49,6 +47,7 @@ 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.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
@ -56,23 +55,23 @@ import kotlinx.coroutines.launch
/**
* Fragment with multiple columns of media previews for the specified account.
*/
@AndroidEntryPoint
class AccountMediaFragment :
Fragment(R.layout.fragment_timeline),
RefreshableFragment,
MenuProvider,
Injectable {
@Inject
lateinit var viewModelFactory: ViewModelFactory
MenuProvider {
@Inject
lateinit var accountManager: AccountManager
@Inject
lateinit var preferences: SharedPreferences
private val binding by viewBinding(FragmentTimelineBinding::bind)
private val viewModel: AccountMediaViewModel by viewModels { viewModelFactory }
private val viewModel: AccountMediaViewModel by viewModels()
private lateinit var adapter: AccountMediaGridAdapter
private var adapter: AccountMediaGridAdapter? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -82,14 +81,14 @@ class AccountMediaFragment :
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
val preferences = PreferenceManager.getDefaultSharedPreferences(view.context)
val useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true)
adapter = AccountMediaGridAdapter(
val adapter = AccountMediaGridAdapter(
useBlurhash = useBlurhash,
context = view.context,
onAttachmentClickListener = ::onAttachmentClick
)
this.adapter = adapter
val columnCount = view.context.resources.getInteger(R.integer.profile_media_column_count)
val imageSpacing = view.context.resources.getDimensionPixelSize(
@ -147,6 +146,12 @@ class AccountMediaFragment :
}
}
override fun onDestroyView() {
// Clear the adapter to prevent leaking the View
adapter = null
super.onDestroyView()
}
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.fragment_account_media, menu)
menu.findItem(R.id.action_refresh)?.apply {
@ -202,13 +207,13 @@ class AccountMediaFragment :
}
}
Attachment.Type.UNKNOWN -> {
context?.openLink(selected.attachment.url)
context?.openLink(selected.attachment.unknownUrl)
}
}
}
override fun refreshContent() {
adapter.refresh()
adapter?.refresh()
}
companion object {

View file

@ -5,18 +5,19 @@ import android.graphics.Color
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.appcompat.content.res.AppCompatResources
import androidx.appcompat.widget.TooltipCompat
import androidx.core.view.setPadding
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil
import com.bumptech.glide.Glide
import com.google.android.material.R as materialR
import com.google.android.material.color.MaterialColors
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemAccountMediaBinding
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.util.BindingHolder
import com.keylesspalace.tusky.util.decodeBlurHash
import com.keylesspalace.tusky.util.BlurhashDrawable
import com.keylesspalace.tusky.util.getFormattedDescription
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
@ -47,7 +48,7 @@ class AccountMediaGridAdapter(
private val baseItemBackgroundColor = MaterialColors.getColor(
context,
com.google.android.material.R.attr.colorSurface,
materialR.attr.colorSurface,
Color.BLACK
)
private val videoIndicator = AppCompatResources.getDrawable(
@ -85,7 +86,7 @@ class AccountMediaGridAdapter(
val blurhash = item.attachment.blurhash
val placeholder = if (useBlurhash && blurhash != null) {
decodeBlurHash(context, blurhash)
BlurhashDrawable(context, blurhash)
} else {
null
}
@ -141,11 +142,7 @@ class AccountMediaGridAdapter(
onAttachmentClickListener(item, imageView)
}
holder.binding.root.setOnLongClickListener { view ->
val description = item.attachment.getFormattedDescription(view.context)
Toast.makeText(view.context, description, Toast.LENGTH_LONG).show()
true
}
TooltipCompat.setTooltipText(holder.binding.root, imageView.contentDescription)
}
}
}

View file

@ -20,7 +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.db.entity.AccountEntity
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.viewdata.AttachmentViewData
import retrofit2.HttpException
@ -59,7 +59,15 @@ class AccountMediaRemoteMediator(
}
val attachments = statuses.flatMap { status ->
AttachmentViewData.list(status, activeAccount.alwaysShowSensitiveMedia)
status.attachments.map { attachment ->
AttachmentViewData(
attachment = attachment,
statusId = status.id,
statusUrl = status.url.orEmpty(),
sensitive = status.sensitive,
isRevealed = activeAccount.alwaysShowSensitiveMedia || !status.sensitive
)
}
}
if (loadType == LoadType.REFRESH) {

View file

@ -24,8 +24,10 @@ import androidx.paging.cachedIn
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.viewdata.AttachmentViewData
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
@HiltViewModel
class AccountMediaViewModel @Inject constructor(
accountManager: AccountManager,
api: MastodonApi

View file

@ -22,14 +22,11 @@ import androidx.fragment.app.commit
import com.keylesspalace.tusky.BottomSheetActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ActivityAccountListBinding
import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
import javax.inject.Inject
import com.keylesspalace.tusky.util.getSerializableExtraCompat
import dagger.hilt.android.AndroidEntryPoint
class AccountListActivity : BottomSheetActivity(), HasAndroidInjector {
@Inject
lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector<Any>
@AndroidEntryPoint
class AccountListActivity : BottomSheetActivity() {
enum class Type {
FOLLOWS,
@ -46,7 +43,7 @@ class AccountListActivity : BottomSheetActivity(), HasAndroidInjector {
val binding = ActivityAccountListBinding.inflate(layoutInflater)
setContentView(binding.root)
val type = intent.getSerializableExtra(EXTRA_TYPE) as Type
val type = intent.getSerializableExtraCompat<Type>(EXTRA_TYPE)!!
val id: String? = intent.getStringExtra(EXTRA_ID)
setSupportActionBar(binding.includedToolbar.toolbar)
@ -69,8 +66,6 @@ class AccountListActivity : BottomSheetActivity(), HasAndroidInjector {
}
}
override fun androidInjector() = dispatchingAndroidInjector
companion object {
private const val EXTRA_TYPE = "type"
private const val EXTRA_ID = "id"

View file

@ -15,12 +15,12 @@
package com.keylesspalace.tusky.components.accountlist
import android.content.SharedPreferences
import android.os.Bundle
import android.util.Log
import android.view.View
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
@ -42,7 +42,6 @@ import com.keylesspalace.tusky.components.accountlist.adapter.FollowRequestsHead
import com.keylesspalace.tusky.components.accountlist.adapter.MutesAdapter
import com.keylesspalace.tusky.databinding.FragmentAccountListBinding
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.Relationship
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.interfaces.AccountActionListener
@ -50,21 +49,24 @@ import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.HttpHeaderLink
import com.keylesspalace.tusky.util.ensureBottomPadding
import com.keylesspalace.tusky.util.getSerializableCompat
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 dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
import retrofit2.Response
@AndroidEntryPoint
class AccountListFragment :
Fragment(R.layout.fragment_account_list),
AccountActionListener,
LinkListener,
Injectable {
LinkListener {
@Inject
lateinit var api: MastodonApi
@ -72,23 +74,26 @@ class AccountListFragment :
@Inject
lateinit var accountManager: AccountManager
@Inject
lateinit var preferences: SharedPreferences
private val binding by viewBinding(FragmentAccountListBinding::bind)
private lateinit var type: Type
private var id: String? = null
private lateinit var scrollListener: EndlessOnScrollListener
private lateinit var adapter: AccountAdapter<*>
private var adapter: AccountAdapter<*>? = null
private var fetching = false
private var bottomId: String? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
type = requireArguments().getSerializable(ARG_TYPE) as Type
type = requireArguments().getSerializableCompat(ARG_TYPE)!!
id = requireArguments().getString(ARG_ID)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.recyclerView.ensureBottomPadding()
binding.recyclerView.setHasFixedSize(true)
val layoutManager = LinearLayoutManager(view.context)
binding.recyclerView.layoutManager = layoutManager
@ -97,17 +102,13 @@ class AccountListFragment :
DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL)
)
binding.swipeRefreshLayout.setOnRefreshListener { fetchAccounts() }
binding.swipeRefreshLayout.setColorSchemeResources(R.color.chinwag_green)
val pm = PreferenceManager.getDefaultSharedPreferences(view.context)
val animateAvatar = pm.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false)
val animateEmojis = pm.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
val showBotOverlay = pm.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true)
val animateAvatar = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false)
val animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
val showBotOverlay = preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true)
val activeAccount = accountManager.activeAccount!!
adapter = when (type) {
val adapter = when (type) {
Type.BLOCKS -> BlocksAdapter(this, animateAvatar, animateEmojis, showBotOverlay)
Type.MUTES -> MutesAdapter(this, animateAvatar, animateEmojis, showBotOverlay)
Type.FOLLOW_REQUESTS -> {
@ -122,22 +123,31 @@ class AccountListFragment :
}
else -> FollowAdapter(this, animateAvatar, animateEmojis, showBotOverlay)
}
this.adapter = adapter
if (binding.recyclerView.adapter == null) {
binding.recyclerView.adapter = adapter
}
scrollListener = object : EndlessOnScrollListener(layoutManager) {
val scrollListener = object : EndlessOnScrollListener(layoutManager) {
override fun onLoadMore(totalItemsCount: Int, view: RecyclerView) {
if (bottomId == null) {
return
}
fetchAccounts(bottomId)
fetchAccounts(adapter, bottomId)
}
}
binding.recyclerView.addOnScrollListener(scrollListener)
fetchAccounts()
binding.swipeRefreshLayout.setOnRefreshListener { fetchAccounts(adapter) }
fetchAccounts(adapter)
}
override fun onDestroyView() {
// Clear the adapter to prevent leaking the View
adapter = null
super.onDestroyView()
}
override fun onViewTag(tag: String) {
@ -245,12 +255,12 @@ class AccountListFragment :
Log.e(TAG, "Failed to $verb account accountId $accountId")
}
override fun onRespondToFollowRequest(accept: Boolean, accountId: String, position: Int) {
override fun onRespondToFollowRequest(accept: Boolean, id: String, position: Int) {
viewLifecycleOwner.lifecycleScope.launch {
if (accept) {
api.authorizeFollowRequest(accountId)
api.authorizeFollowRequest(id)
} else {
api.rejectFollowRequest(accountId)
api.rejectFollowRequest(id)
}.fold(
onSuccess = {
onRespondToFollowRequestSuccess(position)
@ -261,7 +271,7 @@ class AccountListFragment :
} else {
"reject"
}
Log.e(TAG, "Failed to $verb account id $accountId.", throwable)
Log.e(TAG, "Failed to $verb account id $id.", throwable)
}
)
}
@ -300,7 +310,7 @@ class AccountListFragment :
return requireNotNull(id) { "id must not be null for type " + type.name }
}
private fun fetchAccounts(fromId: String? = null) {
private fun fetchAccounts(adapter: AccountAdapter<*>, fromId: String? = null) {
if (fetching) {
return
}
@ -316,19 +326,19 @@ class AccountListFragment :
val response = getFetchCallByListType(fromId)
if (!response.isSuccessful) {
onFetchAccountsFailure(Exception(response.message()))
onFetchAccountsFailure(adapter, Exception(response.message()))
return@launch
}
val accountList = response.body()
if (accountList == null) {
onFetchAccountsFailure(Exception(response.message()))
onFetchAccountsFailure(adapter, Exception(response.message()))
return@launch
}
val linkHeader = response.headers()["Link"]
onFetchAccountsSuccess(accountList, linkHeader)
onFetchAccountsSuccess(adapter, accountList, linkHeader)
} catch (exception: Exception) {
if (exception is CancellationException) {
// Scope is cancelled, probably because the fragment is destroyed.
@ -336,12 +346,16 @@ class AccountListFragment :
// (CancellationException in a cancelled scope is normal and will be ignored)
throw exception
}
onFetchAccountsFailure(exception)
onFetchAccountsFailure(adapter, exception)
}
}
}
private fun onFetchAccountsSuccess(accounts: List<TimelineAccount>, linkHeader: String?) {
private fun onFetchAccountsSuccess(
adapter: AccountAdapter<*>,
accounts: List<TimelineAccount>,
linkHeader: String?
) {
adapter.setBottomLoading(false)
binding.swipeRefreshLayout.isRefreshing = false
@ -356,7 +370,7 @@ class AccountListFragment :
}
if (adapter is MutesAdapter) {
fetchRelationships(accounts.map { it.id })
fetchRelationships(adapter, accounts.map { it.id })
}
bottomId = fromId
@ -375,23 +389,30 @@ class AccountListFragment :
}
}
private fun fetchRelationships(ids: List<String>) {
lifecycleScope.launch {
private fun fetchRelationships(mutesAdapter: MutesAdapter, ids: List<String>) {
viewLifecycleOwner.lifecycleScope.launch {
api.relationships(ids)
.fold(::onFetchRelationshipsSuccess) { throwable ->
Log.e(TAG, "Fetch failure for relationships of accounts: $ids", throwable)
}
.fold(
onSuccess = { relationships ->
onFetchRelationshipsSuccess(mutesAdapter, relationships)
},
onFailure = { throwable ->
Log.e(TAG, "Fetch failure for relationships of accounts: $ids", throwable)
}
)
}
}
private fun onFetchRelationshipsSuccess(relationships: List<Relationship>) {
val mutesAdapter = adapter as MutesAdapter
private fun onFetchRelationshipsSuccess(
mutesAdapter: MutesAdapter,
relationships: List<Relationship>
) {
val mutingNotificationsMap = HashMap<String, Boolean>()
relationships.map { mutingNotificationsMap.put(it.id, it.mutingNotifications) }
mutesAdapter.updateMutingNotificationsMap(mutingNotificationsMap)
}
private fun onFetchAccountsFailure(throwable: Throwable) {
private fun onFetchAccountsFailure(adapter: AccountAdapter<*>, throwable: Throwable) {
fetching = false
binding.swipeRefreshLayout.isRefreshing = false
Log.e(TAG, "Fetch failure", throwable)
@ -400,7 +421,7 @@ class AccountListFragment :
binding.messageView.show()
binding.messageView.setup(throwable) {
binding.messageView.hide()
this.fetchAccounts(null)
this.fetchAccounts(adapter, null)
}
}
}

View file

@ -21,7 +21,7 @@ import com.keylesspalace.tusky.databinding.ItemFooterBinding
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.interfaces.AccountActionListener
import com.keylesspalace.tusky.util.BindingHolder
import com.keylesspalace.tusky.util.removeDuplicates
import com.keylesspalace.tusky.util.removeDuplicatesTo
/** Generic adapter with bottom loading indicator. */
abstract class AccountAdapter<AVH : RecyclerView.ViewHolder> internal constructor(
@ -74,7 +74,7 @@ abstract class AccountAdapter<AVH : RecyclerView.ViewHolder> internal constructo
}
fun update(newAccounts: List<TimelineAccount>) {
accountList = removeDuplicates(newAccounts)
accountList = newAccounts.removeDuplicatesTo(ArrayList())
notifyDataSetChanged()
}

View file

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

View file

@ -16,15 +16,16 @@
package com.keylesspalace.tusky.components.announcements
import android.annotation.SuppressLint
import android.graphics.drawable.Drawable
import android.os.Build
import android.text.SpannableStringBuilder
import android.view.ContextThemeWrapper
import android.text.SpannableString
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.size
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.bumptech.glide.request.target.Target
import com.google.android.material.chip.Chip
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemAnnouncementBinding
@ -33,9 +34,11 @@ 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.clearEmojiTargets
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.parseAsMastodonHtml
import com.keylesspalace.tusky.util.setClickableText
import com.keylesspalace.tusky.util.setEmojiTargets
import com.keylesspalace.tusky.util.visible
interface AnnouncementActionListener : LinkListener {
@ -94,12 +97,19 @@ class AnnouncementAdapter(
// hide button if announcement badge limit is already reached
addReactionChip.visible(item.reactions.size < 8)
val requestManager = Glide.with(chips)
chips.clearEmojiTargets()
val targets = ArrayList<Target<Drawable>>(item.reactions.size)
item.reactions.forEachIndexed { i, reaction ->
(
chips.getChildAt(i)?.takeUnless { it.id == R.id.addReactionChip } as Chip?
?: Chip(ContextThemeWrapper(chips.context, com.google.android.material.R.style.Widget_MaterialComponents_Chip_Choice)).apply {
?: Chip(chips.context).apply {
isCheckable = true
checkedIcon = null
isCloseIconVisible = false
setChipBackgroundColorResource(R.color.selectable_chip_background)
chips.addView(this, i)
}
)
@ -109,13 +119,14 @@ class AnnouncementAdapter(
} else {
// we set the EmojiSpan on a space, because otherwise the Chip won't have the right size
// https://github.com/tuskyapp/Tusky/issues/2308
val spanBuilder = SpannableStringBuilder(" ${reaction.count}")
val spannable = SpannableString(" ${reaction.count}")
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)
val target = span.createGlideTarget(this, animateEmojis)
spannable.setSpan(span, 0, 1, 0)
requestManager
.asDrawable()
.load(
if (animateEmojis) {
@ -124,8 +135,9 @@ class AnnouncementAdapter(
reaction.staticUrl
}
)
.into(span.getTarget(animateEmojis))
this.text = spanBuilder
.into(target)
targets.add(target)
this.text = spannable
}
isChecked = reaction.me
@ -144,11 +156,18 @@ class AnnouncementAdapter(
chips.removeViewAt(item.reactions.size)
}
// Store Glide targets for later cancellation
chips.setEmojiTargets(targets)
addReactionChip.setOnClickListener {
listener.openReactionPicker(item.id, it)
}
}
override fun onViewRecycled(holder: BindingHolder<ItemAnnouncementBinding>) {
holder.binding.chipGroup.clearEmojiTargets()
}
override fun getItemCount() = items.size
fun updateList(items: List<Announcement>) {

View file

@ -17,7 +17,6 @@ package com.keylesspalace.tusky.components.announcements
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.os.Bundle
import android.view.Menu
import android.view.MenuInflater
@ -27,46 +26,36 @@ 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
import com.google.android.material.color.MaterialColors
import com.keylesspalace.tusky.BottomSheetActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.StatusListActivity
import com.keylesspalace.tusky.adapter.EmojiAdapter
import com.keylesspalace.tusky.adapter.OnEmojiSelectedListener
import com.keylesspalace.tusky.databinding.ActivityAnnouncementsBinding
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.Error
import com.keylesspalace.tusky.util.Loading
import com.keylesspalace.tusky.util.Success
import com.keylesspalace.tusky.util.ensureBottomPadding
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
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 dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
@AndroidEntryPoint
class AnnouncementsActivity :
BottomSheetActivity(),
AnnouncementActionListener,
OnEmojiSelectedListener,
MenuProvider,
Injectable {
MenuProvider {
@Inject
lateinit var viewModelFactory: ViewModelFactory
private val viewModel: AnnouncementsViewModel by viewModels { viewModelFactory }
private val viewModel: AnnouncementsViewModel by viewModels()
private val binding by viewBinding(ActivityAnnouncementsBinding::inflate)
@ -98,14 +87,13 @@ class AnnouncementsActivity :
}
binding.swipeRefreshLayout.setOnRefreshListener(this::refreshAnnouncements)
binding.swipeRefreshLayout.setColorSchemeResources(R.color.chinwag_green)
binding.announcementsList.ensureBottomPadding()
binding.announcementsList.setHasFixedSize(true)
binding.announcementsList.layoutManager = LinearLayoutManager(this)
val divider = DividerItemDecoration(this, DividerItemDecoration.VERTICAL)
binding.announcementsList.addItemDecoration(divider)
val preferences: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(this)
val wellbeingEnabled = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false)
val animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
@ -161,12 +149,6 @@ class AnnouncementsActivity :
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.activity_announcements, menu)
menu.findItem(R.id.action_search)?.apply {
icon = IconicsDrawable(this@AnnouncementsActivity, GoogleMaterial.Icon.gmd_search).apply {
sizeDp = 20
colorInt = MaterialColors.getColor(binding.includedToolbar.toolbar, android.R.attr.textColorPrimary)
}
}
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {

View file

@ -29,12 +29,14 @@ 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 dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
@HiltViewModel
class AnnouncementsViewModel @Inject constructor(
private val instanceInfoRepo: InstanceInfoRepository,
private val mastodonApi: MastodonApi,
@ -56,7 +58,7 @@ class AnnouncementsViewModel @Inject constructor(
fun load() {
viewModelScope.launch {
_announcements.value = Loading()
mastodonApi.listAnnouncements()
mastodonApi.announcements()
.fold(
{
_announcements.value = Success(it)

View file

@ -16,15 +16,11 @@
package com.keylesspalace.tusky.components.compose
import android.Manifest
import android.app.ProgressDialog
import android.content.ClipData
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
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
@ -46,32 +42,33 @@ import android.widget.Toast
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.annotation.AttrRes
import androidx.annotation.ColorInt
import androidx.annotation.StringRes
import androidx.annotation.VisibleForTesting
import androidx.appcompat.app.AlertDialog
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.core.content.IntentCompat
import androidx.core.content.res.use
import androidx.core.os.BundleCompat
import androidx.core.view.ContentInfoCompat
import androidx.core.view.OnReceiveContentListener
import androidx.core.view.WindowInsetsCompat.Type.ime
import androidx.core.view.WindowInsetsCompat.Type.systemBars
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import androidx.core.widget.doAfterTextChanged
import androidx.core.widget.doOnTextChanged
import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.transition.TransitionManager
import com.canhub.cropper.CropImage
import com.canhub.cropper.CropImageContract
import com.canhub.cropper.options
import com.google.android.material.R as materialR
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.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.BuildConfig
@ -80,6 +77,7 @@ import com.keylesspalace.tusky.adapter.EmojiAdapter
import com.keylesspalace.tusky.adapter.LocaleAdapter
import com.keylesspalace.tusky.adapter.OnEmojiSelectedListener
import com.keylesspalace.tusky.components.compose.ComposeViewModel.ConfirmationKind
import com.keylesspalace.tusky.components.compose.ComposeViewModel.QueuedMedia
import com.keylesspalace.tusky.components.compose.dialog.CaptionDialog
import com.keylesspalace.tusky.components.compose.dialog.makeFocusDialog
import com.keylesspalace.tusky.components.compose.dialog.showAddPollDialog
@ -87,10 +85,8 @@ import com.keylesspalace.tusky.components.compose.view.ComposeOptionsListener
import com.keylesspalace.tusky.components.compose.view.ComposeScheduleView
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
import com.keylesspalace.tusky.databinding.ActivityComposeBinding
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.DraftAttachment
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.db.entity.AccountEntity
import com.keylesspalace.tusky.db.entity.DraftAttachment
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.NewPoll
@ -100,27 +96,34 @@ import com.keylesspalace.tusky.settings.PrefKeys
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.defaultFinders
import com.keylesspalace.tusky.util.getInitialLanguages
import com.keylesspalace.tusky.util.getLocaleList
import com.keylesspalace.tusky.util.getMediaSize
import com.keylesspalace.tusky.util.getParcelableArrayListExtraCompat
import com.keylesspalace.tusky.util.getParcelableCompat
import com.keylesspalace.tusky.util.getParcelableExtraCompat
import com.keylesspalace.tusky.util.getSerializableCompat
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.highlightSpans
import com.keylesspalace.tusky.util.loadAvatar
import com.keylesspalace.tusky.util.map
import com.keylesspalace.tusky.util.modernLanguageCode
import com.keylesspalace.tusky.util.setDrawableTint
import com.keylesspalace.tusky.util.setOnWindowInsetsChangeListener
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 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.hilt.android.AndroidEntryPoint
import dagger.hilt.android.migration.OptionalInject
import java.io.File
import java.io.IOException
import java.text.DecimalFormat
import java.util.Locale
import javax.inject.Inject
import kotlin.math.max
import kotlin.math.min
import kotlinx.coroutines.flow.collect
@ -129,19 +132,17 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
@OptionalInject
@AndroidEntryPoint
class ComposeActivity :
BaseActivity(),
ComposeOptionsListener,
ComposeAutoCompleteAdapter.AutocompletionProvider,
OnEmojiSelectedListener,
Injectable,
OnReceiveContentListener,
ComposeScheduleView.OnTimeSetListener,
CaptionDialog.Listener {
@Inject
lateinit var viewModelFactory: ViewModelFactory
private lateinit var composeOptionsBehavior: BottomSheetBehavior<*>
private lateinit var addMediaBehavior: BottomSheetBehavior<*>
private lateinit var emojiBehavior: BottomSheetBehavior<*>
@ -152,25 +153,43 @@ class ComposeActivity :
private var photoUploadUri: Uri? = null
private val preferences by unsafeLazy { PreferenceManager.getDefaultSharedPreferences(this) }
@VisibleForTesting
var highlightFinders = defaultFinders
@VisibleForTesting
var maximumTootCharacters = InstanceInfoRepository.DEFAULT_CHARACTER_LIMIT
var charactersReservedPerUrl = InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL
private val viewModel: ComposeViewModel by viewModels { viewModelFactory }
private val viewModel: ComposeViewModel by viewModels()
private val binding by viewBinding(ActivityComposeBinding::inflate)
private var maxUploadMediaNumber = InstanceInfoRepository.DEFAULT_MAX_MEDIA_ATTACHMENTS
private val takePicture =
private val takePictureLauncher =
registerForActivityResult(ActivityResultContracts.TakePicture()) { success ->
if (success) {
pickMedia(photoUploadUri!!)
viewModel.pickMedia(photoUploadUri!!)
}
}
private val pickMediaFile = registerForActivityResult(PickMediaFiles()) { uris ->
private val pickMediaFilePermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
if (isGranted) {
pickMediaFileLauncher.launch(true)
} else {
Snackbar.make(
binding.activityCompose,
R.string.error_media_upload_permission,
Snackbar.LENGTH_SHORT
).apply {
setAction(R.string.action_retry) { onMediaPick() }
// necessary so snackbar is shown over everything
view.elevation = resources.getDimension(R.dimen.compose_activity_snackbar_elevation)
show()
}
}
}
private val pickMediaFileLauncher = registerForActivityResult(PickMediaFiles()) { uris ->
if (viewModel.media.value.size + uris.size > maxUploadMediaNumber) {
Toast.makeText(
this,
@ -182,9 +201,11 @@ class ComposeActivity :
Toast.LENGTH_SHORT
).show()
} else {
uris.forEach { uri ->
pickMedia(uri)
}
viewModel.pickMedia(
uris.map { uri ->
ComposeViewModel.MediaData(uri)
}
)
}
}
@ -195,22 +216,20 @@ class ComposeActivity :
viewModel.cropImageItemOld?.let { itemOld ->
val size = getMediaSize(contentResolver, uriNew)
lifecycleScope.launch {
viewModel.addMediaToQueue(
itemOld.type,
uriNew,
size,
itemOld.description,
// Intentionally reset focus when cropping
null,
itemOld
)
}
viewModel.addMediaToQueue(
type = itemOld.type,
uri = uriNew,
mediaSize = size,
description = itemOld.description,
// Intentionally reset focus when cropping
focus = null,
replaceItem = itemOld
)
}
} else if (result == CropImage.CancelledResult) {
Log.w("ComposeActivity", "Edit image cancelled by user")
Log.w(TAG, "Edit image cancelled by user")
} else {
Log.w("ComposeActivity", "Edit image failed: " + result.error)
Log.w(TAG, "Edit image failed: " + result.error)
displayTransientMessage(R.string.error_image_edit_failed)
}
viewModel.cropImageItemOld = null
@ -245,6 +264,18 @@ class ComposeActivity :
}
setContentView(binding.root)
binding.composeBottomBar.setOnWindowInsetsChangeListener { windowInsets ->
val insets = windowInsets.getInsets(systemBars() or ime())
val bottomBarHeight = resources.getDimensionPixelSize(R.dimen.compose_bottom_bar_height)
val bottomBarPadding = resources.getDimensionPixelSize(R.dimen.compose_bottom_bar_padding_vertical)
binding.composeBottomBar.updatePadding(bottom = insets.bottom + bottomBarPadding)
binding.addMediaBottomSheet.updatePadding(bottom = insets.bottom + bottomBarHeight)
binding.emojiView.updatePadding(bottom = insets.bottom + bottomBarHeight)
binding.composeOptionsBottomSheet.updatePadding(bottom = insets.bottom + bottomBarHeight)
binding.composeScheduleView.updatePadding(bottom = insets.bottom + bottomBarHeight)
binding.composeMainScrollView.updateLayoutParams<ViewGroup.MarginLayoutParams> { bottomMargin = insets.bottom + bottomBarHeight }
}
setupActionBar()
setupAvatar(activeAccount)
@ -273,17 +304,13 @@ 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? = intent.getParcelableExtraCompat(COMPOSE_OPTIONS_EXTRA)
viewModel.setup(composeOptions)
setupButtons()
subscribeToUpdates(mediaAdapter)
if (accountManager.shouldDisplaySelfUsername(this)) {
if (accountManager.shouldDisplaySelfUsername()) {
binding.composeUsernameView.text = getString(
R.string.compose_active_account_description,
activeAccount.fullName
@ -300,7 +327,7 @@ class ComposeActivity :
}
if (!composeOptions?.scheduledAt.isNullOrEmpty()) {
binding.composeScheduleView.setDateTime(composeOptions?.scheduledAt)
binding.composeScheduleView.setDateTime(composeOptions.scheduledAt)
}
setupLanguageSpinner(getInitialLanguages(composeOptions?.language, activeAccount))
@ -311,11 +338,9 @@ class ComposeActivity :
/* Finally, overwrite state with data from saved instance state. */
savedInstanceState?.let {
photoUploadUri = BundleCompat.getParcelable(it, PHOTO_UPLOAD_URI_KEY, Uri::class.java)
photoUploadUri = it.getParcelableCompat(PHOTO_UPLOAD_URI_KEY)
(it.getSerializable(VISIBILITY_KEY) as Status.Visibility).apply {
setStatusVisibility(this)
}
setStatusVisibility(it.getSerializableCompat(VISIBILITY_KEY)!!)
it.getBoolean(CONTENT_WARNING_VISIBLE_KEY).apply {
viewModel.contentWarningChanged(this)
@ -340,22 +365,15 @@ 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 ->
pickMedia(uri)
intent.getParcelableExtraCompat<Uri>(Intent.EXTRA_STREAM)?.let { uri ->
viewModel.pickMedia(uri)
}
}
Intent.ACTION_SEND_MULTIPLE -> {
IntentCompat.getParcelableArrayListExtra(
intent,
Intent.EXTRA_STREAM,
Uri::class.java
)?.forEach { uri ->
pickMedia(uri)
}
intent.getParcelableArrayListExtraCompat<Uri>(Intent.EXTRA_STREAM)
?.map { uri ->
ComposeViewModel.MediaData(uri)
}?.let(viewModel::pickMedia)
}
}
}
@ -463,9 +481,9 @@ class ComposeActivity :
binding.composeEditField.setAdapter(
ComposeAutoCompleteAdapter(
this,
preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false),
preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false),
preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true)
animateAvatar = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false),
animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false),
showBotBadge = preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true)
)
)
binding.composeEditField.setTokenizer(ComposeTokenizer())
@ -474,9 +492,9 @@ class ComposeActivity :
binding.composeEditField.setSelection(binding.composeEditField.length())
val mentionColour = binding.composeEditField.linkTextColors.defaultColor
highlightSpans(binding.composeEditField.text, mentionColour)
binding.composeEditField.text.highlightSpans(mentionColour, highlightFinders)
binding.composeEditField.doAfterTextChanged { editable ->
highlightSpans(editable!!, mentionColour)
editable!!.highlightSpans(mentionColour, highlightFinders)
updateVisibleCharactersLeft()
viewModel.updateContent(editable.toString())
}
@ -558,16 +576,25 @@ class ComposeActivity :
lifecycleScope.launch {
viewModel.uploadError.collect { throwable ->
if (throwable is UploadServerError) {
displayTransientMessage(throwable.errorMessage)
} else {
displayTransientMessage(
getString(
R.string.error_media_upload_sending_fmt,
throwable.message
)
val errorString = when (throwable) {
is UploadServerError -> throwable.errorMessage
is FileSizeException -> {
val decimalFormat = DecimalFormat("0.##")
val allowedSizeInMb = throwable.allowedSizeInBytes.toDouble() / (1024 * 1024)
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 CouldNotOpenFileException -> getString(R.string.error_media_upload_opening)
is MediaTypeException -> getString(R.string.error_media_upload_opening)
else -> getString(
R.string.error_media_upload_sending_fmt,
throwable.message
)
}
displayTransientMessage(errorString)
}
}
@ -586,6 +613,11 @@ class ComposeActivity :
scheduleBehavior = BottomSheetBehavior.from(binding.composeScheduleView)
emojiBehavior = BottomSheetBehavior.from(binding.emojiView)
composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN
addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN
scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN
emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN
val bottomSheetCallback = object : BottomSheetCallback() {
override fun onStateChanged(bottomSheet: View, newState: Int) {
updateOnBackPressedCallbackState()
@ -823,31 +855,29 @@ class ComposeActivity :
binding.descriptionMissingWarningButton.hide()
} else {
binding.composeHideMediaButton.show()
@ColorInt val color = if (contentWarningShown) {
@AttrRes val color = if (contentWarningShown) {
binding.composeHideMediaButton.setImageResource(R.drawable.ic_hide_media_24dp)
binding.composeHideMediaButton.isClickable = false
getColor(R.color.transparent_chinwag_green)
materialR.attr.colorPrimary
} else {
binding.composeHideMediaButton.isClickable = true
if (markMediaSensitive) {
binding.composeHideMediaButton.setImageResource(R.drawable.ic_hide_media_24dp)
getColor(R.color.chinwag_green)
materialR.attr.colorPrimary
} else {
binding.composeHideMediaButton.setImageResource(R.drawable.ic_eye_24dp)
MaterialColors.getColor(
binding.composeHideMediaButton,
android.R.attr.textColorTertiary
)
android.R.attr.textColorTertiary
}
}
binding.composeHideMediaButton.drawable.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN)
binding.composeHideMediaButton.drawable.setTint(
MaterialColors.getColor(
binding.composeHideMediaButton,
color
)
)
var oneMediaWithoutDescription = false
for (media in viewModel.media.value) {
if (media.description.isNullOrEmpty()) {
oneMediaWithoutDescription = true
break
}
val oneMediaWithoutDescription = viewModel.media.value.any { media ->
media.description.isNullOrEmpty()
}
binding.descriptionMissingWarningButton.visibility = if (oneMediaWithoutDescription) View.VISIBLE else View.GONE
}
@ -858,15 +888,16 @@ class ComposeActivity :
// Can't reschedule a published status
enableButton(binding.composeScheduleButton, clickable = false, colorActive = false)
} else {
@ColorInt val color = if (binding.composeScheduleView.time == null) {
@ColorInt val color =
MaterialColors.getColor(
binding.composeScheduleButton,
android.R.attr.textColorTertiary
if (binding.composeScheduleView.time == null) {
android.R.attr.textColorTertiary
} else {
materialR.attr.colorPrimary
}
)
} else {
getColor(R.color.chinwag_green)
}
binding.composeScheduleButton.drawable.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN)
binding.composeScheduleButton.drawable.setTint(color)
}
}
@ -937,7 +968,7 @@ class ComposeActivity :
val errorMessage =
getString(
R.string.error_no_custom_emojis,
accountManager.activeAccount!!.domain
activeAccount
)
displayTransientMessage(errorMessage)
} else {
@ -965,32 +996,16 @@ class ComposeActivity :
}
private fun onMediaPick() {
addMediaBehavior.addBottomSheetCallback(
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) {
addMediaBehavior.removeBottomSheetCallback(this)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && ContextCompat.checkSelfPermission(this@ComposeActivity, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(
this@ComposeActivity,
arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE),
PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE
)
} else {
pickMediaFile.launch(true)
}
}
}
override fun onSlide(bottomSheet: View, slideOffset: Float) {}
}
)
addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
pickMediaFilePermissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE)
} else {
pickMediaFileLauncher.launch(true)
}
addMediaBehavior.setState(BottomSheetBehavior.STATE_HIDDEN)
}
private fun openPollDialog() = lifecycleScope.launch {
addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN
val instanceParams = viewModel.instanceInfo.first()
showAddPollDialog(
context = this@ComposeActivity,
@ -1039,7 +1054,7 @@ class ComposeActivity :
}
override fun onVisibilityChanged(visibility: Status.Visibility) {
composeOptionsBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN
viewModel.changeStatusVisibility(visibility)
}
@ -1061,7 +1076,7 @@ class ComposeActivity :
binding.composeCharactersLeftView.text = String.format(Locale.getDefault(), "%d", remainingLength)
val textColor = if (remainingLength < 0) {
getColor(R.color.tusky_red)
getColor(R.color.warning_color)
} else {
MaterialColors.getColor(
binding.composeCharactersLeftView,
@ -1096,12 +1111,27 @@ class ComposeActivity :
if (contentInfo.clip.description.hasMimeType("image/*")) {
val split = contentInfo.partition { item: ClipData.Item -> item.uri != null }
split.first?.let { content ->
for (i in 0 until content.clip.itemCount) {
pickMedia(
content.clip.getItemAt(i).uri,
contentInfo.clip.description.label as String?
)
val description = (contentInfo.clip.description.label as String?)?.let {
// 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 it) {
null
} else {
it
}
}
viewModel.pickMedia(
content.clip.map { clipItem ->
ComposeViewModel.MediaData(
uri = clipItem.uri,
description = description
)
}
)
}
return split.second
}
@ -1130,33 +1160,8 @@ class ComposeActivity :
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE) {
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
pickMediaFile.launch(true)
} else {
Snackbar.make(
binding.activityCompose,
R.string.error_media_upload_permission,
Snackbar.LENGTH_SHORT
).apply {
setAction(R.string.action_retry) { onMediaPick() }
// necessary so snackbar is shown over everything
view.elevation = resources.getDimension(R.dimen.compose_activity_snackbar_elevation)
show()
}
}
}
}
private fun initiateCameraApp() {
addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN
val photoFile: File = try {
createNewImageFile(this)
@ -1170,8 +1175,7 @@ class ComposeActivity :
this,
BuildConfig.APPLICATION_ID + ".fileprovider",
photoFile
)
takePicture.launch(photoUploadUri)
).also { uri -> takePictureLauncher.launch(uri) }
}
private fun enableButton(button: ImageButton, clickable: Boolean, colorActive: Boolean) {
@ -1198,7 +1202,7 @@ class ComposeActivity :
}
)
binding.addPollTextActionTextView.setTextColor(textColor)
binding.addPollTextActionTextView.compoundDrawablesRelative[0].colorFilter = PorterDuffColorFilter(textColor, PorterDuff.Mode.SRC_IN)
binding.addPollTextActionTextView.compoundDrawablesRelative[0].setTint(textColor)
}
private fun editImageInQueue(item: QueuedMedia) {
@ -1231,65 +1235,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, sanitizedDescription).onFailure { throwable ->
val errorString = when (throwable) {
is FileSizeException -> {
val decimalFormat = DecimalFormat("0.##")
val allowedSizeInMb = throwable.allowedSizeInBytes.toDouble() / (1024 * 1024)
val formattedSize = decimalFormat.format(allowedSizeInMb)
getString(R.string.error_multimedia_size_limit, formattedSize)
}
is VideoOrImageException -> getString(
R.string.error_media_upload_image_or_video
)
else -> getString(R.string.error_media_upload_opening)
}
displayTransientMessage(errorString)
}
}
}
private fun showContentWarning(show: Boolean) {
TransitionManager.beginDelayedTransition(
binding.composeContentWarningBar.parent as ViewGroup
)
@ColorInt val color = if (show) {
@AttrRes val color = if (show) {
binding.composeContentWarningBar.show()
binding.composeContentWarningField.setSelection(
binding.composeContentWarningField.text.length
)
binding.composeContentWarningField.requestFocus()
getColor(R.color.chinwag_green)
materialR.attr.colorPrimary
} else {
binding.composeContentWarningBar.hide()
binding.composeEditField.requestFocus()
MaterialColors.getColor(
binding.composeContentWarningButton,
android.R.attr.textColorTertiary
)
android.R.attr.textColorTertiary
}
binding.composeContentWarningButton.drawable.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN)
binding.composeContentWarningButton.drawable.setTint(
MaterialColors.getColor(
binding.composeHideMediaButton,
color
)
)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
@ -1344,14 +1311,14 @@ class ComposeActivity :
private fun getSaveAsDraftOrDiscardDialog(
contentText: String,
contentWarning: String
): AlertDialog.Builder {
): MaterialAlertDialogBuilder {
val warning = if (viewModel.media.value.isNotEmpty()) {
R.string.compose_save_draft_loses_media
} else {
R.string.compose_save_draft
}
return AlertDialog.Builder(this)
return MaterialAlertDialogBuilder(this)
.setMessage(warning)
.setPositiveButton(R.string.action_save) { _, _ ->
viewModel.stopUploads()
@ -1370,14 +1337,14 @@ class ComposeActivity :
private fun getUpdateDraftOrDiscardDialog(
contentText: String,
contentWarning: String
): AlertDialog.Builder {
): MaterialAlertDialogBuilder {
val warning = if (viewModel.media.value.isNotEmpty()) {
R.string.compose_save_draft_loses_media
} else {
R.string.compose_save_draft
}
return AlertDialog.Builder(this)
return MaterialAlertDialogBuilder(this)
.setMessage(warning)
.setPositiveButton(R.string.action_save) { _, _ ->
viewModel.stopUploads()
@ -1393,8 +1360,8 @@ class ComposeActivity :
* User is editing a post (scheduled, or posted), and can either go back to editing, or
* discard the changes.
*/
private fun getContinueEditingOrDiscardDialog(): AlertDialog.Builder {
return AlertDialog.Builder(this)
private fun getContinueEditingOrDiscardDialog(): MaterialAlertDialogBuilder {
return MaterialAlertDialogBuilder(this)
.setMessage(R.string.compose_unsaved_changes)
.setPositiveButton(R.string.action_continue_edit) { _, _ ->
// Do nothing, dialog will dismiss, user can continue editing
@ -1409,8 +1376,8 @@ class ComposeActivity :
* User is editing an existing draft and making it empty.
* The user can either delete the empty draft or go back to editing.
*/
private fun getDeleteEmptyDraftOrContinueEditing(): AlertDialog.Builder {
return AlertDialog.Builder(this)
private fun getDeleteEmptyDraftOrContinueEditing(): MaterialAlertDialogBuilder {
return MaterialAlertDialogBuilder(this)
.setMessage(R.string.compose_delete_draft)
.setPositiveButton(R.string.action_delete) { _, _ ->
viewModel.deleteDraft()
@ -1429,19 +1396,7 @@ class ComposeActivity :
private fun saveDraftAndFinish(contentText: String, contentWarning: String) {
lifecycleScope.launch {
val dialog = if (viewModel.shouldShowSaveDraftDialog()) {
ProgressDialog.show(
this@ComposeActivity,
null,
getString(R.string.saving_draft),
true,
false
)
} else {
null
}
viewModel.saveDraft(contentText, contentWarning)
dialog?.cancel()
finish()
}
}
@ -1462,30 +1417,6 @@ class ComposeActivity :
}
}
data class QueuedMedia(
val localId: Int,
val uri: Uri,
val type: Type,
val mediaSize: Long,
val uploadPercent: Int = 0,
val id: String? = null,
val description: String? = null,
val focus: Attachment.Focus? = null,
val state: State
) {
enum class Type {
IMAGE,
VIDEO,
AUDIO
}
enum class State {
UPLOADING,
UNPROCESSED,
PROCESSED,
PUBLISHED
}
}
override fun onTimeSet(time: String?) {
viewModel.updateScheduledAt(time)
if (verifyScheduledTime()) {
@ -1550,7 +1481,6 @@ class ComposeActivity :
companion object {
private const val TAG = "ComposeActivity" // logging tag
private const val PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE = 1
internal const val COMPOSE_OPTIONS_EXTRA = "COMPOSE_OPTIONS"
private const val PHOTO_UPLOAD_URI_KEY = "PHOTO_UPLOAD_URI"

View file

@ -37,7 +37,9 @@ class ComposeAutoCompleteAdapter(
private val autocompletionProvider: AutocompletionProvider,
private val animateAvatar: Boolean,
private val animateEmojis: Boolean,
private val showBotBadge: Boolean
private val showBotBadge: Boolean,
// if true, @ # : are returned in the result, otherwise only the raw value
private val withDecoration: Boolean = true,
) : BaseAdapter(), Filterable {
private var resultList: List<AutocompleteResult> = emptyList()
@ -52,36 +54,35 @@ class ComposeAutoCompleteAdapter(
return position.toLong()
}
override fun getFilter(): Filter {
return object : Filter() {
override fun getFilter() = object : Filter() {
override fun convertResultToString(resultValue: Any): CharSequence {
return when (resultValue) {
is AutocompleteResult.AccountResult -> formatUsername(resultValue)
is AutocompleteResult.HashtagResult -> formatHashtag(resultValue)
is AutocompleteResult.EmojiResult -> formatEmoji(resultValue)
else -> ""
}
override fun convertResultToString(resultValue: Any): CharSequence {
return when (resultValue) {
is AutocompleteResult.AccountResult -> if (withDecoration) "@${resultValue.account.username}" else resultValue.account.username
is AutocompleteResult.HashtagResult -> if (withDecoration) "#${resultValue.hashtag}" else resultValue.hashtag
is AutocompleteResult.EmojiResult -> if (withDecoration) ":${resultValue.emoji.shortcode}:" else resultValue.emoji.shortcode
else -> ""
}
}
@WorkerThread
override fun performFiltering(constraint: CharSequence?): FilterResults {
val filterResults = FilterResults()
if (constraint != null) {
val results = autocompletionProvider.search(constraint.toString())
filterResults.values = results
filterResults.count = results.size
}
return filterResults
@WorkerThread
override fun performFiltering(constraint: CharSequence?): FilterResults {
val filterResults = FilterResults()
if (constraint != null) {
val results = autocompletionProvider.search(constraint.toString())
filterResults.values = results
filterResults.count = results.size
}
return filterResults
}
override fun publishResults(constraint: CharSequence?, results: FilterResults) {
if (results.count > 0) {
resultList = results.values as List<AutocompleteResult>
notifyDataSetChanged()
} else {
notifyDataSetInvalidated()
}
@Suppress("UNCHECKED_CAST")
override fun publishResults(constraint: CharSequence?, results: FilterResults) {
if (results.count > 0) {
resultList = results.values as List<AutocompleteResult>
notifyDataSetChanged()
} else {
notifyDataSetInvalidated()
}
}
}
@ -121,7 +122,7 @@ class ComposeAutoCompleteAdapter(
}
is ItemAutocompleteHashtagBinding -> {
val result = getItem(position) as AutocompleteResult.HashtagResult
binding.root.text = formatHashtag(result)
binding.root.text = context.getString(R.string.hashtag_format, result.hashtag)
}
is ItemAutocompleteEmojiBinding -> {
val emojiResult = getItem(position) as AutocompleteResult.EmojiResult
@ -161,17 +162,5 @@ class ComposeAutoCompleteAdapter(
private const val ACCOUNT_VIEW_TYPE = 0
private const val HASHTAG_VIEW_TYPE = 1
private const val EMOJI_VIEW_TYPE = 2
private fun formatUsername(result: AutocompleteResult.AccountResult): String {
return String.format("@%s", result.account.username)
}
private fun formatHashtag(result: AutocompleteResult.HashtagResult): String {
return String.format("#%s", result.hashtag)
}
private fun formatEmoji(result: AutocompleteResult.EmojiResult): String {
return String.format(":%s:", result.emoji.shortcode)
}
}
}

View file

@ -22,7 +22,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.connyduck.calladapter.networkresult.fold
import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeKind
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
import com.keylesspalace.tusky.components.compose.ComposeAutoCompleteAdapter.AutocompleteResult
import com.keylesspalace.tusky.components.drafts.DraftHelper
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfo
@ -38,8 +37,10 @@ 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 dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
@ -54,8 +55,8 @@ import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
@HiltViewModel
class ComposeViewModel @Inject constructor(
private val api: MastodonApi,
private val accountManager: AccountManager,
@ -90,7 +91,7 @@ class ComposeViewModel @Inject constructor(
.shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1)
private val _markMediaAsSensitive =
MutableStateFlow(accountManager.activeAccount?.defaultMediaSensitivity ?: false)
MutableStateFlow(accountManager.activeAccount?.defaultMediaSensitivity == true)
val markMediaAsSensitive: StateFlow<Boolean> = _markMediaAsSensitive.asStateFlow()
private val _statusVisibility = MutableStateFlow(Status.Visibility.UNKNOWN)
@ -125,31 +126,31 @@ class ComposeViewModel @Inject constructor(
private var setupComplete = false
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
if (type != QueuedMedia.Type.IMAGE &&
mediaItems.isNotEmpty() &&
mediaItems[0].type == QueuedMedia.Type.IMAGE
) {
Result.failure(VideoOrImageException())
} else {
val queuedMedia = addMediaToQueue(type, uri, size, description, focus)
Result.success(queuedMedia)
}
} catch (e: Exception) {
Result.failure(e)
fun pickMedia(uri: Uri) {
pickMedia(listOf(MediaData(uri)))
}
fun pickMedia(mediaList: List<MediaData>) = viewModelScope.launch(Dispatchers.IO) {
val instanceInfo = instanceInfo.first()
mediaList.map { m ->
async { mediaUploader.prepareMedia(m.uri, instanceInfo) }
}.forEachIndexed { index, preparedMedia ->
preparedMedia.await().fold({ (type, uri, size) ->
if (type != QueuedMedia.Type.IMAGE &&
_media.value.firstOrNull()?.type == QueuedMedia.Type.IMAGE
) {
_uploadError.emit(VideoOrImageException())
} else {
val pickedMedia = mediaList[index]
addMediaToQueue(type, uri, size, pickedMedia.description, pickedMedia.focus)
}
}, { error ->
_uploadError.emit(error)
})
}
}
suspend fun addMediaToQueue(
fun addMediaToQueue(
type: QueuedMedia.Type,
uri: Uri,
mediaSize: Long,
@ -157,20 +158,17 @@ class ComposeViewModel @Inject constructor(
focus: Attachment.Focus? = null,
replaceItem: QueuedMedia? = null
): QueuedMedia {
var stashMediaItem: QueuedMedia? = null
val mediaItem = QueuedMedia(
localId = mediaUploader.getNewLocalMediaId(),
uri = uri,
type = type,
mediaSize = mediaSize,
description = description,
focus = focus,
state = QueuedMedia.State.UPLOADING
)
_media.update { mediaList ->
val mediaItem = QueuedMedia(
localId = mediaUploader.getNewLocalMediaId(),
uri = uri,
type = type,
mediaSize = mediaSize,
description = description,
focus = focus,
state = QueuedMedia.State.UPLOADING
)
stashMediaItem = mediaItem
if (replaceItem != null) {
mediaUploader.cancelUploadScope(replaceItem.localId)
mediaList.map {
@ -180,7 +178,6 @@ class ComposeViewModel @Inject constructor(
mediaList + mediaItem
}
}
val mediaItem = stashMediaItem!! // stashMediaItem is always non-null and uncaptured at this point, but Kotlin doesn't know that
viewModelScope.launch {
mediaUploader
@ -191,6 +188,7 @@ class ComposeViewModel @Inject constructor(
val newMediaItem = when (event) {
is UploadEvent.ProgressEvent ->
item.copy(uploadPercent = event.percentage)
is UploadEvent.FinishedEvent ->
item.copy(
id = event.mediaId,
@ -327,13 +325,6 @@ class ComposeViewModel @Inject constructor(
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 ->
mediaValue.uri.scheme == "https"
}
}
suspend fun saveDraft(content: String, contentWarning: String) {
val mediaUris: MutableList<String> = mutableListOf()
val mediaDescriptions: MutableList<String?> = mutableListOf()
@ -386,7 +377,7 @@ class ComposeViewModel @Inject constructor(
val tootToSend = StatusToSend(
text = content,
warningText = spoilerText,
visibility = _statusVisibility.value.serverString,
visibility = _statusVisibility.value.stringValue,
sensitive = attachedMedia.isNotEmpty() && (_markMediaAsSensitive.value || _showContentWarning.value),
media = attachedMedia,
scheduledAt = _scheduledAt.value,
@ -453,6 +444,7 @@ class ComposeViewModel @Inject constructor(
emptyList()
})
}
':' -> {
val emojiList = emoji.replayCache.firstOrNull() ?: return emptyList()
val incomplete = token.substring(1)
@ -465,6 +457,7 @@ class ComposeViewModel @Inject constructor(
AutocompleteResult.EmojiResult(emoji)
}
}
else -> {
Log.w(TAG, "Unexpected autocompletion token: $token")
emptyList()
@ -478,16 +471,20 @@ class ComposeViewModel @Inject constructor(
}
composeKind = composeOptions?.kind ?: ComposeKind.NEW
inReplyToId = composeOptions?.inReplyToId
val preferredVisibility = accountManager.activeAccount!!.defaultPostPrivacy
val activeAccount = accountManager.activeAccount!!
val preferredVisibility = if (inReplyToId != null) {
activeAccount.defaultReplyPrivacy.toVisibilityOr(activeAccount.defaultPostPrivacy)
} else {
activeAccount.defaultPostPrivacy
}
val replyVisibility = composeOptions?.replyVisibility ?: Status.Visibility.UNKNOWN
startingVisibility = Status.Visibility.byNum(
preferredVisibility.num.coerceAtLeast(replyVisibility.num)
startingVisibility = Status.Visibility.fromInt(
preferredVisibility.int.coerceAtLeast(replyVisibility.int)
)
inReplyToId = composeOptions?.inReplyToId
modifiedInitialState = composeOptions?.modifiedInitialState == true
val contentWarning = composeOptions?.contentWarning
@ -502,11 +499,9 @@ class ComposeViewModel @Inject constructor(
val draftAttachments = composeOptions?.draftAttachments
if (draftAttachments != null) {
// when coming from DraftActivity
viewModelScope.launch {
draftAttachments.forEach { attachment ->
pickMedia(attachment.uri, attachment.description, attachment.focus)
}
}
draftAttachments.map { attachment ->
MediaData(attachment.uri, attachment.description, attachment.focus)
}.let(::pickMedia)
} else {
composeOptions?.mediaAttachments?.forEach { a ->
// when coming from redraft or ScheduledTootActivity
@ -523,10 +518,11 @@ class ComposeViewModel @Inject constructor(
scheduledTootId = composeOptions?.scheduledTootId
originalStatusId = composeOptions?.statusId
startingText = composeOptions?.content
currentContent = composeOptions?.content
postLanguage = composeOptions?.language
val tootVisibility = composeOptions?.visibility ?: Status.Visibility.UNKNOWN
if (tootVisibility.num != Status.Visibility.UNKNOWN.num) {
if (tootVisibility.int != Status.Visibility.UNKNOWN.int) {
startingVisibility = tootVisibility
}
_statusVisibility.value = startingVisibility
@ -584,6 +580,36 @@ class ComposeViewModel @Inject constructor(
CONTINUE_EDITING_OR_DISCARD_CHANGES, // editing post
CONTINUE_EDITING_OR_DISCARD_DRAFT // edit draft
}
data class QueuedMedia(
val localId: Int,
val uri: Uri,
val type: Type,
val mediaSize: Long,
val uploadPercent: Int = 0,
val id: String? = null,
val description: String? = null,
val focus: Attachment.Focus? = null,
val state: State
) {
enum class Type {
IMAGE,
VIDEO,
AUDIO
}
enum class State {
UPLOADING,
UNPROCESSED,
PROCESSED,
PUBLISHED
}
}
data class MediaData(
val uri: Uri,
val description: String? = null,
val focus: Attachment.Focus? = null
)
}
/**

View file

@ -21,8 +21,8 @@ import android.view.ViewGroup
import android.widget.ImageView
import android.widget.PopupMenu
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.recyclerview.widget.AsyncListDiffer
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
@ -31,18 +31,25 @@ import com.keylesspalace.tusky.components.compose.view.ProgressImageView
class MediaPreviewAdapter(
context: Context,
private val onAddCaption: (ComposeActivity.QueuedMedia) -> Unit,
private val onAddFocus: (ComposeActivity.QueuedMedia) -> Unit,
private val onEditImage: (ComposeActivity.QueuedMedia) -> Unit,
private val onRemove: (ComposeActivity.QueuedMedia) -> Unit
) : RecyclerView.Adapter<MediaPreviewAdapter.PreviewViewHolder>() {
private val onAddCaption: (ComposeViewModel.QueuedMedia) -> Unit,
private val onAddFocus: (ComposeViewModel.QueuedMedia) -> Unit,
private val onEditImage: (ComposeViewModel.QueuedMedia) -> Unit,
private val onRemove: (ComposeViewModel.QueuedMedia) -> Unit
) : ListAdapter<ComposeViewModel.QueuedMedia, MediaPreviewAdapter.PreviewViewHolder>(
object : DiffUtil.ItemCallback<ComposeViewModel.QueuedMedia>() {
override fun areItemsTheSame(
oldItem: ComposeViewModel.QueuedMedia,
newItem: ComposeViewModel.QueuedMedia
) = oldItem.localId == newItem.localId
fun submitList(list: List<ComposeActivity.QueuedMedia>) {
this.differ.submitList(list)
override fun areContentsTheSame(
oldItem: ComposeViewModel.QueuedMedia,
newItem: ComposeViewModel.QueuedMedia
) = oldItem == newItem
}
) {
private fun onMediaClick(position: Int, view: View) {
val item = differ.currentList[position]
private fun onMediaClick(item: ComposeViewModel.QueuedMedia, view: View) {
val popup = PopupMenu(view.context, view)
val addCaptionId = 1
val addFocusId = 2
@ -50,9 +57,9 @@ class MediaPreviewAdapter(
val removeId = 4
popup.menu.add(0, addCaptionId, 0, R.string.action_set_caption)
if (item.type == ComposeActivity.QueuedMedia.Type.IMAGE) {
if (item.type == ComposeViewModel.QueuedMedia.Type.IMAGE) {
popup.menu.add(0, addFocusId, 0, R.string.action_set_focus)
if (item.state != ComposeActivity.QueuedMedia.State.PUBLISHED) {
if (item.state != ComposeViewModel.QueuedMedia.State.PUBLISHED) {
// Already-published items can't be edited
popup.menu.add(0, editImageId, 0, R.string.action_edit_image)
}
@ -73,17 +80,15 @@ class MediaPreviewAdapter(
private val thumbnailViewSize =
context.resources.getDimensionPixelSize(R.dimen.compose_media_preview_size)
override fun getItemCount(): Int = differ.currentList.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PreviewViewHolder {
return PreviewViewHolder(ProgressImageView(parent.context))
}
override fun onBindViewHolder(holder: PreviewViewHolder, position: Int) {
val item = differ.currentList[position]
val item = getItem(position)
holder.progressImageView.setChecked(!item.description.isNullOrEmpty())
holder.progressImageView.setProgress(item.uploadPercent)
if (item.type == ComposeActivity.QueuedMedia.Type.AUDIO) {
if (item.type == ComposeViewModel.QueuedMedia.Type.AUDIO) {
// TODO: Fancy waveform display?
holder.progressImageView.setImageResource(R.drawable.ic_music_box_preview_24dp)
} else {
@ -108,26 +113,11 @@ class MediaPreviewAdapter(
glide.into(imageView)
}
}
private val differ = AsyncListDiffer(
this,
object : DiffUtil.ItemCallback<ComposeActivity.QueuedMedia>() {
override fun areItemsTheSame(
oldItem: ComposeActivity.QueuedMedia,
newItem: ComposeActivity.QueuedMedia
): Boolean {
return oldItem.localId == newItem.localId
}
override fun areContentsTheSame(
oldItem: ComposeActivity.QueuedMedia,
newItem: ComposeActivity.QueuedMedia
): Boolean {
return oldItem == newItem
}
holder.progressImageView.setOnClickListener {
onMediaClick(item, holder.progressImageView)
}
)
}
inner class PreviewViewHolder(val progressImageView: ProgressImageView) :
RecyclerView.ViewHolder(progressImageView) {
@ -140,9 +130,6 @@ class MediaPreviewAdapter(
layoutParams.setMargins(margin, 0, margin, marginBottom)
progressImageView.layoutParams = layoutParams
progressImageView.scaleType = ImageView.ScaleType.CENTER_CROP
progressImageView.setOnClickListener {
onMediaClick(bindingAdapterPosition, progressImageView)
}
}
}
}

View file

@ -27,7 +27,7 @@ import androidx.core.content.FileProvider
import androidx.core.net.toUri
import com.keylesspalace.tusky.BuildConfig
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
import com.keylesspalace.tusky.components.compose.ComposeViewModel.QueuedMedia
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfo
import com.keylesspalace.tusky.network.MediaUploadApi
import com.keylesspalace.tusky.network.asRequestBody
@ -36,10 +36,10 @@ 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 dagger.hilt.android.qualifiers.ApplicationContext
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
@ -92,15 +92,16 @@ class MediaTypeException : Exception()
class CouldNotOpenFileException : Exception()
class UploadServerError(val errorMessage: String) : Exception()
@Singleton
class MediaUploader @Inject constructor(
private val context: Context,
@ApplicationContext private val context: Context,
private val mediaUploadApi: MediaUploadApi
) {
private val uploads = mutableMapOf<Int, UploadData>()
private var mostRecentId: Int = 0
private companion object {
private const val TAG = "MediaUploader"
private val uploads = mutableMapOf<Int, UploadData>()
private var mostRecentId: Int = 0
}
fun getNewLocalMediaId(): Int {
return mostRecentId++
@ -150,7 +151,7 @@ class MediaUploader @Inject constructor(
}
}
fun prepareMedia(inUri: Uri, instanceInfo: InstanceInfo): PreparedMedia {
fun prepareMedia(inUri: Uri, instanceInfo: InstanceInfo): Result<PreparedMedia> = runCatching {
var mediaSize = MEDIA_SIZE_UNKNOWN
var uri = inUri
val mimeType: String?
@ -216,9 +217,8 @@ class MediaUploader @Inject constructor(
Log.w(TAG, "Could not determine file size of upload")
throw MediaTypeException()
}
if (mimeType != null) {
return when (mimeType.substring(0, mimeType.indexOf('/'))) {
when (mimeType.substring(0, mimeType.indexOf('/'))) {
"video" -> {
if (mediaSize > instanceInfo.videoSizeLimit) {
throw FileSizeException(instanceInfo.videoSizeLimit)
@ -246,7 +246,7 @@ class MediaUploader @Inject constructor(
private val contentResolver = context.contentResolver
private suspend fun upload(media: QueuedMedia): Flow<UploadEvent> {
private fun upload(media: QueuedMedia): Flow<UploadEvent> {
return callbackFlow {
var mimeType = contentResolver.getType(media.uri)
@ -264,12 +264,8 @@ class MediaUploader @Inject constructor(
}
val map = MimeTypeMap.getSingleton()
val fileExtension = map.getExtensionFromMimeType(mimeType)
val filename = "%s_%d_%s.%s".format(
context.getString(R.string.app_name),
System.currentTimeMillis(),
randomAlphanumericString(10),
fileExtension
)
val filename =
"${context.getString(R.string.app_name)}_${System.currentTimeMillis()}_${randomAlphanumericString(10)}.$fileExtension"
if (mimeType == null) mimeType = "multipart/form-data"
@ -327,8 +323,4 @@ class MediaUploader @Inject constructor(
return media.type == QueuedMedia.Type.IMAGE &&
(media.mediaSize > instanceInfo.imageSizeLimit || getImageSquarePixels(context.contentResolver, media.uri) > instanceInfo.imageMatrixLimit)
}
private companion object {
private const val TAG = "MediaUploader"
}
}

View file

@ -20,8 +20,9 @@ package com.keylesspalace.tusky.components.compose.dialog
import android.content.Context
import android.view.LayoutInflater
import android.view.WindowManager
import android.widget.ArrayAdapter
import androidx.appcompat.app.AlertDialog
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.textfield.TextInputEditText
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.DialogAddPollBinding
import com.keylesspalace.tusky.entity.NewPoll
@ -37,10 +38,16 @@ fun showAddPollDialog(
) {
val binding = DialogAddPollBinding.inflate(LayoutInflater.from(context))
val dialog = AlertDialog.Builder(context)
val inset = context.resources.getDimensionPixelSize(R.dimen.dialog_inset)
val dialog = MaterialAlertDialogBuilder(context)
.setIcon(R.drawable.ic_poll_24dp)
.setTitle(R.string.create_poll_title)
.setView(binding.root)
.setBackgroundInsetTop(inset)
.setBackgroundInsetEnd(inset)
.setBackgroundInsetBottom(inset)
.setBackgroundInsetStart(inset)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(android.R.string.ok, null)
.create()
@ -63,9 +70,8 @@ fun showAddPollDialog(
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)
}
binding.pollDurationDropDown.setSimpleItems(durationLabels.toTypedArray())
durations = durations.filter { it in minDuration..maxDuration }
binding.addChoiceButton.setOnClickListener {
@ -79,23 +85,24 @@ fun showAddPollDialog(
val secondsInADay = 60 * 60 * 24
val desiredDuration = poll?.expiresIn ?: secondsInADay
val pollDurationId = durations.indexOfLast {
var selectedDurationIndex = durations.indexOfLast {
it <= desiredDuration
}
binding.pollDurationSpinner.setSelection(pollDurationId)
binding.pollDurationDropDown.setText(durationLabels[selectedDurationIndex], false)
binding.pollDurationDropDown.setOnItemClickListener { _, _, position, _ ->
selectedDurationIndex = position
}
binding.multipleChoicesCheckBox.isChecked = poll?.multiple ?: false
binding.multipleChoicesCheckBox.isChecked = poll?.multiple == true
dialog.setOnShowListener {
val button = dialog.getButton(AlertDialog.BUTTON_POSITIVE)
button.setOnClickListener {
val selectedPollDurationId = binding.pollDurationSpinner.selectedItemPosition
onUpdatePoll(
NewPoll(
options = adapter.pollOptions,
expiresIn = durations[selectedPollDurationId],
expiresIn = durations[selectedDurationIndex],
multiple = binding.multipleChoicesCheckBox.isChecked
)
)
@ -106,6 +113,18 @@ fun showAddPollDialog(
dialog.show()
// yes, SOFT_INPUT_ADJUST_RESIZE is deprecated, but without it the dropdown can get behind the keyboard
dialog.window?.setSoftInputMode(
WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE or WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE
)
binding.pollChoices.post {
val firstItemView = binding.pollChoices.layoutManager?.findViewByPosition(0)
val editText = firstItemView?.findViewById<TextInputEditText>(R.id.optionEditText)
editText?.requestFocus()
editText?.setSelection(editText.length())
}
// 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

View file

@ -15,27 +15,27 @@
package com.keylesspalace.tusky.components.compose.dialog
import android.app.Dialog
import android.content.Context
import android.graphics.drawable.Animatable
import android.graphics.drawable.Drawable
import android.net.Uri
import android.os.Bundle
import android.text.InputFilter
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import android.widget.LinearLayout
import androidx.core.os.BundleCompat
import androidx.core.os.bundleOf
import androidx.fragment.app.DialogFragment
import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.DialogImageDescriptionBinding
import com.keylesspalace.tusky.util.getParcelableCompat
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
@ -43,22 +43,35 @@ private const val MEDIA_DESCRIPTION_CHARACTER_LIMIT = 1500
class CaptionDialog : DialogFragment() {
private lateinit var listener: Listener
private val binding by viewBinding(DialogImageDescriptionBinding::bind)
private lateinit var binding: DialogImageDescriptionBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(STYLE_NORMAL, R.style.TuskyDialogFragmentStyle)
private var animatable: Animatable? = null
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val localId = arguments?.getInt(LOCAL_ID_ARG) ?: error("Missing localId")
val inset = requireContext().resources.getDimensionPixelSize(R.dimen.dialog_inset)
return MaterialAlertDialogBuilder(requireContext())
.setView(createView(savedInstanceState))
.setBackgroundInsetTop(inset)
.setBackgroundInsetEnd(inset)
.setBackgroundInsetBottom(inset)
.setBackgroundInsetStart(inset)
.setPositiveButton(android.R.string.ok) { _, _ ->
listener.onUpdateDescription(localId, binding.imageDescriptionText.text.toString())
}
.setNegativeButton(android.R.string.cancel, null)
.create()
}
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?) {
private fun createView(savedInstanceState: Bundle?): View {
binding = DialogImageDescriptionBinding.inflate(layoutInflater)
val imageView = binding.imageDescriptionView
imageView.maxZoom = 6f
val imageDescriptionText = binding.imageDescriptionText
imageDescriptionText.post {
imageDescriptionText.requestFocus()
imageDescriptionText.setSelection(imageDescriptionText.length())
}
binding.imageDescriptionText.hint = resources.getQuantityString(
R.plurals.hint_describe_for_visually_impaired,
@ -71,18 +84,10 @@ class CaptionDialog : DialogFragment() {
binding.imageDescriptionText.setText(it)
}
binding.cancelButton.setOnClickListener {
dismiss()
}
val localId = arguments?.getInt(LOCAL_ID_ARG) ?: error("Missing localId")
binding.okButton.setOnClickListener {
listener.onUpdateDescription(localId, binding.imageDescriptionText.text.toString())
dismiss()
}
isCancelable = true
dialog?.setCanceledOnTouchOutside(false) // Dialog is full screen anyway. But without this, taps in navbar while keyboard is up can dismiss the dialog.
val previewUri = BundleCompat.getParcelable(requireArguments(), PREVIEW_URI_ARG, Uri::class.java) ?: error("Preview Uri is null")
val previewUri = arguments?.getParcelableCompat<Uri>(PREVIEW_URI_ARG) ?: 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)
@ -97,6 +102,23 @@ class CaptionDialog : DialogFragment() {
resource: Drawable,
transition: Transition<in Drawable>?
) {
if (resource is Animatable) {
resource.callback = object : Drawable.Callback {
override fun invalidateDrawable(who: Drawable) {
imageView.invalidate()
}
override fun scheduleDrawable(who: Drawable, what: Runnable, `when`: Long) {
imageView.postDelayed(what, `when`)
}
override fun unscheduleDrawable(who: Drawable, what: Runnable) {
imageView.removeCallbacks(what)
}
}
resource.start()
animatable = resource
}
imageView.setImageDrawable(resource)
}
@ -105,6 +127,7 @@ class CaptionDialog : DialogFragment() {
imageView.hide()
}
})
return binding.root
}
override fun onStart() {
@ -112,7 +135,7 @@ class CaptionDialog : DialogFragment() {
dialog?.apply {
window?.setLayout(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.MATCH_PARENT
LinearLayout.LayoutParams.WRAP_CONTENT
)
window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
}
@ -128,6 +151,12 @@ class CaptionDialog : DialogFragment() {
listener = context as? Listener ?: error("Activity is not ComposeCaptionDialog.Listener")
}
override fun onDestroyView() {
super.onDestroyView()
animatable?.stop()
(animatable as? Drawable?)?.callback = null
}
interface Listener {
fun onUpdateDescription(localId: Int, description: String)
}

View file

@ -20,7 +20,6 @@ import android.graphics.drawable.Drawable
import android.net.Uri
import android.view.WindowManager
import android.widget.FrameLayout
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
@ -30,6 +29,7 @@ import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.target.Target
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.keylesspalace.tusky.databinding.DialogFocusBinding
import com.keylesspalace.tusky.entity.Attachment.Focus
import kotlinx.coroutines.launch
@ -50,10 +50,10 @@ fun <T> T.makeFocusDialog(
.downsample(DownsampleStrategy.CENTER_INSIDE)
.listener(object : RequestListener<Drawable> {
override fun onLoadFailed(
p0: GlideException?,
p1: Any?,
p2: Target<Drawable?>,
p3: Boolean
error: GlideException?,
model: Any?,
target: Target<Drawable?>,
isFirstResource: Boolean
): Boolean {
return false
}
@ -68,15 +68,20 @@ fun <T> T.makeFocusDialog(
val width = resource.intrinsicWidth
val height = resource.intrinsicHeight
dialogBinding.focusIndicator.setImageSize(width, height)
val viewWidth = dialogBinding.imageView.width
val viewHeight = dialogBinding.imageView.height
val scaledHeight = (viewWidth.toFloat() / width.toFloat()) * height
dialogBinding.focusIndicator.setImageSize(viewWidth, scaledHeight.toInt())
// We want the dialog to be a little taller than the image, so you can slide your thumb past the image border,
// but if it's *too* much taller that looks weird. See if a threshold has been crossed:
if (width > height) {
val maxHeight = dialogBinding.focusIndicator.maxAttractiveHeight()
if (dialogBinding.imageView.height > maxHeight) {
val verticalShrinkLayout = FrameLayout.LayoutParams(width, maxHeight)
if (viewHeight > maxHeight) {
val verticalShrinkLayout = FrameLayout.LayoutParams(viewWidth, maxHeight)
dialogBinding.imageView.layoutParams = verticalShrinkLayout
dialogBinding.focusIndicator.layoutParams = verticalShrinkLayout
}
@ -93,7 +98,7 @@ fun <T> T.makeFocusDialog(
dialog.dismiss()
}
val dialog = AlertDialog.Builder(this)
val dialog = MaterialAlertDialogBuilder(this)
.setView(dialogBinding.root)
.setPositiveButton(android.R.string.ok, okListener)
.setNegativeButton(android.R.string.cancel, null)

View file

@ -14,12 +14,13 @@
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.compose.view
import android.annotation.SuppressLint
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.content.res.AppCompatResources
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.ContextCompat
import com.google.android.material.datepicker.CalendarConstraints
import com.google.android.material.datepicker.DateValidatorPointForward
import com.google.android.material.datepicker.MaterialDatePicker
@ -80,19 +81,16 @@ class ComposeScheduleView
}
val scheduled = scheduleDateTimeUtc!!.time
binding.scheduledDateTime.text = String.format(
"%s %s",
dateFormat.format(scheduled),
timeFormat.format(scheduled)
)
@SuppressLint("SetTextI18n")
binding.scheduledDateTime.text = "${dateFormat.format(scheduled)} ${timeFormat.format(scheduled)}"
verifyScheduledTime(scheduled)
}
private fun setEditIcons() {
val icon = ContextCompat.getDrawable(context, R.drawable.ic_create_24dp) ?: return
val icon = AppCompatResources.getDrawable(context, R.drawable.ic_create_24dp) ?: return
val size = binding.scheduledDateTime.lineHeight
icon.setBounds(0, 0, size, size)
binding.scheduledDateTime.setCompoundDrawables(null, null, icon, null)
binding.scheduledDateTime.setCompoundDrawablesRelative(null, null, icon, null)
}
fun setResetOnClickListener(listener: OnClickListener?) {

View file

@ -107,7 +107,7 @@ class FocusIndicatorView
val imageSize = this.imageSize
val focus = this.focus
if (imageSize != null && focus != null) {
if (imageSize != null && focus?.x != null && focus.y != null) {
val x = axisFromFocus(focus.x, imageSize.x, this.width)
val y = axisFromFocus(-focus.y, imageSize.y, this.height)
val circleRadius = getCircleRadius()

View file

@ -16,9 +16,12 @@
package com.keylesspalace.tusky.components.compose.view
import android.content.Context
import android.content.res.ColorStateList
import android.util.AttributeSet
import android.view.LayoutInflater
import android.widget.LinearLayout
import com.google.android.material.R as materialR
import com.google.android.material.card.MaterialCardView
import com.google.android.material.color.MaterialColors
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.PreviewPollOptionsAdapter
import com.keylesspalace.tusky.databinding.ViewPollPreviewBinding
@ -27,23 +30,18 @@ import com.keylesspalace.tusky.entity.NewPoll
class PollPreviewView @JvmOverloads constructor(
context: Context?,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
defStyleAttr: Int = materialR.attr.materialCardViewOutlinedStyle
) :
LinearLayout(context, attrs, defStyleAttr) {
MaterialCardView(context, attrs, defStyleAttr) {
private val adapter = PreviewPollOptionsAdapter()
private val binding = ViewPollPreviewBinding.inflate(LayoutInflater.from(context), this)
init {
orientation = VERTICAL
setBackgroundResource(R.drawable.card_frame)
val padding = resources.getDimensionPixelSize(R.dimen.poll_preview_padding)
setPadding(padding, padding, padding, padding)
setStrokeColor(ColorStateList.valueOf(MaterialColors.getColor(this, materialR.attr.colorOutline)))
strokeWidth
elevation = 0f
binding.pollPreviewOptions.adapter = adapter
}

View file

@ -24,6 +24,8 @@ import android.graphics.RectF
import android.util.AttributeSet
import androidx.appcompat.content.res.AppCompatResources
import at.connyduck.sparkbutton.helpers.Utils
import com.google.android.material.R as materialR
import com.google.android.material.color.MaterialColors
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.view.MediaPreviewImageView
@ -37,7 +39,7 @@ class ProgressImageView
private val progressRect = RectF()
private val biggerRect = RectF()
private val circlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = context.getColor(R.color.chinwag_green)
color = MaterialColors.getColor(this@ProgressImageView, materialR.attr.colorPrimary)
strokeWidth = Utils.dpToPx(context, 4).toFloat()
style = Paint.Style.STROKE
}
@ -46,13 +48,15 @@ class ProgressImageView
}
private val markBgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.FILL
color = context.getColor(R.color.tusky_grey_10)
color = MaterialColors.getColor(this@ProgressImageView, android.R.attr.colorBackground)
}
private val captionDrawable = AppCompatResources.getDrawable(
context,
R.drawable.spellcheck
)!!.apply {
setTint(Color.WHITE)
setTint(
MaterialColors.getColor(this@ProgressImageView, android.R.attr.textColorTertiary)
)
}
private val circleRadius = Utils.dpToPx(context, 14)
private val circleMargin = Utils.dpToPx(context, 14)
@ -68,8 +72,10 @@ class ProgressImageView
}
fun setChecked(checked: Boolean) {
markBgPaint.color =
context.getColor(if (checked) R.color.chinwag_green else R.color.tusky_grey_10)
val backgroundColor = if (checked) materialR.attr.colorPrimary else android.R.attr.colorBackground
val foregroundColor = if (checked) materialR.attr.colorOnPrimary else android.R.attr.textColorTertiary
markBgPaint.color = MaterialColors.getColor(this, backgroundColor)
captionDrawable.setTint(MaterialColors.getColor(this, foregroundColor))
invalidate()
}

View file

@ -38,9 +38,9 @@ class TootButton
init {
if (smallStyle) {
setIconResource(R.drawable.ic_send_24dp)
iconPadding = 0
} else {
setText(R.string.action_send)
iconGravity = ICON_GRAVITY_TEXT_START
}
val padding = resources.getDimensionPixelSize(R.dimen.toot_button_horizontal_padding)
setPadding(padding, 0, padding, 0)

View file

@ -48,13 +48,9 @@ class ConversationAdapter(
onBindViewHolder(holder, position, emptyList())
}
override fun onBindViewHolder(
holder: ConversationViewHolder,
position: Int,
payloads: List<Any>
) {
override fun onBindViewHolder(holder: ConversationViewHolder, position: Int, payloads: List<Any>) {
getItem(position)?.let { conversationViewData ->
holder.setupWithConversation(conversationViewData, payloads.firstOrNull())
holder.setupWithConversation(conversationViewData, payloads)
}
}
@ -80,7 +76,7 @@ class ConversationAdapter(
): Any? {
return if (oldItem == newItem) {
// If items are equal - update timestamp only
listOf(StatusBaseViewHolder.Key.KEY_CREATED)
StatusBaseViewHolder.Key.KEY_CREATED
} else {
// If items are different - update the whole view holder
null

View file

@ -126,7 +126,7 @@ data class ConversationStatusEntity(
visibility = Status.Visibility.DIRECT,
attachments = attachments,
mentions = mentions,
tags = tags,
tags = tags.orEmpty(),
application = null,
pinned = false,
muted = muted,
@ -148,7 +148,7 @@ fun TimelineAccount.toEntity() = ConversationAccountEntity(
username = username,
displayName = name,
avatar = avatar,
emojis = emojis.orEmpty()
emojis = emojis
)
fun Status.toEntity(expanded: Boolean, contentShowing: Boolean, contentCollapsed: Boolean) =

View file

@ -24,7 +24,6 @@ import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
import com.keylesspalace.tusky.R;
@ -69,30 +68,37 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
void setupWithConversation(
@NonNull ConversationViewData conversation,
@Nullable Object payloads
@NonNull List<Object> payloads
) {
StatusViewData.Concrete statusViewData = conversation.getLastStatus();
Status status = statusViewData.getStatus();
if (payloads == null) {
if (payloads.isEmpty()) {
TimelineAccount account = status.getAccount();
setupCollapsedState(statusViewData.isCollapsible(), statusViewData.isCollapsed(), statusViewData.isExpanded(), status.getSpoilerText(), listener);
setDisplayName(account.getDisplayName(), account.getEmojis(), statusDisplayOptions);
String displayName = account.getDisplayName();
if (displayName == null) {
displayName = "";
}
setDisplayName(displayName, account.getEmojis(), statusDisplayOptions);
setUsername(account.getUsername());
setMetaData(statusViewData, statusDisplayOptions, listener);
setIsReply(status.getInReplyToId() != null);
setFavourited(status.getFavourited());
setBookmarked(status.getBookmarked());
List<Attachment> attachments = status.getAttachments();
boolean sensitive = status.getSensitive();
if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) {
if (attachments.isEmpty()) {
mediaContainer.setVisibility(View.GONE);
} else if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) {
mediaContainer.setVisibility(View.VISIBLE);
setMediaPreviews(attachments, sensitive, listener, statusViewData.isShowingContent(),
statusDisplayOptions.useBlurhash());
if (attachments.size() == 0) {
if (attachments.isEmpty()) {
hideSensitiveMediaWarning();
}
// Hide the unused label.
@ -100,6 +106,8 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
mediaLabel.setVisibility(View.GONE);
}
} else {
mediaContainer.setVisibility(View.VISIBLE);
setMediaLabel(attachments, sensitive, listener, statusViewData.isShowingContent());
// Hide all unused views.
mediaPreview.setVisibility(View.GONE);
@ -115,11 +123,9 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
setAvatars(conversation.getAccounts());
} else {
if (payloads instanceof List) {
for (Object item : (List<?>) payloads) {
if (Key.KEY_CREATED.equals(item)) {
setMetaData(statusViewData, statusDisplayOptions, listener);
}
for (Object item : payloads) {
if (Key.KEY_CREATED.equals(item)) {
setMetaData(statusViewData, statusDisplayOptions, listener);
}
}
}

View file

@ -15,98 +15,80 @@
package com.keylesspalace.tusky.components.conversation
import android.content.SharedPreferences
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.PopupMenu
import androidx.core.view.MenuProvider
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.paging.LoadState
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.SimpleItemAnimator
import at.connyduck.sparkbutton.helpers.Utils
import com.google.android.material.color.MaterialColors
import com.google.android.material.dialog.MaterialAlertDialogBuilder
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
import com.keylesspalace.tusky.databinding.FragmentTimelineBinding
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.fragment.SFragment
import com.keylesspalace.tusky.interfaces.ActionButtonActivity
import com.keylesspalace.tusky.interfaces.ReselectableFragment
import com.keylesspalace.tusky.interfaces.StatusActionListener
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.CardViewMode
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.util.ensureBottomPadding
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.isAnyLoading
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.updateRelativeTimePeriodically
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.viewdata.AttachmentViewData
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.hilt.android.AndroidEntryPoint
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
@AndroidEntryPoint
class ConversationsFragment :
SFragment(),
SFragment(R.layout.fragment_timeline),
StatusActionListener,
Injectable,
ReselectableFragment,
MenuProvider {
@Inject
lateinit var viewModelFactory: ViewModelFactory
@Inject
lateinit var eventHub: EventHub
private val viewModel: ConversationsViewModel by viewModels { viewModelFactory }
@Inject
lateinit var preferences: SharedPreferences
private val viewModel: ConversationsViewModel by viewModels()
private val binding by viewBinding(FragmentTimelineBinding::bind)
private lateinit var adapter: ConversationAdapter
private var hideFab = false
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_timeline, container, false)
}
private var adapter: ConversationAdapter? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
val preferences = PreferenceManager.getDefaultSharedPreferences(view.context)
val statusDisplayOptions = StatusDisplayOptions(
animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false),
mediaPreviewEnabled = accountManager.activeAccount?.mediaPreviewEnabled ?: true,
mediaPreviewEnabled = accountManager.activeAccount?.mediaPreviewEnabled != false,
useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false),
showBotOverlay = preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true),
useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true),
@ -120,11 +102,12 @@ class ConversationsFragment :
openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler
)
adapter = ConversationAdapter(statusDisplayOptions, this)
val adapter = ConversationAdapter(statusDisplayOptions, this)
this.adapter = adapter
setupRecyclerView()
setupRecyclerView(adapter)
initSwipeToRefresh()
binding.swipeRefreshLayout.setOnRefreshListener { refreshContent() }
adapter.addLoadStateListener { loadState ->
if (loadState.refresh != LoadState.Loading && loadState.source.refresh != LoadState.Loading) {
@ -135,7 +118,7 @@ class ConversationsFragment :
binding.progressBar.hide()
if (loadState.isAnyLoading()) {
lifecycleScope.launch {
viewLifecycleOwner.lifecycleScope.launch {
eventHub.dispatch(
ConversationsLoadingEvent(
accountManager.activeAccount?.accountId ?: ""
@ -174,7 +157,8 @@ class ConversationsFragment :
adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() {
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
if (positionStart == 0 && adapter.itemCount != itemCount) {
val firstPos = (binding.recyclerView.layoutManager as LinearLayoutManager).findFirstCompletelyVisibleItemPosition()
if (firstPos == 0 && positionStart == 0 && adapter.itemCount != itemCount) {
binding.recyclerView.post {
if (getView() != null) {
binding.recyclerView.scrollBy(0, Utils.dpToPx(requireContext(), -30))
@ -184,53 +168,29 @@ class ConversationsFragment :
}
})
hideFab = preferences.getBoolean(PrefKeys.FAB_HIDE, false)
binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) {
val composeButton = (activity as ActionButtonActivity).actionButton
if (composeButton != null) {
if (hideFab) {
if (dy > 0 && composeButton.isShown) {
composeButton.hide() // hides the button if we're scrolling down
} else if (dy < 0 && !composeButton.isShown) {
composeButton.show() // shows it if we are scrolling up
}
} else if (!composeButton.isShown) {
composeButton.show()
}
}
}
})
viewLifecycleOwner.lifecycleScope.launch {
viewModel.conversationFlow.collectLatest { pagingData ->
adapter.submitData(pagingData)
}
}
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
val useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false)
while (!useAbsoluteTime) {
adapter.notifyItemRangeChanged(
0,
adapter.itemCount,
listOf(StatusBaseViewHolder.Key.KEY_CREATED)
)
delay(1.toDuration(DurationUnit.MINUTES))
}
}
}
updateRelativeTimePeriodically(preferences, adapter)
lifecycleScope.launch {
viewLifecycleOwner.lifecycleScope.launch {
eventHub.events.collect { event ->
if (event is PreferenceChangedEvent) {
onPreferenceChanged(event.preferenceKey)
onPreferenceChanged(adapter, event.preferenceKey)
}
}
}
}
override fun onDestroyView() {
// Clear the adapter to prevent leaking the View
adapter = null
super.onDestroyView()
}
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.fragment_conversations, menu)
menu.findItem(R.id.action_refresh)?.apply {
@ -253,7 +213,8 @@ class ConversationsFragment :
}
}
private fun setupRecyclerView() {
private fun setupRecyclerView(adapter: ConversationAdapter) {
binding.recyclerView.ensureBottomPadding(fab = true)
binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.layoutManager = LinearLayoutManager(context)
@ -268,26 +229,21 @@ class ConversationsFragment :
}
private fun refreshContent() {
adapter.refresh()
adapter?.refresh()
}
private fun initSwipeToRefresh() {
binding.swipeRefreshLayout.setOnRefreshListener { refreshContent() }
binding.swipeRefreshLayout.setColorSchemeResources(R.color.chinwag_green)
}
override fun onReblog(reblog: Boolean, position: Int) {
override fun onReblog(reblog: Boolean, position: Int, visibility: Status.Visibility) {
// its impossible to reblog private messages
}
override fun onFavourite(favourite: Boolean, position: Int) {
adapter.peek(position)?.let { conversation ->
adapter?.peek(position)?.let { conversation ->
viewModel.favourite(favourite, conversation)
}
}
override fun onBookmark(favourite: Boolean, position: Int) {
adapter.peek(position)?.let { conversation ->
adapter?.peek(position)?.let { conversation ->
viewModel.bookmark(favourite, conversation)
}
}
@ -295,7 +251,7 @@ class ConversationsFragment :
override val onMoreTranslate: ((translate: Boolean, position: Int) -> Unit)? = null
override fun onMore(view: View, position: Int) {
adapter.peek(position)?.let { conversation ->
adapter?.peek(position)?.let { conversation ->
val popup = PopupMenu(requireContext(), view)
popup.inflate(R.menu.conversation_more)
@ -319,17 +275,17 @@ class ConversationsFragment :
}
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
adapter.peek(position)?.let { conversation ->
adapter?.peek(position)?.let { conversation ->
viewMedia(
attachmentIndex,
AttachmentViewData.list(conversation.lastStatus.status),
AttachmentViewData.list(conversation.lastStatus),
view
)
}
}
override fun onViewThread(position: Int) {
adapter.peek(position)?.let { conversation ->
adapter?.peek(position)?.let { conversation ->
viewThread(conversation.lastStatus.id, conversation.lastStatus.status.url)
}
}
@ -339,13 +295,13 @@ class ConversationsFragment :
}
override fun onExpandedChange(expanded: Boolean, position: Int) {
adapter.peek(position)?.let { conversation ->
adapter?.peek(position)?.let { conversation ->
viewModel.expandHiddenStatus(expanded, conversation)
}
}
override fun onContentHiddenChange(isShowing: Boolean, position: Int) {
adapter.peek(position)?.let { conversation ->
adapter?.peek(position)?.let { conversation ->
viewModel.showContent(isShowing, conversation)
}
}
@ -355,7 +311,7 @@ class ConversationsFragment :
}
override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) {
adapter.peek(position)?.let { conversation ->
adapter?.peek(position)?.let { conversation ->
viewModel.collapseLongStatus(isCollapsed, conversation)
}
}
@ -375,13 +331,13 @@ class ConversationsFragment :
}
override fun onReply(position: Int) {
adapter.peek(position)?.let { conversation ->
adapter?.peek(position)?.let { conversation ->
reply(conversation.lastStatus.status)
}
}
override fun onVoteInPoll(position: Int, choices: MutableList<Int>) {
adapter.peek(position)?.let { conversation ->
override fun onVoteInPoll(position: Int, choices: List<Int>) {
adapter?.peek(position)?.let { conversation ->
viewModel.voteInPoll(choices, conversation)
}
}
@ -390,7 +346,7 @@ class ConversationsFragment :
}
override fun onReselect() {
if (isAdded) {
if (view != null) {
binding.recyclerView.layoutManager?.scrollToPosition(0)
binding.recyclerView.stopScroll()
}
@ -401,7 +357,7 @@ class ConversationsFragment :
}
private fun deleteConversation(conversation: ConversationViewData) {
AlertDialog.Builder(requireContext())
MaterialAlertDialogBuilder(requireContext())
.setMessage(R.string.dialog_delete_conversation_warning)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(android.R.string.ok) { _, _ ->
@ -410,13 +366,8 @@ class ConversationsFragment :
.show()
}
private fun onPreferenceChanged(key: String) {
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
private fun onPreferenceChanged(adapter: ConversationAdapter, key: String) {
when (key) {
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

@ -5,7 +5,6 @@ import androidx.paging.LoadType
import androidx.paging.PagingState
import androidx.paging.RemoteMediator
import androidx.room.withTransaction
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.HttpHeaderLink
@ -15,19 +14,22 @@ import retrofit2.HttpException
class ConversationsRemoteMediator(
private val api: MastodonApi,
private val db: AppDatabase,
accountManager: AccountManager
private val viewModel: ConversationsViewModel
) : RemoteMediator<Int, ConversationEntity>() {
private var nextKey: String? = null
private var order: Int = 0
private val activeAccount = accountManager.activeAccount!!
override suspend fun load(
loadType: LoadType,
state: PagingState<Int, ConversationEntity>
): MediatorResult {
val activeAccount = viewModel.activeAccountFlow.value
if (activeAccount == null) {
return MediatorResult.Success(endOfPaginationReached = true)
}
if (loadType == LoadType.PREPEND) {
return MediatorResult.Success(endOfPaginationReached = true)
}

View file

@ -28,29 +28,30 @@ import com.keylesspalace.tusky.db.AccountManager
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 dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
@HiltViewModel
class ConversationsViewModel @Inject constructor(
private val timelineCases: TimelineCases,
private val database: AppDatabase,
private val accountManager: AccountManager,
private val api: MastodonApi
private val api: MastodonApi,
accountManager: AccountManager
) : ViewModel() {
val activeAccountFlow = accountManager.activeAccount(viewModelScope)
private val accountId: Long = activeAccountFlow.value!!.id
@OptIn(ExperimentalPagingApi::class)
val conversationFlow = Pager(
config = PagingConfig(pageSize = 30),
remoteMediator = ConversationsRemoteMediator(api, database, accountManager),
config = PagingConfig(
pageSize = 30
),
remoteMediator = ConversationsRemoteMediator(api, database, this),
pagingSourceFactory = {
val activeAccount = accountManager.activeAccount
if (activeAccount == null) {
EmptyPagingSource()
} else {
database.conversationDao().conversationsForAccount(activeAccount.id)
}
database.conversationDao().conversationsForAccount(accountId)
}
)
.flow
@ -63,7 +64,7 @@ class ConversationsViewModel @Inject constructor(
viewModelScope.launch {
timelineCases.favourite(conversation.lastStatus.id, favourite).fold({
val newConversation = conversation.toEntity(
accountId = accountManager.activeAccount!!.id,
accountId = accountId,
favourited = favourite
)
@ -78,7 +79,7 @@ class ConversationsViewModel @Inject constructor(
viewModelScope.launch {
timelineCases.bookmark(conversation.lastStatus.id, bookmark).fold({
val newConversation = conversation.toEntity(
accountId = accountManager.activeAccount!!.id,
accountId = accountId,
bookmarked = bookmark
)
@ -98,7 +99,7 @@ class ConversationsViewModel @Inject constructor(
)
.fold({ poll ->
val newConversation = conversation.toEntity(
accountId = accountManager.activeAccount!!.id,
accountId = accountId,
poll = poll
)
@ -112,7 +113,7 @@ class ConversationsViewModel @Inject constructor(
fun expandHiddenStatus(expanded: Boolean, conversation: ConversationViewData) {
viewModelScope.launch {
val newConversation = conversation.toEntity(
accountId = accountManager.activeAccount!!.id,
accountId = accountId,
expanded = expanded
)
saveConversationToDb(newConversation)
@ -122,7 +123,7 @@ class ConversationsViewModel @Inject constructor(
fun collapseLongStatus(collapsed: Boolean, conversation: ConversationViewData) {
viewModelScope.launch {
val newConversation = conversation.toEntity(
accountId = accountManager.activeAccount!!.id,
accountId = accountId,
collapsed = collapsed
)
saveConversationToDb(newConversation)
@ -132,7 +133,7 @@ class ConversationsViewModel @Inject constructor(
fun showContent(showing: Boolean, conversation: ConversationViewData) {
viewModelScope.launch {
val newConversation = conversation.toEntity(
accountId = accountManager.activeAccount!!.id,
accountId = accountId,
showingHiddenContent = showing
)
saveConversationToDb(newConversation)
@ -146,7 +147,7 @@ class ConversationsViewModel @Inject constructor(
database.conversationDao().delete(
id = conversation.id,
accountId = accountManager.activeAccount!!.id
accountId = accountId
)
} catch (e: Exception) {
Log.w(TAG, "failed to delete conversation", e)
@ -163,7 +164,7 @@ class ConversationsViewModel @Inject constructor(
)
val newConversation = conversation.toEntity(
accountId = accountManager.activeAccount!!.id,
accountId = accountId,
muted = !conversation.lastStatus.status.muted
)

View file

@ -4,14 +4,10 @@ import android.os.Bundle
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ActivityAccountListBinding
import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
import javax.inject.Inject
import dagger.hilt.android.AndroidEntryPoint
class DomainBlocksActivity : BaseActivity(), HasAndroidInjector {
@Inject
lateinit var androidInjector: DispatchingAndroidInjector<Any>
@AndroidEntryPoint
class DomainBlocksActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -30,6 +26,4 @@ class DomainBlocksActivity : BaseActivity(), HasAndroidInjector {
.replace(R.id.fragment_container, DomainBlocksFragment())
.commit()
}
override fun androidInjector() = androidInjector
}

View file

@ -12,28 +12,27 @@ 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.ensureBottomPadding
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 dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
class DomainBlocksFragment : Fragment(R.layout.fragment_domain_blocks), Injectable {
@Inject
lateinit var viewModelFactory: ViewModelFactory
@AndroidEntryPoint
class DomainBlocksFragment : Fragment(R.layout.fragment_domain_blocks) {
private val binding by viewBinding(FragmentDomainBlocksBinding::bind)
private val viewModel: DomainBlocksViewModel by viewModels { viewModelFactory }
private val viewModel: DomainBlocksViewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val adapter = DomainBlocksAdapter(viewModel::unblock)
binding.recyclerView.ensureBottomPadding()
binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.addItemDecoration(
DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL)
@ -47,7 +46,7 @@ class DomainBlocksFragment : Fragment(R.layout.fragment_domain_blocks), Injectab
}
}
lifecycleScope.launch {
viewLifecycleOwner.lifecycleScope.launch {
viewModel.domainPager.collectLatest { pagingData ->
adapter.submitData(pagingData)
}

View file

@ -39,7 +39,10 @@ class DomainBlocksRepository @Inject constructor(
@OptIn(ExperimentalPagingApi::class)
val domainPager = Pager(
config = PagingConfig(pageSize = PAGE_SIZE, initialLoadSize = PAGE_SIZE),
config = PagingConfig(
pageSize = PAGE_SIZE,
initialLoadSize = PAGE_SIZE
),
remoteMediator = DomainBlocksRemoteMediator(api, this),
pagingSourceFactory = factory
).flow

View file

@ -8,12 +8,14 @@ import androidx.paging.cachedIn
import at.connyduck.calladapter.networkresult.fold
import at.connyduck.calladapter.networkresult.onFailure
import com.keylesspalace.tusky.R
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
@HiltViewModel
class DomainBlocksViewModel @Inject constructor(
private val repo: DomainBlocksRepository
) : ViewModel() {

View file

@ -23,12 +23,13 @@ import androidx.core.content.FileProvider
import androidx.core.net.toUri
import com.keylesspalace.tusky.BuildConfig
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.DraftAttachment
import com.keylesspalace.tusky.db.DraftEntity
import com.keylesspalace.tusky.db.entity.DraftAttachment
import com.keylesspalace.tusky.db.entity.DraftEntity
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.NewPoll
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.util.copyToFile
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.File
import java.io.IOException
import java.text.SimpleDateFormat
@ -43,7 +44,7 @@ import okio.buffer
import okio.sink
class DraftHelper @Inject constructor(
val context: Context,
@ApplicationContext val context: Context,
private val okHttpClient: OkHttpClient,
db: AppDatabase
) {
@ -177,7 +178,7 @@ class DraftHelper @Inject constructor(
map.getExtensionFromMimeType(mimeType)
}
val filename = String.format("Tusky_Draft_Media_%s_%d.%s", timeStamp, index, fileExtension)
val filename = "Tusky_Draft_Media_${timeStamp}_$index.$fileExtension"
val file = File(folder, filename)
if (scheme == "https") {

View file

@ -24,7 +24,7 @@ import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.db.DraftAttachment
import com.keylesspalace.tusky.db.entity.DraftAttachment
import com.keylesspalace.tusky.view.MediaPreviewImageView
class DraftMediaAdapter(

View file

@ -32,25 +32,24 @@ import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.compose.ComposeActivity
import com.keylesspalace.tusky.databinding.ActivityDraftsBinding
import com.keylesspalace.tusky.db.DraftEntity
import com.keylesspalace.tusky.db.DraftsAlert
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.db.entity.DraftEntity
import com.keylesspalace.tusky.util.ensureBottomPadding
import com.keylesspalace.tusky.util.isHttpNotFound
import com.keylesspalace.tusky.util.parseAsMastodonHtml
import com.keylesspalace.tusky.util.visible
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
@AndroidEntryPoint
class DraftsActivity : BaseActivity(), DraftActionListener {
@Inject
lateinit var viewModelFactory: ViewModelFactory
@Inject
lateinit var draftsAlert: DraftsAlert
private val viewModel: DraftsViewModel by viewModels { viewModelFactory }
private val viewModel: DraftsViewModel by viewModels()
private lateinit var binding: ActivityDraftsBinding
private lateinit var bottomSheet: BottomSheetBehavior<LinearLayout>
@ -68,6 +67,8 @@ class DraftsActivity : BaseActivity(), DraftActionListener {
setDisplayShowHomeEnabled(true)
}
binding.draftsRecyclerView.ensureBottomPadding()
binding.draftsErrorMessageView.setup(R.drawable.elephant_friend_empty, R.string.no_drafts)
val adapter = DraftsAdapter(this)

View file

@ -22,7 +22,7 @@ import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.databinding.ItemDraftBinding
import com.keylesspalace.tusky.db.DraftEntity
import com.keylesspalace.tusky.db.entity.DraftEntity
import com.keylesspalace.tusky.util.BindingHolder
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show

View file

@ -23,12 +23,14 @@ import androidx.paging.cachedIn
import at.connyduck.calladapter.networkresult.NetworkResult
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.DraftEntity
import com.keylesspalace.tusky.db.entity.DraftEntity
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.launch
@HiltViewModel
class DraftsViewModel @Inject constructor(
val database: AppDatabase,
val accountManager: AccountManager,
@ -37,7 +39,9 @@ class DraftsViewModel @Inject constructor(
) : ViewModel() {
val drafts = Pager(
config = PagingConfig(pageSize = 20),
config = PagingConfig(
pageSize = 20
),
pagingSourceFactory = {
database.draftDao().draftsPagingSource(
accountManager.activeAccount?.id!!

View file

@ -1,38 +1,54 @@
/* Copyright 2024 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.filters
import android.content.Context
import android.content.DialogInterface.BUTTON_POSITIVE
import android.os.Bundle
import android.view.View
import android.view.WindowManager
import android.widget.AdapterView
import android.widget.ArrayAdapter
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.core.content.IntentCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat.Type.systemBars
import androidx.core.view.size
import androidx.core.view.updatePadding
import androidx.core.widget.doAfterTextChanged
import androidx.lifecycle.lifecycleScope
import at.connyduck.calladapter.networkresult.fold
import com.google.android.material.chip.Chip
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.materialswitch.MaterialSwitch
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.switchmaterial.SwitchMaterial
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.FilterUpdatedEvent
import com.keylesspalace.tusky.databinding.ActivityEditFilterBinding
import com.keylesspalace.tusky.databinding.DialogFilterBinding
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.FilterKeyword
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.getParcelableExtraCompat
import com.keylesspalace.tusky.util.isHttpNotFound
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible
import java.util.Date
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.launch
@AndroidEntryPoint
class EditFilterActivity : BaseActivity() {
@Inject
lateinit var api: MastodonApi
@ -40,20 +56,17 @@ class EditFilterActivity : BaseActivity() {
@Inject
lateinit var eventHub: EventHub
@Inject
lateinit var viewModelFactory: ViewModelFactory
private val binding by viewBinding(ActivityEditFilterBinding::inflate)
private val viewModel: EditFilterViewModel by viewModels { viewModelFactory }
private val viewModel: EditFilterViewModel by viewModels()
private lateinit var filter: Filter
private var originalFilter: Filter? = null
private lateinit var contextSwitches: Map<SwitchMaterial, Filter.Kind>
private lateinit var contextSwitches: Map<MaterialSwitch, Filter.Kind>
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
originalFilter = IntentCompat.getParcelableExtra(intent, FILTER_TO_EDIT, Filter::class.java)
originalFilter = intent.getParcelableExtraCompat(FILTER_TO_EDIT)
filter = originalFilter ?: Filter("", "", listOf(), null, Filter.Action.WARN.action, listOf())
binding.apply {
contextSwitches = mapOf(
@ -81,6 +94,12 @@ class EditFilterActivity : BaseActivity() {
}
)
ViewCompat.setOnApplyWindowInsetsListener(binding.scrollView) { scrollView, insets ->
val systemBarsInsets = insets.getInsets(systemBars())
scrollView.updatePadding(bottom = systemBarsInsets.bottom)
insets.inset(0, 0, 0, systemBarsInsets.bottom)
}
binding.actionChip.setOnClickListener { showAddKeywordDialog() }
binding.filterSaveButton.setOnClickListener { saveChanges() }
binding.filterDeleteButton.setOnClickListener {
@ -114,30 +133,20 @@ class EditFilterActivity : BaseActivity() {
}
)
}
binding.filterDurationSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(
parent: AdapterView<*>?,
view: View?,
position: Int,
id: Long
) {
viewModel.setDuration(
if (originalFilter?.expiresAt == null) {
position
} else {
position - 1
}
)
}
override fun onNothingSelected(parent: AdapterView<*>?) {
viewModel.setDuration(0)
}
binding.filterDurationDropDown.onItemClickListener = AdapterView.OnItemClickListener { _, _, position, _ ->
viewModel.setDuration(
if (originalFilter?.expiresAt == null) {
position
} else {
position - 1
}
)
}
validateSaveButton()
if (originalFilter == null) {
binding.filterActionWarn.isChecked = true
initializeDurationDropDown(false)
} else {
loadFilter()
}
@ -179,10 +188,17 @@ class EditFilterActivity : BaseActivity() {
// Populate the UI from the filter's members
private fun loadFilter() {
viewModel.load(filter)
if (filter.expiresAt != null) {
val durationNames = listOf(getString(R.string.duration_no_change)) + resources.getStringArray(R.array.filter_duration_names)
binding.filterDurationSpinner.adapter = ArrayAdapter(this, android.R.layout.simple_list_item_1, durationNames)
initializeDurationDropDown(withNoChange = filter.expiresAt != null)
}
private fun initializeDurationDropDown(withNoChange: Boolean) {
val durationNames = if (withNoChange) {
arrayOf(getString(R.string.duration_no_change)) + resources.getStringArray(R.array.filter_duration_names)
} else {
resources.getStringArray(R.array.filter_duration_names)
}
binding.filterDurationDropDown.setSimpleItems(durationNames)
binding.filterDurationDropDown.setText(durationNames[0], false)
}
private fun updateKeywords(newKeywords: List<FilterKeyword>) {
@ -223,7 +239,7 @@ class EditFilterActivity : BaseActivity() {
private fun showAddKeywordDialog() {
val binding = DialogFilterBinding.inflate(layoutInflater)
binding.phraseWholeWord.isChecked = true
AlertDialog.Builder(this)
val dialog = MaterialAlertDialogBuilder(this)
.setTitle(R.string.filter_keyword_addition_title)
.setView(binding.root)
.setPositiveButton(android.R.string.ok) { _, _ ->
@ -237,6 +253,12 @@ class EditFilterActivity : BaseActivity() {
}
.setNegativeButton(android.R.string.cancel, null)
.show()
dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
val editText = binding.phraseEditText
editText.requestFocus()
editText.setSelection(editText.length())
}
private fun showEditKeywordDialog(keyword: FilterKeyword) {
@ -244,7 +266,7 @@ class EditFilterActivity : BaseActivity() {
binding.phraseEditText.setText(keyword.keyword)
binding.phraseWholeWord.isChecked = keyword.wholeWord
AlertDialog.Builder(this)
val dialog = MaterialAlertDialogBuilder(this)
.setTitle(R.string.filter_edit_keyword_title)
.setView(binding.root)
.setPositiveButton(R.string.filter_dialog_update_button) { _, _ ->
@ -258,6 +280,12 @@ class EditFilterActivity : BaseActivity() {
}
.setNegativeButton(android.R.string.cancel, null)
.show()
dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
val editText = binding.phraseEditText
editText.requestFocus()
editText.setSelection(editText.length())
}
private fun validateSaveButton() {
@ -278,7 +306,7 @@ class EditFilterActivity : BaseActivity() {
} else {
Snackbar.make(
binding.root,
"Error saving filter '${viewModel.title.value}'",
getString(R.string.error_deleting_filter, viewModel.title.value),
Snackbar.LENGTH_SHORT
).show()
}
@ -301,7 +329,7 @@ class EditFilterActivity : BaseActivity() {
{
Snackbar.make(
binding.root,
"Error deleting filter '${filter.title}'",
getString(R.string.error_deleting_filter, filter.title),
Snackbar.LENGTH_SHORT
).show()
}
@ -309,7 +337,7 @@ class EditFilterActivity : BaseActivity() {
} else {
Snackbar.make(
binding.root,
"Error deleting filter '${filter.title}'",
getString(R.string.error_deleting_filter, filter.title),
Snackbar.LENGTH_SHORT
).show()
}
@ -321,19 +349,5 @@ class EditFilterActivity : BaseActivity() {
companion object {
const val FILTER_TO_EDIT = "FilterToEdit"
// Mastodon *stores* the absolute date in the filter,
// but create/edit take a number of seconds (relative to the time the operation is posted)
fun getSecondsForDurationIndex(index: Int, context: Context?, default: Date? = null): Int? {
return when (index) {
-1 -> if (default == null) {
default
} else {
((default.time - System.currentTimeMillis()) / 1000).toInt()
}
0 -> null
else -> context?.resources?.getIntArray(R.array.filter_duration_values)?.get(index)
}
}
}
}

View file

@ -1,21 +1,38 @@
/* Copyright 2024 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.filters
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.connyduck.calladapter.networkresult.fold
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.FilterKeyword
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.isHttpNotFound
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub: EventHub) : ViewModel() {
@HiltViewModel
class EditFilterViewModel @Inject constructor(val api: MastodonApi) : ViewModel() {
private var originalFilter: Filter? = null
private val _title = MutableStateFlow("")
@ -111,12 +128,12 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub
durationIndex: Int,
context: Context
): Boolean {
val expiresInSeconds = EditFilterActivity.getSecondsForDurationIndex(durationIndex, context)
val expiration = getExpirationForDurationIndex(durationIndex, context)
api.createFilter(
title = title,
context = contexts,
filterAction = action,
expiresInSeconds = expiresInSeconds
expiresIn = expiration
).fold(
{ newFilter ->
// This is _terrible_, but the all-in-one update filter api Just Doesn't Work
@ -132,7 +149,7 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub
return (
throwable.isHttpNotFound() &&
// Endpoint not found, fall back to v1 api
createFilterV1(contexts, expiresInSeconds)
createFilterV1(contexts, expiration)
)
}
)
@ -146,13 +163,13 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub
durationIndex: Int,
context: Context
): Boolean {
val expiresInSeconds = EditFilterActivity.getSecondsForDurationIndex(durationIndex, context)
val expiration = getExpirationForDurationIndex(durationIndex, context)
api.updateFilter(
id = originalFilter.id,
title = title,
context = contexts,
filterAction = action,
expiresInSeconds = expiresInSeconds
expires = expiration
).fold(
{
// This is _terrible_, but the all-in-one update filter api Just Doesn't Work
@ -172,7 +189,7 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub
{ throwable ->
if (throwable.isHttpNotFound()) {
// Endpoint not found, fall back to v1 api
if (updateFilterV1(contexts, expiresInSeconds)) {
if (updateFilterV1(contexts, expiration)) {
return true
}
}
@ -181,13 +198,13 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub
)
}
private suspend fun createFilterV1(context: List<String>, expiresInSeconds: Int?): Boolean {
private suspend fun createFilterV1(context: List<String>, expiration: FilterExpiration?): Boolean {
return _keywords.value.map { keyword ->
api.createFilterV1(keyword.keyword, context, false, keyword.wholeWord, expiresInSeconds)
api.createFilterV1(keyword.keyword, context, false, keyword.wholeWord, expiration)
}.none { it.isFailure }
}
private suspend fun updateFilterV1(context: List<String>, expiresInSeconds: Int?): Boolean {
private suspend fun updateFilterV1(context: List<String>, expiration: FilterExpiration?): Boolean {
val results = _keywords.value.map { keyword ->
if (originalFilter == null) {
api.createFilterV1(
@ -195,7 +212,7 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub
context = context,
irreversible = false,
wholeWord = keyword.wholeWord,
expiresInSeconds = expiresInSeconds
expiresIn = expiration
)
} else {
api.updateFilterV1(
@ -204,7 +221,7 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub
context = context,
irreversible = false,
wholeWord = keyword.wholeWord,
expiresInSeconds = expiresInSeconds
expiresIn = expiration
)
}
}
@ -212,4 +229,18 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub
return results.none { it.isFailure }
}
companion object {
// Mastodon *stores* the absolute date in the filter,
// but create/edit take a number of seconds (relative to the time the operation is posted)
private fun getExpirationForDurationIndex(index: Int, context: Context): FilterExpiration? {
return when (index) {
-1 -> FilterExpiration.unchanged
0 -> FilterExpiration.never
else -> FilterExpiration.seconds(
context.resources.getIntArray(R.array.filter_duration_values)[index]
)
}
}
}
}

View file

@ -0,0 +1,37 @@
/* Copyright 2024 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.filters
import kotlin.jvm.JvmInline
/**
* Custom class to have typesafety for filter expirations.
* Retrofit will call toString when sending this class as part of a form-urlencoded body.
*/
@JvmInline
value class FilterExpiration private constructor(val seconds: Int) {
override fun toString(): String {
return if (seconds < 0) "" else seconds.toString()
}
companion object {
val unchanged: FilterExpiration? = null
val never: FilterExpiration = FilterExpiration(-1)
fun seconds(seconds: Int): FilterExpiration = FilterExpiration(seconds)
}
}

View file

@ -18,14 +18,14 @@
package com.keylesspalace.tusky.components.filters
import android.app.Activity
import androidx.appcompat.app.AlertDialog
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.util.await
internal suspend fun Activity.showDeleteFilterDialog(filterTitle: String) = AlertDialog.Builder(
this
)
.setMessage(getString(R.string.dialog_delete_filter_text, filterTitle))
.setCancelable(true)
.create()
.await(R.string.dialog_delete_filter_positive_action, android.R.string.cancel)
internal suspend fun Activity.showDeleteFilterDialog(filterTitle: String): Int {
return MaterialAlertDialogBuilder(this)
.setMessage(getString(R.string.dialog_delete_filter_text, filterTitle))
.setCancelable(true)
.create()
.await(R.string.dialog_delete_filter_positive_action, android.R.string.cancel)
}

View file

@ -3,27 +3,36 @@ package com.keylesspalace.tusky.components.filters
import android.content.DialogInterface.BUTTON_POSITIVE
import android.content.Intent
import android.os.Bundle
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.DividerItemDecoration
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ActivityFiltersBinding
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.util.ensureBottomMargin
import com.keylesspalace.tusky.util.ensureBottomPadding
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.launchAndRepeatOnLifecycle
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 javax.inject.Inject
import com.keylesspalace.tusky.util.withSlideInAnimation
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
@AndroidEntryPoint
class FiltersActivity : BaseActivity(), FiltersListener {
@Inject
lateinit var viewModelFactory: ViewModelFactory
private val binding by viewBinding(ActivityFiltersBinding::inflate)
private val viewModel: FiltersViewModel by viewModels { viewModelFactory }
private val viewModel: FiltersViewModel by viewModels()
private val editFilterLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
// refresh the filters upon returning from EditFilterActivity
reloadFilters()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -36,24 +45,26 @@ class FiltersActivity : BaseActivity(), FiltersListener {
setDisplayShowHomeEnabled(true)
}
binding.filtersList.ensureBottomPadding(fab = true)
binding.addFilterButton.ensureBottomMargin()
binding.addFilterButton.setOnClickListener {
launchEditFilterActivity()
}
binding.swipeRefreshLayout.setOnRefreshListener { loadFilters() }
binding.swipeRefreshLayout.setColorSchemeResources(R.color.chinwag_green)
binding.swipeRefreshLayout.setOnRefreshListener { reloadFilters() }
setTitle(R.string.pref_title_timeline_filters)
}
override fun onResume() {
super.onResume()
loadFilters()
binding.filtersList.addItemDecoration(
DividerItemDecoration(this, DividerItemDecoration.VERTICAL)
)
observeViewModel()
}
private fun observeViewModel() {
lifecycleScope.launch {
launchAndRepeatOnLifecycle {
viewModel.state.collect { state ->
binding.progressBar.visible(
state.loadingState == FiltersViewModel.LoadingState.LOADING
@ -70,7 +81,7 @@ class FiltersActivity : BaseActivity(), FiltersListener {
R.drawable.errorphant_offline,
R.string.error_network
) {
loadFilters()
reloadFilters()
}
binding.messageView.show()
}
@ -79,7 +90,7 @@ class FiltersActivity : BaseActivity(), FiltersListener {
R.drawable.errorphant_error,
R.string.error_generic
) {
loadFilters()
reloadFilters()
}
binding.messageView.show()
}
@ -101,8 +112,8 @@ class FiltersActivity : BaseActivity(), FiltersListener {
}
}
private fun loadFilters() {
viewModel.load()
private fun reloadFilters() {
viewModel.reload()
}
private fun launchEditFilterActivity(filter: Filter? = null) {
@ -110,8 +121,8 @@ class FiltersActivity : BaseActivity(), FiltersListener {
if (filter != null) {
putExtra(EditFilterActivity.FILTER_TO_EDIT, filter)
}
}
startActivityWithSlideInAnimation(intent)
}.withSlideInAnimation()
editFilterLauncher.launch(intent)
}
override fun deleteFilter(filter: Filter) {

View file

@ -1,21 +1,28 @@
package com.keylesspalace.tusky.components.filters
import android.util.Log
import android.view.View
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.connyduck.calladapter.networkresult.fold
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.appstore.FilterUpdatedEvent
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.isHttpNotFound
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@HiltViewModel
class FiltersViewModel @Inject constructor(
private val api: MastodonApi,
private val eventHub: EventHub
@ -34,76 +41,93 @@ class FiltersViewModel @Inject constructor(
private val _state = MutableStateFlow(State(emptyList(), LoadingState.INITIAL))
val state: StateFlow<State> = _state.asStateFlow()
fun load() {
this@FiltersViewModel._state.value = _state.value.copy(loadingState = LoadingState.LOADING)
private val loadTrigger = MutableStateFlow(0)
init {
viewModelScope.launch {
observeLoad()
}
}
private suspend fun observeLoad() {
loadTrigger.collectLatest {
_state.update { it.copy(loadingState = LoadingState.LOADING) }
api.getFilters().fold(
{ filters ->
this@FiltersViewModel._state.value = State(filters, LoadingState.LOADED)
_state.value = State(filters, LoadingState.LOADED)
},
{ throwable ->
if (throwable.isHttpNotFound()) {
Log.i(TAG, "failed loading filters v2, falling back to v1", throwable)
api.getFiltersV1().fold(
{ filters ->
this@FiltersViewModel._state.value = State(filters.map { it.toFilter() }, LoadingState.LOADED)
_state.value = State(filters.map { it.toFilter() }, LoadingState.LOADED)
},
{ _ ->
// TODO log errors (also below)
this@FiltersViewModel._state.value = _state.value.copy(loadingState = LoadingState.ERROR_OTHER)
{ t ->
Log.w(TAG, "failed loading filters v1", t)
_state.value = State(emptyList(), LoadingState.ERROR_OTHER)
}
)
this@FiltersViewModel._state.value = _state.value.copy(loadingState = LoadingState.ERROR_OTHER)
} else {
this@FiltersViewModel._state.value = _state.value.copy(loadingState = LoadingState.ERROR_NETWORK)
Log.w(TAG, "failed loading filters v2", throwable)
_state.update { it.copy(loadingState = LoadingState.ERROR_NETWORK) }
}
}
)
}
}
fun deleteFilter(filter: Filter, parent: View) {
viewModelScope.launch {
api.deleteFilter(filter.id).fold(
{
this@FiltersViewModel._state.value = State(
this@FiltersViewModel._state.value.filters.filter {
it.id != filter.id
},
fun reload() {
loadTrigger.update { it + 1 }
}
suspend fun deleteFilter(filter: Filter, parent: View) {
// First wait for a pending loading operation to complete
_state.first { it.loadingState > LoadingState.LOADING }
api.deleteFilter(filter.id).fold(
{
_state.update { currentState ->
State(
currentState.filters.filter { it.id != filter.id },
LoadingState.LOADED
)
for (context in filter.context) {
eventHub.dispatch(PreferenceChangedEvent(context))
}
},
{ throwable ->
if (throwable.isHttpNotFound()) {
api.deleteFilterV1(filter.id).fold(
{
this@FiltersViewModel._state.value = State(
this@FiltersViewModel._state.value.filters.filter {
it.id != filter.id
},
}
eventHub.dispatch(FilterUpdatedEvent(filter.context))
},
{ throwable ->
if (throwable.isHttpNotFound()) {
api.deleteFilterV1(filter.id).fold(
{
_state.update { currentState ->
State(
currentState.filters.filter { it.id != filter.id },
LoadingState.LOADED
)
},
{
Snackbar.make(
parent,
"Error deleting filter '${filter.title}'",
Snackbar.LENGTH_SHORT
).show()
}
)
} else {
Snackbar.make(
parent,
"Error deleting filter '${filter.title}'",
Snackbar.LENGTH_SHORT
).show()
}
},
{
Snackbar.make(
parent,
parent.context.getString(R.string.error_deleting_filter, filter.title),
Snackbar.LENGTH_SHORT
).show()
}
)
} else {
Snackbar.make(
parent,
parent.context.getString(R.string.error_deleting_filter, filter.title),
Snackbar.LENGTH_SHORT
).show()
}
)
}
}
)
}
companion object {
private const val TAG = "FiltersViewModel"
}
}

View file

@ -1,53 +1,48 @@
package com.keylesspalace.tusky.components.followedtags
import android.app.Dialog
import android.content.DialogInterface
import android.content.SharedPreferences
import android.os.Bundle
import android.util.Log
import android.widget.AutoCompleteTextView
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.lifecycleScope
import androidx.paging.LoadState
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.SimpleItemAnimator
import at.connyduck.calladapter.networkresult.fold
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.compose.ComposeAutoCompleteAdapter
import com.keylesspalace.tusky.StatusListActivity
import com.keylesspalace.tusky.databinding.ActivityFollowedTagsBinding
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.interfaces.HashtagActionListener
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.copyToClipboard
import com.keylesspalace.tusky.util.ensureBottomMargin
import com.keylesspalace.tusky.util.ensureBottomPadding
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible
import com.keylesspalace.tusky.view.showHashtagPickerDialog
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
@AndroidEntryPoint
class FollowedTagsActivity :
BaseActivity(),
HashtagActionListener,
ComposeAutoCompleteAdapter.AutocompletionProvider {
@Inject
lateinit var api: MastodonApi
HashtagActionListener {
@Inject
lateinit var viewModelFactory: ViewModelFactory
lateinit var api: MastodonApi
@Inject
lateinit var sharedPreferences: SharedPreferences
private val binding by viewBinding(ActivityFollowedTagsBinding::inflate)
private val viewModel: FollowedTagsViewModel by viewModels { viewModelFactory }
private val viewModel: FollowedTagsViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -61,9 +56,11 @@ class FollowedTagsActivity :
setDisplayShowHomeEnabled(true)
}
binding.fab.ensureBottomMargin()
binding.followedTagsView.ensureBottomPadding(fab = true)
binding.fab.setOnClickListener {
val dialog: DialogFragment = FollowTagDialog.newInstance()
dialog.show(supportFragmentManager, "dialog")
showDialog()
}
setupAdapter().let { adapter ->
@ -85,19 +82,6 @@ class FollowedTagsActivity :
DividerItemDecoration(this, DividerItemDecoration.VERTICAL)
)
(binding.followedTagsView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
val hideFab = sharedPreferences.getBoolean(PrefKeys.FAB_HIDE, false)
if (hideFab) {
binding.followedTagsView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
if (dy > 0 && binding.fab.isShown) {
binding.fab.hide()
} else if (dy < 0 && !binding.fab.isShown) {
binding.fab.show()
}
}
})
}
}
private fun setupAdapter(): FollowedTagsAdapter {
@ -123,7 +107,7 @@ class FollowedTagsActivity :
private fun follow(tagName: String, position: Int = -1) {
lifecycleScope.launch {
api.followTag(tagName).fold(
val snackbarText = api.followTag(tagName).fold(
{
if (position == -1) {
viewModel.tags.add(it)
@ -131,17 +115,20 @@ class FollowedTagsActivity :
viewModel.tags.add(position, it)
}
viewModel.currentSource?.invalidate()
getString(R.string.follow_hashtag_success, tagName)
},
{
Snackbar.make(
this@FollowedTagsActivity,
binding.followedTagsView,
getString(R.string.error_following_hashtag_format, tagName),
Snackbar.LENGTH_SHORT
)
.show()
{ t ->
Log.w(TAG, "failed to follow hashtag $tagName", t)
getString(R.string.error_following_hashtag_format, tagName)
}
)
Snackbar.make(
this@FollowedTagsActivity,
binding.followedTagsView,
snackbarText,
Snackbar.LENGTH_SHORT
)
.show()
}
}
@ -178,41 +165,24 @@ class FollowedTagsActivity :
}
}
override fun search(token: String): List<ComposeAutoCompleteAdapter.AutocompleteResult> {
return viewModel.searchAutocompleteSuggestions(token)
override fun viewTag(tagName: String) {
startActivity(StatusListActivity.newHashtagIntent(this, tagName))
}
override fun copyTagName(tagName: String) {
copyToClipboard(
"#$tagName",
getString(R.string.confirmation_hashtag_copied, tagName),
)
}
private fun showDialog() {
showHashtagPickerDialog(api, R.string.dialog_follow_hashtag_title) { hashtag ->
follow(hashtag)
}
}
companion object {
const val TAG = "FollowedTagsActivity"
}
class FollowTagDialog : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val layout = layoutInflater.inflate(R.layout.dialog_follow_hashtag, null)
val autoCompleteTextView = layout.findViewById<AutoCompleteTextView>(R.id.hashtag)!!
autoCompleteTextView.setAdapter(
ComposeAutoCompleteAdapter(
requireActivity() as FollowedTagsActivity,
animateAvatar = false,
animateEmojis = false,
showBotBadge = false
)
)
return AlertDialog.Builder(requireActivity())
.setTitle(R.string.dialog_follow_hashtag_title)
.setView(layout)
.setPositiveButton(android.R.string.ok) { _, _ ->
(requireActivity() as FollowedTagsActivity).follow(
autoCompleteTextView.text.toString().removePrefix("#")
)
}
.setNegativeButton(android.R.string.cancel) { _: DialogInterface, _: Int -> }
.create()
}
companion object {
fun newInstance(): FollowTagDialog = FollowTagDialog()
}
}
}

View file

@ -27,7 +27,17 @@ class FollowedTagsAdapter(
position: Int
) {
viewModel.tags[position].let { tag ->
holder.itemView.findViewById<TextView>(R.id.followed_tag).text = tag.name
holder.itemView.findViewById<TextView>(R.id.followed_tag).apply {
text = tag.name
setOnClickListener {
actionListener.viewTag(tag.name)
}
setOnLongClickListener {
actionListener.copyTagName(tag.name)
true
}
}
holder.itemView.findViewById<ImageButton>(
R.id.followed_tag_unfollow
).setOnClickListener {

View file

@ -1,31 +1,29 @@
package com.keylesspalace.tusky.components.followedtags
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.ExperimentalPagingApi
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.cachedIn
import at.connyduck.calladapter.networkresult.fold
import com.keylesspalace.tusky.components.compose.ComposeAutoCompleteAdapter
import com.keylesspalace.tusky.components.search.SearchType
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.HashTag
import com.keylesspalace.tusky.network.MastodonApi
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.runBlocking
@HiltViewModel
class FollowedTagsViewModel @Inject constructor(
private val api: MastodonApi
) : ViewModel(), Injectable {
val api: MastodonApi
) : ViewModel() {
val tags: MutableList<HashTag> = mutableListOf()
var nextKey: String? = null
var currentSource: FollowedTagsPagingSource? = null
@OptIn(ExperimentalPagingApi::class)
val pager = Pager(
config = PagingConfig(pageSize = 100),
config = PagingConfig(
pageSize = 100
),
remoteMediator = FollowedTagsRemoteMediator(api, this),
pagingSourceFactory = {
FollowedTagsPagingSource(
@ -36,24 +34,6 @@ class FollowedTagsViewModel @Inject constructor(
}
).flow.cachedIn(viewModelScope)
fun searchAutocompleteSuggestions(
token: String
): List<ComposeAutoCompleteAdapter.AutocompleteResult> {
return runBlocking {
api.search(query = token, type = SearchType.Hashtag.apiParameter, limit = 10)
.fold({ searchResult ->
searchResult.hashtags.map {
ComposeAutoCompleteAdapter.AutocompleteResult.HashtagResult(
it.name
)
}
}, { e ->
Log.e(TAG, "Autocomplete search for $token failed.", e)
emptyList()
})
}
}
companion object {
private const val TAG = "FollowedTagsViewModel"
}

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