Merge tag 'v25.2' into develop
# Conflicts: # README.md # app/build.gradle # app/lint-baseline.xml # app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt # app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusNotificationViewHolder.kt # app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt # app/src/main/java/com/keylesspalace/tusky/util/ThemeUtils.kt # app/src/main/res/layout/activity_about.xml # app/src/main/res/layout/item_emoji_pref.xml # app/src/main/res/values-ar/strings.xml # app/src/main/res/values-bg/strings.xml # app/src/main/res/values-cy/strings.xml # app/src/main/res/values-de/strings.xml # app/src/main/res/values-fa/strings.xml # app/src/main/res/values-gd/strings.xml # app/src/main/res/values-gl/strings.xml # app/src/main/res/values-hu/strings.xml # app/src/main/res/values-is/strings.xml # app/src/main/res/values-it/strings.xml # app/src/main/res/values-ja/strings.xml # app/src/main/res/values-nl/strings.xml # app/src/main/res/values-oc/strings.xml # app/src/main/res/values-pt-rBR/strings.xml # app/src/main/res/values-pt-rPT/strings.xml # app/src/main/res/values-ru/strings.xml # app/src/main/res/values-si/strings.xml # app/src/main/res/values-sv/strings.xml # app/src/main/res/values-tr/strings.xml # app/src/main/res/values-uk/strings.xml # app/src/main/res/values-vi/strings.xml # app/src/main/res/values-zh-rCN/strings.xml # app/src/main/res/values/strings.xml # fastlane/metadata/android/ru/full_description.txt # fastlane/metadata/android/zh-Hans/full_description.txt
This commit is contained in:
parent
84670dbc0b
commit
875013e47f
630 changed files with 22153 additions and 18732 deletions
|
|
@ -7,11 +7,21 @@ indent_style = space
|
|||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.{java,kt}]
|
||||
ij_kotlin_imports_layout = *
|
||||
|
||||
# Disable wildcard imports
|
||||
[*.{java, kt}]
|
||||
ij_kotlin_name_count_to_use_star_import = 999
|
||||
ij_kotlin_name_count_to_use_star_import_for_members = 999
|
||||
ij_java_class_count_to_use_import_on_demand = 999
|
||||
|
||||
ktlint_code_style = android_studio
|
||||
|
||||
# Disable trailing comma
|
||||
ktlint_standard_trailing-comma-on-call-site = disabled
|
||||
ktlint_standard_trailing-comma-on-declaration-site = disabled
|
||||
|
||||
max_line_length = off
|
||||
|
||||
[*.{yml,yaml}]
|
||||
indent_size = 2
|
||||
|
|
|
|||
41
.github/ISSUE_TEMPLATE/1.bug_report.yml
vendored
Normal file
41
.github/ISSUE_TEMPLATE/1.bug_report.yml
vendored
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
name: Bug Report
|
||||
description: If something isn't working as expected
|
||||
labels: [bug]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Make sure that you are submitting a new bug that was not previously reported or already fixed.
|
||||
|
||||
Please use a concise and distinct title for the issue.
|
||||
|
||||
If possible, attach screenshots, videos or links to posts to illustrate the problem.
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Detailed description
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Steps to reproduce the problem
|
||||
description: What were you trying to do?
|
||||
value: |
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
...
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Debug information
|
||||
description: |
|
||||
This info can be copied from the 'About' screen in Tusky 24+.
|
||||
If you are on a lower version or can't access the screen, please provide us with the Tusky Version, Android Version, Device and the Mastodon instance this problem occurred on.
|
||||
placeholder: |
|
||||
Tusky Test 22.0-b814c2c0
|
||||
Android 12
|
||||
Fairphone 4
|
||||
mastodon.social
|
||||
validations:
|
||||
required: true
|
||||
19
.github/ISSUE_TEMPLATE/2.feature_request.yml
vendored
Normal file
19
.github/ISSUE_TEMPLATE/2.feature_request.yml
vendored
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
name: Feature Request
|
||||
description: I have a suggestion
|
||||
labels: [enhancement]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: Please use a concise and distinct title for the issue.
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Pitch
|
||||
description: Describe your idea for a feature. Make sure it has not already been suggested/implemented/turned down before.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Motivation
|
||||
description: Why do you think this feature is needed? Who would benefit from it?
|
||||
validations:
|
||||
required: true
|
||||
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
blank_issues_enabled: true
|
||||
24
.github/ci-gradle.properties
vendored
Normal file
24
.github/ci-gradle.properties
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
#
|
||||
# Copyright 2023 Tusky Contributors
|
||||
#
|
||||
# This file is a part of Tusky.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
# GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
# License, or (at your option) any later version.
|
||||
#
|
||||
# Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
# the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
# Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
# see <http://www.gnu.org/licenses>.
|
||||
#
|
||||
|
||||
# CI build workers are ephemeral, so don't benefit from the Gradle daemon
|
||||
org.gradle.daemon=false
|
||||
org.gradle.parallel=true
|
||||
org.gradle.workers.max=2
|
||||
|
||||
kotlin.incremental=false
|
||||
kotlin.compiler.execution.strategy=in-process
|
||||
44
.github/workflows/ci.yml
vendored
Normal file
44
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '21'
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Gradle Wrapper Validation
|
||||
uses: gradle/actions/wrapper-validation@v3
|
||||
|
||||
- name: Copy CI gradle.properties
|
||||
run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties
|
||||
|
||||
- name: Gradle Build Action
|
||||
uses: gradle/gradle-build-action@v3
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/develop' }}
|
||||
|
||||
- name: ktlint
|
||||
run: ./gradlew clean ktlintCheck
|
||||
|
||||
- name: Regular lint
|
||||
run: ./gradlew app:lintGreenDebug
|
||||
|
||||
- name: Test
|
||||
run: ./gradlew app:testGreenDebugUnitTest
|
||||
|
||||
- name: Build
|
||||
run: ./gradlew app:buildGreenDebug
|
||||
34
.github/workflows/populate-gradle-build-cache.yml
vendored
Normal file
34
.github/workflows/populate-gradle-build-cache.yml
vendored
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# Build the app on each push to `develop`, populating the build cache to speed
|
||||
# up CI on PRs.
|
||||
|
||||
name: Populate build cache
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: app:buildGreenDebug
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '21'
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Gradle Wrapper Validation
|
||||
uses: gradle/actions/wrapper-validation@v3
|
||||
|
||||
- name: Copy CI gradle.properties
|
||||
run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties
|
||||
|
||||
- uses: gradle/gradle-build-action@v3
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/develop' }}
|
||||
|
||||
- name: Run app:buildGreenDebug
|
||||
run: ./gradlew app:buildGreenDebug
|
||||
88
CHANGELOG.md
88
CHANGELOG.md
|
|
@ -6,6 +6,94 @@
|
|||
|
||||
### Significant bug fixes
|
||||
|
||||
## v25.2
|
||||
|
||||
### Significant bug fixes
|
||||
|
||||
- Fixes a bug that could sometimes crash Tusky when rotating the screen while viewing an account list [PR#4430](https://github.com/tuskyapp/Tusky/pull/4430)
|
||||
- Fixes a bug that could crash Tusky at startup under certain conditions [PR#4431](https://github.com/tuskyapp/Tusky/pull/4431)
|
||||
- Fixes a bug that caused Tusky to crash when custom emojis with too large dimensions were loaded [PR#4429](https://github.com/tuskyapp/Tusky/pull/4429)
|
||||
- Makes Tusky work again with Iceshrimp by working around a quirk in their API implementation [PR#4426](https://github.com/tuskyapp/Tusky/pull/4426)
|
||||
- Fixes a bug that made translations not work on some servers [PR#4422](https://github.com/tuskyapp/Tusky/pull/4422)
|
||||
|
||||
## v25.1
|
||||
|
||||
### Significant bug fixes
|
||||
|
||||
- Fixed two crashes at startup introduced in 25.0 [PR#4415](https://github.com/tuskyapp/Tusky/pull/4415) [PR#4417](https://github.com/tuskyapp/Tusky/pull/4417)
|
||||
|
||||
## v25.0
|
||||
|
||||
### New features and other improvements
|
||||
|
||||
- Added support for the [Mastodon translation api](https://docs.joinmastodon.org/methods/statuses/#translate).
|
||||
You can now find a new option "translate" in the three-dot-menu on posts that are not in your display language when your server supports the translation api.
|
||||
Support is determined by checking the `configuration.translation.enabled` attribute of the `/api/v2/instance` endpoint.
|
||||
[PR#4307](https://github.com/tuskyapp/Tusky/pull/4307)
|
||||
- The language of a post is now shown in the metadata section of the detail post view, if it is available. [PR#4127](https://github.com/tuskyapp/Tusky/pull/4127)
|
||||
- The transitions between screens have been changed to feel faster and align more with default Android transitions. [PR#4285](https://github.com/tuskyapp/Tusky/pull/4285)
|
||||
- The post statistic section below the detail post view is now always shown to prevent layout shifts on the first like or boost.
|
||||
[PR#4205](https://github.com/tuskyapp/Tusky/pull/4205) [PR#4260](https://github.com/tuskyapp/Tusky/pull/4260)
|
||||
- The filters for boosts/replies/self-boosts in the home timeline have moved from general preferences to account specific preferences. [PR#4115](https://github.com/tuskyapp/Tusky/pull/4115)
|
||||
- The json parsing library has been migrated from Gson to Moshi. This change will make Tusky no longer crash on unexpected server responses. [PR#4309](https://github.com/tuskyapp/Tusky/pull/4309)
|
||||
- Small layout improvements to the header of the profile view [PR#4375](https://github.com/tuskyapp/Tusky/pull/4375) [PR#4371](https://github.com/tuskyapp/Tusky/pull/4371)
|
||||
- support for Android 14 Upside Down Cake [PR#4224](https://github.com/tuskyapp/Tusky/pull/4224)
|
||||
- Various internal refactorings to improve performance and maintainability.
|
||||
[PR#4269](https://github.com/tuskyapp/Tusky/pull/4269)
|
||||
[PR#4290](https://github.com/tuskyapp/Tusky/pull/4290)
|
||||
[PR#4291](https://github.com/tuskyapp/Tusky/pull/4291)
|
||||
[PR#4296](https://github.com/tuskyapp/Tusky/pull/4296)
|
||||
[PR#4364](https://github.com/tuskyapp/Tusky/pull/4364)
|
||||
[PR#4366](https://github.com/tuskyapp/Tusky/pull/4366)
|
||||
[PR#4372](https://github.com/tuskyapp/Tusky/pull/4372)
|
||||
[PR#4356](https://github.com/tuskyapp/Tusky/pull/4356)
|
||||
[PR#4348](https://github.com/tuskyapp/Tusky/pull/4348)
|
||||
[PR#4339](https://github.com/tuskyapp/Tusky/pull/4339)
|
||||
[PR#4337](https://github.com/tuskyapp/Tusky/pull/4337)
|
||||
[PR#4336](https://github.com/tuskyapp/Tusky/pull/4336)
|
||||
[PR#4330](https://github.com/tuskyapp/Tusky/pull/4330)
|
||||
[PR#4235](https://github.com/tuskyapp/Tusky/pull/4235)
|
||||
[PR#4081](https://github.com/tuskyapp/Tusky/pull/4081)
|
||||
|
||||
### Significant bug fixes
|
||||
|
||||
- The setting to hide the notification filter bar that was accidentally removed is back. [PR#4225](https://github.com/tuskyapp/Tusky/pull/4225)
|
||||
- The profile picture in the bottom navigation bar now has the correct content description. [PR#4400](https://github.com/tuskyapp/Tusky/pull/4400)
|
||||
|
||||
## v24.1
|
||||
|
||||
- The screen will stay on again while a video is playing. [PR#4168](https://github.com/tuskyapp/Tusky/pull/4168)
|
||||
- A memory leak has been fixed. This should improve stability and performance. [PR#4150](https://github.com/tuskyapp/Tusky/pull/4150) [PR#4153](https://github.com/tuskyapp/Tusky/pull/4153)
|
||||
- Emojis are now correctly counted as 1 character when composing a post. [PR#4152](https://github.com/tuskyapp/Tusky/pull/4152)
|
||||
- Fixed a crash when text was selected on some devices. [PR#4166](https://github.com/tuskyapp/Tusky/pull/4166)
|
||||
- The icons in the help texts of empty timelines will now always be correctly
|
||||
aligned. [PR#4179](https://github.com/tuskyapp/Tusky/pull/4179)
|
||||
- Fixed ANR caused by direct message badge [PR#4182](https://github.com/tuskyapp/Tusky/pull/4182)
|
||||
|
||||
## v24.0
|
||||
|
||||
### New features and other improvements
|
||||
|
||||
- The number of tabs that can be configured is no longer limited. [PR#4058](https://github.com/tuskyapp/Tusky/pull/4058)
|
||||
- Blockquotes and code blocks in posts now look nicer [PR#4090](https://github.com/tuskyapp/Tusky/pull/4090) [PR#4091](https://github.com/tuskyapp/Tusky/pull/4091)
|
||||
- The old behavior of the notification tab (pre Tusky 22.0) has been restored. [PR#4015](https://github.com/tuskyapp/Tusky/pull/4015)
|
||||
- Role badges are now shown on profiles (Mastodon 4.2 feature). [PR#4029](https://github.com/tuskyapp/Tusky/pull/4029)
|
||||
- The video player has been upgraded to Google Jetpack Media3; video compatibility should be improved, and you can now adjust playback speed. [PR#3857](https://github.com/tuskyapp/Tusky/pull/3857)
|
||||
- New theme option to use the black theme when following the system design. [PR#3957](https://github.com/tuskyapp/Tusky/pull/3957)
|
||||
- Following the system design is now the default theme setting. [PR#3813](https://github.com/tuskyapp/Tusky/pull/3957)
|
||||
- A new view to see trending posts is available both in the menu and as custom tab. [PR#4007](https://github.com/tuskyapp/Tusky/pull/4007)
|
||||
- A new option to hide self boosts has been added. [PR#4101](https://github.com/tuskyapp/Tusky/pull/4101)
|
||||
- The `api/v2/instance` endpoint is now supported. [PR#4062](https://github.com/tuskyapp/Tusky/pull/4062)
|
||||
- New settings for lists:
|
||||
- Hide from the home timeline [PR#3932](https://github.com/tuskyapp/Tusky/pull/3932)
|
||||
- Decide which replies should be shown in the list [PR#4072](https://github.com/tuskyapp/Tusky/pull/4072)
|
||||
- The oldest supported Android version is now Android 7 Nougat [PR#4014](https://github.com/tuskyapp/Tusky/pull/4014)
|
||||
|
||||
### Significant bug fixes
|
||||
|
||||
- **Empty trends no longer causes Tusky to crash**, [PR#3853](https://github.com/tuskyapp/Tusky/pull/3853)
|
||||
|
||||
|
||||
## v23.0
|
||||
|
||||
### New features and other improvements
|
||||
|
|
|
|||
|
|
@ -22,8 +22,9 @@ We try to follow the [Guide to app architecture](https://developer.android.com/t
|
|||
|
||||
### Kotlin
|
||||
Tusky was originally written in Java, but is in the process of migrating to Kotlin. All new code must be written in Kotlin.
|
||||
We try to follow the [Kotlin Style Guide](https://developer.android.com/kotlin/style-guide) and make format the code according to the default [ktlint codestyle](https://github.com/pinterest/ktlint).
|
||||
You can check the codestyle by running `./gradlew ktlintCheck`.
|
||||
We try to follow the [Kotlin Style Guide](https://developer.android.com/kotlin/style-guide) and format the code according to the default [ktlint codestyle](https://github.com/pinterest/ktlint).
|
||||
You can check the codestyle by running `./gradlew ktlintCheck lint`. This will fail if you have any errors, and produces a detailed report which also lists warnings.
|
||||
We intentionally have very few hard linting errors, so that new contributors can focus on what they want to achieve instead of fighting the linter.
|
||||
|
||||
### Text
|
||||
All English text that will be visible to users must be put in `app/src/main/res/values/strings.xml` so it is translateable into other languages.
|
||||
|
|
@ -42,12 +43,15 @@ All icons are from the Material iconset, find new icons [here](https://fonts.goo
|
|||
We try to make Tusky as accessible as possible for as many people as possible. Please make sure that all touch targets are at least 48dpx48dp in size, Text has sufficient contrast and images or icons have a image description. See [this guide](https://developer.android.com/guide/topics/ui/accessibility/apps) for more information.
|
||||
|
||||
### Supported servers
|
||||
Tusky is primarily a Mastodon client and aims to always support the newest Mastodon version. Other platforms implementing the Mastodon Api, e.g. Akkoma, GoToSocial or Pixelfed should also work with Tusky but no special effort is made to support their quirks or additional features.
|
||||
Tusky is primarily a Mastodon client and aims to always support the newest Mastodon version. Other platforms implementing the Mastodon API, e.g. Akkoma, GoToSocial or Pixelfed should also work with Tusky, but no special effort is made to support their quirks or additional features.
|
||||
|
||||
### Payment Policy
|
||||
Our payment policy may be viewed [here](https://github.com/tuskyapp/Tusky/blob/develop/doc/PaymentPolicy.md).
|
||||
|
||||
## Troubleshooting / FAQ
|
||||
|
||||
- Tusky should be built with the newest version of Android Studio
|
||||
- Tusky should be built with the newest version of Android Studio.
|
||||
- Tusky comes with two sets of build variants, "blue" and "green", which can be installed simultaneously and are distinguished by the colors of their icons. Green is intended for local development and testing, whereas blue is for releases.
|
||||
|
||||
## Resources
|
||||
- [Mastodon Api documentation](https://docs.joinmastodon.org/api/)
|
||||
- [Mastodon API documentation](https://docs.joinmastodon.org/api/)
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
[Issue text goes here].
|
||||
|
||||
* * * *
|
||||
- Tusky Version:
|
||||
- Android Version:
|
||||
- Android Device:
|
||||
- Mastodon instance (if applicable):
|
||||
|
||||
- [ ] I searched or browsed the repo’s other issues to ensure this is not a duplicate.
|
||||
|
|
@ -12,10 +12,11 @@ It is derived from [Tusky](https://tusky.app) and has been modified to reflect C
|
|||
## Features
|
||||
|
||||
- Material Design
|
||||
- Most Mastodon APIs implemented
|
||||
- Multi-Account support
|
||||
- Respect device preferences for light/dark theming
|
||||
- Drafts - compose posts and save them for later
|
||||
- Choose between different emoji styles
|
||||
- Choose between different emoji styles
|
||||
- Optimized for all screen sizes
|
||||
- Completely open-source - no non-free dependencies like Google services
|
||||
|
||||
|
|
@ -26,4 +27,4 @@ If you have any bug reports, feature requests or questions please open an issue
|
|||
|
||||
For translating Tusky into your language, visit https://weblate.tusky.app/
|
||||
|
||||
###
|
||||
###
|
||||
|
|
|
|||
|
|
@ -24,15 +24,15 @@ final def SUPPORT_ACCOUNT_URL = "https://social.chinwag.org/@ChinwagNews"
|
|||
final def REGISTER_ACCOUNT_URL = "https://chinwag.org/auth/sign_up"
|
||||
|
||||
android {
|
||||
compileSdk 33
|
||||
compileSdk 34
|
||||
namespace "com.keylesspalace.tusky"
|
||||
defaultConfig {
|
||||
applicationId APP_ID
|
||||
namespace "com.keylesspalace.tusky"
|
||||
minSdk 23
|
||||
targetSdk 33
|
||||
versionCode 90
|
||||
versionName "23.0-CW0"
|
||||
minSdk 24
|
||||
targetSdk 34
|
||||
versionCode 121
|
||||
versionName "25.2-CW0"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables.useSupportLibrary = true
|
||||
|
||||
|
|
@ -44,10 +44,21 @@ android {
|
|||
buildConfigField("String", "REGISTER_ACCOUNT_URL", "\"$REGISTER_ACCOUNT_URL\"")
|
||||
}
|
||||
buildTypes {
|
||||
debug {
|
||||
isDefault true
|
||||
}
|
||||
release {
|
||||
minifyEnabled true
|
||||
shrinkResources true
|
||||
proguardFiles 'proguard-rules.pro'
|
||||
|
||||
kotlinOptions {
|
||||
freeCompilerArgs = [
|
||||
"-Xno-param-assertions",
|
||||
"-Xno-call-assertions",
|
||||
"-Xno-receiver-assertions"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -58,13 +69,13 @@ android {
|
|||
resValue "string", "app_name", APP_NAME + " Test"
|
||||
applicationIdSuffix ".test"
|
||||
versionNameSuffix "-" + gitSha
|
||||
isDefault true
|
||||
}
|
||||
}
|
||||
|
||||
lint {
|
||||
lintConfig file("lint.xml")
|
||||
// Regenerate by deleting app/lint-baseline.xml, then run:
|
||||
// ./gradlew lintBlueDebug
|
||||
// Regenerate by deleting the file and running `./gradlew app:lintGreenDebug`
|
||||
baseline = file("lint-baseline.xml")
|
||||
}
|
||||
|
||||
|
|
@ -103,12 +114,6 @@ android {
|
|||
includeInApk false
|
||||
includeInBundle false
|
||||
}
|
||||
// Can remove this once https://issuetracker.google.com/issues/260059413 is fixed.
|
||||
// https://kotlinlang.org/docs/gradle-configure-project.html#gradle-java-toolchains-support
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_11
|
||||
targetCompatibility JavaVersion.VERSION_11
|
||||
}
|
||||
applicationVariants.configureEach { variant ->
|
||||
variant.outputs.configureEach {
|
||||
outputFileName = "Tusky_${variant.versionName}_${variant.versionCode}_${gitSha}_" +
|
||||
|
|
@ -119,6 +124,7 @@ android {
|
|||
|
||||
ksp {
|
||||
arg("room.schemaLocation", "$projectDir/schemas")
|
||||
arg("room.generateKotlin", "true")
|
||||
arg("room.incremental", "true")
|
||||
}
|
||||
|
||||
|
|
@ -132,7 +138,6 @@ configurations {
|
|||
// library versions are in PROJECT_ROOT/gradle/libs.versions.toml
|
||||
dependencies {
|
||||
implementation libs.kotlinx.coroutines.android
|
||||
implementation libs.kotlinx.coroutines.rx3
|
||||
|
||||
implementation libs.bundles.androidx
|
||||
implementation libs.bundles.room
|
||||
|
|
@ -140,28 +145,26 @@ dependencies {
|
|||
|
||||
implementation libs.android.material
|
||||
|
||||
implementation libs.gson
|
||||
implementation libs.bundles.moshi
|
||||
ksp libs.moshi.kotlin.codegen
|
||||
|
||||
implementation libs.bundles.retrofit
|
||||
implementation libs.networkresult.calladapter
|
||||
|
||||
implementation libs.bundles.okhttp
|
||||
implementation libs.okio
|
||||
|
||||
implementation libs.conscrypt.android
|
||||
|
||||
implementation libs.bundles.glide
|
||||
kapt libs.glide.compiler
|
||||
|
||||
implementation libs.bundles.rxjava3
|
||||
|
||||
implementation libs.bundles.autodispose
|
||||
ksp libs.glide.compiler
|
||||
|
||||
implementation libs.bundles.dagger
|
||||
kapt libs.bundles.dagger.processors
|
||||
|
||||
implementation libs.sparkbutton
|
||||
|
||||
implementation libs.photoview
|
||||
implementation libs.touchimageview
|
||||
|
||||
implementation libs.bundles.material.drawer
|
||||
implementation libs.material.typeface
|
||||
|
|
@ -189,3 +192,20 @@ dependencies {
|
|||
androidTestImplementation libs.androidx.room.testing
|
||||
androidTestImplementation libs.androidx.test.junit
|
||||
}
|
||||
|
||||
// Work around warnings of:
|
||||
// WARNING: Illegal reflective access by org.jetbrains.kotlin.kapt3.util.ModuleManipulationUtilsKt (file:/C:/Users/Andi/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-annotation-processing-gradle/1.8.22/28dab7e0ee9ce62c03bf97de3543c911dc653700/kotlin-annotation-processing-gradle-1.8.22.jar) to constructor com.sun.tools.javac.util.Context()
|
||||
// See https://youtrack.jetbrains.com/issue/KT-30589/Kapt-An-illegal-reflective-access-operation-has-occurred
|
||||
tasks.withType(org.jetbrains.kotlin.gradle.internal.KaptWithoutKotlincTask).configureEach {
|
||||
kaptProcessJvmArgs.addAll([
|
||||
"--add-opens", "jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED",
|
||||
"--add-opens", "jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED",
|
||||
"--add-opens", "jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED",
|
||||
"--add-opens", "jdk.compiler/com.sun.tools.javac.jvm=ALL-UNNAMED",
|
||||
"--add-opens", "jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED",
|
||||
"--add-opens", "jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED",
|
||||
"--add-opens", "jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED",
|
||||
"--add-opens", "jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED",
|
||||
"--add-opens", "jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED",
|
||||
"--add-opens", "jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED"])
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
45
app/lint.xml
45
app/lint.xml
|
|
@ -29,17 +29,48 @@
|
|||
|
||||
Disable these for the time being. -->
|
||||
<issue id="UnusedIds" severity="ignore" />
|
||||
<issue id="UnusedResources" severity="ignore" />
|
||||
|
||||
<!-- Logs are stripped in release builds. -->
|
||||
<issue id="LogConditional" severity="ignore" />
|
||||
|
||||
<!-- Ensure we are warned about errors in the baseline -->
|
||||
<issue id="LintBaseline" severity="warning" />
|
||||
<!-- Newer dependencies are handled by Renovate, and don't need a warning -->
|
||||
<issue id="GradleDependency" severity="ignore" />
|
||||
<issue id="NewerVersionAvailable" severity="ignore" />
|
||||
|
||||
<!-- Warn about typos. The typo database in lint is not exhaustive, and it's unclear
|
||||
how to add to it when it's wrong. -->
|
||||
<issue id="Typos" severity="warning" />
|
||||
<!-- Typographical punctuation is not something we care about at the moment -->
|
||||
<issue id="TypographyQuotes" severity="ignore" />
|
||||
<issue id="TypographyDashes" severity="ignore" />
|
||||
<issue id="TypographyEllipsis" severity="ignore" />
|
||||
|
||||
<!-- Mark all other lint issues as errors -->
|
||||
<issue id="all" severity="error" />
|
||||
<!-- Translations come from external parties -->
|
||||
<issue id="MissingQuantity" severity="ignore" />
|
||||
<issue id="ImpliedQuantity" severity="ignore" />
|
||||
<!-- Most alleged typos are in translations -->
|
||||
<issue id="Typos" severity="ignore" />
|
||||
|
||||
<!-- Basically all of our vectors are external -->
|
||||
<issue id="VectorPath" severity="ignore" />
|
||||
<issue id="Overdraw" severity="ignore" />
|
||||
|
||||
<!-- Irrelevant api version warnings -->
|
||||
<issue id="OldTargetApi" severity="ignore" />
|
||||
<issue id="UnusedAttribute" severity="ignore" />
|
||||
|
||||
<!-- We do not *want* all the text in the app to be selectable -->
|
||||
<issue id="SelectableText" severity="ignore" />
|
||||
|
||||
<!-- This is heavily used by the viewbinding helper -->
|
||||
<issue id="SyntheticAccessor" severity="ignore" />
|
||||
|
||||
<!-- Things we would actually question in a code review -->
|
||||
<issue id="MissingPermission" severity="error" />
|
||||
<issue id="InvalidPackage" severity="error" />
|
||||
<issue id="UseCompatLoadingForDrawables" severity="error" />
|
||||
<issue id="UseCompatTextViewDrawableXml" severity="error" />
|
||||
<issue id="Recycle" severity="error" />
|
||||
<issue id="KeyboardInaccessibleWidget" severity="error" />
|
||||
|
||||
<!-- Mark all other lint issues as warnings -->
|
||||
<issue id="all" severity="warning" />
|
||||
</lint>
|
||||
|
|
|
|||
79
app/proguard-rules.pro
vendored
79
app/proguard-rules.pro
vendored
|
|
@ -1,83 +1,44 @@
|
|||
# GENERAL OPTIONS
|
||||
|
||||
# turn on all optimizations except those that are known to cause problems on Android
|
||||
-optimizations !code/simplification/cast,!field/*,!class/merging/*
|
||||
-optimizationpasses 6
|
||||
-allowaccessmodification
|
||||
-dontpreverify
|
||||
|
||||
-dontusemixedcaseclassnames
|
||||
-dontskipnonpubliclibraryclasses
|
||||
-keepattributes *Annotation*
|
||||
# Preserve some attributes that may be required for reflection.
|
||||
-keepattributes RuntimeVisible*Annotations, AnnotationDefault
|
||||
|
||||
# For native methods, see http://proguard.sourceforge.net/manual/examples.html#native
|
||||
-keepclasseswithmembernames class * {
|
||||
native <methods>;
|
||||
}
|
||||
# keep setters in Views so that animations can still work.
|
||||
# see http://proguard.sourceforge.net/manual/examples.html#beans
|
||||
-keepclassmembers public class * extends android.view.View {
|
||||
void set*(***);
|
||||
*** get*();
|
||||
}
|
||||
# We want to keep methods in Activity that could be used in the XML attribute onClick
|
||||
-keepclassmembers class * extends android.app.Activity {
|
||||
public void *(android.view.View);
|
||||
}
|
||||
|
||||
# For enumeration classes, see http://proguard.sourceforge.net/manual/examples.html#enumerations
|
||||
-keepclassmembers enum * {
|
||||
-keepclassmembers,allowoptimization enum * {
|
||||
public static **[] values();
|
||||
public static ** valueOf(java.lang.String);
|
||||
}
|
||||
|
||||
-keepclassmembers class * implements android.os.Parcelable {
|
||||
public static final ** CREATOR;
|
||||
}
|
||||
|
||||
-keepclassmembers class **.R$* {
|
||||
public static <fields>;
|
||||
# Preserve annotated Javascript interface methods.
|
||||
-keepclassmembers class * {
|
||||
@android.webkit.JavascriptInterface <methods>;
|
||||
}
|
||||
|
||||
# The support libraries contains references to newer platform versions.
|
||||
# Don't warn about those in case this app is linking against an older
|
||||
# platform version. We know about them, and they are safe.
|
||||
-dontnote androidx.**
|
||||
-dontwarn androidx.**
|
||||
|
||||
# This class is deprecated, but remains for backward compatibility.
|
||||
-dontwarn android.util.FloatMath
|
||||
|
||||
# These classes are duplicated between android.jar and core-lambda-stubs.jar.
|
||||
-dontnote java.lang.invoke.**
|
||||
|
||||
# TUSKY SPECIFIC OPTIONS
|
||||
|
||||
# keep members of our model classes, they are used in json de/serialization
|
||||
-keepclassmembers class com.keylesspalace.tusky.entity.* { *; }
|
||||
|
||||
-keep public enum com.keylesspalace.tusky.entity.*$** {
|
||||
**[] $VALUES;
|
||||
public *;
|
||||
}
|
||||
|
||||
-keepclassmembers class com.keylesspalace.tusky.components.conversation.ConversationAccountEntity { *; }
|
||||
-keepclassmembers class com.keylesspalace.tusky.db.DraftAttachment { *; }
|
||||
|
||||
-keep enum com.keylesspalace.tusky.db.DraftAttachment$Type {
|
||||
public *;
|
||||
}
|
||||
|
||||
# https://github.com/google/gson/blob/master/examples/android-proguard-example/proguard.cfg
|
||||
|
||||
# Prevent proguard from stripping interface information from TypeAdapter, TypeAdapterFactory,
|
||||
# JsonSerializer, JsonDeserializer instances (so they can be used in @JsonAdapter)
|
||||
-keep class * extends com.google.gson.TypeAdapter
|
||||
-keep class * implements com.google.gson.TypeAdapterFactory
|
||||
-keep class * implements com.google.gson.JsonSerializer
|
||||
-keep class * implements com.google.gson.JsonDeserializer
|
||||
|
||||
# Retain generic signatures of TypeToken and its subclasses with R8 version 3.0 and higher.
|
||||
-keep,allowobfuscation,allowshrinking class com.google.gson.reflect.TypeToken
|
||||
-keep,allowobfuscation,allowshrinking class * extends com.google.gson.reflect.TypeToken
|
||||
|
||||
# Retain generic signatures of classes used in MastodonApi so Retrofit works
|
||||
-keep,allowobfuscation,allowshrinking class io.reactivex.rxjava3.core.Single
|
||||
-keep,allowobfuscation,allowshrinking class retrofit2.Response
|
||||
-keep,allowobfuscation,allowshrinking class kotlin.collections.List
|
||||
-keep,allowobfuscation,allowshrinking class kotlin.collections.Map
|
||||
-keep,allowobfuscation,allowshrinking class retrofit2.Call
|
||||
|
||||
# https://r8.googlesource.com/r8/+/refs/heads/master/compatibility-faq.md#retrofit
|
||||
-keepattributes Signature
|
||||
-keep class kotlin.coroutines.Continuation
|
||||
|
||||
# preserve line numbers for crash reporting
|
||||
-keepattributes SourceFile,LineNumberTable
|
||||
-renamesourcefileattribute SourceFile
|
||||
|
|
|
|||
1009
app/schemas/com.keylesspalace.tusky.db.AppDatabase/52.json
Normal file
1009
app/schemas/com.keylesspalace.tusky.db.AppDatabase/52.json
Normal file
File diff suppressed because it is too large
Load diff
1009
app/schemas/com.keylesspalace.tusky.db.AppDatabase/53.json
Normal file
1009
app/schemas/com.keylesspalace.tusky.db.AppDatabase/53.json
Normal file
File diff suppressed because it is too large
Load diff
1016
app/schemas/com.keylesspalace.tusky.db.AppDatabase/54.json
Normal file
1016
app/schemas/com.keylesspalace.tusky.db.AppDatabase/54.json
Normal file
File diff suppressed because it is too large
Load diff
1034
app/schemas/com.keylesspalace.tusky.db.AppDatabase/56.json
Normal file
1034
app/schemas/com.keylesspalace.tusky.db.AppDatabase/56.json
Normal file
File diff suppressed because it is too large
Load diff
1040
app/schemas/com.keylesspalace.tusky.db.AppDatabase/58.json
Normal file
1040
app/schemas/com.keylesspalace.tusky.db.AppDatabase/58.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -19,7 +19,7 @@ class MigrationsTest {
|
|||
@Rule
|
||||
var helper: MigrationTestHelper = MigrationTestHelper(
|
||||
InstrumentationRegistry.getInstrumentation(),
|
||||
AppDatabase::class.java.canonicalName,
|
||||
AppDatabase::class.java.canonicalName!!,
|
||||
FrameworkSQLiteOpenHelperFactory()
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,8 @@
|
|||
android:supportsRtl="true"
|
||||
android:theme="@style/TuskyTheme"
|
||||
android:usesCleartextTraffic="false"
|
||||
android:localeConfig="@xml/locales_config">
|
||||
android:localeConfig="@xml/locales_config"
|
||||
android:enableOnBackInvokedCallback="true">
|
||||
|
||||
<activity
|
||||
android:name=".SplashActivity"
|
||||
|
|
@ -130,7 +131,7 @@
|
|||
android:theme="@style/Base.Theme.AppCompat" />
|
||||
<activity
|
||||
android:name=".components.search.SearchActivity"
|
||||
android:launchMode="singleTop"
|
||||
android:launchMode="singleTask"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEARCH" />
|
||||
|
|
@ -148,7 +149,7 @@
|
|||
<activity
|
||||
android:name=".components.report.ReportActivity"
|
||||
android:windowSoftInputMode="stateAlwaysHidden|adjustResize" />
|
||||
<activity android:name=".components.instancemute.InstanceListActivity" />
|
||||
<activity android:name=".components.domainblocks.DomainBlocksActivity" />
|
||||
<activity android:name=".components.scheduled.ScheduledStatusActivity" />
|
||||
<activity android:name=".components.announcements.AnnouncementsActivity" />
|
||||
<activity android:name=".components.drafts.DraftsActivity" />
|
||||
|
|
@ -188,14 +189,14 @@
|
|||
android:icon="@drawable/ic_chinwag_logo_simple"
|
||||
android:label="@string/tusky_compose_post_quicksetting_label"
|
||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
|
||||
android:exported="true"
|
||||
tools:targetApi="24">
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<service android:name=".service.SendStatusService"
|
||||
android:foregroundServiceType="shortService"
|
||||
android:exported="false" />
|
||||
|
||||
<provider
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
package com.keylesspalace.tusky
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.text.SpannableString
|
||||
import android.text.SpannableStringBuilder
|
||||
|
|
@ -8,13 +12,22 @@ import android.text.method.LinkMovementMethod
|
|||
import android.text.style.URLSpan
|
||||
import android.text.util.Linkify
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
|
||||
import com.keylesspalace.tusky.databinding.ActivityAboutBinding
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
import com.keylesspalace.tusky.util.NoUnderlineURLSpan
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class AboutActivity : BottomSheetActivity(), Injectable {
|
||||
@Inject
|
||||
lateinit var instanceInfoRepository: InstanceInfoRepository
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
|
@ -32,13 +45,41 @@ class AboutActivity : BottomSheetActivity(), Injectable {
|
|||
|
||||
binding.versionTextView.text = getString(R.string.about_app_version, getString(R.string.app_name), BuildConfig.VERSION_NAME)
|
||||
|
||||
binding.deviceInfo.text = getString(
|
||||
R.string.about_device_info,
|
||||
Build.MANUFACTURER,
|
||||
Build.MODEL,
|
||||
Build.VERSION.RELEASE,
|
||||
Build.VERSION.SDK_INT
|
||||
)
|
||||
|
||||
lifecycleScope.launch {
|
||||
accountManager.activeAccount?.let { account ->
|
||||
val instanceInfo = instanceInfoRepository.getUpdatedInstanceInfoOrFallback()
|
||||
binding.accountInfo.text = getString(
|
||||
R.string.about_account_info,
|
||||
account.username,
|
||||
account.domain,
|
||||
instanceInfo.version
|
||||
)
|
||||
binding.accountInfoTitle.show()
|
||||
binding.accountInfo.show()
|
||||
}
|
||||
}
|
||||
|
||||
if (BuildConfig.CUSTOM_INSTANCE.isBlank()) {
|
||||
binding.aboutPoweredByTusky.hide()
|
||||
}
|
||||
|
||||
binding.aboutLicenseInfoTextView.setClickableTextWithoutUnderlines(R.string.about_tusky_license)
|
||||
binding.aboutWebsiteInfoTextView.setClickableTextWithoutUnderlines(R.string.about_project_site)
|
||||
binding.aboutBugsFeaturesInfoTextView.setClickableTextWithoutUnderlines(R.string.about_bug_feature_request_site)
|
||||
binding.aboutLicenseInfoTextView.setClickableTextWithoutUnderlines(
|
||||
R.string.about_tusky_license
|
||||
)
|
||||
binding.aboutWebsiteInfoTextView.setClickableTextWithoutUnderlines(
|
||||
R.string.about_project_site
|
||||
)
|
||||
binding.aboutBugsFeaturesInfoTextView.setClickableTextWithoutUnderlines(
|
||||
R.string.about_bug_feature_request_site
|
||||
)
|
||||
|
||||
binding.tuskyProfileButton.setOnClickListener {
|
||||
viewUrl(BuildConfig.SUPPORT_ACCOUNT_URL)
|
||||
|
|
@ -47,6 +88,16 @@ class AboutActivity : BottomSheetActivity(), Injectable {
|
|||
binding.aboutLicensesButton.setOnClickListener {
|
||||
startActivityWithSlideInAnimation(Intent(this, LicenseActivity::class.java))
|
||||
}
|
||||
|
||||
binding.copyDeviceInfo.setOnClickListener {
|
||||
val text = "${binding.versionTextView.text}\n\nDevice:\n\n${binding.deviceInfo.text}\n\nAccount:\n\n${binding.accountInfo.text}"
|
||||
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val clip = ClipData.newPlainText("Tusky version information", text)
|
||||
clipboard.setPrimaryClip(clip)
|
||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
|
||||
Toast.makeText(this, getString(R.string.about_copied), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -45,8 +45,8 @@ import com.keylesspalace.tusky.util.unsafeLazy
|
|||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel
|
||||
import com.keylesspalace.tusky.viewmodel.State
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private typealias AccountInfo = Pair<TimelineAccount, Boolean>
|
||||
|
||||
|
|
@ -82,11 +82,18 @@ class AccountsInListFragment : DialogFragment(), Injectable {
|
|||
super.onStart()
|
||||
dialog?.apply {
|
||||
// Stretch dialog to the window
|
||||
window?.setLayout(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.MATCH_PARENT)
|
||||
window?.setLayout(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
return inflater.inflate(R.layout.fragment_accounts_in_list, container, false)
|
||||
}
|
||||
|
||||
|
|
@ -164,15 +171,27 @@ class AccountsInListFragment : DialogFragment(), Injectable {
|
|||
return oldItem.id == newItem.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: TimelineAccount, newItem: TimelineAccount): Boolean {
|
||||
override fun areContentsTheSame(
|
||||
oldItem: TimelineAccount,
|
||||
newItem: TimelineAccount
|
||||
): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
}
|
||||
|
||||
inner class Adapter : ListAdapter<TimelineAccount, BindingHolder<ItemFollowRequestBinding>>(AccountDiffer) {
|
||||
inner class Adapter : ListAdapter<TimelineAccount, BindingHolder<ItemFollowRequestBinding>>(
|
||||
AccountDiffer
|
||||
) {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemFollowRequestBinding> {
|
||||
val binding = ItemFollowRequestBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
viewType: Int
|
||||
): BindingHolder<ItemFollowRequestBinding> {
|
||||
val binding = ItemFollowRequestBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
val holder = BindingHolder(binding)
|
||||
|
||||
binding.notificationTextView.hide()
|
||||
|
|
@ -186,7 +205,10 @@ class AccountsInListFragment : DialogFragment(), Injectable {
|
|||
return holder
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: BindingHolder<ItemFollowRequestBinding>, position: Int) {
|
||||
override fun onBindViewHolder(
|
||||
holder: BindingHolder<ItemFollowRequestBinding>,
|
||||
position: Int
|
||||
) {
|
||||
val account = getItem(position)
|
||||
holder.binding.displayNameTextView.text = account.name.emojify(account.emojis, holder.binding.displayNameTextView, animateEmojis)
|
||||
holder.binding.usernameTextView.text = account.username
|
||||
|
|
@ -204,10 +226,19 @@ class AccountsInListFragment : DialogFragment(), Injectable {
|
|||
}
|
||||
}
|
||||
|
||||
inner class SearchAdapter : ListAdapter<AccountInfo, BindingHolder<ItemFollowRequestBinding>>(SearchDiffer) {
|
||||
inner class SearchAdapter : ListAdapter<AccountInfo, BindingHolder<ItemFollowRequestBinding>>(
|
||||
SearchDiffer
|
||||
) {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemFollowRequestBinding> {
|
||||
val binding = ItemFollowRequestBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
viewType: Int
|
||||
): BindingHolder<ItemFollowRequestBinding> {
|
||||
val binding = ItemFollowRequestBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
val holder = BindingHolder(binding)
|
||||
|
||||
binding.notificationTextView.hide()
|
||||
|
|
@ -224,7 +255,10 @@ class AccountsInListFragment : DialogFragment(), Injectable {
|
|||
return holder
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: BindingHolder<ItemFollowRequestBinding>, position: Int) {
|
||||
override fun onBindViewHolder(
|
||||
holder: BindingHolder<ItemFollowRequestBinding>,
|
||||
position: Int
|
||||
) {
|
||||
val (account, inAList) = getItem(position)
|
||||
|
||||
holder.binding.displayNameTextView.text = account.name.emojify(account.emojis, holder.binding.displayNameTextView, animateEmojis)
|
||||
|
|
|
|||
|
|
@ -47,7 +47,9 @@ import com.keylesspalace.tusky.db.AccountManager;
|
|||
import com.keylesspalace.tusky.di.Injectable;
|
||||
import com.keylesspalace.tusky.interfaces.AccountSelectionListener;
|
||||
import com.keylesspalace.tusky.interfaces.PermissionRequester;
|
||||
import com.keylesspalace.tusky.settings.AppTheme;
|
||||
import com.keylesspalace.tusky.settings.PrefKeys;
|
||||
import com.keylesspalace.tusky.util.ActivityExtensions;
|
||||
import com.keylesspalace.tusky.util.ThemeUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
|
|
@ -56,10 +58,17 @@ import java.util.List;
|
|||
|
||||
import javax.inject.Inject;
|
||||
|
||||
import static com.keylesspalace.tusky.settings.PrefKeys.APP_THEME;
|
||||
import static com.keylesspalace.tusky.util.ActivityExtensions.supportsOverridingActivityTransitions;
|
||||
|
||||
public abstract class BaseActivity extends AppCompatActivity implements Injectable {
|
||||
|
||||
public static final String OPEN_WITH_SLIDE_IN = "OPEN_WITH_SLIDE_IN";
|
||||
|
||||
private static final String TAG = "BaseActivity";
|
||||
|
||||
@Inject
|
||||
@NonNull
|
||||
public AccountManager accountManager;
|
||||
|
||||
private static final int REQUESTER_NONE = Integer.MAX_VALUE;
|
||||
|
|
@ -69,14 +78,19 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
|
|||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
if (supportsOverridingActivityTransitions() && activityTransitionWasRequested()) {
|
||||
overrideActivityTransition(OVERRIDE_TRANSITION_OPEN, R.anim.activity_open_enter, R.anim.activity_open_exit);
|
||||
overrideActivityTransition(OVERRIDE_TRANSITION_CLOSE, R.anim.activity_close_enter, R.anim.activity_close_exit);
|
||||
}
|
||||
|
||||
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this);
|
||||
|
||||
/* There isn't presently a way to globally change the theme of a whole application at
|
||||
* runtime, just individual activities. So, each activity has to set its theme before any
|
||||
* views are created. */
|
||||
String theme = preferences.getString("appTheme", ThemeUtils.APP_THEME_DEFAULT);
|
||||
String theme = preferences.getString(APP_THEME, AppTheme.DEFAULT.getValue());
|
||||
Log.d("activeTheme", theme);
|
||||
if (theme.equals("black")) {
|
||||
if (ThemeUtils.isBlack(getResources().getConfiguration(), theme)) {
|
||||
setTheme(R.style.TuskyBlackTheme);
|
||||
}
|
||||
|
||||
|
|
@ -87,7 +101,7 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
|
|||
|
||||
setTaskDescription(new ActivityManager.TaskDescription(appName, appIcon, recentsBackgroundColor));
|
||||
|
||||
int style = textStyle(preferences.getString("statusTextSize", "medium"));
|
||||
int style = textStyle(preferences.getString(PrefKeys.STATUS_TEXT_SIZE, "medium"));
|
||||
getTheme().applyStyle(style, true);
|
||||
|
||||
if(requiresLogin()) {
|
||||
|
|
@ -97,6 +111,10 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
|
|||
requesters = new HashMap<>();
|
||||
}
|
||||
|
||||
private boolean activityTransitionWasRequested() {
|
||||
return getIntent().getBooleanExtra(OPEN_WITH_SLIDE_IN, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void attachBaseContext(Context newBase) {
|
||||
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(newBase);
|
||||
|
|
@ -162,13 +180,8 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
|
|||
return style;
|
||||
}
|
||||
|
||||
public void startActivityWithSlideInAnimation(Intent intent) {
|
||||
super.startActivity(intent);
|
||||
overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(MenuItem item) {
|
||||
public boolean onOptionsItemSelected(@NonNull MenuItem item) {
|
||||
if (item.getItemId() == android.R.id.home) {
|
||||
getOnBackPressedDispatcher().onBackPressed();
|
||||
return true;
|
||||
|
|
@ -179,11 +192,10 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
|
|||
@Override
|
||||
public void finish() {
|
||||
super.finish();
|
||||
overridePendingTransition(R.anim.slide_from_left, R.anim.slide_to_right);
|
||||
}
|
||||
|
||||
public void finishWithoutSlideOutAnimation() {
|
||||
super.finish();
|
||||
// if this activity was opened with slide-in, close it with slide out
|
||||
if (!supportsOverridingActivityTransitions() && activityTransitionWasRequested()) {
|
||||
overridePendingTransition(R.anim.activity_close_enter, R.anim.activity_close_exit);
|
||||
}
|
||||
}
|
||||
|
||||
protected void redirectIfNotLoggedIn() {
|
||||
|
|
@ -191,12 +203,12 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
|
|||
if (account == null) {
|
||||
Intent intent = new Intent(this, LoginActivity.class);
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
startActivityWithSlideInAnimation(intent);
|
||||
ActivityExtensions.startActivityWithSlideInAnimation(this, intent);
|
||||
finish();
|
||||
}
|
||||
}
|
||||
|
||||
protected void showErrorDialog(View anyView, @StringRes int descriptionId, @StringRes int actionId, View.OnClickListener listener) {
|
||||
protected void showErrorDialog(@Nullable View anyView, @StringRes int descriptionId, @StringRes int actionId, @Nullable View.OnClickListener listener) {
|
||||
if (anyView != null) {
|
||||
Snackbar bar = Snackbar.make(anyView, getString(descriptionId), Snackbar.LENGTH_SHORT);
|
||||
bar.setAction(actionId, listener);
|
||||
|
|
@ -204,7 +216,7 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
|
|||
}
|
||||
}
|
||||
|
||||
public void showAccountChooserDialog(CharSequence dialogTitle, boolean showActiveAccount, AccountSelectionListener listener) {
|
||||
public void showAccountChooserDialog(@Nullable CharSequence dialogTitle, boolean showActiveAccount, @NonNull AccountSelectionListener listener) {
|
||||
List<AccountEntity> accounts = accountManager.getAllAccountsOrderedByActive();
|
||||
AccountEntity activeAccount = accountManager.getActiveAccount();
|
||||
|
||||
|
|
@ -231,9 +243,9 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
|
|||
adapter.addAll(accounts);
|
||||
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle(dialogTitle)
|
||||
.setAdapter(adapter, (dialogInterface, index) -> listener.onAccountSelected(accounts.get(index)))
|
||||
.show();
|
||||
.setTitle(dialogTitle)
|
||||
.setAdapter(adapter, (dialogInterface, index) -> listener.onAccountSelected(accounts.get(index)))
|
||||
.show();
|
||||
}
|
||||
|
||||
public @Nullable String getOpenAsText() {
|
||||
|
|
@ -256,11 +268,10 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
|
|||
|
||||
public void openAsAccount(@NonNull String url, @NonNull AccountEntity account) {
|
||||
accountManager.setActiveAccount(account.getId());
|
||||
Intent intent = new Intent(this, MainActivity.class);
|
||||
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
|
||||
intent.putExtra(MainActivity.REDIRECT_URL, url);
|
||||
Intent intent = MainActivity.redirectIntent(this, account.getId(), url);
|
||||
|
||||
startActivity(intent);
|
||||
finishWithoutSlideOutAnimation();
|
||||
finish();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
@ -272,7 +283,7 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
|
|||
}
|
||||
}
|
||||
|
||||
public void requestPermissions(String[] permissions, PermissionRequester requester) {
|
||||
public void requestPermissions(@NonNull String[] permissions, @NonNull PermissionRequester requester) {
|
||||
ArrayList<String> permissionsToRequest = new ArrayList<>();
|
||||
for(String permission: permissions) {
|
||||
if (ContextCompat.checkSelfPermission(this, permission) != PackageManager.PERMISSION_GRANTED) {
|
||||
|
|
|
|||
|
|
@ -22,17 +22,17 @@ import android.view.View
|
|||
import android.widget.LinearLayout
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider
|
||||
import autodispose2.autoDispose
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.keylesspalace.tusky.components.account.AccountActivity
|
||||
import com.keylesspalace.tusky.components.viewthread.ViewThreadActivity
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.looksLikeMastodonUrl
|
||||
import com.keylesspalace.tusky.util.openLink
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** this is the base class for all activities that open links
|
||||
* links are checked against the api if they are mastodon links so they can be opened in Tusky
|
||||
|
|
@ -64,45 +64,48 @@ abstract class BottomSheetActivity : BaseActivity() {
|
|||
})
|
||||
}
|
||||
|
||||
open fun viewUrl(url: String, lookupFallbackBehavior: PostLookupFallbackBehavior = PostLookupFallbackBehavior.OPEN_IN_BROWSER) {
|
||||
open fun viewUrl(
|
||||
url: String,
|
||||
lookupFallbackBehavior: PostLookupFallbackBehavior = PostLookupFallbackBehavior.OPEN_IN_BROWSER
|
||||
) {
|
||||
if (!looksLikeMastodonUrl(url)) {
|
||||
openLink(url)
|
||||
return
|
||||
}
|
||||
|
||||
mastodonApi.searchObservable(
|
||||
query = url,
|
||||
resolve = true
|
||||
).observeOn(AndroidSchedulers.mainThread())
|
||||
.autoDispose(AndroidLifecycleScopeProvider.from(this, Lifecycle.Event.ON_DESTROY))
|
||||
.subscribe(
|
||||
{ (accounts, statuses) ->
|
||||
lifecycleScope.launch {
|
||||
mastodonApi.search(
|
||||
query = url,
|
||||
resolve = true
|
||||
).fold(
|
||||
onSuccess = { (accounts, statuses) ->
|
||||
if (getCancelSearchRequested(url)) {
|
||||
return@subscribe
|
||||
return@launch
|
||||
}
|
||||
|
||||
onEndSearch(url)
|
||||
|
||||
if (statuses.isNotEmpty()) {
|
||||
viewThread(statuses[0].id, statuses[0].url)
|
||||
return@subscribe
|
||||
return@launch
|
||||
}
|
||||
accounts.firstOrNull { it.url == url }?.let { account ->
|
||||
accounts.firstOrNull { it.url.equals(url, ignoreCase = true) }?.let { account ->
|
||||
// Some servers return (unrelated) accounts for url searches (#2804)
|
||||
// Verify that the account's url matches the query
|
||||
viewAccount(account.id)
|
||||
return@subscribe
|
||||
return@launch
|
||||
}
|
||||
|
||||
performUrlFallbackAction(url, lookupFallbackBehavior)
|
||||
},
|
||||
{
|
||||
onFailure = {
|
||||
if (!getCancelSearchRequested(url)) {
|
||||
onEndSearch(url)
|
||||
performUrlFallbackAction(url, lookupFallbackBehavior)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
onBeginSearch(url)
|
||||
}
|
||||
|
|
@ -121,10 +124,17 @@ abstract class BottomSheetActivity : BaseActivity() {
|
|||
startActivityWithSlideInAnimation(intent)
|
||||
}
|
||||
|
||||
protected open fun performUrlFallbackAction(url: String, fallbackBehavior: PostLookupFallbackBehavior) {
|
||||
protected open fun performUrlFallbackAction(
|
||||
url: String,
|
||||
fallbackBehavior: PostLookupFallbackBehavior
|
||||
) {
|
||||
when (fallbackBehavior) {
|
||||
PostLookupFallbackBehavior.OPEN_IN_BROWSER -> openLink(url)
|
||||
PostLookupFallbackBehavior.DISPLAY_ERROR -> Toast.makeText(this, getString(R.string.post_lookup_error_format, url), Toast.LENGTH_SHORT).show()
|
||||
PostLookupFallbackBehavior.DISPLAY_ERROR -> Toast.makeText(
|
||||
this,
|
||||
getString(R.string.post_lookup_error_format, url),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,9 +25,11 @@ import android.view.Menu
|
|||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.core.widget.doAfterTextChanged
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.bumptech.glide.Glide
|
||||
|
|
@ -46,15 +48,18 @@ import com.keylesspalace.tusky.di.ViewModelFactory
|
|||
import com.keylesspalace.tusky.util.Error
|
||||
import com.keylesspalace.tusky.util.Loading
|
||||
import com.keylesspalace.tusky.util.Success
|
||||
import com.keylesspalace.tusky.util.await
|
||||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.viewmodel.EditProfileViewModel
|
||||
import com.keylesspalace.tusky.viewmodel.ProfileDataInUi
|
||||
import com.mikepenz.iconics.IconicsDrawable
|
||||
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
|
||||
import com.mikepenz.iconics.utils.colorInt
|
||||
import com.mikepenz.iconics.utils.sizeDp
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class EditProfileActivity : BaseActivity(), Injectable {
|
||||
|
||||
|
|
@ -96,6 +101,14 @@ class EditProfileActivity : BaseActivity(), Injectable {
|
|||
}
|
||||
}
|
||||
|
||||
private val currentProfileData
|
||||
get() = ProfileDataInUi(
|
||||
displayName = binding.displayNameEditText.text.toString(),
|
||||
note = binding.noteEditText.text.toString(),
|
||||
locked = binding.lockedCheckBox.isChecked,
|
||||
fields = accountFieldEditAdapter.getFieldData()
|
||||
)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
|
|
@ -114,9 +127,17 @@ class EditProfileActivity : BaseActivity(), Injectable {
|
|||
binding.fieldList.layoutManager = LinearLayoutManager(this)
|
||||
binding.fieldList.adapter = accountFieldEditAdapter
|
||||
|
||||
val plusDrawable = IconicsDrawable(this, GoogleMaterial.Icon.gmd_add).apply { sizeDp = 12; colorInt = Color.WHITE }
|
||||
val plusDrawable = IconicsDrawable(this, GoogleMaterial.Icon.gmd_add).apply {
|
||||
sizeDp = 12
|
||||
colorInt = Color.WHITE
|
||||
}
|
||||
|
||||
binding.addFieldButton.setCompoundDrawablesRelativeWithIntrinsicBounds(plusDrawable, null, null, null)
|
||||
binding.addFieldButton.setCompoundDrawablesRelativeWithIntrinsicBounds(
|
||||
plusDrawable,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)
|
||||
|
||||
binding.addFieldButton.setOnClickListener {
|
||||
accountFieldEditAdapter.addField()
|
||||
|
|
@ -131,52 +152,64 @@ class EditProfileActivity : BaseActivity(), Injectable {
|
|||
|
||||
viewModel.obtainProfile()
|
||||
|
||||
viewModel.profileData.observe(this) { profileRes ->
|
||||
when (profileRes) {
|
||||
is Success -> {
|
||||
val me = profileRes.data
|
||||
if (me != null) {
|
||||
binding.displayNameEditText.setText(me.displayName)
|
||||
binding.noteEditText.setText(me.source?.note)
|
||||
binding.lockedCheckBox.isChecked = me.locked
|
||||
lifecycleScope.launch {
|
||||
viewModel.profileData.collect { profileRes ->
|
||||
if (profileRes == null) return@collect
|
||||
when (profileRes) {
|
||||
is Success -> {
|
||||
val me = profileRes.data
|
||||
if (me != null) {
|
||||
binding.displayNameEditText.setText(me.displayName)
|
||||
binding.noteEditText.setText(me.source?.note)
|
||||
binding.lockedCheckBox.isChecked = me.locked
|
||||
|
||||
accountFieldEditAdapter.setFields(me.source?.fields.orEmpty())
|
||||
binding.addFieldButton.isVisible =
|
||||
(me.source?.fields?.size ?: 0) < maxAccountFields
|
||||
accountFieldEditAdapter.setFields(me.source?.fields.orEmpty())
|
||||
binding.addFieldButton.isVisible =
|
||||
(me.source?.fields?.size ?: 0) < maxAccountFields
|
||||
|
||||
if (viewModel.avatarData.value == null) {
|
||||
Glide.with(this)
|
||||
.load(me.avatar)
|
||||
.placeholder(R.drawable.avatar_default)
|
||||
.transform(
|
||||
FitCenter(),
|
||||
RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_80dp))
|
||||
)
|
||||
.into(binding.avatarPreview)
|
||||
}
|
||||
if (viewModel.avatarData.value == null) {
|
||||
Glide.with(this@EditProfileActivity)
|
||||
.load(me.avatar)
|
||||
.placeholder(R.drawable.avatar_default)
|
||||
.transform(
|
||||
FitCenter(),
|
||||
RoundedCorners(
|
||||
resources.getDimensionPixelSize(R.dimen.avatar_radius_80dp)
|
||||
)
|
||||
)
|
||||
.into(binding.avatarPreview)
|
||||
}
|
||||
|
||||
if (viewModel.headerData.value == null) {
|
||||
Glide.with(this)
|
||||
.load(me.header)
|
||||
.into(binding.headerPreview)
|
||||
if (viewModel.headerData.value == null) {
|
||||
Glide.with(this@EditProfileActivity)
|
||||
.load(me.header)
|
||||
.into(binding.headerPreview)
|
||||
}
|
||||
}
|
||||
}
|
||||
is Error -> {
|
||||
Snackbar.make(
|
||||
binding.avatarButton,
|
||||
R.string.error_generic,
|
||||
Snackbar.LENGTH_LONG
|
||||
)
|
||||
.setAction(R.string.action_retry) {
|
||||
viewModel.obtainProfile()
|
||||
}
|
||||
.show()
|
||||
}
|
||||
is Loading -> { }
|
||||
}
|
||||
is Error -> {
|
||||
Snackbar.make(binding.avatarButton, R.string.error_generic, Snackbar.LENGTH_LONG)
|
||||
.setAction(R.string.action_retry) {
|
||||
viewModel.obtainProfile()
|
||||
}
|
||||
.show()
|
||||
}
|
||||
is Loading -> { }
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
viewModel.instanceData.collect { instanceInfo ->
|
||||
maxAccountFields = instanceInfo.maxFields
|
||||
accountFieldEditAdapter.setFieldLimits(instanceInfo.maxFieldNameLength, instanceInfo.maxFieldValueLength)
|
||||
accountFieldEditAdapter.setFieldLimits(
|
||||
instanceInfo.maxFieldNameLength,
|
||||
instanceInfo.maxFieldValueLength
|
||||
)
|
||||
binding.addFieldButton.isVisible =
|
||||
accountFieldEditAdapter.itemCount < maxAccountFields
|
||||
}
|
||||
|
|
@ -185,60 +218,85 @@ class EditProfileActivity : BaseActivity(), Injectable {
|
|||
observeImage(viewModel.avatarData, binding.avatarPreview, true)
|
||||
observeImage(viewModel.headerData, binding.headerPreview, false)
|
||||
|
||||
viewModel.saveData.observe(
|
||||
this
|
||||
) {
|
||||
when (it) {
|
||||
is Success -> {
|
||||
finish()
|
||||
}
|
||||
is Loading -> {
|
||||
binding.saveProgressBar.visibility = View.VISIBLE
|
||||
}
|
||||
is Error -> {
|
||||
onSaveFailure(it.errorMessage)
|
||||
lifecycleScope.launch {
|
||||
viewModel.saveData.collect {
|
||||
if (it == null) return@collect
|
||||
when (it) {
|
||||
is Success -> {
|
||||
finish()
|
||||
}
|
||||
is Loading -> {
|
||||
binding.saveProgressBar.visibility = View.VISIBLE
|
||||
}
|
||||
is Error -> {
|
||||
onSaveFailure(it.errorMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
binding.displayNameEditText.doAfterTextChanged {
|
||||
viewModel.dataChanged(currentProfileData)
|
||||
}
|
||||
|
||||
binding.displayNameEditText.doAfterTextChanged {
|
||||
viewModel.dataChanged(currentProfileData)
|
||||
}
|
||||
|
||||
binding.lockedCheckBox.setOnCheckedChangeListener { _, _ ->
|
||||
viewModel.dataChanged(currentProfileData)
|
||||
}
|
||||
|
||||
accountFieldEditAdapter.onFieldsChanged = {
|
||||
viewModel.dataChanged(currentProfileData)
|
||||
}
|
||||
|
||||
val onBackCallback = object : OnBackPressedCallback(enabled = false) {
|
||||
override fun handleOnBackPressed() {
|
||||
showUnsavedChangesDialog()
|
||||
}
|
||||
}
|
||||
|
||||
onBackPressedDispatcher.addCallback(this, onBackCallback)
|
||||
lifecycleScope.launch {
|
||||
viewModel.isChanged.collect { dataWasChanged ->
|
||||
onBackCallback.isEnabled = dataWasChanged
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
if (!isFinishing) {
|
||||
viewModel.updateProfile(
|
||||
binding.displayNameEditText.text.toString(),
|
||||
binding.noteEditText.text.toString(),
|
||||
binding.lockedCheckBox.isChecked,
|
||||
accountFieldEditAdapter.getFieldData()
|
||||
)
|
||||
viewModel.updateProfile(currentProfileData)
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeImage(
|
||||
liveData: LiveData<Uri>,
|
||||
flow: StateFlow<Uri?>,
|
||||
imageView: ImageView,
|
||||
roundedCorners: Boolean
|
||||
) {
|
||||
liveData.observe(
|
||||
this
|
||||
) { imageUri ->
|
||||
lifecycleScope.launch {
|
||||
flow.collect { imageUri ->
|
||||
|
||||
// skipping all caches so we can always reuse the same uri
|
||||
val glide = Glide.with(imageView)
|
||||
.load(imageUri)
|
||||
.skipMemoryCache(true)
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
// skipping all caches so we can always reuse the same uri
|
||||
val glide = Glide.with(imageView)
|
||||
.load(imageUri)
|
||||
.skipMemoryCache(true)
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
|
||||
if (roundedCorners) {
|
||||
glide.transform(
|
||||
FitCenter(),
|
||||
RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_80dp))
|
||||
).into(imageView)
|
||||
} else {
|
||||
glide.into(imageView)
|
||||
if (roundedCorners) {
|
||||
glide.transform(
|
||||
FitCenter(),
|
||||
RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_80dp))
|
||||
).into(imageView)
|
||||
} else {
|
||||
glide.into(imageView)
|
||||
}
|
||||
|
||||
imageView.show()
|
||||
}
|
||||
|
||||
imageView.show()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -287,14 +345,7 @@ class EditProfileActivity : BaseActivity(), Injectable {
|
|||
return super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
private fun save() {
|
||||
viewModel.save(
|
||||
binding.displayNameEditText.text.toString(),
|
||||
binding.noteEditText.text.toString(),
|
||||
binding.lockedCheckBox.isChecked,
|
||||
accountFieldEditAdapter.getFieldData()
|
||||
)
|
||||
}
|
||||
private fun save() = viewModel.save(currentProfileData)
|
||||
|
||||
private fun onSaveFailure(msg: String?) {
|
||||
val errorMsg = msg ?: getString(R.string.error_media_upload_sending)
|
||||
|
|
@ -304,6 +355,22 @@ class EditProfileActivity : BaseActivity(), Injectable {
|
|||
|
||||
private fun onPickFailure(throwable: Throwable?) {
|
||||
Log.w("EditProfileActivity", "failed to pick media", throwable)
|
||||
Snackbar.make(binding.avatarButton, R.string.error_media_upload_sending, Snackbar.LENGTH_LONG).show()
|
||||
Snackbar.make(
|
||||
binding.avatarButton,
|
||||
R.string.error_media_upload_sending,
|
||||
Snackbar.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
|
||||
private fun showUnsavedChangesDialog() = lifecycleScope.launch {
|
||||
when (launchSaveDialog()) {
|
||||
AlertDialog.BUTTON_POSITIVE -> save()
|
||||
else -> finish()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun launchSaveDialog() = AlertDialog.Builder(this)
|
||||
.setMessage(getString(R.string.dialog_save_profile_changes_message))
|
||||
.create()
|
||||
.await(R.string.action_save, R.string.action_discard)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,11 +19,14 @@ import android.os.Bundle
|
|||
import android.util.Log
|
||||
import android.widget.TextView
|
||||
import androidx.annotation.RawRes
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.keylesspalace.tusky.databinding.ActivityLicenseBinding
|
||||
import com.keylesspalace.tusky.util.closeQuietly
|
||||
import java.io.BufferedReader
|
||||
import java.io.IOException
|
||||
import java.io.InputStreamReader
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okio.buffer
|
||||
import okio.source
|
||||
|
||||
class LicenseActivity : BaseActivity() {
|
||||
|
||||
|
|
@ -44,23 +47,15 @@ class LicenseActivity : BaseActivity() {
|
|||
}
|
||||
|
||||
private fun loadFileIntoTextView(@RawRes fileId: Int, textView: TextView) {
|
||||
val sb = StringBuilder()
|
||||
|
||||
val br = BufferedReader(InputStreamReader(resources.openRawResource(fileId)))
|
||||
|
||||
try {
|
||||
var line: String? = br.readLine()
|
||||
while (line != null) {
|
||||
sb.append(line)
|
||||
sb.append('\n')
|
||||
line = br.readLine()
|
||||
lifecycleScope.launch {
|
||||
textView.text = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
resources.openRawResource(fileId).source().buffer().use { it.readUtf8() }
|
||||
} catch (e: IOException) {
|
||||
Log.w("LicenseActivity", e)
|
||||
""
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w("LicenseActivity", e)
|
||||
}
|
||||
|
||||
br.closeQuietly()
|
||||
|
||||
textView.text = sb.toString()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
/* Copyright 2017 Andrew Dawson
|
||||
/* Copyright Tusky contributors
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
|
|
@ -23,11 +23,7 @@ import android.os.Bundle
|
|||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.EditText
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageButton
|
||||
import android.widget.PopupMenu
|
||||
import android.widget.TextView
|
||||
import androidx.activity.viewModels
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
|
|
@ -37,16 +33,17 @@ import androidx.recyclerview.widget.DiffUtil
|
|||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import at.connyduck.sparkbutton.helpers.Utils
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.keylesspalace.tusky.databinding.ActivityListsBinding
|
||||
import com.keylesspalace.tusky.databinding.DialogListBinding
|
||||
import com.keylesspalace.tusky.databinding.ItemListBinding
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||
import com.keylesspalace.tusky.entity.MastoList
|
||||
import com.keylesspalace.tusky.util.BindingHolder
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
import com.keylesspalace.tusky.viewmodel.ListsViewModel
|
||||
|
|
@ -56,18 +53,12 @@ import com.keylesspalace.tusky.viewmodel.ListsViewModel.LoadingState.ERROR_OTHER
|
|||
import com.keylesspalace.tusky.viewmodel.ListsViewModel.LoadingState.INITIAL
|
||||
import com.keylesspalace.tusky.viewmodel.ListsViewModel.LoadingState.LOADED
|
||||
import com.keylesspalace.tusky.viewmodel.ListsViewModel.LoadingState.LOADING
|
||||
import com.mikepenz.iconics.IconicsDrawable
|
||||
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
|
||||
import com.mikepenz.iconics.utils.colorInt
|
||||
import com.mikepenz.iconics.utils.sizeDp
|
||||
import dagger.android.DispatchingAndroidInjector
|
||||
import dagger.android.HasAndroidInjector
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Created by charlag on 1/4/18.
|
||||
*/
|
||||
// TODO use the ListSelectionFragment (and/or its adapter or binding) here; but keep the LoadingState from here (?)
|
||||
|
||||
class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
|
||||
|
||||
|
|
@ -118,7 +109,7 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
|
|||
viewModel.events.collect { event ->
|
||||
when (event) {
|
||||
Event.CREATE_ERROR -> showMessage(R.string.error_create_list)
|
||||
Event.RENAME_ERROR -> showMessage(R.string.error_rename_list)
|
||||
Event.UPDATE_ERROR -> showMessage(R.string.error_rename_list)
|
||||
Event.DELETE_ERROR -> showMessage(R.string.error_delete_list)
|
||||
}
|
||||
}
|
||||
|
|
@ -126,16 +117,11 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
|
|||
}
|
||||
|
||||
private fun showlistNameDialog(list: MastoList?) {
|
||||
val layout = FrameLayout(this)
|
||||
val editText = EditText(this)
|
||||
editText.setHint(R.string.hint_list_name)
|
||||
layout.addView(editText)
|
||||
val margin = Utils.dpToPx(this, 8)
|
||||
(editText.layoutParams as ViewGroup.MarginLayoutParams)
|
||||
.setMargins(margin, margin, margin, 0)
|
||||
|
||||
val binding = DialogListBinding.inflate(layoutInflater).apply {
|
||||
replyPolicySpinner.setSelection(MastoList.ReplyPolicy.from(list?.repliesPolicy).ordinal)
|
||||
}
|
||||
val dialog = AlertDialog.Builder(this)
|
||||
.setView(layout)
|
||||
.setView(binding.root)
|
||||
.setPositiveButton(
|
||||
if (list == null) {
|
||||
R.string.action_create_list
|
||||
|
|
@ -143,17 +129,31 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
|
|||
R.string.action_rename_list
|
||||
}
|
||||
) { _, _ ->
|
||||
onPickedDialogName(editText.text, list?.id)
|
||||
onPickedDialogName(
|
||||
binding.nameText.text.toString(),
|
||||
list?.id,
|
||||
binding.exclusiveCheckbox.isChecked,
|
||||
MastoList.ReplyPolicy.entries[binding.replyPolicySpinner.selectedItemPosition].policy
|
||||
)
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
|
||||
val positiveButton = dialog.getButton(Dialog.BUTTON_POSITIVE)
|
||||
editText.doOnTextChanged { s, _, _, _ ->
|
||||
positiveButton.isEnabled = s?.isNotBlank() == true
|
||||
binding.nameText.let { editText ->
|
||||
editText.doOnTextChanged { s, _, _, _ ->
|
||||
dialog.getButton(Dialog.BUTTON_POSITIVE).isEnabled = s?.isNotBlank() == true
|
||||
}
|
||||
editText.setText(list?.title)
|
||||
editText.text?.let { editText.setSelection(it.length) }
|
||||
}
|
||||
|
||||
list?.let {
|
||||
if (it.exclusive == null) {
|
||||
binding.exclusiveCheckbox.visible(false)
|
||||
} else {
|
||||
binding.exclusiveCheckbox.isChecked = it.exclusive
|
||||
}
|
||||
}
|
||||
editText.setText(list?.title)
|
||||
editText.text?.let { editText.setSelection(it.length) }
|
||||
}
|
||||
|
||||
private fun showListDeleteDialog(list: MastoList) {
|
||||
|
|
@ -174,13 +174,13 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
|
|||
INITIAL, LOADING -> binding.messageView.hide()
|
||||
ERROR_NETWORK -> {
|
||||
binding.messageView.show()
|
||||
binding.messageView.setup(R.drawable.elephant_offline, R.string.error_network) {
|
||||
binding.messageView.setup(R.drawable.errorphant_offline, R.string.error_network) {
|
||||
viewModel.retryLoading()
|
||||
}
|
||||
}
|
||||
ERROR_OTHER -> {
|
||||
binding.messageView.show()
|
||||
binding.messageView.setup(R.drawable.elephant_error, R.string.error_generic) {
|
||||
binding.messageView.setup(R.drawable.errorphant_error, R.string.error_generic) {
|
||||
viewModel.retryLoading()
|
||||
}
|
||||
}
|
||||
|
|
@ -192,6 +192,7 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
|
|||
R.string.message_empty,
|
||||
null
|
||||
)
|
||||
binding.messageView.showHelp(R.string.help_empty_lists)
|
||||
} else {
|
||||
binding.messageView.hide()
|
||||
}
|
||||
|
|
@ -206,9 +207,9 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
|
|||
).show()
|
||||
}
|
||||
|
||||
private fun onListSelected(listId: String, listTitle: String) {
|
||||
private fun onListSelected(list: MastoList) {
|
||||
startActivityWithSlideInAnimation(
|
||||
StatusListActivity.newListIntent(this, listId, listTitle)
|
||||
StatusListActivity.newListIntent(this, list.id, list.title)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -226,7 +227,7 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
|
|||
setOnMenuItemClickListener { item ->
|
||||
when (item.itemId) {
|
||||
R.id.list_edit -> openListSettings(list)
|
||||
R.id.list_rename -> renameListDialog(list)
|
||||
R.id.list_update -> renameListDialog(list)
|
||||
R.id.list_delete -> showListDeleteDialog(list)
|
||||
else -> return@setOnMenuItemClickListener false
|
||||
}
|
||||
|
|
@ -247,51 +248,42 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
|
|||
}
|
||||
|
||||
private inner class ListsAdapter :
|
||||
ListAdapter<MastoList, ListsAdapter.ListViewHolder>(ListsDiffer) {
|
||||
ListAdapter<MastoList, BindingHolder<ItemListBinding>>(ListsDiffer) {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ListViewHolder {
|
||||
return LayoutInflater.from(parent.context).inflate(R.layout.item_list, parent, false)
|
||||
.let(this::ListViewHolder)
|
||||
.apply {
|
||||
val iconColor = MaterialColors.getColor(nameTextView, android.R.attr.textColorTertiary)
|
||||
val context = nameTextView.context
|
||||
val icon = IconicsDrawable(context, GoogleMaterial.Icon.gmd_list).apply { sizeDp = 20; colorInt = iconColor }
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
viewType: Int
|
||||
): BindingHolder<ItemListBinding> {
|
||||
return BindingHolder(ItemListBinding.inflate(LayoutInflater.from(parent.context), parent, false))
|
||||
}
|
||||
|
||||
nameTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(icon, null, null, null)
|
||||
override fun onBindViewHolder(holder: BindingHolder<ItemListBinding>, position: Int) {
|
||||
val item = getItem(position)
|
||||
holder.binding.listName.text = item.title
|
||||
|
||||
holder.binding.moreButton.apply {
|
||||
visible(true)
|
||||
setOnClickListener {
|
||||
onMore(item, holder.binding.moreButton)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ListViewHolder, position: Int) {
|
||||
holder.nameTextView.text = getItem(position).title
|
||||
}
|
||||
|
||||
private inner class ListViewHolder(view: View) :
|
||||
RecyclerView.ViewHolder(view),
|
||||
View.OnClickListener {
|
||||
val nameTextView: TextView = view.findViewById(R.id.list_name_textview)
|
||||
val moreButton: ImageButton = view.findViewById(R.id.editListButton)
|
||||
|
||||
init {
|
||||
view.setOnClickListener(this)
|
||||
moreButton.setOnClickListener(this)
|
||||
}
|
||||
|
||||
override fun onClick(v: View) {
|
||||
if (v == itemView) {
|
||||
val list = getItem(bindingAdapterPosition)
|
||||
onListSelected(list.id, list.title)
|
||||
} else {
|
||||
onMore(getItem(bindingAdapterPosition), v)
|
||||
}
|
||||
holder.itemView.setOnClickListener {
|
||||
onListSelected(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onPickedDialogName(name: CharSequence, listId: String?) {
|
||||
private fun onPickedDialogName(
|
||||
name: String,
|
||||
listId: String?,
|
||||
exclusive: Boolean,
|
||||
replyPolicy: String
|
||||
) {
|
||||
if (listId == null) {
|
||||
viewModel.createNewList(name.toString())
|
||||
viewModel.createNewList(name, exclusive, replyPolicy)
|
||||
} else {
|
||||
viewModel.renameList(listId, name.toString())
|
||||
viewModel.updateList(listId, name, exclusive, replyPolicy)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@
|
|||
package com.keylesspalace.tusky
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.content.DialogInterface
|
||||
import android.content.Intent
|
||||
|
|
@ -26,6 +28,7 @@ import android.graphics.drawable.Animatable
|
|||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.text.TextUtils
|
||||
import android.util.Log
|
||||
|
|
@ -33,6 +36,7 @@ import android.view.KeyEvent
|
|||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.MenuItem.SHOW_AS_ACTION_NEVER
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
|
|
@ -41,15 +45,18 @@ import androidx.appcompat.content.res.AppCompatResources
|
|||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.IntentCompat
|
||||
import androidx.core.content.pm.ShortcutManagerCompat
|
||||
import androidx.core.view.GravityCompat
|
||||
import androidx.core.view.MenuProvider
|
||||
import androidx.core.view.forEach
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.drawerlayout.widget.DrawerLayout
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.viewpager2.widget.MarginPageTransformer
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.RequestManager
|
||||
import com.bumptech.glide.load.resource.bitmap.RoundedCorners
|
||||
import com.bumptech.glide.request.target.CustomTarget
|
||||
import com.bumptech.glide.request.target.FixedSizeDrawable
|
||||
|
|
@ -60,8 +67,11 @@ import com.google.android.material.tabs.TabLayout.OnTabSelectedListener
|
|||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import com.keylesspalace.tusky.appstore.AnnouncementReadEvent
|
||||
import com.keylesspalace.tusky.appstore.CacheUpdater
|
||||
import com.keylesspalace.tusky.appstore.ConversationsLoadingEvent
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.appstore.MainTabsChangedEvent
|
||||
import com.keylesspalace.tusky.appstore.NewNotificationsEvent
|
||||
import com.keylesspalace.tusky.appstore.NotificationsLoadingEvent
|
||||
import com.keylesspalace.tusky.appstore.ProfileEditedEvent
|
||||
import com.keylesspalace.tusky.components.account.AccountActivity
|
||||
import com.keylesspalace.tusky.components.accountlist.AccountListActivity
|
||||
|
|
@ -81,8 +91,10 @@ import com.keylesspalace.tusky.components.trending.TrendingActivity
|
|||
import com.keylesspalace.tusky.databinding.ActivityMainBinding
|
||||
import com.keylesspalace.tusky.db.AccountEntity
|
||||
import com.keylesspalace.tusky.db.DraftsAlert
|
||||
import com.keylesspalace.tusky.di.ApplicationScope
|
||||
import com.keylesspalace.tusky.entity.Account
|
||||
import com.keylesspalace.tusky.entity.Notification
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.interfaces.AccountSelectionListener
|
||||
import com.keylesspalace.tusky.interfaces.ActionButtonActivity
|
||||
import com.keylesspalace.tusky.interfaces.FabFragment
|
||||
|
|
@ -91,16 +103,17 @@ import com.keylesspalace.tusky.pager.MainPagerAdapter
|
|||
import com.keylesspalace.tusky.settings.PrefKeys
|
||||
import com.keylesspalace.tusky.usecase.DeveloperToolsUseCase
|
||||
import com.keylesspalace.tusky.usecase.LogoutUsecase
|
||||
import com.keylesspalace.tusky.util.ShareShortcutHelper
|
||||
import com.keylesspalace.tusky.util.deleteStaleCachedMedia
|
||||
import com.keylesspalace.tusky.util.emojify
|
||||
import com.keylesspalace.tusky.util.getDimension
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.reduceSwipeSensitivity
|
||||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
|
||||
import com.keylesspalace.tusky.util.supportsOverridingActivityTransitions
|
||||
import com.keylesspalace.tusky.util.unsafeLazy
|
||||
import com.keylesspalace.tusky.util.updateShortcut
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
import com.mikepenz.iconics.IconicsDrawable
|
||||
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
|
||||
import com.mikepenz.iconics.utils.colorInt
|
||||
|
|
@ -131,9 +144,10 @@ import com.mikepenz.materialdrawer.widget.AccountHeaderView
|
|||
import dagger.android.DispatchingAndroidInjector
|
||||
import dagger.android.HasAndroidInjector
|
||||
import de.c1710.filemojicompat_ui.helpers.EMOJI_PREFERENCE
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInjector, MenuProvider {
|
||||
@Inject
|
||||
|
|
@ -154,21 +168,23 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
@Inject
|
||||
lateinit var developerToolsUseCase: DeveloperToolsUseCase
|
||||
|
||||
@Inject
|
||||
lateinit var shareShortcutHelper: ShareShortcutHelper
|
||||
|
||||
@Inject
|
||||
@ApplicationScope
|
||||
lateinit var externalScope: CoroutineScope
|
||||
|
||||
private val binding by viewBinding(ActivityMainBinding::inflate)
|
||||
|
||||
private lateinit var header: AccountHeaderView
|
||||
|
||||
private var notificationTabPosition = 0
|
||||
private var onTabSelectedListener: OnTabSelectedListener? = null
|
||||
|
||||
private var unreadAnnouncementsCount = 0
|
||||
|
||||
private val preferences by unsafeLazy { PreferenceManager.getDefaultSharedPreferences(this) }
|
||||
|
||||
private lateinit var glide: RequestManager
|
||||
|
||||
private var accountLocked: Boolean = false
|
||||
|
||||
// We need to know if the emoji pack has been changed
|
||||
private var selectedEmojiPack: String? = null
|
||||
|
||||
|
|
@ -178,37 +194,68 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
/** Adapter for the different timeline tabs */
|
||||
private lateinit var tabAdapter: MainPagerAdapter
|
||||
|
||||
private var directMessageTab: TabLayout.Tab? = null
|
||||
|
||||
private val onBackPressedCallback = object : OnBackPressedCallback(false) {
|
||||
override fun handleOnBackPressed() {
|
||||
when {
|
||||
binding.mainDrawerLayout.isOpen -> {
|
||||
binding.mainDrawerLayout.close()
|
||||
}
|
||||
binding.viewPager.currentItem != 0 -> {
|
||||
binding.viewPager.currentItem = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("RestrictedApi")
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val activeAccount = accountManager.activeAccount
|
||||
?: return // will be redirected to LoginActivity by BaseActivity
|
||||
|
||||
if (supportsOverridingActivityTransitions() && explodeAnimationWasRequested()) {
|
||||
overrideActivityTransition(OVERRIDE_TRANSITION_OPEN, R.anim.explode, R.anim.activity_open_exit)
|
||||
}
|
||||
|
||||
var showNotificationTab = false
|
||||
if (intent != null) {
|
||||
|
||||
// check for savedInstanceState in order to not handle intent events more than once
|
||||
if (intent != null && savedInstanceState == null) {
|
||||
val notificationId = intent.getIntExtra(NOTIFICATION_ID, -1)
|
||||
if (notificationId != -1) {
|
||||
// opened from a notification action, cancel the notification
|
||||
val notificationManager = getSystemService(
|
||||
NOTIFICATION_SERVICE
|
||||
) as NotificationManager
|
||||
notificationManager.cancel(intent.getStringExtra(NOTIFICATION_TAG), notificationId)
|
||||
}
|
||||
|
||||
/** there are two possibilities the accountId can be passed to MainActivity:
|
||||
* - from our code as long 'account_id'
|
||||
* - from our code as Long Intent Extra TUSKY_ACCOUNT_ID
|
||||
* - from share shortcuts as String 'android.intent.extra.shortcut.ID'
|
||||
*/
|
||||
var accountId = intent.getLongExtra(NotificationHelper.ACCOUNT_ID, -1)
|
||||
if (accountId == -1L) {
|
||||
var tuskyAccountId = intent.getLongExtra(TUSKY_ACCOUNT_ID, -1)
|
||||
if (tuskyAccountId == -1L) {
|
||||
val accountIdString = intent.getStringExtra(ShortcutManagerCompat.EXTRA_SHORTCUT_ID)
|
||||
if (accountIdString != null) {
|
||||
accountId = accountIdString.toLong()
|
||||
tuskyAccountId = accountIdString.toLong()
|
||||
}
|
||||
}
|
||||
val accountRequested = accountId != -1L
|
||||
if (accountRequested && accountId != activeAccount.id) {
|
||||
accountManager.setActiveAccount(accountId)
|
||||
val accountRequested = tuskyAccountId != -1L
|
||||
if (accountRequested && tuskyAccountId != activeAccount.id) {
|
||||
accountManager.setActiveAccount(tuskyAccountId)
|
||||
}
|
||||
|
||||
val openDrafts = intent.getBooleanExtra(OPEN_DRAFTS, false)
|
||||
|
||||
if (canHandleMimeType(intent.type)) {
|
||||
if (canHandleMimeType(intent.type) || intent.hasExtra(COMPOSE_OPTIONS)) {
|
||||
// Sharing to Tusky from an external app
|
||||
if (accountRequested) {
|
||||
// The correct account is already active
|
||||
forwardShare(intent)
|
||||
forwardToComposeActivity(intent)
|
||||
} else {
|
||||
// No account was provided, show the chooser
|
||||
showAccountChooserDialog(
|
||||
|
|
@ -219,10 +266,10 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
val requestedId = account.id
|
||||
if (requestedId == activeAccount.id) {
|
||||
// The correct account is already active
|
||||
forwardShare(intent)
|
||||
forwardToComposeActivity(intent)
|
||||
} else {
|
||||
// A different account was requested, restart the activity
|
||||
intent.putExtra(NotificationHelper.ACCOUNT_ID, requestedId)
|
||||
intent.putExtra(TUSKY_ACCOUNT_ID, requestedId)
|
||||
changeAccount(requestedId, intent)
|
||||
}
|
||||
}
|
||||
|
|
@ -232,11 +279,14 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
} else if (openDrafts) {
|
||||
val intent = DraftsActivity.newIntent(this)
|
||||
startActivity(intent)
|
||||
} else if (accountRequested && savedInstanceState == null) {
|
||||
} else if (accountRequested && intent.hasExtra(NOTIFICATION_TYPE)) {
|
||||
// user clicked a notification, show follow requests for type FOLLOW_REQUEST,
|
||||
// otherwise show notification tab
|
||||
if (intent.getStringExtra(NotificationHelper.TYPE) == Notification.Type.FOLLOW_REQUEST.name) {
|
||||
val intent = AccountListActivity.newIntent(this, AccountListActivity.Type.FOLLOW_REQUESTS, accountLocked = true)
|
||||
if (intent.getStringExtra(NOTIFICATION_TYPE) == Notification.Type.FOLLOW_REQUEST.name) {
|
||||
val intent = AccountListActivity.newIntent(
|
||||
this,
|
||||
AccountListActivity.Type.FOLLOW_REQUESTS
|
||||
)
|
||||
startActivityWithSlideInAnimation(intent)
|
||||
} else {
|
||||
showNotificationTab = true
|
||||
|
|
@ -245,17 +295,27 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
}
|
||||
window.statusBarColor = Color.TRANSPARENT // don't draw a status bar, the DrawerLayout and the MaterialDrawerLayout have their own
|
||||
setContentView(binding.root)
|
||||
setSupportActionBar(binding.mainToolbar)
|
||||
|
||||
glide = Glide.with(this)
|
||||
|
||||
binding.composeButton.setOnClickListener {
|
||||
val composeIntent = Intent(applicationContext, ComposeActivity::class.java)
|
||||
startActivity(composeIntent)
|
||||
}
|
||||
|
||||
// Determine which of the three toolbars should be the supportActionBar (which hosts
|
||||
// the options menu).
|
||||
val hideTopToolbar = preferences.getBoolean(PrefKeys.HIDE_TOP_TOOLBAR, false)
|
||||
binding.mainToolbar.visible(!hideTopToolbar)
|
||||
if (hideTopToolbar) {
|
||||
when (preferences.getString(PrefKeys.MAIN_NAV_POSITION, "top")) {
|
||||
"top" -> setSupportActionBar(binding.topNav)
|
||||
"bottom" -> setSupportActionBar(binding.bottomNav)
|
||||
}
|
||||
binding.mainToolbar.hide()
|
||||
// There's not enough space in the top/bottom bars to show the title as well.
|
||||
supportActionBar?.setDisplayShowTitleEnabled(false)
|
||||
} else {
|
||||
setSupportActionBar(binding.mainToolbar)
|
||||
binding.mainToolbar.show()
|
||||
}
|
||||
|
||||
loadDrawerAvatar(activeAccount.profilePictureUrl, true)
|
||||
|
||||
|
|
@ -266,7 +326,12 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
setupDrawer(
|
||||
savedInstanceState,
|
||||
addSearchButton = hideTopToolbar,
|
||||
addTrendingButton = !accountManager.activeAccount!!.tabPreferences.hasTab(TRENDING)
|
||||
addTrendingTagsButton = !accountManager.activeAccount!!.tabPreferences.hasTab(
|
||||
TRENDING_TAGS
|
||||
),
|
||||
addTrendingStatusesButton = !accountManager.activeAccount!!.tabPreferences.hasTab(
|
||||
TRENDING_STATUSES
|
||||
)
|
||||
)
|
||||
|
||||
/* Fetch user info while we're doing other things. This has to be done after setting up the
|
||||
|
|
@ -291,47 +356,57 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
is MainTabsChangedEvent -> {
|
||||
refreshMainDrawerItems(
|
||||
addSearchButton = hideTopToolbar,
|
||||
addTrendingButton = !event.newTabs.hasTab(TRENDING)
|
||||
addTrendingTagsButton = !event.newTabs.hasTab(TRENDING_TAGS),
|
||||
addTrendingStatusesButton = !event.newTabs.hasTab(TRENDING_STATUSES)
|
||||
)
|
||||
|
||||
setupTabs(false)
|
||||
}
|
||||
|
||||
is AnnouncementReadEvent -> {
|
||||
unreadAnnouncementsCount--
|
||||
updateAnnouncementsBadge()
|
||||
}
|
||||
is NewNotificationsEvent -> {
|
||||
directMessageTab?.let {
|
||||
if (event.accountId == activeAccount.accountId) {
|
||||
val hasDirectMessageNotification =
|
||||
event.notifications.any {
|
||||
it.type == Notification.Type.MENTION && it.status?.visibility == Status.Visibility.DIRECT
|
||||
}
|
||||
|
||||
if (hasDirectMessageNotification) {
|
||||
showDirectMessageBadge(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
is NotificationsLoadingEvent -> {
|
||||
if (event.accountId == activeAccount.accountId) {
|
||||
showDirectMessageBadge(false)
|
||||
}
|
||||
}
|
||||
is ConversationsLoadingEvent -> {
|
||||
if (event.accountId == activeAccount.accountId) {
|
||||
showDirectMessageBadge(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Schedulers.io().scheduleDirect {
|
||||
externalScope.launch(Dispatchers.IO) {
|
||||
// Flush old media that was cached for sharing
|
||||
deleteStaleCachedMedia(applicationContext.getExternalFilesDir("Tusky"))
|
||||
}
|
||||
|
||||
selectedEmojiPack = preferences.getString(EMOJI_PREFERENCE, "")
|
||||
|
||||
onBackPressedDispatcher.addCallback(
|
||||
this,
|
||||
object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
when {
|
||||
binding.mainDrawerLayout.isOpen -> {
|
||||
binding.mainDrawerLayout.close()
|
||||
}
|
||||
binding.viewPager.currentItem != 0 -> {
|
||||
binding.viewPager.currentItem = 0
|
||||
}
|
||||
else -> {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
onBackPressedDispatcher.addCallback(this, onBackPressedCallback)
|
||||
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||
if (
|
||||
Build.VERSION.SDK_INT >= 33 &&
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
ActivityCompat.requestPermissions(
|
||||
this,
|
||||
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
||||
|
|
@ -343,6 +418,22 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
draftsAlert.observeInContext(this, true)
|
||||
}
|
||||
|
||||
private fun showDirectMessageBadge(showBadge: Boolean) {
|
||||
directMessageTab?.let { tab ->
|
||||
tab.badge?.isVisible = showBadge
|
||||
|
||||
// TODO a bit cumbersome (also for resetting)
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
accountManager.activeAccount?.let {
|
||||
if (it.hasDirectMessageBadge != showBadge) {
|
||||
it.hasDirectMessageBadge = showBadge
|
||||
accountManager.saveAccount(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||
menuInflater.inflate(R.menu.activity_main, menu)
|
||||
menu.findItem(R.id.action_search)?.apply {
|
||||
|
|
@ -353,6 +444,20 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
}
|
||||
}
|
||||
|
||||
override fun onPrepareMenu(menu: Menu) {
|
||||
super.onPrepareMenu(menu)
|
||||
|
||||
// If the main toolbar is hidden then there's no space in the top/bottomNav to show
|
||||
// the menu items as icons, so forceably disable them
|
||||
if (!binding.mainToolbar.isVisible) {
|
||||
menu.forEach {
|
||||
it.setShowAsAction(
|
||||
SHOW_AS_ACTION_NEVER
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMenuItemSelected(item: MenuItem): Boolean {
|
||||
return when (item.itemId) {
|
||||
R.id.action_search -> {
|
||||
|
|
@ -425,12 +530,23 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
}
|
||||
}
|
||||
|
||||
private fun forwardShare(intent: Intent) {
|
||||
val composeIntent = Intent(this, ComposeActivity::class.java)
|
||||
composeIntent.action = intent.action
|
||||
composeIntent.type = intent.type
|
||||
composeIntent.putExtras(intent)
|
||||
composeIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
private fun forwardToComposeActivity(intent: Intent) {
|
||||
val composeOptions = IntentCompat.getParcelableExtra(
|
||||
intent,
|
||||
COMPOSE_OPTIONS,
|
||||
ComposeActivity.ComposeOptions::class.java
|
||||
)
|
||||
|
||||
val composeIntent = if (composeOptions != null) {
|
||||
ComposeActivity.startIntent(this, composeOptions)
|
||||
} else {
|
||||
Intent(this, ComposeActivity::class.java).apply {
|
||||
action = intent.action
|
||||
type = intent.type
|
||||
putExtras(intent)
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
}
|
||||
}
|
||||
startActivity(composeIntent)
|
||||
finish()
|
||||
}
|
||||
|
|
@ -438,13 +554,14 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
private fun setupDrawer(
|
||||
savedInstanceState: Bundle?,
|
||||
addSearchButton: Boolean,
|
||||
addTrendingButton: Boolean
|
||||
addTrendingTagsButton: Boolean,
|
||||
addTrendingStatusesButton: Boolean
|
||||
) {
|
||||
val drawerOpenClickListener = View.OnClickListener { binding.mainDrawerLayout.open() }
|
||||
|
||||
binding.mainToolbar.setNavigationOnClickListener(drawerOpenClickListener)
|
||||
binding.topNavAvatar.setOnClickListener(drawerOpenClickListener)
|
||||
binding.bottomNavAvatar.setOnClickListener(drawerOpenClickListener)
|
||||
binding.topNav.setNavigationOnClickListener(drawerOpenClickListener)
|
||||
binding.bottomNav.setNavigationOnClickListener(drawerOpenClickListener)
|
||||
|
||||
header = AccountHeaderView(this).apply {
|
||||
headerBackgroundScaleType = ImageView.ScaleType.CENTER_CROP
|
||||
|
|
@ -468,17 +585,21 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
header.currentProfileName.ellipsize = TextUtils.TruncateAt.END
|
||||
|
||||
header.accountHeaderBackground.setColorFilter(getColor(R.color.headerBackgroundFilter))
|
||||
header.accountHeaderBackground.setBackgroundColor(MaterialColors.getColor(header, R.attr.colorBackgroundAccent))
|
||||
val animateAvatars = preferences.getBoolean("animateGifAvatars", false)
|
||||
header.accountHeaderBackground.setBackgroundColor(
|
||||
MaterialColors.getColor(header, R.attr.colorBackgroundAccent)
|
||||
)
|
||||
val animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false)
|
||||
|
||||
DrawerImageLoader.init(object : AbstractDrawerImageLoader() {
|
||||
override fun set(imageView: ImageView, uri: Uri, placeholder: Drawable, tag: String?) {
|
||||
if (animateAvatars) {
|
||||
glide.load(uri)
|
||||
Glide.with(imageView)
|
||||
.load(uri)
|
||||
.placeholder(placeholder)
|
||||
.into(imageView)
|
||||
} else {
|
||||
glide.asBitmap()
|
||||
Glide.with(imageView)
|
||||
.asBitmap()
|
||||
.load(uri)
|
||||
.placeholder(placeholder)
|
||||
.into(imageView)
|
||||
|
|
@ -486,12 +607,12 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
}
|
||||
|
||||
override fun cancel(imageView: ImageView) {
|
||||
glide.clear(imageView)
|
||||
// nothing to do, Glide already handles cancellation automatically
|
||||
}
|
||||
|
||||
override fun placeholder(ctx: Context, tag: String?): Drawable {
|
||||
if (tag == DrawerImageLoader.Tags.PROFILE.name || tag == DrawerImageLoader.Tags.PROFILE_DRAWER_ITEM.name) {
|
||||
return ctx.getDrawable(R.drawable.avatar_default)!!
|
||||
return AppCompatResources.getDrawable(ctx, R.drawable.avatar_default)!!
|
||||
}
|
||||
|
||||
return super.placeholder(ctx, tag)
|
||||
|
|
@ -499,12 +620,33 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
})
|
||||
|
||||
binding.mainDrawer.apply {
|
||||
refreshMainDrawerItems(addSearchButton, addTrendingButton)
|
||||
refreshMainDrawerItems(
|
||||
addSearchButton = addSearchButton,
|
||||
addTrendingTagsButton = addTrendingTagsButton,
|
||||
addTrendingStatusesButton = addTrendingStatusesButton
|
||||
)
|
||||
setSavedInstance(savedInstanceState)
|
||||
}
|
||||
binding.mainDrawerLayout.addDrawerListener(object : DrawerLayout.DrawerListener {
|
||||
override fun onDrawerSlide(drawerView: View, slideOffset: Float) { }
|
||||
|
||||
override fun onDrawerOpened(drawerView: View) {
|
||||
onBackPressedCallback.isEnabled = true
|
||||
}
|
||||
|
||||
override fun onDrawerClosed(drawerView: View) {
|
||||
onBackPressedCallback.isEnabled = binding.tabLayout.selectedTabPosition > 0
|
||||
}
|
||||
|
||||
override fun onDrawerStateChanged(newState: Int) { }
|
||||
})
|
||||
}
|
||||
|
||||
private fun refreshMainDrawerItems(addSearchButton: Boolean, addTrendingButton: Boolean) {
|
||||
private fun refreshMainDrawerItems(
|
||||
addSearchButton: Boolean,
|
||||
addTrendingTagsButton: Boolean,
|
||||
addTrendingStatusesButton: Boolean
|
||||
) {
|
||||
binding.mainDrawer.apply {
|
||||
itemAdapter.clear()
|
||||
tintStatusBar = true
|
||||
|
|
@ -538,7 +680,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
nameRes = R.string.action_view_follow_requests
|
||||
iconicsIcon = GoogleMaterial.Icon.gmd_person_add
|
||||
onClick = {
|
||||
val intent = AccountListActivity.newIntent(context, AccountListActivity.Type.FOLLOW_REQUESTS, accountLocked = accountLocked)
|
||||
val intent = AccountListActivity.newIntent(context, AccountListActivity.Type.FOLLOW_REQUESTS)
|
||||
startActivityWithSlideInAnimation(intent)
|
||||
}
|
||||
},
|
||||
|
|
@ -621,7 +763,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
)
|
||||
}
|
||||
|
||||
if (addTrendingButton) {
|
||||
if (addTrendingTagsButton) {
|
||||
binding.mainDrawer.addItemsAtPosition(
|
||||
5,
|
||||
primaryDrawerItem {
|
||||
|
|
@ -633,6 +775,19 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (addTrendingStatusesButton) {
|
||||
binding.mainDrawer.addItemsAtPosition(
|
||||
6,
|
||||
primaryDrawerItem {
|
||||
nameRes = R.string.title_public_trending_statuses
|
||||
iconicsIcon = GoogleMaterial.Icon.gmd_local_fire_department
|
||||
onClick = {
|
||||
startActivityWithSlideInAnimation(StatusListActivity.newTrendingIntent(context))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
|
|
@ -702,6 +857,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
// Detach any existing mediator before changing tab contents and attaching a new mediator
|
||||
tabLayoutMediator?.detach()
|
||||
|
||||
directMessageTab = null
|
||||
|
||||
tabAdapter.tabs = tabs
|
||||
tabAdapter.notifyItemRangeChanged(0, tabs.size)
|
||||
|
||||
|
|
@ -712,6 +869,12 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
LIST -> tabs[position].arguments[1]
|
||||
else -> getString(tabs[position].text)
|
||||
}
|
||||
if (tabs[position].id == DIRECT) {
|
||||
val badge = tab.orCreateBadge
|
||||
badge.isVisible = accountManager.activeAccount?.hasDirectMessageBadge ?: false
|
||||
badge.backgroundColor = MaterialColors.getColor(binding.mainDrawer, com.google.android.material.R.attr.colorPrimary)
|
||||
directMessageTab = tab
|
||||
}
|
||||
}.also { it.attach() }
|
||||
|
||||
// Selected tab is either
|
||||
|
|
@ -737,9 +900,22 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
|
||||
onTabSelectedListener = object : OnTabSelectedListener {
|
||||
override fun onTabSelected(tab: TabLayout.Tab) {
|
||||
onBackPressedCallback.isEnabled = tab.position > 0 || binding.mainDrawerLayout.isOpen
|
||||
|
||||
binding.mainToolbar.title = tab.contentDescription
|
||||
|
||||
refreshComposeButtonState(tabAdapter, tab.position)
|
||||
|
||||
if (tab == directMessageTab) {
|
||||
tab.badge?.isVisible = false
|
||||
|
||||
accountManager.activeAccount?.let {
|
||||
if (it.hasDirectMessageBadge) {
|
||||
it.hasDirectMessageBadge = false
|
||||
accountManager.saveAccount(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTabUnselected(tab: TabLayout.Tab) {}
|
||||
|
|
@ -756,10 +932,13 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
activeTabLayout.addOnTabSelectedListener(it)
|
||||
}
|
||||
|
||||
val activeTabPosition = if (selectNotificationTab) notificationTabPosition else 0
|
||||
supportActionBar?.title = tabs[activeTabPosition].title(this@MainActivity)
|
||||
supportActionBar?.title = tabs[position].title(this@MainActivity)
|
||||
binding.mainToolbar.setOnClickListener {
|
||||
(tabAdapter.getFragment(activeTabLayout.selectedTabPosition) as? ReselectableFragment)?.onReselect()
|
||||
(
|
||||
tabAdapter.getFragment(
|
||||
activeTabLayout.selectedTabPosition
|
||||
) as? ReselectableFragment
|
||||
)?.onReselect()
|
||||
}
|
||||
|
||||
updateProfiles()
|
||||
|
|
@ -790,7 +969,9 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
}
|
||||
// open LoginActivity to add new account
|
||||
if (profile.identifier == DRAWER_ITEM_ADD_ACCOUNT) {
|
||||
startActivityWithSlideInAnimation(LoginActivity.getIntent(this, LoginActivity.MODE_ADDITIONAL_LOGIN))
|
||||
startActivityWithSlideInAnimation(
|
||||
LoginActivity.getIntent(this, LoginActivity.MODE_ADDITIONAL_LOGIN)
|
||||
)
|
||||
return false
|
||||
}
|
||||
// change Account
|
||||
|
|
@ -802,15 +983,18 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
cacheUpdater.stop()
|
||||
accountManager.setActiveAccount(newSelectedId)
|
||||
val intent = Intent(this, MainActivity::class.java)
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
intent.putExtra(OPEN_WITH_EXPLODE_ANIMATION, true)
|
||||
if (forward != null) {
|
||||
intent.type = forward.type
|
||||
intent.action = forward.action
|
||||
intent.putExtras(forward)
|
||||
}
|
||||
startActivity(intent)
|
||||
finishWithoutSlideOutAnimation()
|
||||
overridePendingTransition(R.anim.explode, R.anim.explode)
|
||||
finish()
|
||||
if (!supportsOverridingActivityTransitions()) {
|
||||
@Suppress("DEPRECATION")
|
||||
overridePendingTransition(R.anim.explode, R.anim.activity_open_exit)
|
||||
}
|
||||
}
|
||||
|
||||
private fun logout() {
|
||||
|
|
@ -833,7 +1017,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
LoginActivity.getIntent(this@MainActivity, LoginActivity.MODE_DEFAULT)
|
||||
}
|
||||
startActivity(intent)
|
||||
finishWithoutSlideOutAnimation()
|
||||
finish()
|
||||
}
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
|
|
@ -853,17 +1037,26 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
}
|
||||
|
||||
private fun onFetchUserInfoSuccess(me: Account) {
|
||||
glide.asBitmap()
|
||||
Glide.with(header.accountHeaderBackground)
|
||||
.asBitmap()
|
||||
.load(me.header)
|
||||
.into(header.accountHeaderBackground)
|
||||
|
||||
loadDrawerAvatar(me.avatar, false)
|
||||
|
||||
accountManager.updateActiveAccount(me)
|
||||
NotificationHelper.createNotificationChannelsForAccount(accountManager.activeAccount!!, this)
|
||||
NotificationHelper.createNotificationChannelsForAccount(
|
||||
accountManager.activeAccount!!,
|
||||
this
|
||||
)
|
||||
|
||||
// Setup push notifications
|
||||
showMigrationNoticeIfNecessary(this, binding.mainCoordinatorLayout, binding.composeButton, accountManager)
|
||||
showMigrationNoticeIfNecessary(
|
||||
this,
|
||||
binding.mainCoordinatorLayout,
|
||||
binding.composeButton,
|
||||
accountManager
|
||||
)
|
||||
if (NotificationHelper.areNotificationsEnabled(this, accountManager)) {
|
||||
lifecycleScope.launch {
|
||||
enablePushNotificationsWithFallback(this@MainActivity, mastodonApi, accountManager)
|
||||
|
|
@ -872,122 +1065,94 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
disableAllNotifications(this, accountManager)
|
||||
}
|
||||
|
||||
accountLocked = me.locked
|
||||
|
||||
updateProfiles()
|
||||
updateShortcut(this, accountManager.activeAccount!!)
|
||||
shareShortcutHelper.updateShortcuts()
|
||||
}
|
||||
|
||||
@SuppressLint("CheckResult")
|
||||
private fun loadDrawerAvatar(avatarUrl: String, showPlaceholder: Boolean) {
|
||||
val hideTopToolbar = preferences.getBoolean(PrefKeys.HIDE_TOP_TOOLBAR, false)
|
||||
val animateAvatars = preferences.getBoolean("animateGifAvatars", false)
|
||||
val animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false)
|
||||
|
||||
if (hideTopToolbar) {
|
||||
val navOnBottom = preferences.getString("mainNavPosition", "top") == "bottom"
|
||||
|
||||
val avatarView = if (navOnBottom) {
|
||||
binding.bottomNavAvatar.show()
|
||||
binding.bottomNavAvatar
|
||||
val activeToolbar = if (hideTopToolbar) {
|
||||
val navOnBottom = preferences.getString(PrefKeys.MAIN_NAV_POSITION, "top") == "bottom"
|
||||
if (navOnBottom) {
|
||||
binding.bottomNav
|
||||
} else {
|
||||
binding.topNavAvatar.show()
|
||||
binding.topNavAvatar
|
||||
}
|
||||
|
||||
if (animateAvatars) {
|
||||
Glide.with(this)
|
||||
.load(avatarUrl)
|
||||
.placeholder(R.drawable.avatar_default)
|
||||
.into(avatarView)
|
||||
} else {
|
||||
Glide.with(this)
|
||||
.asBitmap()
|
||||
.load(avatarUrl)
|
||||
.placeholder(R.drawable.avatar_default)
|
||||
.into(avatarView)
|
||||
binding.topNav
|
||||
}
|
||||
} else {
|
||||
binding.bottomNavAvatar.hide()
|
||||
binding.topNavAvatar.hide()
|
||||
binding.mainToolbar
|
||||
}
|
||||
|
||||
val navIconSize = resources.getDimensionPixelSize(R.dimen.avatar_toolbar_nav_icon_size)
|
||||
val navIconSize = resources.getDimensionPixelSize(R.dimen.avatar_toolbar_nav_icon_size)
|
||||
|
||||
if (animateAvatars) {
|
||||
glide.asDrawable()
|
||||
.load(avatarUrl)
|
||||
.transform(
|
||||
RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp))
|
||||
)
|
||||
.apply {
|
||||
if (showPlaceholder) {
|
||||
placeholder(R.drawable.avatar_default)
|
||||
if (animateAvatars) {
|
||||
Glide.with(this)
|
||||
.asDrawable()
|
||||
.load(avatarUrl)
|
||||
.transform(
|
||||
RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp))
|
||||
)
|
||||
.apply {
|
||||
if (showPlaceholder) placeholder(R.drawable.avatar_default)
|
||||
}
|
||||
.into(object : CustomTarget<Drawable>(navIconSize, navIconSize) {
|
||||
|
||||
override fun onLoadStarted(placeholder: Drawable?) {
|
||||
placeholder?.let {
|
||||
activeToolbar.navigationIcon = FixedSizeDrawable(it, navIconSize, navIconSize)
|
||||
}
|
||||
}
|
||||
.into(object : CustomTarget<Drawable>(navIconSize, navIconSize) {
|
||||
|
||||
override fun onLoadStarted(placeholder: Drawable?) {
|
||||
if (placeholder != null) {
|
||||
binding.mainToolbar.navigationIcon =
|
||||
FixedSizeDrawable(placeholder, navIconSize, navIconSize)
|
||||
}
|
||||
}
|
||||
override fun onResourceReady(
|
||||
resource: Drawable,
|
||||
transition: Transition<in Drawable>?
|
||||
) {
|
||||
if (resource is Animatable) resource.start()
|
||||
activeToolbar.navigationIcon = FixedSizeDrawable(resource, navIconSize, navIconSize)
|
||||
}
|
||||
|
||||
override fun onResourceReady(
|
||||
resource: Drawable,
|
||||
transition: Transition<in Drawable>?
|
||||
) {
|
||||
if (resource is Animatable) {
|
||||
resource.start()
|
||||
}
|
||||
binding.mainToolbar.navigationIcon =
|
||||
FixedSizeDrawable(resource, navIconSize, navIconSize)
|
||||
}
|
||||
|
||||
override fun onLoadCleared(placeholder: Drawable?) {
|
||||
if (placeholder != null) {
|
||||
binding.mainToolbar.navigationIcon =
|
||||
FixedSizeDrawable(placeholder, navIconSize, navIconSize)
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
glide.asBitmap()
|
||||
.load(avatarUrl)
|
||||
.transform(
|
||||
RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp))
|
||||
)
|
||||
.apply {
|
||||
if (showPlaceholder) {
|
||||
placeholder(R.drawable.avatar_default)
|
||||
override fun onLoadCleared(placeholder: Drawable?) {
|
||||
placeholder?.let {
|
||||
activeToolbar.navigationIcon = FixedSizeDrawable(it, navIconSize, navIconSize)
|
||||
}
|
||||
}
|
||||
.into(object : CustomTarget<Bitmap>(navIconSize, navIconSize) {
|
||||
|
||||
override fun onLoadStarted(placeholder: Drawable?) {
|
||||
if (placeholder != null) {
|
||||
binding.mainToolbar.navigationIcon =
|
||||
FixedSizeDrawable(placeholder, navIconSize, navIconSize)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
Glide.with(this)
|
||||
.asBitmap()
|
||||
.load(avatarUrl)
|
||||
.transform(
|
||||
RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp))
|
||||
)
|
||||
.apply {
|
||||
if (showPlaceholder) placeholder(R.drawable.avatar_default)
|
||||
}
|
||||
.into(object : CustomTarget<Bitmap>(navIconSize, navIconSize) {
|
||||
override fun onLoadStarted(placeholder: Drawable?) {
|
||||
placeholder?.let {
|
||||
activeToolbar.navigationIcon = FixedSizeDrawable(it, navIconSize, navIconSize)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResourceReady(
|
||||
resource: Bitmap,
|
||||
transition: Transition<in Bitmap>?
|
||||
) {
|
||||
binding.mainToolbar.navigationIcon = FixedSizeDrawable(
|
||||
BitmapDrawable(resources, resource),
|
||||
navIconSize,
|
||||
navIconSize
|
||||
)
|
||||
}
|
||||
override fun onResourceReady(
|
||||
resource: Bitmap,
|
||||
transition: Transition<in Bitmap>?
|
||||
) {
|
||||
activeToolbar.navigationIcon = FixedSizeDrawable(
|
||||
BitmapDrawable(resources, resource),
|
||||
navIconSize,
|
||||
navIconSize
|
||||
)
|
||||
}
|
||||
|
||||
override fun onLoadCleared(placeholder: Drawable?) {
|
||||
if (placeholder != null) {
|
||||
binding.mainToolbar.navigationIcon =
|
||||
FixedSizeDrawable(placeholder, navIconSize, navIconSize)
|
||||
}
|
||||
override fun onLoadCleared(placeholder: Drawable?) {
|
||||
placeholder?.let {
|
||||
activeToolbar.navigationIcon = FixedSizeDrawable(it, navIconSize, navIconSize)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1007,7 +1172,12 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
}
|
||||
|
||||
private fun updateAnnouncementsBadge() {
|
||||
binding.mainDrawer.updateBadge(DRAWER_ITEM_ANNOUNCEMENTS, StringHolder(if (unreadAnnouncementsCount <= 0) null else unreadAnnouncementsCount.toString()))
|
||||
binding.mainDrawer.updateBadge(
|
||||
DRAWER_ITEM_ANNOUNCEMENTS,
|
||||
StringHolder(
|
||||
if (unreadAnnouncementsCount <= 0) null else unreadAnnouncementsCount.toString()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun updateProfiles() {
|
||||
|
|
@ -1041,16 +1211,93 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
}
|
||||
}
|
||||
|
||||
private fun explodeAnimationWasRequested(): Boolean {
|
||||
return intent.getBooleanExtra(OPEN_WITH_EXPLODE_ANIMATION, false)
|
||||
}
|
||||
|
||||
override fun getActionButton() = binding.composeButton
|
||||
|
||||
override fun androidInjector() = androidInjector
|
||||
|
||||
companion object {
|
||||
const val OPEN_WITH_EXPLODE_ANIMATION = "explode"
|
||||
|
||||
private const val TAG = "MainActivity" // logging tag
|
||||
private const val DRAWER_ITEM_ADD_ACCOUNT: Long = -13
|
||||
private const val DRAWER_ITEM_ANNOUNCEMENTS: Long = 14
|
||||
const val REDIRECT_URL = "redirectUrl"
|
||||
const val OPEN_DRAFTS = "draft"
|
||||
private const val REDIRECT_URL = "redirectUrl"
|
||||
private const val OPEN_DRAFTS = "draft"
|
||||
private const val TUSKY_ACCOUNT_ID = "tuskyAccountId"
|
||||
private const val COMPOSE_OPTIONS = "composeOptions"
|
||||
private const val NOTIFICATION_TYPE = "notificationType"
|
||||
private const val NOTIFICATION_TAG = "notificationTag"
|
||||
private const val NOTIFICATION_ID = "notificationId"
|
||||
|
||||
/**
|
||||
* Switches the active account to the provided accountId and then stays on MainActivity
|
||||
*/
|
||||
@JvmStatic
|
||||
fun accountSwitchIntent(context: Context, tuskyAccountId: Long): Intent {
|
||||
return Intent(context, MainActivity::class.java).apply {
|
||||
putExtra(TUSKY_ACCOUNT_ID, tuskyAccountId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches the active account to the accountId and takes the user to the correct place according to the notification they clicked
|
||||
*/
|
||||
@JvmStatic
|
||||
fun openNotificationIntent(
|
||||
context: Context,
|
||||
tuskyAccountId: Long,
|
||||
type: Notification.Type
|
||||
): Intent {
|
||||
return accountSwitchIntent(context, tuskyAccountId).apply {
|
||||
putExtra(NOTIFICATION_TYPE, type.name)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches the active account to the accountId and then opens ComposeActivity with the provided options
|
||||
* @param tuskyAccountId the id of the Tusky account to open the screen with. Set to -1 for current account.
|
||||
* @param notificationId optional id of the notification that should be cancelled when this intent is opened
|
||||
* @param notificationTag optional tag of the notification that should be cancelled when this intent is opened
|
||||
*/
|
||||
@JvmStatic
|
||||
fun composeIntent(
|
||||
context: Context,
|
||||
options: ComposeActivity.ComposeOptions,
|
||||
tuskyAccountId: Long = -1,
|
||||
notificationTag: String? = null,
|
||||
notificationId: Int = -1
|
||||
): Intent {
|
||||
return accountSwitchIntent(context, tuskyAccountId).apply {
|
||||
action = Intent.ACTION_SEND // so it can be opened via shortcuts
|
||||
putExtra(COMPOSE_OPTIONS, options)
|
||||
putExtra(NOTIFICATION_TAG, notificationTag)
|
||||
putExtra(NOTIFICATION_ID, notificationId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* switches the active account to the accountId and then tries to resolve and show the provided url
|
||||
*/
|
||||
@JvmStatic
|
||||
fun redirectIntent(context: Context, tuskyAccountId: Long, url: String): Intent {
|
||||
return accountSwitchIntent(context, tuskyAccountId).apply {
|
||||
putExtra(REDIRECT_URL, url)
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* switches the active account to the provided accountId and then opens drafts
|
||||
*/
|
||||
fun draftIntent(context: Context, tuskyAccountId: Long): Intent {
|
||||
return accountSwitchIntent(context, tuskyAccountId).apply {
|
||||
putExtra(OPEN_DRAFTS, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -27,17 +27,20 @@ import at.connyduck.calladapter.networkresult.fold
|
|||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
|
||||
import com.keylesspalace.tusky.components.filters.EditFilterActivity
|
||||
import com.keylesspalace.tusky.components.filters.FiltersActivity
|
||||
import com.keylesspalace.tusky.components.timeline.TimelineFragment
|
||||
import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel.Kind
|
||||
import com.keylesspalace.tusky.databinding.ActivityStatuslistBinding
|
||||
import com.keylesspalace.tusky.entity.Filter
|
||||
import com.keylesspalace.tusky.entity.FilterV1
|
||||
import com.keylesspalace.tusky.util.isHttpNotFound
|
||||
import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import dagger.android.DispatchingAndroidInjector
|
||||
import dagger.android.HasAndroidInjector
|
||||
import kotlinx.coroutines.launch
|
||||
import retrofit2.HttpException
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
|
||||
|
||||
|
|
@ -47,7 +50,9 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
|
|||
@Inject
|
||||
lateinit var eventHub: EventHub
|
||||
|
||||
private val binding: ActivityStatuslistBinding by viewBinding(ActivityStatuslistBinding::inflate)
|
||||
private val binding: ActivityStatuslistBinding by viewBinding(
|
||||
ActivityStatuslistBinding::inflate
|
||||
)
|
||||
private lateinit var kind: Kind
|
||||
private var hashtag: String? = null
|
||||
private var followTagItem: MenuItem? = null
|
||||
|
|
@ -74,6 +79,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
|
|||
Kind.FAVOURITES -> getString(R.string.title_favourites)
|
||||
Kind.BOOKMARKS -> getString(R.string.title_bookmarks)
|
||||
Kind.TAG -> getString(R.string.title_tag).format(hashtag)
|
||||
Kind.PUBLIC_TRENDING_STATUSES -> getString(R.string.title_public_trending_statuses)
|
||||
else -> intent.getStringExtra(EXTRA_LIST_TITLE)
|
||||
}
|
||||
|
||||
|
|
@ -132,9 +138,19 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
|
|||
{
|
||||
followTagItem?.isVisible = false
|
||||
unfollowTagItem?.isVisible = true
|
||||
|
||||
Snackbar.make(
|
||||
binding.root,
|
||||
getString(R.string.following_hashtag_success_format, tag),
|
||||
Snackbar.LENGTH_SHORT
|
||||
).show()
|
||||
},
|
||||
{
|
||||
Snackbar.make(binding.root, getString(R.string.error_following_hashtag_format, tag), Snackbar.LENGTH_SHORT).show()
|
||||
Snackbar.make(
|
||||
binding.root,
|
||||
getString(R.string.error_following_hashtag_format, tag),
|
||||
Snackbar.LENGTH_SHORT
|
||||
).show()
|
||||
Log.e(TAG, "Failed to follow #$tag", it)
|
||||
}
|
||||
)
|
||||
|
|
@ -152,9 +168,19 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
|
|||
{
|
||||
followTagItem?.isVisible = true
|
||||
unfollowTagItem?.isVisible = false
|
||||
|
||||
Snackbar.make(
|
||||
binding.root,
|
||||
getString(R.string.unfollowing_hashtag_success_format, tag),
|
||||
Snackbar.LENGTH_SHORT
|
||||
).show()
|
||||
},
|
||||
{
|
||||
Snackbar.make(binding.root, getString(R.string.error_unfollowing_hashtag_format, tag), Snackbar.LENGTH_SHORT).show()
|
||||
Snackbar.make(
|
||||
binding.root,
|
||||
getString(R.string.error_unfollowing_hashtag_format, tag),
|
||||
Snackbar.LENGTH_SHORT
|
||||
).show()
|
||||
Log.e(TAG, "Failed to unfollow #$tag", it)
|
||||
}
|
||||
)
|
||||
|
|
@ -169,6 +195,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
|
|||
*/
|
||||
private fun updateMuteTagMenuItems() {
|
||||
val tag = hashtag ?: return
|
||||
val hashedTag = "#$tag"
|
||||
|
||||
muteTagItem?.isVisible = true
|
||||
muteTagItem?.isEnabled = false
|
||||
|
|
@ -178,18 +205,17 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
|
|||
mastodonApi.getFilters().fold(
|
||||
{ filters ->
|
||||
mutedFilter = filters.firstOrNull { filter ->
|
||||
filter.context.contains(Filter.Kind.HOME.kind) && filter.keywords.any {
|
||||
it.keyword == tag
|
||||
}
|
||||
// TODO shouldn't this be an exact match (only one keyword; exactly the hashtag)?
|
||||
filter.context.contains(Filter.Kind.HOME.kind) && filter.title == hashedTag
|
||||
}
|
||||
updateTagMuteState(mutedFilter != null)
|
||||
},
|
||||
{ throwable ->
|
||||
if (throwable is HttpException && throwable.code() == 404) {
|
||||
if (throwable.isHttpNotFound()) {
|
||||
mastodonApi.getFiltersV1().fold(
|
||||
{ filters ->
|
||||
mutedFilterV1 = filters.firstOrNull { filter ->
|
||||
tag == filter.phrase && filter.context.contains(FilterV1.HOME)
|
||||
hashedTag == filter.phrase && filter.context.contains(FilterV1.HOME)
|
||||
}
|
||||
updateTagMuteState(mutedFilterV1 != null)
|
||||
},
|
||||
|
|
@ -221,6 +247,9 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
|
|||
val tag = hashtag ?: return true
|
||||
|
||||
lifecycleScope.launch {
|
||||
var filterCreateSuccess = false
|
||||
val hashedTag = "#$tag"
|
||||
|
||||
mastodonApi.createFilter(
|
||||
title = "#$tag",
|
||||
context = listOf(FilterV1.HOME),
|
||||
|
|
@ -228,19 +257,31 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
|
|||
expiresInSeconds = null
|
||||
).fold(
|
||||
{ filter ->
|
||||
if (mastodonApi.addFilterKeyword(filterId = filter.id, keyword = tag, wholeWord = true).isSuccess) {
|
||||
mutedFilter = filter
|
||||
updateTagMuteState(true)
|
||||
if (mastodonApi.addFilterKeyword(
|
||||
filterId = filter.id,
|
||||
keyword = hashedTag,
|
||||
wholeWord = true
|
||||
).isSuccess
|
||||
) {
|
||||
// must be requested again; otherwise does not contain the keyword (but server does)
|
||||
mutedFilter = mastodonApi.getFilter(filter.id).getOrNull()
|
||||
|
||||
// TODO the preference key here ("home") is not meaningful; should probably be another event if any
|
||||
eventHub.dispatch(PreferenceChangedEvent(filter.context[0]))
|
||||
filterCreateSuccess = true
|
||||
} else {
|
||||
Snackbar.make(binding.root, getString(R.string.error_muting_hashtag_format, tag), Snackbar.LENGTH_SHORT).show()
|
||||
Snackbar.make(
|
||||
binding.root,
|
||||
getString(R.string.error_muting_hashtag_format, tag),
|
||||
Snackbar.LENGTH_SHORT
|
||||
).show()
|
||||
Log.e(TAG, "Failed to mute #$tag")
|
||||
}
|
||||
},
|
||||
{ throwable ->
|
||||
if (throwable is HttpException && throwable.code() == 404) {
|
||||
if (throwable.isHttpNotFound()) {
|
||||
mastodonApi.createFilterV1(
|
||||
tag,
|
||||
hashedTag,
|
||||
listOf(FilterV1.HOME),
|
||||
irreversible = false,
|
||||
wholeWord = true,
|
||||
|
|
@ -248,20 +289,50 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
|
|||
).fold(
|
||||
{ filter ->
|
||||
mutedFilterV1 = filter
|
||||
updateTagMuteState(true)
|
||||
eventHub.dispatch(PreferenceChangedEvent(filter.context[0]))
|
||||
filterCreateSuccess = true
|
||||
},
|
||||
{ throwable ->
|
||||
Snackbar.make(binding.root, getString(R.string.error_muting_hashtag_format, tag), Snackbar.LENGTH_SHORT).show()
|
||||
Snackbar.make(
|
||||
binding.root,
|
||||
getString(R.string.error_muting_hashtag_format, tag),
|
||||
Snackbar.LENGTH_SHORT
|
||||
).show()
|
||||
Log.e(TAG, "Failed to mute #$tag", throwable)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
Snackbar.make(binding.root, getString(R.string.error_muting_hashtag_format, tag), Snackbar.LENGTH_SHORT).show()
|
||||
Snackbar.make(
|
||||
binding.root,
|
||||
getString(R.string.error_muting_hashtag_format, tag),
|
||||
Snackbar.LENGTH_SHORT
|
||||
).show()
|
||||
Log.e(TAG, "Failed to mute #$tag", throwable)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (filterCreateSuccess) {
|
||||
updateTagMuteState(true)
|
||||
Snackbar.make(
|
||||
binding.root,
|
||||
getString(R.string.muting_hashtag_success_format, tag),
|
||||
Snackbar.LENGTH_LONG
|
||||
).apply {
|
||||
setAction(R.string.action_view_filter) {
|
||||
val intent = if (mutedFilter != null) {
|
||||
Intent(this@StatusListActivity, EditFilterActivity::class.java).apply {
|
||||
putExtra(EditFilterActivity.FILTER_TO_EDIT, mutedFilter)
|
||||
}
|
||||
} else {
|
||||
Intent(this@StatusListActivity, FiltersActivity::class.java)
|
||||
}
|
||||
|
||||
startActivityWithSlideInAnimation(intent)
|
||||
}
|
||||
show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
|
|
@ -307,9 +378,19 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
|
|||
eventHub.dispatch(PreferenceChangedEvent(Filter.Kind.HOME.kind))
|
||||
mutedFilterV1 = null
|
||||
mutedFilter = null
|
||||
|
||||
Snackbar.make(
|
||||
binding.root,
|
||||
getString(R.string.unmuting_hashtag_success_format, tag),
|
||||
Snackbar.LENGTH_SHORT
|
||||
).show()
|
||||
},
|
||||
{ throwable ->
|
||||
Snackbar.make(binding.root, getString(R.string.error_unmuting_hashtag_format, tag), Snackbar.LENGTH_SHORT).show()
|
||||
Snackbar.make(
|
||||
binding.root,
|
||||
getString(R.string.error_unmuting_hashtag_format, tag),
|
||||
Snackbar.LENGTH_SHORT
|
||||
).show()
|
||||
Log.e(TAG, "Failed to unmute #$tag", throwable)
|
||||
}
|
||||
)
|
||||
|
|
@ -351,5 +432,10 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
|
|||
putExtra(EXTRA_KIND, Kind.TAG.name)
|
||||
putExtra(EXTRA_HASHTAG, hashtag)
|
||||
}
|
||||
|
||||
fun newTrendingIntent(context: Context) =
|
||||
Intent(context, StatusListActivity::class.java).apply {
|
||||
putExtra(EXTRA_KIND, Kind.PUBLIC_TRENDING_STATUSES.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,10 +20,10 @@ import androidx.annotation.DrawableRes
|
|||
import androidx.annotation.StringRes
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.keylesspalace.tusky.components.conversation.ConversationsFragment
|
||||
import com.keylesspalace.tusky.components.notifications.NotificationsFragment
|
||||
import com.keylesspalace.tusky.components.timeline.TimelineFragment
|
||||
import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel
|
||||
import com.keylesspalace.tusky.components.trending.TrendingFragment
|
||||
import com.keylesspalace.tusky.components.trending.TrendingTagsFragment
|
||||
import com.keylesspalace.tusky.fragment.NotificationsFragment
|
||||
import java.util.Objects
|
||||
|
||||
/** this would be a good case for a sealed class, but that does not work nice with Room */
|
||||
|
|
@ -33,9 +33,11 @@ const val NOTIFICATIONS = "Notifications"
|
|||
const val LOCAL = "Local"
|
||||
const val FEDERATED = "Federated"
|
||||
const val DIRECT = "Direct"
|
||||
const val TRENDING = "Trending"
|
||||
const val TRENDING_TAGS = "TrendingTags"
|
||||
const val TRENDING_STATUSES = "TrendingStatuses"
|
||||
const val HASHTAG = "Hashtag"
|
||||
const val LIST = "List"
|
||||
const val BOOKMARKS = "Bookmarks"
|
||||
|
||||
data class TabData(
|
||||
val id: String,
|
||||
|
|
@ -52,9 +54,7 @@ data class TabData(
|
|||
other as TabData
|
||||
|
||||
if (id != other.id) return false
|
||||
if (arguments != other.arguments) return false
|
||||
|
||||
return true
|
||||
return arguments == other.arguments
|
||||
}
|
||||
|
||||
override fun hashCode() = Objects.hash(id, arguments)
|
||||
|
|
@ -94,11 +94,21 @@ fun createTabDataFromId(id: String, arguments: List<String> = emptyList()): TabD
|
|||
icon = R.drawable.ic_reblog_direct_24dp,
|
||||
fragment = { ConversationsFragment.newInstance() }
|
||||
)
|
||||
TRENDING -> TabData(
|
||||
id = TRENDING,
|
||||
TRENDING_TAGS -> TabData(
|
||||
id = TRENDING_TAGS,
|
||||
text = R.string.title_public_trending_hashtags,
|
||||
icon = R.drawable.ic_trending_up_24px,
|
||||
fragment = { TrendingFragment.newInstance() }
|
||||
fragment = { TrendingTagsFragment.newInstance() }
|
||||
)
|
||||
TRENDING_STATUSES -> TabData(
|
||||
id = TRENDING_STATUSES,
|
||||
text = R.string.title_public_trending_statuses,
|
||||
icon = R.drawable.ic_hot_24dp,
|
||||
fragment = {
|
||||
TimelineFragment.newInstance(
|
||||
TimelineViewModel.Kind.PUBLIC_TRENDING_STATUSES
|
||||
)
|
||||
}
|
||||
)
|
||||
HASHTAG -> TabData(
|
||||
id = HASHTAG,
|
||||
|
|
@ -106,16 +116,31 @@ fun createTabDataFromId(id: String, arguments: List<String> = emptyList()): TabD
|
|||
icon = R.drawable.ic_hashtag,
|
||||
fragment = { args -> TimelineFragment.newHashtagInstance(args) },
|
||||
arguments = arguments,
|
||||
title = { context -> arguments.joinToString(separator = " ") { context.getString(R.string.title_tag, it) } }
|
||||
title = { context ->
|
||||
arguments.joinToString(separator = " ") {
|
||||
context.getString(R.string.title_tag, it)
|
||||
}
|
||||
}
|
||||
)
|
||||
LIST -> TabData(
|
||||
id = LIST,
|
||||
text = R.string.list,
|
||||
icon = R.drawable.ic_list,
|
||||
fragment = { args -> TimelineFragment.newInstance(TimelineViewModel.Kind.LIST, args.getOrNull(0).orEmpty()) },
|
||||
fragment = { args ->
|
||||
TimelineFragment.newInstance(
|
||||
TimelineViewModel.Kind.LIST,
|
||||
args.getOrNull(0).orEmpty()
|
||||
)
|
||||
},
|
||||
arguments = arguments,
|
||||
title = { arguments.getOrNull(1).orEmpty() }
|
||||
)
|
||||
BOOKMARKS -> TabData(
|
||||
id = BOOKMARKS,
|
||||
text = R.string.title_bookmarks,
|
||||
icon = R.drawable.ic_bookmark_active_24dp,
|
||||
fragment = { TimelineFragment.newInstance(TimelineViewModel.Kind.BOOKMARKS) }
|
||||
)
|
||||
else -> throw IllegalArgumentException("unknown tab type")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,18 +15,10 @@
|
|||
|
||||
package com.keylesspalace.tusky
|
||||
|
||||
import android.content.Intent
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.TextView
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.widget.AppCompatEditText
|
||||
|
|
@ -38,34 +30,29 @@ import androidx.recyclerview.widget.ItemTouchHelper
|
|||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.transition.TransitionManager
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import at.connyduck.sparkbutton.helpers.Utils
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.google.android.material.transition.MaterialArcMotion
|
||||
import com.google.android.material.transition.MaterialContainerTransform
|
||||
import com.keylesspalace.tusky.adapter.ItemInteractionListener
|
||||
import com.keylesspalace.tusky.adapter.TabAdapter
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.appstore.MainTabsChangedEvent
|
||||
import com.keylesspalace.tusky.components.account.list.ListSelectionFragment
|
||||
import com.keylesspalace.tusky.databinding.ActivityTabPreferenceBinding
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
import com.keylesspalace.tusky.entity.MastoList
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.getDimension
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.util.unsafeLazy
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.awaitCancellation
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import dagger.android.DispatchingAndroidInjector
|
||||
import dagger.android.HasAndroidInjector
|
||||
import java.util.regex.Pattern
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListener {
|
||||
class TabPreferenceActivity : BaseActivity(), Injectable, HasAndroidInjector, ItemInteractionListener, ListSelectionFragment.ListSelectionListener {
|
||||
|
||||
@Inject
|
||||
lateinit var mastodonApi: MastodonApi
|
||||
|
|
@ -73,6 +60,9 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
|
|||
@Inject
|
||||
lateinit var eventHub: EventHub
|
||||
|
||||
@Inject
|
||||
lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector<Any>
|
||||
|
||||
private val binding by viewBinding(ActivityTabPreferenceBinding::inflate)
|
||||
|
||||
private lateinit var currentTabs: MutableList<TabData>
|
||||
|
|
@ -82,9 +72,13 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
|
|||
|
||||
private var tabsChanged = false
|
||||
|
||||
private val selectedItemElevation by unsafeLazy { resources.getDimension(R.dimen.selected_drag_item_elevation) }
|
||||
private val selectedItemElevation by unsafeLazy {
|
||||
resources.getDimension(R.dimen.selected_drag_item_elevation)
|
||||
}
|
||||
|
||||
private val hashtagRegex by unsafeLazy { Pattern.compile("([\\w_]*[\\p{Alpha}_][\\w_]*)", Pattern.CASE_INSENSITIVE) }
|
||||
private val hashtagRegex by unsafeLazy {
|
||||
Pattern.compile("([\\w_]*[\\p{Alpha}_][\\w_]*)", Pattern.CASE_INSENSITIVE)
|
||||
}
|
||||
|
||||
private val onFabDismissedCallback = object : OnBackPressedCallback(false) {
|
||||
override fun handleOnBackPressed() {
|
||||
|
|
@ -109,14 +103,19 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
|
|||
currentTabsAdapter = TabAdapter(currentTabs, false, this, currentTabs.size <= MIN_TAB_COUNT)
|
||||
binding.currentTabsRecyclerView.adapter = currentTabsAdapter
|
||||
binding.currentTabsRecyclerView.layoutManager = LinearLayoutManager(this)
|
||||
binding.currentTabsRecyclerView.addItemDecoration(DividerItemDecoration(this, LinearLayoutManager.VERTICAL))
|
||||
binding.currentTabsRecyclerView.addItemDecoration(
|
||||
DividerItemDecoration(this, LinearLayoutManager.VERTICAL)
|
||||
)
|
||||
|
||||
addTabAdapter = TabAdapter(listOf(createTabDataFromId(DIRECT)), true, this)
|
||||
binding.addTabRecyclerView.adapter = addTabAdapter
|
||||
binding.addTabRecyclerView.layoutManager = LinearLayoutManager(this)
|
||||
|
||||
touchHelper = ItemTouchHelper(object : ItemTouchHelper.Callback() {
|
||||
override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int {
|
||||
override fun getMovementFlags(
|
||||
recyclerView: RecyclerView,
|
||||
viewHolder: RecyclerView.ViewHolder
|
||||
): Int {
|
||||
return makeMovementFlags(ItemTouchHelper.UP or ItemTouchHelper.DOWN, ItemTouchHelper.END)
|
||||
}
|
||||
|
||||
|
|
@ -128,7 +127,11 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
|
|||
return MIN_TAB_COUNT < currentTabs.size
|
||||
}
|
||||
|
||||
override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean {
|
||||
override fun onMove(
|
||||
recyclerView: RecyclerView,
|
||||
viewHolder: RecyclerView.ViewHolder,
|
||||
target: RecyclerView.ViewHolder
|
||||
): Boolean {
|
||||
val temp = currentTabs[viewHolder.bindingAdapterPosition]
|
||||
currentTabs[viewHolder.bindingAdapterPosition] = currentTabs[target.bindingAdapterPosition]
|
||||
currentTabs[target.bindingAdapterPosition] = temp
|
||||
|
|
@ -148,7 +151,10 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
|
|||
}
|
||||
}
|
||||
|
||||
override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
|
||||
override fun clearView(
|
||||
recyclerView: RecyclerView,
|
||||
viewHolder: RecyclerView.ViewHolder
|
||||
) {
|
||||
super.clearView(recyclerView, viewHolder)
|
||||
viewHolder.itemView.elevation = 0f
|
||||
}
|
||||
|
|
@ -164,18 +170,12 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
|
|||
toggleFab(false)
|
||||
}
|
||||
|
||||
binding.maxTabsInfo.text = resources.getQuantityString(R.plurals.max_tab_number_reached, MAX_TAB_COUNT, MAX_TAB_COUNT)
|
||||
|
||||
updateAvailableTabs()
|
||||
|
||||
onBackPressedDispatcher.addCallback(onFabDismissedCallback)
|
||||
}
|
||||
|
||||
override fun onTabAdded(tab: TabData) {
|
||||
if (currentTabs.size >= MAX_TAB_COUNT) {
|
||||
return
|
||||
}
|
||||
|
||||
toggleFab(false)
|
||||
|
||||
if (tab.id == HASHTAG) {
|
||||
|
|
@ -273,81 +273,24 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
|
|||
editText.requestFocus()
|
||||
}
|
||||
|
||||
private var listSelectDialog: ListSelectionFragment? = null
|
||||
|
||||
private fun showSelectListDialog() {
|
||||
val adapter = object : ArrayAdapter<MastoList>(this, android.R.layout.simple_list_item_1) {
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
val view = super.getView(position, convertView, parent)
|
||||
getItem(position)?.let { item -> (view as TextView).text = item.title }
|
||||
return view
|
||||
}
|
||||
}
|
||||
listSelectDialog = ListSelectionFragment.newInstance(null)
|
||||
listSelectDialog?.show(supportFragmentManager, null)
|
||||
|
||||
val statusLayout = LinearLayout(this)
|
||||
statusLayout.gravity = Gravity.CENTER
|
||||
val progress = ProgressBar(this)
|
||||
val preferredPadding = getDimension(this, androidx.appcompat.R.attr.dialogPreferredPadding)
|
||||
progress.setPadding(preferredPadding, 0, preferredPadding, 0)
|
||||
progress.visible(false)
|
||||
|
||||
val noListsText = TextView(this)
|
||||
noListsText.setPadding(preferredPadding, 0, preferredPadding, 0)
|
||||
noListsText.text = getText(R.string.select_list_empty)
|
||||
noListsText.visible(false)
|
||||
|
||||
statusLayout.addView(progress)
|
||||
statusLayout.addView(noListsText)
|
||||
|
||||
val dialogBuilder = AlertDialog.Builder(this)
|
||||
.setTitle(R.string.select_list_title)
|
||||
.setNeutralButton(R.string.select_list_manage) { _, _ ->
|
||||
val listIntent = Intent(applicationContext, ListsActivity::class.java)
|
||||
startActivity(listIntent)
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.setView(statusLayout)
|
||||
.setAdapter(adapter) { _, position ->
|
||||
adapter.getItem(position)?.let { item ->
|
||||
val newTab = createTabDataFromId(LIST, listOf(item.id, item.title))
|
||||
currentTabs.add(newTab)
|
||||
currentTabsAdapter.notifyItemInserted(currentTabs.size - 1)
|
||||
updateAvailableTabs()
|
||||
saveTabs()
|
||||
}
|
||||
}
|
||||
|
||||
val showProgressBarJob = getProgressBarJob(progress, 500)
|
||||
showProgressBarJob.start()
|
||||
|
||||
val dialog = dialogBuilder.show()
|
||||
|
||||
lifecycleScope.launch {
|
||||
mastodonApi.getLists().fold(
|
||||
{ lists ->
|
||||
showProgressBarJob.cancel()
|
||||
adapter.addAll(lists)
|
||||
if (lists.isEmpty()) {
|
||||
noListsText.show()
|
||||
}
|
||||
},
|
||||
{ throwable ->
|
||||
dialog.hide()
|
||||
Log.e("TabPreferenceActivity", "failed to load lists", throwable)
|
||||
Snackbar.make(binding.root, R.string.error_list_load, Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
private fun getProgressBarJob(progressView: View, delayMs: Long) = this.lifecycleScope.launch(
|
||||
start = CoroutineStart.LAZY
|
||||
) {
|
||||
try {
|
||||
delay(delayMs)
|
||||
progressView.show()
|
||||
awaitCancellation()
|
||||
} finally {
|
||||
progressView.hide()
|
||||
}
|
||||
override fun onListSelected(list: MastoList) {
|
||||
listSelectDialog?.dismiss()
|
||||
listSelectDialog = null
|
||||
|
||||
val newTab = createTabDataFromId(LIST, listOf(list.id, list.title))
|
||||
currentTabs.add(newTab)
|
||||
currentTabsAdapter.notifyItemInserted(currentTabs.size - 1)
|
||||
updateAvailableTabs()
|
||||
saveTabs()
|
||||
}
|
||||
|
||||
private fun validateHashtag(input: CharSequence?): Boolean {
|
||||
|
|
@ -378,17 +321,23 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
|
|||
if (!currentTabs.contains(directMessagesTab)) {
|
||||
addableTabs.add(directMessagesTab)
|
||||
}
|
||||
val trendingTab = createTabDataFromId(TRENDING)
|
||||
if (!currentTabs.contains(trendingTab)) {
|
||||
addableTabs.add(trendingTab)
|
||||
val trendingTagsTab = createTabDataFromId(TRENDING_TAGS)
|
||||
if (!currentTabs.contains(trendingTagsTab)) {
|
||||
addableTabs.add(trendingTagsTab)
|
||||
}
|
||||
val bookmarksTab = createTabDataFromId(BOOKMARKS)
|
||||
if (!currentTabs.contains(bookmarksTab)) {
|
||||
addableTabs.add(bookmarksTab)
|
||||
}
|
||||
val trendingStatusesTab = createTabDataFromId(TRENDING_STATUSES)
|
||||
if (!currentTabs.contains(trendingStatusesTab)) {
|
||||
addableTabs.add(trendingStatusesTab)
|
||||
}
|
||||
|
||||
addableTabs.add(createTabDataFromId(HASHTAG))
|
||||
addableTabs.add(createTabDataFromId(LIST))
|
||||
|
||||
addTabAdapter.updateData(addableTabs)
|
||||
|
||||
binding.maxTabsInfo.visible(addableTabs.size == 0 || currentTabs.size >= MAX_TAB_COUNT)
|
||||
currentTabsAdapter.setRemoveButtonVisible(currentTabs.size > MIN_TAB_COUNT)
|
||||
}
|
||||
|
||||
|
|
@ -419,8 +368,9 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
|
|||
}
|
||||
}
|
||||
|
||||
override fun androidInjector() = dispatchingAndroidInjector
|
||||
|
||||
companion object {
|
||||
private const val MIN_TAB_COUNT = 2
|
||||
private const val MAX_TAB_COUNT = 5
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,12 +22,13 @@ import androidx.work.Constraints
|
|||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import autodispose2.AutoDisposePlugins
|
||||
import com.keylesspalace.tusky.components.notifications.NotificationHelper
|
||||
import com.keylesspalace.tusky.di.AppInjector
|
||||
import com.keylesspalace.tusky.settings.AppTheme
|
||||
import com.keylesspalace.tusky.settings.NEW_INSTALL_SCHEMA_VERSION
|
||||
import com.keylesspalace.tusky.settings.PrefKeys
|
||||
import com.keylesspalace.tusky.settings.PrefKeys.APP_THEME
|
||||
import com.keylesspalace.tusky.settings.SCHEMA_VERSION
|
||||
import com.keylesspalace.tusky.util.APP_THEME_DEFAULT
|
||||
import com.keylesspalace.tusky.util.LocaleManager
|
||||
import com.keylesspalace.tusky.util.setAppNightMode
|
||||
import com.keylesspalace.tusky.worker.PruneCacheWorker
|
||||
|
|
@ -37,11 +38,10 @@ import dagger.android.HasAndroidInjector
|
|||
import de.c1710.filemojicompat_defaults.DefaultEmojiPackList
|
||||
import de.c1710.filemojicompat_ui.helpers.EmojiPackHelper
|
||||
import de.c1710.filemojicompat_ui.helpers.EmojiPreference
|
||||
import io.reactivex.rxjava3.plugins.RxJavaPlugins
|
||||
import org.conscrypt.Conscrypt
|
||||
import java.security.Security
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
import org.conscrypt.Conscrypt
|
||||
|
||||
class TuskyApplication : Application(), HasAndroidInjector {
|
||||
@Inject
|
||||
|
|
@ -71,12 +71,13 @@ class TuskyApplication : Application(), HasAndroidInjector {
|
|||
|
||||
Security.insertProviderAt(Conscrypt.newProvider(), 1)
|
||||
|
||||
AutoDisposePlugins.setHideProxies(false) // a small performance optimization
|
||||
|
||||
AppInjector.init(this)
|
||||
|
||||
// Migrate shared preference keys and defaults from version to version.
|
||||
val oldVersion = sharedPreferences.getInt(PrefKeys.SCHEMA_VERSION, 0)
|
||||
val oldVersion = sharedPreferences.getInt(
|
||||
PrefKeys.SCHEMA_VERSION,
|
||||
NEW_INSTALL_SCHEMA_VERSION
|
||||
)
|
||||
if (oldVersion != SCHEMA_VERSION) {
|
||||
upgradeSharedPreferences(oldVersion, SCHEMA_VERSION)
|
||||
}
|
||||
|
|
@ -87,15 +88,11 @@ class TuskyApplication : Application(), HasAndroidInjector {
|
|||
EmojiPackHelper.init(this, DefaultEmojiPackList.get(this), allowPackImports = false)
|
||||
|
||||
// init night mode
|
||||
val theme = sharedPreferences.getString("appTheme", APP_THEME_DEFAULT)
|
||||
val theme = sharedPreferences.getString(APP_THEME, AppTheme.DEFAULT.value)
|
||||
setAppNightMode(theme)
|
||||
|
||||
localeManager.setLocale()
|
||||
|
||||
RxJavaPlugins.setErrorHandler {
|
||||
Log.w("RxJava", "undeliverable exception", it)
|
||||
}
|
||||
|
||||
NotificationHelper.createWorkerNotificationChannel(this)
|
||||
|
||||
WorkManager.initialize(
|
||||
|
|
@ -130,6 +127,27 @@ class TuskyApplication : Application(), HasAndroidInjector {
|
|||
editor.remove(PrefKeys.MEDIA_PREVIEW_ENABLED)
|
||||
}
|
||||
|
||||
if (oldVersion < 2023072401) {
|
||||
// The notifications filter / clear options are shown on a menu, not a separate bar,
|
||||
// the preference to display them is not needed.
|
||||
editor.remove(PrefKeys.Deprecated.SHOW_NOTIFICATIONS_FILTER)
|
||||
}
|
||||
|
||||
if (oldVersion != NEW_INSTALL_SCHEMA_VERSION && oldVersion < 2023082301) {
|
||||
// Default value for appTheme is now THEME_SYSTEM. If the user is upgrading and
|
||||
// didn't have an explicit preference set use the previous default, so the
|
||||
// theme does not unexpectedly change.
|
||||
if (!sharedPreferences.contains(APP_THEME)) {
|
||||
editor.putString(APP_THEME, AppTheme.NIGHT.value)
|
||||
}
|
||||
}
|
||||
|
||||
if (oldVersion < 2023112001) {
|
||||
editor.remove(PrefKeys.TAB_FILTER_HOME_REPLIES)
|
||||
editor.remove(PrefKeys.TAB_FILTER_HOME_BOOSTS)
|
||||
editor.remove(PrefKeys.TAB_SHOW_HOME_SELF_BOOSTS)
|
||||
}
|
||||
|
||||
editor.putInt(PrefKeys.SCHEMA_VERSION, newVersion)
|
||||
editor.apply()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,19 +35,17 @@ import android.util.Log
|
|||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.webkit.MimeTypeMap
|
||||
import android.widget.Toast
|
||||
import androidx.core.app.ShareCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.content.IntentCompat
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider
|
||||
import autodispose2.autoDispose
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.request.FutureTarget
|
||||
import com.keylesspalace.tusky.BuildConfig.APPLICATION_ID
|
||||
import com.keylesspalace.tusky.components.viewthread.ViewThreadActivity
|
||||
import com.keylesspalace.tusky.databinding.ActivityViewMediaBinding
|
||||
|
|
@ -57,20 +55,30 @@ import com.keylesspalace.tusky.fragment.ViewVideoFragment
|
|||
import com.keylesspalace.tusky.pager.ImagePagerAdapter
|
||||
import com.keylesspalace.tusky.pager.SingleImagePagerAdapter
|
||||
import com.keylesspalace.tusky.util.getTemporaryMediaFilename
|
||||
import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
|
||||
import com.keylesspalace.tusky.util.submitAsync
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.viewdata.AttachmentViewData
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import dagger.android.DispatchingAndroidInjector
|
||||
import dagger.android.HasAndroidInjector
|
||||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
typealias ToolbarVisibilityListener = (isVisible: Boolean) -> Unit
|
||||
|
||||
class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener, ViewVideoFragment.VideoActionsListener {
|
||||
class ViewMediaActivity :
|
||||
BaseActivity(),
|
||||
HasAndroidInjector,
|
||||
ViewImageFragment.PhotoActionsListener,
|
||||
ViewVideoFragment.VideoActionsListener {
|
||||
|
||||
@Inject
|
||||
lateinit var androidInjector: DispatchingAndroidInjector<Any>
|
||||
|
||||
private val binding by viewBinding(ActivityViewMediaBinding::inflate)
|
||||
|
||||
|
|
@ -97,7 +105,11 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
|
|||
supportPostponeEnterTransition()
|
||||
|
||||
// Gather the parameters.
|
||||
attachments = IntentCompat.getParcelableArrayListExtra(intent, EXTRA_ATTACHMENTS, AttachmentViewData::class.java)
|
||||
attachments = IntentCompat.getParcelableArrayListExtra(
|
||||
intent,
|
||||
EXTRA_ATTACHMENTS,
|
||||
AttachmentViewData::class.java
|
||||
)
|
||||
val initialPosition = intent.getIntExtra(EXTRA_ATTACHMENT_INDEX, 0)
|
||||
|
||||
// Adapter is actually of existential type PageAdapter & SharedElementsTransitionListener
|
||||
|
|
@ -119,6 +131,7 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
|
|||
binding.viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
|
||||
override fun onPageSelected(position: Int) {
|
||||
binding.toolbar.title = getPageTitle(position)
|
||||
adjustScreenWakefulness()
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -150,6 +163,8 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
|
|||
window.sharedElementEnterTransition.removeListener(this)
|
||||
}
|
||||
})
|
||||
|
||||
adjustScreenWakefulness()
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
|
|
@ -206,7 +221,11 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
|
|||
private fun downloadMedia() {
|
||||
val url = imageUrl ?: attachments!![binding.viewPager.currentItem].attachment.url
|
||||
val filename = Uri.parse(url).lastPathSegment
|
||||
Toast.makeText(applicationContext, resources.getString(R.string.download_image, filename), Toast.LENGTH_SHORT).show()
|
||||
Toast.makeText(
|
||||
applicationContext,
|
||||
resources.getString(R.string.download_image, filename),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
|
||||
val downloadManager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
||||
val request = DownloadManager.Request(Uri.parse(url))
|
||||
|
|
@ -216,8 +235,13 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
|
|||
|
||||
private fun requestDownloadMedia() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||
requestPermissions(arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE)) { _, grantResults ->
|
||||
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
||||
requestPermissions(
|
||||
arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
) { _, grantResults ->
|
||||
if (
|
||||
grantResults.isNotEmpty() &&
|
||||
grantResults[0] == PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
downloadMedia()
|
||||
} else {
|
||||
showErrorDialog(
|
||||
|
|
@ -234,7 +258,9 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
|
|||
|
||||
private fun onOpenStatus() {
|
||||
val attach = attachments!![binding.viewPager.currentItem]
|
||||
startActivityWithSlideInAnimation(ViewThreadActivity.startIntent(this, attach.statusId, attach.statusUrl))
|
||||
startActivityWithSlideInAnimation(
|
||||
ViewThreadActivity.startIntent(this, attach.statusId, attach.statusUrl)
|
||||
)
|
||||
}
|
||||
|
||||
private fun copyLink() {
|
||||
|
|
@ -267,7 +293,9 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
|
|||
private fun shareFile(file: File, mimeType: String?) {
|
||||
ShareCompat.IntentBuilder(this)
|
||||
.setType(mimeType)
|
||||
.addStream(FileProvider.getUriForFile(applicationContext, "$APPLICATION_ID.fileprovider", file))
|
||||
.addStream(
|
||||
FileProvider.getUriForFile(applicationContext, "$APPLICATION_ID.fileprovider", file)
|
||||
)
|
||||
.setChooserTitle(R.string.send_media_to)
|
||||
.startChooser()
|
||||
}
|
||||
|
|
@ -278,46 +306,37 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
|
|||
isCreating = true
|
||||
binding.progressBarShare.visibility = View.VISIBLE
|
||||
invalidateOptionsMenu()
|
||||
val file = File(directory, getTemporaryMediaFilename("png"))
|
||||
val futureTask: FutureTarget<Bitmap> =
|
||||
Glide.with(applicationContext).asBitmap().load(Uri.parse(url)).submit()
|
||||
Single.fromCallable {
|
||||
val bitmap = futureTask.get()
|
||||
try {
|
||||
val stream = FileOutputStream(file)
|
||||
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
|
||||
stream.close()
|
||||
return@fromCallable true
|
||||
} catch (fnfe: FileNotFoundException) {
|
||||
Log.e(TAG, "Error writing temporary media.")
|
||||
} catch (ioe: IOException) {
|
||||
Log.e(TAG, "Error writing temporary media.")
|
||||
}
|
||||
return@fromCallable false
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.doOnDispose {
|
||||
futureTask.cancel(true)
|
||||
}
|
||||
.autoDispose(AndroidLifecycleScopeProvider.from(this, Lifecycle.Event.ON_DESTROY))
|
||||
.subscribe(
|
||||
{ result ->
|
||||
Log.d(TAG, "Download image result: $result")
|
||||
isCreating = false
|
||||
invalidateOptionsMenu()
|
||||
binding.progressBarShare.visibility = View.GONE
|
||||
if (result) {
|
||||
shareFile(file, "image/png")
|
||||
|
||||
lifecycleScope.launch {
|
||||
val file = File(directory, getTemporaryMediaFilename("png"))
|
||||
val result = try {
|
||||
val bitmap =
|
||||
Glide.with(applicationContext).asBitmap().load(Uri.parse(url)).submitAsync()
|
||||
try {
|
||||
FileOutputStream(file).use { stream ->
|
||||
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
|
||||
}
|
||||
},
|
||||
{ error ->
|
||||
isCreating = false
|
||||
invalidateOptionsMenu()
|
||||
binding.progressBarShare.visibility = View.GONE
|
||||
Log.e(TAG, "Failed to download image", error)
|
||||
true
|
||||
} catch (ioe: IOException) {
|
||||
// FileNotFoundException is covered by IOException
|
||||
Log.e(TAG, "Error writing temporary media.")
|
||||
false
|
||||
}.also { result -> Log.d(TAG, "Download image result: $result") }
|
||||
} catch (error: Throwable) {
|
||||
if (error is CancellationException) {
|
||||
throw error
|
||||
}
|
||||
)
|
||||
Log.e(TAG, "Failed to download image", error)
|
||||
false
|
||||
}
|
||||
|
||||
isCreating = false
|
||||
invalidateOptionsMenu()
|
||||
binding.progressBarShare.visibility = View.GONE
|
||||
if (result) {
|
||||
shareFile(file, "image/png")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun shareMediaFile(directory: File, url: String) {
|
||||
|
|
@ -337,6 +356,19 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
|
|||
shareFile(file, mimeType)
|
||||
}
|
||||
|
||||
// Prevent this activity from dimming or sleeping the screen if, and only if, it is playing video or audio
|
||||
private fun adjustScreenWakefulness() {
|
||||
attachments?.run {
|
||||
if (get(binding.viewPager.currentItem).attachment.type == Attachment.Type.IMAGE) {
|
||||
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
} else {
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun androidInjector() = androidInjector
|
||||
|
||||
companion object {
|
||||
private const val EXTRA_ATTACHMENTS = "attachments"
|
||||
private const val EXTRA_ATTACHMENT_INDEX = "index"
|
||||
|
|
@ -344,7 +376,11 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
|
|||
private const val TAG = "ViewMediaActivity"
|
||||
|
||||
@JvmStatic
|
||||
fun newIntent(context: Context?, attachments: List<AttachmentViewData>, index: Int): Intent {
|
||||
fun newIntent(
|
||||
context: Context?,
|
||||
attachments: List<AttachmentViewData>,
|
||||
index: Int
|
||||
): Intent {
|
||||
val intent = Intent(context, ViewMediaActivity::class.java)
|
||||
intent.putParcelableArrayListExtra(EXTRA_ATTACHMENTS, ArrayList(attachments))
|
||||
intent.putExtra(EXTRA_ATTACHMENT_INDEX, index)
|
||||
|
|
|
|||
|
|
@ -24,7 +24,9 @@ import com.keylesspalace.tusky.entity.StringField
|
|||
import com.keylesspalace.tusky.util.BindingHolder
|
||||
import com.keylesspalace.tusky.util.fixTextSelection
|
||||
|
||||
class AccountFieldEditAdapter : RecyclerView.Adapter<BindingHolder<ItemEditFieldBinding>>() {
|
||||
class AccountFieldEditAdapter(
|
||||
var onFieldsChanged: () -> Unit = { }
|
||||
) : RecyclerView.Adapter<BindingHolder<ItemEditFieldBinding>>() {
|
||||
|
||||
private val fieldData = mutableListOf<MutableStringPair>()
|
||||
private var maxNameLength: Int? = null
|
||||
|
|
@ -62,8 +64,15 @@ class AccountFieldEditAdapter : RecyclerView.Adapter<BindingHolder<ItemEditField
|
|||
|
||||
override fun getItemCount() = fieldData.size
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemEditFieldBinding> {
|
||||
val binding = ItemEditFieldBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
viewType: Int
|
||||
): BindingHolder<ItemEditFieldBinding> {
|
||||
val binding = ItemEditFieldBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
return BindingHolder(binding)
|
||||
}
|
||||
|
||||
|
|
@ -83,10 +92,12 @@ class AccountFieldEditAdapter : RecyclerView.Adapter<BindingHolder<ItemEditField
|
|||
|
||||
holder.binding.accountFieldNameText.doAfterTextChanged { newText ->
|
||||
fieldData.getOrNull(holder.bindingAdapterPosition)?.first = newText.toString()
|
||||
onFieldsChanged()
|
||||
}
|
||||
|
||||
holder.binding.accountFieldValueText.doAfterTextChanged { newText ->
|
||||
fieldData.getOrNull(holder.bindingAdapterPosition)?.second = newText.toString()
|
||||
onFieldsChanged()
|
||||
}
|
||||
|
||||
// Ensure the textview contents are selectable
|
||||
|
|
|
|||
|
|
@ -28,7 +28,10 @@ import com.keylesspalace.tusky.settings.PrefKeys
|
|||
import com.keylesspalace.tusky.util.emojify
|
||||
import com.keylesspalace.tusky.util.loadAvatar
|
||||
|
||||
class AccountSelectionAdapter(context: Context) : ArrayAdapter<AccountEntity>(context, R.layout.item_autocomplete_account) {
|
||||
class AccountSelectionAdapter(context: Context) : ArrayAdapter<AccountEntity>(
|
||||
context,
|
||||
R.layout.item_autocomplete_account
|
||||
) {
|
||||
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
val binding = if (convertView == null) {
|
||||
|
|
@ -47,7 +50,7 @@ class AccountSelectionAdapter(context: Context) : ArrayAdapter<AccountEntity>(co
|
|||
binding.avatarBadge.visibility = View.GONE // We never want to display the bot badge here
|
||||
|
||||
val avatarRadius = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_42dp)
|
||||
val animateAvatar = pm.getBoolean("animateGifAvatars", false)
|
||||
val animateAvatar = pm.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false)
|
||||
|
||||
loadAvatar(account.profilePictureUrl, binding.avatar, avatarRadius, animateAvatar)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,13 +31,20 @@ class EmojiAdapter(
|
|||
private val animate: Boolean
|
||||
) : RecyclerView.Adapter<BindingHolder<ItemEmojiButtonBinding>>() {
|
||||
|
||||
private val emojiList: List<Emoji> = emojiList.filter { emoji -> emoji.visibleInPicker == null || emoji.visibleInPicker }
|
||||
private val emojiList: List<Emoji> = emojiList.filter { emoji -> emoji.visibleInPicker }
|
||||
.sortedBy { it.shortcode.lowercase(Locale.ROOT) }
|
||||
|
||||
override fun getItemCount() = emojiList.size
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemEmojiButtonBinding> {
|
||||
val binding = ItemEmojiButtonBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
viewType: Int
|
||||
): BindingHolder<ItemEmojiButtonBinding> {
|
||||
val binding = ItemEmojiButtonBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
return BindingHolder(binding)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,17 +16,15 @@
|
|||
package com.keylesspalace.tusky.adapter
|
||||
|
||||
import android.graphics.Typeface
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.text.SpannableString
|
||||
import android.text.Spanned
|
||||
import android.text.style.StyleSpan
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.components.notifications.NotificationsPagingAdapter
|
||||
import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding
|
||||
import com.keylesspalace.tusky.entity.TimelineAccount
|
||||
import com.keylesspalace.tusky.interfaces.AccountActionListener
|
||||
import com.keylesspalace.tusky.interfaces.LinkListener
|
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
||||
import com.keylesspalace.tusky.util.emojify
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.loadAvatar
|
||||
|
|
@ -35,33 +33,12 @@ import com.keylesspalace.tusky.util.setClickableText
|
|||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.util.unicodeWrap
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
import com.keylesspalace.tusky.viewdata.NotificationViewData
|
||||
|
||||
class FollowRequestViewHolder(
|
||||
private val binding: ItemFollowRequestBinding,
|
||||
private val accountActionListener: AccountActionListener,
|
||||
private val linkListener: LinkListener,
|
||||
private val showHeader: Boolean
|
||||
) : NotificationsPagingAdapter.ViewHolder, RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
override fun bind(
|
||||
viewData: NotificationViewData,
|
||||
payloads: List<*>?,
|
||||
statusDisplayOptions: StatusDisplayOptions
|
||||
) {
|
||||
// Skip updates with payloads. That indicates a timestamp update, and
|
||||
// this view does not have timestamps.
|
||||
if (!payloads.isNullOrEmpty()) return
|
||||
|
||||
setupWithAccount(
|
||||
viewData.account,
|
||||
statusDisplayOptions.animateAvatars,
|
||||
statusDisplayOptions.animateEmojis,
|
||||
statusDisplayOptions.showBotOverlay
|
||||
)
|
||||
|
||||
setupActionListener(accountActionListener, viewData.account.id)
|
||||
}
|
||||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
fun setupWithAccount(
|
||||
account: TimelineAccount,
|
||||
|
|
@ -72,7 +49,7 @@ class FollowRequestViewHolder(
|
|||
val wrappedName = account.name.unicodeWrap()
|
||||
val emojifiedName: CharSequence = wrappedName.emojify(
|
||||
account.emojis,
|
||||
itemView,
|
||||
binding.displayNameTextView,
|
||||
animateEmojis
|
||||
)
|
||||
binding.displayNameTextView.text = emojifiedName
|
||||
|
|
@ -81,17 +58,15 @@ class FollowRequestViewHolder(
|
|||
R.string.notification_follow_request_format,
|
||||
wrappedName
|
||||
)
|
||||
binding.notificationTextView.text = SpannableStringBuilder(wholeMessage).apply {
|
||||
setSpan(
|
||||
StyleSpan(Typeface.BOLD),
|
||||
0,
|
||||
wrappedName.length,
|
||||
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
}.emojify(account.emojis, itemView, animateEmojis)
|
||||
binding.notificationTextView.text = SpannableString(wholeMessage).apply {
|
||||
setSpan(StyleSpan(Typeface.BOLD), 0, wrappedName.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
}.emojify(account.emojis, binding.notificationTextView, animateEmojis)
|
||||
}
|
||||
binding.notificationTextView.visible(showHeader)
|
||||
val formattedUsername = itemView.context.getString(R.string.post_username_format, account.username)
|
||||
val formattedUsername = itemView.context.getString(
|
||||
R.string.post_username_format,
|
||||
account.username
|
||||
)
|
||||
binding.usernameTextView.text = formattedUsername
|
||||
if (account.note.isEmpty()) {
|
||||
binding.accountNote.hide()
|
||||
|
|
@ -102,7 +77,9 @@ class FollowRequestViewHolder(
|
|||
.emojify(account.emojis, binding.accountNote, animateEmojis)
|
||||
setClickableText(binding.accountNote, emojifiedNote, emptyList(), null, linkListener)
|
||||
}
|
||||
val avatarRadius = binding.avatar.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp)
|
||||
val avatarRadius = binding.avatar.context.resources.getDimensionPixelSize(
|
||||
R.dimen.avatar_radius_48dp
|
||||
)
|
||||
loadAvatar(account.avatar, binding.avatar, avatarRadius, animateAvatar)
|
||||
binding.avatarBadge.visible(showBotOverlay && account.bot)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,11 @@ import com.keylesspalace.tusky.util.getTuskyDisplayName
|
|||
import com.keylesspalace.tusky.util.modernLanguageCode
|
||||
import java.util.Locale
|
||||
|
||||
class LocaleAdapter(context: Context, resource: Int, locales: List<Locale>) : ArrayAdapter<Locale>(context, resource, locales) {
|
||||
class LocaleAdapter(context: Context, resource: Int, locales: List<Locale>) : ArrayAdapter<Locale>(
|
||||
context,
|
||||
resource,
|
||||
locales
|
||||
) {
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
return (super.getView(position, convertView, parent) as TextView).apply {
|
||||
setTextColor(MaterialColors.getColor(this, android.R.attr.textColorTertiary))
|
||||
|
|
|
|||
|
|
@ -0,0 +1,708 @@
|
|||
/* Copyright 2021 Tusky Contributors
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.adapter;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.PorterDuff;
|
||||
import android.graphics.Typeface;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.text.InputFilter;
|
||||
import android.text.SpannableStringBuilder;
|
||||
import android.text.Spanned;
|
||||
import android.text.TextUtils;
|
||||
import android.text.style.StyleSpan;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.Button;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.ColorRes;
|
||||
import androidx.annotation.DrawableRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.bumptech.glide.Glide;
|
||||
import com.keylesspalace.tusky.R;
|
||||
import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding;
|
||||
import com.keylesspalace.tusky.databinding.ItemReportNotificationBinding;
|
||||
import com.keylesspalace.tusky.entity.Emoji;
|
||||
import com.keylesspalace.tusky.entity.Notification;
|
||||
import com.keylesspalace.tusky.entity.Status;
|
||||
import com.keylesspalace.tusky.entity.TimelineAccount;
|
||||
import com.keylesspalace.tusky.interfaces.AccountActionListener;
|
||||
import com.keylesspalace.tusky.interfaces.LinkListener;
|
||||
import com.keylesspalace.tusky.interfaces.StatusActionListener;
|
||||
import com.keylesspalace.tusky.util.AbsoluteTimeFormatter;
|
||||
import com.keylesspalace.tusky.util.CardViewMode;
|
||||
import com.keylesspalace.tusky.util.CustomEmojiHelper;
|
||||
import com.keylesspalace.tusky.util.ImageLoadingHelper;
|
||||
import com.keylesspalace.tusky.util.LinkHelper;
|
||||
import com.keylesspalace.tusky.util.SmartLengthInputFilter;
|
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions;
|
||||
import com.keylesspalace.tusky.util.StringUtils;
|
||||
import com.keylesspalace.tusky.util.TimestampUtils;
|
||||
import com.keylesspalace.tusky.viewdata.NotificationViewData;
|
||||
import com.keylesspalace.tusky.viewdata.StatusViewData;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
import at.connyduck.sparkbutton.helpers.Utils;
|
||||
|
||||
public class NotificationsAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> implements LinkListener{
|
||||
|
||||
public interface AdapterDataSource<T> {
|
||||
int getItemCount();
|
||||
|
||||
T getItemAt(int pos);
|
||||
}
|
||||
|
||||
|
||||
private static final int VIEW_TYPE_STATUS = 0;
|
||||
private static final int VIEW_TYPE_STATUS_NOTIFICATION = 1;
|
||||
private static final int VIEW_TYPE_FOLLOW = 2;
|
||||
private static final int VIEW_TYPE_FOLLOW_REQUEST = 3;
|
||||
private static final int VIEW_TYPE_PLACEHOLDER = 4;
|
||||
private static final int VIEW_TYPE_REPORT = 5;
|
||||
private static final int VIEW_TYPE_UNKNOWN = 6;
|
||||
|
||||
private static final InputFilter[] COLLAPSE_INPUT_FILTER = new InputFilter[]{SmartLengthInputFilter.INSTANCE};
|
||||
private static final InputFilter[] NO_INPUT_FILTER = new InputFilter[0];
|
||||
|
||||
private final String accountId;
|
||||
private StatusDisplayOptions statusDisplayOptions;
|
||||
private final StatusActionListener statusListener;
|
||||
private final NotificationActionListener notificationActionListener;
|
||||
private final AccountActionListener accountActionListener;
|
||||
private final AdapterDataSource<NotificationViewData> dataSource;
|
||||
private final AbsoluteTimeFormatter absoluteTimeFormatter = new AbsoluteTimeFormatter();
|
||||
|
||||
public NotificationsAdapter(String accountId,
|
||||
AdapterDataSource<NotificationViewData> dataSource,
|
||||
StatusDisplayOptions statusDisplayOptions,
|
||||
StatusActionListener statusListener,
|
||||
NotificationActionListener notificationActionListener,
|
||||
AccountActionListener accountActionListener) {
|
||||
|
||||
this.accountId = accountId;
|
||||
this.dataSource = dataSource;
|
||||
this.statusDisplayOptions = statusDisplayOptions;
|
||||
this.statusListener = statusListener;
|
||||
this.notificationActionListener = notificationActionListener;
|
||||
this.accountActionListener = accountActionListener;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
||||
LayoutInflater inflater = LayoutInflater.from(parent.getContext());
|
||||
switch (viewType) {
|
||||
case VIEW_TYPE_STATUS: {
|
||||
View view = inflater
|
||||
.inflate(R.layout.item_status, parent, false);
|
||||
return new StatusViewHolder(view);
|
||||
}
|
||||
case VIEW_TYPE_STATUS_NOTIFICATION: {
|
||||
View view = inflater
|
||||
.inflate(R.layout.item_status_notification, parent, false);
|
||||
return new StatusNotificationViewHolder(view, statusDisplayOptions, absoluteTimeFormatter);
|
||||
}
|
||||
case VIEW_TYPE_FOLLOW: {
|
||||
View view = inflater
|
||||
.inflate(R.layout.item_follow, parent, false);
|
||||
return new FollowViewHolder(view, statusDisplayOptions);
|
||||
}
|
||||
case VIEW_TYPE_FOLLOW_REQUEST: {
|
||||
ItemFollowRequestBinding binding = ItemFollowRequestBinding.inflate(inflater, parent, false);
|
||||
return new FollowRequestViewHolder(binding, this, true);
|
||||
}
|
||||
case VIEW_TYPE_PLACEHOLDER: {
|
||||
View view = inflater
|
||||
.inflate(R.layout.item_status_placeholder, parent, false);
|
||||
return new PlaceholderViewHolder(view);
|
||||
}
|
||||
case VIEW_TYPE_REPORT: {
|
||||
ItemReportNotificationBinding binding = ItemReportNotificationBinding.inflate(inflater, parent, false);
|
||||
return new ReportNotificationViewHolder(binding);
|
||||
}
|
||||
default:
|
||||
case VIEW_TYPE_UNKNOWN: {
|
||||
View view = new View(parent.getContext());
|
||||
view.setLayoutParams(
|
||||
new ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
Utils.dpToPx(parent.getContext(), 24)
|
||||
)
|
||||
);
|
||||
return new RecyclerView.ViewHolder(view) {
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) {
|
||||
bindViewHolder(viewHolder, position, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position, @NonNull List<Object> payloads) {
|
||||
bindViewHolder(viewHolder, position, payloads);
|
||||
}
|
||||
|
||||
private void bindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position, @Nullable List<Object> payloads) {
|
||||
Object payloadForHolder = payloads != null && !payloads.isEmpty() ? payloads.get(0) : null;
|
||||
if (position < this.dataSource.getItemCount()) {
|
||||
NotificationViewData notification = dataSource.getItemAt(position);
|
||||
if (notification instanceof NotificationViewData.Placeholder) {
|
||||
if (payloadForHolder == null) {
|
||||
NotificationViewData.Placeholder placeholder = ((NotificationViewData.Placeholder) notification);
|
||||
PlaceholderViewHolder holder = (PlaceholderViewHolder) viewHolder;
|
||||
holder.setup(statusListener, placeholder.isLoading());
|
||||
}
|
||||
return;
|
||||
}
|
||||
NotificationViewData.Concrete concreteNotification =
|
||||
(NotificationViewData.Concrete) notification;
|
||||
switch (viewHolder.getItemViewType()) {
|
||||
case VIEW_TYPE_STATUS: {
|
||||
StatusViewHolder holder = (StatusViewHolder) viewHolder;
|
||||
StatusViewData.Concrete status = concreteNotification.getStatusViewData();
|
||||
if (status == null) {
|
||||
/* in some very rare cases servers sends null status even though they should not,
|
||||
* we have to handle it somehow */
|
||||
holder.showStatusContent(false);
|
||||
} else {
|
||||
if (payloads == null) {
|
||||
holder.showStatusContent(true);
|
||||
}
|
||||
holder.setupWithStatus(status, statusListener, statusDisplayOptions, payloadForHolder);
|
||||
}
|
||||
if (concreteNotification.getType() == Notification.Type.POLL) {
|
||||
holder.setPollInfo(accountId.equals(concreteNotification.getAccount().getId()));
|
||||
} else {
|
||||
holder.hideStatusInfo();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case VIEW_TYPE_STATUS_NOTIFICATION: {
|
||||
StatusNotificationViewHolder holder = (StatusNotificationViewHolder) viewHolder;
|
||||
StatusViewData.Concrete statusViewData = concreteNotification.getStatusViewData();
|
||||
if (payloadForHolder == null) {
|
||||
if (statusViewData == null) {
|
||||
/* in some very rare cases servers sends null status even though they should not,
|
||||
* we have to handle it somehow */
|
||||
holder.showNotificationContent(false);
|
||||
} else {
|
||||
holder.showNotificationContent(true);
|
||||
|
||||
Status status = statusViewData.getActionable();
|
||||
holder.setDisplayName(status.getAccount().getDisplayName(), status.getAccount().getEmojis());
|
||||
holder.setUsername(status.getAccount().getUsername());
|
||||
holder.setCreatedAt(status.getCreatedAt());
|
||||
|
||||
if (concreteNotification.getType() == Notification.Type.STATUS ||
|
||||
concreteNotification.getType() == Notification.Type.UPDATE) {
|
||||
holder.setAvatar(status.getAccount().getAvatar(), status.getAccount().getBot());
|
||||
} else {
|
||||
holder.setAvatars(status.getAccount().getAvatar(),
|
||||
concreteNotification.getAccount().getAvatar());
|
||||
}
|
||||
}
|
||||
|
||||
holder.setMessage(concreteNotification, statusListener);
|
||||
holder.setupButtons(notificationActionListener,
|
||||
concreteNotification.getAccount().getId(),
|
||||
concreteNotification.getId());
|
||||
} else {
|
||||
if (payloadForHolder instanceof List)
|
||||
for (Object item : (List<?>) payloadForHolder) {
|
||||
if (StatusBaseViewHolder.Key.KEY_CREATED.equals(item) && statusViewData != null) {
|
||||
holder.setCreatedAt(statusViewData.getStatus().getActionableStatus().getCreatedAt());
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case VIEW_TYPE_FOLLOW: {
|
||||
if (payloadForHolder == null) {
|
||||
FollowViewHolder holder = (FollowViewHolder) viewHolder;
|
||||
holder.setMessage(concreteNotification.getAccount(), concreteNotification.getType() == Notification.Type.SIGN_UP);
|
||||
holder.setupButtons(notificationActionListener, concreteNotification.getAccount().getId());
|
||||
}
|
||||
break;
|
||||
}
|
||||
case VIEW_TYPE_FOLLOW_REQUEST: {
|
||||
if (payloadForHolder == null) {
|
||||
FollowRequestViewHolder holder = (FollowRequestViewHolder) viewHolder;
|
||||
holder.setupWithAccount(concreteNotification.getAccount(), statusDisplayOptions.animateAvatars(), statusDisplayOptions.animateEmojis(), statusDisplayOptions.showBotOverlay());
|
||||
holder.setupActionListener(accountActionListener, concreteNotification.getAccount().getId());
|
||||
}
|
||||
break;
|
||||
}
|
||||
case VIEW_TYPE_REPORT: {
|
||||
if (payloadForHolder == null) {
|
||||
ReportNotificationViewHolder holder = (ReportNotificationViewHolder) viewHolder;
|
||||
holder.setupWithReport(concreteNotification.getAccount(), concreteNotification.getReport(), statusDisplayOptions.animateAvatars(), statusDisplayOptions.animateEmojis());
|
||||
holder.setupActionListener(notificationActionListener, concreteNotification.getReport().getTargetAccount().getId(), concreteNotification.getAccount().getId(), concreteNotification.getReport().getId());
|
||||
}
|
||||
}
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
return dataSource.getItemCount();
|
||||
}
|
||||
|
||||
public void setMediaPreviewEnabled(boolean mediaPreviewEnabled) {
|
||||
this.statusDisplayOptions = statusDisplayOptions.copy(
|
||||
statusDisplayOptions.animateAvatars(),
|
||||
mediaPreviewEnabled,
|
||||
statusDisplayOptions.useAbsoluteTime(),
|
||||
statusDisplayOptions.showBotOverlay(),
|
||||
statusDisplayOptions.useBlurhash(),
|
||||
CardViewMode.NONE,
|
||||
statusDisplayOptions.confirmReblogs(),
|
||||
statusDisplayOptions.confirmFavourites(),
|
||||
statusDisplayOptions.hideStats(),
|
||||
statusDisplayOptions.animateEmojis(),
|
||||
statusDisplayOptions.showStatsInline(),
|
||||
statusDisplayOptions.showSensitiveMedia(),
|
||||
statusDisplayOptions.openSpoiler()
|
||||
);
|
||||
}
|
||||
|
||||
public boolean isMediaPreviewEnabled() {
|
||||
return this.statusDisplayOptions.mediaPreviewEnabled();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemViewType(int position) {
|
||||
NotificationViewData notification = dataSource.getItemAt(position);
|
||||
if (notification instanceof NotificationViewData.Concrete) {
|
||||
NotificationViewData.Concrete concrete = ((NotificationViewData.Concrete) notification);
|
||||
switch (concrete.getType()) {
|
||||
case MENTION:
|
||||
case POLL: {
|
||||
return VIEW_TYPE_STATUS;
|
||||
}
|
||||
case STATUS:
|
||||
case FAVOURITE:
|
||||
case REBLOG:
|
||||
case UPDATE: {
|
||||
return VIEW_TYPE_STATUS_NOTIFICATION;
|
||||
}
|
||||
case FOLLOW:
|
||||
case SIGN_UP: {
|
||||
return VIEW_TYPE_FOLLOW;
|
||||
}
|
||||
case FOLLOW_REQUEST: {
|
||||
return VIEW_TYPE_FOLLOW_REQUEST;
|
||||
}
|
||||
case REPORT: {
|
||||
return VIEW_TYPE_REPORT;
|
||||
}
|
||||
default: {
|
||||
return VIEW_TYPE_UNKNOWN;
|
||||
}
|
||||
}
|
||||
} else if (notification instanceof NotificationViewData.Placeholder) {
|
||||
return VIEW_TYPE_PLACEHOLDER;
|
||||
} else {
|
||||
throw new AssertionError("Unknown notification type");
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
public interface NotificationActionListener {
|
||||
void onViewAccount(String id);
|
||||
|
||||
void onViewStatusForNotificationId(String notificationId);
|
||||
|
||||
void onViewReport(String reportId);
|
||||
|
||||
void onExpandedChange(boolean expanded, int position);
|
||||
|
||||
/**
|
||||
* Called when the status {@link android.widget.ToggleButton} responsible for collapsing long
|
||||
* status content is interacted with.
|
||||
*
|
||||
* @param isCollapsed Whether the status content is shown in a collapsed state or fully.
|
||||
* @param position The position of the status in the list.
|
||||
*/
|
||||
void onNotificationContentCollapsedChange(boolean isCollapsed, int position);
|
||||
}
|
||||
|
||||
private static class FollowViewHolder extends RecyclerView.ViewHolder {
|
||||
private final TextView message;
|
||||
private final TextView usernameView;
|
||||
private final TextView displayNameView;
|
||||
private final ImageView avatar;
|
||||
private final StatusDisplayOptions statusDisplayOptions;
|
||||
|
||||
FollowViewHolder(View itemView, StatusDisplayOptions statusDisplayOptions) {
|
||||
super(itemView);
|
||||
message = itemView.findViewById(R.id.notification_text);
|
||||
usernameView = itemView.findViewById(R.id.notification_username);
|
||||
displayNameView = itemView.findViewById(R.id.notification_display_name);
|
||||
avatar = itemView.findViewById(R.id.notification_avatar);
|
||||
this.statusDisplayOptions = statusDisplayOptions;
|
||||
}
|
||||
|
||||
void setMessage(TimelineAccount account, Boolean isSignUp) {
|
||||
Context context = message.getContext();
|
||||
|
||||
String format = context.getString(isSignUp ? R.string.notification_sign_up_format : R.string.notification_follow_format);
|
||||
String wrappedDisplayName = StringUtils.unicodeWrap(account.getName());
|
||||
String wholeMessage = String.format(format, wrappedDisplayName);
|
||||
CharSequence emojifiedMessage = CustomEmojiHelper.emojify(
|
||||
wholeMessage, account.getEmojis(), message, statusDisplayOptions.animateEmojis()
|
||||
);
|
||||
message.setText(emojifiedMessage);
|
||||
|
||||
String username = context.getString(R.string.post_username_format, account.getUsername());
|
||||
usernameView.setText(username);
|
||||
|
||||
CharSequence emojifiedDisplayName = CustomEmojiHelper.emojify(
|
||||
wrappedDisplayName, account.getEmojis(), usernameView, statusDisplayOptions.animateEmojis()
|
||||
);
|
||||
|
||||
displayNameView.setText(emojifiedDisplayName);
|
||||
|
||||
int avatarRadius = avatar.getContext().getResources()
|
||||
.getDimensionPixelSize(R.dimen.avatar_radius_42dp);
|
||||
|
||||
ImageLoadingHelper.loadAvatar(account.getAvatar(), avatar, avatarRadius,
|
||||
statusDisplayOptions.animateAvatars(), null);
|
||||
|
||||
}
|
||||
|
||||
void setupButtons(final NotificationActionListener listener, final String accountId) {
|
||||
itemView.setOnClickListener(v -> listener.onViewAccount(accountId));
|
||||
}
|
||||
}
|
||||
|
||||
private static class StatusNotificationViewHolder extends RecyclerView.ViewHolder
|
||||
implements View.OnClickListener {
|
||||
|
||||
private final View container;
|
||||
private final TextView message;
|
||||
// private final View statusNameBar;
|
||||
private final TextView displayName;
|
||||
private final TextView username;
|
||||
private final TextView timestampInfo;
|
||||
private final TextView statusContent;
|
||||
private final ImageView statusAvatar;
|
||||
private final ImageView notificationAvatar;
|
||||
private final TextView contentWarningDescriptionTextView;
|
||||
private final Button contentWarningButton;
|
||||
private final Button contentCollapseButton; // TODO: This code SHOULD be based on StatusBaseViewHolder
|
||||
private final StatusDisplayOptions statusDisplayOptions;
|
||||
private final AbsoluteTimeFormatter absoluteTimeFormatter;
|
||||
|
||||
private String accountId;
|
||||
private String notificationId;
|
||||
private NotificationActionListener notificationActionListener;
|
||||
private StatusViewData.Concrete statusViewData;
|
||||
|
||||
private final int avatarRadius48dp;
|
||||
private final int avatarRadius36dp;
|
||||
private final int avatarRadius24dp;
|
||||
|
||||
StatusNotificationViewHolder(
|
||||
View itemView,
|
||||
StatusDisplayOptions statusDisplayOptions,
|
||||
AbsoluteTimeFormatter absoluteTimeFormatter
|
||||
) {
|
||||
super(itemView);
|
||||
message = itemView.findViewById(R.id.notification_top_text);
|
||||
// statusNameBar = itemView.findViewById(R.id.status_name_bar);
|
||||
displayName = itemView.findViewById(R.id.status_display_name);
|
||||
username = itemView.findViewById(R.id.status_username);
|
||||
timestampInfo = itemView.findViewById(R.id.status_meta_info);
|
||||
statusContent = itemView.findViewById(R.id.notification_content);
|
||||
statusAvatar = itemView.findViewById(R.id.notification_status_avatar);
|
||||
notificationAvatar = itemView.findViewById(R.id.notification_notification_avatar);
|
||||
contentWarningDescriptionTextView = itemView.findViewById(R.id.notification_content_warning_description);
|
||||
contentWarningButton = itemView.findViewById(R.id.notification_content_warning_button);
|
||||
contentCollapseButton = itemView.findViewById(R.id.button_toggle_notification_content);
|
||||
|
||||
container = itemView.findViewById(R.id.notification_container);
|
||||
|
||||
this.statusDisplayOptions = statusDisplayOptions;
|
||||
this.absoluteTimeFormatter = absoluteTimeFormatter;
|
||||
|
||||
int darkerFilter = Color.rgb(123, 123, 123);
|
||||
statusAvatar.setColorFilter(darkerFilter, PorterDuff.Mode.MULTIPLY);
|
||||
notificationAvatar.setColorFilter(darkerFilter, PorterDuff.Mode.MULTIPLY);
|
||||
|
||||
itemView.setOnClickListener(this);
|
||||
message.setOnClickListener(this);
|
||||
statusContent.setOnClickListener(this);
|
||||
|
||||
this.avatarRadius48dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_48dp);
|
||||
this.avatarRadius36dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_36dp);
|
||||
this.avatarRadius24dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_24dp);
|
||||
}
|
||||
|
||||
private void showNotificationContent(boolean show) {
|
||||
// statusNameBar.setVisibility(show ? View.VISIBLE : View.GONE);
|
||||
contentWarningDescriptionTextView.setVisibility(show ? View.VISIBLE : View.GONE);
|
||||
contentWarningButton.setVisibility(show ? View.VISIBLE : View.GONE);
|
||||
statusContent.setVisibility(show ? View.VISIBLE : View.GONE);
|
||||
statusAvatar.setVisibility(show ? View.VISIBLE : View.GONE);
|
||||
notificationAvatar.setVisibility(show ? View.VISIBLE : View.GONE);
|
||||
}
|
||||
|
||||
private void setDisplayName(String name, List<Emoji> emojis) {
|
||||
CharSequence emojifiedName = CustomEmojiHelper.emojify(name, emojis, displayName, statusDisplayOptions.animateEmojis());
|
||||
displayName.setText(emojifiedName);
|
||||
}
|
||||
|
||||
private void setUsername(String name) {
|
||||
Context context = username.getContext();
|
||||
String format = context.getString(R.string.post_username_format);
|
||||
String usernameText = String.format(format, name);
|
||||
username.setText(usernameText);
|
||||
}
|
||||
|
||||
protected void setCreatedAt(@Nullable Date createdAt) {
|
||||
if (statusDisplayOptions.useAbsoluteTime()) {
|
||||
timestampInfo.setText(absoluteTimeFormatter.format(createdAt, true));
|
||||
} else {
|
||||
// This is the visible timestampInfo.
|
||||
String readout;
|
||||
/* This one is for screen-readers. Frequently, they would mispronounce timestamps like "17m"
|
||||
* as 17 meters instead of minutes. */
|
||||
CharSequence readoutAloud;
|
||||
if (createdAt != null) {
|
||||
long then = createdAt.getTime();
|
||||
long now = new Date().getTime();
|
||||
readout = TimestampUtils.getRelativeTimeSpanString(timestampInfo.getContext(), then, now);
|
||||
readoutAloud = android.text.format.DateUtils.getRelativeTimeSpanString(then, now,
|
||||
android.text.format.DateUtils.SECOND_IN_MILLIS,
|
||||
android.text.format.DateUtils.FORMAT_ABBREV_RELATIVE);
|
||||
} else {
|
||||
// unknown minutes~
|
||||
readout = "?m";
|
||||
readoutAloud = "? minutes";
|
||||
}
|
||||
timestampInfo.setText(readout);
|
||||
timestampInfo.setContentDescription(readoutAloud);
|
||||
}
|
||||
}
|
||||
|
||||
Drawable getIconWithColor(Context context, @DrawableRes int drawable, @ColorRes int color) {
|
||||
Drawable icon = ContextCompat.getDrawable(context, drawable);
|
||||
if (icon != null) {
|
||||
icon.setColorFilter(context.getColor(color), PorterDuff.Mode.SRC_ATOP);
|
||||
}
|
||||
return icon;
|
||||
}
|
||||
|
||||
void setMessage(NotificationViewData.Concrete notificationViewData, LinkListener listener) {
|
||||
this.statusViewData = notificationViewData.getStatusViewData();
|
||||
|
||||
String displayName = StringUtils.unicodeWrap(notificationViewData.getAccount().getName());
|
||||
Notification.Type type = notificationViewData.getType();
|
||||
|
||||
Context context = message.getContext();
|
||||
String format;
|
||||
Drawable icon;
|
||||
switch (type) {
|
||||
default:
|
||||
case FAVOURITE: {
|
||||
icon = getIconWithColor(context, R.drawable.ic_star_24dp, R.color.tusky_orange);
|
||||
format = context.getString(R.string.notification_favourite_format);
|
||||
break;
|
||||
}
|
||||
case REBLOG: {
|
||||
icon = getIconWithColor(context, R.drawable.ic_repeat_24dp, R.color.tusky_blue);
|
||||
format = context.getString(R.string.notification_reblog_format);
|
||||
break;
|
||||
}
|
||||
case STATUS: {
|
||||
icon = getIconWithColor(context, R.drawable.ic_home_24dp, R.color.tusky_blue);
|
||||
format = context.getString(R.string.notification_subscription_format);
|
||||
break;
|
||||
}
|
||||
case UPDATE: {
|
||||
icon = getIconWithColor(context, R.drawable.ic_edit_24dp, R.color.tusky_blue);
|
||||
format = context.getString(R.string.notification_update_format);
|
||||
break;
|
||||
}
|
||||
}
|
||||
message.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null);
|
||||
String wholeMessage = String.format(format, displayName);
|
||||
final SpannableStringBuilder str = new SpannableStringBuilder(wholeMessage);
|
||||
int displayNameIndex = format.indexOf("%s");
|
||||
str.setSpan(
|
||||
new StyleSpan(Typeface.BOLD),
|
||||
displayNameIndex,
|
||||
displayNameIndex + displayName.length(),
|
||||
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||
);
|
||||
CharSequence emojifiedText = CustomEmojiHelper.emojify(
|
||||
str, notificationViewData.getAccount().getEmojis(), message, statusDisplayOptions.animateEmojis()
|
||||
);
|
||||
message.setText(emojifiedText);
|
||||
|
||||
if (statusViewData != null) {
|
||||
boolean hasSpoiler = !TextUtils.isEmpty(statusViewData.getStatus().getSpoilerText());
|
||||
contentWarningDescriptionTextView.setVisibility(hasSpoiler ? View.VISIBLE : View.GONE);
|
||||
contentWarningButton.setVisibility(hasSpoiler ? View.VISIBLE : View.GONE);
|
||||
if (statusViewData.isExpanded()) {
|
||||
contentWarningButton.setText(R.string.post_content_warning_show_less);
|
||||
} else {
|
||||
contentWarningButton.setText(R.string.post_content_warning_show_more);
|
||||
}
|
||||
|
||||
contentWarningButton.setOnClickListener(view -> {
|
||||
if (getBindingAdapterPosition() != RecyclerView.NO_POSITION) {
|
||||
notificationActionListener.onExpandedChange(!statusViewData.isExpanded(), getBindingAdapterPosition());
|
||||
}
|
||||
statusContent.setVisibility(statusViewData.isExpanded() ? View.GONE : View.VISIBLE);
|
||||
});
|
||||
|
||||
setupContentAndSpoiler(listener);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
void setupButtons(final NotificationActionListener listener, final String accountId,
|
||||
final String notificationId) {
|
||||
this.notificationActionListener = listener;
|
||||
this.accountId = accountId;
|
||||
this.notificationId = notificationId;
|
||||
}
|
||||
|
||||
void setAvatar(@Nullable String statusAvatarUrl, boolean isBot) {
|
||||
statusAvatar.setPaddingRelative(0, 0, 0, 0);
|
||||
|
||||
ImageLoadingHelper.loadAvatar(statusAvatarUrl,
|
||||
statusAvatar, avatarRadius48dp, statusDisplayOptions.animateAvatars(), null);
|
||||
|
||||
if (statusDisplayOptions.showBotOverlay() && isBot) {
|
||||
notificationAvatar.setVisibility(View.VISIBLE);
|
||||
Glide.with(notificationAvatar)
|
||||
.load(R.drawable.bot_badge)
|
||||
.into(notificationAvatar);
|
||||
|
||||
} else {
|
||||
notificationAvatar.setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
|
||||
void setAvatars(@Nullable String statusAvatarUrl, @Nullable String notificationAvatarUrl) {
|
||||
int padding = Utils.dpToPx(statusAvatar.getContext(), 12);
|
||||
statusAvatar.setPaddingRelative(0, 0, padding, padding);
|
||||
|
||||
ImageLoadingHelper.loadAvatar(statusAvatarUrl,
|
||||
statusAvatar, avatarRadius36dp, statusDisplayOptions.animateAvatars(), null);
|
||||
|
||||
notificationAvatar.setVisibility(View.VISIBLE);
|
||||
ImageLoadingHelper.loadAvatar(notificationAvatarUrl, notificationAvatar,
|
||||
avatarRadius24dp, statusDisplayOptions.animateAvatars(), null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
if (notificationActionListener == null)
|
||||
return;
|
||||
|
||||
if (v == container || v == statusContent) {
|
||||
notificationActionListener.onViewStatusForNotificationId(notificationId);
|
||||
}
|
||||
else if (v == message) {
|
||||
notificationActionListener.onViewAccount(accountId);
|
||||
}
|
||||
}
|
||||
|
||||
private void setupContentAndSpoiler(final LinkListener listener) {
|
||||
|
||||
boolean shouldShowContentIfSpoiler = statusViewData.isExpanded();
|
||||
boolean hasSpoiler = !TextUtils.isEmpty(statusViewData.getStatus(). getSpoilerText());
|
||||
if (!shouldShowContentIfSpoiler && hasSpoiler) {
|
||||
statusContent.setVisibility(View.GONE);
|
||||
} else {
|
||||
statusContent.setVisibility(View.VISIBLE);
|
||||
}
|
||||
|
||||
Spanned content = statusViewData.getContent();
|
||||
List<Emoji> emojis = statusViewData.getActionable().getEmojis();
|
||||
|
||||
if (statusViewData.isCollapsible() && (statusViewData.isExpanded() || !hasSpoiler)) {
|
||||
contentCollapseButton.setOnClickListener(view -> {
|
||||
int position = getBindingAdapterPosition();
|
||||
if (position != RecyclerView.NO_POSITION && notificationActionListener != null) {
|
||||
notificationActionListener.onNotificationContentCollapsedChange(!statusViewData.isCollapsed(), position);
|
||||
}
|
||||
});
|
||||
|
||||
contentCollapseButton.setVisibility(View.VISIBLE);
|
||||
if (statusViewData.isCollapsed()) {
|
||||
contentCollapseButton.setText(R.string.post_content_warning_show_more);
|
||||
statusContent.setFilters(COLLAPSE_INPUT_FILTER);
|
||||
} else {
|
||||
contentCollapseButton.setText(R.string.post_content_warning_show_less);
|
||||
statusContent.setFilters(NO_INPUT_FILTER);
|
||||
}
|
||||
} else {
|
||||
contentCollapseButton.setVisibility(View.GONE);
|
||||
statusContent.setFilters(NO_INPUT_FILTER);
|
||||
}
|
||||
|
||||
CharSequence emojifiedText = CustomEmojiHelper.emojify(
|
||||
content, emojis, statusContent, statusDisplayOptions.animateEmojis()
|
||||
);
|
||||
LinkHelper.setClickableText(statusContent, emojifiedText, statusViewData.getActionable().getMentions(), statusViewData.getActionable().getTags(), listener);
|
||||
|
||||
CharSequence emojifiedContentWarning = CustomEmojiHelper.emojify(
|
||||
statusViewData.getStatus().getSpoilerText(),
|
||||
statusViewData.getActionable().getEmojis(),
|
||||
contentWarningDescriptionTextView,
|
||||
statusDisplayOptions.animateEmojis()
|
||||
);
|
||||
contentWarningDescriptionTextView.setText(emojifiedContentWarning);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void onViewTag(@NonNull String tag) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewAccount(@NonNull String id) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewUrl(@NonNull String url) {
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -67,7 +67,10 @@ class PollAdapter : RecyclerView.Adapter<BindingHolder<ItemPollBinding>>() {
|
|||
.map { pollOptions.indexOf(it) }
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemPollBinding> {
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
viewType: Int
|
||||
): BindingHolder<ItemPollBinding> {
|
||||
val binding = ItemPollBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
return BindingHolder(binding)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,7 +40,11 @@ class PreviewPollOptionsAdapter : RecyclerView.Adapter<PreviewViewHolder>() {
|
|||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PreviewViewHolder {
|
||||
return PreviewViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.item_poll_preview_option, parent, false))
|
||||
return PreviewViewHolder(
|
||||
LayoutInflater.from(
|
||||
parent.context
|
||||
).inflate(R.layout.item_poll_preview_option, parent, false)
|
||||
)
|
||||
}
|
||||
|
||||
override fun getItemCount() = options.size
|
||||
|
|
|
|||
|
|
@ -20,76 +20,33 @@ import androidx.core.content.ContextCompat
|
|||
import androidx.recyclerview.widget.RecyclerView
|
||||
import at.connyduck.sparkbutton.helpers.Utils
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.components.notifications.NotificationActionListener
|
||||
import com.keylesspalace.tusky.components.notifications.NotificationsPagingAdapter
|
||||
import com.keylesspalace.tusky.adapter.NotificationsAdapter.NotificationActionListener
|
||||
import com.keylesspalace.tusky.databinding.ItemReportNotificationBinding
|
||||
import com.keylesspalace.tusky.entity.Report
|
||||
import com.keylesspalace.tusky.entity.TimelineAccount
|
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
||||
import com.keylesspalace.tusky.util.emojify
|
||||
import com.keylesspalace.tusky.util.getRelativeTimeSpanString
|
||||
import com.keylesspalace.tusky.util.loadAvatar
|
||||
import com.keylesspalace.tusky.util.unicodeWrap
|
||||
import com.keylesspalace.tusky.viewdata.NotificationViewData
|
||||
import java.util.Date
|
||||
|
||||
class ReportNotificationViewHolder(
|
||||
private val binding: ItemReportNotificationBinding,
|
||||
private val notificationActionListener: NotificationActionListener
|
||||
) : NotificationsPagingAdapter.ViewHolder, RecyclerView.ViewHolder(binding.root) {
|
||||
private val binding: ItemReportNotificationBinding
|
||||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
override fun bind(
|
||||
viewData: NotificationViewData,
|
||||
payloads: List<*>?,
|
||||
statusDisplayOptions: StatusDisplayOptions
|
||||
) {
|
||||
// Skip updates with payloads. That indicates a timestamp update, and
|
||||
// this view does not have timestamps.
|
||||
if (!payloads.isNullOrEmpty()) return
|
||||
|
||||
setupWithReport(
|
||||
viewData.account,
|
||||
viewData.report!!,
|
||||
statusDisplayOptions.animateAvatars,
|
||||
statusDisplayOptions.animateEmojis
|
||||
)
|
||||
setupActionListener(
|
||||
notificationActionListener,
|
||||
viewData.report.targetAccount.id,
|
||||
viewData.account.id,
|
||||
viewData.report.id
|
||||
)
|
||||
}
|
||||
|
||||
private fun setupWithReport(
|
||||
fun setupWithReport(
|
||||
reporter: TimelineAccount,
|
||||
report: Report,
|
||||
animateAvatar: Boolean,
|
||||
animateEmojis: Boolean
|
||||
) {
|
||||
val reporterName = reporter.name.unicodeWrap().emojify(
|
||||
reporter.emojis,
|
||||
binding.root,
|
||||
animateEmojis
|
||||
)
|
||||
val reporteeName = report.targetAccount.name.unicodeWrap().emojify(
|
||||
report.targetAccount.emojis,
|
||||
itemView,
|
||||
animateEmojis
|
||||
)
|
||||
val icon = ContextCompat.getDrawable(binding.root.context, R.drawable.ic_flag_24dp)
|
||||
val reporterName = reporter.name.unicodeWrap().emojify(reporter.emojis, binding.notificationTopText, animateEmojis)
|
||||
val reporteeName = report.targetAccount.name.unicodeWrap().emojify(report.targetAccount.emojis, binding.notificationTopText, animateEmojis)
|
||||
|
||||
val icon = ContextCompat.getDrawable(itemView.context, R.drawable.ic_flag_24dp)
|
||||
|
||||
binding.notificationTopText.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null)
|
||||
binding.notificationTopText.text = itemView.context.getString(
|
||||
R.string.notification_header_report_format,
|
||||
reporterName,
|
||||
reporteeName
|
||||
)
|
||||
binding.notificationSummary.text = itemView.context.getString(
|
||||
R.string.notification_summary_report_format,
|
||||
getRelativeTimeSpanString(itemView.context, report.createdAt.time, Date().time),
|
||||
report.status_ids?.size ?: 0
|
||||
)
|
||||
binding.notificationTopText.text = itemView.context.getString(R.string.notification_header_report_format, reporterName, reporteeName)
|
||||
binding.notificationSummary.text = itemView.context.getString(R.string.notification_summary_report_format, getRelativeTimeSpanString(itemView.context, report.createdAt.time, System.currentTimeMillis()), report.statusIds?.size ?: 0)
|
||||
binding.notificationCategory.text = getTranslatedCategory(itemView.context, report.category)
|
||||
|
||||
// Fancy avatar inset
|
||||
|
|
@ -110,7 +67,7 @@ class ReportNotificationViewHolder(
|
|||
)
|
||||
}
|
||||
|
||||
private fun setupActionListener(
|
||||
fun setupActionListener(
|
||||
listener: NotificationActionListener,
|
||||
reporteeId: String,
|
||||
reporterId: String,
|
||||
|
|
|
|||
|
|
@ -48,13 +48,16 @@ import com.keylesspalace.tusky.entity.Filter;
|
|||
import com.keylesspalace.tusky.entity.FilterResult;
|
||||
import com.keylesspalace.tusky.entity.HashTag;
|
||||
import com.keylesspalace.tusky.entity.Status;
|
||||
import com.keylesspalace.tusky.entity.Translation;
|
||||
import com.keylesspalace.tusky.interfaces.StatusActionListener;
|
||||
import com.keylesspalace.tusky.util.AbsoluteTimeFormatter;
|
||||
import com.keylesspalace.tusky.util.AttachmentHelper;
|
||||
import com.keylesspalace.tusky.util.CardViewMode;
|
||||
import com.keylesspalace.tusky.util.CompositeWithOpaqueBackground;
|
||||
import com.keylesspalace.tusky.util.CustomEmojiHelper;
|
||||
import com.keylesspalace.tusky.util.ImageLoadingHelper;
|
||||
import com.keylesspalace.tusky.util.LinkHelper;
|
||||
import com.keylesspalace.tusky.util.LocaleUtilsKt;
|
||||
import com.keylesspalace.tusky.util.NumberUtils;
|
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions;
|
||||
import com.keylesspalace.tusky.util.TimestampUtils;
|
||||
|
|
@ -65,10 +68,13 @@ import com.keylesspalace.tusky.viewdata.PollOptionViewData;
|
|||
import com.keylesspalace.tusky.viewdata.PollViewData;
|
||||
import com.keylesspalace.tusky.viewdata.PollViewDataKt;
|
||||
import com.keylesspalace.tusky.viewdata.StatusViewData;
|
||||
import com.keylesspalace.tusky.viewdata.TranslationViewData;
|
||||
|
||||
import java.text.NumberFormat;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import at.connyduck.sparkbutton.SparkButton;
|
||||
import at.connyduck.sparkbutton.helpers.Utils;
|
||||
|
|
@ -114,10 +120,13 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
private final TextView cardDescription;
|
||||
private final TextView cardUrl;
|
||||
private final PollAdapter pollAdapter;
|
||||
protected LinearLayout filteredPlaceholder;
|
||||
protected TextView filteredPlaceholderLabel;
|
||||
protected Button filteredPlaceholderShowButton;
|
||||
protected ConstraintLayout statusContainer;
|
||||
protected final LinearLayout filteredPlaceholder;
|
||||
protected final TextView filteredPlaceholderLabel;
|
||||
protected final Button filteredPlaceholderShowButton;
|
||||
protected final ConstraintLayout statusContainer;
|
||||
private final TextView translationStatusView;
|
||||
private final Button untranslateButton;
|
||||
|
||||
|
||||
private final NumberFormat numberFormat = NumberFormat.getNumberInstance();
|
||||
private final AbsoluteTimeFormatter absoluteTimeFormatter = new AbsoluteTimeFormatter();
|
||||
|
|
@ -128,7 +137,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
|
||||
private final Drawable mediaPreviewUnloaded;
|
||||
|
||||
protected StatusBaseViewHolder(View itemView) {
|
||||
protected StatusBaseViewHolder(@NonNull View itemView) {
|
||||
super(itemView);
|
||||
displayName = itemView.findViewById(R.id.status_display_name);
|
||||
username = itemView.findViewById(R.id.status_username);
|
||||
|
|
@ -149,10 +158,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
sensitiveMediaWarning = itemView.findViewById(R.id.status_sensitive_media_warning);
|
||||
sensitiveMediaShow = itemView.findViewById(R.id.status_sensitive_media_button);
|
||||
mediaLabels = new TextView[]{
|
||||
itemView.findViewById(R.id.status_media_label_0),
|
||||
itemView.findViewById(R.id.status_media_label_1),
|
||||
itemView.findViewById(R.id.status_media_label_2),
|
||||
itemView.findViewById(R.id.status_media_label_3)
|
||||
itemView.findViewById(R.id.status_media_label_0),
|
||||
itemView.findViewById(R.id.status_media_label_1),
|
||||
itemView.findViewById(R.id.status_media_label_2),
|
||||
itemView.findViewById(R.id.status_media_label_3)
|
||||
};
|
||||
mediaDescriptions = new CharSequence[mediaLabels.length];
|
||||
contentWarningDescription = itemView.findViewById(R.id.status_content_warning_description);
|
||||
|
|
@ -180,6 +189,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
pollOptions.setLayoutManager(new LinearLayoutManager(pollOptions.getContext()));
|
||||
((DefaultItemAnimator) pollOptions.getItemAnimator()).setSupportsChangeAnimations(false);
|
||||
|
||||
translationStatusView = itemView.findViewById(R.id.status_translation_status);
|
||||
untranslateButton = itemView.findViewById(R.id.status_button_untranslate);
|
||||
|
||||
this.avatarRadius48dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_48dp);
|
||||
this.avatarRadius36dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_36dp);
|
||||
this.avatarRadius24dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_24dp);
|
||||
|
|
@ -189,14 +201,14 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
TouchDelegateHelper.expandTouchSizeToFillRow((ViewGroup) itemView, CollectionsKt.listOfNotNull(replyButton, reblogButton, favouriteButton, bookmarkButton, moreButton));
|
||||
}
|
||||
|
||||
protected void setDisplayName(String name, List<Emoji> customEmojis, StatusDisplayOptions statusDisplayOptions) {
|
||||
protected void setDisplayName(@NonNull String name, @Nullable List<Emoji> customEmojis, @NonNull StatusDisplayOptions statusDisplayOptions) {
|
||||
CharSequence emojifiedName = CustomEmojiHelper.emojify(
|
||||
name, customEmojis, displayName, statusDisplayOptions.animateEmojis()
|
||||
name, customEmojis, displayName, statusDisplayOptions.animateEmojis()
|
||||
);
|
||||
displayName.setText(emojifiedName);
|
||||
}
|
||||
|
||||
protected void setUsername(String name) {
|
||||
protected void setUsername(@Nullable String name) {
|
||||
Context context = username.getContext();
|
||||
String usernameText = context.getString(R.string.post_username_format, name);
|
||||
username.setText(usernameText);
|
||||
|
|
@ -208,7 +220,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
|
||||
protected void setSpoilerAndContent(@NonNull StatusViewData.Concrete status,
|
||||
@NonNull StatusDisplayOptions statusDisplayOptions,
|
||||
final StatusActionListener listener) {
|
||||
final @NonNull StatusActionListener listener) {
|
||||
|
||||
Status actionable = status.getActionable();
|
||||
String spoilerText = status.getSpoilerText();
|
||||
|
|
@ -219,7 +231,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
|
||||
if (sensitive) {
|
||||
CharSequence emojiSpoiler = CustomEmojiHelper.emojify(
|
||||
spoilerText, emojis, contentWarningDescription, statusDisplayOptions.animateEmojis()
|
||||
spoilerText, emojis, contentWarningDescription, statusDisplayOptions.animateEmojis()
|
||||
);
|
||||
contentWarningDescription.setText(emojiSpoiler);
|
||||
contentWarningDescription.setVisibility(View.VISIBLE);
|
||||
|
|
@ -269,9 +281,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
Status actionable = status.getActionable();
|
||||
Spanned content = status.getContent();
|
||||
List<Status.Mention> mentions = actionable.getMentions();
|
||||
List<HashTag> tags =actionable.getTags();
|
||||
List<HashTag> tags = actionable.getTags();
|
||||
List<Emoji> emojis = actionable.getEmojis();
|
||||
PollViewData poll = PollViewDataKt.toViewData(actionable.getPoll());
|
||||
PollViewData poll = PollViewDataKt.toViewData(status.getPoll());
|
||||
|
||||
if (expanded) {
|
||||
CharSequence emojifiedText = CustomEmojiHelper.emojify(content, emojis, this.content, statusDisplayOptions.animateEmojis());
|
||||
|
|
@ -302,7 +314,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
}
|
||||
|
||||
private void setAvatar(String url,
|
||||
@Nullable String rebloggedUrl,
|
||||
@Nullable String rebloggedUrl,
|
||||
boolean isBot,
|
||||
StatusDisplayOptions statusDisplayOptions) {
|
||||
|
||||
|
|
@ -313,8 +325,8 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
if (statusDisplayOptions.showBotOverlay() && isBot) {
|
||||
avatarInset.setVisibility(View.VISIBLE);
|
||||
Glide.with(avatarInset)
|
||||
.load(R.drawable.bot_badge)
|
||||
.into(avatarInset);
|
||||
.load(R.drawable.bot_badge)
|
||||
.into(avatarInset);
|
||||
} else {
|
||||
avatarInset.setVisibility(View.GONE);
|
||||
}
|
||||
|
|
@ -328,17 +340,21 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
avatarInset.setVisibility(View.VISIBLE);
|
||||
avatarInset.setBackground(null);
|
||||
ImageLoadingHelper.loadAvatar(rebloggedUrl, avatarInset, avatarRadius24dp,
|
||||
statusDisplayOptions.animateAvatars());
|
||||
statusDisplayOptions.animateAvatars(), null);
|
||||
|
||||
avatarRadius = avatarRadius36dp;
|
||||
}
|
||||
|
||||
ImageLoadingHelper.loadAvatar(url, avatar, avatarRadius,
|
||||
statusDisplayOptions.animateAvatars());
|
||||
|
||||
ImageLoadingHelper.loadAvatar(
|
||||
url,
|
||||
avatar,
|
||||
avatarRadius,
|
||||
statusDisplayOptions.animateAvatars(),
|
||||
Collections.singletonList(new CompositeWithOpaqueBackground(MaterialColors.getColor(avatar, android.R.attr.colorBackground)))
|
||||
);
|
||||
}
|
||||
|
||||
protected void setMetaData(StatusViewData.Concrete statusViewData, StatusDisplayOptions statusDisplayOptions, StatusActionListener listener) {
|
||||
protected void setMetaData(@NonNull StatusViewData.Concrete statusViewData, @NonNull StatusDisplayOptions statusDisplayOptions, @NonNull StatusActionListener listener) {
|
||||
|
||||
Status status = statusViewData.getActionable();
|
||||
Date createdAt = status.getCreatedAt();
|
||||
|
|
@ -377,8 +393,8 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
long then = createdAt.getTime();
|
||||
long now = System.currentTimeMillis();
|
||||
return DateUtils.getRelativeTimeSpanString(then, now,
|
||||
DateUtils.SECOND_IN_MILLIS,
|
||||
DateUtils.FORMAT_ABBREV_RELATIVE);
|
||||
DateUtils.SECOND_IN_MILLIS,
|
||||
DateUtils.FORMAT_ABBREV_RELATIVE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -461,9 +477,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
imageView.removeFocalPoint();
|
||||
|
||||
Glide.with(imageView)
|
||||
.load(placeholder)
|
||||
.centerInside()
|
||||
.into(imageView);
|
||||
.load(placeholder)
|
||||
.centerInside()
|
||||
.into(imageView);
|
||||
} else {
|
||||
Focus focus = meta != null ? meta.getFocus() : null;
|
||||
|
||||
|
|
@ -471,29 +487,29 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
imageView.setFocalPoint(focus);
|
||||
|
||||
Glide.with(imageView.getContext())
|
||||
.load(previewUrl)
|
||||
.placeholder(placeholder)
|
||||
.centerInside()
|
||||
.addListener(imageView)
|
||||
.into(imageView);
|
||||
.load(previewUrl)
|
||||
.placeholder(placeholder)
|
||||
.centerInside()
|
||||
.addListener(imageView)
|
||||
.into(imageView);
|
||||
} else {
|
||||
imageView.removeFocalPoint();
|
||||
|
||||
Glide.with(imageView)
|
||||
.load(previewUrl)
|
||||
.placeholder(placeholder)
|
||||
.centerInside()
|
||||
.into(imageView);
|
||||
.load(previewUrl)
|
||||
.placeholder(placeholder)
|
||||
.centerInside()
|
||||
.into(imageView);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected void setMediaPreviews(
|
||||
final List<Attachment> attachments,
|
||||
boolean sensitive,
|
||||
final StatusActionListener listener,
|
||||
boolean showingContent,
|
||||
boolean useBlurhash
|
||||
final @NonNull List<Attachment> attachments,
|
||||
boolean sensitive,
|
||||
final @NonNull StatusActionListener listener,
|
||||
boolean showingContent,
|
||||
boolean useBlurhash
|
||||
) {
|
||||
|
||||
mediaPreview.setVisibility(View.VISIBLE);
|
||||
|
|
@ -512,10 +528,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
}
|
||||
|
||||
loadImage(
|
||||
imageView,
|
||||
showingContent ? previewUrl : null,
|
||||
attachment.getMeta(),
|
||||
useBlurhash ? attachment.getBlurhash() : null
|
||||
imageView,
|
||||
showingContent ? previewUrl : null,
|
||||
attachment.getMeta(),
|
||||
useBlurhash ? attachment.getBlurhash() : null
|
||||
);
|
||||
|
||||
final Attachment.Type type = attachment.getType();
|
||||
|
|
@ -577,13 +593,13 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
private void updateMediaLabel(int index, boolean sensitive, boolean showingContent) {
|
||||
Context context = itemView.getContext();
|
||||
CharSequence label = (sensitive && !showingContent) ?
|
||||
context.getString(R.string.post_sensitive_media_title) :
|
||||
mediaDescriptions[index];
|
||||
context.getString(R.string.post_sensitive_media_title) :
|
||||
mediaDescriptions[index];
|
||||
mediaLabels[index].setText(label);
|
||||
}
|
||||
|
||||
protected void setMediaLabel(List<Attachment> attachments, boolean sensitive,
|
||||
final StatusActionListener listener, boolean showingContent) {
|
||||
protected void setMediaLabel(@NonNull List<Attachment> attachments, boolean sensitive,
|
||||
final @NonNull StatusActionListener listener, boolean showingContent) {
|
||||
Context context = itemView.getContext();
|
||||
for (int i = 0; i < mediaLabels.length; i++) {
|
||||
TextView mediaLabel = mediaLabels[i];
|
||||
|
|
@ -604,7 +620,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
}
|
||||
}
|
||||
|
||||
private void setAttachmentClickListener(View view, StatusActionListener listener,
|
||||
private void setAttachmentClickListener(View view, @NonNull StatusActionListener listener,
|
||||
int index, Attachment attachment, boolean animateTransition) {
|
||||
view.setOnClickListener(v -> {
|
||||
int position = getBindingAdapterPosition();
|
||||
|
|
@ -628,10 +644,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
sensitiveMediaShow.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
protected void setupButtons(final StatusActionListener listener,
|
||||
final String accountId,
|
||||
final String statusContent,
|
||||
StatusDisplayOptions statusDisplayOptions) {
|
||||
protected void setupButtons(final @NonNull StatusActionListener listener,
|
||||
final @NonNull String accountId,
|
||||
final @Nullable String statusContent,
|
||||
@NonNull StatusDisplayOptions statusDisplayOptions) {
|
||||
View.OnClickListener profileButtonClickListener = button -> listener.onViewAccount(accountId);
|
||||
|
||||
avatar.setOnClickListener(profileButtonClickListener);
|
||||
|
|
@ -721,8 +737,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
}
|
||||
popup.setOnMenuItemClickListener(item -> {
|
||||
listener.onReblog(!buttonState, position);
|
||||
if(!buttonState) {
|
||||
if (!buttonState) {
|
||||
reblogButton.playAnimation();
|
||||
reblogButton.setChecked(true);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
|
@ -742,16 +759,17 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
}
|
||||
popup.setOnMenuItemClickListener(item -> {
|
||||
listener.onFavourite(!buttonState, position);
|
||||
if(!buttonState) {
|
||||
if (!buttonState) {
|
||||
favouriteButton.playAnimation();
|
||||
favouriteButton.setChecked(true);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
popup.show();
|
||||
}
|
||||
|
||||
public void setupWithStatus(StatusViewData.Concrete status, final StatusActionListener listener,
|
||||
StatusDisplayOptions statusDisplayOptions) {
|
||||
public void setupWithStatus(@NonNull StatusViewData.Concrete status, final @NonNull StatusActionListener listener,
|
||||
@NonNull StatusDisplayOptions statusDisplayOptions) {
|
||||
this.setupWithStatus(status, listener, statusDisplayOptions, null);
|
||||
}
|
||||
|
||||
|
|
@ -762,16 +780,16 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
if (payloads == null) {
|
||||
Status actionable = status.getActionable();
|
||||
setDisplayName(actionable.getAccount().getName(), actionable.getAccount().getEmojis(), statusDisplayOptions);
|
||||
setUsername(status.getUsername());
|
||||
setUsername(actionable.getAccount().getUsername());
|
||||
setMetaData(status, statusDisplayOptions, listener);
|
||||
setIsReply(actionable.getInReplyToId() != null);
|
||||
setReplyCount(actionable.getRepliesCount(), statusDisplayOptions.showStatsInline());
|
||||
setAvatar(actionable.getAccount().getAvatar(), status.getRebloggedAvatar(),
|
||||
actionable.getAccount().getBot(), statusDisplayOptions);
|
||||
actionable.getAccount().getBot(), statusDisplayOptions);
|
||||
setReblogged(actionable.getReblogged());
|
||||
setFavourited(actionable.getFavourited());
|
||||
setBookmarked(actionable.getBookmarked());
|
||||
List<Attachment> attachments = actionable.getAttachments();
|
||||
List<Attachment> attachments = status.getAttachments();
|
||||
boolean sensitive = actionable.getSensitive();
|
||||
if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) {
|
||||
setMediaPreviews(attachments, sensitive, listener, status.isShowingContent(), statusDisplayOptions.useBlurhash());
|
||||
|
|
@ -793,8 +811,11 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
setupCard(status, status.isExpanded(), statusDisplayOptions.cardViewMode(), statusDisplayOptions, listener);
|
||||
|
||||
setupButtons(listener, actionable.getAccount().getId(), status.getContent().toString(),
|
||||
statusDisplayOptions);
|
||||
setRebloggingEnabled(actionable.rebloggingAllowed(), actionable.getVisibility());
|
||||
statusDisplayOptions);
|
||||
|
||||
setTranslationStatus(status, listener);
|
||||
|
||||
setRebloggingEnabled(actionable.isRebloggingAllowed(), actionable.getVisibility());
|
||||
|
||||
setSpoilerAndContent(status, statusDisplayOptions, listener);
|
||||
|
||||
|
|
@ -819,6 +840,29 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
}
|
||||
}
|
||||
|
||||
private void setTranslationStatus(StatusViewData.Concrete status, StatusActionListener listener) {
|
||||
var translationViewData = status.getTranslation();
|
||||
if (translationViewData != null) {
|
||||
if (translationViewData instanceof TranslationViewData.Loaded) {
|
||||
Translation translation = ((TranslationViewData.Loaded) translationViewData).getData();
|
||||
translationStatusView.setVisibility(View.VISIBLE);
|
||||
var langName = LocaleUtilsKt.localeNameForUntrustedISO639LangCode(translation.getDetectedSourceLanguage());
|
||||
translationStatusView.setText(translationStatusView.getContext().getString(R.string.label_translated, langName, translation.getProvider()));
|
||||
untranslateButton.setVisibility(View.VISIBLE);
|
||||
untranslateButton.setOnClickListener((v) -> listener.onUntranslate(getBindingAdapterPosition()));
|
||||
} else {
|
||||
translationStatusView.setVisibility(View.VISIBLE);
|
||||
translationStatusView.setText(R.string.label_translating);
|
||||
untranslateButton.setVisibility(View.GONE);
|
||||
untranslateButton.setOnClickListener(null);
|
||||
}
|
||||
} else {
|
||||
translationStatusView.setVisibility(View.GONE);
|
||||
untranslateButton.setVisibility(View.GONE);
|
||||
untranslateButton.setOnClickListener(null);
|
||||
}
|
||||
}
|
||||
|
||||
private void setupFilterPlaceholder(StatusViewData.Concrete status, StatusActionListener listener, StatusDisplayOptions displayOptions) {
|
||||
if (status.getFilterAction() != Filter.Action.WARN) {
|
||||
showFilteredPlaceholder(false);
|
||||
|
|
@ -838,12 +882,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
}
|
||||
|
||||
filteredPlaceholderLabel.setText(itemView.getContext().getString(R.string.status_filter_placeholder_label_format, matchedFilter.getTitle()));
|
||||
filteredPlaceholderShowButton.setOnClickListener(view -> {
|
||||
listener.clearWarningAction(getBindingAdapterPosition());
|
||||
});
|
||||
filteredPlaceholderShowButton.setOnClickListener(view -> listener.clearWarningAction(getBindingAdapterPosition()));
|
||||
}
|
||||
|
||||
protected static boolean hasPreviewableAttachment(List<Attachment> attachments) {
|
||||
protected static boolean hasPreviewableAttachment(@NonNull List<Attachment> attachments) {
|
||||
for (Attachment attachment : attachments) {
|
||||
if (attachment.getType() == Attachment.Type.AUDIO || attachment.getType() == Attachment.Type.UNKNOWN) {
|
||||
return false;
|
||||
|
|
@ -858,54 +900,84 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
Status actionable = status.getActionable();
|
||||
|
||||
String description = context.getString(R.string.description_status,
|
||||
actionable.getAccount().getDisplayName(),
|
||||
getContentWarningDescription(context, status),
|
||||
(TextUtils.isEmpty(status.getSpoilerText()) || !actionable.getSensitive() || status.isExpanded() ? status.getContent() : ""),
|
||||
getCreatedAtDescription(actionable.getCreatedAt(), statusDisplayOptions),
|
||||
actionable.getEditedAt() != null ? context.getString(R.string.description_post_edited) : "",
|
||||
getReblogDescription(context, status),
|
||||
status.getUsername(),
|
||||
actionable.getReblogged() ? context.getString(R.string.description_post_reblogged) : "",
|
||||
actionable.getFavourited() ? context.getString(R.string.description_post_favourited) : "",
|
||||
actionable.getBookmarked() ? context.getString(R.string.description_post_bookmarked) : "",
|
||||
getMediaDescription(context, status),
|
||||
getVisibilityDescription(context, actionable.getVisibility()),
|
||||
getFavsText(context, actionable.getFavouritesCount()),
|
||||
getReblogsText(context, actionable.getReblogsCount()),
|
||||
getPollDescription(status, context, statusDisplayOptions)
|
||||
// 1 display_name
|
||||
actionable.getAccount().getDisplayName(),
|
||||
// 2 CW?
|
||||
getContentWarningDescription(context, status),
|
||||
// 3 content?
|
||||
(TextUtils.isEmpty(status.getSpoilerText()) || !actionable.getSensitive() || status.isExpanded() ? status.getContent() : ""),
|
||||
// 4 date
|
||||
getCreatedAtDescription(actionable.getCreatedAt(), statusDisplayOptions),
|
||||
// 5 edited?
|
||||
actionable.getEditedAt() != null ? context.getString(R.string.description_post_edited) : "",
|
||||
// 6 reposted_by?
|
||||
getReblogDescription(context, status),
|
||||
// 7 username
|
||||
actionable.getAccount().getUsername(),
|
||||
// 8 reposted
|
||||
actionable.getReblogged() ? context.getString(R.string.description_post_reblogged) : "",
|
||||
// 9 favorited
|
||||
actionable.getFavourited() ? context.getString(R.string.description_post_favourited) : "",
|
||||
// 10 bookmarked
|
||||
actionable.getBookmarked() ? context.getString(R.string.description_post_bookmarked) : "",
|
||||
// 11 media
|
||||
getMediaDescription(context, status),
|
||||
// 12 visibility
|
||||
getVisibilityDescription(context, actionable.getVisibility()),
|
||||
// 13 fav_number
|
||||
getFavsText(context, actionable.getFavouritesCount()),
|
||||
// 14 reblog_number
|
||||
getReblogsText(context, actionable.getReblogsCount()),
|
||||
// 15 poll?
|
||||
getPollDescription(status, context, statusDisplayOptions),
|
||||
// 16 translated?
|
||||
getTranslatedDescription(context, status.getTranslation())
|
||||
);
|
||||
itemView.setContentDescription(description);
|
||||
}
|
||||
|
||||
private String getTranslatedDescription(Context context, TranslationViewData translationViewData) {
|
||||
if (translationViewData == null) {
|
||||
return "";
|
||||
} else if (translationViewData instanceof TranslationViewData.Loading) {
|
||||
return context.getString(R.string.label_translating);
|
||||
} else {
|
||||
Translation translation = ((TranslationViewData.Loaded) translationViewData).getData();
|
||||
var langName = LocaleUtilsKt.localeNameForUntrustedISO639LangCode(translation.getDetectedSourceLanguage());
|
||||
return context.getString(R.string.label_translated, langName, translation.getProvider());
|
||||
}
|
||||
}
|
||||
|
||||
private static CharSequence getReblogDescription(Context context,
|
||||
@NonNull StatusViewData.Concrete status) {
|
||||
@Nullable
|
||||
Status reblog = status.getRebloggingStatus();
|
||||
if (reblog != null) {
|
||||
return context
|
||||
.getString(R.string.post_boosted_format, reblog.getAccount().getUsername());
|
||||
.getString(R.string.post_boosted_format, reblog.getAccount().getUsername());
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private static CharSequence getMediaDescription(Context context,
|
||||
@NonNull StatusViewData.Concrete status) {
|
||||
if (status.getActionable().getAttachments().isEmpty()) {
|
||||
@NonNull StatusViewData.Concrete viewData) {
|
||||
if (viewData.getAttachments().isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
StringBuilder mediaDescriptions = CollectionsKt.fold(
|
||||
status.getActionable().getAttachments(),
|
||||
new StringBuilder(),
|
||||
(builder, a) -> {
|
||||
if (a.getDescription() == null) {
|
||||
String placeholder =
|
||||
context.getString(R.string.description_post_media_no_description_placeholder);
|
||||
return builder.append(placeholder);
|
||||
} else {
|
||||
builder.append("; ");
|
||||
return builder.append(a.getDescription());
|
||||
}
|
||||
});
|
||||
viewData.getAttachments(),
|
||||
new StringBuilder(),
|
||||
(builder, a) -> {
|
||||
if (a.getDescription() == null) {
|
||||
String placeholder =
|
||||
context.getString(R.string.description_post_media_no_description_placeholder);
|
||||
return builder.append(placeholder);
|
||||
} else {
|
||||
builder.append("; ");
|
||||
return builder.append(a.getDescription());
|
||||
}
|
||||
});
|
||||
return context.getString(R.string.description_post_media, mediaDescriptions);
|
||||
}
|
||||
|
||||
|
|
@ -918,7 +990,8 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
}
|
||||
}
|
||||
|
||||
protected static CharSequence getVisibilityDescription(Context context, Status.Visibility visibility) {
|
||||
@NonNull
|
||||
protected static CharSequence getVisibilityDescription(@NonNull Context context, @Nullable Status.Visibility visibility) {
|
||||
|
||||
if (visibility == null) {
|
||||
return "";
|
||||
|
|
@ -947,7 +1020,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
private CharSequence getPollDescription(@NonNull StatusViewData.Concrete status,
|
||||
Context context,
|
||||
StatusDisplayOptions statusDisplayOptions) {
|
||||
PollViewData poll = PollViewDataKt.toViewData(status.getActionable().getPoll());
|
||||
PollViewData poll = PollViewDataKt.toViewData(status.getPoll());
|
||||
if (poll == null) {
|
||||
return "";
|
||||
} else {
|
||||
|
|
@ -962,27 +1035,21 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
}
|
||||
}
|
||||
args[4] = getPollInfoText(System.currentTimeMillis(), poll, statusDisplayOptions,
|
||||
context);
|
||||
context);
|
||||
return context.getString(R.string.description_poll, args);
|
||||
}
|
||||
}
|
||||
|
||||
protected CharSequence getFavsText(Context context, int count) {
|
||||
if (count > 0) {
|
||||
String countString = numberFormat.format(count);
|
||||
return HtmlCompat.fromHtml(context.getResources().getQuantityString(R.plurals.favs, count, countString), HtmlCompat.FROM_HTML_MODE_LEGACY);
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
@NonNull
|
||||
protected CharSequence getFavsText(@NonNull Context context, int count) {
|
||||
String countString = numberFormat.format(count);
|
||||
return HtmlCompat.fromHtml(context.getResources().getQuantityString(R.plurals.favs, count, countString), HtmlCompat.FROM_HTML_MODE_LEGACY);
|
||||
}
|
||||
|
||||
protected CharSequence getReblogsText(Context context, int count) {
|
||||
if (count > 0) {
|
||||
String countString = numberFormat.format(count);
|
||||
return HtmlCompat.fromHtml(context.getResources().getQuantityString(R.plurals.reblogs, count, countString), HtmlCompat.FROM_HTML_MODE_LEGACY);
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
@NonNull
|
||||
protected CharSequence getReblogsText(@NonNull Context context, int count) {
|
||||
String countString = numberFormat.format(count);
|
||||
return HtmlCompat.fromHtml(context.getResources().getQuantityString(R.plurals.reblogs, count, countString), HtmlCompat.FROM_HTML_MODE_LEGACY);
|
||||
}
|
||||
|
||||
private void setupPoll(PollViewData poll, List<Emoji> emojis,
|
||||
|
|
@ -1005,26 +1072,26 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
}
|
||||
};
|
||||
pollAdapter.setup(
|
||||
poll.getOptions(),
|
||||
poll.getVotesCount(),
|
||||
poll.getVotersCount(),
|
||||
emojis,
|
||||
PollAdapter.RESULT,
|
||||
viewThreadListener,
|
||||
statusDisplayOptions.animateEmojis()
|
||||
poll.getOptions(),
|
||||
poll.getVotesCount(),
|
||||
poll.getVotersCount(),
|
||||
emojis,
|
||||
PollAdapter.RESULT,
|
||||
viewThreadListener,
|
||||
statusDisplayOptions.animateEmojis()
|
||||
);
|
||||
|
||||
pollButton.setVisibility(View.GONE);
|
||||
} else {
|
||||
// voting possible
|
||||
pollAdapter.setup(
|
||||
poll.getOptions(),
|
||||
poll.getVotesCount(),
|
||||
poll.getVotersCount(),
|
||||
emojis,
|
||||
poll.getMultiple() ? PollAdapter.MULTIPLE : PollAdapter.SINGLE,
|
||||
null,
|
||||
statusDisplayOptions.animateEmojis()
|
||||
poll.getOptions(),
|
||||
poll.getVotesCount(),
|
||||
poll.getVotersCount(),
|
||||
emojis,
|
||||
poll.getMultiple() ? PollAdapter.MULTIPLE : PollAdapter.SINGLE,
|
||||
null,
|
||||
statusDisplayOptions.animateEmojis()
|
||||
);
|
||||
|
||||
pollButton.setVisibility(View.VISIBLE);
|
||||
|
|
@ -1077,11 +1144,11 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
}
|
||||
|
||||
protected void setupCard(
|
||||
final StatusViewData.Concrete status,
|
||||
boolean expanded,
|
||||
final CardViewMode cardViewMode,
|
||||
final StatusDisplayOptions statusDisplayOptions,
|
||||
final StatusActionListener listener
|
||||
final @NonNull StatusViewData.Concrete status,
|
||||
boolean expanded,
|
||||
final @NonNull CardViewMode cardViewMode,
|
||||
final @NonNull StatusDisplayOptions statusDisplayOptions,
|
||||
final @NonNull StatusActionListener listener
|
||||
) {
|
||||
if (cardView == null) {
|
||||
return;
|
||||
|
|
@ -1095,7 +1162,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
actionable.getPoll() == null &&
|
||||
card != null &&
|
||||
!TextUtils.isEmpty(card.getUrl()) &&
|
||||
(!actionable.getSensitive() || expanded) &&
|
||||
(TextUtils.isEmpty(actionable.getSpoilerText()) || expanded) &&
|
||||
(!status.isCollapsible() || !status.isCollapsed())) {
|
||||
|
||||
cardView.setVisibility(View.VISIBLE);
|
||||
|
|
@ -1119,14 +1186,14 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
if (statusDisplayOptions.mediaPreviewEnabled() && !actionable.getSensitive() && !TextUtils.isEmpty(card.getImage())) {
|
||||
|
||||
int radius = cardImage.getContext().getResources()
|
||||
.getDimensionPixelSize(R.dimen.card_radius);
|
||||
.getDimensionPixelSize(R.dimen.card_radius);
|
||||
ShapeAppearanceModel.Builder cardImageShape = ShapeAppearanceModel.builder();
|
||||
|
||||
if (card.getWidth() > card.getHeight()) {
|
||||
cardView.setOrientation(LinearLayout.VERTICAL);
|
||||
|
||||
cardImage.getLayoutParams().height = cardImage.getContext().getResources()
|
||||
.getDimensionPixelSize(R.dimen.card_image_vertical_height);
|
||||
.getDimensionPixelSize(R.dimen.card_image_vertical_height);
|
||||
cardImage.getLayoutParams().width = ViewGroup.LayoutParams.MATCH_PARENT;
|
||||
cardInfo.getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT;
|
||||
cardInfo.getLayoutParams().width = ViewGroup.LayoutParams.WRAP_CONTENT;
|
||||
|
|
@ -1136,7 +1203,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
cardView.setOrientation(LinearLayout.HORIZONTAL);
|
||||
cardImage.getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT;
|
||||
cardImage.getLayoutParams().width = cardImage.getContext().getResources()
|
||||
.getDimensionPixelSize(R.dimen.card_image_horizontal_width);
|
||||
.getDimensionPixelSize(R.dimen.card_image_horizontal_width);
|
||||
cardInfo.getLayoutParams().height = ViewGroup.LayoutParams.WRAP_CONTENT;
|
||||
cardInfo.getLayoutParams().width = ViewGroup.LayoutParams.MATCH_PARENT;
|
||||
cardImageShape.setTopLeftCorner(CornerFamily.ROUNDED, radius);
|
||||
|
|
@ -1148,40 +1215,40 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
cardImage.setScaleType(ImageView.ScaleType.CENTER_CROP);
|
||||
|
||||
RequestBuilder<Drawable> builder = Glide.with(cardImage.getContext())
|
||||
.load(card.getImage())
|
||||
.dontTransform();
|
||||
.load(card.getImage())
|
||||
.dontTransform();
|
||||
if (statusDisplayOptions.useBlurhash() && !TextUtils.isEmpty(card.getBlurhash())) {
|
||||
builder = builder.placeholder(decodeBlurHash(card.getBlurhash()));
|
||||
}
|
||||
builder.into(cardImage);
|
||||
} else if (statusDisplayOptions.useBlurhash() && !TextUtils.isEmpty(card.getBlurhash())) {
|
||||
int radius = cardImage.getContext().getResources()
|
||||
.getDimensionPixelSize(R.dimen.card_radius);
|
||||
.getDimensionPixelSize(R.dimen.card_radius);
|
||||
|
||||
cardView.setOrientation(LinearLayout.HORIZONTAL);
|
||||
cardImage.getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT;
|
||||
cardImage.getLayoutParams().width = cardImage.getContext().getResources()
|
||||
.getDimensionPixelSize(R.dimen.card_image_horizontal_width);
|
||||
.getDimensionPixelSize(R.dimen.card_image_horizontal_width);
|
||||
cardInfo.getLayoutParams().height = ViewGroup.LayoutParams.WRAP_CONTENT;
|
||||
cardInfo.getLayoutParams().width = ViewGroup.LayoutParams.MATCH_PARENT;
|
||||
|
||||
ShapeAppearanceModel cardImageShape = ShapeAppearanceModel.builder()
|
||||
.setTopLeftCorner(CornerFamily.ROUNDED, radius)
|
||||
.setBottomLeftCorner(CornerFamily.ROUNDED, radius)
|
||||
.build();
|
||||
.setTopLeftCorner(CornerFamily.ROUNDED, radius)
|
||||
.setBottomLeftCorner(CornerFamily.ROUNDED, radius)
|
||||
.build();
|
||||
cardImage.setShapeAppearanceModel(cardImageShape);
|
||||
|
||||
cardImage.setScaleType(ImageView.ScaleType.CENTER_CROP);
|
||||
|
||||
Glide.with(cardImage.getContext())
|
||||
.load(decodeBlurHash(card.getBlurhash()))
|
||||
.dontTransform()
|
||||
.into(cardImage);
|
||||
.load(decodeBlurHash(card.getBlurhash()))
|
||||
.dontTransform()
|
||||
.into(cardImage);
|
||||
} else {
|
||||
cardView.setOrientation(LinearLayout.HORIZONTAL);
|
||||
cardImage.getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT;
|
||||
cardImage.getLayoutParams().width = cardImage.getContext().getResources()
|
||||
.getDimensionPixelSize(R.dimen.card_image_horizontal_width);
|
||||
.getDimensionPixelSize(R.dimen.card_image_horizontal_width);
|
||||
cardInfo.getLayoutParams().height = ViewGroup.LayoutParams.WRAP_CONTENT;
|
||||
cardInfo.getLayoutParams().width = ViewGroup.LayoutParams.MATCH_PARENT;
|
||||
|
||||
|
|
@ -1190,8 +1257,8 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
cardImage.setScaleType(ImageView.ScaleType.CENTER);
|
||||
|
||||
Glide.with(cardImage.getContext())
|
||||
.load(R.drawable.card_image_placeholder)
|
||||
.into(cardImage);
|
||||
.load(R.drawable.card_image_placeholder)
|
||||
.into(cardImage);
|
||||
}
|
||||
|
||||
View.OnClickListener visitLink = v -> listener.onViewUrl(card.getUrl());
|
||||
|
|
@ -1199,8 +1266,8 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
cardView.setOnClickListener(visitLink);
|
||||
// View embedded photos in our image viewer instead of opening the browser
|
||||
cardImage.setOnClickListener(card.getType().equals(Card.TYPE_PHOTO) && !TextUtils.isEmpty(card.getEmbedUrl()) ?
|
||||
v -> cardView.getContext().startActivity(ViewMediaActivity.newSingleImageIntent(cardView.getContext(), card.getEmbedUrl())) :
|
||||
visitLink);
|
||||
v -> cardView.getContext().startActivity(ViewMediaActivity.newSingleImageIntent(cardView.getContext(), card.getEmbedUrl())) :
|
||||
visitLink);
|
||||
|
||||
cardView.setClipToOutline(true);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -9,11 +9,13 @@ import android.text.method.LinkMovementMethod;
|
|||
import android.text.style.DynamicDrawableSpan;
|
||||
import android.text.style.ImageSpan;
|
||||
import android.view.View;
|
||||
import android.widget.Button;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.content.res.AppCompatResources;
|
||||
import androidx.appcompat.widget.ViewUtils;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.keylesspalace.tusky.R;
|
||||
|
|
@ -23,6 +25,7 @@ import com.keylesspalace.tusky.util.CardViewMode;
|
|||
import com.keylesspalace.tusky.util.LinkHelper;
|
||||
import com.keylesspalace.tusky.util.NoUnderlineURLSpan;
|
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions;
|
||||
import com.keylesspalace.tusky.util.ViewExtensionsKt;
|
||||
import com.keylesspalace.tusky.viewdata.StatusViewData;
|
||||
|
||||
import java.text.DateFormat;
|
||||
|
|
@ -35,7 +38,7 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder {
|
|||
|
||||
private static final DateFormat dateFormat = DateFormat.getDateTimeInstance(DateFormat.DEFAULT, DateFormat.SHORT);
|
||||
|
||||
public StatusDetailedViewHolder(View view) {
|
||||
public StatusDetailedViewHolder(@NonNull View view) {
|
||||
super(view);
|
||||
reblogs = view.findViewById(R.id.status_reblogs);
|
||||
favourites = view.findViewById(R.id.status_favourites);
|
||||
|
|
@ -43,7 +46,7 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder {
|
|||
}
|
||||
|
||||
@Override
|
||||
protected void setMetaData(StatusViewData.Concrete statusViewData, StatusDisplayOptions statusDisplayOptions, StatusActionListener listener) {
|
||||
protected void setMetaData(@NonNull StatusViewData.Concrete statusViewData, @NonNull StatusDisplayOptions statusDisplayOptions, @NonNull StatusActionListener listener) {
|
||||
|
||||
Status status = statusViewData.getActionable();
|
||||
|
||||
|
|
@ -57,8 +60,8 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder {
|
|||
|
||||
if (visibilityIcon != null) {
|
||||
ImageSpan visibilityIconSpan = new ImageSpan(
|
||||
visibilityIcon,
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ? DynamicDrawableSpan.ALIGN_CENTER : DynamicDrawableSpan.ALIGN_BASELINE
|
||||
visibilityIcon,
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ? DynamicDrawableSpan.ALIGN_CENTER : DynamicDrawableSpan.ALIGN_BASELINE
|
||||
);
|
||||
sb.setSpan(visibilityIconSpan, 0, visibilityString.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
}
|
||||
|
|
@ -67,7 +70,6 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder {
|
|||
|
||||
Date createdAt = status.getCreatedAt();
|
||||
if (createdAt != null) {
|
||||
|
||||
sb.append(" ");
|
||||
sb.append(dateFormat.format(createdAt));
|
||||
}
|
||||
|
|
@ -95,10 +97,16 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder {
|
|||
}
|
||||
}
|
||||
|
||||
String language = status.getLanguage();
|
||||
|
||||
if (language != null) {
|
||||
sb.append(metadataJoiner);
|
||||
sb.append(language.toUpperCase());
|
||||
}
|
||||
|
||||
Status.Application app = status.getApplication();
|
||||
|
||||
if (app != null) {
|
||||
|
||||
sb.append(metadataJoiner);
|
||||
|
||||
if (app.getWebsite() != null) {
|
||||
|
|
@ -114,25 +122,8 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder {
|
|||
}
|
||||
|
||||
private void setReblogAndFavCount(int reblogCount, int favCount, StatusActionListener listener) {
|
||||
|
||||
if (reblogCount > 0) {
|
||||
reblogs.setText(getReblogsText(reblogs.getContext(), reblogCount));
|
||||
reblogs.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
reblogs.setVisibility(View.GONE);
|
||||
}
|
||||
if (favCount > 0) {
|
||||
favourites.setText(getFavsText(favourites.getContext(), favCount));
|
||||
favourites.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
favourites.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
if (reblogs.getVisibility() == View.GONE && favourites.getVisibility() == View.GONE) {
|
||||
infoDivider.setVisibility(View.GONE);
|
||||
} else {
|
||||
infoDivider.setVisibility(View.VISIBLE);
|
||||
}
|
||||
reblogs.setText(getReblogsText(reblogs.getContext(), reblogCount));
|
||||
favourites.setText(getFavsText(favourites.getContext(), favCount));
|
||||
|
||||
reblogs.setOnClickListener(v -> {
|
||||
int position = getBindingAdapterPosition();
|
||||
|
|
@ -155,8 +146,8 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder {
|
|||
@Nullable Object payloads) {
|
||||
// We never collapse statuses in the detail view
|
||||
StatusViewData.Concrete uncollapsedStatus = (status.isCollapsible() && status.isCollapsed()) ?
|
||||
status.copyWithCollapsed(false) :
|
||||
status;
|
||||
status.copyWithCollapsed(false) :
|
||||
status;
|
||||
|
||||
super.setupWithStatus(uncollapsedStatus, listener, statusDisplayOptions, payloads);
|
||||
setupCard(uncollapsedStatus, status.isExpanded(), CardViewMode.FULL_WIDTH, statusDisplayOptions, listener); // Always show card for detailed status
|
||||
|
|
@ -165,7 +156,7 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder {
|
|||
|
||||
if (!statusDisplayOptions.hideStats()) {
|
||||
setReblogAndFavCount(actionable.getReblogsCount(),
|
||||
actionable.getFavouritesCount(), listener);
|
||||
actionable.getFavouritesCount(), listener);
|
||||
} else {
|
||||
hideQuantitativeStats();
|
||||
}
|
||||
|
|
@ -197,7 +188,7 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder {
|
|||
}
|
||||
|
||||
final Drawable visibilityDrawable = AppCompatResources.getDrawable(
|
||||
this.metaInfo.getContext(), visibilityIcon
|
||||
this.metaInfo.getContext(), visibilityIcon
|
||||
);
|
||||
if (visibilityDrawable == null) {
|
||||
return null;
|
||||
|
|
@ -205,10 +196,10 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder {
|
|||
|
||||
final int size = (int) this.metaInfo.getTextSize();
|
||||
visibilityDrawable.setBounds(
|
||||
0,
|
||||
0,
|
||||
size,
|
||||
size
|
||||
0,
|
||||
0,
|
||||
size,
|
||||
size
|
||||
);
|
||||
visibilityDrawable.setTint(this.metaInfo.getCurrentTextColor());
|
||||
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ public class StatusViewHolder extends StatusBaseViewHolder {
|
|||
private final TextView favouritedCountLabel;
|
||||
private final TextView reblogsCountLabel;
|
||||
|
||||
public StatusViewHolder(View itemView) {
|
||||
public StatusViewHolder(@NonNull View itemView) {
|
||||
super(itemView);
|
||||
statusInfo = itemView.findViewById(R.id.status_info);
|
||||
contentCollapseButton = itemView.findViewById(R.id.button_toggle_content);
|
||||
|
|
|
|||
|
|
@ -56,7 +56,11 @@ class TabAdapter(
|
|||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ViewBinding> {
|
||||
val binding = if (small) {
|
||||
ItemTabPreferenceSmallBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
ItemTabPreferenceSmallBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
} else {
|
||||
ItemTabPreferenceBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,18 @@
|
|||
package com.keylesspalace.tusky.appstore
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.db.AppDatabase
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
class CacheUpdater @Inject constructor(
|
||||
eventHub: EventHub,
|
||||
accountManager: AccountManager,
|
||||
appDatabase: AppDatabase,
|
||||
gson: Gson
|
||||
appDatabase: AppDatabase
|
||||
) {
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
|
@ -26,22 +24,20 @@ class CacheUpdater @Inject constructor(
|
|||
eventHub.events.collect { event ->
|
||||
val accountId = accountManager.activeAccount?.id ?: return@collect
|
||||
when (event) {
|
||||
is FavoriteEvent ->
|
||||
timelineDao.setFavourited(accountId, event.statusId, event.favourite)
|
||||
is ReblogEvent ->
|
||||
timelineDao.setReblogged(accountId, event.statusId, event.reblog)
|
||||
is BookmarkEvent ->
|
||||
timelineDao.setBookmarked(accountId, event.statusId, event.bookmark)
|
||||
is StatusChangedEvent -> {
|
||||
val status = event.status
|
||||
timelineDao.update(
|
||||
accountId = accountId,
|
||||
status = status
|
||||
)
|
||||
}
|
||||
is UnfollowEvent ->
|
||||
timelineDao.removeAllByUser(accountId, event.accountId)
|
||||
is StatusDeletedEvent ->
|
||||
timelineDao.delete(accountId, event.statusId)
|
||||
is PollVoteEvent -> {
|
||||
val pollString = gson.toJson(event.poll)
|
||||
timelineDao.setVoted(accountId, event.statusId, pollString)
|
||||
timelineDao.setVoted(accountId, event.statusId, event.poll)
|
||||
}
|
||||
is PinEvent ->
|
||||
timelineDao.setPinned(accountId, event.statusId, event.pinned)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,24 +2,29 @@ package com.keylesspalace.tusky.appstore
|
|||
|
||||
import com.keylesspalace.tusky.TabData
|
||||
import com.keylesspalace.tusky.entity.Account
|
||||
import com.keylesspalace.tusky.entity.Notification
|
||||
import com.keylesspalace.tusky.entity.Poll
|
||||
import com.keylesspalace.tusky.entity.ScheduledStatus
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
|
||||
data class FavoriteEvent(val statusId: String, val favourite: Boolean) : Event
|
||||
data class ReblogEvent(val statusId: String, val reblog: Boolean) : Event
|
||||
data class BookmarkEvent(val statusId: String, val bookmark: Boolean) : Event
|
||||
data class StatusChangedEvent(val status: Status) : Event
|
||||
data class MuteConversationEvent(val statusId: String, val mute: Boolean) : Event
|
||||
data class UnfollowEvent(val accountId: String) : Event
|
||||
data class BlockEvent(val accountId: String) : Event
|
||||
data class MuteEvent(val accountId: String) : Event
|
||||
data class StatusDeletedEvent(val statusId: String) : Event
|
||||
data class StatusComposedEvent(val status: Status) : Event
|
||||
data class StatusScheduledEvent(val status: Status) : Event
|
||||
data class StatusEditedEvent(val originalId: String, val status: Status) : Event
|
||||
data class StatusScheduledEvent(val scheduledStatus: ScheduledStatus) : Event
|
||||
data class ProfileEditedEvent(val newProfileData: Account) : Event
|
||||
data class PreferenceChangedEvent(val preferenceKey: String) : Event
|
||||
data class MainTabsChangedEvent(val newTabs: List<TabData>) : Event
|
||||
data class PollVoteEvent(val statusId: String, val poll: Poll) : Event
|
||||
data class DomainMuteEvent(val instance: String) : Event
|
||||
data class AnnouncementReadEvent(val announcementId: String) : Event
|
||||
data class PinEvent(val statusId: String, val pinned: Boolean) : Event
|
||||
data class FilterUpdatedEvent(val filterContext: List<String>) : Event
|
||||
data class NewNotificationsEvent(
|
||||
val accountId: String,
|
||||
val notifications: List<Notification>
|
||||
) : Event
|
||||
data class ConversationsLoadingEvent(val accountId: String) : Event
|
||||
data class NotificationsLoadingEvent(val accountId: String) : Event
|
||||
|
|
|
|||
|
|
@ -1,19 +1,33 @@
|
|||
package com.keylesspalace.tusky.appstore
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import java.util.function.Consumer
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
interface Event
|
||||
|
||||
@Singleton
|
||||
class EventHub @Inject constructor() {
|
||||
|
||||
private val sharedEventFlow: MutableSharedFlow<Event> = MutableSharedFlow()
|
||||
val events: Flow<Event> = sharedEventFlow
|
||||
private val _events = MutableSharedFlow<Event>()
|
||||
val events: SharedFlow<Event> = _events.asSharedFlow()
|
||||
|
||||
suspend fun dispatch(event: Event) {
|
||||
sharedEventFlow.emit(event)
|
||||
_events.emit(event)
|
||||
}
|
||||
|
||||
// TODO remove as soon as NotificationsFragment is Kotlin
|
||||
fun subscribe(lifecycleOwner: LifecycleOwner, consumer: Consumer<Event>) {
|
||||
lifecycleOwner.lifecycleScope.launch {
|
||||
events.collect { event ->
|
||||
consumer.accept(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,9 +22,11 @@ import android.content.Context
|
|||
import android.content.Intent
|
||||
import android.content.res.ColorStateList
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.LayerDrawable
|
||||
import android.graphics.Typeface
|
||||
import android.os.Bundle
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.text.TextWatcher
|
||||
import android.text.style.StyleSpan
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
|
|
@ -32,10 +34,12 @@ import android.view.View
|
|||
import android.view.ViewGroup
|
||||
import androidx.activity.viewModels
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.Px
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.core.app.ActivityOptionsCompat
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import androidx.core.view.MenuProvider
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
|
|
@ -43,11 +47,13 @@ import androidx.core.view.WindowInsetsCompat
|
|||
import androidx.core.view.WindowInsetsCompat.Type.systemBars
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.core.widget.doAfterTextChanged
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.viewpager2.widget.MarginPageTransformer
|
||||
import com.bumptech.glide.Glide
|
||||
import com.google.android.material.appbar.AppBarLayout
|
||||
import com.google.android.material.chip.Chip
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
import com.google.android.material.shape.MaterialShapeDrawable
|
||||
|
|
@ -60,7 +66,7 @@ import com.keylesspalace.tusky.EditProfileActivity
|
|||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.StatusListActivity
|
||||
import com.keylesspalace.tusky.ViewMediaActivity
|
||||
import com.keylesspalace.tusky.components.account.list.ListsForAccountFragment
|
||||
import com.keylesspalace.tusky.components.account.list.ListSelectionFragment
|
||||
import com.keylesspalace.tusky.components.accountlist.AccountListActivity
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity
|
||||
import com.keylesspalace.tusky.components.report.ReportActivity
|
||||
|
|
@ -86,6 +92,7 @@ import com.keylesspalace.tusky.util.parseAsMastodonHtml
|
|||
import com.keylesspalace.tusky.util.reduceSwipeSensitivity
|
||||
import com.keylesspalace.tusky.util.setClickableText
|
||||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
|
||||
import com.keylesspalace.tusky.util.unsafeLazy
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
|
|
@ -102,6 +109,7 @@ import java.text.SimpleDateFormat
|
|||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.abs
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider, HasAndroidInjector, LinkListener {
|
||||
|
||||
|
|
@ -173,9 +181,9 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
|
|||
viewModel.setAccountInfo(intent.getStringExtra(KEY_ACCOUNT_ID)!!)
|
||||
|
||||
val sharedPrefs = PreferenceManager.getDefaultSharedPreferences(this)
|
||||
animateAvatar = sharedPrefs.getBoolean("animateGifAvatars", false)
|
||||
animateAvatar = sharedPrefs.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false)
|
||||
animateEmojis = sharedPrefs.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
|
||||
hideFab = sharedPrefs.getBoolean("fabHide", false)
|
||||
hideFab = sharedPrefs.getBoolean(PrefKeys.FAB_HIDE, false)
|
||||
|
||||
handleWindowInsets()
|
||||
setupToolbar()
|
||||
|
|
@ -261,9 +269,18 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
|
|||
binding.accountFragmentViewPager.adapter = adapter
|
||||
binding.accountFragmentViewPager.offscreenPageLimit = 2
|
||||
|
||||
val pageTitles = arrayOf(getString(R.string.title_posts), getString(R.string.title_posts_with_replies), getString(R.string.title_posts_pinned), getString(R.string.title_media))
|
||||
val pageTitles =
|
||||
arrayOf(
|
||||
getString(R.string.title_posts),
|
||||
getString(R.string.title_posts_with_replies),
|
||||
getString(R.string.title_posts_pinned),
|
||||
getString(R.string.title_media)
|
||||
)
|
||||
|
||||
TabLayoutMediator(binding.accountTabLayout, binding.accountFragmentViewPager) { tab, position ->
|
||||
TabLayoutMediator(
|
||||
binding.accountTabLayout,
|
||||
binding.accountFragmentViewPager
|
||||
) { tab, position ->
|
||||
tab.text = pageTitles[position]
|
||||
}.attach()
|
||||
|
||||
|
|
@ -295,7 +312,15 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
|
|||
val right = insets.getInsets(systemBars()).right
|
||||
val bottom = insets.getInsets(systemBars()).bottom
|
||||
val left = insets.getInsets(systemBars()).left
|
||||
binding.accountCoordinatorLayout.updatePadding(right = right, bottom = bottom, left = left)
|
||||
binding.accountCoordinatorLayout.updatePadding(
|
||||
right = right,
|
||||
bottom = bottom,
|
||||
left = left
|
||||
)
|
||||
binding.swipeToRefreshLayout.setProgressViewEndTarget(
|
||||
false,
|
||||
top + resources.getDimensionPixelSize(R.dimen.account_swiperefresh_distance)
|
||||
)
|
||||
|
||||
WindowInsetsCompat.CONSUMED
|
||||
}
|
||||
|
|
@ -312,30 +337,24 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
|
|||
|
||||
val appBarElevation = resources.getDimension(R.dimen.actionbar_elevation)
|
||||
|
||||
val toolbarBackground = MaterialShapeDrawable.createWithElevationOverlay(this, appBarElevation)
|
||||
val toolbarBackground = MaterialShapeDrawable.createWithElevationOverlay(
|
||||
this,
|
||||
appBarElevation
|
||||
)
|
||||
toolbarBackground.fillColor = ColorStateList.valueOf(Color.TRANSPARENT)
|
||||
binding.accountToolbar.background = toolbarBackground
|
||||
|
||||
// Provide a non-transparent background to the navigation and overflow icons to ensure
|
||||
// they remain visible over whatever the profile background image might be.
|
||||
val backgroundCircle = AppCompatResources.getDrawable(this, R.drawable.background_circle)!!
|
||||
backgroundCircle.alpha = 210 // Any lower than this and the backgrounds interfere
|
||||
binding.accountToolbar.navigationIcon = LayerDrawable(
|
||||
arrayOf(
|
||||
backgroundCircle,
|
||||
binding.accountToolbar.navigationIcon
|
||||
)
|
||||
)
|
||||
binding.accountToolbar.overflowIcon = LayerDrawable(
|
||||
arrayOf(
|
||||
backgroundCircle,
|
||||
binding.accountToolbar.overflowIcon
|
||||
)
|
||||
binding.accountToolbar.setNavigationIcon(R.drawable.ic_arrow_back_with_background)
|
||||
binding.accountToolbar.setOverflowIcon(
|
||||
AppCompatResources.getDrawable(this, R.drawable.ic_more_with_background)
|
||||
)
|
||||
|
||||
binding.accountHeaderInfoContainer.background = MaterialShapeDrawable.createWithElevationOverlay(this, appBarElevation)
|
||||
|
||||
val avatarBackground = MaterialShapeDrawable.createWithElevationOverlay(this, appBarElevation).apply {
|
||||
val avatarBackground = MaterialShapeDrawable.createWithElevationOverlay(
|
||||
this,
|
||||
appBarElevation
|
||||
).apply {
|
||||
fillColor = ColorStateList.valueOf(toolbarColor)
|
||||
elevation = appBarElevation
|
||||
shapeAppearanceModel = ShapeAppearanceModel.builder()
|
||||
|
|
@ -375,11 +394,17 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
|
|||
|
||||
binding.accountAvatarImageView.visible(scaledAvatarSize > 0)
|
||||
|
||||
val transparencyPercent = (abs(verticalOffset) / titleVisibleHeight.toFloat()).coerceAtMost(1f)
|
||||
val transparencyPercent = (abs(verticalOffset) / titleVisibleHeight.toFloat()).coerceAtMost(
|
||||
1f
|
||||
)
|
||||
|
||||
window.statusBarColor = argbEvaluator.evaluate(transparencyPercent, statusBarColorTransparent, statusBarColorOpaque) as Int
|
||||
|
||||
val evaluatedToolbarColor = argbEvaluator.evaluate(transparencyPercent, Color.TRANSPARENT, toolbarColor) as Int
|
||||
val evaluatedToolbarColor = argbEvaluator.evaluate(
|
||||
transparencyPercent,
|
||||
Color.TRANSPARENT,
|
||||
toolbarColor
|
||||
) as Int
|
||||
|
||||
toolbarBackground.fillColor = ColorStateList.valueOf(evaluatedToolbarColor)
|
||||
|
||||
|
|
@ -397,31 +422,46 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
|
|||
* Subscribe to data loaded at the view model
|
||||
*/
|
||||
private fun subscribeObservables() {
|
||||
viewModel.accountData.observe(this) {
|
||||
when (it) {
|
||||
is Success -> onAccountChanged(it.data)
|
||||
is Error -> {
|
||||
Snackbar.make(binding.accountCoordinatorLayout, R.string.error_generic, Snackbar.LENGTH_LONG)
|
||||
lifecycleScope.launch {
|
||||
viewModel.accountData.collect {
|
||||
if (it == null) return@collect
|
||||
when (it) {
|
||||
is Success -> onAccountChanged(it.data)
|
||||
is Error -> {
|
||||
Snackbar.make(
|
||||
binding.accountCoordinatorLayout,
|
||||
R.string.error_generic,
|
||||
Snackbar.LENGTH_LONG
|
||||
)
|
||||
.setAction(R.string.action_retry) { viewModel.refresh() }
|
||||
.show()
|
||||
}
|
||||
is Loading -> { }
|
||||
}
|
||||
}
|
||||
}
|
||||
lifecycleScope.launch {
|
||||
viewModel.relationshipData.collect {
|
||||
val relation = it?.data
|
||||
if (relation != null) {
|
||||
onRelationshipChanged(relation)
|
||||
}
|
||||
|
||||
if (it is Error) {
|
||||
Snackbar.make(
|
||||
binding.accountCoordinatorLayout,
|
||||
R.string.error_generic,
|
||||
Snackbar.LENGTH_LONG
|
||||
)
|
||||
.setAction(R.string.action_retry) { viewModel.refresh() }
|
||||
.show()
|
||||
}
|
||||
is Loading -> { }
|
||||
}
|
||||
}
|
||||
viewModel.relationshipData.observe(this) {
|
||||
val relation = it?.data
|
||||
if (relation != null) {
|
||||
onRelationshipChanged(relation)
|
||||
lifecycleScope.launch {
|
||||
viewModel.noteSaved.collect {
|
||||
binding.saveNoteInfo.visible(it, View.INVISIBLE)
|
||||
}
|
||||
|
||||
if (it is Error) {
|
||||
Snackbar.make(binding.accountCoordinatorLayout, R.string.error_generic, Snackbar.LENGTH_LONG)
|
||||
.setAction(R.string.action_retry) { viewModel.refresh() }
|
||||
.show()
|
||||
}
|
||||
}
|
||||
viewModel.noteSaved.observe(this) {
|
||||
binding.saveNoteInfo.visible(it, View.INVISIBLE)
|
||||
}
|
||||
|
||||
// "Post failed" dialog should display in this activity
|
||||
|
|
@ -438,10 +478,10 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
|
|||
*/
|
||||
private fun setupRefreshLayout() {
|
||||
binding.swipeToRefreshLayout.setOnRefreshListener { onRefresh() }
|
||||
viewModel.isRefreshing.observe(
|
||||
this
|
||||
) { isRefreshing ->
|
||||
binding.swipeToRefreshLayout.isRefreshing = isRefreshing == true
|
||||
lifecycleScope.launch {
|
||||
viewModel.isRefreshing.collect { isRefreshing ->
|
||||
binding.swipeToRefreshLayout.isRefreshing = isRefreshing == true
|
||||
}
|
||||
}
|
||||
binding.swipeToRefreshLayout.setColorSchemeResources(R.color.chinwag_green)
|
||||
}
|
||||
|
|
@ -460,25 +500,33 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
|
|||
val fullUsername = getFullUsername(loadedAccount)
|
||||
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText(null, fullUsername))
|
||||
Snackbar.make(binding.root, getString(R.string.account_username_copied), Snackbar.LENGTH_SHORT)
|
||||
Snackbar.make(
|
||||
binding.root,
|
||||
getString(R.string.account_username_copied),
|
||||
Snackbar.LENGTH_SHORT
|
||||
)
|
||||
.show()
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
val emojifiedNote = account.note.parseAsMastodonHtml().emojify(account.emojis, binding.accountNoteTextView, animateEmojis)
|
||||
val emojifiedNote = account.note.parseAsMastodonHtml().emojify(
|
||||
account.emojis,
|
||||
binding.accountNoteTextView,
|
||||
animateEmojis
|
||||
)
|
||||
setClickableText(binding.accountNoteTextView, emojifiedNote, emptyList(), null, this)
|
||||
|
||||
accountFieldAdapter.fields = account.fields.orEmpty()
|
||||
accountFieldAdapter.emojis = account.emojis.orEmpty()
|
||||
accountFieldAdapter.fields = account.fields
|
||||
accountFieldAdapter.emojis = account.emojis
|
||||
accountFieldAdapter.notifyDataSetChanged()
|
||||
|
||||
binding.accountLockedImageView.visible(account.locked)
|
||||
binding.accountBadgeTextView.visible(account.bot)
|
||||
|
||||
updateAccountAvatar()
|
||||
updateToolbar()
|
||||
updateBadges()
|
||||
updateMovedAccount()
|
||||
updateRemoteAccount()
|
||||
updateAccountJoinedDate()
|
||||
|
|
@ -491,6 +539,39 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
|
|||
}
|
||||
}
|
||||
|
||||
private fun updateBadges() {
|
||||
binding.accountBadgeContainer.removeAllViews()
|
||||
|
||||
val isLight = resources.getBoolean(R.bool.lightNavigationBar)
|
||||
|
||||
if (loadedAccount?.bot == true) {
|
||||
val badgeView =
|
||||
getBadge(
|
||||
getColor(R.color.tusky_grey_50),
|
||||
R.drawable.ic_bot_24dp,
|
||||
getString(R.string.profile_badge_bot_text),
|
||||
isLight
|
||||
)
|
||||
binding.accountBadgeContainer.addView(badgeView)
|
||||
}
|
||||
|
||||
loadedAccount?.roles?.forEach { role ->
|
||||
val badgeColor = if (role.color.isNotBlank()) {
|
||||
Color.parseColor(role.color)
|
||||
} else {
|
||||
// sometimes the color is not set for a role, in this case fall back to our default blue
|
||||
getColor(R.color.tusky_blue)
|
||||
}
|
||||
|
||||
val sb = SpannableStringBuilder("${role.name} ${viewModel.domain}")
|
||||
sb.setSpan(StyleSpan(Typeface.BOLD), 0, role.name.length, 0)
|
||||
|
||||
val badgeView = getBadge(badgeColor, R.drawable.profile_badge_person_24dp, sb, isLight)
|
||||
|
||||
binding.accountBadgeContainer.addView(badgeView)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateAccountJoinedDate() {
|
||||
loadedAccount?.let { account ->
|
||||
try {
|
||||
|
|
@ -579,7 +660,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
|
|||
*/
|
||||
private fun updateRemoteAccount() {
|
||||
loadedAccount?.let { account ->
|
||||
if (account.isRemote()) {
|
||||
if (account.isRemote) {
|
||||
binding.accountRemoveView.show()
|
||||
binding.accountRemoveView.setOnClickListener {
|
||||
openLink(account.url)
|
||||
|
|
@ -772,13 +853,16 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
|
|||
loadedAccount?.let { loadedAccount ->
|
||||
val muteDomain = menu.findItem(R.id.action_mute_domain)
|
||||
domain = getDomain(loadedAccount.url)
|
||||
if (domain.isEmpty()) {
|
||||
when {
|
||||
// If we can't get the domain, there's no way we can mute it anyway...
|
||||
menu.removeItem(R.id.action_mute_domain)
|
||||
} else {
|
||||
if (blockingDomain) {
|
||||
// If the account is from our own domain, muting it is no-op
|
||||
domain.isEmpty() || viewModel.isFromOwnDomain -> {
|
||||
menu.removeItem(R.id.action_mute_domain)
|
||||
}
|
||||
blockingDomain -> {
|
||||
muteDomain.title = getString(R.string.action_unmute_domain, domain)
|
||||
} else {
|
||||
}
|
||||
else -> {
|
||||
muteDomain.title = getString(R.string.action_mute_domain, domain)
|
||||
}
|
||||
}
|
||||
|
|
@ -837,7 +921,9 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
|
|||
} else {
|
||||
AlertDialog.Builder(this)
|
||||
.setMessage(getString(R.string.mute_domain_warning, instance))
|
||||
.setPositiveButton(getString(R.string.mute_domain_warning_dialog_ok)) { _, _ -> viewModel.blockDomain(instance) }
|
||||
.setPositiveButton(
|
||||
getString(R.string.mute_domain_warning_dialog_ok)
|
||||
) { _, _ -> viewModel.blockDomain(instance) }
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
|
|
@ -930,7 +1016,12 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
|
|||
sendIntent.action = Intent.ACTION_SEND
|
||||
sendIntent.putExtra(Intent.EXTRA_TEXT, url)
|
||||
sendIntent.type = "text/plain"
|
||||
startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_account_link_to)))
|
||||
startActivity(
|
||||
Intent.createChooser(
|
||||
sendIntent,
|
||||
resources.getText(R.string.send_account_link_to)
|
||||
)
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
|
@ -942,7 +1033,12 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
|
|||
sendIntent.action = Intent.ACTION_SEND
|
||||
sendIntent.putExtra(Intent.EXTRA_TEXT, fullUsername)
|
||||
sendIntent.type = "text/plain"
|
||||
startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_account_username_to)))
|
||||
startActivity(
|
||||
Intent.createChooser(
|
||||
sendIntent,
|
||||
resources.getText(R.string.send_account_username_to)
|
||||
)
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
|
@ -955,7 +1051,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
|
|||
return true
|
||||
}
|
||||
R.id.action_add_or_remove_from_list -> {
|
||||
ListsForAccountFragment.newInstance(viewModel.accountId).show(supportFragmentManager, null)
|
||||
ListSelectionFragment.newInstance(viewModel.accountId).show(supportFragmentManager, null)
|
||||
return true
|
||||
}
|
||||
R.id.action_mute_domain -> {
|
||||
|
|
@ -973,7 +1069,9 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
|
|||
}
|
||||
R.id.action_report -> {
|
||||
loadedAccount?.let { loadedAccount ->
|
||||
startActivity(ReportActivity.getIntent(this, viewModel.accountId, loadedAccount.username))
|
||||
startActivity(
|
||||
ReportActivity.getIntent(this, viewModel.accountId, loadedAccount.username)
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
|
@ -990,7 +1088,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
|
|||
}
|
||||
|
||||
private fun getFullUsername(account: Account): String {
|
||||
return if (account.isRemote()) {
|
||||
return if (account.isRemote) {
|
||||
"@" + account.username
|
||||
} else {
|
||||
val localUsername = account.localUsername
|
||||
|
|
@ -1000,6 +1098,51 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
|
|||
}
|
||||
}
|
||||
|
||||
private fun getBadge(
|
||||
@ColorInt baseColor: Int,
|
||||
@DrawableRes icon: Int,
|
||||
text: CharSequence,
|
||||
isLight: Boolean
|
||||
): Chip {
|
||||
val badge = Chip(this)
|
||||
|
||||
// text color with maximum contrast
|
||||
val textColor = if (isLight) Color.BLACK else Color.WHITE
|
||||
// badge color with 50% transparency so it blends in with the theme background
|
||||
val backgroundColor = Color.argb(
|
||||
128,
|
||||
Color.red(baseColor),
|
||||
Color.green(baseColor),
|
||||
Color.blue(baseColor)
|
||||
)
|
||||
// a color between the text color and the badge color
|
||||
val outlineColor = ColorUtils.blendARGB(textColor, baseColor, 0.7f)
|
||||
|
||||
// configure the badge
|
||||
badge.text = text
|
||||
badge.setTextColor(textColor)
|
||||
badge.chipStrokeWidth = resources.getDimension(R.dimen.profile_badge_stroke_width)
|
||||
badge.chipStrokeColor = ColorStateList.valueOf(outlineColor)
|
||||
badge.setChipIconResource(icon)
|
||||
badge.isChipIconVisible = true
|
||||
badge.chipIconSize = resources.getDimension(R.dimen.profile_badge_icon_size)
|
||||
badge.chipIconTint = ColorStateList.valueOf(outlineColor)
|
||||
badge.chipBackgroundColor = ColorStateList.valueOf(backgroundColor)
|
||||
|
||||
// badge isn't clickable, so disable all related behavior
|
||||
badge.isClickable = false
|
||||
badge.isFocusable = false
|
||||
badge.setEnsureMinTouchTargetSize(false)
|
||||
|
||||
// reset some chip defaults so it looks better for our badge usecase
|
||||
badge.iconStartPadding = resources.getDimension(R.dimen.profile_badge_icon_start_padding)
|
||||
badge.iconEndPadding = resources.getDimension(R.dimen.profile_badge_icon_end_padding)
|
||||
badge.minHeight = resources.getDimensionPixelSize(R.dimen.profile_badge_min_height)
|
||||
badge.chipMinHeight = resources.getDimension(R.dimen.profile_badge_min_height)
|
||||
badge.updatePadding(top = 0, bottom = 0)
|
||||
return badge
|
||||
}
|
||||
|
||||
override fun androidInjector() = dispatchingAndroidInjector
|
||||
|
||||
companion object {
|
||||
|
|
|
|||
|
|
@ -38,8 +38,15 @@ class AccountFieldAdapter(
|
|||
|
||||
override fun getItemCount() = fields.size
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemAccountFieldBinding> {
|
||||
val binding = ItemAccountFieldBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
viewType: Int
|
||||
): BindingHolder<ItemAccountFieldBinding> {
|
||||
val binding = ItemAccountFieldBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
return BindingHolder(binding)
|
||||
}
|
||||
|
||||
|
|
@ -51,11 +58,20 @@ class AccountFieldAdapter(
|
|||
val emojifiedName = field.name.emojify(emojis, nameTextView, animateEmojis)
|
||||
nameTextView.text = emojifiedName
|
||||
|
||||
val emojifiedValue = field.value.parseAsMastodonHtml().emojify(emojis, valueTextView, animateEmojis)
|
||||
val emojifiedValue = field.value.parseAsMastodonHtml().emojify(
|
||||
emojis,
|
||||
valueTextView,
|
||||
animateEmojis
|
||||
)
|
||||
setClickableText(valueTextView, emojifiedValue, emptyList(), null, linkListener)
|
||||
|
||||
if (field.verifiedAt != null) {
|
||||
valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_check_circle, 0)
|
||||
valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(
|
||||
0,
|
||||
0,
|
||||
R.drawable.ic_check_circle,
|
||||
0
|
||||
)
|
||||
} else {
|
||||
valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,7 +33,11 @@ class AccountPagerAdapter(
|
|||
override fun createFragment(position: Int): Fragment {
|
||||
return when (position) {
|
||||
0 -> TimelineFragment.newInstance(TimelineViewModel.Kind.USER, accountId, false)
|
||||
1 -> TimelineFragment.newInstance(TimelineViewModel.Kind.USER_WITH_REPLIES, accountId, false)
|
||||
1 -> TimelineFragment.newInstance(
|
||||
TimelineViewModel.Kind.USER_WITH_REPLIES,
|
||||
accountId,
|
||||
false
|
||||
)
|
||||
2 -> TimelineFragment.newInstance(TimelineViewModel.Kind.USER_PINNED, accountId, false)
|
||||
3 -> AccountMediaFragment.newInstance(accountId)
|
||||
else -> throw AssertionError("Page $position is out of AccountPagerAdapter bounds")
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
package com.keylesspalace.tusky.components.account
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
|
|
@ -19,58 +18,83 @@ import com.keylesspalace.tusky.util.Error
|
|||
import com.keylesspalace.tusky.util.Loading
|
||||
import com.keylesspalace.tusky.util.Resource
|
||||
import com.keylesspalace.tusky.util.Success
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import com.keylesspalace.tusky.util.getDomain
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class AccountViewModel @Inject constructor(
|
||||
private val mastodonApi: MastodonApi,
|
||||
private val eventHub: EventHub,
|
||||
private val accountManager: AccountManager
|
||||
accountManager: AccountManager
|
||||
) : ViewModel() {
|
||||
|
||||
val accountData = MutableLiveData<Resource<Account>>()
|
||||
val relationshipData = MutableLiveData<Resource<Relationship>>()
|
||||
private val _accountData = MutableStateFlow(null as Resource<Account>?)
|
||||
val accountData: StateFlow<Resource<Account>?> = _accountData.asStateFlow()
|
||||
|
||||
val noteSaved = MutableLiveData<Boolean>()
|
||||
private val _relationshipData = MutableStateFlow(null as Resource<Relationship>?)
|
||||
val relationshipData: StateFlow<Resource<Relationship>?> = _relationshipData.asStateFlow()
|
||||
|
||||
private val _noteSaved = MutableStateFlow(false)
|
||||
val noteSaved: StateFlow<Boolean> = _noteSaved.asStateFlow()
|
||||
|
||||
private val _isRefreshing = MutableSharedFlow<Boolean>(1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
val isRefreshing: SharedFlow<Boolean> = _isRefreshing.asSharedFlow()
|
||||
|
||||
val isRefreshing = MutableLiveData<Boolean>().apply { value = false }
|
||||
private var isDataLoading = false
|
||||
|
||||
lateinit var accountId: String
|
||||
var isSelf = false
|
||||
|
||||
/** the domain of the viewed account **/
|
||||
var domain = ""
|
||||
|
||||
/** True if the viewed account has the same domain as the active account */
|
||||
var isFromOwnDomain = false
|
||||
|
||||
private var noteUpdateJob: Job? = null
|
||||
|
||||
private val activeAccount = accountManager.activeAccount!!
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
eventHub.events.collect { event ->
|
||||
if (event is ProfileEditedEvent && event.newProfileData.id == accountData.value?.data?.id) {
|
||||
accountData.postValue(Success(event.newProfileData))
|
||||
if (event is ProfileEditedEvent && event.newProfileData.id == _accountData.value?.data?.id) {
|
||||
_accountData.value = Success(event.newProfileData)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun obtainAccount(reload: Boolean = false) {
|
||||
if (accountData.value == null || reload) {
|
||||
if (_accountData.value == null || reload) {
|
||||
isDataLoading = true
|
||||
accountData.postValue(Loading())
|
||||
_accountData.value = Loading()
|
||||
|
||||
viewModelScope.launch {
|
||||
mastodonApi.account(accountId)
|
||||
.fold(
|
||||
{ account ->
|
||||
accountData.postValue(Success(account))
|
||||
domain = getDomain(account.url)
|
||||
isFromOwnDomain = domain == activeAccount.domain
|
||||
|
||||
_accountData.value = Success(account)
|
||||
isDataLoading = false
|
||||
isRefreshing.postValue(false)
|
||||
_isRefreshing.emit(false)
|
||||
},
|
||||
{ t ->
|
||||
Log.w(TAG, "failed obtaining account", t)
|
||||
accountData.postValue(Error(cause = t))
|
||||
_accountData.value = Error(cause = t)
|
||||
isDataLoading = false
|
||||
isRefreshing.postValue(false)
|
||||
_isRefreshing.emit(false)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -78,18 +102,25 @@ class AccountViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
private fun obtainRelationship(reload: Boolean = false) {
|
||||
if (relationshipData.value == null || reload) {
|
||||
relationshipData.postValue(Loading())
|
||||
if (_relationshipData.value == null || reload) {
|
||||
_relationshipData.value = Loading()
|
||||
|
||||
viewModelScope.launch {
|
||||
mastodonApi.relationships(listOf(accountId))
|
||||
.fold(
|
||||
{ relationships ->
|
||||
relationshipData.postValue(if (relationships.isNotEmpty()) Success(relationships[0]) else Error())
|
||||
_relationshipData.value =
|
||||
if (relationships.isNotEmpty()) {
|
||||
Success(
|
||||
relationships[0]
|
||||
)
|
||||
} else {
|
||||
Error()
|
||||
}
|
||||
},
|
||||
{ t ->
|
||||
Log.w(TAG, "failed obtaining relationships", t)
|
||||
relationshipData.postValue(Error(cause = t))
|
||||
_relationshipData.value = Error(cause = t)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -97,7 +128,7 @@ class AccountViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
fun changeFollowState() {
|
||||
val relationship = relationshipData.value?.data
|
||||
val relationship = _relationshipData.value?.data
|
||||
if (relationship?.following == true || relationship?.requested == true) {
|
||||
changeRelationship(RelationShipAction.UNFOLLOW)
|
||||
} else {
|
||||
|
|
@ -106,7 +137,7 @@ class AccountViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
fun changeBlockState() {
|
||||
if (relationshipData.value?.data?.blocking == true) {
|
||||
if (_relationshipData.value?.data?.blocking == true) {
|
||||
changeRelationship(RelationShipAction.UNBLOCK)
|
||||
} else {
|
||||
changeRelationship(RelationShipAction.BLOCK)
|
||||
|
|
@ -122,9 +153,9 @@ class AccountViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
fun changeSubscribingState() {
|
||||
val relationship = relationshipData.value?.data
|
||||
if (relationship?.notifying == true || /* Mastodon 3.3.0rc1 */
|
||||
relationship?.subscribing == true /* Pleroma */
|
||||
val relationship = _relationshipData.value?.data
|
||||
if (relationship?.notifying == true || // Mastodon 3.3.0rc1
|
||||
relationship?.subscribing == true // Pleroma
|
||||
) {
|
||||
changeRelationship(RelationShipAction.UNSUBSCRIBE)
|
||||
} else {
|
||||
|
|
@ -136,9 +167,9 @@ class AccountViewModel @Inject constructor(
|
|||
viewModelScope.launch {
|
||||
mastodonApi.blockDomain(instance).fold({
|
||||
eventHub.dispatch(DomainMuteEvent(instance))
|
||||
val relation = relationshipData.value?.data
|
||||
val relation = _relationshipData.value?.data
|
||||
if (relation != null) {
|
||||
relationshipData.postValue(Success(relation.copy(blockingDomain = true)))
|
||||
_relationshipData.value = Success(relation.copy(blockingDomain = true))
|
||||
}
|
||||
}, { e ->
|
||||
Log.e(TAG, "Error muting $instance", e)
|
||||
|
|
@ -149,9 +180,9 @@ class AccountViewModel @Inject constructor(
|
|||
fun unblockDomain(instance: String) {
|
||||
viewModelScope.launch {
|
||||
mastodonApi.unblockDomain(instance).fold({
|
||||
val relation = relationshipData.value?.data
|
||||
val relation = _relationshipData.value?.data
|
||||
if (relation != null) {
|
||||
relationshipData.postValue(Success(relation.copy(blockingDomain = false)))
|
||||
_relationshipData.value = Success(relation.copy(blockingDomain = false))
|
||||
}
|
||||
}, { e ->
|
||||
Log.e(TAG, "Error unmuting $instance", e)
|
||||
|
|
@ -160,7 +191,7 @@ class AccountViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
fun changeShowReblogsState() {
|
||||
if (relationshipData.value?.data?.showingReblogs == true) {
|
||||
if (_relationshipData.value?.data?.showingReblogs == true) {
|
||||
changeRelationship(RelationShipAction.FOLLOW, false)
|
||||
} else {
|
||||
changeRelationship(RelationShipAction.FOLLOW, true)
|
||||
|
|
@ -175,9 +206,9 @@ class AccountViewModel @Inject constructor(
|
|||
parameter: Boolean? = null,
|
||||
duration: Int? = null
|
||||
) = viewModelScope.launch {
|
||||
val relation = relationshipData.value?.data
|
||||
val account = accountData.value?.data
|
||||
val isMastodon = relationshipData.value?.data?.notifying != null
|
||||
val relation = _relationshipData.value?.data
|
||||
val account = _accountData.value?.data
|
||||
val isMastodon = _relationshipData.value?.data?.notifying != null
|
||||
|
||||
if (relation != null && account != null) {
|
||||
// optimistically post new state for faster response
|
||||
|
|
@ -210,7 +241,7 @@ class AccountViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
}
|
||||
relationshipData.postValue(Loading(newRelation))
|
||||
_relationshipData.value = Loading(newRelation)
|
||||
}
|
||||
|
||||
val relationshipCall = when (relationshipAction) {
|
||||
|
|
@ -245,7 +276,7 @@ class AccountViewModel @Inject constructor(
|
|||
|
||||
relationshipCall.fold(
|
||||
{ relationship ->
|
||||
relationshipData.postValue(Success(relationship))
|
||||
_relationshipData.value = Success(relationship)
|
||||
|
||||
when (relationshipAction) {
|
||||
RelationShipAction.UNFOLLOW -> eventHub.dispatch(UnfollowEvent(accountId))
|
||||
|
|
@ -256,22 +287,22 @@ class AccountViewModel @Inject constructor(
|
|||
},
|
||||
{ t ->
|
||||
Log.w(TAG, "failed loading relationship", t)
|
||||
relationshipData.postValue(Error(relation, cause = t))
|
||||
_relationshipData.value = Error(relation, cause = t)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun noteChanged(newNote: String) {
|
||||
noteSaved.postValue(false)
|
||||
_noteSaved.value = false
|
||||
noteUpdateJob?.cancel()
|
||||
noteUpdateJob = viewModelScope.launch {
|
||||
delay(1500)
|
||||
mastodonApi.updateAccountNote(accountId, newNote)
|
||||
.fold(
|
||||
{
|
||||
noteSaved.postValue(true)
|
||||
_noteSaved.value = true
|
||||
delay(4000)
|
||||
noteSaved.postValue(false)
|
||||
_noteSaved.value = false
|
||||
},
|
||||
{ t ->
|
||||
Log.w(TAG, "Error updating note", t)
|
||||
|
|
@ -298,12 +329,19 @@ class AccountViewModel @Inject constructor(
|
|||
|
||||
fun setAccountInfo(accountId: String) {
|
||||
this.accountId = accountId
|
||||
this.isSelf = accountManager.activeAccount?.accountId == accountId
|
||||
this.isSelf = activeAccount.accountId == accountId
|
||||
reload(false)
|
||||
}
|
||||
|
||||
enum class RelationShipAction {
|
||||
FOLLOW, UNFOLLOW, BLOCK, UNBLOCK, MUTE, UNMUTE, SUBSCRIBE, UNSUBSCRIBE
|
||||
FOLLOW,
|
||||
UNFOLLOW,
|
||||
BLOCK,
|
||||
UNBLOCK,
|
||||
MUTE,
|
||||
UNMUTE,
|
||||
SUBSCRIBE,
|
||||
UNSUBSCRIBE
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,260 @@
|
|||
/* Copyright Tusky Contributors
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>.
|
||||
*/
|
||||
|
||||
package com.keylesspalace.tusky.components.account.list
|
||||
|
||||
import android.app.Dialog
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.keylesspalace.tusky.ListsActivity
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.databinding.FragmentListsListBinding
|
||||
import com.keylesspalace.tusky.databinding.ItemListBinding
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||
import com.keylesspalace.tusky.entity.MastoList
|
||||
import com.keylesspalace.tusky.util.BindingHolder
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.awaitCancellation
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ListSelectionFragment : DialogFragment(), Injectable {
|
||||
|
||||
interface ListSelectionListener {
|
||||
fun onListSelected(list: MastoList)
|
||||
}
|
||||
|
||||
@Inject
|
||||
lateinit var viewModelFactory: ViewModelFactory
|
||||
|
||||
private val viewModel: ListsForAccountViewModel by viewModels { viewModelFactory }
|
||||
|
||||
private var _binding: FragmentListsListBinding? = null
|
||||
|
||||
// This property is only valid between onCreateDialog and onDestroyView
|
||||
private val binding get() = _binding!!
|
||||
|
||||
private val adapter = Adapter()
|
||||
|
||||
private var selectListener: ListSelectionListener? = null
|
||||
private var accountId: String? = null
|
||||
|
||||
override fun onAttach(context: Context) {
|
||||
super.onAttach(context)
|
||||
selectListener = context as? ListSelectionListener
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setStyle(STYLE_NORMAL, R.style.TuskyDialogFragmentStyle)
|
||||
accountId = requireArguments().getString(ARG_ACCOUNT_ID)
|
||||
}
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val context = requireContext()
|
||||
|
||||
_binding = FragmentListsListBinding.inflate(layoutInflater)
|
||||
binding.listsView.adapter = adapter
|
||||
|
||||
val dialogBuilder = AlertDialog.Builder(context)
|
||||
.setView(binding.root)
|
||||
.setTitle(R.string.select_list_title)
|
||||
.setNeutralButton(R.string.select_list_manage) { _, _ ->
|
||||
val listIntent = Intent(context, ListsActivity::class.java)
|
||||
startActivity(listIntent)
|
||||
}
|
||||
.setNegativeButton(if (accountId != null) R.string.button_done else android.R.string.cancel, null)
|
||||
|
||||
val dialog = dialogBuilder.create()
|
||||
|
||||
val showProgressBarJob = getProgressBarJob(binding.progressBar, 500)
|
||||
showProgressBarJob.start()
|
||||
|
||||
// TODO change this to a (single) LoadState like elsewhere?
|
||||
lifecycleScope.launch {
|
||||
viewModel.states.collectLatest { states ->
|
||||
binding.progressBar.hide()
|
||||
showProgressBarJob.cancel()
|
||||
if (states.isEmpty()) {
|
||||
binding.messageView.show()
|
||||
binding.messageView.setup(R.drawable.elephant_friend_empty, R.string.no_lists)
|
||||
} else {
|
||||
binding.listsView.show()
|
||||
adapter.submitList(states)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
viewModel.loadError.collectLatest { error ->
|
||||
Log.e(TAG, "failed to load lists", error)
|
||||
binding.progressBar.hide()
|
||||
showProgressBarJob.cancel()
|
||||
binding.listsView.hide()
|
||||
binding.messageView.apply {
|
||||
show()
|
||||
setup(error) { load() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
viewModel.actionError.collectLatest { error ->
|
||||
when (error.type) {
|
||||
ActionError.Type.ADD -> {
|
||||
Snackbar.make(
|
||||
binding.root,
|
||||
R.string.failed_to_add_to_list,
|
||||
Snackbar.LENGTH_LONG
|
||||
)
|
||||
.setAction(R.string.action_retry) {
|
||||
viewModel.addAccountToList(accountId!!, error.listId)
|
||||
}
|
||||
.show()
|
||||
}
|
||||
ActionError.Type.REMOVE -> {
|
||||
Snackbar.make(
|
||||
binding.root,
|
||||
R.string.failed_to_remove_from_list,
|
||||
Snackbar.LENGTH_LONG
|
||||
)
|
||||
.setAction(R.string.action_retry) {
|
||||
viewModel.removeAccountFromList(accountId!!, error.listId)
|
||||
}
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
load()
|
||||
}
|
||||
|
||||
return dialog
|
||||
}
|
||||
|
||||
private fun getProgressBarJob(progressView: View, delayMs: Long) = this.lifecycleScope.launch(
|
||||
start = CoroutineStart.LAZY
|
||||
) {
|
||||
try {
|
||||
delay(delayMs)
|
||||
progressView.show()
|
||||
awaitCancellation()
|
||||
} finally {
|
||||
progressView.hide()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
|
||||
private fun load() {
|
||||
binding.progressBar.show()
|
||||
binding.listsView.hide()
|
||||
binding.messageView.hide()
|
||||
viewModel.load(accountId)
|
||||
}
|
||||
|
||||
private object Differ : DiffUtil.ItemCallback<AccountListState>() {
|
||||
override fun areItemsTheSame(
|
||||
oldItem: AccountListState,
|
||||
newItem: AccountListState
|
||||
): Boolean {
|
||||
return oldItem.list.id == newItem.list.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(
|
||||
oldItem: AccountListState,
|
||||
newItem: AccountListState
|
||||
): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
}
|
||||
|
||||
inner class Adapter :
|
||||
ListAdapter<AccountListState, BindingHolder<ItemListBinding>>(Differ) {
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
viewType: Int
|
||||
): BindingHolder<ItemListBinding> {
|
||||
return BindingHolder(ItemListBinding.inflate(LayoutInflater.from(parent.context), parent, false))
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: BindingHolder<ItemListBinding>, position: Int) {
|
||||
val item = getItem(position)
|
||||
holder.binding.listName.text = item.list.title
|
||||
accountId?.let { accountId ->
|
||||
holder.binding.addButton.apply {
|
||||
visible(!item.includesAccount)
|
||||
setOnClickListener {
|
||||
viewModel.addAccountToList(accountId, item.list.id)
|
||||
}
|
||||
}
|
||||
holder.binding.removeButton.apply {
|
||||
visible(item.includesAccount)
|
||||
setOnClickListener {
|
||||
viewModel.removeAccountFromList(accountId, item.list.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
holder.itemView.setOnClickListener {
|
||||
selectListener?.onListSelected(item.list)
|
||||
|
||||
accountId?.let { accountId ->
|
||||
if (item.includesAccount) {
|
||||
viewModel.removeAccountFromList(accountId, item.list.id)
|
||||
} else {
|
||||
viewModel.addAccountToList(accountId, item.list.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ListsListFragment"
|
||||
private const val ARG_ACCOUNT_ID = "accountId"
|
||||
|
||||
fun newInstance(accountId: String?): ListSelectionFragment {
|
||||
val args = Bundle().apply {
|
||||
putString(ARG_ACCOUNT_ID, accountId)
|
||||
}
|
||||
return ListSelectionFragment().apply { arguments = args }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,200 +0,0 @@
|
|||
/* Copyright 2022 kyori19
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>.
|
||||
*/
|
||||
|
||||
package com.keylesspalace.tusky.components.account.list
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.LinearLayout
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.databinding.FragmentListsForAccountBinding
|
||||
import com.keylesspalace.tusky.databinding.ItemAddOrRemoveFromListBinding
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||
import com.keylesspalace.tusky.util.BindingHolder
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
class ListsForAccountFragment : DialogFragment(), Injectable {
|
||||
|
||||
@Inject
|
||||
lateinit var viewModelFactory: ViewModelFactory
|
||||
|
||||
private val viewModel: ListsForAccountViewModel by viewModels { viewModelFactory }
|
||||
private val binding by viewBinding(FragmentListsForAccountBinding::bind)
|
||||
|
||||
private val adapter = Adapter()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setStyle(STYLE_NORMAL, R.style.TuskyDialogFragmentStyle)
|
||||
|
||||
viewModel.setup(requireArguments().getString(ARG_ACCOUNT_ID)!!)
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
dialog?.apply {
|
||||
window?.setLayout(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
return inflater.inflate(R.layout.fragment_lists_for_account, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
binding.listsView.layoutManager = LinearLayoutManager(view.context)
|
||||
binding.listsView.adapter = adapter
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
viewModel.states.collectLatest { states ->
|
||||
binding.progressBar.hide()
|
||||
if (states.isEmpty()) {
|
||||
binding.messageView.show()
|
||||
binding.messageView.setup(R.drawable.elephant_friend_empty, R.string.no_lists) {
|
||||
load()
|
||||
}
|
||||
} else {
|
||||
binding.listsView.show()
|
||||
adapter.submitList(states)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
viewModel.loadError.collectLatest { error ->
|
||||
binding.progressBar.hide()
|
||||
binding.listsView.hide()
|
||||
binding.messageView.apply {
|
||||
show()
|
||||
setup(error) { load() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
viewModel.actionError.collectLatest { error ->
|
||||
when (error.type) {
|
||||
ActionError.Type.ADD -> {
|
||||
Snackbar.make(binding.root, R.string.failed_to_add_to_list, Snackbar.LENGTH_LONG)
|
||||
.setAction(R.string.action_retry) {
|
||||
viewModel.addAccountToList(error.listId)
|
||||
}
|
||||
.show()
|
||||
}
|
||||
ActionError.Type.REMOVE -> {
|
||||
Snackbar.make(binding.root, R.string.failed_to_remove_from_list, Snackbar.LENGTH_LONG)
|
||||
.setAction(R.string.action_retry) {
|
||||
viewModel.removeAccountFromList(error.listId)
|
||||
}
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
binding.doneButton.setOnClickListener {
|
||||
dismiss()
|
||||
}
|
||||
|
||||
load()
|
||||
}
|
||||
|
||||
private fun load() {
|
||||
binding.progressBar.show()
|
||||
binding.listsView.hide()
|
||||
binding.messageView.hide()
|
||||
viewModel.load()
|
||||
}
|
||||
|
||||
private object Differ : DiffUtil.ItemCallback<AccountListState>() {
|
||||
override fun areItemsTheSame(
|
||||
oldItem: AccountListState,
|
||||
newItem: AccountListState
|
||||
): Boolean {
|
||||
return oldItem.list.id == newItem.list.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(
|
||||
oldItem: AccountListState,
|
||||
newItem: AccountListState
|
||||
): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
}
|
||||
|
||||
inner class Adapter :
|
||||
ListAdapter<AccountListState, BindingHolder<ItemAddOrRemoveFromListBinding>>(Differ) {
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
viewType: Int
|
||||
): BindingHolder<ItemAddOrRemoveFromListBinding> {
|
||||
val binding =
|
||||
ItemAddOrRemoveFromListBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
return BindingHolder(binding)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: BindingHolder<ItemAddOrRemoveFromListBinding>, position: Int) {
|
||||
val item = getItem(position)
|
||||
holder.binding.listNameView.text = item.list.title
|
||||
holder.binding.addButton.apply {
|
||||
visible(!item.includesAccount)
|
||||
setOnClickListener {
|
||||
viewModel.addAccountToList(item.list.id)
|
||||
}
|
||||
}
|
||||
holder.binding.removeButton.apply {
|
||||
visible(item.includesAccount)
|
||||
setOnClickListener {
|
||||
viewModel.removeAccountFromList(item.list.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ARG_ACCOUNT_ID = "accountId"
|
||||
|
||||
fun newInstance(accountId: String): ListsForAccountFragment {
|
||||
val args = Bundle().apply {
|
||||
putString(ARG_ACCOUNT_ID, accountId)
|
||||
}
|
||||
return ListsForAccountFragment().apply { arguments = args }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -24,14 +24,13 @@ import at.connyduck.calladapter.networkresult.onSuccess
|
|||
import at.connyduck.calladapter.networkresult.runCatching
|
||||
import com.keylesspalace.tusky.entity.MastoList
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class AccountListState(
|
||||
val list: MastoList,
|
||||
|
|
@ -54,35 +53,30 @@ class ListsForAccountViewModel @Inject constructor(
|
|||
private val mastodonApi: MastodonApi
|
||||
) : ViewModel() {
|
||||
|
||||
private lateinit var accountId: String
|
||||
|
||||
private val _states = MutableSharedFlow<List<AccountListState>>(1)
|
||||
val states: SharedFlow<List<AccountListState>> = _states
|
||||
val states: SharedFlow<List<AccountListState>> = _states.asSharedFlow()
|
||||
|
||||
private val _loadError = MutableSharedFlow<Throwable>(1)
|
||||
val loadError: SharedFlow<Throwable> = _loadError
|
||||
val loadError: SharedFlow<Throwable> = _loadError.asSharedFlow()
|
||||
|
||||
private val _actionError = MutableSharedFlow<ActionError>(1)
|
||||
val actionError: SharedFlow<ActionError> = _actionError
|
||||
val actionError: SharedFlow<ActionError> = _actionError.asSharedFlow()
|
||||
|
||||
fun setup(accountId: String) {
|
||||
this.accountId = accountId
|
||||
}
|
||||
|
||||
fun load() {
|
||||
fun load(accountId: String?) {
|
||||
_loadError.resetReplayCache()
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
val (all, includes) = listOf(
|
||||
async { mastodonApi.getLists() },
|
||||
async { mastodonApi.getListsIncludesAccount(accountId) }
|
||||
).awaitAll()
|
||||
val all = mastodonApi.getLists().getOrThrow()
|
||||
var includes: List<MastoList> = emptyList()
|
||||
if (accountId != null) {
|
||||
includes = mastodonApi.getListsIncludesAccount(accountId).getOrThrow()
|
||||
}
|
||||
|
||||
_states.emit(
|
||||
all.getOrThrow().map { list ->
|
||||
all.map { listState ->
|
||||
AccountListState(
|
||||
list = list,
|
||||
includesAccount = includes.getOrThrow().any { it.id == list.id }
|
||||
list = listState,
|
||||
includesAccount = includes.any { it.id == listState.id }
|
||||
)
|
||||
}
|
||||
)
|
||||
|
|
@ -93,7 +87,9 @@ class ListsForAccountViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
fun addAccountToList(listId: String) {
|
||||
// TODO there is no "progress" visible for these
|
||||
|
||||
fun addAccountToList(accountId: String, listId: String) {
|
||||
_actionError.resetReplayCache()
|
||||
viewModelScope.launch {
|
||||
mastodonApi.addAccountToList(listId, listOf(accountId))
|
||||
|
|
@ -114,7 +110,7 @@ class ListsForAccountViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
fun removeAccountFromList(listId: String) {
|
||||
fun removeAccountFromList(accountId: String, listId: String) {
|
||||
_actionError.resetReplayCache()
|
||||
viewModelScope.launch {
|
||||
mastodonApi.deleteAccountFromList(listId, listOf(accountId))
|
||||
|
|
|
|||
|
|
@ -49,9 +49,9 @@ import com.mikepenz.iconics.IconicsDrawable
|
|||
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
|
||||
import com.mikepenz.iconics.utils.colorInt
|
||||
import com.mikepenz.iconics.utils.sizeDp
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Fragment with multiple columns of media previews for the specified account.
|
||||
|
|
@ -82,22 +82,23 @@ class AccountMediaFragment :
|
|||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
|
||||
|
||||
val alwaysShowSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia
|
||||
|
||||
val preferences = PreferenceManager.getDefaultSharedPreferences(view.context)
|
||||
val useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true)
|
||||
|
||||
adapter = AccountMediaGridAdapter(
|
||||
alwaysShowSensitiveMedia = alwaysShowSensitiveMedia,
|
||||
useBlurhash = useBlurhash,
|
||||
context = view.context,
|
||||
onAttachmentClickListener = ::onAttachmentClick
|
||||
)
|
||||
|
||||
val columnCount = view.context.resources.getInteger(R.integer.profile_media_column_count)
|
||||
val imageSpacing = view.context.resources.getDimensionPixelSize(R.dimen.profile_media_spacing)
|
||||
val imageSpacing = view.context.resources.getDimensionPixelSize(
|
||||
R.dimen.profile_media_spacing
|
||||
)
|
||||
|
||||
binding.recyclerView.addItemDecoration(GridSpacingItemDecoration(columnCount, imageSpacing, 0))
|
||||
binding.recyclerView.addItemDecoration(
|
||||
GridSpacingItemDecoration(columnCount, imageSpacing, 0)
|
||||
)
|
||||
|
||||
binding.recyclerView.layoutManager = GridLayoutManager(view.context, columnCount)
|
||||
binding.recyclerView.adapter = adapter
|
||||
|
|
@ -127,7 +128,11 @@ class AccountMediaFragment :
|
|||
is LoadState.NotLoading -> {
|
||||
if (loadState.append is LoadState.NotLoading && loadState.source.refresh is LoadState.NotLoading) {
|
||||
binding.statusView.show()
|
||||
binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null)
|
||||
binding.statusView.setup(
|
||||
R.drawable.elephant_friend_empty,
|
||||
R.string.message_empty,
|
||||
null
|
||||
)
|
||||
}
|
||||
}
|
||||
is LoadState.Error -> {
|
||||
|
|
@ -178,11 +183,19 @@ class AccountMediaFragment :
|
|||
Attachment.Type.GIFV,
|
||||
Attachment.Type.VIDEO,
|
||||
Attachment.Type.AUDIO -> {
|
||||
val intent = ViewMediaActivity.newIntent(context, attachmentsFromSameStatus, currentIndex)
|
||||
val intent = ViewMediaActivity.newIntent(
|
||||
context,
|
||||
attachmentsFromSameStatus,
|
||||
currentIndex
|
||||
)
|
||||
if (activity != null) {
|
||||
val url = selected.attachment.url
|
||||
ViewCompat.setTransitionName(view, url)
|
||||
val options = ActivityOptionsCompat.makeSceneTransitionAnimation(requireActivity(), view, url)
|
||||
val options = ActivityOptionsCompat.makeSceneTransitionAnimation(
|
||||
requireActivity(),
|
||||
view,
|
||||
url
|
||||
)
|
||||
startActivity(intent, options.toBundle())
|
||||
} else {
|
||||
startActivity(intent)
|
||||
|
|
|
|||
|
|
@ -21,36 +21,57 @@ import com.keylesspalace.tusky.util.getFormattedDescription
|
|||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.viewdata.AttachmentViewData
|
||||
import java.util.Random
|
||||
import kotlin.random.Random
|
||||
|
||||
class AccountMediaGridAdapter(
|
||||
private val alwaysShowSensitiveMedia: Boolean,
|
||||
private val useBlurhash: Boolean,
|
||||
context: Context,
|
||||
private val onAttachmentClickListener: (AttachmentViewData, View) -> Unit
|
||||
) : PagingDataAdapter<AttachmentViewData, BindingHolder<ItemAccountMediaBinding>>(
|
||||
object : DiffUtil.ItemCallback<AttachmentViewData>() {
|
||||
override fun areItemsTheSame(oldItem: AttachmentViewData, newItem: AttachmentViewData): Boolean {
|
||||
override fun areItemsTheSame(
|
||||
oldItem: AttachmentViewData,
|
||||
newItem: AttachmentViewData
|
||||
): Boolean {
|
||||
return oldItem.attachment.id == newItem.attachment.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: AttachmentViewData, newItem: AttachmentViewData): Boolean {
|
||||
override fun areContentsTheSame(
|
||||
oldItem: AttachmentViewData,
|
||||
newItem: AttachmentViewData
|
||||
): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
}
|
||||
) {
|
||||
|
||||
private val baseItemBackgroundColor = MaterialColors.getColor(context, com.google.android.material.R.attr.colorSurface, Color.BLACK)
|
||||
private val videoIndicator = AppCompatResources.getDrawable(context, R.drawable.ic_play_indicator)
|
||||
private val mediaHiddenDrawable = AppCompatResources.getDrawable(context, R.drawable.ic_hide_media_24dp)
|
||||
private val baseItemBackgroundColor = MaterialColors.getColor(
|
||||
context,
|
||||
com.google.android.material.R.attr.colorSurface,
|
||||
Color.BLACK
|
||||
)
|
||||
private val videoIndicator = AppCompatResources.getDrawable(
|
||||
context,
|
||||
R.drawable.ic_play_indicator
|
||||
)
|
||||
private val mediaHiddenDrawable = AppCompatResources.getDrawable(
|
||||
context,
|
||||
R.drawable.ic_hide_media_24dp
|
||||
)
|
||||
|
||||
private val itemBgBaseHSV = FloatArray(3)
|
||||
private val random = Random()
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemAccountMediaBinding> {
|
||||
val binding = ItemAccountMediaBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
viewType: Int
|
||||
): BindingHolder<ItemAccountMediaBinding> {
|
||||
val binding = ItemAccountMediaBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
Color.colorToHSV(baseItemBackgroundColor, itemBgBaseHSV)
|
||||
itemBgBaseHSV[2] = itemBgBaseHSV[2] + random.nextFloat() / 3f - 1f / 6f
|
||||
itemBgBaseHSV[2] = itemBgBaseHSV[2] + Random.nextFloat() / 3f - 1f / 6f
|
||||
binding.root.setBackgroundColor(Color.HSVToColor(itemBgBaseHSV))
|
||||
return BindingHolder(binding)
|
||||
}
|
||||
|
|
@ -72,7 +93,11 @@ class AccountMediaGridAdapter(
|
|||
if (item.attachment.type == Attachment.Type.AUDIO) {
|
||||
overlay.hide()
|
||||
|
||||
imageView.setPadding(context.resources.getDimensionPixelSize(R.dimen.profile_media_audio_icon_padding))
|
||||
imageView.setPadding(
|
||||
context.resources.getDimensionPixelSize(
|
||||
R.dimen.profile_media_audio_icon_padding
|
||||
)
|
||||
)
|
||||
|
||||
Glide.with(imageView)
|
||||
.load(R.drawable.ic_music_box_preview_24dp)
|
||||
|
|
@ -80,7 +105,7 @@ class AccountMediaGridAdapter(
|
|||
.into(imageView)
|
||||
|
||||
imageView.contentDescription = item.attachment.getFormattedDescription(context)
|
||||
} else if (item.sensitive && !item.isRevealed && !alwaysShowSensitiveMedia) {
|
||||
} else if (item.sensitive && !item.isRevealed) {
|
||||
overlay.show()
|
||||
overlay.setImageDrawable(mediaHiddenDrawable)
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import androidx.paging.LoadType
|
|||
import androidx.paging.PagingState
|
||||
import androidx.paging.RemoteMediator
|
||||
import com.keylesspalace.tusky.components.timeline.util.ifExpected
|
||||
import com.keylesspalace.tusky.db.AccountEntity
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.viewdata.AttachmentViewData
|
||||
import retrofit2.HttpException
|
||||
|
|
@ -27,9 +28,9 @@ import retrofit2.HttpException
|
|||
@OptIn(ExperimentalPagingApi::class)
|
||||
class AccountMediaRemoteMediator(
|
||||
private val api: MastodonApi,
|
||||
private val activeAccount: AccountEntity,
|
||||
private val viewModel: AccountMediaViewModel
|
||||
) : RemoteMediator<String, AttachmentViewData>() {
|
||||
|
||||
override suspend fun load(
|
||||
loadType: LoadType,
|
||||
state: PagingState<String, AttachmentViewData>
|
||||
|
|
@ -58,7 +59,7 @@ class AccountMediaRemoteMediator(
|
|||
}
|
||||
|
||||
val attachments = statuses.flatMap { status ->
|
||||
AttachmentViewData.list(status)
|
||||
AttachmentViewData.list(status, activeAccount.alwaysShowSensitiveMedia)
|
||||
}
|
||||
|
||||
if (loadType == LoadType.REFRESH) {
|
||||
|
|
|
|||
|
|
@ -21,11 +21,13 @@ import androidx.paging.ExperimentalPagingApi
|
|||
import androidx.paging.Pager
|
||||
import androidx.paging.PagingConfig
|
||||
import androidx.paging.cachedIn
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.viewdata.AttachmentViewData
|
||||
import javax.inject.Inject
|
||||
|
||||
class AccountMediaViewModel @Inject constructor(
|
||||
accountManager: AccountManager,
|
||||
api: MastodonApi
|
||||
) : ViewModel() {
|
||||
|
||||
|
|
@ -35,6 +37,8 @@ class AccountMediaViewModel @Inject constructor(
|
|||
|
||||
var currentSource: AccountMediaPagingSource? = null
|
||||
|
||||
val activeAccount = accountManager.activeAccount!!
|
||||
|
||||
@OptIn(ExperimentalPagingApi::class)
|
||||
val media = Pager(
|
||||
config = PagingConfig(
|
||||
|
|
@ -48,7 +52,7 @@ class AccountMediaViewModel @Inject constructor(
|
|||
currentSource = source
|
||||
}
|
||||
},
|
||||
remoteMediator = AccountMediaRemoteMediator(api, this)
|
||||
remoteMediator = AccountMediaRemoteMediator(api, activeAccount, this)
|
||||
).flow
|
||||
.cachedIn(viewModelScope)
|
||||
|
||||
|
|
|
|||
|
|
@ -48,7 +48,6 @@ class AccountListActivity : BottomSheetActivity(), HasAndroidInjector {
|
|||
|
||||
val type = intent.getSerializableExtra(EXTRA_TYPE) as Type
|
||||
val id: String? = intent.getStringExtra(EXTRA_ID)
|
||||
val accountLocked: Boolean = intent.getBooleanExtra(EXTRA_ACCOUNT_LOCKED, false)
|
||||
|
||||
setSupportActionBar(binding.includedToolbar.toolbar)
|
||||
supportActionBar?.apply {
|
||||
|
|
@ -66,7 +65,7 @@ class AccountListActivity : BottomSheetActivity(), HasAndroidInjector {
|
|||
}
|
||||
|
||||
supportFragmentManager.commit {
|
||||
replace(R.id.fragment_container, AccountListFragment.newInstance(type, id, accountLocked))
|
||||
replace(R.id.fragment_container, AccountListFragment.newInstance(type, id))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -75,13 +74,11 @@ class AccountListActivity : BottomSheetActivity(), HasAndroidInjector {
|
|||
companion object {
|
||||
private const val EXTRA_TYPE = "type"
|
||||
private const val EXTRA_ID = "id"
|
||||
private const val EXTRA_ACCOUNT_LOCKED = "acc_locked"
|
||||
|
||||
fun newIntent(context: Context, type: Type, id: String? = null, accountLocked: Boolean = false): Intent {
|
||||
fun newIntent(context: Context, type: Type, id: String? = null): Intent {
|
||||
return Intent(context, AccountListActivity::class.java).apply {
|
||||
putExtra(EXTRA_TYPE, type)
|
||||
putExtra(EXTRA_ID, id)
|
||||
putExtra(EXTRA_ACCOUNT_LOCKED, accountLocked)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ import android.os.Bundle
|
|||
import android.util.Log
|
||||
import android.view.View
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.recyclerview.widget.ConcatAdapter
|
||||
|
|
@ -28,10 +27,7 @@ import androidx.recyclerview.widget.LinearLayoutManager
|
|||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.SimpleItemAnimator
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from
|
||||
import autodispose2.autoDispose
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.keylesspalace.tusky.BaseActivity
|
||||
import com.keylesspalace.tusky.BottomSheetActivity
|
||||
import com.keylesspalace.tusky.PostLookupFallbackBehavior
|
||||
import com.keylesspalace.tusky.R
|
||||
|
|
@ -56,13 +52,13 @@ import com.keylesspalace.tusky.settings.PrefKeys
|
|||
import com.keylesspalace.tusky.util.HttpHeaderLink
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.view.EndlessOnScrollListener
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.launch
|
||||
import retrofit2.Response
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
|
||||
class AccountListFragment :
|
||||
Fragment(R.layout.fragment_account_list),
|
||||
|
|
@ -97,7 +93,9 @@ class AccountListFragment :
|
|||
val layoutManager = LinearLayoutManager(view.context)
|
||||
binding.recyclerView.layoutManager = layoutManager
|
||||
(binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
|
||||
binding.recyclerView.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL))
|
||||
binding.recyclerView.addItemDecoration(
|
||||
DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL)
|
||||
)
|
||||
|
||||
binding.swipeRefreshLayout.setOnRefreshListener { fetchAccounts() }
|
||||
binding.swipeRefreshLayout.setColorSchemeResources(R.color.chinwag_green)
|
||||
|
|
@ -107,15 +105,18 @@ class AccountListFragment :
|
|||
val animateEmojis = pm.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
|
||||
val showBotOverlay = pm.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true)
|
||||
|
||||
val activeAccount = accountManager.activeAccount!!
|
||||
|
||||
adapter = when (type) {
|
||||
Type.BLOCKS -> BlocksAdapter(this, animateAvatar, animateEmojis, showBotOverlay)
|
||||
Type.MUTES -> MutesAdapter(this, animateAvatar, animateEmojis, showBotOverlay)
|
||||
Type.FOLLOW_REQUESTS -> {
|
||||
val headerAdapter = FollowRequestsHeaderAdapter(
|
||||
instanceName = accountManager.activeAccount!!.domain,
|
||||
accountLocked = arguments?.getBoolean(ARG_ACCOUNT_LOCKED) == true
|
||||
instanceName = activeAccount.domain,
|
||||
accountLocked = activeAccount.locked
|
||||
)
|
||||
val followRequestsAdapter = FollowRequestsAdapter(this, this, animateAvatar, animateEmojis, showBotOverlay)
|
||||
val followRequestsAdapter =
|
||||
FollowRequestsAdapter(this, this, animateAvatar, animateEmojis, showBotOverlay)
|
||||
binding.recyclerView.adapter = ConcatAdapter(headerAdapter, followRequestsAdapter)
|
||||
followRequestsAdapter
|
||||
}
|
||||
|
|
@ -140,15 +141,13 @@ class AccountListFragment :
|
|||
}
|
||||
|
||||
override fun onViewTag(tag: String) {
|
||||
(activity as BaseActivity?)
|
||||
?.startActivityWithSlideInAnimation(StatusListActivity.newHashtagIntent(requireContext(), tag))
|
||||
activity?.startActivityWithSlideInAnimation(
|
||||
StatusListActivity.newHashtagIntent(requireContext(), tag)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onViewAccount(id: String) {
|
||||
(activity as BaseActivity?)?.let {
|
||||
val intent = AccountActivity.getIntent(it, id)
|
||||
it.startActivityWithSlideInAnimation(intent)
|
||||
}
|
||||
activity?.startActivityWithSlideInAnimation(AccountActivity.getIntent(requireContext(), id))
|
||||
}
|
||||
|
||||
override fun onViewUrl(url: String) {
|
||||
|
|
@ -224,7 +223,11 @@ class AccountListFragment :
|
|||
val unblockedUser = blocksAdapter.removeItem(position)
|
||||
|
||||
if (unblockedUser != null) {
|
||||
Snackbar.make(binding.recyclerView, R.string.confirmation_unblocked, Snackbar.LENGTH_LONG)
|
||||
Snackbar.make(
|
||||
binding.recyclerView,
|
||||
R.string.confirmation_unblocked,
|
||||
Snackbar.LENGTH_LONG
|
||||
)
|
||||
.setAction(R.string.action_undo) {
|
||||
blocksAdapter.addItem(unblockedUser, position)
|
||||
onBlock(true, id, position)
|
||||
|
|
@ -242,22 +245,17 @@ class AccountListFragment :
|
|||
Log.e(TAG, "Failed to $verb account accountId $accountId")
|
||||
}
|
||||
|
||||
override fun onRespondToFollowRequest(
|
||||
accept: Boolean,
|
||||
accountId: String,
|
||||
position: Int
|
||||
) {
|
||||
if (accept) {
|
||||
api.authorizeFollowRequest(accountId)
|
||||
} else {
|
||||
api.rejectFollowRequest(accountId)
|
||||
}.observeOn(AndroidSchedulers.mainThread())
|
||||
.autoDispose(from(this, Lifecycle.Event.ON_DESTROY))
|
||||
.subscribe(
|
||||
{
|
||||
override fun onRespondToFollowRequest(accept: Boolean, accountId: String, position: Int) {
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
if (accept) {
|
||||
api.authorizeFollowRequest(accountId)
|
||||
} else {
|
||||
api.rejectFollowRequest(accountId)
|
||||
}.fold(
|
||||
onSuccess = {
|
||||
onRespondToFollowRequestSuccess(position)
|
||||
},
|
||||
{ throwable ->
|
||||
onFailure = { throwable ->
|
||||
val verb = if (accept) {
|
||||
"accept"
|
||||
} else {
|
||||
|
|
@ -266,6 +264,7 @@ class AccountListFragment :
|
|||
Log.e(TAG, "Failed to $verb account id $accountId.", throwable)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onRespondToFollowRequestSuccess(position: Int) {
|
||||
|
|
@ -330,7 +329,13 @@ class AccountListFragment :
|
|||
|
||||
val linkHeader = response.headers()["Link"]
|
||||
onFetchAccountsSuccess(accountList, linkHeader)
|
||||
} catch (exception: IOException) {
|
||||
} catch (exception: Exception) {
|
||||
if (exception is CancellationException) {
|
||||
// Scope is cancelled, probably because the fragment is destroyed.
|
||||
// We must not touch any views anymore, so rethrow the exception.
|
||||
// (CancellationException in a cancelled scope is normal and will be ignored)
|
||||
throw exception
|
||||
}
|
||||
onFetchAccountsFailure(exception)
|
||||
}
|
||||
}
|
||||
|
|
@ -404,14 +409,12 @@ class AccountListFragment :
|
|||
private const val TAG = "AccountList" // logging tag
|
||||
private const val ARG_TYPE = "type"
|
||||
private const val ARG_ID = "id"
|
||||
private const val ARG_ACCOUNT_LOCKED = "acc_locked"
|
||||
|
||||
fun newInstance(type: Type, id: String? = null, accountLocked: Boolean = false): AccountListFragment {
|
||||
fun newInstance(type: Type, id: String? = null): AccountListFragment {
|
||||
return AccountListFragment().apply {
|
||||
arguments = Bundle(3).apply {
|
||||
putSerializable(ARG_TYPE, type)
|
||||
putString(ARG_ID, id)
|
||||
putBoolean(ARG_ACCOUNT_LOCKED, accountLocked)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,9 +60,7 @@ abstract class AccountAdapter<AVH : RecyclerView.ViewHolder> internal constructo
|
|||
}
|
||||
}
|
||||
|
||||
private fun createFooterViewHolder(
|
||||
parent: ViewGroup
|
||||
): RecyclerView.ViewHolder {
|
||||
private fun createFooterViewHolder(parent: ViewGroup): RecyclerView.ViewHolder {
|
||||
val binding = ItemFooterBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
return BindingHolder(binding)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,16 +39,27 @@ class BlocksAdapter(
|
|||
) {
|
||||
|
||||
override fun createAccountViewHolder(parent: ViewGroup): BindingHolder<ItemBlockedUserBinding> {
|
||||
val binding = ItemBlockedUserBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
val binding = ItemBlockedUserBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
return BindingHolder(binding)
|
||||
}
|
||||
|
||||
override fun onBindAccountViewHolder(viewHolder: BindingHolder<ItemBlockedUserBinding>, position: Int) {
|
||||
override fun onBindAccountViewHolder(
|
||||
viewHolder: BindingHolder<ItemBlockedUserBinding>,
|
||||
position: Int
|
||||
) {
|
||||
val account = accountList[position]
|
||||
val binding = viewHolder.binding
|
||||
val context = binding.root.context
|
||||
|
||||
val emojifiedName = account.name.emojify(account.emojis, binding.blockedUserDisplayName, animateEmojis)
|
||||
val emojifiedName = account.name.emojify(
|
||||
account.emojis,
|
||||
binding.blockedUserDisplayName,
|
||||
animateEmojis
|
||||
)
|
||||
binding.blockedUserDisplayName.text = emojifiedName
|
||||
val formattedUsername = context.getString(R.string.post_username_format, account.username)
|
||||
binding.blockedUserUsername.text = formattedUsername
|
||||
|
|
|
|||
|
|
@ -44,7 +44,6 @@ class FollowRequestsAdapter(
|
|||
)
|
||||
return FollowRequestViewHolder(
|
||||
binding,
|
||||
accountActionListener,
|
||||
linkListener,
|
||||
showHeader = false
|
||||
)
|
||||
|
|
|
|||
|
|
@ -27,12 +27,22 @@ class FollowRequestsHeaderAdapter(
|
|||
private val accountLocked: Boolean
|
||||
) : RecyclerView.Adapter<BindingHolder<ItemFollowRequestsHeaderBinding>>() {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemFollowRequestsHeaderBinding> {
|
||||
val binding = ItemFollowRequestsHeaderBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
viewType: Int
|
||||
): BindingHolder<ItemFollowRequestsHeaderBinding> {
|
||||
val binding = ItemFollowRequestsHeaderBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
return BindingHolder(binding)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(viewHolder: BindingHolder<ItemFollowRequestsHeaderBinding>, position: Int) {
|
||||
override fun onBindViewHolder(
|
||||
viewHolder: BindingHolder<ItemFollowRequestsHeaderBinding>,
|
||||
position: Int
|
||||
) {
|
||||
viewHolder.binding.root.text = viewHolder.binding.root.context.getString(R.string.follow_requests_info, instanceName)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -42,18 +42,29 @@ class MutesAdapter(
|
|||
private val mutingNotificationsMap = HashMap<String, Boolean>()
|
||||
|
||||
override fun createAccountViewHolder(parent: ViewGroup): BindingHolder<ItemMutedUserBinding> {
|
||||
val binding = ItemMutedUserBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
val binding = ItemMutedUserBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
return BindingHolder(binding)
|
||||
}
|
||||
|
||||
override fun onBindAccountViewHolder(viewHolder: BindingHolder<ItemMutedUserBinding>, position: Int) {
|
||||
override fun onBindAccountViewHolder(
|
||||
viewHolder: BindingHolder<ItemMutedUserBinding>,
|
||||
position: Int
|
||||
) {
|
||||
val account = accountList[position]
|
||||
val binding = viewHolder.binding
|
||||
val context = binding.root.context
|
||||
|
||||
val mutingNotifications = mutingNotificationsMap[account.id]
|
||||
|
||||
val emojifiedName = account.name.emojify(account.emojis, binding.mutedUserDisplayName, animateEmojis)
|
||||
val emojifiedName = account.name.emojify(
|
||||
account.emojis,
|
||||
binding.mutedUserDisplayName,
|
||||
animateEmojis
|
||||
)
|
||||
binding.mutedUserDisplayName.text = emojifiedName
|
||||
|
||||
val formattedUsername = context.getString(R.string.post_username_format, account.username)
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
|
||||
package com.keylesspalace.tusky.components.announcements
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Build
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.view.ContextThemeWrapper
|
||||
|
|
@ -29,13 +30,13 @@ import com.keylesspalace.tusky.R
|
|||
import com.keylesspalace.tusky.databinding.ItemAnnouncementBinding
|
||||
import com.keylesspalace.tusky.entity.Announcement
|
||||
import com.keylesspalace.tusky.interfaces.LinkListener
|
||||
import com.keylesspalace.tusky.util.AbsoluteTimeFormatter
|
||||
import com.keylesspalace.tusky.util.BindingHolder
|
||||
import com.keylesspalace.tusky.util.EmojiSpan
|
||||
import com.keylesspalace.tusky.util.emojify
|
||||
import com.keylesspalace.tusky.util.parseAsMastodonHtml
|
||||
import com.keylesspalace.tusky.util.setClickableText
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
import java.lang.ref.WeakReference
|
||||
|
||||
interface AnnouncementActionListener : LinkListener {
|
||||
fun openReactionPicker(announcementId: String, target: View)
|
||||
|
|
@ -50,19 +51,35 @@ class AnnouncementAdapter(
|
|||
private val animateEmojis: Boolean = false
|
||||
) : RecyclerView.Adapter<BindingHolder<ItemAnnouncementBinding>>() {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemAnnouncementBinding> {
|
||||
val binding = ItemAnnouncementBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
private val absoluteTimeFormatter = AbsoluteTimeFormatter()
|
||||
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
viewType: Int
|
||||
): BindingHolder<ItemAnnouncementBinding> {
|
||||
val binding = ItemAnnouncementBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
return BindingHolder(binding)
|
||||
}
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
override fun onBindViewHolder(holder: BindingHolder<ItemAnnouncementBinding>, position: Int) {
|
||||
val item = items[position]
|
||||
|
||||
holder.binding.announcementDate.text = absoluteTimeFormatter.format(item.publishedAt, false)
|
||||
|
||||
val text = holder.binding.text
|
||||
val chips = holder.binding.chipGroup
|
||||
val addReactionChip = holder.binding.addReactionChip
|
||||
|
||||
val emojifiedText: CharSequence = item.content.parseAsMastodonHtml().emojify(item.emojis, text, animateEmojis)
|
||||
val emojifiedText: CharSequence = item.content.parseAsMastodonHtml().emojify(
|
||||
item.emojis,
|
||||
text,
|
||||
animateEmojis
|
||||
)
|
||||
|
||||
setClickableText(text, emojifiedText, item.mentions, item.tags, listener)
|
||||
|
||||
|
|
@ -93,14 +110,20 @@ class AnnouncementAdapter(
|
|||
// we set the EmojiSpan on a space, because otherwise the Chip won't have the right size
|
||||
// https://github.com/tuskyapp/Tusky/issues/2308
|
||||
val spanBuilder = SpannableStringBuilder(" ${reaction.count}")
|
||||
val span = EmojiSpan(WeakReference(this))
|
||||
val span = EmojiSpan(this)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
span.contentDescription = reaction.name
|
||||
}
|
||||
spanBuilder.setSpan(span, 0, 1, 0)
|
||||
Glide.with(this)
|
||||
.asDrawable()
|
||||
.load(if (animateEmojis) { reaction.url } else { reaction.staticUrl })
|
||||
.load(
|
||||
if (animateEmojis) {
|
||||
reaction.url
|
||||
} else {
|
||||
reaction.staticUrl
|
||||
}
|
||||
)
|
||||
.into(span.getTarget(animateEmojis))
|
||||
this.text = spanBuilder
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import android.view.View
|
|||
import android.widget.PopupWindow
|
||||
import androidx.activity.viewModels
|
||||
import androidx.core.view.MenuProvider
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
|
|
@ -44,6 +45,7 @@ import com.keylesspalace.tusky.util.Loading
|
|||
import com.keylesspalace.tusky.util.Success
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
|
||||
import com.keylesspalace.tusky.util.unsafeLazy
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.view.EmojiPicker
|
||||
|
|
@ -52,6 +54,7 @@ import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
|
|||
import com.mikepenz.iconics.utils.colorInt
|
||||
import com.mikepenz.iconics.utils.sizeDp
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class AnnouncementsActivity :
|
||||
BottomSheetActivity(),
|
||||
|
|
@ -110,35 +113,46 @@ class AnnouncementsActivity :
|
|||
|
||||
binding.announcementsList.adapter = adapter
|
||||
|
||||
viewModel.announcements.observe(this) {
|
||||
when (it) {
|
||||
is Success -> {
|
||||
binding.progressBar.hide()
|
||||
binding.swipeRefreshLayout.isRefreshing = false
|
||||
if (it.data.isNullOrEmpty()) {
|
||||
binding.errorMessageView.setup(R.drawable.elephant_friend_empty, R.string.no_announcements)
|
||||
binding.errorMessageView.show()
|
||||
} else {
|
||||
lifecycleScope.launch {
|
||||
viewModel.announcements.collect {
|
||||
if (it == null) return@collect
|
||||
when (it) {
|
||||
is Success -> {
|
||||
binding.progressBar.hide()
|
||||
binding.swipeRefreshLayout.isRefreshing = false
|
||||
if (it.data.isNullOrEmpty()) {
|
||||
binding.errorMessageView.setup(
|
||||
R.drawable.elephant_friend_empty,
|
||||
R.string.no_announcements
|
||||
)
|
||||
binding.errorMessageView.show()
|
||||
} else {
|
||||
binding.errorMessageView.hide()
|
||||
}
|
||||
adapter.updateList(it.data ?: listOf())
|
||||
}
|
||||
is Loading -> {
|
||||
binding.errorMessageView.hide()
|
||||
}
|
||||
adapter.updateList(it.data ?: listOf())
|
||||
}
|
||||
is Loading -> {
|
||||
binding.errorMessageView.hide()
|
||||
}
|
||||
is Error -> {
|
||||
binding.progressBar.hide()
|
||||
binding.swipeRefreshLayout.isRefreshing = false
|
||||
binding.errorMessageView.setup(R.drawable.elephant_error, R.string.error_generic) {
|
||||
refreshAnnouncements()
|
||||
is Error -> {
|
||||
binding.progressBar.hide()
|
||||
binding.swipeRefreshLayout.isRefreshing = false
|
||||
binding.errorMessageView.setup(
|
||||
R.drawable.errorphant_error,
|
||||
R.string.error_generic
|
||||
) {
|
||||
refreshAnnouncements()
|
||||
}
|
||||
binding.errorMessageView.show()
|
||||
}
|
||||
binding.errorMessageView.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.emojis.observe(this) {
|
||||
picker.adapter = EmojiAdapter(it, this, animateEmojis)
|
||||
lifecycleScope.launch {
|
||||
viewModel.emoji.collect {
|
||||
picker.adapter = EmojiAdapter(it, this@AnnouncementsActivity, animateEmojis)
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.load()
|
||||
|
|
|
|||
|
|
@ -16,8 +16,6 @@
|
|||
package com.keylesspalace.tusky.components.announcements
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
|
|
@ -31,8 +29,11 @@ import com.keylesspalace.tusky.util.Error
|
|||
import com.keylesspalace.tusky.util.Loading
|
||||
import com.keylesspalace.tusky.util.Resource
|
||||
import com.keylesspalace.tusky.util.Success
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class AnnouncementsViewModel @Inject constructor(
|
||||
private val instanceInfoRepo: InstanceInfoRepository,
|
||||
|
|
@ -40,31 +41,33 @@ class AnnouncementsViewModel @Inject constructor(
|
|||
private val eventHub: EventHub
|
||||
) : ViewModel() {
|
||||
|
||||
private val announcementsMutable = MutableLiveData<Resource<List<Announcement>>>()
|
||||
val announcements: LiveData<Resource<List<Announcement>>> = announcementsMutable
|
||||
private val _announcements = MutableStateFlow(null as Resource<List<Announcement>>?)
|
||||
val announcements: StateFlow<Resource<List<Announcement>>?> = _announcements.asStateFlow()
|
||||
|
||||
private val emojisMutable = MutableLiveData<List<Emoji>>()
|
||||
val emojis: LiveData<List<Emoji>> = emojisMutable
|
||||
private val _emoji = MutableStateFlow(emptyList<Emoji>())
|
||||
val emoji: StateFlow<List<Emoji>> = _emoji.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
emojisMutable.postValue(instanceInfoRepo.getEmojis())
|
||||
_emoji.value = instanceInfoRepo.getEmojis()
|
||||
}
|
||||
}
|
||||
|
||||
fun load() {
|
||||
viewModelScope.launch {
|
||||
announcementsMutable.postValue(Loading())
|
||||
_announcements.value = Loading()
|
||||
mastodonApi.listAnnouncements()
|
||||
.fold(
|
||||
{
|
||||
announcementsMutable.postValue(Success(it))
|
||||
_announcements.value = Success(it)
|
||||
it.filter { announcement -> !announcement.read }
|
||||
.forEach { announcement ->
|
||||
mastodonApi.dismissAnnouncement(announcement.id)
|
||||
.fold(
|
||||
{
|
||||
eventHub.dispatch(AnnouncementReadEvent(announcement.id))
|
||||
eventHub.dispatch(
|
||||
AnnouncementReadEvent(announcement.id)
|
||||
)
|
||||
},
|
||||
{ throwable ->
|
||||
Log.d(
|
||||
|
|
@ -77,7 +80,7 @@ class AnnouncementsViewModel @Inject constructor(
|
|||
}
|
||||
},
|
||||
{
|
||||
announcementsMutable.postValue(Error(cause = it))
|
||||
_announcements.value = Error(cause = it)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -88,9 +91,9 @@ class AnnouncementsViewModel @Inject constructor(
|
|||
mastodonApi.addAnnouncementReaction(announcementId, name)
|
||||
.fold(
|
||||
{
|
||||
announcementsMutable.postValue(
|
||||
_announcements.value =
|
||||
Success(
|
||||
announcements.value!!.data!!.map { announcement ->
|
||||
announcements.value?.data?.map { announcement ->
|
||||
if (announcement.id == announcementId) {
|
||||
announcement.copy(
|
||||
reactions = if (announcement.reactions.find { reaction -> reaction.name == name } != null) {
|
||||
|
|
@ -107,7 +110,7 @@ class AnnouncementsViewModel @Inject constructor(
|
|||
} else {
|
||||
listOf(
|
||||
*announcement.reactions.toTypedArray(),
|
||||
emojis.value!!.find { emoji -> emoji.shortcode == name }!!.run {
|
||||
emoji.value.find { emoji -> emoji.shortcode == name }!!.run {
|
||||
Announcement.Reaction(
|
||||
name,
|
||||
1,
|
||||
|
|
@ -124,7 +127,6 @@ class AnnouncementsViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
},
|
||||
{
|
||||
Log.w(TAG, "Failed to add reaction to the announcement.", it)
|
||||
|
|
@ -138,7 +140,7 @@ class AnnouncementsViewModel @Inject constructor(
|
|||
mastodonApi.removeAnnouncementReaction(announcementId, name)
|
||||
.fold(
|
||||
{
|
||||
announcementsMutable.postValue(
|
||||
_announcements.value =
|
||||
Success(
|
||||
announcements.value!!.data!!.map { announcement ->
|
||||
if (announcement.id == announcementId) {
|
||||
|
|
@ -163,7 +165,6 @@ class AnnouncementsViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
},
|
||||
{
|
||||
Log.w(TAG, "Failed to remove reaction from the announcement.", it)
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@
|
|||
package com.keylesspalace.tusky.components.compose
|
||||
|
||||
import android.Manifest
|
||||
import android.app.NotificationManager
|
||||
import android.app.ProgressDialog
|
||||
import android.content.ClipData
|
||||
import android.content.Context
|
||||
|
|
@ -26,6 +25,7 @@ import android.content.pm.PackageManager
|
|||
import android.graphics.Bitmap
|
||||
import android.graphics.PorterDuff
|
||||
import android.graphics.PorterDuffColorFilter
|
||||
import android.icu.text.BreakIterator
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
|
|
@ -70,6 +70,7 @@ import com.canhub.cropper.CropImage
|
|||
import com.canhub.cropper.CropImageContract
|
||||
import com.canhub.cropper.options
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.keylesspalace.tusky.BaseActivity
|
||||
|
|
@ -94,8 +95,9 @@ import com.keylesspalace.tusky.entity.Attachment
|
|||
import com.keylesspalace.tusky.entity.Emoji
|
||||
import com.keylesspalace.tusky.entity.NewPoll
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.settings.AppTheme
|
||||
import com.keylesspalace.tusky.settings.PrefKeys
|
||||
import com.keylesspalace.tusky.util.APP_THEME_DEFAULT
|
||||
import com.keylesspalace.tusky.settings.PrefKeys.APP_THEME
|
||||
import com.keylesspalace.tusky.util.MentionSpan
|
||||
import com.keylesspalace.tusky.util.PickMediaFiles
|
||||
import com.keylesspalace.tusky.util.getInitialLanguages
|
||||
|
|
@ -114,11 +116,6 @@ import com.mikepenz.iconics.IconicsDrawable
|
|||
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
|
||||
import com.mikepenz.iconics.utils.colorInt
|
||||
import com.mikepenz.iconics.utils.sizeDp
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.text.DecimalFormat
|
||||
|
|
@ -126,6 +123,11 @@ import java.util.Locale
|
|||
import javax.inject.Inject
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
class ComposeActivity :
|
||||
BaseActivity(),
|
||||
|
|
@ -162,14 +164,23 @@ class ComposeActivity :
|
|||
|
||||
private var maxUploadMediaNumber = InstanceInfoRepository.DEFAULT_MAX_MEDIA_ATTACHMENTS
|
||||
|
||||
private val takePicture = registerForActivityResult(ActivityResultContracts.TakePicture()) { success ->
|
||||
if (success) {
|
||||
pickMedia(photoUploadUri!!)
|
||||
private val takePicture =
|
||||
registerForActivityResult(ActivityResultContracts.TakePicture()) { success ->
|
||||
if (success) {
|
||||
pickMedia(photoUploadUri!!)
|
||||
}
|
||||
}
|
||||
}
|
||||
private val pickMediaFile = registerForActivityResult(PickMediaFiles()) { uris ->
|
||||
if (viewModel.media.value.size + uris.size > maxUploadMediaNumber) {
|
||||
Toast.makeText(this, resources.getQuantityString(R.plurals.error_upload_max_media_reached, maxUploadMediaNumber, maxUploadMediaNumber), Toast.LENGTH_SHORT).show()
|
||||
Toast.makeText(
|
||||
this,
|
||||
resources.getQuantityString(
|
||||
R.plurals.error_upload_max_media_reached,
|
||||
maxUploadMediaNumber,
|
||||
maxUploadMediaNumber
|
||||
),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
} else {
|
||||
uris.forEach { uri ->
|
||||
pickMedia(uri)
|
||||
|
|
@ -190,7 +201,8 @@ class ComposeActivity :
|
|||
uriNew,
|
||||
size,
|
||||
itemOld.description,
|
||||
null, // Intentionally reset focus when cropping
|
||||
// Intentionally reset focus when cropping
|
||||
null,
|
||||
itemOld
|
||||
)
|
||||
}
|
||||
|
|
@ -204,27 +216,30 @@ class ComposeActivity :
|
|||
viewModel.cropImageItemOld = null
|
||||
}
|
||||
|
||||
private val onBackPressedCallback = object : OnBackPressedCallback(false) {
|
||||
override fun handleOnBackPressed() {
|
||||
if (composeOptionsBehavior.state == BottomSheetBehavior.STATE_EXPANDED ||
|
||||
addMediaBehavior.state == BottomSheetBehavior.STATE_EXPANDED ||
|
||||
emojiBehavior.state == BottomSheetBehavior.STATE_EXPANDED ||
|
||||
scheduleBehavior.state == BottomSheetBehavior.STATE_EXPANDED
|
||||
) {
|
||||
composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
return
|
||||
}
|
||||
|
||||
handleCloseButton()
|
||||
}
|
||||
}
|
||||
|
||||
public override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val notificationId = intent.getIntExtra(NOTIFICATION_ID_EXTRA, -1)
|
||||
if (notificationId != -1) {
|
||||
// ComposeActivity was opened from a notification, delete the notification
|
||||
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||
notificationManager.cancel(notificationId)
|
||||
}
|
||||
activeAccount = accountManager.activeAccount ?: return
|
||||
|
||||
// If started from an intent then compose as the account ID from the intent.
|
||||
// Otherwise use the active account. If null then the user is not logged in,
|
||||
// and return from the activity.
|
||||
val intentAccountId = intent.getLongExtra(ACCOUNT_ID_EXTRA, -1)
|
||||
activeAccount = if (intentAccountId != -1L) {
|
||||
accountManager.getAccountById(intentAccountId)
|
||||
} else {
|
||||
accountManager.activeAccount
|
||||
} ?: return
|
||||
|
||||
val theme = preferences.getString("appTheme", APP_THEME_DEFAULT)
|
||||
val theme = preferences.getString(APP_THEME, AppTheme.DEFAULT.value)
|
||||
if (theme == "black") {
|
||||
setTheme(R.style.TuskyDialogActivityBlackTheme)
|
||||
}
|
||||
|
|
@ -236,7 +251,11 @@ class ComposeActivity :
|
|||
val mediaAdapter = MediaPreviewAdapter(
|
||||
this,
|
||||
onAddCaption = { item ->
|
||||
CaptionDialog.newInstance(item.localId, item.description, item.uri).show(supportFragmentManager, "caption_dialog")
|
||||
CaptionDialog.newInstance(
|
||||
item.localId,
|
||||
item.description,
|
||||
item.uri
|
||||
).show(supportFragmentManager, "caption_dialog")
|
||||
},
|
||||
onAddFocus = { item ->
|
||||
makeFocusDialog(item.focus, item.uri) { newFocus ->
|
||||
|
|
@ -254,7 +273,11 @@ class ComposeActivity :
|
|||
|
||||
/* If the composer is started up as a reply to another post, override the "starting" state
|
||||
* based on what the intent from the reply request passes. */
|
||||
val composeOptions: ComposeOptions? = IntentCompat.getParcelableExtra(intent, COMPOSE_OPTIONS_EXTRA, ComposeOptions::class.java)
|
||||
val composeOptions: ComposeOptions? = IntentCompat.getParcelableExtra(
|
||||
intent,
|
||||
COMPOSE_OPTIONS_EXTRA,
|
||||
ComposeOptions::class.java
|
||||
)
|
||||
viewModel.setup(composeOptions)
|
||||
|
||||
setupButtons()
|
||||
|
|
@ -280,7 +303,7 @@ class ComposeActivity :
|
|||
binding.composeScheduleView.setDateTime(composeOptions?.scheduledAt)
|
||||
}
|
||||
|
||||
setupLanguageSpinner(getInitialLanguages(composeOptions?.language, accountManager.activeAccount))
|
||||
setupLanguageSpinner(getInitialLanguages(composeOptions?.language, activeAccount))
|
||||
setupComposeField(preferences, viewModel.startingText)
|
||||
setupContentWarningField(composeOptions?.contentWarning)
|
||||
setupPollView()
|
||||
|
|
@ -317,12 +340,20 @@ class ComposeActivity :
|
|||
if (type.startsWith("image/") || type.startsWith("video/") || type.startsWith("audio/")) {
|
||||
when (intent.action) {
|
||||
Intent.ACTION_SEND -> {
|
||||
IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)?.let { uri ->
|
||||
IntentCompat.getParcelableExtra(
|
||||
intent,
|
||||
Intent.EXTRA_STREAM,
|
||||
Uri::class.java
|
||||
)?.let { uri ->
|
||||
pickMedia(uri)
|
||||
}
|
||||
}
|
||||
Intent.ACTION_SEND_MULTIPLE -> {
|
||||
IntentCompat.getParcelableArrayListExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)?.forEach { uri ->
|
||||
IntentCompat.getParcelableArrayListExtra(
|
||||
intent,
|
||||
Intent.EXTRA_STREAM,
|
||||
Uri::class.java
|
||||
)?.forEach { uri ->
|
||||
pickMedia(uri)
|
||||
}
|
||||
}
|
||||
|
|
@ -342,7 +373,13 @@ class ComposeActivity :
|
|||
val end = binding.composeEditField.selectionEnd.coerceAtLeast(0)
|
||||
val left = min(start, end)
|
||||
val right = max(start, end)
|
||||
binding.composeEditField.text.replace(left, right, shareBody, 0, shareBody.length)
|
||||
binding.composeEditField.text.replace(
|
||||
left,
|
||||
right,
|
||||
shareBody,
|
||||
0,
|
||||
shareBody.length
|
||||
)
|
||||
// move edittext cursor to first when shareBody parsed
|
||||
binding.composeEditField.text.insert(0, "\n")
|
||||
binding.composeEditField.setSelection(0)
|
||||
|
|
@ -355,23 +392,48 @@ class ComposeActivity :
|
|||
if (replyingStatusAuthor != null) {
|
||||
binding.composeReplyView.show()
|
||||
binding.composeReplyView.text = getString(R.string.replying_to, replyingStatusAuthor)
|
||||
val arrowDownIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_arrow_drop_down).apply { sizeDp = 12 }
|
||||
val arrowDownIcon = IconicsDrawable(
|
||||
this,
|
||||
GoogleMaterial.Icon.gmd_arrow_drop_down
|
||||
).apply {
|
||||
sizeDp = 12
|
||||
}
|
||||
|
||||
setDrawableTint(this, arrowDownIcon, android.R.attr.textColorTertiary)
|
||||
binding.composeReplyView.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, arrowDownIcon, null)
|
||||
binding.composeReplyView.setCompoundDrawablesRelativeWithIntrinsicBounds(
|
||||
null,
|
||||
null,
|
||||
arrowDownIcon,
|
||||
null
|
||||
)
|
||||
|
||||
binding.composeReplyView.setOnClickListener {
|
||||
TransitionManager.beginDelayedTransition(binding.composeReplyContentView.parent as ViewGroup)
|
||||
TransitionManager.beginDelayedTransition(
|
||||
binding.composeReplyContentView.parent as ViewGroup
|
||||
)
|
||||
|
||||
if (binding.composeReplyContentView.isVisible) {
|
||||
binding.composeReplyContentView.hide()
|
||||
binding.composeReplyView.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, arrowDownIcon, null)
|
||||
binding.composeReplyView.setCompoundDrawablesRelativeWithIntrinsicBounds(
|
||||
null,
|
||||
null,
|
||||
arrowDownIcon,
|
||||
null
|
||||
)
|
||||
} else {
|
||||
binding.composeReplyContentView.show()
|
||||
val arrowUpIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_arrow_drop_up).apply { sizeDp = 12 }
|
||||
val arrowUpIcon = IconicsDrawable(
|
||||
this,
|
||||
GoogleMaterial.Icon.gmd_arrow_drop_up
|
||||
).apply { sizeDp = 12 }
|
||||
|
||||
setDrawableTint(this, arrowUpIcon, android.R.attr.textColorTertiary)
|
||||
binding.composeReplyView.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, arrowUpIcon, null)
|
||||
binding.composeReplyView.setCompoundDrawablesRelativeWithIntrinsicBounds(
|
||||
null,
|
||||
null,
|
||||
arrowUpIcon,
|
||||
null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -382,13 +444,21 @@ class ComposeActivity :
|
|||
if (startingContentWarning != null) {
|
||||
binding.composeContentWarningField.setText(startingContentWarning)
|
||||
}
|
||||
binding.composeContentWarningField.doOnTextChanged { _, _, _, _ -> updateVisibleCharactersLeft() }
|
||||
binding.composeContentWarningField.doOnTextChanged { newContentWarning, _, _, _ ->
|
||||
updateVisibleCharactersLeft()
|
||||
viewModel.updateContentWarning(newContentWarning?.toString())
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupComposeField(preferences: SharedPreferences, startingText: String?) {
|
||||
binding.composeEditField.setOnReceiveContentListener(this)
|
||||
|
||||
binding.composeEditField.setOnKeyListener { _, keyCode, event -> this.onKeyDown(keyCode, event) }
|
||||
binding.composeEditField.setOnKeyListener { _, keyCode, event ->
|
||||
this.onKeyDown(
|
||||
keyCode,
|
||||
event
|
||||
)
|
||||
}
|
||||
|
||||
binding.composeEditField.setAdapter(
|
||||
ComposeAutoCompleteAdapter(
|
||||
|
|
@ -408,6 +478,7 @@ class ComposeActivity :
|
|||
binding.composeEditField.doAfterTextChanged { editable ->
|
||||
highlightSpans(editable!!, mentionColour)
|
||||
updateVisibleCharactersLeft()
|
||||
viewModel.updateContent(editable.toString())
|
||||
}
|
||||
|
||||
// work around Android platform bug -> https://issuetracker.google.com/issues/67102093
|
||||
|
|
@ -433,7 +504,9 @@ class ComposeActivity :
|
|||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
viewModel.showContentWarning.combine(viewModel.markMediaAsSensitive) { showContentWarning, markSensitive ->
|
||||
viewModel.showContentWarning.combine(
|
||||
viewModel.markMediaAsSensitive
|
||||
) { showContentWarning, markSensitive ->
|
||||
updateSensitiveMediaToggle(markSensitive, showContentWarning)
|
||||
showContentWarning(showContentWarning)
|
||||
}.collect()
|
||||
|
|
@ -448,7 +521,10 @@ class ComposeActivity :
|
|||
mediaAdapter.submitList(media)
|
||||
|
||||
binding.composeMediaPreviewBar.visible(media.isNotEmpty())
|
||||
updateSensitiveMediaToggle(viewModel.markMediaAsSensitive.value, viewModel.showContentWarning.value)
|
||||
updateSensitiveMediaToggle(
|
||||
viewModel.markMediaAsSensitive.value,
|
||||
viewModel.showContentWarning.value
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -485,10 +561,21 @@ class ComposeActivity :
|
|||
if (throwable is UploadServerError) {
|
||||
displayTransientMessage(throwable.errorMessage)
|
||||
} else {
|
||||
displayTransientMessage(R.string.error_media_upload_sending)
|
||||
displayTransientMessage(
|
||||
getString(
|
||||
R.string.error_media_upload_sending_fmt,
|
||||
throwable.message
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
viewModel.closeConfirmation.collect {
|
||||
updateOnBackPressedCallbackState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupButtons() {
|
||||
|
|
@ -499,6 +586,17 @@ class ComposeActivity :
|
|||
scheduleBehavior = BottomSheetBehavior.from(binding.composeScheduleView)
|
||||
emojiBehavior = BottomSheetBehavior.from(binding.emojiView)
|
||||
|
||||
val bottomSheetCallback = object : BottomSheetCallback() {
|
||||
override fun onStateChanged(bottomSheet: View, newState: Int) {
|
||||
updateOnBackPressedCallbackState()
|
||||
}
|
||||
override fun onSlide(bottomSheet: View, slideOffset: Float) { }
|
||||
}
|
||||
composeOptionsBehavior.addBottomSheetCallback(bottomSheetCallback)
|
||||
addMediaBehavior.addBottomSheetCallback(bottomSheetCallback)
|
||||
scheduleBehavior.addBottomSheetCallback(bottomSheetCallback)
|
||||
emojiBehavior.addBottomSheetCallback(bottomSheetCallback)
|
||||
|
||||
enableButton(binding.composeEmojiButton, clickable = false, colorActive = false)
|
||||
|
||||
// Setup the interface buttons.
|
||||
|
|
@ -519,46 +617,58 @@ class ComposeActivity :
|
|||
|
||||
val textColor = MaterialColors.getColor(binding.root, android.R.attr.textColorTertiary)
|
||||
|
||||
val cameraIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_camera_alt).apply { colorInt = textColor; sizeDp = 18 }
|
||||
binding.actionPhotoTake.setCompoundDrawablesRelativeWithIntrinsicBounds(cameraIcon, null, null, null)
|
||||
val cameraIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_camera_alt).apply {
|
||||
colorInt = textColor
|
||||
sizeDp = 18
|
||||
}
|
||||
binding.actionPhotoTake.setCompoundDrawablesRelativeWithIntrinsicBounds(
|
||||
cameraIcon,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)
|
||||
|
||||
val imageIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_image).apply { colorInt = textColor; sizeDp = 18 }
|
||||
binding.actionPhotoPick.setCompoundDrawablesRelativeWithIntrinsicBounds(imageIcon, null, null, null)
|
||||
val imageIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_image).apply {
|
||||
colorInt = textColor
|
||||
sizeDp = 18
|
||||
}
|
||||
binding.actionPhotoPick.setCompoundDrawablesRelativeWithIntrinsicBounds(
|
||||
imageIcon,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)
|
||||
|
||||
val pollIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_poll).apply { colorInt = textColor; sizeDp = 18 }
|
||||
binding.addPollTextActionTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(pollIcon, null, null, null)
|
||||
val pollIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_poll).apply {
|
||||
colorInt = textColor
|
||||
sizeDp = 18
|
||||
}
|
||||
binding.addPollTextActionTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(
|
||||
pollIcon,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)
|
||||
|
||||
binding.actionPhotoTake.visible(Intent(MediaStore.ACTION_IMAGE_CAPTURE).resolveActivity(packageManager) != null)
|
||||
binding.actionPhotoTake.visible(
|
||||
Intent(MediaStore.ACTION_IMAGE_CAPTURE).resolveActivity(packageManager) != null
|
||||
)
|
||||
|
||||
binding.actionPhotoTake.setOnClickListener { initiateCameraApp() }
|
||||
binding.actionPhotoPick.setOnClickListener { onMediaPick() }
|
||||
binding.addPollTextActionTextView.setOnClickListener { openPollDialog() }
|
||||
|
||||
onBackPressedDispatcher.addCallback(
|
||||
this,
|
||||
object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
if (composeOptionsBehavior.state == BottomSheetBehavior.STATE_EXPANDED ||
|
||||
addMediaBehavior.state == BottomSheetBehavior.STATE_EXPANDED ||
|
||||
emojiBehavior.state == BottomSheetBehavior.STATE_EXPANDED ||
|
||||
scheduleBehavior.state == BottomSheetBehavior.STATE_EXPANDED
|
||||
) {
|
||||
composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
return
|
||||
}
|
||||
|
||||
handleCloseButton()
|
||||
}
|
||||
}
|
||||
)
|
||||
onBackPressedDispatcher.addCallback(this, onBackPressedCallback)
|
||||
}
|
||||
|
||||
private fun setupLanguageSpinner(initialLanguages: List<String>) {
|
||||
binding.composePostLanguageButton.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
|
||||
override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) {
|
||||
override fun onItemSelected(
|
||||
parent: AdapterView<*>,
|
||||
view: View?,
|
||||
position: Int,
|
||||
id: Long
|
||||
) {
|
||||
viewModel.postLanguage = (parent.adapter.getItem(position) as Locale).modernLanguageCode
|
||||
}
|
||||
|
||||
|
|
@ -588,7 +698,7 @@ class ComposeActivity :
|
|||
a.getDimensionPixelSize(0, 1)
|
||||
}
|
||||
|
||||
val animateAvatars = preferences.getBoolean("animateGifAvatars", false)
|
||||
val animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false)
|
||||
loadAvatar(
|
||||
activeAccount.profilePictureUrl,
|
||||
binding.composeAvatar,
|
||||
|
|
@ -601,10 +711,23 @@ class ComposeActivity :
|
|||
)
|
||||
}
|
||||
|
||||
private fun updateOnBackPressedCallbackState() {
|
||||
val confirmation = viewModel.closeConfirmation.value
|
||||
onBackPressedCallback.isEnabled = confirmation != ConfirmationKind.NONE ||
|
||||
composeOptionsBehavior.state != BottomSheetBehavior.STATE_HIDDEN ||
|
||||
addMediaBehavior.state != BottomSheetBehavior.STATE_HIDDEN ||
|
||||
emojiBehavior.state != BottomSheetBehavior.STATE_HIDDEN ||
|
||||
scheduleBehavior.state != BottomSheetBehavior.STATE_HIDDEN
|
||||
}
|
||||
|
||||
private fun replaceTextAtCaret(text: CharSequence) {
|
||||
// If you select "backward" in an editable, you get SelectionStart > SelectionEnd
|
||||
val start = binding.composeEditField.selectionStart.coerceAtMost(binding.composeEditField.selectionEnd)
|
||||
val end = binding.composeEditField.selectionStart.coerceAtLeast(binding.composeEditField.selectionEnd)
|
||||
val start = binding.composeEditField.selectionStart.coerceAtMost(
|
||||
binding.composeEditField.selectionEnd
|
||||
)
|
||||
val end = binding.composeEditField.selectionStart.coerceAtLeast(
|
||||
binding.composeEditField.selectionEnd
|
||||
)
|
||||
val textToInsert = if (start > 0 && !binding.composeEditField.text[start - 1].isWhitespace()) {
|
||||
" $text"
|
||||
} else {
|
||||
|
|
@ -618,8 +741,12 @@ class ComposeActivity :
|
|||
|
||||
fun prependSelectedWordsWith(text: CharSequence) {
|
||||
// If you select "backward" in an editable, you get SelectionStart > SelectionEnd
|
||||
val start = binding.composeEditField.selectionStart.coerceAtMost(binding.composeEditField.selectionEnd)
|
||||
val end = binding.composeEditField.selectionStart.coerceAtLeast(binding.composeEditField.selectionEnd)
|
||||
val start = binding.composeEditField.selectionStart.coerceAtMost(
|
||||
binding.composeEditField.selectionEnd
|
||||
)
|
||||
val end = binding.composeEditField.selectionStart.coerceAtLeast(
|
||||
binding.composeEditField.selectionEnd
|
||||
)
|
||||
val editorText = binding.composeEditField.text
|
||||
|
||||
if (start == end) {
|
||||
|
|
@ -687,7 +814,10 @@ class ComposeActivity :
|
|||
this.viewModel.toggleMarkSensitive()
|
||||
}
|
||||
|
||||
private fun updateSensitiveMediaToggle(markMediaSensitive: Boolean, contentWarningShown: Boolean) {
|
||||
private fun updateSensitiveMediaToggle(
|
||||
markMediaSensitive: Boolean,
|
||||
contentWarningShown: Boolean
|
||||
) {
|
||||
if (viewModel.media.value.isEmpty()) {
|
||||
binding.composeHideMediaButton.hide()
|
||||
binding.descriptionMissingWarningButton.hide()
|
||||
|
|
@ -704,7 +834,10 @@ class ComposeActivity :
|
|||
getColor(R.color.chinwag_green)
|
||||
} else {
|
||||
binding.composeHideMediaButton.setImageResource(R.drawable.ic_eye_24dp)
|
||||
MaterialColors.getColor(binding.composeHideMediaButton, android.R.attr.textColorTertiary)
|
||||
MaterialColors.getColor(
|
||||
binding.composeHideMediaButton,
|
||||
android.R.attr.textColorTertiary
|
||||
)
|
||||
}
|
||||
}
|
||||
binding.composeHideMediaButton.drawable.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN)
|
||||
|
|
@ -726,7 +859,10 @@ class ComposeActivity :
|
|||
enableButton(binding.composeScheduleButton, clickable = false, colorActive = false)
|
||||
} else {
|
||||
@ColorInt val color = if (binding.composeScheduleView.time == null) {
|
||||
MaterialColors.getColor(binding.composeScheduleButton, android.R.attr.textColorTertiary)
|
||||
MaterialColors.getColor(
|
||||
binding.composeScheduleButton,
|
||||
android.R.attr.textColorTertiary
|
||||
)
|
||||
} else {
|
||||
getColor(R.color.chinwag_green)
|
||||
}
|
||||
|
|
@ -757,7 +893,11 @@ class ComposeActivity :
|
|||
binding.composeToggleVisibilityButton.setImageResource(iconRes)
|
||||
if (viewModel.editing) {
|
||||
// Can't update visibility on published status
|
||||
enableButton(binding.composeToggleVisibilityButton, clickable = false, colorActive = false)
|
||||
enableButton(
|
||||
binding.composeToggleVisibilityButton,
|
||||
clickable = false,
|
||||
colorActive = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -794,7 +934,11 @@ class ComposeActivity :
|
|||
private fun showEmojis() {
|
||||
binding.emojiView.adapter?.let {
|
||||
if (it.itemCount == 0) {
|
||||
val errorMessage = getString(R.string.error_no_custom_emojis, accountManager.activeAccount!!.domain)
|
||||
val errorMessage =
|
||||
getString(
|
||||
R.string.error_no_custom_emojis,
|
||||
accountManager.activeAccount!!.domain
|
||||
)
|
||||
displayTransientMessage(errorMessage)
|
||||
} else {
|
||||
if (emojiBehavior.state == BottomSheetBehavior.STATE_HIDDEN || emojiBehavior.state == BottomSheetBehavior.STATE_COLLAPSED) {
|
||||
|
|
@ -822,7 +966,7 @@ class ComposeActivity :
|
|||
|
||||
private fun onMediaPick() {
|
||||
addMediaBehavior.addBottomSheetCallback(
|
||||
object : BottomSheetBehavior.BottomSheetCallback() {
|
||||
object : BottomSheetCallback() {
|
||||
override fun onStateChanged(bottomSheet: View, newState: Int) {
|
||||
// Wait until bottom sheet is not collapsed and show next screen after
|
||||
if (newState == BottomSheetBehavior.STATE_COLLAPSED) {
|
||||
|
|
@ -861,9 +1005,14 @@ class ComposeActivity :
|
|||
|
||||
private fun setupPollView() {
|
||||
val margin = resources.getDimensionPixelSize(R.dimen.compose_media_preview_margin)
|
||||
val marginBottom = resources.getDimensionPixelSize(R.dimen.compose_media_preview_margin_bottom)
|
||||
val marginBottom = resources.getDimensionPixelSize(
|
||||
R.dimen.compose_media_preview_margin_bottom
|
||||
)
|
||||
|
||||
val layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||
val layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
layoutParams.setMargins(margin, margin, margin, marginBottom)
|
||||
binding.pollPreview.layoutParams = layoutParams
|
||||
|
||||
|
|
@ -885,13 +1034,13 @@ class ComposeActivity :
|
|||
}
|
||||
|
||||
private fun removePoll() {
|
||||
viewModel.poll.value = null
|
||||
viewModel.updatePoll(null)
|
||||
binding.pollPreview.hide()
|
||||
}
|
||||
|
||||
override fun onVisibilityChanged(visibility: Status.Visibility) {
|
||||
composeOptionsBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
||||
viewModel.statusVisibility.value = visibility
|
||||
viewModel.changeStatusVisibility(visibility)
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
|
|
@ -914,7 +1063,10 @@ class ComposeActivity :
|
|||
val textColor = if (remainingLength < 0) {
|
||||
getColor(R.color.tusky_red)
|
||||
} else {
|
||||
MaterialColors.getColor(binding.composeCharactersLeftView, android.R.attr.textColorTertiary)
|
||||
MaterialColors.getColor(
|
||||
binding.composeCharactersLeftView,
|
||||
android.R.attr.textColorTertiary
|
||||
)
|
||||
}
|
||||
binding.composeCharactersLeftView.setTextColor(textColor)
|
||||
}
|
||||
|
|
@ -926,7 +1078,9 @@ class ComposeActivity :
|
|||
}
|
||||
|
||||
private fun verifyScheduledTime(): Boolean {
|
||||
return binding.composeScheduleView.verifyScheduledTime(binding.composeScheduleView.getDateTime(viewModel.scheduledAt.value))
|
||||
return binding.composeScheduleView.verifyScheduledTime(
|
||||
binding.composeScheduleView.getDateTime(viewModel.scheduledAt.value)
|
||||
)
|
||||
}
|
||||
|
||||
private fun onSendClicked() {
|
||||
|
|
@ -976,7 +1130,11 @@ class ComposeActivity :
|
|||
}
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray) {
|
||||
override fun onRequestPermissionsResult(
|
||||
requestCode: Int,
|
||||
permissions: Array<String>,
|
||||
grantResults: IntArray
|
||||
) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
|
||||
if (requestCode == PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE) {
|
||||
|
|
@ -1051,14 +1209,20 @@ class ComposeActivity :
|
|||
val tempFile = createNewImageFile(this, if (isPng) ".png" else ".jpg")
|
||||
|
||||
// "Authority" must be the same as the android:authorities string in AndroidManifest.xml
|
||||
val uriNew = FileProvider.getUriForFile(this, BuildConfig.APPLICATION_ID + ".fileprovider", tempFile)
|
||||
val uriNew = FileProvider.getUriForFile(
|
||||
this,
|
||||
BuildConfig.APPLICATION_ID + ".fileprovider",
|
||||
tempFile
|
||||
)
|
||||
|
||||
viewModel.cropImageItemOld = item
|
||||
|
||||
cropImage.launch(
|
||||
options(uri = item.uri) {
|
||||
setOutputUri(uriNew)
|
||||
setOutputCompressFormat(if (isPng) Bitmap.CompressFormat.PNG else Bitmap.CompressFormat.JPEG)
|
||||
setOutputCompressFormat(
|
||||
if (isPng) Bitmap.CompressFormat.PNG else Bitmap.CompressFormat.JPEG
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -1067,9 +1231,28 @@ class ComposeActivity :
|
|||
viewModel.removeMediaFromQueue(item)
|
||||
}
|
||||
|
||||
private fun sanitizePickMediaDescription(description: String?): String? {
|
||||
if (description == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
// The Gboard android keyboard attaches this text whenever the user
|
||||
// pastes something from the keyboard's suggestion bar.
|
||||
// Due to different end user locales, the exact text may vary, but at
|
||||
// least in version 13.4.08, all of the translations contained the
|
||||
// string "Gboard".
|
||||
if ("Gboard" in description) {
|
||||
return null
|
||||
}
|
||||
|
||||
return description
|
||||
}
|
||||
|
||||
private fun pickMedia(uri: Uri, description: String? = null) {
|
||||
val sanitizedDescription = sanitizePickMediaDescription(description)
|
||||
|
||||
lifecycleScope.launch {
|
||||
viewModel.pickMedia(uri, description).onFailure { throwable ->
|
||||
viewModel.pickMedia(uri, sanitizedDescription).onFailure { throwable ->
|
||||
val errorString = when (throwable) {
|
||||
is FileSizeException -> {
|
||||
val decimalFormat = DecimalFormat("0.##")
|
||||
|
|
@ -1077,7 +1260,9 @@ class ComposeActivity :
|
|||
val formattedSize = decimalFormat.format(allowedSizeInMb)
|
||||
getString(R.string.error_multimedia_size_limit, formattedSize)
|
||||
}
|
||||
is VideoOrImageException -> getString(R.string.error_media_upload_image_or_video)
|
||||
is VideoOrImageException -> getString(
|
||||
R.string.error_media_upload_image_or_video
|
||||
)
|
||||
else -> getString(R.string.error_media_upload_opening)
|
||||
}
|
||||
displayTransientMessage(errorString)
|
||||
|
|
@ -1086,16 +1271,23 @@ class ComposeActivity :
|
|||
}
|
||||
|
||||
private fun showContentWarning(show: Boolean) {
|
||||
TransitionManager.beginDelayedTransition(binding.composeContentWarningBar.parent as ViewGroup)
|
||||
TransitionManager.beginDelayedTransition(
|
||||
binding.composeContentWarningBar.parent as ViewGroup
|
||||
)
|
||||
@ColorInt val color = if (show) {
|
||||
binding.composeContentWarningBar.show()
|
||||
binding.composeContentWarningField.setSelection(binding.composeContentWarningField.text.length)
|
||||
binding.composeContentWarningField.setSelection(
|
||||
binding.composeContentWarningField.text.length
|
||||
)
|
||||
binding.composeContentWarningField.requestFocus()
|
||||
getColor(R.color.chinwag_green)
|
||||
} else {
|
||||
binding.composeContentWarningBar.hide()
|
||||
binding.composeEditField.requestFocus()
|
||||
MaterialColors.getColor(binding.composeContentWarningButton, android.R.attr.textColorTertiary)
|
||||
MaterialColors.getColor(
|
||||
binding.composeContentWarningButton,
|
||||
android.R.attr.textColorTertiary
|
||||
)
|
||||
}
|
||||
binding.composeContentWarningButton.drawable.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN)
|
||||
}
|
||||
|
|
@ -1130,10 +1322,10 @@ class ComposeActivity :
|
|||
private fun handleCloseButton() {
|
||||
val contentText = binding.composeEditField.text.toString()
|
||||
val contentWarning = binding.composeContentWarningField.text.toString()
|
||||
when (viewModel.handleCloseButton(contentText, contentWarning)) {
|
||||
when (viewModel.closeConfirmation.value) {
|
||||
ConfirmationKind.NONE -> {
|
||||
viewModel.stopUploads()
|
||||
finishWithoutSlideOutAnimation()
|
||||
finish()
|
||||
}
|
||||
ConfirmationKind.SAVE_OR_DISCARD ->
|
||||
getSaveAsDraftOrDiscardDialog(contentText, contentWarning).show()
|
||||
|
|
@ -1149,7 +1341,10 @@ class ComposeActivity :
|
|||
/**
|
||||
* User is editing a new post, and can either save the changes as a draft or discard them.
|
||||
*/
|
||||
private fun getSaveAsDraftOrDiscardDialog(contentText: String, contentWarning: String): AlertDialog.Builder {
|
||||
private fun getSaveAsDraftOrDiscardDialog(
|
||||
contentText: String,
|
||||
contentWarning: String
|
||||
): AlertDialog.Builder {
|
||||
val warning = if (viewModel.media.value.isNotEmpty()) {
|
||||
R.string.compose_save_draft_loses_media
|
||||
} else {
|
||||
|
|
@ -1172,7 +1367,10 @@ class ComposeActivity :
|
|||
* User is editing an existing draft, and can either update the draft with the new changes or
|
||||
* discard them.
|
||||
*/
|
||||
private fun getUpdateDraftOrDiscardDialog(contentText: String, contentWarning: String): AlertDialog.Builder {
|
||||
private fun getUpdateDraftOrDiscardDialog(
|
||||
contentText: String,
|
||||
contentWarning: String
|
||||
): AlertDialog.Builder {
|
||||
val warning = if (viewModel.media.value.isNotEmpty()) {
|
||||
R.string.compose_save_draft_loses_media
|
||||
} else {
|
||||
|
|
@ -1187,7 +1385,7 @@ class ComposeActivity :
|
|||
}
|
||||
.setNegativeButton(R.string.action_discard) { _, _ ->
|
||||
viewModel.stopUploads()
|
||||
finishWithoutSlideOutAnimation()
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1203,7 +1401,7 @@ class ComposeActivity :
|
|||
}
|
||||
.setNegativeButton(R.string.action_discard) { _, _ ->
|
||||
viewModel.stopUploads()
|
||||
finishWithoutSlideOutAnimation()
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1217,7 +1415,7 @@ class ComposeActivity :
|
|||
.setPositiveButton(R.string.action_delete) { _, _ ->
|
||||
viewModel.deleteDraft()
|
||||
viewModel.stopUploads()
|
||||
finishWithoutSlideOutAnimation()
|
||||
finish()
|
||||
}
|
||||
.setNegativeButton(R.string.action_continue_edit) { _, _ ->
|
||||
// Do nothing, dialog will dismiss, user can continue editing
|
||||
|
|
@ -1226,7 +1424,7 @@ class ComposeActivity :
|
|||
|
||||
private fun deleteDraftAndFinish() {
|
||||
viewModel.deleteDraft()
|
||||
finishWithoutSlideOutAnimation()
|
||||
finish()
|
||||
}
|
||||
|
||||
private fun saveDraftAndFinish(contentText: String, contentWarning: String) {
|
||||
|
|
@ -1244,7 +1442,7 @@ class ComposeActivity :
|
|||
}
|
||||
viewModel.saveDraft(contentText, contentWarning)
|
||||
dialog?.cancel()
|
||||
finishWithoutSlideOutAnimation()
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1276,10 +1474,15 @@ class ComposeActivity :
|
|||
val state: State
|
||||
) {
|
||||
enum class Type {
|
||||
IMAGE, VIDEO, AUDIO;
|
||||
IMAGE,
|
||||
VIDEO,
|
||||
AUDIO
|
||||
}
|
||||
enum class State {
|
||||
UPLOADING, UNPROCESSED, PROCESSED, PUBLISHED
|
||||
UPLOADING,
|
||||
UNPROCESSED,
|
||||
PROCESSED,
|
||||
PUBLISHED
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1350,8 +1553,6 @@ class ComposeActivity :
|
|||
private const val PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE = 1
|
||||
|
||||
internal const val COMPOSE_OPTIONS_EXTRA = "COMPOSE_OPTIONS"
|
||||
private const val NOTIFICATION_ID_EXTRA = "NOTIFICATION_ID"
|
||||
private const val ACCOUNT_ID_EXTRA = "ACCOUNT_ID"
|
||||
private const val PHOTO_UPLOAD_URI_KEY = "PHOTO_UPLOAD_URI"
|
||||
private const val VISIBILITY_KEY = "VISIBILITY"
|
||||
private const val SCHEDULED_TIME_KEY = "SCHEDULE"
|
||||
|
|
@ -1359,26 +1560,12 @@ class ComposeActivity :
|
|||
|
||||
/**
|
||||
* @param options ComposeOptions to configure the ComposeActivity
|
||||
* @param notificationId the id of the notification that starts the Activity
|
||||
* @param accountId the id of the account to compose with, null for the current account
|
||||
* @return an Intent to start the ComposeActivity
|
||||
*/
|
||||
@JvmStatic
|
||||
@JvmOverloads
|
||||
fun startIntent(
|
||||
context: Context,
|
||||
options: ComposeOptions,
|
||||
notificationId: Int? = null,
|
||||
accountId: Long? = null
|
||||
): Intent {
|
||||
fun startIntent(context: Context, options: ComposeOptions): Intent {
|
||||
return Intent(context, ComposeActivity::class.java).apply {
|
||||
putExtra(COMPOSE_OPTIONS_EXTRA, options)
|
||||
if (notificationId != null) {
|
||||
putExtra(NOTIFICATION_ID_EXTRA, notificationId)
|
||||
}
|
||||
if (accountId != null) {
|
||||
putExtra(ACCOUNT_ID_EXTRA, accountId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1410,7 +1597,7 @@ class ComposeActivity :
|
|||
*/
|
||||
@JvmStatic
|
||||
fun statusLength(body: Spanned, contentWarning: Spanned?, urlLength: Int): Int {
|
||||
var length = body.length - body.getSpans(0, body.length, URLSpan::class.java)
|
||||
var length = body.toString().perceivedCharacterLength() - body.getSpans(0, body.length, URLSpan::class.java)
|
||||
.fold(0) { acc, span ->
|
||||
// Accumulate a count of characters to be *ignored* in the final length
|
||||
acc + when (span) {
|
||||
|
|
@ -1423,15 +1610,25 @@ class ComposeActivity :
|
|||
}
|
||||
else -> {
|
||||
// Expected to be negative if the URL length < maxUrlLength
|
||||
span.url.length - urlLength
|
||||
span.url.perceivedCharacterLength() - urlLength
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Content warning text is treated as is, URLs or mentions there are not special
|
||||
contentWarning?.let { length += it.length }
|
||||
|
||||
contentWarning?.let { length += it.toString().perceivedCharacterLength() }
|
||||
return length
|
||||
}
|
||||
|
||||
// String.length would count emojis as multiple characters but Mastodon counts them as 1, so we need this workaround
|
||||
private fun String.perceivedCharacterLength(): Int {
|
||||
val breakIterator = BreakIterator.getCharacterInstance()
|
||||
breakIterator.setText(this)
|
||||
var count = 0
|
||||
while (breakIterator.next() != BreakIterator.DONE) {
|
||||
count++
|
||||
}
|
||||
return count
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -108,7 +108,9 @@ class ComposeAutoCompleteAdapter(
|
|||
val account = accountResult.account
|
||||
binding.username.text = context.getString(R.string.post_username_format, account.username)
|
||||
binding.displayName.text = account.name.emojify(account.emojis, binding.displayName, animateEmojis)
|
||||
val avatarRadius = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_42dp)
|
||||
val avatarRadius = context.resources.getDimensionPixelSize(
|
||||
R.dimen.avatar_radius_42dp
|
||||
)
|
||||
loadAvatar(
|
||||
account.avatar,
|
||||
binding.avatar,
|
||||
|
|
@ -143,12 +145,12 @@ class ComposeAutoCompleteAdapter(
|
|||
}
|
||||
}
|
||||
|
||||
sealed class AutocompleteResult {
|
||||
class AccountResult(val account: TimelineAccount) : AutocompleteResult()
|
||||
sealed interface AutocompleteResult {
|
||||
class AccountResult(val account: TimelineAccount) : AutocompleteResult
|
||||
|
||||
class HashtagResult(val hashtag: String) : AutocompleteResult()
|
||||
class HashtagResult(val hashtag: String) : AutocompleteResult
|
||||
|
||||
class EmojiResult(val emoji: Emoji) : AutocompleteResult()
|
||||
class EmojiResult(val emoji: Emoji) : AutocompleteResult
|
||||
}
|
||||
|
||||
interface AutocompletionProvider {
|
||||
|
|
|
|||
|
|
@ -38,22 +38,24 @@ import com.keylesspalace.tusky.service.MediaToSend
|
|||
import com.keylesspalace.tusky.service.ServiceClient
|
||||
import com.keylesspalace.tusky.service.StatusToSend
|
||||
import com.keylesspalace.tusky.util.randomAlphanumericString
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.shareIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
class ComposeViewModel @Inject constructor(
|
||||
private val api: MastodonApi,
|
||||
private val accountManager: AccountManager,
|
||||
|
|
@ -78,34 +80,61 @@ class ComposeViewModel @Inject constructor(
|
|||
private var modifiedInitialState: Boolean = false
|
||||
private var hasScheduledTimeChanged: Boolean = false
|
||||
|
||||
val instanceInfo: SharedFlow<InstanceInfo> = instanceInfoRepo::getInstanceInfo.asFlow()
|
||||
private var currentContent: String? = ""
|
||||
private var currentContentWarning: String? = ""
|
||||
|
||||
val instanceInfo: SharedFlow<InstanceInfo> = instanceInfoRepo::getUpdatedInstanceInfoOrFallback.asFlow()
|
||||
.shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1)
|
||||
|
||||
val emoji: SharedFlow<List<Emoji>> = instanceInfoRepo::getEmojis.asFlow()
|
||||
.shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1)
|
||||
|
||||
val markMediaAsSensitive: MutableStateFlow<Boolean> =
|
||||
private val _markMediaAsSensitive =
|
||||
MutableStateFlow(accountManager.activeAccount?.defaultMediaSensitivity ?: false)
|
||||
val markMediaAsSensitive: StateFlow<Boolean> = _markMediaAsSensitive.asStateFlow()
|
||||
|
||||
val statusVisibility: MutableStateFlow<Status.Visibility> = MutableStateFlow(Status.Visibility.UNKNOWN)
|
||||
val showContentWarning: MutableStateFlow<Boolean> = MutableStateFlow(false)
|
||||
val poll: MutableStateFlow<NewPoll?> = MutableStateFlow(null)
|
||||
val scheduledAt: MutableStateFlow<String?> = MutableStateFlow(null)
|
||||
private val _statusVisibility = MutableStateFlow(Status.Visibility.UNKNOWN)
|
||||
val statusVisibility: StateFlow<Status.Visibility> = _statusVisibility.asStateFlow()
|
||||
|
||||
val media: MutableStateFlow<List<QueuedMedia>> = MutableStateFlow(emptyList())
|
||||
val uploadError = MutableSharedFlow<Throwable>(replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
private val _showContentWarning = MutableStateFlow(false)
|
||||
val showContentWarning: StateFlow<Boolean> = _showContentWarning.asStateFlow()
|
||||
|
||||
lateinit var composeKind: ComposeKind
|
||||
private val _poll = MutableStateFlow(null as NewPoll?)
|
||||
val poll: StateFlow<NewPoll?> = _poll.asStateFlow()
|
||||
|
||||
private val _scheduledAt = MutableStateFlow(null as String?)
|
||||
val scheduledAt: StateFlow<String?> = _scheduledAt.asStateFlow()
|
||||
|
||||
private val _media = MutableStateFlow(emptyList<QueuedMedia>())
|
||||
val media: StateFlow<List<QueuedMedia>> = _media.asStateFlow()
|
||||
|
||||
private val _uploadError = MutableSharedFlow<Throwable>(
|
||||
replay = 0,
|
||||
extraBufferCapacity = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||
)
|
||||
val uploadError: SharedFlow<Throwable> = _uploadError.asSharedFlow()
|
||||
|
||||
private val _closeConfirmation = MutableStateFlow(ConfirmationKind.NONE)
|
||||
val closeConfirmation: StateFlow<ConfirmationKind> = _closeConfirmation.asStateFlow()
|
||||
|
||||
private lateinit var composeKind: ComposeKind
|
||||
|
||||
// Used in ComposeActivity to pass state to result function when cropImage contract inflight
|
||||
var cropImageItemOld: QueuedMedia? = null
|
||||
|
||||
private var setupComplete = false
|
||||
|
||||
suspend fun pickMedia(mediaUri: Uri, description: String? = null, focus: Attachment.Focus? = null): Result<QueuedMedia> = withContext(Dispatchers.IO) {
|
||||
suspend fun pickMedia(
|
||||
mediaUri: Uri,
|
||||
description: String? = null,
|
||||
focus: Attachment.Focus? = null
|
||||
): Result<QueuedMedia> = withContext(
|
||||
Dispatchers.IO
|
||||
) {
|
||||
try {
|
||||
val (type, uri, size) = mediaUploader.prepareMedia(mediaUri, instanceInfo.first())
|
||||
val mediaItems = media.value
|
||||
val mediaItems = _media.value
|
||||
if (type != QueuedMedia.Type.IMAGE &&
|
||||
mediaItems.isNotEmpty() &&
|
||||
mediaItems[0].type == QueuedMedia.Type.IMAGE
|
||||
|
|
@ -130,7 +159,7 @@ class ComposeViewModel @Inject constructor(
|
|||
): QueuedMedia {
|
||||
var stashMediaItem: QueuedMedia? = null
|
||||
|
||||
media.update { mediaList ->
|
||||
_media.update { mediaList ->
|
||||
val mediaItem = QueuedMedia(
|
||||
localId = mediaUploader.getNewLocalMediaId(),
|
||||
uri = uri,
|
||||
|
|
@ -157,7 +186,7 @@ class ComposeViewModel @Inject constructor(
|
|||
mediaUploader
|
||||
.uploadMedia(mediaItem, instanceInfo.first())
|
||||
.collect { event ->
|
||||
val item = media.value.find { it.localId == mediaItem.localId }
|
||||
val item = _media.value.find { it.localId == mediaItem.localId }
|
||||
?: return@collect
|
||||
val newMediaItem = when (event) {
|
||||
is UploadEvent.ProgressEvent ->
|
||||
|
|
@ -166,15 +195,19 @@ class ComposeViewModel @Inject constructor(
|
|||
item.copy(
|
||||
id = event.mediaId,
|
||||
uploadPercent = -1,
|
||||
state = if (event.processed) { QueuedMedia.State.PROCESSED } else { QueuedMedia.State.UNPROCESSED }
|
||||
state = if (event.processed) {
|
||||
QueuedMedia.State.PROCESSED
|
||||
} else {
|
||||
QueuedMedia.State.UNPROCESSED
|
||||
}
|
||||
)
|
||||
is UploadEvent.ErrorEvent -> {
|
||||
media.update { mediaList -> mediaList.filter { it.localId != mediaItem.localId } }
|
||||
uploadError.emit(event.error)
|
||||
_media.update { mediaList -> mediaList.filter { it.localId != mediaItem.localId } }
|
||||
_uploadError.emit(event.error)
|
||||
return@collect
|
||||
}
|
||||
}
|
||||
media.update { mediaList ->
|
||||
_media.update { mediaList ->
|
||||
mediaList.map { mediaItem ->
|
||||
if (mediaItem.localId == newMediaItem.localId) {
|
||||
newMediaItem
|
||||
|
|
@ -185,11 +218,22 @@ class ComposeViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
}
|
||||
updateCloseConfirmation()
|
||||
return mediaItem
|
||||
}
|
||||
|
||||
private fun addUploadedMedia(id: String, type: QueuedMedia.Type, uri: Uri, description: String?, focus: Attachment.Focus?) {
|
||||
media.update { mediaList ->
|
||||
fun changeStatusVisibility(visibility: Status.Visibility) {
|
||||
_statusVisibility.value = visibility
|
||||
}
|
||||
|
||||
private fun addUploadedMedia(
|
||||
id: String,
|
||||
type: QueuedMedia.Type,
|
||||
uri: Uri,
|
||||
description: String?,
|
||||
focus: Attachment.Focus?
|
||||
) {
|
||||
_media.update { mediaList ->
|
||||
val mediaItem = QueuedMedia(
|
||||
localId = mediaUploader.getNewLocalMediaId(),
|
||||
uri = uri,
|
||||
|
|
@ -207,22 +251,38 @@ class ComposeViewModel @Inject constructor(
|
|||
|
||||
fun removeMediaFromQueue(item: QueuedMedia) {
|
||||
mediaUploader.cancelUploadScope(item.localId)
|
||||
media.update { mediaList -> mediaList.filter { it.localId != item.localId } }
|
||||
_media.update { mediaList -> mediaList.filter { it.localId != item.localId } }
|
||||
updateCloseConfirmation()
|
||||
}
|
||||
|
||||
fun toggleMarkSensitive() {
|
||||
this.markMediaAsSensitive.value = this.markMediaAsSensitive.value != true
|
||||
this._markMediaAsSensitive.value = this._markMediaAsSensitive.value != true
|
||||
}
|
||||
|
||||
fun handleCloseButton(contentText: String?, contentWarning: String?): ConfirmationKind {
|
||||
return if (didChange(contentText, contentWarning)) {
|
||||
fun updateContent(newContent: String?) {
|
||||
currentContent = newContent
|
||||
updateCloseConfirmation()
|
||||
}
|
||||
|
||||
fun updateContentWarning(newContentWarning: String?) {
|
||||
currentContentWarning = newContentWarning
|
||||
updateCloseConfirmation()
|
||||
}
|
||||
|
||||
private fun updateCloseConfirmation() {
|
||||
val contentWarning = if (_showContentWarning.value) {
|
||||
currentContentWarning
|
||||
} else {
|
||||
""
|
||||
}
|
||||
this._closeConfirmation.value = if (didChange(currentContent, contentWarning)) {
|
||||
when (composeKind) {
|
||||
ComposeKind.NEW -> if (isEmpty(contentText, contentWarning)) {
|
||||
ComposeKind.NEW -> if (isEmpty(currentContent, contentWarning)) {
|
||||
ConfirmationKind.NONE
|
||||
} else {
|
||||
ConfirmationKind.SAVE_OR_DISCARD
|
||||
}
|
||||
ComposeKind.EDIT_DRAFT -> if (isEmpty(contentText, contentWarning)) {
|
||||
ComposeKind.EDIT_DRAFT -> if (isEmpty(currentContent, contentWarning)) {
|
||||
ConfirmationKind.CONTINUE_EDITING_OR_DISCARD_DRAFT
|
||||
} else {
|
||||
ConfirmationKind.UPDATE_OR_DISCARD
|
||||
|
|
@ -238,20 +298,21 @@ class ComposeViewModel @Inject constructor(
|
|||
private fun didChange(content: String?, contentWarning: String?): Boolean {
|
||||
val textChanged = content.orEmpty() != startingText.orEmpty()
|
||||
val contentWarningChanged = contentWarning.orEmpty() != startingContentWarning
|
||||
val mediaChanged = media.value.isNotEmpty()
|
||||
val pollChanged = poll.value != null
|
||||
val mediaChanged = _media.value.isNotEmpty()
|
||||
val pollChanged = _poll.value != null
|
||||
val didScheduledTimeChange = hasScheduledTimeChanged
|
||||
|
||||
return modifiedInitialState || textChanged || contentWarningChanged || mediaChanged || pollChanged || didScheduledTimeChange
|
||||
}
|
||||
|
||||
private fun isEmpty(content: String?, contentWarning: String?): Boolean {
|
||||
return !modifiedInitialState && (content.isNullOrBlank() && contentWarning.isNullOrBlank() && media.value.isEmpty() && poll.value == null)
|
||||
return !modifiedInitialState && (content.isNullOrBlank() && contentWarning.isNullOrBlank() && _media.value.isEmpty() && _poll.value == null)
|
||||
}
|
||||
|
||||
fun contentWarningChanged(value: Boolean) {
|
||||
showContentWarning.value = value
|
||||
_showContentWarning.value = value
|
||||
contentWarningStateChanged = true
|
||||
updateCloseConfirmation()
|
||||
}
|
||||
|
||||
fun deleteDraft() {
|
||||
|
|
@ -263,12 +324,12 @@ class ComposeViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
fun stopUploads() {
|
||||
mediaUploader.cancelUploadScope(*media.value.map { it.localId }.toIntArray())
|
||||
mediaUploader.cancelUploadScope(*_media.value.map { it.localId }.toIntArray())
|
||||
}
|
||||
|
||||
fun shouldShowSaveDraftDialog(): Boolean {
|
||||
// if any of the media files need to be downloaded first it could take a while, so show a loading dialog
|
||||
return media.value.any { mediaValue ->
|
||||
return _media.value.any { mediaValue ->
|
||||
mediaValue.uri.scheme == "https"
|
||||
}
|
||||
}
|
||||
|
|
@ -277,7 +338,7 @@ class ComposeViewModel @Inject constructor(
|
|||
val mediaUris: MutableList<String> = mutableListOf()
|
||||
val mediaDescriptions: MutableList<String?> = mutableListOf()
|
||||
val mediaFocus: MutableList<Attachment.Focus?> = mutableListOf()
|
||||
media.value.forEach { item ->
|
||||
for (item in _media.value) {
|
||||
mediaUris.add(item.uri.toString())
|
||||
mediaDescriptions.add(item.description)
|
||||
mediaFocus.add(item.focus)
|
||||
|
|
@ -289,15 +350,15 @@ class ComposeViewModel @Inject constructor(
|
|||
inReplyToId = inReplyToId,
|
||||
content = content,
|
||||
contentWarning = contentWarning,
|
||||
sensitive = markMediaAsSensitive.value,
|
||||
visibility = statusVisibility.value,
|
||||
sensitive = _markMediaAsSensitive.value,
|
||||
visibility = _statusVisibility.value,
|
||||
mediaUris = mediaUris,
|
||||
mediaDescriptions = mediaDescriptions,
|
||||
mediaFocus = mediaFocus,
|
||||
poll = poll.value,
|
||||
poll = _poll.value,
|
||||
failedToSend = false,
|
||||
failedToSendAlert = false,
|
||||
scheduledAt = scheduledAt.value,
|
||||
scheduledAt = _scheduledAt.value,
|
||||
language = postLanguage,
|
||||
statusId = originalStatusId
|
||||
)
|
||||
|
|
@ -307,16 +368,12 @@ class ComposeViewModel @Inject constructor(
|
|||
* Send status to the server.
|
||||
* Uses current state plus provided arguments.
|
||||
*/
|
||||
suspend fun sendStatus(
|
||||
content: String,
|
||||
spoilerText: String,
|
||||
accountId: Long
|
||||
) {
|
||||
suspend fun sendStatus(content: String, spoilerText: String, accountId: Long) {
|
||||
if (!scheduledTootId.isNullOrEmpty()) {
|
||||
api.deleteScheduledStatus(scheduledTootId!!)
|
||||
}
|
||||
|
||||
val attachedMedia = media.value.map { item ->
|
||||
val attachedMedia = _media.value.map { item ->
|
||||
MediaToSend(
|
||||
localId = item.localId,
|
||||
id = item.id,
|
||||
|
|
@ -329,12 +386,12 @@ class ComposeViewModel @Inject constructor(
|
|||
val tootToSend = StatusToSend(
|
||||
text = content,
|
||||
warningText = spoilerText,
|
||||
visibility = statusVisibility.value.serverString(),
|
||||
sensitive = attachedMedia.isNotEmpty() && (markMediaAsSensitive.value || showContentWarning.value),
|
||||
visibility = _statusVisibility.value.serverString,
|
||||
sensitive = attachedMedia.isNotEmpty() && (_markMediaAsSensitive.value || _showContentWarning.value),
|
||||
media = attachedMedia,
|
||||
scheduledAt = scheduledAt.value,
|
||||
scheduledAt = _scheduledAt.value,
|
||||
inReplyToId = inReplyToId,
|
||||
poll = poll.value,
|
||||
poll = _poll.value,
|
||||
replyingStatusContent = null,
|
||||
replyingStatusAuthorUsername = null,
|
||||
accountId = accountId,
|
||||
|
|
@ -349,7 +406,7 @@ class ComposeViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
private fun updateMediaItem(localId: Int, mutator: (QueuedMedia) -> QueuedMedia) {
|
||||
media.update { mediaList ->
|
||||
_media.update { mediaList ->
|
||||
mediaList.map { mediaItem ->
|
||||
if (mediaItem.localId == localId) {
|
||||
mutator(mediaItem)
|
||||
|
|
@ -373,9 +430,9 @@ class ComposeViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
fun searchAutocompleteSuggestions(token: String): List<AutocompleteResult> {
|
||||
when (token[0]) {
|
||||
'@' -> {
|
||||
return api.searchAccountsSync(query = token.substring(1), limit = 10)
|
||||
return when (token[0]) {
|
||||
'@' -> runBlocking {
|
||||
api.searchAccounts(query = token.substring(1), limit = 10)
|
||||
.fold({ accounts ->
|
||||
accounts.map { AutocompleteResult.AccountResult(it) }
|
||||
}, { e ->
|
||||
|
|
@ -383,8 +440,12 @@ class ComposeViewModel @Inject constructor(
|
|||
emptyList()
|
||||
})
|
||||
}
|
||||
'#' -> {
|
||||
return api.searchSync(query = token, type = SearchType.Hashtag.apiParameter, limit = 10)
|
||||
'#' -> runBlocking {
|
||||
api.search(
|
||||
query = token,
|
||||
type = SearchType.Hashtag.apiParameter,
|
||||
limit = 10
|
||||
)
|
||||
.fold({ searchResult ->
|
||||
searchResult.hashtags.map { AutocompleteResult.HashtagResult(it.name) }
|
||||
}, { e ->
|
||||
|
|
@ -396,7 +457,7 @@ class ComposeViewModel @Inject constructor(
|
|||
val emojiList = emoji.replayCache.firstOrNull() ?: return emptyList()
|
||||
val incomplete = token.substring(1)
|
||||
|
||||
return emojiList.filter { emoji ->
|
||||
emojiList.filter { emoji ->
|
||||
emoji.shortcode.contains(incomplete, ignoreCase = true)
|
||||
}.sortedBy { emoji ->
|
||||
emoji.shortcode.indexOf(incomplete, ignoreCase = true)
|
||||
|
|
@ -406,7 +467,7 @@ class ComposeViewModel @Inject constructor(
|
|||
}
|
||||
else -> {
|
||||
Log.w(TAG, "Unexpected autocompletion token: $token")
|
||||
return emptyList()
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -434,7 +495,7 @@ class ComposeViewModel @Inject constructor(
|
|||
startingContentWarning = contentWarning
|
||||
}
|
||||
if (!contentWarningStateChanged) {
|
||||
showContentWarning.value = !contentWarning.isNullOrBlank()
|
||||
_showContentWarning.value = !contentWarning.isNullOrBlank()
|
||||
}
|
||||
|
||||
// recreate media list
|
||||
|
|
@ -468,7 +529,7 @@ class ComposeViewModel @Inject constructor(
|
|||
if (tootVisibility.num != Status.Visibility.UNKNOWN.num) {
|
||||
startingVisibility = tootVisibility
|
||||
}
|
||||
statusVisibility.value = startingVisibility
|
||||
_statusVisibility.value = startingVisibility
|
||||
val mentionedUsernames = composeOptions?.mentionedUsernames
|
||||
if (mentionedUsernames != null) {
|
||||
val builder = StringBuilder()
|
||||
|
|
@ -480,30 +541,33 @@ class ComposeViewModel @Inject constructor(
|
|||
startingText = builder.toString()
|
||||
}
|
||||
|
||||
scheduledAt.value = composeOptions?.scheduledAt
|
||||
_scheduledAt.value = composeOptions?.scheduledAt
|
||||
|
||||
composeOptions?.sensitive?.let { markMediaAsSensitive.value = it }
|
||||
composeOptions?.sensitive?.let { _markMediaAsSensitive.value = it }
|
||||
|
||||
val poll = composeOptions?.poll
|
||||
if (poll != null && composeOptions.mediaAttachments.isNullOrEmpty()) {
|
||||
this.poll.value = poll
|
||||
this._poll.value = poll
|
||||
}
|
||||
replyingStatusContent = composeOptions?.replyingStatusContent
|
||||
replyingStatusAuthor = composeOptions?.replyingStatusAuthor
|
||||
|
||||
updateCloseConfirmation()
|
||||
|
||||
setupComplete = true
|
||||
}
|
||||
|
||||
fun updatePoll(newPoll: NewPoll) {
|
||||
poll.value = newPoll
|
||||
fun updatePoll(newPoll: NewPoll?) {
|
||||
_poll.value = newPoll
|
||||
updateCloseConfirmation()
|
||||
}
|
||||
|
||||
fun updateScheduledAt(newScheduledAt: String?) {
|
||||
if (newScheduledAt != scheduledAt.value) {
|
||||
if (newScheduledAt != _scheduledAt.value) {
|
||||
hasScheduledTimeChanged = true
|
||||
}
|
||||
|
||||
scheduledAt.value = newScheduledAt
|
||||
_scheduledAt.value = newScheduledAt
|
||||
}
|
||||
|
||||
val editing: Boolean
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ fun downsizeImage(
|
|||
tempFile: File
|
||||
): Boolean {
|
||||
val decodeBoundsInputStream = try {
|
||||
contentResolver.openInputStream(uri)
|
||||
contentResolver.openInputStream(uri) ?: return false
|
||||
} catch (e: FileNotFoundException) {
|
||||
return false
|
||||
}
|
||||
|
|
@ -54,10 +54,10 @@ fun downsizeImage(
|
|||
// Get EXIF data, for orientation info.
|
||||
val orientation = getImageOrientation(uri, contentResolver)
|
||||
/* Unfortunately, there isn't a determined worst case compression ratio for image
|
||||
* formats. So, the only way to tell if they're too big is to compress them and
|
||||
* test, and keep trying at smaller sizes. The initial estimate should be good for
|
||||
* many cases, so it should only iterate once, but the loop is used to be absolutely
|
||||
* sure it gets downsized to below the limit. */
|
||||
* formats. So, the only way to tell if they're too big is to compress them and
|
||||
* test, and keep trying at smaller sizes. The initial estimate should be good for
|
||||
* many cases, so it should only iterate once, but the loop is used to be absolutely
|
||||
* sure it gets downsized to below the limit. */
|
||||
var scaledImageSize = 1024
|
||||
do {
|
||||
val outputStream = try {
|
||||
|
|
@ -66,7 +66,7 @@ fun downsizeImage(
|
|||
return false
|
||||
}
|
||||
val decodeBitmapInputStream = try {
|
||||
contentResolver.openInputStream(uri)
|
||||
contentResolver.openInputStream(uri) ?: return false
|
||||
} catch (e: FileNotFoundException) {
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -113,11 +113,17 @@ class MediaPreviewAdapter(
|
|||
private val differ = AsyncListDiffer(
|
||||
this,
|
||||
object : DiffUtil.ItemCallback<ComposeActivity.QueuedMedia>() {
|
||||
override fun areItemsTheSame(oldItem: ComposeActivity.QueuedMedia, newItem: ComposeActivity.QueuedMedia): Boolean {
|
||||
override fun areItemsTheSame(
|
||||
oldItem: ComposeActivity.QueuedMedia,
|
||||
newItem: ComposeActivity.QueuedMedia
|
||||
): Boolean {
|
||||
return oldItem.localId == newItem.localId
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: ComposeActivity.QueuedMedia, newItem: ComposeActivity.QueuedMedia): Boolean {
|
||||
override fun areContentsTheSame(
|
||||
oldItem: ComposeActivity.QueuedMedia,
|
||||
newItem: ComposeActivity.QueuedMedia
|
||||
): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,12 +30,16 @@ import com.keylesspalace.tusky.R
|
|||
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
|
||||
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfo
|
||||
import com.keylesspalace.tusky.network.MediaUploadApi
|
||||
import com.keylesspalace.tusky.network.ProgressRequestBody
|
||||
import com.keylesspalace.tusky.network.asRequestBody
|
||||
import com.keylesspalace.tusky.util.MEDIA_SIZE_UNKNOWN
|
||||
import com.keylesspalace.tusky.util.getImageSquarePixels
|
||||
import com.keylesspalace.tusky.util.getMediaSize
|
||||
import com.keylesspalace.tusky.util.getServerErrorMessage
|
||||
import com.keylesspalace.tusky.util.randomAlphanumericString
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
|
|
@ -52,21 +56,20 @@ import kotlinx.coroutines.flow.flow
|
|||
import kotlinx.coroutines.flow.shareIn
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.MultipartBody
|
||||
import okio.buffer
|
||||
import okio.sink
|
||||
import okio.source
|
||||
import retrofit2.HttpException
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.util.Date
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
sealed interface FinalUploadEvent
|
||||
|
||||
sealed class UploadEvent {
|
||||
data class ProgressEvent(val percentage: Int) : UploadEvent()
|
||||
data class FinishedEvent(val mediaId: String, val processed: Boolean) : UploadEvent(), FinalUploadEvent
|
||||
data class ErrorEvent(val error: Throwable) : UploadEvent(), FinalUploadEvent
|
||||
sealed interface UploadEvent {
|
||||
data class ProgressEvent(val percentage: Int) : UploadEvent
|
||||
data class FinishedEvent(
|
||||
val mediaId: String,
|
||||
val processed: Boolean
|
||||
) : UploadEvent, FinalUploadEvent
|
||||
data class ErrorEvent(val error: Throwable) : UploadEvent, FinalUploadEvent
|
||||
}
|
||||
|
||||
data class UploadData(
|
||||
|
|
@ -79,11 +82,7 @@ fun createNewImageFile(context: Context, suffix: String = ".jpg"): File {
|
|||
val randomId = randomAlphanumericString(12)
|
||||
val imageFileName = "Tusky_${randomId}_"
|
||||
val storageDir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)
|
||||
return File.createTempFile(
|
||||
imageFileName, /* prefix */
|
||||
suffix, /* suffix */
|
||||
storageDir /* directory */
|
||||
)
|
||||
return File.createTempFile(imageFileName, suffix, storageDir)
|
||||
}
|
||||
|
||||
data class PreparedMedia(val type: QueuedMedia.Type, val uri: Uri, val size: Long)
|
||||
|
|
@ -163,22 +162,22 @@ class MediaUploader @Inject constructor(
|
|||
|
||||
val suffix = "." + MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType ?: "tmp")
|
||||
|
||||
contentResolver.openInputStream(inUri).use { input ->
|
||||
contentResolver.openInputStream(inUri)?.source().use { input ->
|
||||
if (input == null) {
|
||||
Log.w(TAG, "Media input is null")
|
||||
uri = inUri
|
||||
return@use
|
||||
}
|
||||
val file = File.createTempFile("randomTemp1", suffix, context.cacheDir)
|
||||
FileOutputStream(file.absoluteFile).use { out ->
|
||||
input.copyTo(out)
|
||||
uri = FileProvider.getUriForFile(
|
||||
context,
|
||||
BuildConfig.APPLICATION_ID + ".fileprovider",
|
||||
file
|
||||
)
|
||||
mediaSize = getMediaSize(contentResolver, uri)
|
||||
file.absoluteFile.sink().buffer().use { out ->
|
||||
out.writeAll(input)
|
||||
}
|
||||
uri = FileProvider.getUriForFile(
|
||||
context,
|
||||
BuildConfig.APPLICATION_ID + ".fileprovider",
|
||||
file
|
||||
)
|
||||
mediaSize = getMediaSize(contentResolver, uri)
|
||||
}
|
||||
}
|
||||
ContentResolver.SCHEME_FILE -> {
|
||||
|
|
@ -191,17 +190,18 @@ class MediaUploader @Inject constructor(
|
|||
val suffix = inputFile.name.substringAfterLast('.', "tmp")
|
||||
mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(suffix)
|
||||
val file = File.createTempFile("randomTemp1", ".$suffix", context.cacheDir)
|
||||
val input = FileInputStream(inputFile)
|
||||
|
||||
FileOutputStream(file.absoluteFile).use { out ->
|
||||
input.copyTo(out)
|
||||
uri = FileProvider.getUriForFile(
|
||||
context,
|
||||
BuildConfig.APPLICATION_ID + ".fileprovider",
|
||||
file
|
||||
)
|
||||
mediaSize = getMediaSize(contentResolver, uri)
|
||||
inputFile.source().use { input ->
|
||||
file.absoluteFile.sink().buffer().use { out ->
|
||||
out.writeAll(input)
|
||||
}
|
||||
}
|
||||
uri = FileProvider.getUriForFile(
|
||||
context,
|
||||
BuildConfig.APPLICATION_ID + ".fileprovider",
|
||||
file
|
||||
)
|
||||
mediaSize = getMediaSize(contentResolver, uri)
|
||||
}
|
||||
else -> {
|
||||
Log.w(TAG, "Unknown uri scheme $uri")
|
||||
|
|
@ -254,9 +254,9 @@ class MediaUploader @Inject constructor(
|
|||
// .m4a files. See https://github.com/tuskyapp/Tusky/issues/3189 for details.
|
||||
// Sniff the content of the file to determine the actual type.
|
||||
if (mimeType != null && (
|
||||
mimeType.startsWith("audio/", ignoreCase = true) ||
|
||||
mimeType.startsWith("video/", ignoreCase = true)
|
||||
)
|
||||
mimeType.startsWith("audio/", ignoreCase = true) ||
|
||||
mimeType.startsWith("video/", ignoreCase = true)
|
||||
)
|
||||
) {
|
||||
val retriever = MediaMetadataRetriever()
|
||||
retriever.setDataSource(context, media.uri)
|
||||
|
|
@ -264,22 +264,20 @@ class MediaUploader @Inject constructor(
|
|||
}
|
||||
val map = MimeTypeMap.getSingleton()
|
||||
val fileExtension = map.getExtensionFromMimeType(mimeType)
|
||||
val filename = "%s_%s_%s.%s".format(
|
||||
val filename = "%s_%d_%s.%s".format(
|
||||
context.getString(R.string.app_name),
|
||||
Date().time.toString(),
|
||||
System.currentTimeMillis(),
|
||||
randomAlphanumericString(10),
|
||||
fileExtension
|
||||
)
|
||||
|
||||
val stream = contentResolver.openInputStream(media.uri)
|
||||
|
||||
if (mimeType == null) mimeType = "multipart/form-data"
|
||||
|
||||
var lastProgress = -1
|
||||
val fileBody = ProgressRequestBody(
|
||||
stream!!,
|
||||
media.mediaSize,
|
||||
mimeType.toMediaTypeOrNull()!!
|
||||
val fileBody = media.uri.asRequestBody(
|
||||
contentResolver,
|
||||
requireNotNull(mimeType.toMediaTypeOrNull()) { "Invalid Content Type" },
|
||||
media.mediaSize
|
||||
) { percentage ->
|
||||
if (percentage != lastProgress) {
|
||||
trySend(UploadEvent.ProgressEvent(percentage))
|
||||
|
|
|
|||
|
|
@ -60,7 +60,9 @@ fun showAddPollDialog(
|
|||
binding.pollChoices.adapter = adapter
|
||||
|
||||
var durations = context.resources.getIntArray(R.array.poll_duration_values).toList()
|
||||
val durationLabels = context.resources.getStringArray(R.array.poll_duration_names).filterIndexed { index, _ -> durations[index] in minDuration..maxDuration }
|
||||
val durationLabels = context.resources.getStringArray(
|
||||
R.array.poll_duration_names
|
||||
).filterIndexed { index, _ -> durations[index] in minDuration..maxDuration }
|
||||
binding.pollDurationSpinner.adapter = ArrayAdapter(context, android.R.layout.simple_spinner_item, durationLabels).apply {
|
||||
setDropDownViewResource(androidx.appcompat.R.layout.support_simple_spinner_dropdown_item)
|
||||
}
|
||||
|
|
@ -75,8 +77,8 @@ fun showAddPollDialog(
|
|||
}
|
||||
}
|
||||
|
||||
val DAY_SECONDS = 60 * 60 * 24
|
||||
val desiredDuration = poll?.expiresIn ?: DAY_SECONDS
|
||||
val secondsInADay = 60 * 60 * 24
|
||||
val desiredDuration = poll?.expiresIn ?: secondsInADay
|
||||
val pollDurationId = durations.indexOfLast {
|
||||
it <= desiredDuration
|
||||
}
|
||||
|
|
@ -105,5 +107,7 @@ fun showAddPollDialog(
|
|||
dialog.show()
|
||||
|
||||
// make the dialog focusable so the keyboard does not stay behind it
|
||||
dialog.window?.clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM)
|
||||
dialog.window?.clearFlags(
|
||||
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,8 +41,15 @@ class AddPollOptionsAdapter(
|
|||
notifyItemInserted(options.size - 1)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemAddPollOptionBinding> {
|
||||
val binding = ItemAddPollOptionBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
viewType: Int
|
||||
): BindingHolder<ItemAddPollOptionBinding> {
|
||||
val binding = ItemAddPollOptionBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
val holder = BindingHolder(binding)
|
||||
binding.optionEditText.filters = arrayOf(InputFilter.LengthFilter(maxOptionLength))
|
||||
|
||||
|
|
@ -75,10 +82,6 @@ class AddPollOptionsAdapter(
|
|||
}
|
||||
|
||||
private fun validateInput(): Boolean {
|
||||
if (options.contains("") || options.distinct().size != options.size) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
return !(options.contains("") || options.distinct().size != options.size)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@
|
|||
|
||||
package com.keylesspalace.tusky.components.compose.dialog
|
||||
|
||||
import android.app.Dialog
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
|
|
@ -25,8 +24,7 @@ import android.view.LayoutInflater
|
|||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.WindowManager
|
||||
import android.widget.EditText
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import android.widget.LinearLayout
|
||||
import androidx.core.os.BundleCompat
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.DialogFragment
|
||||
|
|
@ -36,45 +34,56 @@ import com.bumptech.glide.request.target.CustomTarget
|
|||
import com.bumptech.glide.request.transition.Transition
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.databinding.DialogImageDescriptionBinding
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
|
||||
// https://github.com/tootsuite/mastodon/blob/c6904c0d3766a2ea8a81ab025c127169ecb51373/app/models/media_attachment.rb#L32
|
||||
private const val MEDIA_DESCRIPTION_CHARACTER_LIMIT = 1500
|
||||
|
||||
class CaptionDialog : DialogFragment() {
|
||||
private lateinit var listener: Listener
|
||||
private lateinit var input: EditText
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val context = requireContext()
|
||||
private val binding by viewBinding(DialogImageDescriptionBinding::bind)
|
||||
|
||||
val binding = DialogImageDescriptionBinding.inflate(layoutInflater)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setStyle(STYLE_NORMAL, R.style.TuskyDialogFragmentStyle)
|
||||
}
|
||||
|
||||
input = binding.imageDescriptionText
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View = inflater.inflate(R.layout.dialog_image_description, container, false)
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val imageView = binding.imageDescriptionView
|
||||
imageView.maximumScale = 6f
|
||||
imageView.maxZoom = 6f
|
||||
|
||||
input.hint = resources.getQuantityString(
|
||||
binding.imageDescriptionText.hint = resources.getQuantityString(
|
||||
R.plurals.hint_describe_for_visually_impaired,
|
||||
MEDIA_DESCRIPTION_CHARACTER_LIMIT,
|
||||
MEDIA_DESCRIPTION_CHARACTER_LIMIT
|
||||
)
|
||||
input.filters = arrayOf(InputFilter.LengthFilter(MEDIA_DESCRIPTION_CHARACTER_LIMIT))
|
||||
input.setText(arguments?.getString(EXISTING_DESCRIPTION_ARG))
|
||||
binding.imageDescriptionText.filters = arrayOf(InputFilter.LengthFilter(MEDIA_DESCRIPTION_CHARACTER_LIMIT))
|
||||
binding.imageDescriptionText.setText(arguments?.getString(EXISTING_DESCRIPTION_ARG))
|
||||
savedInstanceState?.getCharSequence(DESCRIPTION_KEY)?.let {
|
||||
binding.imageDescriptionText.setText(it)
|
||||
}
|
||||
|
||||
binding.cancelButton.setOnClickListener {
|
||||
dismiss()
|
||||
}
|
||||
val localId = arguments?.getInt(LOCAL_ID_ARG) ?: error("Missing localId")
|
||||
val dialog = AlertDialog.Builder(context)
|
||||
.setView(binding.root)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
listener.onUpdateDescription(localId, input.text.toString())
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.create()
|
||||
binding.okButton.setOnClickListener {
|
||||
listener.onUpdateDescription(localId, binding.imageDescriptionText.text.toString())
|
||||
dismiss()
|
||||
}
|
||||
|
||||
isCancelable = true
|
||||
val window = dialog.window
|
||||
window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
|
||||
|
||||
val previewUri = BundleCompat.getParcelable(requireArguments(), PREVIEW_URI_ARG, Uri::class.java) ?: error("Preview Uri is null")
|
||||
|
||||
// Load the image and manually set it into the ImageView because it doesn't have a fixed size.
|
||||
Glide.with(this)
|
||||
.load(previewUri)
|
||||
|
|
@ -90,27 +99,30 @@ class CaptionDialog : DialogFragment() {
|
|||
) {
|
||||
imageView.setImageDrawable(resource)
|
||||
}
|
||||
})
|
||||
|
||||
return dialog
|
||||
override fun onLoadFailed(errorDrawable: Drawable?) {
|
||||
super.onLoadFailed(errorDrawable)
|
||||
imageView.hide()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
dialog?.apply {
|
||||
window?.setLayout(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
outState.putString(DESCRIPTION_KEY, input.text.toString())
|
||||
outState.putCharSequence(DESCRIPTION_KEY, binding.imageDescriptionText.text)
|
||||
super.onSaveInstanceState(outState)
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
savedInstanceState?.getString(DESCRIPTION_KEY)?.let {
|
||||
input.setText(it)
|
||||
}
|
||||
return super.onCreateView(inflater, container, savedInstanceState)
|
||||
}
|
||||
|
||||
override fun onAttach(context: Context) {
|
||||
super.onAttach(context)
|
||||
listener = context as? Listener ?: error("Activity is not ComposeCaptionDialog.Listener")
|
||||
|
|
@ -121,17 +133,14 @@ class CaptionDialog : DialogFragment() {
|
|||
}
|
||||
|
||||
companion object {
|
||||
fun newInstance(
|
||||
localId: Int,
|
||||
existingDescription: String?,
|
||||
previewUri: Uri
|
||||
) = CaptionDialog().apply {
|
||||
arguments = bundleOf(
|
||||
LOCAL_ID_ARG to localId,
|
||||
EXISTING_DESCRIPTION_ARG to existingDescription,
|
||||
PREVIEW_URI_ARG to previewUri
|
||||
)
|
||||
}
|
||||
fun newInstance(localId: Int, existingDescription: String?, previewUri: Uri) =
|
||||
CaptionDialog().apply {
|
||||
arguments = bundleOf(
|
||||
LOCAL_ID_ARG to localId,
|
||||
EXISTING_DESCRIPTION_ARG to existingDescription,
|
||||
PREVIEW_URI_ARG to previewUri
|
||||
)
|
||||
}
|
||||
|
||||
private const val DESCRIPTION_KEY = "description"
|
||||
private const val EXISTING_DESCRIPTION_ARG = "existing_description"
|
||||
|
|
|
|||
|
|
@ -49,12 +49,23 @@ fun <T> T.makeFocusDialog(
|
|||
.load(previewUri)
|
||||
.downsample(DownsampleStrategy.CENTER_INSIDE)
|
||||
.listener(object : RequestListener<Drawable> {
|
||||
override fun onLoadFailed(p0: GlideException?, p1: Any?, p2: Target<Drawable?>?, p3: Boolean): Boolean {
|
||||
override fun onLoadFailed(
|
||||
p0: GlideException?,
|
||||
p1: Any?,
|
||||
p2: Target<Drawable?>,
|
||||
p3: Boolean
|
||||
): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onResourceReady(resource: Drawable?, model: Any?, target: Target<Drawable?>?, dataSource: DataSource?, isFirstResource: Boolean): Boolean {
|
||||
val width = resource!!.intrinsicWidth
|
||||
override fun onResourceReady(
|
||||
resource: Drawable,
|
||||
model: Any,
|
||||
target: Target<Drawable?>?,
|
||||
dataSource: DataSource,
|
||||
isFirstResource: Boolean
|
||||
): Boolean {
|
||||
val width = resource.intrinsicWidth
|
||||
val height = resource.intrinsicHeight
|
||||
|
||||
dialogBinding.focusIndicator.setImageSize(width, height)
|
||||
|
|
|
|||
|
|
@ -21,7 +21,10 @@ import android.widget.RadioGroup
|
|||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
|
||||
class ComposeOptionsView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : RadioGroup(context, attrs) {
|
||||
class ComposeOptionsView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : RadioGroup(
|
||||
context,
|
||||
attrs
|
||||
) {
|
||||
|
||||
var listener: ComposeOptionsListener? = null
|
||||
|
||||
|
|
|
|||
|
|
@ -57,7 +57,9 @@ class ComposeScheduleView
|
|||
).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
private var scheduleDateTime: Calendar? = null
|
||||
|
||||
/** The date/time the user has chosen to schedule the status, in UTC */
|
||||
private var scheduleDateTimeUtc: Calendar? = null
|
||||
|
||||
init {
|
||||
binding.scheduledDateTime.setOnClickListener { openPickDateDialog() }
|
||||
|
|
@ -71,13 +73,13 @@ class ComposeScheduleView
|
|||
}
|
||||
|
||||
private fun updateScheduleUi() {
|
||||
if (scheduleDateTime == null) {
|
||||
if (scheduleDateTimeUtc == null) {
|
||||
binding.scheduledDateTime.text = ""
|
||||
binding.invalidScheduleWarning.visibility = GONE
|
||||
return
|
||||
}
|
||||
|
||||
val scheduled = scheduleDateTime!!.time
|
||||
val scheduled = scheduleDateTimeUtc!!.time
|
||||
binding.scheduledDateTime.text = String.format(
|
||||
"%s %s",
|
||||
dateFormat.format(scheduled),
|
||||
|
|
@ -98,21 +100,37 @@ class ComposeScheduleView
|
|||
}
|
||||
|
||||
fun resetSchedule() {
|
||||
scheduleDateTime = null
|
||||
scheduleDateTimeUtc = null
|
||||
updateScheduleUi()
|
||||
}
|
||||
|
||||
fun openPickDateDialog() {
|
||||
val yesterday = Calendar.getInstance().timeInMillis - 24 * 60 * 60 * 1000
|
||||
// The earliest point in time the calendar should display. Start with current date/time
|
||||
val earliest = calendar().apply {
|
||||
// Add the minimum scheduling interval. This may roll the calendar over to the
|
||||
// next day (e.g. if the current time is 23:57).
|
||||
add(Calendar.SECOND, MINIMUM_SCHEDULED_SECONDS)
|
||||
// Clear out the time components, so it's midnight
|
||||
set(Calendar.HOUR_OF_DAY, 0)
|
||||
set(Calendar.MINUTE, 0)
|
||||
set(Calendar.SECOND, 0)
|
||||
set(Calendar.MILLISECOND, 0)
|
||||
}
|
||||
val calendarConstraints = CalendarConstraints.Builder()
|
||||
.setValidator(
|
||||
DateValidatorPointForward.from(yesterday)
|
||||
)
|
||||
.setValidator(DateValidatorPointForward.from(earliest.timeInMillis))
|
||||
.build()
|
||||
initializeSuggestedTime()
|
||||
|
||||
// Work around a misfeature in MaterialDatePicker. The `selection` is treated as
|
||||
// millis-from-epoch, in UTC, which is good. However, it is also *displayed* in UTC
|
||||
// instead of converting to the user's local timezone.
|
||||
//
|
||||
// So we have to add the TZ offset before setting it in the picker
|
||||
val tzOffset = TimeZone.getDefault().getOffset(scheduleDateTimeUtc!!.timeInMillis)
|
||||
|
||||
val picker = MaterialDatePicker.Builder
|
||||
.datePicker()
|
||||
.setSelection(scheduleDateTime!!.timeInMillis)
|
||||
.setSelection(scheduleDateTimeUtc!!.timeInMillis + tzOffset)
|
||||
.setCalendarConstraints(calendarConstraints)
|
||||
.build()
|
||||
picker.addOnPositiveButtonClickListener { selection: Long -> onDateSet(selection) }
|
||||
|
|
@ -129,11 +147,12 @@ class ComposeScheduleView
|
|||
|
||||
private fun openPickTimeDialog() {
|
||||
val pickerBuilder = MaterialTimePicker.Builder()
|
||||
scheduleDateTime?.let {
|
||||
scheduleDateTimeUtc?.let {
|
||||
pickerBuilder.setHour(it[Calendar.HOUR_OF_DAY])
|
||||
.setMinute(it[Calendar.MINUTE])
|
||||
}
|
||||
|
||||
pickerBuilder.setTitleText(dateFormat.format(scheduleDateTimeUtc!!.timeInMillis))
|
||||
pickerBuilder.setTimeFormat(getTimeFormat(context))
|
||||
|
||||
val picker = pickerBuilder.build()
|
||||
|
|
@ -154,7 +173,7 @@ class ComposeScheduleView
|
|||
fun setDateTime(scheduledAt: String?) {
|
||||
val date = getDateTime(scheduledAt) ?: return
|
||||
initializeSuggestedTime()
|
||||
scheduleDateTime!!.time = date
|
||||
scheduleDateTimeUtc!!.time = date
|
||||
updateScheduleUi()
|
||||
}
|
||||
|
||||
|
|
@ -180,31 +199,32 @@ class ComposeScheduleView
|
|||
// see https://github.com/material-components/material-components-android/issues/882
|
||||
newDate.timeZone = TimeZone.getTimeZone("UTC")
|
||||
newDate.timeInMillis = selection
|
||||
scheduleDateTime!![newDate[Calendar.YEAR], newDate[Calendar.MONTH]] = newDate[Calendar.DATE]
|
||||
scheduleDateTimeUtc!![newDate[Calendar.YEAR], newDate[Calendar.MONTH]] = newDate[Calendar.DATE]
|
||||
openPickTimeDialog()
|
||||
}
|
||||
|
||||
private fun onTimeSet(hourOfDay: Int, minute: Int) {
|
||||
initializeSuggestedTime()
|
||||
scheduleDateTime?.set(Calendar.HOUR_OF_DAY, hourOfDay)
|
||||
scheduleDateTime?.set(Calendar.MINUTE, minute)
|
||||
scheduleDateTimeUtc?.set(Calendar.HOUR_OF_DAY, hourOfDay)
|
||||
scheduleDateTimeUtc?.set(Calendar.MINUTE, minute)
|
||||
updateScheduleUi()
|
||||
listener?.onTimeSet(time)
|
||||
}
|
||||
|
||||
val time: String?
|
||||
get() = scheduleDateTime?.time?.let { iso8601.format(it) }
|
||||
get() = scheduleDateTimeUtc?.time?.let { iso8601.format(it) }
|
||||
|
||||
private fun initializeSuggestedTime() {
|
||||
if (scheduleDateTime == null) {
|
||||
scheduleDateTime = calendar().apply {
|
||||
if (scheduleDateTimeUtc == null) {
|
||||
scheduleDateTimeUtc = calendar().apply {
|
||||
add(Calendar.MINUTE, 15)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
var MINIMUM_SCHEDULED_SECONDS = 330 // Minimum is 5 minutes, pad 30 seconds for posting
|
||||
// Minimum is 5 minutes, pad 30 seconds for posting
|
||||
private const val MINIMUM_SCHEDULED_SECONDS = 330
|
||||
fun calendar(): Calendar = Calendar.getInstance(TimeZone.getDefault())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,7 +68,9 @@ class FocusIndicatorView
|
|||
return offset.toFloat() + ((value + 1.0f) / 2.0f) * innerLimit.toFloat() // From range -1..1
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility") // Android Studio wants us to implement PerformClick for accessibility, but that unfortunately cannot be made meaningful for this widget.
|
||||
@SuppressLint(
|
||||
"ClickableViewAccessibility"
|
||||
) // Android Studio wants us to implement PerformClick for accessibility, but that unfortunately cannot be made meaningful for this widget.
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
if (event.actionMasked == MotionEvent.ACTION_CANCEL) {
|
||||
return false
|
||||
|
|
@ -112,7 +114,13 @@ class FocusIndicatorView
|
|||
|
||||
curtainPath.reset() // Draw a flood fill with a hole cut out of it
|
||||
curtainPath.fillType = Path.FillType.WINDING
|
||||
curtainPath.addRect(0.0f, 0.0f, this.width.toFloat(), this.height.toFloat(), Path.Direction.CW)
|
||||
curtainPath.addRect(
|
||||
0.0f,
|
||||
0.0f,
|
||||
this.width.toFloat(),
|
||||
this.height.toFloat(),
|
||||
Path.Direction.CW
|
||||
)
|
||||
curtainPath.addCircle(x, y, circleRadius, Path.Direction.CCW)
|
||||
canvas.drawPath(curtainPath, curtainPaint)
|
||||
|
||||
|
|
|
|||
|
|
@ -60,7 +60,10 @@ class TootButton
|
|||
Status.Visibility.PRIVATE,
|
||||
Status.Visibility.DIRECT -> {
|
||||
setText(R.string.action_send)
|
||||
IconicsDrawable(context, GoogleMaterial.Icon.gmd_lock).apply { sizeDp = 18; colorInt = Color.WHITE }
|
||||
IconicsDrawable(context, GoogleMaterial.Icon.gmd_lock).apply {
|
||||
sizeDp = 18
|
||||
colorInt = Color.WHITE
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
null
|
||||
|
|
|
|||
|
|
@ -38,7 +38,9 @@ class ConversationAdapter(
|
|||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ConversationViewHolder {
|
||||
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_conversation, parent, false)
|
||||
val view = LayoutInflater.from(
|
||||
parent.context
|
||||
).inflate(R.layout.item_conversation, parent, false)
|
||||
return ConversationViewHolder(view, statusDisplayOptions, listener)
|
||||
}
|
||||
|
||||
|
|
@ -58,15 +60,24 @@ class ConversationAdapter(
|
|||
|
||||
companion object {
|
||||
val CONVERSATION_COMPARATOR = object : DiffUtil.ItemCallback<ConversationViewData>() {
|
||||
override fun areItemsTheSame(oldItem: ConversationViewData, newItem: ConversationViewData): Boolean {
|
||||
override fun areItemsTheSame(
|
||||
oldItem: ConversationViewData,
|
||||
newItem: ConversationViewData
|
||||
): Boolean {
|
||||
return oldItem.id == newItem.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: ConversationViewData, newItem: ConversationViewData): Boolean {
|
||||
override fun areContentsTheSame(
|
||||
oldItem: ConversationViewData,
|
||||
newItem: ConversationViewData
|
||||
): Boolean {
|
||||
return false // Items are different always. It allows to refresh timestamp on every view holder update
|
||||
}
|
||||
|
||||
override fun getChangePayload(oldItem: ConversationViewData, newItem: ConversationViewData): Any? {
|
||||
override fun getChangePayload(
|
||||
oldItem: ConversationViewData,
|
||||
newItem: ConversationViewData
|
||||
): Any? {
|
||||
return if (oldItem == newItem) {
|
||||
// If items are equal - update timestamp only
|
||||
listOf(StatusBaseViewHolder.Key.KEY_CREATED)
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import com.keylesspalace.tusky.entity.Poll
|
|||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.entity.TimelineAccount
|
||||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||
import com.squareup.moshi.JsonClass
|
||||
import java.util.Date
|
||||
|
||||
@Entity(primaryKeys = ["id", "accountId"])
|
||||
|
|
@ -50,6 +51,7 @@ data class ConversationEntity(
|
|||
}
|
||||
}
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class ConversationAccountEntity(
|
||||
val id: String,
|
||||
val localUsername: String,
|
||||
|
|
@ -131,7 +133,7 @@ data class ConversationStatusEntity(
|
|||
poll = poll,
|
||||
card = null,
|
||||
language = language,
|
||||
filtered = null
|
||||
filtered = emptyList()
|
||||
),
|
||||
isExpanded = expanded,
|
||||
isShowingContent = showingHiddenContent,
|
||||
|
|
@ -140,21 +142,16 @@ data class ConversationStatusEntity(
|
|||
}
|
||||
}
|
||||
|
||||
fun TimelineAccount.toEntity() =
|
||||
ConversationAccountEntity(
|
||||
id = id,
|
||||
localUsername = localUsername,
|
||||
username = username,
|
||||
displayName = name,
|
||||
avatar = avatar,
|
||||
emojis = emojis.orEmpty()
|
||||
)
|
||||
fun TimelineAccount.toEntity() = ConversationAccountEntity(
|
||||
id = id,
|
||||
localUsername = localUsername,
|
||||
username = username,
|
||||
displayName = name,
|
||||
avatar = avatar,
|
||||
emojis = emojis.orEmpty()
|
||||
)
|
||||
|
||||
fun Status.toEntity(
|
||||
expanded: Boolean,
|
||||
contentShowing: Boolean,
|
||||
contentCollapsed: Boolean
|
||||
) =
|
||||
fun Status.toEntity(expanded: Boolean, contentShowing: Boolean, contentCollapsed: Boolean) =
|
||||
ConversationStatusEntity(
|
||||
id = id,
|
||||
url = url,
|
||||
|
|
@ -177,7 +174,7 @@ fun Status.toEntity(
|
|||
showingHiddenContent = contentShowing,
|
||||
expanded = expanded,
|
||||
collapsed = contentCollapsed,
|
||||
muted = muted ?: false,
|
||||
muted = muted,
|
||||
poll = poll,
|
||||
language = language
|
||||
)
|
||||
|
|
@ -188,16 +185,15 @@ fun Conversation.toEntity(
|
|||
expanded: Boolean,
|
||||
contentShowing: Boolean,
|
||||
contentCollapsed: Boolean
|
||||
) =
|
||||
ConversationEntity(
|
||||
accountId = accountId,
|
||||
id = id,
|
||||
order = order,
|
||||
accounts = accounts.map { it.toEntity() },
|
||||
unread = unread,
|
||||
lastStatus = lastStatus!!.toEntity(
|
||||
expanded = expanded,
|
||||
contentShowing = contentShowing,
|
||||
contentCollapsed = contentCollapsed
|
||||
)
|
||||
) = ConversationEntity(
|
||||
accountId = accountId,
|
||||
id = id,
|
||||
order = order,
|
||||
accounts = accounts.map { it.toEntity() },
|
||||
unread = unread,
|
||||
lastStatus = lastStatus!!.toEntity(
|
||||
expanded = expanded,
|
||||
contentShowing = contentShowing,
|
||||
contentCollapsed = contentCollapsed
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -27,7 +27,10 @@ class ConversationLoadStateAdapter(
|
|||
private val retryCallback: () -> Unit
|
||||
) : LoadStateAdapter<BindingHolder<ItemNetworkStateBinding>>() {
|
||||
|
||||
override fun onBindViewHolder(holder: BindingHolder<ItemNetworkStateBinding>, loadState: LoadState) {
|
||||
override fun onBindViewHolder(
|
||||
holder: BindingHolder<ItemNetworkStateBinding>,
|
||||
loadState: LoadState
|
||||
) {
|
||||
val binding = holder.binding
|
||||
binding.progressBar.visible(loadState == LoadState.Loading)
|
||||
binding.retryButton.visible(loadState is LoadState.Error)
|
||||
|
|
@ -47,7 +50,11 @@ class ConversationLoadStateAdapter(
|
|||
parent: ViewGroup,
|
||||
loadState: LoadState
|
||||
): BindingHolder<ItemNetworkStateBinding> {
|
||||
val binding = ItemNetworkStateBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
val binding = ItemNetworkStateBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
return BindingHolder(binding)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ data class ConversationViewData(
|
|||
accountId: Long,
|
||||
favourited: Boolean = lastStatus.status.favourited,
|
||||
bookmarked: Boolean = lastStatus.status.bookmarked,
|
||||
muted: Boolean = lastStatus.status.muted ?: false,
|
||||
muted: Boolean = lastStatus.status.muted,
|
||||
poll: Poll? = lastStatus.status.poll,
|
||||
expanded: Boolean = lastStatus.isExpanded,
|
||||
collapsed: Boolean = lastStatus.isCollapsed,
|
||||
|
|
@ -57,7 +57,7 @@ data class ConversationViewData(
|
|||
fun StatusViewData.Concrete.toConversationStatusEntity(
|
||||
favourited: Boolean = status.favourited,
|
||||
bookmarked: Boolean = status.bookmarked,
|
||||
muted: Boolean = status.muted ?: false,
|
||||
muted: Boolean = status.muted,
|
||||
poll: Poll? = status.poll,
|
||||
expanded: Boolean = isExpanded,
|
||||
collapsed: Boolean = isCollapsed,
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
|
|||
if (payloads == null) {
|
||||
TimelineAccount account = status.getAccount();
|
||||
|
||||
setupCollapsedState(statusViewData.isCollapsible(), statusViewData.isCollapsed(), statusViewData.isExpanded(), statusViewData.getSpoilerText(), listener);
|
||||
setupCollapsedState(statusViewData.isCollapsible(), statusViewData.isCollapsed(), statusViewData.isExpanded(), status.getSpoilerText(), listener);
|
||||
|
||||
setDisplayName(account.getDisplayName(), account.getEmojis(), statusDisplayOptions);
|
||||
setUsername(account.getUsername());
|
||||
|
|
@ -144,7 +144,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
|
|||
ImageView avatarView = avatars[i];
|
||||
if (i < accounts.size()) {
|
||||
ImageLoadingHelper.loadAvatar(accounts.get(i).getAvatar(), avatarView,
|
||||
avatarRadius48dp, statusDisplayOptions.animateAvatars());
|
||||
avatarRadius48dp, statusDisplayOptions.animateAvatars(), null);
|
||||
avatarView.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
avatarView.setVisibility(View.GONE);
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import com.google.android.material.color.MaterialColors
|
|||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.StatusListActivity
|
||||
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
|
||||
import com.keylesspalace.tusky.appstore.ConversationsLoadingEvent
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
|
||||
import com.keylesspalace.tusky.components.account.AccountActivity
|
||||
|
|
@ -54,6 +55,7 @@ import com.keylesspalace.tusky.settings.PrefKeys
|
|||
import com.keylesspalace.tusky.util.CardViewMode
|
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.isAnyLoading
|
||||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.viewdata.AttachmentViewData
|
||||
|
|
@ -61,12 +63,12 @@ import com.mikepenz.iconics.IconicsDrawable
|
|||
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
|
||||
import com.mikepenz.iconics.utils.colorInt
|
||||
import com.mikepenz.iconics.utils.sizeDp
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.DurationUnit
|
||||
import kotlin.time.toDuration
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ConversationsFragment :
|
||||
SFragment(),
|
||||
|
|
@ -89,7 +91,11 @@ class ConversationsFragment :
|
|||
|
||||
private var hideFab = false
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
return inflater.inflate(R.layout.fragment_timeline, container, false)
|
||||
}
|
||||
|
||||
|
|
@ -99,14 +105,14 @@ class ConversationsFragment :
|
|||
val preferences = PreferenceManager.getDefaultSharedPreferences(view.context)
|
||||
|
||||
val statusDisplayOptions = StatusDisplayOptions(
|
||||
animateAvatars = preferences.getBoolean("animateGifAvatars", false),
|
||||
animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false),
|
||||
mediaPreviewEnabled = accountManager.activeAccount?.mediaPreviewEnabled ?: true,
|
||||
useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false),
|
||||
showBotOverlay = preferences.getBoolean("showBotOverlay", true),
|
||||
useBlurhash = preferences.getBoolean("useBlurhash", true),
|
||||
useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false),
|
||||
showBotOverlay = preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true),
|
||||
useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true),
|
||||
cardViewMode = CardViewMode.NONE,
|
||||
confirmReblogs = preferences.getBoolean("confirmReblogs", true),
|
||||
confirmFavourites = preferences.getBoolean("confirmFavourites", false),
|
||||
confirmReblogs = preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true),
|
||||
confirmFavourites = preferences.getBoolean(PrefKeys.CONFIRM_FAVOURITES, false),
|
||||
hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false),
|
||||
animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false),
|
||||
showStatsInline = preferences.getBoolean(PrefKeys.SHOW_STATS_INLINE, false),
|
||||
|
|
@ -128,18 +134,37 @@ class ConversationsFragment :
|
|||
binding.statusView.hide()
|
||||
binding.progressBar.hide()
|
||||
|
||||
if (loadState.isAnyLoading()) {
|
||||
lifecycleScope.launch {
|
||||
eventHub.dispatch(
|
||||
ConversationsLoadingEvent(
|
||||
accountManager.activeAccount?.accountId ?: ""
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (adapter.itemCount == 0) {
|
||||
when (loadState.refresh) {
|
||||
is LoadState.NotLoading -> {
|
||||
if (loadState.append is LoadState.NotLoading && loadState.source.refresh is LoadState.NotLoading) {
|
||||
binding.statusView.show()
|
||||
binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null)
|
||||
binding.statusView.setup(
|
||||
R.drawable.elephant_friend_empty,
|
||||
R.string.message_empty,
|
||||
null
|
||||
)
|
||||
binding.statusView.showHelp(R.string.help_empty_conversations)
|
||||
}
|
||||
}
|
||||
|
||||
is LoadState.Error -> {
|
||||
binding.statusView.show()
|
||||
binding.statusView.setup((loadState.refresh as LoadState.Error).error) { refreshContent() }
|
||||
binding.statusView.setup(
|
||||
(loadState.refresh as LoadState.Error).error
|
||||
) { refreshContent() }
|
||||
}
|
||||
|
||||
is LoadState.Loading -> {
|
||||
binding.progressBar.show()
|
||||
}
|
||||
|
|
@ -223,6 +248,7 @@ class ConversationsFragment :
|
|||
refreshContent()
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
|
@ -231,11 +257,14 @@ class ConversationsFragment :
|
|||
binding.recyclerView.setHasFixedSize(true)
|
||||
binding.recyclerView.layoutManager = LinearLayoutManager(context)
|
||||
|
||||
binding.recyclerView.addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL))
|
||||
binding.recyclerView.addItemDecoration(
|
||||
DividerItemDecoration(context, DividerItemDecoration.VERTICAL)
|
||||
)
|
||||
|
||||
(binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
|
||||
|
||||
binding.recyclerView.adapter = adapter.withLoadStateFooter(ConversationLoadStateAdapter(adapter::retry))
|
||||
binding.recyclerView.adapter =
|
||||
adapter.withLoadStateFooter(ConversationLoadStateAdapter(adapter::retry))
|
||||
}
|
||||
|
||||
private fun refreshContent() {
|
||||
|
|
@ -263,13 +292,15 @@ class ConversationsFragment :
|
|||
}
|
||||
}
|
||||
|
||||
override val onMoreTranslate: ((translate: Boolean, position: Int) -> Unit)? = null
|
||||
|
||||
override fun onMore(view: View, position: Int) {
|
||||
adapter.peek(position)?.let { conversation ->
|
||||
|
||||
val popup = PopupMenu(requireContext(), view)
|
||||
popup.inflate(R.menu.conversation_more)
|
||||
|
||||
if (conversation.lastStatus.status.muted == true) {
|
||||
if (conversation.lastStatus.status.muted) {
|
||||
popup.menu.removeItem(R.id.status_mute_conversation)
|
||||
} else {
|
||||
popup.menu.removeItem(R.id.status_unmute_conversation)
|
||||
|
|
@ -289,7 +320,11 @@ class ConversationsFragment :
|
|||
|
||||
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
|
||||
adapter.peek(position)?.let { conversation ->
|
||||
viewMedia(attachmentIndex, AttachmentViewData.list(conversation.lastStatus.status), view)
|
||||
viewMedia(
|
||||
attachmentIndex,
|
||||
AttachmentViewData.list(conversation.lastStatus.status),
|
||||
view
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -361,6 +396,10 @@ class ConversationsFragment :
|
|||
}
|
||||
}
|
||||
|
||||
override fun onUntranslate(position: Int) {
|
||||
// not needed
|
||||
}
|
||||
|
||||
private fun deleteConversation(conversation: ConversationViewData) {
|
||||
AlertDialog.Builder(requireContext())
|
||||
.setMessage(R.string.dialog_delete_conversation_warning)
|
||||
|
|
@ -377,6 +416,7 @@ class ConversationsFragment :
|
|||
PrefKeys.FAB_HIDE -> {
|
||||
hideFab = sharedPreferences.getBoolean(PrefKeys.FAB_HIDE, false)
|
||||
}
|
||||
|
||||
PrefKeys.MEDIA_PREVIEW_ENABLED -> {
|
||||
val enabled = accountManager.activeAccount!!.mediaPreviewEnabled
|
||||
val oldMediaPreviewEnabled = adapter.mediaPreviewEnabled
|
||||
|
|
|
|||
|
|
@ -38,7 +38,10 @@ class ConversationsRemoteMediator(
|
|||
}
|
||||
|
||||
try {
|
||||
val conversationsResponse = api.getConversations(maxId = nextKey, limit = state.config.pageSize)
|
||||
val conversationsResponse = api.getConversations(
|
||||
maxId = nextKey,
|
||||
limit = state.config.pageSize
|
||||
)
|
||||
|
||||
val conversations = conversationsResponse.body()
|
||||
if (!conversationsResponse.isSuccessful || conversations == null) {
|
||||
|
|
|
|||
|
|
@ -29,9 +29,9 @@ import com.keylesspalace.tusky.db.AppDatabase
|
|||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.usecase.TimelineCases
|
||||
import com.keylesspalace.tusky.util.EmptyPagingSource
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
class ConversationsViewModel @Inject constructor(
|
||||
private val timelineCases: TimelineCases,
|
||||
|
|
@ -91,7 +91,11 @@ class ConversationsViewModel @Inject constructor(
|
|||
|
||||
fun voteInPoll(choices: List<Int>, conversation: ConversationViewData) {
|
||||
viewModelScope.launch {
|
||||
timelineCases.voteInPoll(conversation.lastStatus.id, conversation.lastStatus.status.poll?.id!!, choices)
|
||||
timelineCases.voteInPoll(
|
||||
conversation.lastStatus.id,
|
||||
conversation.lastStatus.status.poll?.id!!,
|
||||
choices
|
||||
)
|
||||
.fold({ poll ->
|
||||
val newConversation = conversation.toEntity(
|
||||
accountId = accountManager.activeAccount!!.id,
|
||||
|
|
@ -155,12 +159,12 @@ class ConversationsViewModel @Inject constructor(
|
|||
try {
|
||||
timelineCases.muteConversation(
|
||||
conversation.lastStatus.id,
|
||||
!(conversation.lastStatus.status.muted ?: false)
|
||||
!conversation.lastStatus.status.muted
|
||||
)
|
||||
|
||||
val newConversation = conversation.toEntity(
|
||||
accountId = accountManager.activeAccount!!.id,
|
||||
muted = !(conversation.lastStatus.status.muted ?: false)
|
||||
muted = !conversation.lastStatus.status.muted
|
||||
)
|
||||
|
||||
database.conversationDao().insert(newConversation)
|
||||
|
|
|
|||
|
|
@ -1,15 +1,14 @@
|
|||
package com.keylesspalace.tusky.components.instancemute
|
||||
package com.keylesspalace.tusky.components.domainblocks
|
||||
|
||||
import android.os.Bundle
|
||||
import com.keylesspalace.tusky.BaseActivity
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.components.instancemute.fragment.InstanceListFragment
|
||||
import com.keylesspalace.tusky.databinding.ActivityAccountListBinding
|
||||
import dagger.android.DispatchingAndroidInjector
|
||||
import dagger.android.HasAndroidInjector
|
||||
import javax.inject.Inject
|
||||
|
||||
class InstanceListActivity : BaseActivity(), HasAndroidInjector {
|
||||
class DomainBlocksActivity : BaseActivity(), HasAndroidInjector {
|
||||
|
||||
@Inject
|
||||
lateinit var androidInjector: DispatchingAndroidInjector<Any>
|
||||
|
|
@ -28,7 +27,7 @@ class InstanceListActivity : BaseActivity(), HasAndroidInjector {
|
|||
|
||||
supportFragmentManager
|
||||
.beginTransaction()
|
||||
.replace(R.id.fragment_container, InstanceListFragment())
|
||||
.replace(R.id.fragment_container, DomainBlocksFragment())
|
||||
.commit()
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
package com.keylesspalace.tusky.components.domainblocks
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.paging.PagingDataAdapter
|
||||
import com.keylesspalace.tusky.components.followedtags.FollowedTagsAdapter.Companion.STRING_COMPARATOR
|
||||
import com.keylesspalace.tusky.databinding.ItemBlockedDomainBinding
|
||||
import com.keylesspalace.tusky.util.BindingHolder
|
||||
|
||||
class DomainBlocksAdapter(
|
||||
private val onUnmute: (String) -> Unit
|
||||
) : PagingDataAdapter<String, BindingHolder<ItemBlockedDomainBinding>>(STRING_COMPARATOR) {
|
||||
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
viewType: Int
|
||||
): BindingHolder<ItemBlockedDomainBinding> {
|
||||
val binding = ItemBlockedDomainBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
return BindingHolder(binding)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: BindingHolder<ItemBlockedDomainBinding>, position: Int) {
|
||||
getItem(position)?.let { instance ->
|
||||
holder.binding.blockedDomain.text = instance
|
||||
holder.binding.blockedDomainUnblock.setOnClickListener {
|
||||
onUnmute(instance)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
package com.keylesspalace.tusky.components.domainblocks
|
||||
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.paging.LoadState
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.databinding.FragmentDomainBlocksBinding
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class DomainBlocksFragment : Fragment(R.layout.fragment_domain_blocks), Injectable {
|
||||
|
||||
@Inject
|
||||
lateinit var viewModelFactory: ViewModelFactory
|
||||
|
||||
private val binding by viewBinding(FragmentDomainBlocksBinding::bind)
|
||||
|
||||
private val viewModel: DomainBlocksViewModel by viewModels { viewModelFactory }
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val adapter = DomainBlocksAdapter(viewModel::unblock)
|
||||
|
||||
binding.recyclerView.setHasFixedSize(true)
|
||||
binding.recyclerView.addItemDecoration(
|
||||
DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL)
|
||||
)
|
||||
binding.recyclerView.adapter = adapter
|
||||
binding.recyclerView.layoutManager = LinearLayoutManager(view.context)
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
viewModel.uiEvents.collect { event ->
|
||||
showSnackbar(event)
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
viewModel.domainPager.collectLatest { pagingData ->
|
||||
adapter.submitData(pagingData)
|
||||
}
|
||||
}
|
||||
|
||||
adapter.addLoadStateListener { loadState ->
|
||||
binding.progressBar.visible(
|
||||
loadState.refresh == LoadState.Loading && adapter.itemCount == 0
|
||||
)
|
||||
|
||||
if (loadState.refresh is LoadState.Error) {
|
||||
binding.recyclerView.hide()
|
||||
binding.messageView.show()
|
||||
val errorState = loadState.refresh as LoadState.Error
|
||||
binding.messageView.setup(errorState.error) { adapter.retry() }
|
||||
Log.w(TAG, "error loading blocked domains", errorState.error)
|
||||
} else if (loadState.refresh is LoadState.NotLoading && adapter.itemCount == 0) {
|
||||
binding.recyclerView.hide()
|
||||
binding.messageView.show()
|
||||
binding.messageView.setup(R.drawable.elephant_friend_empty, R.string.message_empty)
|
||||
} else {
|
||||
binding.recyclerView.show()
|
||||
binding.messageView.hide()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showSnackbar(event: SnackbarEvent) {
|
||||
val message = if (event.throwable == null) {
|
||||
getString(event.message, event.domain)
|
||||
} else {
|
||||
Log.w(TAG, event.throwable)
|
||||
val error = event.throwable.localizedMessage ?: getString(R.string.ui_error_unknown)
|
||||
getString(event.message, event.domain, error)
|
||||
}
|
||||
|
||||
Snackbar.make(binding.recyclerView, message, Snackbar.LENGTH_LONG)
|
||||
.setTextMaxLines(5)
|
||||
.setAction(event.actionText, event.action)
|
||||
.show()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "DomainBlocksFragment"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package com.keylesspalace.tusky.components.domainblocks
|
||||
|
||||
import androidx.paging.PagingSource
|
||||
import androidx.paging.PagingState
|
||||
|
||||
class DomainBlocksPagingSource(
|
||||
private val domains: List<String>,
|
||||
private val nextKey: String?
|
||||
) : PagingSource<String, String>() {
|
||||
override fun getRefreshKey(state: PagingState<String, String>): String? = null
|
||||
|
||||
override suspend fun load(params: LoadParams<String>): LoadResult<String, String> {
|
||||
return if (params is LoadParams.Refresh) {
|
||||
LoadResult.Page(domains, null, nextKey)
|
||||
} else {
|
||||
LoadResult.Page(emptyList(), null, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
package com.keylesspalace.tusky.components.domainblocks
|
||||
|
||||
import androidx.paging.ExperimentalPagingApi
|
||||
import androidx.paging.LoadType
|
||||
import androidx.paging.PagingState
|
||||
import androidx.paging.RemoteMediator
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.HttpHeaderLink
|
||||
import retrofit2.HttpException
|
||||
import retrofit2.Response
|
||||
|
||||
@OptIn(ExperimentalPagingApi::class)
|
||||
class DomainBlocksRemoteMediator(
|
||||
private val api: MastodonApi,
|
||||
private val repository: DomainBlocksRepository
|
||||
) : RemoteMediator<String, String>() {
|
||||
|
||||
override suspend fun load(
|
||||
loadType: LoadType,
|
||||
state: PagingState<String, String>
|
||||
): MediatorResult {
|
||||
return try {
|
||||
val response = request(loadType)
|
||||
?: return MediatorResult.Success(endOfPaginationReached = true)
|
||||
|
||||
return applyResponse(response)
|
||||
} catch (e: Exception) {
|
||||
MediatorResult.Error(e)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun request(loadType: LoadType): Response<List<String>>? {
|
||||
return when (loadType) {
|
||||
LoadType.PREPEND -> null
|
||||
LoadType.APPEND -> api.domainBlocks(maxId = repository.nextKey)
|
||||
LoadType.REFRESH -> {
|
||||
repository.nextKey = null
|
||||
repository.domains.clear()
|
||||
api.domainBlocks()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyResponse(response: Response<List<String>>): MediatorResult {
|
||||
val tags = response.body()
|
||||
if (!response.isSuccessful || tags == null) {
|
||||
return MediatorResult.Error(HttpException(response))
|
||||
}
|
||||
|
||||
val links = HttpHeaderLink.parse(response.headers()["Link"])
|
||||
repository.nextKey = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter("max_id")
|
||||
repository.domains.addAll(tags)
|
||||
repository.invalidate()
|
||||
|
||||
return MediatorResult.Success(endOfPaginationReached = repository.nextKey == null)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
/*
|
||||
* Copyright 2023 Tusky Contributors
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>.
|
||||
*/
|
||||
|
||||
package com.keylesspalace.tusky.components.domainblocks
|
||||
|
||||
import androidx.paging.ExperimentalPagingApi
|
||||
import androidx.paging.InvalidatingPagingSourceFactory
|
||||
import androidx.paging.Pager
|
||||
import androidx.paging.PagingConfig
|
||||
import androidx.paging.PagingSource
|
||||
import at.connyduck.calladapter.networkresult.NetworkResult
|
||||
import at.connyduck.calladapter.networkresult.onSuccess
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import javax.inject.Inject
|
||||
|
||||
class DomainBlocksRepository @Inject constructor(
|
||||
private val api: MastodonApi
|
||||
) {
|
||||
val domains: MutableList<String> = mutableListOf()
|
||||
var nextKey: String? = null
|
||||
|
||||
private var factory = InvalidatingPagingSourceFactory {
|
||||
DomainBlocksPagingSource(domains.toList(), nextKey)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalPagingApi::class)
|
||||
val domainPager = Pager(
|
||||
config = PagingConfig(pageSize = PAGE_SIZE, initialLoadSize = PAGE_SIZE),
|
||||
remoteMediator = DomainBlocksRemoteMediator(api, this),
|
||||
pagingSourceFactory = factory
|
||||
).flow
|
||||
|
||||
/** Invalidate the active paging source, see [PagingSource.invalidate] */
|
||||
fun invalidate() {
|
||||
factory.invalidate()
|
||||
}
|
||||
|
||||
suspend fun block(domain: String): NetworkResult<Unit> {
|
||||
return api.blockDomain(domain).onSuccess {
|
||||
domains.add(domain)
|
||||
factory.invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun unblock(domain: String): NetworkResult<Unit> {
|
||||
return api.unblockDomain(domain).onSuccess {
|
||||
domains.remove(domain)
|
||||
factory.invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PAGE_SIZE = 20
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue