Merge remote-tracking branch 'tuskyapp/develop'
# Conflicts: # .gitignore # README.md # app/build.gradle # app/src/green/res/mipmap-hdpi/ic_launcher.png # app/src/green/res/mipmap-mdpi/ic_launcher.png # app/src/green/res/mipmap-xhdpi/ic_launcher.png # app/src/green/res/mipmap-xxhdpi/ic_launcher.png # app/src/green/res/mipmap-xxxhdpi/ic_launcher.png # app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt # app/src/main/java/com/keylesspalace/tusky/MainActivity.kt # app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt # app/src/main/java/com/keylesspalace/tusky/TabData.kt # app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java # app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java # app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt # app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt # app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt # app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt # app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt # app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt # app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesActivity.kt # app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt # app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt # app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt # app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt # app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt # app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt # app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt # app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt # app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt # app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt # app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt # app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt # app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt # app/src/main/java/com/keylesspalace/tusky/entity/NewStatus.kt # app/src/main/java/com/keylesspalace/tusky/entity/Status.kt # app/src/main/java/com/keylesspalace/tusky/entity/TimelineAccount.kt # app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java # app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt # app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt # app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt # app/src/main/java/com/keylesspalace/tusky/util/StatusParsingHelper.kt # app/src/main/res/layout/activity_about.xml # app/src/main/res/layout/activity_main.xml # app/src/main/res/layout/item_status.xml # app/src/main/res/layout/item_status_notification.xml # app/src/main/res/values-ar/strings.xml # app/src/main/res/values-be/strings.xml # app/src/main/res/values-bn-rBD/strings.xml # app/src/main/res/values-ca/strings.xml # app/src/main/res/values-cy/strings.xml # app/src/main/res/values-de/strings.xml # app/src/main/res/values-es/strings.xml # app/src/main/res/values-eu/strings.xml # app/src/main/res/values-fa/strings.xml # app/src/main/res/values-fr/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-in/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-lv/strings.xml # app/src/main/res/values-nb-rNO/strings.xml # app/src/main/res/values-night/theme_colors.xml # app/src/main/res/values-oc/strings.xml # app/src/main/res/values-pl/strings.xml # app/src/main/res/values-pt-rBR/strings.xml # app/src/main/res/values-ru/strings.xml # app/src/main/res/values-sa/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/attrs.xml # app/src/main/res/values/styles.xml # app/src/main/res/values/theme_colors.xml # app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt # app/src/test/java/com/keylesspalace/tusky/FilterV1Test.kt # app/src/test/java/com/keylesspalace/tusky/components/timeline/StatusMocker.kt # app/src/test/java/com/keylesspalace/tusky/db/TimelineDaoTest.kt # app/src/test/java/com/keylesspalace/tusky/usecase/TimelineCasesTest.kt # app/src/test/java/com/keylesspalace/tusky/util/RickRollTest.kt # assets/tusky_banner.xcf # fastlane/metadata/android/ca/changelogs/58.txt # fastlane/metadata/android/ca/full_description.txt # fastlane/metadata/android/de/changelogs/58.txt # fastlane/metadata/android/de/changelogs/61.txt # fastlane/metadata/android/de/changelogs/67.txt # fastlane/metadata/android/de/changelogs/68.txt # fastlane/metadata/android/de/changelogs/70.txt # fastlane/metadata/android/de/changelogs/72.txt # fastlane/metadata/android/de/changelogs/74.txt # fastlane/metadata/android/de/changelogs/77.txt # fastlane/metadata/android/de/changelogs/80.txt # fastlane/metadata/android/de/changelogs/82.txt # fastlane/metadata/android/de/changelogs/83.txt # fastlane/metadata/android/de/changelogs/87.txt # fastlane/metadata/android/de/changelogs/89.txt # fastlane/metadata/android/de/changelogs/94.txt # fastlane/metadata/android/de/full_description.txt # fastlane/metadata/android/de/short_description.txt # fastlane/metadata/android/fa/changelogs/58.txt # fastlane/metadata/android/it/changelogs/58.txt # gradle.properties # gradle/libs.versions.toml # instance-build.gradle
|
@ -0,0 +1,17 @@
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
indent_size = 4
|
||||||
|
indent_style = space
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
[*.{yml,yaml}]
|
||||||
|
indent_size = 2
|
|
@ -0,0 +1,4 @@
|
||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
*.bat text eol=crlf
|
||||||
|
*.jar binary
|
|
@ -3,10 +3,11 @@
|
||||||
/local.properties
|
/local.properties
|
||||||
/.idea
|
/.idea
|
||||||
.DS_Store
|
.DS_Store
|
||||||
/build
|
build
|
||||||
/captures
|
/captures
|
||||||
.externalNativeBuild
|
.externalNativeBuild
|
||||||
app/release
|
app/release
|
||||||
|
app-release.apk
|
||||||
app/green
|
app/green
|
||||||
app/blue
|
app/blue
|
||||||
app/src/main/gen
|
app/src/main/gen
|
||||||
|
|
After Width: | Height: | Size: 27 KiB |
|
@ -0,0 +1,98 @@
|
||||||
|
# Tusky changelog
|
||||||
|
|
||||||
|
## Unreleased or Tusky Nightly
|
||||||
|
|
||||||
|
### New features and other improvements
|
||||||
|
|
||||||
|
### Significant bug fixes
|
||||||
|
|
||||||
|
## v22.0 beta 6
|
||||||
|
|
||||||
|
### Significant bug fixes
|
||||||
|
|
||||||
|
- **Save reading position in the Notifications tab more frequently**, [PR#3685](https://github.com/tuskyapp/Tusky/pull/3685)
|
||||||
|
|
||||||
|
## v22.0 beta 5
|
||||||
|
|
||||||
|
## Significant bug fixes
|
||||||
|
|
||||||
|
- **Rolled back APNG library to fix broken animated emojis**, [PR#3676](https://github.com/tuskyapp/Tusky/pull/3676)
|
||||||
|
- **Save local copy of notification marker in case server does not support the API**, [PR#3672](https://github.com/tuskyapp/Tusky/pull/3672)
|
||||||
|
|
||||||
|
## v22.0 beta 4
|
||||||
|
|
||||||
|
### Significant bug fixes
|
||||||
|
|
||||||
|
- **Fixed repeated fetch of notifications if configured with multiple accounts**, [PR#3660](https://github.com/tuskyapp/Tusky/pull/3660)
|
||||||
|
|
||||||
|
## v22.0 beta 3
|
||||||
|
|
||||||
|
### Significant bug fixes
|
||||||
|
|
||||||
|
- **Fixed crash when viewing a thread**, [PR#3622](https://github.com/tuskyapp/Tusky/pull/3622)
|
||||||
|
- **Fixed crash processing Mastodon filters**, [PR#3634](https://github.com/tuskyapp/Tusky/pull/3634)
|
||||||
|
- **Links in bios of follow/follow request notifications are clickable**, [PR#3646](https://github.com/tuskyapp/Tusky/pull/3646)
|
||||||
|
- **Android Notifications updates**, [PR#3636](https://github.com/tuskyapp/Tusky/pull/3626)
|
||||||
|
- Android notification for a Mastodon notification should only be shown once
|
||||||
|
- Android notifications are grouped by Mastodon notification type (follow, mention, boost, etc)
|
||||||
|
- Potential for missing notifications has been removed
|
||||||
|
|
||||||
|
## v22.0 beta 2
|
||||||
|
|
||||||
|
### Significant bug fixes
|
||||||
|
|
||||||
|
- **Improved notification loading speed**, [PR#3598](https://github.com/tuskyapp/Tusky/pull/3598)
|
||||||
|
- **Restore showing 0/1/1+ for replies**, [PR#3590](https://github.com/tuskyapp/Tusky/pull/3590)
|
||||||
|
- **Show filter titles, not filter keywords, on filtered posts**, [PR#3589](https://github.com/tuskyapp/Tusky/pull/3589)
|
||||||
|
- **Fixed a bug where opening a status could open an unrelated link**, [PR#3600](https://github.com/tuskyapp/Tusky/pull/3600)
|
||||||
|
- **Show "Add" button in correct place when there are no filters**, [PR#3561](https://github.com/tuskyapp/Tusky/pull/3561)
|
||||||
|
- **Fixed assorted crashes**
|
||||||
|
|
||||||
|
## v22.0 beta 1
|
||||||
|
|
||||||
|
### New features and other improvements
|
||||||
|
|
||||||
|
- **View trending hashtags**, [PR#3149](https://github.com/tuskyapp/Tusky/pull/3149) by [@knossos](https://fosstodon.org/@knossos)
|
||||||
|
- View trending hashtags from the side menu, or by adding them to a new tab.
|
||||||
|
- **Edit image description and focus point**, [PR#3215](https://github.com/tuskyapp/Tusky/pull/3215) by [@Tak](https://mastodon.gamedev.place/@Tak)
|
||||||
|
- Edit image descriptions and focus points when editing posts.
|
||||||
|
- **View profile banner images**, [PR#3274](https://github.com/tuskyapp/Tusky/pull/3274) by [@Tak](https://mastodon.gamedev.place/@Tak)
|
||||||
|
- Tap the banner image on any profile to view it full size, save, share, etc.
|
||||||
|
- **Follow new hashtags**, [PR#3275](https://github.com/tuskyapp/Tusky/pull/3275) by [@nikclayton](https://mastodon.social/@nikclayton)
|
||||||
|
- Follow new hashtags from the "Followed hashtags" screen.
|
||||||
|
- **Better ordering when selecting languages**, [PR#3293](https://github.com/tuskyapp/Tusky/pull/3293) by [@Tak](https://mastodon.gamedev.place/@Tak)
|
||||||
|
- Tusky will prioritise the language of the post being replied to, your default posting language, configured Tusky languages, and configured system languages when ordering the list of languages to post in.
|
||||||
|
- **"Load more" break is more prominent**, [PR#3376](https://github.com/tuskyapp/Tusky/pull/3376) by [@lakoja](https://freiburg.social/@lakoja)
|
||||||
|
- Adjusted the design so the "Load more" break in a timeline is more obvious.
|
||||||
|
- **Add "Refresh" menu**, [PR#3121](https://github.com/tuskyapp/Tusky/pull/3121) by [@nikclayton](https://mastodon.social/@nikclayton)
|
||||||
|
- Tusky timelines can now be refreshed from a menu as well as swiping, making this accessible to assistive devices.
|
||||||
|
- **Notifications timeline improvements**, [PR#3159](https://github.com/tuskyapp/Tusky/pull/3159) by [@nikclayton](https://mastodon.social/@nikclayton)
|
||||||
|
- Notifications no longer need to "Load more", they are loaded automatically as you scroll.
|
||||||
|
- Errors when interacting with notifications are displayed to the user, with a "Retry" option.
|
||||||
|
- **Show the difference between versions of a post**, [PR#3314](https://github.com/tuskyapp/Tusky/pull/3314) by [@nikclayton](https://mastodon.social/@nikclayton)
|
||||||
|
- Viewing the edits to a post highlights the differences (text that was added or deleted) between the different versions.
|
||||||
|
- **Support Mastodon v4 filters**, [PR#3188](https://github.com/tuskyapp/Tusky/pull/3188) by [@Tak](https://mastodon.gamedev.place/@Tak)
|
||||||
|
- Mastodon v4 introduced additional [filtering controls](https://docs.joinmastodon.org/user/moderating/#filters).
|
||||||
|
- **Option to show post statistics in the timeline**, [PR#3413](https://github.com/tuskyapp/Tusky/pull/3413)
|
||||||
|
- Tusky can now (optionally) show the number of replies, reposts, and favourites a post has received, in the timeline.
|
||||||
|
- **Expanded tappable area for links, hashtags, and mentions in a post**, [PR#3382](https://github.com/tuskyapp/Tusky/pull/3382) by [@nikclayton](https://mastodon.social/@nikclayton)
|
||||||
|
- Links, hashtags, and mentions in a post now react to taps that are a little above, below, or to the side of the tappable text, making them more accessible.
|
||||||
|
|
||||||
|
### Significant bug fixes
|
||||||
|
|
||||||
|
- **Remember selected tab and position**, [PR#3255](https://github.com/tuskyapp/Tusky/pull/3255) by [@nikclayton](https://mastodon.social/@nikclayton)
|
||||||
|
- Changing your tab settings (adding, removing, re-ordering) remembers your reading position in those tabs.
|
||||||
|
- **Show player controls during audio playback**, [PR#3286](https://github.com/tuskyapp/Tusky/pull/3286) by [@EricFrohnhoefer](https://mastodon.social/@EricFrohnhoefer)
|
||||||
|
- A regression from v21.0 where the media player controls could not be used.
|
||||||
|
- **Keep notifications until read**, [PR#3312](https://github.com/tuskyapp/Tusky/pull/3312) by [@lakoja](https://freiburg.social/@lakoja)
|
||||||
|
- Opening Tusky would dismiss all active Tusky Android notifications.
|
||||||
|
- **Fix copying URLs at the end of a post**, [PR#3380](https://github.com/tuskyapp/Tusky/pull/3380) by [@nikclayton](https://mastodon.social/@nikclayton)
|
||||||
|
- Copying a URL from the end of a post could include an extra Unicode whitespace character, making the URL unusable as is.
|
||||||
|
- **Correctly display mixed RTL and LTR text in profiles**, [PR#3328](https://github.com/tuskyapp/Tusky/pull/3328) by [@nikclayton](https://mastodon.social/@nikclayton)
|
||||||
|
- Profile text that contained a mix of right-to-left and left-to-right writing directions would display incorrectly.
|
||||||
|
- **Stop showing duplicates of edited posts in threads**, [PR#3377](https://github.com/tuskyapp/Tusky/pull/3377) by [@Tak](https://mastodon.gamedev.place/@Tak)
|
||||||
|
- Editing a post in thread view would show the old and new version of the post in the thread.
|
||||||
|
- **Correct post length calculation**, [PR#3392](https://github.com/tuskyapp/Tusky/pull/3392) by [@nikclayton](https://mastodon.social/@nikclayton)
|
||||||
|
- In a post that mentioned a user (e.g., `@tusky@mastodon.social`) Tusky was incorrectly including the `@mastodon.social` part when calculating the post's length, leading to incorrect "This post is too long" errors.
|
||||||
|
- **Always publish image captions**, [PR#3421](https://github.com/tuskyapp/Tusky/pull/3421) by [@lakoja](https://freiburg.social/@lakoja)
|
||||||
|
- Finishing editing an image caption before the image had finished loading would lose the caption.
|
|
@ -1,51 +1,53 @@
|
||||||
# Contributing
|
# Contributing
|
||||||
|
|
||||||
## Getting Started
|
Thanks for your interest in contributing to Tusky! Here are some informations to help you get started.
|
||||||
1. Fork the repository on the GitHub page by clicking the Fork button. This makes a fork of the project under your GitHub account.
|
|
||||||
2. Clone your fork to your machine. ```git clone https://github.com/<Your_Username>/Tusky```
|
|
||||||
3. Create a new branch named after your change. ```git checkout -b your-change-name``` (```checkout``` switches to a branch, ```-b``` specifies that the branch is a new one)
|
|
||||||
|
|
||||||
## Making Changes
|
If you have any questions, don't hesitate to open an issue or join our [development chat on Matrix](https://riot.im/app/#/room/#Tusky:matrix.org).
|
||||||
|
|
||||||
### Text
|
## Contributing translations
|
||||||
All English text that will be visible to users should be put in ```app/src/main/res/values/strings.xml```. Any text that is missing in a translation will fall back to the version in this file. Be aware that anything added to this file will need to be translated, so be very concise with wording and try to add as few things as possible. Look for existing strings to use first. If there is untranslatable text that you don't want to keep as a string constant in a Java class, you can use the string resource file ```app/src/main/res/values/donottranslate.xml```.
|
|
||||||
|
|
||||||
### Translation
|
Translations are managed on our [Weblate](https://weblate.tusky.app/projects/tusky/tusky/). You can create an account and translate texts through the interface, no coding knowledge required.
|
||||||
Translations are done through our [Weblate](https://weblate.tusky.app/projects/tusky/tusky/).
|
To add a new language, click on the 'Start a new translation' button on at the bottom of the page.
|
||||||
To add a new language, click on the 'Start a new translation' button on at the bottom of the page.
|
|
||||||
|
- Use gender-neutral language
|
||||||
|
- Address users informally (e.g. in German "du" and never "Sie")
|
||||||
|
|
||||||
|
## Contributing code
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
You should have a general understanding of Android development and Git.
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
We try to follow the [Guide to app architecture](https://developer.android.com/topic/architecture).
|
||||||
|
|
||||||
### Kotlin
|
### Kotlin
|
||||||
This project is in the process of migrating to Kotlin, all new code must be written in 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).
|
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`.
|
You can check the codestyle by running `./gradlew ktlintCheck`.
|
||||||
|
|
||||||
### Java
|
### Text
|
||||||
Existing code in Java should follow the [Android Style Guide](https://source.android.com/source/code-style), which is what Android uses for their own source code. ```@Nullable``` and ```@NotNull``` annotations are really helpful for Kotlin interoperability. Please don't submit new features written in Java.
|
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.
|
||||||
|
Try to keep texts friendly and concise.
|
||||||
|
If there is untranslatable text that you don't want to keep as a string constant in Kotlin code, you can use the string resource file `app/src/main/res/values/donottranslate.xml`.
|
||||||
|
|
||||||
### Viewbinding
|
### Viewbinding
|
||||||
We use [Viewbinding](https://developer.android.com/topic/libraries/view-binding) to reference views. No contribution using another mechanism will be accepted.
|
We use [Viewbinding](https://developer.android.com/topic/libraries/view-binding) to reference views. No contribution using another mechanism will be accepted.
|
||||||
There are useful extensions in `src/main/java/com/keylesspalace/tusky/util/ViewExtensions.kt` that make working with viewbinding easier.
|
There are useful extensions in `src/main/java/com/keylesspalace/tusky/util/ViewExtensions.kt` that make working with viewbinding easier.
|
||||||
|
|
||||||
### Visuals
|
### Visuals
|
||||||
There are three themes in the app, so any visual changes should be checked with each of them to ensure they look appropriate no matter which theme is selected. Usually, you can use existing color attributes like ```?attr/colorPrimary``` and ```?attr/textColorSecondary```.
|
There are three themes in the app, so any visual changes should be checked with each of them to ensure they look appropriate no matter which theme is selected. Usually, you can use existing color attributes like `?attr/colorPrimary` and `?attr/textColorSecondary`.
|
||||||
|
All icons are from the Material iconset, find new icons [here](https://fonts.google.com/icons) (Google fonts) or [here](https://fonts.google.com/icons) (community contributions).
|
||||||
|
|
||||||
### Saving
|
### Accessibility
|
||||||
Any time you get a good chunk of work done it's good to make a commit. You can either uses Android Studio's built-in UI for doing this or running the commands:
|
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.
|
||||||
```
|
|
||||||
git add .
|
|
||||||
git commit -m "Describe the changes in this commit here."
|
|
||||||
```
|
|
||||||
|
|
||||||
## Submitting Your Changes
|
### Supported servers
|
||||||
1. Make sure your branch is up-to-date with the ```develop``` branch. Run:
|
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.
|
||||||
```
|
|
||||||
git fetch
|
|
||||||
git rebase origin/develop
|
|
||||||
```
|
|
||||||
It may refuse to start the rebase if there's changes that haven't been committed, so make sure you've added and committed everything. If there were changes on develop to any of the parts of files you worked on, a conflict will arise when you rebase. [Resolving a merge conflict](https://help.github.com/articles/resolving-a-merge-conflict-using-the-command-line) is a good guide to help with this. After committing the resolution, you can run ```git rebase --continue``` to finish the rebase. If you want to cancel, like if you make some mistake in resolving the conflict, you can always do ```git rebase --abort```.
|
|
||||||
|
|
||||||
2. Push your local branch to your fork on GitHub by running ```git push origin your-change-name```.
|
## Troubleshooting / FAQ
|
||||||
3. Then, go to the original project page and make a pull request. Select your fork/branch and use ```develop``` as the base branch.
|
|
||||||
4. Wait for feedback on your pull request and be ready to make some changes
|
|
||||||
|
|
||||||
If you have any questions, don't hesitate to open an issue or contact [Tusky@mastodon.social](https://mastodon.social/@Tusky). Please also ask before you start implementing a new big feature.
|
- 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/)
|
||||||
|
|
|
@ -16,7 +16,7 @@ Tusky is a beautiful Android client for [Mastodon](https://github.com/mastodon/m
|
||||||
- Multi-Account support
|
- Multi-Account support
|
||||||
- Dark, light and black themes with the possibility to auto-switch based on the time of day
|
- Dark, light and black themes with the possibility to auto-switch based on the time of day
|
||||||
- Drafts - compose posts and save them for later
|
- Drafts - compose posts and save them for later
|
||||||
- Choose between different emoji styles
|
- Choose between different emoji styles
|
||||||
- Optimized for all screen sizes
|
- Optimized for all screen sizes
|
||||||
- Completely open-source - no non-free dependencies like Google services
|
- Completely open-source - no non-free dependencies like Google services
|
||||||
|
|
||||||
|
|
22
Release.md
|
@ -7,20 +7,21 @@ This approach of having ~500 user on the nightly releases and ~5000 users on the
|
||||||
## Beta
|
## Beta
|
||||||
|
|
||||||
- Make sure all new features are well tested by Nightly users and all issues addressed as good as possible. Check GitHub issues, Google Play crash reports, messages on `@Tusky@mastodon.social`, emails on `tusky@connyduck.at`, #Tusky hashtag.
|
- Make sure all new features are well tested by Nightly users and all issues addressed as good as possible. Check GitHub issues, Google Play crash reports, messages on `@Tusky@mastodon.social`, emails on `tusky@connyduck.at`, #Tusky hashtag.
|
||||||
- Merge the latest Weblate translations (Weblate -> Repository maintenance -> commit all changes, then merge the automatic PRs by @nailyk-weblate on GitHub)
|
- Merge the latest Weblate translations (Weblate -> Repository maintenance -> commit all changes, then merge the automatic PRs by @nailyk-weblate on GitHub)
|
||||||
- Check all the translations (Android Studio shows warnings on problems). Sometimes translators add faulty translations that would crash Tusky in this language, e.g. wrong number of formatting parameters. In this case it is usually easiest to just delete the string. [Example cleanup](https://github.com/tuskyapp/Tusky/commit/feaea70af418c77178985144a2d01a8e97725dfd).
|
- Check all the translations (Android Studio shows warnings on problems). Sometimes translators add faulty translations that would crash Tusky in this language, e.g. wrong number of formatting parameters. In this case it is usually easiest to just delete the string. [Example cleanup](https://github.com/tuskyapp/Tusky/commit/feaea70af418c77178985144a2d01a8e97725dfd).
|
||||||
- Update `versionCode` and `versionName` in `app/build.gradle`
|
- Update `versionCode` and `versionName` in `app/build.gradle`
|
||||||
- Add a new short changelog under `fastlane/metadata/android/en-US/changelogs`. Use the next versionCode as the filename. This is so translators on Weblate have the duration of the beta to translate the changelog and F-Droid users will see it in their language on the release. If another beta is released, the changelogs have to be renamed. Note that changelogs shouldn't be over 500 characters or F-Droid will truncate them.
|
- Add a new short changelog under `fastlane/metadata/android/en-US/changelogs`. Use the next versionCode as the filename. This is so translators on Weblate have the duration of the beta to translate the changelog and F-Droid users will see it in their language on the release. If another beta is released, the changelogs have to be renamed. Note that changelogs shouldn't be over 500 characters or F-Droid will truncate them.
|
||||||
- Build the app as apk and as app bundle.
|
|
||||||
- Do a quick check to make sure the build doesn't crash. Also install it over the last release to make sure the database migrations are correct.
|
|
||||||
- Merge `develop` into `main`
|
- Merge `develop` into `main`
|
||||||
- Create a new [GitHub release](https://github.com/tuskyapp/Tusky/releases).
|
- Create a new [GitHub release](https://github.com/tuskyapp/Tusky/releases).
|
||||||
- Tag the head of `main`.
|
- Tag the head of `main`.
|
||||||
- Create an exhaustive changelog by going through all commits since the last release.
|
- Create an exhaustive changelog by going through all commits since the last release.
|
||||||
- Attach the apk, adb and mapping.txt files to the release
|
|
||||||
- Mark the release as being a pre-release.
|
- Mark the release as being a pre-release.
|
||||||
|
- Bitrise will automatically build and upload the release to the Internal Testing track on Google Play.
|
||||||
|
- Do a quick check to make sure the build doesn't crash, e.g. by enrolling yourself into the test track.
|
||||||
|
- In case there are any problems, delete the GitHub release, fix the problems and start again
|
||||||
|
- Download the build as apk from Google Play (App Bundle Explorer -> chose the release -> Downloads -> Signed, universal APK). Attach it to the GitHub Release.
|
||||||
|
- Create a new Open Testing release on Google Play. Reuse the build from the Internal Testing track.
|
||||||
- Create a merge request at F-Droid. [Example](https://gitlab.com/fdroid/fdroiddata/-/merge_requests/11218) (F-Droid automatically picks up new release tags, but not beta ones. This could probably be changed somehow.)
|
- Create a merge request at F-Droid. [Example](https://gitlab.com/fdroid/fdroiddata/-/merge_requests/11218) (F-Droid automatically picks up new release tags, but not beta ones. This could probably be changed somehow.)
|
||||||
- Upload the release to the Open Testing track on Google Play.
|
|
||||||
- Announce the release
|
- Announce the release
|
||||||
|
|
||||||
## Full release
|
## Full release
|
||||||
|
@ -28,15 +29,16 @@ This approach of having ~500 user on the nightly releases and ~5000 users on the
|
||||||
- Make sure all new features are well tested by beta users and all issues addressed as good as possible. Check GitHub issues, Google Play crash reports, messages on `@Tusky@mastodon.social`, #Tusky hashtag.
|
- Make sure all new features are well tested by beta users and all issues addressed as good as possible. Check GitHub issues, Google Play crash reports, messages on `@Tusky@mastodon.social`, #Tusky hashtag.
|
||||||
- Merge the latest Weblate translations (Weblate -> Repository maintenance -> commit all changes, then merge the automatic PRs by @nailyk-weblate on GitHub)
|
- Merge the latest Weblate translations (Weblate -> Repository maintenance -> commit all changes, then merge the automatic PRs by @nailyk-weblate on GitHub)
|
||||||
- Update `versionCode` and `versionName` in `app/build.gradle`
|
- Update `versionCode` and `versionName` in `app/build.gradle`
|
||||||
- Build the app as apk and as app bundle.
|
|
||||||
- Do a quick check to make sure the build doesn't crash. Also install it over the last release to make sure the database migrations are correct.
|
|
||||||
- Merge `develop` into `main`
|
- Merge `develop` into `main`
|
||||||
- Create a new [GitHub release](https://github.com/tuskyapp/Tusky/releases).
|
- Create a new [GitHub release](https://github.com/tuskyapp/Tusky/releases).
|
||||||
- Tag the head of `main`.
|
- Tag the head of `main`.
|
||||||
- Resuse the changelog from the beta release, or create a new one if this is only a minor release.
|
- Reuse the changelog from the beta release, or create a new one if this is only a minor release.
|
||||||
- Attach the apk, adb and mapping.txt files to the release
|
|
||||||
- (F-Droid will automatically detect and build the release)
|
- (F-Droid will automatically detect and build the release)
|
||||||
- Upload the release to the Production track on Google Play.
|
- Bitrise will automatically build and upload the release to the Internal Testing track on Google Play.
|
||||||
|
- Do a quick check to make sure the build doesn't crash, e.g. by enrolling yourself into the test track.
|
||||||
|
- In case there are any problems, delete the GitHub release, fix the problems and start again
|
||||||
|
- Download the build as apk from Google Play (App Bundle Explorer -> chose the release -> Downloads -> Signed, universal APK). Attach it to the GitHub Release.
|
||||||
|
- Create a new full release on Google Play. Reuse the build from the Internal Testing track.
|
||||||
- update the download link on the homepage ([repo](https://github.com/tuskyapp/tuskyapp.github.io))
|
- update the download link on the homepage ([repo](https://github.com/tuskyapp/tuskyapp.github.io))
|
||||||
- Announce the release
|
- Announce the release
|
||||||
|
|
||||||
|
|
|
@ -1,2 +0,0 @@
|
||||||
/build
|
|
||||||
app-release.apk
|
|
117
app/build.gradle
|
@ -1,30 +1,33 @@
|
||||||
apply plugin: 'com.android.application'
|
plugins {
|
||||||
apply plugin: 'kotlin-android'
|
alias(libs.plugins.android.application)
|
||||||
apply plugin: 'kotlin-kapt'
|
alias(libs.plugins.kotlin.android)
|
||||||
apply plugin: 'kotlin-parcelize'
|
alias(libs.plugins.kotlin.kapt)
|
||||||
|
alias(libs.plugins.kotlin.parcelize)
|
||||||
apply from: "../instance-build.gradle"
|
|
||||||
|
|
||||||
def getGitSha = {
|
|
||||||
def stdout = new ByteArrayOutputStream()
|
|
||||||
try {
|
|
||||||
exec {
|
|
||||||
commandLine 'git', 'rev-parse', '--short', 'HEAD'
|
|
||||||
standardOutput = stdout
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
return "unknown"
|
|
||||||
}
|
|
||||||
return stdout.toString().trim()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
apply from: 'getGitSha.gradle'
|
||||||
|
|
||||||
|
final def gitSha = ext.getGitSha()
|
||||||
|
|
||||||
|
// The app name
|
||||||
|
final def APP_NAME = "Yuito"
|
||||||
|
// The application id. Must be unique, e.g. based on your domain
|
||||||
|
final def APP_ID = "net.accelf.yuito"
|
||||||
|
// url of a custom app logo. Recommended size at least 600x600. Keep empty to use the Tusky elephant friend.
|
||||||
|
final def CUSTOM_LOGO_URL = ""
|
||||||
|
// e.g. mastodon.social. Keep empty to not suggest any instance on the signup screen
|
||||||
|
final def CUSTOM_INSTANCE = ""
|
||||||
|
// link to your support account. Will be linked on the about page when not empty.
|
||||||
|
final def SUPPORT_ACCOUNT_URL = "https://odakyu.app/@ars42525"
|
||||||
|
|
||||||
android {
|
android {
|
||||||
compileSdkVersion 33
|
compileSdk 33
|
||||||
|
namespace "com.keylesspalace.tusky"
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId 'net.accelf.yuito'
|
applicationId APP_ID
|
||||||
namespace 'com.keylesspalace.tusky'
|
namespace "com.keylesspalace.tusky"
|
||||||
minSdkVersion 23
|
minSdk 23
|
||||||
targetSdkVersion 33
|
targetSdk 33
|
||||||
versionCode 55
|
versionCode 55
|
||||||
versionName '4.5.2'
|
versionName '4.5.2'
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
@ -35,12 +38,6 @@ android {
|
||||||
buildConfigField("String", "CUSTOM_LOGO_URL", "\"$CUSTOM_LOGO_URL\"")
|
buildConfigField("String", "CUSTOM_LOGO_URL", "\"$CUSTOM_LOGO_URL\"")
|
||||||
buildConfigField("String", "CUSTOM_INSTANCE", "\"$CUSTOM_INSTANCE\"")
|
buildConfigField("String", "CUSTOM_INSTANCE", "\"$CUSTOM_INSTANCE\"")
|
||||||
buildConfigField("String", "SUPPORT_ACCOUNT_URL", "\"$SUPPORT_ACCOUNT_URL\"")
|
buildConfigField("String", "SUPPORT_ACCOUNT_URL", "\"$SUPPORT_ACCOUNT_URL\"")
|
||||||
|
|
||||||
kapt {
|
|
||||||
arguments {
|
|
||||||
arg("room.schemaLocation", "$projectDir/schemas")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
debug {
|
debug {
|
||||||
|
@ -58,20 +55,26 @@ android {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
flavorDimensions "color"
|
flavorDimensions += "color"
|
||||||
productFlavors {
|
productFlavors {
|
||||||
blue {}
|
blue {}
|
||||||
green {
|
green {
|
||||||
resValue "string", "app_name", APP_NAME + " Test"
|
resValue "string", "app_name", APP_NAME + " Test"
|
||||||
applicationIdSuffix ".test"
|
applicationIdSuffix ".test"
|
||||||
versionNameSuffix "-" + getGitSha()
|
versionNameSuffix "-" + gitSha
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
lintOptions {
|
lint {
|
||||||
disable 'MissingTranslation'
|
lintConfig file("lint.xml")
|
||||||
|
// Regenerate by deleting app/lint-baseline.xml, then run:
|
||||||
|
// ./gradlew lintBlueDebug
|
||||||
|
baseline = file("lint-baseline.xml")
|
||||||
}
|
}
|
||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
|
buildConfig true
|
||||||
|
resValues true
|
||||||
viewBinding true
|
viewBinding true
|
||||||
}
|
}
|
||||||
testOptions {
|
testOptions {
|
||||||
|
@ -81,17 +84,19 @@ android {
|
||||||
}
|
}
|
||||||
unitTests.all {
|
unitTests.all {
|
||||||
systemProperty 'robolectric.logging.enabled', 'true'
|
systemProperty 'robolectric.logging.enabled', 'true'
|
||||||
|
systemProperty 'robolectric.lazyload', 'ON'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sourceSets {
|
sourceSets {
|
||||||
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
|
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
packagingOptions {
|
// Exclude unneeded files added by libraries
|
||||||
// Exclude unneeded files added by libraries
|
packagingOptions.resources.excludes += [
|
||||||
exclude 'LICENSE_OFL'
|
'LICENSE_OFL',
|
||||||
exclude 'LICENSE_UNICODE'
|
'LICENSE_UNICODE',
|
||||||
}
|
]
|
||||||
|
|
||||||
bundle {
|
bundle {
|
||||||
language {
|
language {
|
||||||
// bundle all languages in every apk so the dynamic language switching works
|
// bundle all languages in every apk so the dynamic language switching works
|
||||||
|
@ -102,14 +107,34 @@ android {
|
||||||
includeInApk false
|
includeInApk false
|
||||||
includeInBundle 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}_" +
|
||||||
|
"${variant.flavorName}_${buildType.name}.apk"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
repositories {
|
kapt {
|
||||||
maven {
|
arguments {
|
||||||
url 'https://maven.accelf.net/'
|
arg("room.schemaLocation", "$projectDir/schemas")
|
||||||
|
arg("room.incremental", "true")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
configurations {
|
||||||
|
// JNI-only libraries don't play nicely with Robolectric
|
||||||
|
// see https://github.com/tuskyapp/Tusky/pull/3367
|
||||||
|
testImplementation.exclude group: "org.conscrypt", module: "conscrypt-android"
|
||||||
|
testRuntime.exclude group: "org.conscrypt", module: "conscrypt-android"
|
||||||
|
}
|
||||||
|
|
||||||
// library versions are in PROJECT_ROOT/gradle/libs.versions.toml
|
// library versions are in PROJECT_ROOT/gradle/libs.versions.toml
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation libs.kotlinx.coroutines.android
|
implementation libs.kotlinx.coroutines.android
|
||||||
|
@ -145,11 +170,7 @@ dependencies {
|
||||||
implementation libs.photoview
|
implementation libs.photoview
|
||||||
|
|
||||||
implementation libs.bundles.material.drawer
|
implementation libs.bundles.material.drawer
|
||||||
implementation libs.material.typeface, {
|
implementation libs.material.typeface
|
||||||
artifact {
|
|
||||||
type = "aar"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
implementation libs.image.cropper
|
implementation libs.image.cropper
|
||||||
|
|
||||||
|
@ -158,6 +179,8 @@ dependencies {
|
||||||
implementation libs.bouncycastle
|
implementation libs.bouncycastle
|
||||||
implementation libs.unified.push
|
implementation libs.unified.push
|
||||||
|
|
||||||
|
implementation libs.bundles.xmldiff
|
||||||
|
|
||||||
testImplementation libs.androidx.test.junit
|
testImplementation libs.androidx.test.junit
|
||||||
testImplementation libs.robolectric
|
testImplementation libs.robolectric
|
||||||
testImplementation libs.bundles.mockito
|
testImplementation libs.bundles.mockito
|
||||||
|
@ -165,6 +188,8 @@ dependencies {
|
||||||
testImplementation libs.androidx.core.testing
|
testImplementation libs.androidx.core.testing
|
||||||
testImplementation libs.kotlinx.coroutines.test
|
testImplementation libs.kotlinx.coroutines.test
|
||||||
testImplementation libs.androidx.work.testing
|
testImplementation libs.androidx.work.testing
|
||||||
|
testImplementation libs.truth
|
||||||
|
testImplementation libs.turbine
|
||||||
|
|
||||||
androidTestImplementation libs.espresso.core
|
androidTestImplementation libs.espresso.core
|
||||||
androidTestImplementation libs.androidx.room.testing
|
androidTestImplementation libs.androidx.room.testing
|
||||||
|
|
|
@ -0,0 +1,27 @@
|
||||||
|
import org.gradle.api.provider.ValueSourceParameters
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
// Must wrap this in a ValueSource in order to get well-defined fail behavior without confusing Gradle on repeat builds.
|
||||||
|
abstract class GitShaValueSource implements ValueSource<String, ValueSourceParameters.None> {
|
||||||
|
@Inject abstract ExecOperations getExecOperations()
|
||||||
|
|
||||||
|
@Override String obtain() {
|
||||||
|
try {
|
||||||
|
def output = new ByteArrayOutputStream()
|
||||||
|
|
||||||
|
execOperations.exec {
|
||||||
|
it.commandLine 'git', 'rev-parse', '--short=8', 'HEAD'
|
||||||
|
it.standardOutput = output
|
||||||
|
}
|
||||||
|
return output.toString().trim()
|
||||||
|
} catch (GradleException ignore) {
|
||||||
|
// Git executable unavailable, or we are not building in a git repo. Fall through:
|
||||||
|
}
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export closure
|
||||||
|
ext.getGitSha = {
|
||||||
|
providers.of(GitShaValueSource) {}.get()
|
||||||
|
}
|
|
@ -0,0 +1,41 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
~ 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>.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<lint>
|
||||||
|
<!-- Missing translations are OK -->
|
||||||
|
<issue id="MissingTranslation" severity="ignore" />
|
||||||
|
|
||||||
|
<!-- Duplicate strings are OK. This can happen when e.g., "favourite" appears as both
|
||||||
|
a noun and a verb -->
|
||||||
|
<issue id="DuplicateStrings" severity="ignore" />
|
||||||
|
|
||||||
|
<!-- Resource IDs used in viewbinding are incorrectly reported as unused,
|
||||||
|
https://issuetracker.google.com/issues/204797401.
|
||||||
|
|
||||||
|
Disable these for the time being. -->
|
||||||
|
<issue id="UnusedIds" 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" />
|
||||||
|
|
||||||
|
<!-- Mark all other lint issues as errors -->
|
||||||
|
<issue id="all" severity="error" />
|
||||||
|
</lint>
|
Before Width: | Height: | Size: 1.7 KiB |
After Width: | Height: | Size: 3.7 KiB |
Before Width: | Height: | Size: 1.2 KiB |
After Width: | Height: | Size: 2.4 KiB |
Before Width: | Height: | Size: 2.5 KiB |
After Width: | Height: | Size: 5.2 KiB |
Before Width: | Height: | Size: 4.0 KiB |
After Width: | Height: | Size: 8.1 KiB |
Before Width: | Height: | Size: 5.8 KiB |
After Width: | Height: | Size: 11 KiB |
|
@ -123,7 +123,7 @@
|
||||||
<activity android:name=".EditProfileActivity" />
|
<activity android:name=".EditProfileActivity" />
|
||||||
<activity android:name=".components.preference.PreferencesActivity" />
|
<activity android:name=".components.preference.PreferencesActivity" />
|
||||||
<activity android:name=".StatusListActivity" />
|
<activity android:name=".StatusListActivity" />
|
||||||
<activity android:name=".AccountListActivity" />
|
<activity android:name=".components.accountlist.AccountListActivity" />
|
||||||
<activity android:name=".AboutActivity" />
|
<activity android:name=".AboutActivity" />
|
||||||
<activity android:name=".TabPreferenceActivity" />
|
<activity android:name=".TabPreferenceActivity" />
|
||||||
<activity
|
<activity
|
||||||
|
@ -143,7 +143,8 @@
|
||||||
</activity>
|
</activity>
|
||||||
<activity android:name=".ListsActivity" />
|
<activity android:name=".ListsActivity" />
|
||||||
<activity android:name=".LicenseActivity" />
|
<activity android:name=".LicenseActivity" />
|
||||||
<activity android:name=".FiltersActivity" />
|
<activity android:name=".components.filters.FiltersActivity" />
|
||||||
|
<activity android:name=".components.trending.TrendingActivity" />
|
||||||
<activity android:name=".components.followedtags.FollowedTagsActivity" />
|
<activity android:name=".components.followedtags.FollowedTagsActivity" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".components.report.ReportActivity"
|
android:name=".components.report.ReportActivity"
|
||||||
|
@ -152,9 +153,9 @@
|
||||||
<activity android:name=".components.scheduled.ScheduledStatusActivity" />
|
<activity android:name=".components.scheduled.ScheduledStatusActivity" />
|
||||||
<activity android:name=".components.announcements.AnnouncementsActivity" />
|
<activity android:name=".components.announcements.AnnouncementsActivity" />
|
||||||
<activity android:name=".components.drafts.DraftsActivity" />
|
<activity android:name=".components.drafts.DraftsActivity" />
|
||||||
|
<activity android:name="com.keylesspalace.tusky.components.filters.EditFilterActivity"
|
||||||
|
android:windowSoftInputMode="adjustResize" />
|
||||||
|
|
||||||
<receiver android:name=".receiver.NotificationClearBroadcastReceiver"
|
|
||||||
android:exported="false" />
|
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".receiver.SendStatusBroadcastReceiver"
|
android:name=".receiver.SendStatusBroadcastReceiver"
|
||||||
android:enabled="true"
|
android:enabled="true"
|
||||||
|
|
|
@ -61,7 +61,6 @@ class AboutActivity : BottomSheetActivity(), Injectable {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun TextView.setClickableTextWithoutUnderlines(@StringRes textId: Int) {
|
private fun TextView.setClickableTextWithoutUnderlines(@StringRes textId: Int) {
|
||||||
|
|
||||||
val text = SpannableString(context.getText(textId))
|
val text = SpannableString(context.getText(textId))
|
||||||
|
|
||||||
Linkify.addLinks(text, Linkify.WEB_URLS)
|
Linkify.addLinks(text, Linkify.WEB_URLS)
|
||||||
|
|
|
@ -41,6 +41,7 @@ import com.keylesspalace.tusky.util.emojify
|
||||||
import com.keylesspalace.tusky.util.hide
|
import com.keylesspalace.tusky.util.hide
|
||||||
import com.keylesspalace.tusky.util.loadAvatar
|
import com.keylesspalace.tusky.util.loadAvatar
|
||||||
import com.keylesspalace.tusky.util.show
|
import com.keylesspalace.tusky.util.show
|
||||||
|
import com.keylesspalace.tusky.util.unsafeLazy
|
||||||
import com.keylesspalace.tusky.util.viewBinding
|
import com.keylesspalace.tusky.util.viewBinding
|
||||||
import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel
|
import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel
|
||||||
import com.keylesspalace.tusky.viewmodel.State
|
import com.keylesspalace.tusky.viewmodel.State
|
||||||
|
@ -63,10 +64,10 @@ class AccountsInListFragment : DialogFragment(), Injectable {
|
||||||
private val adapter = Adapter()
|
private val adapter = Adapter()
|
||||||
private val searchAdapter = SearchAdapter()
|
private val searchAdapter = SearchAdapter()
|
||||||
|
|
||||||
private val radius by lazy { resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp) }
|
private val radius by unsafeLazy { resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp) }
|
||||||
private val pm by lazy { PreferenceManager.getDefaultSharedPreferences(requireContext()) }
|
private val pm by unsafeLazy { PreferenceManager.getDefaultSharedPreferences(requireContext()) }
|
||||||
private val animateAvatar by lazy { pm.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false) }
|
private val animateAvatar by unsafeLazy { pm.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false) }
|
||||||
private val animateEmojis by lazy { pm.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) }
|
private val animateEmojis by unsafeLazy { pm.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) }
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
@ -113,7 +114,7 @@ class AccountsInListFragment : DialogFragment(), Injectable {
|
||||||
binding.searchView.isSubmitButtonEnabled = true
|
binding.searchView.isSubmitButtonEnabled = true
|
||||||
binding.searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
|
binding.searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
|
||||||
override fun onQueryTextSubmit(query: String?): Boolean {
|
override fun onQueryTextSubmit(query: String?): Boolean {
|
||||||
viewModel.search(query ?: "")
|
viewModel.search(query.orEmpty())
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -152,12 +153,14 @@ class AccountsInListFragment : DialogFragment(), Injectable {
|
||||||
if (error is IOException) {
|
if (error is IOException) {
|
||||||
binding.messageView.setup(
|
binding.messageView.setup(
|
||||||
R.drawable.elephant_offline,
|
R.drawable.elephant_offline,
|
||||||
R.string.error_network, retryAction
|
R.string.error_network,
|
||||||
|
retryAction
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
binding.messageView.setup(
|
binding.messageView.setup(
|
||||||
R.drawable.elephant_error,
|
R.drawable.elephant_error,
|
||||||
R.string.error_generic, retryAction
|
R.string.error_generic,
|
||||||
|
retryAction
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -79,7 +79,7 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
|
||||||
/* set the taskdescription programmatically, the theme would turn it blue */
|
/* set the taskdescription programmatically, the theme would turn it blue */
|
||||||
String appName = getString(R.string.app_name);
|
String appName = getString(R.string.app_name);
|
||||||
Bitmap appIcon = BitmapFactory.decodeResource(getResources(), R.mipmap.ic_launcher);
|
Bitmap appIcon = BitmapFactory.decodeResource(getResources(), R.mipmap.ic_launcher);
|
||||||
int recentsBackgroundColor = MaterialColors.getColor(this, R.attr.colorSurface, Color.BLACK);
|
int recentsBackgroundColor = MaterialColors.getColor(this, com.google.android.material.R.attr.colorSurface, Color.BLACK);
|
||||||
|
|
||||||
setTaskDescription(new ActivityManager.TaskDescription(appName, appIcon, recentsBackgroundColor));
|
setTaskDescription(new ActivityManager.TaskDescription(appName, appIcon, recentsBackgroundColor));
|
||||||
|
|
||||||
|
@ -239,8 +239,6 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
|
||||||
}
|
}
|
||||||
if (permissionsToRequest.isEmpty()) {
|
if (permissionsToRequest.isEmpty()) {
|
||||||
int[] permissionsAlreadyGranted = new int[permissions.length];
|
int[] permissionsAlreadyGranted = new int[permissions.length];
|
||||||
for (int i = 0; i < permissionsAlreadyGranted.length; ++i)
|
|
||||||
permissionsAlreadyGranted[i] = PackageManager.PERMISSION_GRANTED;
|
|
||||||
requester.onRequestPermissionsResult(permissions, permissionsAlreadyGranted);
|
requester.onRequestPermissionsResult(permissions, permissionsAlreadyGranted);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
@ -93,8 +93,11 @@ abstract class BottomSheetActivity : BaseActivity() {
|
||||||
if (statuses.isNotEmpty()) {
|
if (statuses.isNotEmpty()) {
|
||||||
viewThread(statuses[0].id, statuses[0].url)
|
viewThread(statuses[0].id, statuses[0].url)
|
||||||
return@subscribe
|
return@subscribe
|
||||||
} else if (accounts.isNotEmpty()) {
|
}
|
||||||
viewAccount(accounts[0].id)
|
accounts.firstOrNull { it.url == url }?.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@subscribe
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -181,5 +184,5 @@ abstract class BottomSheetActivity : BaseActivity() {
|
||||||
|
|
||||||
enum class PostLookupFallbackBehavior {
|
enum class PostLookupFallbackBehavior {
|
||||||
OPEN_IN_BROWSER,
|
OPEN_IN_BROWSER,
|
||||||
DISPLAY_ERROR,
|
DISPLAY_ERROR
|
||||||
}
|
}
|
||||||
|
|
|
@ -34,6 +34,7 @@ import com.bumptech.glide.Glide
|
||||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||||
import com.bumptech.glide.load.resource.bitmap.FitCenter
|
import com.bumptech.glide.load.resource.bitmap.FitCenter
|
||||||
import com.bumptech.glide.load.resource.bitmap.RoundedCorners
|
import com.bumptech.glide.load.resource.bitmap.RoundedCorners
|
||||||
|
import com.canhub.cropper.CropImage
|
||||||
import com.canhub.cropper.CropImageContract
|
import com.canhub.cropper.CropImageContract
|
||||||
import com.canhub.cropper.options
|
import com.canhub.cropper.options
|
||||||
import com.google.android.material.snackbar.Snackbar
|
import com.google.android.material.snackbar.Snackbar
|
||||||
|
@ -80,14 +81,18 @@ class EditProfileActivity : BaseActivity(), Injectable {
|
||||||
}
|
}
|
||||||
|
|
||||||
private val cropImage = registerForActivityResult(CropImageContract()) { result ->
|
private val cropImage = registerForActivityResult(CropImageContract()) { result ->
|
||||||
if (result.isSuccessful) {
|
if (result is CropImage.CancelledResult) {
|
||||||
if (result.uriContent == viewModel.getAvatarUri()) {
|
return@registerForActivityResult
|
||||||
viewModel.newAvatarPicked()
|
}
|
||||||
} else {
|
|
||||||
viewModel.newHeaderPicked()
|
if (!result.isSuccessful) {
|
||||||
}
|
return@registerForActivityResult onPickFailure(result.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.uriContent == viewModel.getAvatarUri()) {
|
||||||
|
viewModel.newAvatarPicked()
|
||||||
} else {
|
} else {
|
||||||
onPickFailure(result.error)
|
viewModel.newHeaderPicked()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -131,12 +136,11 @@ class EditProfileActivity : BaseActivity(), Injectable {
|
||||||
is Success -> {
|
is Success -> {
|
||||||
val me = profileRes.data
|
val me = profileRes.data
|
||||||
if (me != null) {
|
if (me != null) {
|
||||||
|
|
||||||
binding.displayNameEditText.setText(me.intentionallyUseDisplayName)
|
binding.displayNameEditText.setText(me.intentionallyUseDisplayName)
|
||||||
binding.noteEditText.setText(me.source?.note)
|
binding.noteEditText.setText(me.source?.note)
|
||||||
binding.lockedCheckBox.isChecked = me.locked
|
binding.lockedCheckBox.isChecked = me.locked
|
||||||
|
|
||||||
accountFieldEditAdapter.setFields(me.source?.fields ?: emptyList())
|
accountFieldEditAdapter.setFields(me.source?.fields.orEmpty())
|
||||||
binding.addFieldButton.isVisible =
|
binding.addFieldButton.isVisible =
|
||||||
(me.source?.fields?.size ?: 0) < maxAccountFields
|
(me.source?.fields?.size ?: 0) < maxAccountFields
|
||||||
|
|
||||||
|
|
|
@ -1,183 +0,0 @@
|
||||||
package com.keylesspalace.tusky
|
|
||||||
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.text.format.DateUtils
|
|
||||||
import android.widget.AdapterView
|
|
||||||
import android.widget.ArrayAdapter
|
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.lifecycle.lifecycleScope
|
|
||||||
import at.connyduck.calladapter.networkresult.fold
|
|
||||||
import at.connyduck.calladapter.networkresult.getOrElse
|
|
||||||
import com.keylesspalace.tusky.appstore.EventHub
|
|
||||||
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
|
|
||||||
import com.keylesspalace.tusky.databinding.ActivityFiltersBinding
|
|
||||||
import com.keylesspalace.tusky.entity.Filter
|
|
||||||
import com.keylesspalace.tusky.network.MastodonApi
|
|
||||||
import com.keylesspalace.tusky.util.hide
|
|
||||||
import com.keylesspalace.tusky.util.show
|
|
||||||
import com.keylesspalace.tusky.util.viewBinding
|
|
||||||
import com.keylesspalace.tusky.view.getSecondsForDurationIndex
|
|
||||||
import com.keylesspalace.tusky.view.setupEditDialogForFilter
|
|
||||||
import com.keylesspalace.tusky.view.showAddFilterDialog
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import java.io.IOException
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
class FiltersActivity : BaseActivity() {
|
|
||||||
@Inject
|
|
||||||
lateinit var api: MastodonApi
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var eventHub: EventHub
|
|
||||||
|
|
||||||
private val binding by viewBinding(ActivityFiltersBinding::inflate)
|
|
||||||
|
|
||||||
private lateinit var context: String
|
|
||||||
private lateinit var filters: MutableList<Filter>
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
|
|
||||||
setContentView(binding.root)
|
|
||||||
setSupportActionBar(binding.includedToolbar.toolbar)
|
|
||||||
supportActionBar?.run {
|
|
||||||
// Back button
|
|
||||||
setDisplayHomeAsUpEnabled(true)
|
|
||||||
setDisplayShowHomeEnabled(true)
|
|
||||||
}
|
|
||||||
binding.addFilterButton.setOnClickListener {
|
|
||||||
showAddFilterDialog(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
title = intent?.getStringExtra(FILTERS_TITLE)
|
|
||||||
context = intent?.getStringExtra(FILTERS_CONTEXT)!!
|
|
||||||
loadFilters()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateFilter(id: String, phrase: String, filterContext: List<String>, irreversible: Boolean, wholeWord: Boolean, expiresInSeconds: Int?, itemIndex: Int) {
|
|
||||||
lifecycleScope.launch {
|
|
||||||
api.updateFilter(id, phrase, filterContext, irreversible, wholeWord, expiresInSeconds).fold(
|
|
||||||
{ updatedFilter ->
|
|
||||||
if (updatedFilter.context.contains(context)) {
|
|
||||||
filters[itemIndex] = updatedFilter
|
|
||||||
} else {
|
|
||||||
filters.removeAt(itemIndex)
|
|
||||||
}
|
|
||||||
refreshFilterDisplay()
|
|
||||||
eventHub.dispatch(PreferenceChangedEvent(context))
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Toast.makeText(this@FiltersActivity, "Error updating filter '$phrase'", Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun deleteFilter(itemIndex: Int) {
|
|
||||||
val filter = filters[itemIndex]
|
|
||||||
if (filter.context.size == 1) {
|
|
||||||
lifecycleScope.launch {
|
|
||||||
// This is the only context for this filter; delete it
|
|
||||||
api.deleteFilter(filters[itemIndex].id).fold(
|
|
||||||
{
|
|
||||||
filters.removeAt(itemIndex)
|
|
||||||
refreshFilterDisplay()
|
|
||||||
eventHub.dispatch(PreferenceChangedEvent(context))
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Toast.makeText(this@FiltersActivity, "Error deleting filter '${filters[itemIndex].phrase}'", Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Keep the filter, but remove it from this context
|
|
||||||
val oldFilter = filters[itemIndex]
|
|
||||||
val newFilter = Filter(
|
|
||||||
oldFilter.id, oldFilter.phrase, oldFilter.context.filter { c -> c != context },
|
|
||||||
oldFilter.expiresAt, oldFilter.irreversible, oldFilter.wholeWord
|
|
||||||
)
|
|
||||||
updateFilter(
|
|
||||||
newFilter.id, newFilter.phrase, newFilter.context, newFilter.irreversible, newFilter.wholeWord,
|
|
||||||
getSecondsForDurationIndex(-1, this, oldFilter.expiresAt), itemIndex
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun createFilter(phrase: String, wholeWord: Boolean, expiresInSeconds: Int? = null) {
|
|
||||||
lifecycleScope.launch {
|
|
||||||
api.createFilter(phrase, listOf(context), false, wholeWord, expiresInSeconds).fold(
|
|
||||||
{ filter ->
|
|
||||||
filters.add(filter)
|
|
||||||
refreshFilterDisplay()
|
|
||||||
eventHub.dispatch(PreferenceChangedEvent(context))
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Toast.makeText(this@FiltersActivity, "Error creating filter '$phrase'", Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun refreshFilterDisplay() {
|
|
||||||
binding.filtersView.adapter = ArrayAdapter(
|
|
||||||
this,
|
|
||||||
android.R.layout.simple_list_item_1,
|
|
||||||
filters.map { filter ->
|
|
||||||
if (filter.expiresAt == null) {
|
|
||||||
filter.phrase
|
|
||||||
} else {
|
|
||||||
getString(
|
|
||||||
R.string.filter_expiration_format,
|
|
||||||
filter.phrase,
|
|
||||||
DateUtils.getRelativeTimeSpanString(
|
|
||||||
filter.expiresAt.time,
|
|
||||||
System.currentTimeMillis(),
|
|
||||||
DateUtils.MINUTE_IN_MILLIS,
|
|
||||||
DateUtils.FORMAT_ABBREV_RELATIVE
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
binding.filtersView.onItemClickListener = AdapterView.OnItemClickListener { _, _, position, _ -> setupEditDialogForFilter(this, filters[position], position) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun loadFilters() {
|
|
||||||
|
|
||||||
binding.filterMessageView.hide()
|
|
||||||
binding.filtersView.hide()
|
|
||||||
binding.addFilterButton.hide()
|
|
||||||
binding.filterProgressBar.show()
|
|
||||||
|
|
||||||
lifecycleScope.launch {
|
|
||||||
val newFilters = api.getFilters().getOrElse {
|
|
||||||
binding.filterProgressBar.hide()
|
|
||||||
binding.filterMessageView.show()
|
|
||||||
if (it is IOException) {
|
|
||||||
binding.filterMessageView.setup(
|
|
||||||
R.drawable.elephant_offline,
|
|
||||||
R.string.error_network
|
|
||||||
) { loadFilters() }
|
|
||||||
} else {
|
|
||||||
binding.filterMessageView.setup(
|
|
||||||
R.drawable.elephant_error,
|
|
||||||
R.string.error_generic
|
|
||||||
) { loadFilters() }
|
|
||||||
}
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
filters = newFilters.filter { it.context.contains(context) }.toMutableList()
|
|
||||||
refreshFilterDisplay()
|
|
||||||
|
|
||||||
binding.filtersView.show()
|
|
||||||
binding.addFilterButton.show()
|
|
||||||
binding.filterProgressBar.hide()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val FILTERS_CONTEXT = "filters_context"
|
|
||||||
const val FILTERS_TITLE = "filters_title"
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -45,7 +45,6 @@ class LicenseActivity : BaseActivity() {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadFileIntoTextView(@RawRes fileId: Int, textView: TextView) {
|
private fun loadFileIntoTextView(@RawRes fileId: Int, textView: TextView) {
|
||||||
|
|
||||||
val sb = StringBuilder()
|
val sb = StringBuilder()
|
||||||
|
|
||||||
val br = BufferedReader(InputStreamReader(resources.openRawResource(fileId)))
|
val br = BufferedReader(InputStreamReader(resources.openRawResource(fileId)))
|
||||||
|
|
|
@ -31,6 +31,7 @@ import android.widget.TextView
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.core.widget.doOnTextChanged
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.recyclerview.widget.DiffUtil
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
import androidx.recyclerview.widget.DividerItemDecoration
|
import androidx.recyclerview.widget.DividerItemDecoration
|
||||||
|
@ -45,7 +46,6 @@ import com.keylesspalace.tusky.di.Injectable
|
||||||
import com.keylesspalace.tusky.di.ViewModelFactory
|
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||||
import com.keylesspalace.tusky.entity.MastoList
|
import com.keylesspalace.tusky.entity.MastoList
|
||||||
import com.keylesspalace.tusky.util.hide
|
import com.keylesspalace.tusky.util.hide
|
||||||
import com.keylesspalace.tusky.util.onTextChanged
|
|
||||||
import com.keylesspalace.tusky.util.show
|
import com.keylesspalace.tusky.util.show
|
||||||
import com.keylesspalace.tusky.util.viewBinding
|
import com.keylesspalace.tusky.util.viewBinding
|
||||||
import com.keylesspalace.tusky.util.visible
|
import com.keylesspalace.tusky.util.visible
|
||||||
|
@ -101,6 +101,9 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
|
||||||
DividerItemDecoration(this, DividerItemDecoration.VERTICAL)
|
DividerItemDecoration(this, DividerItemDecoration.VERTICAL)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
binding.swipeRefreshLayout.setOnRefreshListener { viewModel.retryLoading() }
|
||||||
|
binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
|
||||||
|
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
viewModel.state.collect(this@ListsActivity::update)
|
viewModel.state.collect(this@ListsActivity::update)
|
||||||
}
|
}
|
||||||
|
@ -113,7 +116,6 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
|
||||||
|
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
viewModel.events.collect { event ->
|
viewModel.events.collect { event ->
|
||||||
@Suppress("WHEN_ENUM_CAN_BE_NULL_IN_JAVA")
|
|
||||||
when (event) {
|
when (event) {
|
||||||
Event.CREATE_ERROR -> showMessage(R.string.error_create_list)
|
Event.CREATE_ERROR -> showMessage(R.string.error_create_list)
|
||||||
Event.RENAME_ERROR -> showMessage(R.string.error_rename_list)
|
Event.RENAME_ERROR -> showMessage(R.string.error_rename_list)
|
||||||
|
@ -135,8 +137,11 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
|
||||||
val dialog = AlertDialog.Builder(this)
|
val dialog = AlertDialog.Builder(this)
|
||||||
.setView(layout)
|
.setView(layout)
|
||||||
.setPositiveButton(
|
.setPositiveButton(
|
||||||
if (list == null) R.string.action_create_list
|
if (list == null) {
|
||||||
else R.string.action_rename_list
|
R.string.action_create_list
|
||||||
|
} else {
|
||||||
|
R.string.action_rename_list
|
||||||
|
}
|
||||||
) { _, _ ->
|
) { _, _ ->
|
||||||
onPickedDialogName(editText.text, list?.id)
|
onPickedDialogName(editText.text, list?.id)
|
||||||
}
|
}
|
||||||
|
@ -144,8 +149,8 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
|
||||||
.show()
|
.show()
|
||||||
|
|
||||||
val positiveButton = dialog.getButton(Dialog.BUTTON_POSITIVE)
|
val positiveButton = dialog.getButton(Dialog.BUTTON_POSITIVE)
|
||||||
editText.onTextChanged { s, _, _, _ ->
|
editText.doOnTextChanged { s, _, _, _ ->
|
||||||
positiveButton.isEnabled = s.isNotBlank()
|
positiveButton.isEnabled = s?.isNotBlank() == true
|
||||||
}
|
}
|
||||||
editText.setText(list?.title)
|
editText.setText(list?.title)
|
||||||
editText.text?.let { editText.setSelection(it.length) }
|
editText.text?.let { editText.setSelection(it.length) }
|
||||||
|
@ -164,6 +169,7 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
|
||||||
private fun update(state: ListsViewModel.State) {
|
private fun update(state: ListsViewModel.State) {
|
||||||
adapter.submitList(state.lists)
|
adapter.submitList(state.lists)
|
||||||
binding.progressBar.visible(state.loadingState == LOADING)
|
binding.progressBar.visible(state.loadingState == LOADING)
|
||||||
|
binding.swipeRefreshLayout.isRefreshing = state.loadingState == LOADING
|
||||||
when (state.loadingState) {
|
when (state.loadingState) {
|
||||||
INITIAL, LOADING -> binding.messageView.hide()
|
INITIAL, LOADING -> binding.messageView.hide()
|
||||||
ERROR_NETWORK -> {
|
ERROR_NETWORK -> {
|
||||||
|
@ -182,7 +188,8 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
|
||||||
if (state.lists.isEmpty()) {
|
if (state.lists.isEmpty()) {
|
||||||
binding.messageView.show()
|
binding.messageView.show()
|
||||||
binding.messageView.setup(
|
binding.messageView.setup(
|
||||||
R.drawable.elephant_friend_empty, R.string.message_empty,
|
R.drawable.elephant_friend_empty,
|
||||||
|
R.string.message_empty,
|
||||||
null
|
null
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
@ -193,7 +200,9 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
|
||||||
|
|
||||||
private fun showMessage(@StringRes messageId: Int) {
|
private fun showMessage(@StringRes messageId: Int) {
|
||||||
Snackbar.make(
|
Snackbar.make(
|
||||||
binding.listsRecycler, messageId, Snackbar.LENGTH_SHORT
|
binding.listsRecycler,
|
||||||
|
messageId,
|
||||||
|
Snackbar.LENGTH_SHORT
|
||||||
).show()
|
).show()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -30,11 +30,13 @@ import android.graphics.drawable.BitmapDrawable
|
||||||
import android.graphics.drawable.Drawable
|
import android.graphics.drawable.Drawable
|
||||||
import android.graphics.drawable.InsetDrawable
|
import android.graphics.drawable.InsetDrawable
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.text.TextUtils
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.util.TypedValue
|
import android.util.TypedValue
|
||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
|
import android.view.Menu
|
||||||
|
import android.view.MenuInflater
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
|
@ -42,20 +44,19 @@ import android.widget.ImageView
|
||||||
import androidx.activity.OnBackPressedCallback
|
import androidx.activity.OnBackPressedCallback
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.appcompat.content.res.AppCompatResources
|
||||||
import androidx.appcompat.view.menu.MenuBuilder
|
import androidx.appcompat.view.menu.MenuBuilder
|
||||||
import androidx.appcompat.widget.PopupMenu
|
import androidx.appcompat.widget.PopupMenu
|
||||||
import androidx.appcompat.widget.ThemeUtils
|
|
||||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||||
import androidx.core.app.ActivityCompat
|
import androidx.core.app.ActivityCompat
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.content.pm.ShortcutManagerCompat
|
import androidx.core.content.pm.ShortcutManagerCompat
|
||||||
import androidx.core.view.GravityCompat
|
import androidx.core.view.GravityCompat
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.core.view.MenuProvider
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import androidx.viewpager2.widget.MarginPageTransformer
|
import androidx.viewpager2.widget.MarginPageTransformer
|
||||||
import at.connyduck.calladapter.networkresult.fold
|
import at.connyduck.calladapter.networkresult.fold
|
||||||
import autodispose2.androidx.lifecycle.autoDispose
|
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.bumptech.glide.RequestManager
|
import com.bumptech.glide.RequestManager
|
||||||
import com.bumptech.glide.load.resource.bitmap.RoundedCorners
|
import com.bumptech.glide.load.resource.bitmap.RoundedCorners
|
||||||
|
@ -68,18 +69,19 @@ import com.google.android.material.tabs.TabLayout.OnTabSelectedListener
|
||||||
import com.google.android.material.tabs.TabLayoutMediator
|
import com.google.android.material.tabs.TabLayoutMediator
|
||||||
import com.keylesspalace.tusky.appstore.AnnouncementReadEvent
|
import com.keylesspalace.tusky.appstore.AnnouncementReadEvent
|
||||||
import com.keylesspalace.tusky.appstore.CacheUpdater
|
import com.keylesspalace.tusky.appstore.CacheUpdater
|
||||||
import com.keylesspalace.tusky.appstore.Event
|
|
||||||
import com.keylesspalace.tusky.appstore.EventHub
|
import com.keylesspalace.tusky.appstore.EventHub
|
||||||
import com.keylesspalace.tusky.appstore.MainTabsChangedEvent
|
import com.keylesspalace.tusky.appstore.MainTabsChangedEvent
|
||||||
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
|
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
|
||||||
import com.keylesspalace.tusky.appstore.ProfileEditedEvent
|
import com.keylesspalace.tusky.appstore.ProfileEditedEvent
|
||||||
import com.keylesspalace.tusky.components.account.AccountActivity
|
import com.keylesspalace.tusky.components.account.AccountActivity
|
||||||
|
import com.keylesspalace.tusky.components.accountlist.AccountListActivity
|
||||||
import com.keylesspalace.tusky.components.announcements.AnnouncementsActivity
|
import com.keylesspalace.tusky.components.announcements.AnnouncementsActivity
|
||||||
import com.keylesspalace.tusky.components.compose.ComposeActivity
|
import com.keylesspalace.tusky.components.compose.ComposeActivity
|
||||||
import com.keylesspalace.tusky.components.compose.ComposeActivity.Companion.canHandleMimeType
|
import com.keylesspalace.tusky.components.compose.ComposeActivity.Companion.canHandleMimeType
|
||||||
import com.keylesspalace.tusky.components.drafts.DraftsActivity
|
import com.keylesspalace.tusky.components.drafts.DraftsActivity
|
||||||
import com.keylesspalace.tusky.components.login.LoginActivity
|
import com.keylesspalace.tusky.components.login.LoginActivity
|
||||||
import com.keylesspalace.tusky.components.notifications.NotificationHelper
|
import com.keylesspalace.tusky.components.notifications.NotificationHelper
|
||||||
|
import com.keylesspalace.tusky.components.notifications.NotificationsFragment
|
||||||
import com.keylesspalace.tusky.components.notifications.disableAllNotifications
|
import com.keylesspalace.tusky.components.notifications.disableAllNotifications
|
||||||
import com.keylesspalace.tusky.components.notifications.enablePushNotificationsWithFallback
|
import com.keylesspalace.tusky.components.notifications.enablePushNotificationsWithFallback
|
||||||
import com.keylesspalace.tusky.components.notifications.showMigrationNoticeIfNecessary
|
import com.keylesspalace.tusky.components.notifications.showMigrationNoticeIfNecessary
|
||||||
|
@ -87,15 +89,16 @@ import com.keylesspalace.tusky.components.preference.PreferencesActivity
|
||||||
import com.keylesspalace.tusky.components.scheduled.ScheduledStatusActivity
|
import com.keylesspalace.tusky.components.scheduled.ScheduledStatusActivity
|
||||||
import com.keylesspalace.tusky.components.search.SearchActivity
|
import com.keylesspalace.tusky.components.search.SearchActivity
|
||||||
import com.keylesspalace.tusky.components.timeline.TimelineFragment
|
import com.keylesspalace.tusky.components.timeline.TimelineFragment
|
||||||
|
import com.keylesspalace.tusky.components.trending.TrendingActivity
|
||||||
import com.keylesspalace.tusky.databinding.ActivityMainBinding
|
import com.keylesspalace.tusky.databinding.ActivityMainBinding
|
||||||
import com.keylesspalace.tusky.db.AccountEntity
|
import com.keylesspalace.tusky.db.AccountEntity
|
||||||
import com.keylesspalace.tusky.db.DraftsAlert
|
import com.keylesspalace.tusky.db.DraftsAlert
|
||||||
import com.keylesspalace.tusky.di.ViewModelFactory
|
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||||
import com.keylesspalace.tusky.entity.Account
|
import com.keylesspalace.tusky.entity.Account
|
||||||
import com.keylesspalace.tusky.entity.Notification
|
import com.keylesspalace.tusky.entity.Notification
|
||||||
import com.keylesspalace.tusky.fragment.NotificationsFragment
|
|
||||||
import com.keylesspalace.tusky.interfaces.AccountSelectionListener
|
import com.keylesspalace.tusky.interfaces.AccountSelectionListener
|
||||||
import com.keylesspalace.tusky.interfaces.ActionButtonActivity
|
import com.keylesspalace.tusky.interfaces.ActionButtonActivity
|
||||||
|
import com.keylesspalace.tusky.interfaces.FabFragment
|
||||||
import com.keylesspalace.tusky.interfaces.ReselectableFragment
|
import com.keylesspalace.tusky.interfaces.ReselectableFragment
|
||||||
import com.keylesspalace.tusky.interfaces.ResettableFragment
|
import com.keylesspalace.tusky.interfaces.ResettableFragment
|
||||||
import com.keylesspalace.tusky.pager.MainPagerAdapter
|
import com.keylesspalace.tusky.pager.MainPagerAdapter
|
||||||
|
@ -109,6 +112,7 @@ import com.keylesspalace.tusky.util.hide
|
||||||
import com.keylesspalace.tusky.util.reduceSwipeSensitivity
|
import com.keylesspalace.tusky.util.reduceSwipeSensitivity
|
||||||
import com.keylesspalace.tusky.util.setDrawableTint
|
import com.keylesspalace.tusky.util.setDrawableTint
|
||||||
import com.keylesspalace.tusky.util.show
|
import com.keylesspalace.tusky.util.show
|
||||||
|
import com.keylesspalace.tusky.util.unsafeLazy
|
||||||
import com.keylesspalace.tusky.util.updateShortcut
|
import com.keylesspalace.tusky.util.updateShortcut
|
||||||
import com.keylesspalace.tusky.util.viewBinding
|
import com.keylesspalace.tusky.util.viewBinding
|
||||||
import com.keylesspalace.tusky.util.visible
|
import com.keylesspalace.tusky.util.visible
|
||||||
|
@ -143,16 +147,15 @@ import com.mikepenz.materialdrawer.widget.AccountHeaderView
|
||||||
import dagger.android.DispatchingAndroidInjector
|
import dagger.android.DispatchingAndroidInjector
|
||||||
import dagger.android.HasAndroidInjector
|
import dagger.android.HasAndroidInjector
|
||||||
import de.c1710.filemojicompat_ui.helpers.EMOJI_PREFERENCE
|
import de.c1710.filemojicompat_ui.helpers.EMOJI_PREFERENCE
|
||||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
|
||||||
import io.reactivex.rxjava3.core.Single
|
|
||||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||||
|
import kotlinx.coroutines.job
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import net.accelf.yuito.FooterDrawerItem
|
import net.accelf.yuito.FooterDrawerItem
|
||||||
import net.accelf.yuito.QuickTootViewModel
|
import net.accelf.yuito.QuickTootViewModel
|
||||||
import net.accelf.yuito.streaming.StreamingManager
|
import net.accelf.yuito.streaming.StreamingManager
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInjector {
|
class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInjector, MenuProvider {
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var androidInjector: DispatchingAndroidInjector<Any>
|
lateinit var androidInjector: DispatchingAndroidInjector<Any>
|
||||||
|
|
||||||
|
@ -188,7 +191,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
||||||
|
|
||||||
private var unreadAnnouncementsCount = 0
|
private var unreadAnnouncementsCount = 0
|
||||||
|
|
||||||
private val preferences by lazy { PreferenceManager.getDefaultSharedPreferences(this) }
|
private val preferences by unsafeLazy { PreferenceManager.getDefaultSharedPreferences(this) }
|
||||||
|
|
||||||
private lateinit var glide: RequestManager
|
private lateinit var glide: RequestManager
|
||||||
|
|
||||||
|
@ -197,6 +200,12 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
||||||
// We need to know if the emoji pack has been changed
|
// We need to know if the emoji pack has been changed
|
||||||
private var selectedEmojiPack: String? = null
|
private var selectedEmojiPack: String? = null
|
||||||
|
|
||||||
|
/** Mediate between binding.viewPager and the chosen tab layout */
|
||||||
|
private var tabLayoutMediator: TabLayoutMediator? = null
|
||||||
|
|
||||||
|
/** Adapter for the different timeline tabs */
|
||||||
|
private lateinit var tabAdapter: MainPagerAdapter
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
@ -231,7 +240,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
||||||
} else {
|
} else {
|
||||||
// No account was provided, show the chooser
|
// No account was provided, show the chooser
|
||||||
showAccountChooserDialog(
|
showAccountChooserDialog(
|
||||||
getString(R.string.action_share_as), true,
|
getString(R.string.action_share_as),
|
||||||
|
true,
|
||||||
object : AccountSelectionListener {
|
object : AccountSelectionListener {
|
||||||
override fun onAccountSelected(account: AccountEntity) {
|
override fun onAccountSelected(account: AccountEntity) {
|
||||||
val requestedId = account.id
|
val requestedId = account.id
|
||||||
|
@ -263,6 +273,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
||||||
}
|
}
|
||||||
window.statusBarColor = Color.TRANSPARENT // don't draw a status bar, the DrawerLayout and the MaterialDrawerLayout have their own
|
window.statusBarColor = Color.TRANSPARENT // don't draw a status bar, the DrawerLayout and the MaterialDrawerLayout have their own
|
||||||
setContentView(binding.root)
|
setContentView(binding.root)
|
||||||
|
setSupportActionBar(binding.mainToolbar)
|
||||||
|
|
||||||
glide = Glide.with(this)
|
glide = Glide.with(this)
|
||||||
|
|
||||||
|
@ -274,21 +285,15 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
||||||
|
|
||||||
loadDrawerAvatar(activeAccount.profilePictureUrl, true)
|
loadDrawerAvatar(activeAccount.profilePictureUrl, true)
|
||||||
|
|
||||||
binding.mainToolbar.menu.add(R.string.action_search).apply {
|
addMenuProvider(this)
|
||||||
setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM)
|
|
||||||
icon = IconicsDrawable(this@MainActivity, GoogleMaterial.Icon.gmd_search).apply {
|
|
||||||
sizeDp = 20
|
|
||||||
colorInt = MaterialColors.getColor(binding.mainToolbar, android.R.attr.textColorPrimary)
|
|
||||||
}
|
|
||||||
setOnMenuItemClickListener {
|
|
||||||
startActivity(SearchActivity.getIntent(this@MainActivity))
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.viewPager.reduceSwipeSensitivity()
|
binding.viewPager.reduceSwipeSensitivity()
|
||||||
|
|
||||||
setupDrawer(savedInstanceState, addSearchButton = hideTopToolbar)
|
setupDrawer(
|
||||||
|
savedInstanceState,
|
||||||
|
addSearchButton = hideTopToolbar,
|
||||||
|
addTrendingButton = !accountManager.activeAccount!!.tabPreferences.hasTab(TRENDING)
|
||||||
|
)
|
||||||
|
|
||||||
/* Fetch user info while we're doing other things. This has to be done after setting up the
|
/* Fetch user info while we're doing other things. This has to be done after setting up the
|
||||||
* drawer, though, because its callback touches the header in the drawer. */
|
* drawer, though, because its callback touches the header in the drawer. */
|
||||||
|
@ -296,19 +301,40 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
||||||
|
|
||||||
fetchAnnouncements()
|
fetchAnnouncements()
|
||||||
|
|
||||||
|
streamingManager.setup(lifecycleScope.coroutineContext.job) { active ->
|
||||||
|
if (active) {
|
||||||
|
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
|
} else {
|
||||||
|
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialise the tab adapter and set to viewpager. Fragments appear to be leaked if the
|
||||||
|
// adapter changes over the life of the viewPager (the adapter, not its contents), so set
|
||||||
|
// the initial list of tabs to empty, and set the full list later in setupTabs(). See
|
||||||
|
// https://github.com/tuskyapp/Tusky/issues/3251 for details.
|
||||||
|
tabAdapter = MainPagerAdapter(emptyList(), this)
|
||||||
|
binding.viewPager.adapter = tabAdapter
|
||||||
|
|
||||||
setupTabs(showNotificationTab)
|
setupTabs(showNotificationTab)
|
||||||
|
|
||||||
if (PreferenceManager.getDefaultSharedPreferences(this).getBoolean(PrefKeys.VIEW_PAGER_OFF_SCREEN_LIMIT, false)) {
|
if (PreferenceManager.getDefaultSharedPreferences(this).getBoolean(PrefKeys.VIEW_PAGER_OFF_SCREEN_LIMIT, false)) {
|
||||||
binding.viewPager.offscreenPageLimit = 9
|
binding.viewPager.offscreenPageLimit = 9
|
||||||
}
|
}
|
||||||
|
|
||||||
eventHub.events
|
lifecycleScope.launch {
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
eventHub.events.collect { event ->
|
||||||
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
|
|
||||||
.subscribe { event: Event? ->
|
|
||||||
when (event) {
|
when (event) {
|
||||||
is ProfileEditedEvent -> onFetchUserInfoSuccess(event.newProfileData)
|
is ProfileEditedEvent -> onFetchUserInfoSuccess(event.newProfileData)
|
||||||
is MainTabsChangedEvent -> setupTabs(false)
|
is MainTabsChangedEvent -> {
|
||||||
|
refreshMainDrawerItems(
|
||||||
|
addSearchButton = hideTopToolbar,
|
||||||
|
addTrendingButton = !event.newTabs.hasTab(TRENDING)
|
||||||
|
)
|
||||||
|
|
||||||
|
setupTabs(false)
|
||||||
|
}
|
||||||
|
|
||||||
is AnnouncementReadEvent -> {
|
is AnnouncementReadEvent -> {
|
||||||
unreadAnnouncementsCount--
|
unreadAnnouncementsCount--
|
||||||
updateAnnouncementsBadge()
|
updateAnnouncementsBadge()
|
||||||
|
@ -316,6 +342,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
||||||
}
|
}
|
||||||
binding.viewQuickToot.handleEvent(event)
|
binding.viewQuickToot.handleEvent(event)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Schedulers.io().scheduleDirect {
|
Schedulers.io().scheduleDirect {
|
||||||
// Flush old media that was cached for sharing
|
// Flush old media that was cached for sharing
|
||||||
|
@ -355,14 +382,28 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
||||||
draftsAlert.observeInContext(this, true)
|
draftsAlert.observeInContext(this, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPause() {
|
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||||
super.onPause()
|
menuInflater.inflate(R.menu.activity_main, menu)
|
||||||
streamingManager.pause()
|
menu.findItem(R.id.action_search)?.apply {
|
||||||
|
icon = IconicsDrawable(this@MainActivity, GoogleMaterial.Icon.gmd_search).apply {
|
||||||
|
sizeDp = 20
|
||||||
|
colorInt = MaterialColors.getColor(binding.mainToolbar, android.R.attr.textColorPrimary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMenuItemSelected(item: MenuItem): Boolean {
|
||||||
|
return when (item.itemId) {
|
||||||
|
R.id.action_search -> {
|
||||||
|
startActivity(SearchActivity.getIntent(this@MainActivity))
|
||||||
|
true
|
||||||
|
}
|
||||||
|
else -> super.onOptionsItemSelected(item)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
NotificationHelper.clearNotificationsForActiveAccount(this, accountManager)
|
|
||||||
val currentEmojiPack = preferences.getString(EMOJI_PREFERENCE, "")
|
val currentEmojiPack = preferences.getString(EMOJI_PREFERENCE, "")
|
||||||
if (currentEmojiPack != selectedEmojiPack) {
|
if (currentEmojiPack != selectedEmojiPack) {
|
||||||
Log.d(
|
Log.d(
|
||||||
|
@ -373,7 +414,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
||||||
selectedEmojiPack = currentEmojiPack
|
selectedEmojiPack = currentEmojiPack
|
||||||
recreate()
|
recreate()
|
||||||
}
|
}
|
||||||
streamingManager.resume()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStart() {
|
override fun onStart() {
|
||||||
|
@ -382,20 +422,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
||||||
if (binding.mainDrawerLayout.isOpen) {
|
if (binding.mainDrawerLayout.isOpen) {
|
||||||
binding.mainDrawerLayout.closeDrawer(GravityCompat.START, false)
|
binding.mainDrawerLayout.closeDrawer(GravityCompat.START, false)
|
||||||
}
|
}
|
||||||
keepScreenOn()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun keepScreenOn() {
|
|
||||||
if (streamingManager.active) {
|
|
||||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
|
||||||
} else {
|
|
||||||
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStop() {
|
|
||||||
super.onStop()
|
|
||||||
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
|
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
|
||||||
|
@ -417,7 +443,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
||||||
// FIXME: blackberry keyONE raises SHIFT key event even CTRL IS PRESSED
|
// FIXME: blackberry keyONE raises SHIFT key event even CTRL IS PRESSED
|
||||||
when (keyCode) {
|
when (keyCode) {
|
||||||
KeyEvent.KEYCODE_N -> {
|
KeyEvent.KEYCODE_N -> {
|
||||||
|
|
||||||
// open compose activity by pressing SHIFT + N (or CTRL + N)
|
// open compose activity by pressing SHIFT + N (or CTRL + N)
|
||||||
val composeIntent = Intent(applicationContext, ComposeActivity::class.java)
|
val composeIntent = Intent(applicationContext, ComposeActivity::class.java)
|
||||||
startActivity(composeIntent)
|
startActivity(composeIntent)
|
||||||
|
@ -449,8 +474,11 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
||||||
finish()
|
finish()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupDrawer(savedInstanceState: Bundle?, addSearchButton: Boolean) {
|
private fun setupDrawer(
|
||||||
|
savedInstanceState: Bundle?,
|
||||||
|
addSearchButton: Boolean,
|
||||||
|
addTrendingButton: Boolean
|
||||||
|
) {
|
||||||
val drawerOpenClickListener = View.OnClickListener { binding.mainDrawerLayout.open() }
|
val drawerOpenClickListener = View.OnClickListener { binding.mainDrawerLayout.open() }
|
||||||
|
|
||||||
binding.mainToolbar.setNavigationOnClickListener(drawerOpenClickListener)
|
binding.mainToolbar.setNavigationOnClickListener(drawerOpenClickListener)
|
||||||
|
@ -475,6 +503,9 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
||||||
closeDrawerOnProfileListClick = true
|
closeDrawerOnProfileListClick = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
header.currentProfileName.maxLines = 1
|
||||||
|
header.currentProfileName.ellipsize = TextUtils.TruncateAt.END
|
||||||
|
|
||||||
header.accountHeaderBackground.setColorFilter(getColor(R.color.headerBackgroundFilter))
|
header.accountHeaderBackground.setColorFilter(getColor(R.color.headerBackgroundFilter))
|
||||||
header.accountHeaderBackground.setBackgroundColor(MaterialColors.getColor(header, R.attr.colorBackgroundAccent))
|
header.accountHeaderBackground.setBackgroundColor(MaterialColors.getColor(header, R.attr.colorBackgroundAccent))
|
||||||
val animateAvatars = preferences.getBoolean("animateGifAvatars", false)
|
val animateAvatars = preferences.getBoolean("animateGifAvatars", false)
|
||||||
|
@ -507,6 +538,14 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
||||||
})
|
})
|
||||||
|
|
||||||
binding.mainDrawer.apply {
|
binding.mainDrawer.apply {
|
||||||
|
refreshMainDrawerItems(addSearchButton, addTrendingButton)
|
||||||
|
setSavedInstance(savedInstanceState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshMainDrawerItems(addSearchButton: Boolean, addTrendingButton: Boolean) {
|
||||||
|
binding.mainDrawer.apply {
|
||||||
|
itemAdapter.clear()
|
||||||
tintStatusBar = true
|
tintStatusBar = true
|
||||||
addItems(
|
addItems(
|
||||||
primaryDrawerItem {
|
primaryDrawerItem {
|
||||||
|
@ -572,8 +611,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
||||||
startActivityWithSlideInAnimation(AnnouncementsActivity.newIntent(context))
|
startActivityWithSlideInAnimation(AnnouncementsActivity.newIntent(context))
|
||||||
}
|
}
|
||||||
badgeStyle = BadgeStyle().apply {
|
badgeStyle = BadgeStyle().apply {
|
||||||
textColor = ColorHolder.fromColor(MaterialColors.getColor(binding.mainDrawer, R.attr.colorOnPrimary))
|
textColor = ColorHolder.fromColor(MaterialColors.getColor(binding.mainDrawer, com.google.android.material.R.attr.colorOnPrimary))
|
||||||
color = ColorHolder.fromColor(MaterialColors.getColor(binding.mainDrawer, R.attr.colorPrimary))
|
color = ColorHolder.fromColor(MaterialColors.getColor(binding.mainDrawer, com.google.android.material.R.attr.colorPrimary))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
DividerDrawerItem(),
|
DividerDrawerItem(),
|
||||||
|
@ -626,7 +665,18 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
setSavedInstance(savedInstanceState)
|
if (addTrendingButton) {
|
||||||
|
binding.mainDrawer.addItemsAtPosition(
|
||||||
|
5,
|
||||||
|
primaryDrawerItem {
|
||||||
|
nameRes = R.string.title_public_trending_hashtags
|
||||||
|
iconicsIcon = GoogleMaterial.Icon.gmd_trending_up
|
||||||
|
onClick = {
|
||||||
|
startActivityWithSlideInAnimation(TrendingActivity.getIntent(context))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (BuildConfig.DEBUG) {
|
if (BuildConfig.DEBUG) {
|
||||||
|
@ -692,8 +742,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupTabs(selectNotificationTab: Boolean) {
|
private fun setupTabs(selectNotificationTab: Boolean) {
|
||||||
val activeTabLayout = if (preferences.getString("mainNavPosition", "top") == "bottom") {
|
val activeTabLayout = if (preferences.getString(PrefKeys.MAIN_NAV_POSITION, "top") == "bottom") {
|
||||||
val actionBarSize = getDimension(this, R.attr.actionBarSize)
|
val actionBarSize = getDimension(this, androidx.appcompat.R.attr.actionBarSize)
|
||||||
val fabMargin = resources.getDimensionPixelSize(R.dimen.fabMargin)
|
val fabMargin = resources.getDimensionPixelSize(R.dimen.fabMargin)
|
||||||
(binding.composeButton.layoutParams as CoordinatorLayout.LayoutParams).bottomMargin = actionBarSize + fabMargin
|
(binding.composeButton.layoutParams as CoordinatorLayout.LayoutParams).bottomMargin = actionBarSize + fabMargin
|
||||||
binding.topNav.hide()
|
binding.topNav.hide()
|
||||||
|
@ -705,22 +755,31 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
||||||
binding.tabLayout
|
binding.tabLayout
|
||||||
}
|
}
|
||||||
|
|
||||||
val tabs = accountManager.activeAccount!!.tabPreferences.toMutableList()
|
// Save the previous tab so it can be restored later
|
||||||
|
val previousTab = tabAdapter.tabs.getOrNull(binding.viewPager.currentItem)
|
||||||
|
|
||||||
val adapter = MainPagerAdapter(tabs, this)
|
val tabs = accountManager.activeAccount!!.tabPreferences.toMutableList()
|
||||||
binding.viewPager.adapter = adapter
|
val popups = MutableList<PopupMenu?>(tabs.size) { null }
|
||||||
TabLayoutMediator(activeTabLayout, binding.viewPager) { _: TabLayout.Tab?, _: Int -> }.attach()
|
|
||||||
activeTabLayout.removeAllTabs()
|
// Detach any existing mediator before changing tab contents and attaching a new mediator
|
||||||
val popups = ArrayList<PopupMenu>()
|
tabLayoutMediator?.detach()
|
||||||
for (i in tabs.indices) {
|
|
||||||
val tab = activeTabLayout.newTab()
|
tabAdapter.tabs = tabs
|
||||||
.setIcon(tabs[i].icon)
|
tabAdapter.notifyItemRangeChanged(0, tabs.size)
|
||||||
if (tabs[i].id == LIST) {
|
|
||||||
tab.contentDescription = tabs[i].arguments[1]
|
tabLayoutMediator = TabLayoutMediator(activeTabLayout, binding.viewPager, true) {
|
||||||
} else {
|
tab: TabLayout.Tab, position: Int ->
|
||||||
tab.setContentDescription(tabs[i].text)
|
val data = tabs[position]
|
||||||
|
|
||||||
|
tab.icon = AppCompatResources.getDrawable(this@MainActivity, data.icon)
|
||||||
|
tab.contentDescription = when (data.id) {
|
||||||
|
LIST -> data.arguments[1]
|
||||||
|
else -> getString(data.text)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (popups[position] != null) {
|
||||||
|
return@TabLayoutMediator
|
||||||
}
|
}
|
||||||
activeTabLayout.addTab(tab)
|
|
||||||
|
|
||||||
val popup = PopupMenu(this, tab.view)
|
val popup = PopupMenu(this, tab.view)
|
||||||
popup.menuInflater.inflate(R.menu.view_tab_action, popup.menu)
|
popup.menuInflater.inflate(R.menu.view_tab_action, popup.menu)
|
||||||
|
@ -729,19 +788,19 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
||||||
if (popup.menu is MenuBuilder) {
|
if (popup.menu is MenuBuilder) {
|
||||||
val menuBuilder = popup.menu as MenuBuilder
|
val menuBuilder = popup.menu as MenuBuilder
|
||||||
|
|
||||||
if (tabs[i].id in arrayOf(HOME, LOCAL, FEDERATED, HASHTAG, LIST)) {
|
if (data.id in arrayOf(HOME, LOCAL, FEDERATED, HASHTAG, LIST)) {
|
||||||
menuBuilder.findItem(R.id.tabReset).isVisible = true
|
menuBuilder.findItem(R.id.tabReset).isVisible = true
|
||||||
}
|
}
|
||||||
if (tabs[i].id == LIST) {
|
if (data.id == LIST) {
|
||||||
menuBuilder.findItem(R.id.tabEditList).isVisible = true
|
menuBuilder.findItem(R.id.tabEditList).isVisible = true
|
||||||
}
|
}
|
||||||
if (tabs[i].id in arrayOf(HOME, LOCAL, FEDERATED, LIST)) {
|
if (data.id in arrayOf(HOME, LOCAL, FEDERATED, LIST)) {
|
||||||
menuBuilder.findItem(R.id.tabToggleStreaming).apply {
|
menuBuilder.findItem(R.id.tabToggleStreaming).apply {
|
||||||
isVisible = true
|
isVisible = true
|
||||||
isChecked = tabs[i].enableStreaming
|
isChecked = data.enableStreaming
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (tabs[i].id == NOTIFICATIONS) {
|
if (data.id == NOTIFICATIONS) {
|
||||||
menuBuilder.findItem(R.id.tabToggleNotificationsFilter).isVisible = true
|
menuBuilder.findItem(R.id.tabToggleNotificationsFilter).isVisible = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -750,15 +809,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
||||||
val iconMarginPx = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 8f, resources.displayMetrics).toInt()
|
val iconMarginPx = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 8f, resources.displayMetrics).toInt()
|
||||||
|
|
||||||
if (item.icon != null) {
|
if (item.icon != null) {
|
||||||
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.LOLLIPOP) {
|
item.icon = InsetDrawable(item.icon, iconMarginPx, 0, iconMarginPx, 0)
|
||||||
item.icon = InsetDrawable(item.icon, iconMarginPx, 0, iconMarginPx, 0)
|
|
||||||
} else {
|
|
||||||
item.icon = object : InsetDrawable(item.icon, iconMarginPx, 0, iconMarginPx, 0) {
|
|
||||||
override fun getIntrinsicWidth(): Int {
|
|
||||||
return intrinsicHeight + iconMarginPx + iconMarginPx
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setDrawableTint(this, item.icon!!, android.R.attr.textColorPrimary)
|
setDrawableTint(this, item.icon!!, android.R.attr.textColorPrimary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -766,12 +817,14 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
||||||
}
|
}
|
||||||
|
|
||||||
popup.setOnMenuItemClickListener { item ->
|
popup.setOnMenuItemClickListener { item ->
|
||||||
val fragment = adapter.getFragment(tab.position)
|
val fragment = tabAdapter.getFragment(tab.position)
|
||||||
when (item.itemId) {
|
when (item.itemId) {
|
||||||
R.id.tabJumpToTop -> {
|
R.id.tabJumpToTop -> {
|
||||||
if (fragment is ReselectableFragment) {
|
if (fragment is ReselectableFragment) {
|
||||||
(fragment as ReselectableFragment).onReselect()
|
(fragment as ReselectableFragment).onReselect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
refreshComposeButtonState(tabAdapter, tab.position)
|
||||||
}
|
}
|
||||||
R.id.tabReset -> {
|
R.id.tabReset -> {
|
||||||
if (fragment is ResettableFragment) {
|
if (fragment is ResettableFragment) {
|
||||||
|
@ -780,27 +833,21 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
||||||
}
|
}
|
||||||
R.id.tabEditList -> {
|
R.id.tabEditList -> {
|
||||||
AccountsInListFragment.newInstance(
|
AccountsInListFragment.newInstance(
|
||||||
tabs[i].arguments.getOrNull(0).orEmpty(),
|
data.arguments.getOrNull(0).orEmpty(),
|
||||||
tabs[i].arguments.getOrNull(1).orEmpty()
|
data.arguments.getOrNull(1).orEmpty()
|
||||||
).show(supportFragmentManager, null)
|
).show(supportFragmentManager, null)
|
||||||
}
|
}
|
||||||
R.id.tabToggleStreaming -> {
|
R.id.tabToggleStreaming -> {
|
||||||
if (fragment is TimelineFragment) {
|
if (fragment is TimelineFragment) {
|
||||||
val current = fragment.toggleStreaming()
|
val to = !item.isChecked
|
||||||
item.isChecked = current
|
fragment.setStreamingEnabled(to)
|
||||||
|
item.isChecked = to
|
||||||
tintCheckIcon(item)
|
tintCheckIcon(item)
|
||||||
|
|
||||||
keepScreenOn()
|
tabs[position] = data.copy(enableStreaming = to)
|
||||||
|
|
||||||
tabs[i] = tabs[i].copy(enableStreaming = current)
|
|
||||||
accountManager.activeAccount?.let {
|
accountManager.activeAccount?.let {
|
||||||
Single.fromCallable {
|
it.tabPreferences = tabs
|
||||||
it.tabPreferences = tabs
|
accountManager.saveAccount(it)
|
||||||
accountManager.saveAccount(it)
|
|
||||||
}
|
|
||||||
.subscribeOn(Schedulers.io())
|
|
||||||
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
|
|
||||||
.subscribe()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -812,22 +859,28 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
||||||
!prefs.getBoolean("showNotificationsFilter", true)
|
!prefs.getBoolean("showNotificationsFilter", true)
|
||||||
)
|
)
|
||||||
.apply()
|
.apply()
|
||||||
eventHub.dispatch(PreferenceChangedEvent("showNotificationsFilter"))
|
lifecycleScope.launch {
|
||||||
|
eventHub.dispatch(PreferenceChangedEvent("showNotificationsFilter"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
popups.add(popup)
|
popups[position] = popup
|
||||||
|
}.also { it.attach() }
|
||||||
|
|
||||||
if (tabs[i].id == NOTIFICATIONS) {
|
// Selected tab is either
|
||||||
notificationTabPosition = i
|
// - Notification tab (if appropriate)
|
||||||
if (selectNotificationTab) {
|
// - The previously selected tab (if it hasn't been removed)
|
||||||
tab.select()
|
// - Left-most tab
|
||||||
}
|
val position = if (selectNotificationTab) {
|
||||||
}
|
tabs.indexOfFirst { it.id == NOTIFICATIONS }
|
||||||
}
|
} else {
|
||||||
|
previousTab?.let { tabs.indexOfFirst { it == previousTab } }
|
||||||
|
}.takeIf { it != -1 } ?: 0
|
||||||
|
binding.viewPager.setCurrentItem(position, false)
|
||||||
|
|
||||||
val pageMargin = resources.getDimensionPixelSize(R.dimen.tab_page_margin)
|
val pageMargin = resources.getDimensionPixelSize(R.dimen.tab_page_margin)
|
||||||
binding.viewPager.setPageTransformer(MarginPageTransformer(pageMargin))
|
binding.viewPager.setPageTransformer(MarginPageTransformer(pageMargin))
|
||||||
|
@ -841,31 +894,41 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
||||||
|
|
||||||
onTabSelectedListener = object : OnTabSelectedListener {
|
onTabSelectedListener = object : OnTabSelectedListener {
|
||||||
override fun onTabSelected(tab: TabLayout.Tab) {
|
override fun onTabSelected(tab: TabLayout.Tab) {
|
||||||
if (tab.position == notificationTabPosition) {
|
binding.mainToolbar.title = tab.contentDescription
|
||||||
NotificationHelper.clearNotificationsForActiveAccount(this@MainActivity, accountManager)
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.mainToolbar.title = tabs[tab.position].title(this@MainActivity)
|
refreshComposeButtonState(tabAdapter, tab.position)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onTabUnselected(tab: TabLayout.Tab) {}
|
override fun onTabUnselected(tab: TabLayout.Tab) {}
|
||||||
|
|
||||||
override fun onTabReselected(tab: TabLayout.Tab) {
|
override fun onTabReselected(tab: TabLayout.Tab) {
|
||||||
popups[tab.position].show()
|
popups[tab.position]?.show()
|
||||||
}
|
}
|
||||||
}.also {
|
}.also {
|
||||||
activeTabLayout.addOnTabSelectedListener(it)
|
activeTabLayout.addOnTabSelectedListener(it)
|
||||||
}
|
}
|
||||||
|
|
||||||
val activeTabPosition = if (selectNotificationTab) notificationTabPosition else 0
|
val activeTabPosition = if (selectNotificationTab) notificationTabPosition else 0
|
||||||
binding.mainToolbar.title = tabs[activeTabPosition].title(this@MainActivity)
|
supportActionBar?.title = tabs[activeTabPosition].title(this@MainActivity)
|
||||||
binding.mainToolbar.setOnClickListener {
|
binding.mainToolbar.setOnClickListener {
|
||||||
(adapter.getFragment(activeTabLayout.selectedTabPosition) as? ReselectableFragment)?.onReselect()
|
(tabAdapter.getFragment(activeTabLayout.selectedTabPosition) as? ReselectableFragment)?.onReselect()
|
||||||
}
|
}
|
||||||
|
|
||||||
updateProfiles()
|
updateProfiles()
|
||||||
|
}
|
||||||
|
|
||||||
keepScreenOn()
|
private fun refreshComposeButtonState(adapter: MainPagerAdapter, tabPosition: Int) {
|
||||||
|
adapter.getFragment(tabPosition)?.also { fragment ->
|
||||||
|
if (fragment is FabFragment) {
|
||||||
|
if (fragment.isFabVisible()) {
|
||||||
|
binding.composeButton.show()
|
||||||
|
} else {
|
||||||
|
binding.composeButton.hide()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
binding.composeButton.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleProfileClick(profile: IProfile, current: Boolean): Boolean {
|
private fun handleProfileClick(profile: IProfile, current: Boolean): Boolean {
|
||||||
|
@ -969,7 +1032,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadDrawerAvatar(avatarUrl: String, showPlaceholder: Boolean) {
|
private fun loadDrawerAvatar(avatarUrl: String, showPlaceholder: Boolean) {
|
||||||
|
|
||||||
val hideTopToolbar = preferences.getBoolean(PrefKeys.HIDE_TOP_TOOLBAR, false)
|
val hideTopToolbar = preferences.getBoolean(PrefKeys.HIDE_TOP_TOOLBAR, false)
|
||||||
val animateAvatars = preferences.getBoolean("animateGifAvatars", false)
|
val animateAvatars = preferences.getBoolean("animateGifAvatars", false)
|
||||||
|
|
||||||
|
@ -997,7 +1059,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
||||||
.into(avatarView)
|
.into(avatarView)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
||||||
binding.bottomNavAvatar.hide()
|
binding.bottomNavAvatar.hide()
|
||||||
binding.topNavAvatar.hide()
|
binding.topNavAvatar.hide()
|
||||||
|
|
||||||
|
@ -1104,16 +1165,17 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
||||||
|
|
||||||
private fun updateProfiles() {
|
private fun updateProfiles() {
|
||||||
val animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
|
val animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
|
||||||
val profiles: MutableList<IProfile> = accountManager.getAllAccountsOrderedByActive().map { acc ->
|
val profiles: MutableList<IProfile> =
|
||||||
ProfileDrawerItem().apply {
|
accountManager.getAllAccountsOrderedByActive().map { acc ->
|
||||||
isSelected = acc.isActive
|
ProfileDrawerItem().apply {
|
||||||
nameText = acc.displayName.emojify(acc.emojis, header, animateEmojis)
|
isSelected = acc.isActive
|
||||||
iconUrl = acc.profilePictureUrl
|
nameText = acc.displayName.emojify(acc.emojis, header, animateEmojis)
|
||||||
isNameShown = true
|
iconUrl = acc.profilePictureUrl
|
||||||
identifier = acc.id
|
isNameShown = true
|
||||||
descriptionText = acc.fullName
|
identifier = acc.id
|
||||||
}
|
descriptionText = acc.fullName
|
||||||
}.toMutableList()
|
}
|
||||||
|
}.toMutableList()
|
||||||
|
|
||||||
// reuse the already existing "add account" item
|
// reuse the already existing "add account" item
|
||||||
for (profile in header.profiles.orEmpty()) {
|
for (profile in header.profiles.orEmpty()) {
|
||||||
|
@ -1127,7 +1189,9 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
||||||
header.setActiveProfile(accountManager.activeAccount!!.id)
|
header.setActiveProfile(accountManager.activeAccount!!.id)
|
||||||
binding.mainToolbar.subtitle = if (accountManager.shouldDisplaySelfUsername(this)) {
|
binding.mainToolbar.subtitle = if (accountManager.shouldDisplaySelfUsername(this)) {
|
||||||
accountManager.activeAccount!!.fullName
|
accountManager.activeAccount!!.fullName
|
||||||
} else null
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getActionButton() = binding.composeButton
|
override fun getActionButton() = binding.composeButton
|
||||||
|
|
|
@ -23,11 +23,8 @@ import android.view.Menu
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
import androidx.fragment.app.commit
|
import androidx.fragment.app.commit
|
||||||
import androidx.lifecycle.Lifecycle
|
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import at.connyduck.calladapter.networkresult.fold
|
import at.connyduck.calladapter.networkresult.fold
|
||||||
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider
|
|
||||||
import autodispose2.autoDispose
|
|
||||||
import com.google.android.material.snackbar.Snackbar
|
import com.google.android.material.snackbar.Snackbar
|
||||||
import com.keylesspalace.tusky.appstore.EventHub
|
import com.keylesspalace.tusky.appstore.EventHub
|
||||||
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
|
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
|
||||||
|
@ -36,12 +33,13 @@ import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel.K
|
||||||
import com.keylesspalace.tusky.databinding.ActivityStatuslistBinding
|
import com.keylesspalace.tusky.databinding.ActivityStatuslistBinding
|
||||||
import com.keylesspalace.tusky.di.ViewModelFactory
|
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||||
import com.keylesspalace.tusky.entity.Filter
|
import com.keylesspalace.tusky.entity.Filter
|
||||||
|
import com.keylesspalace.tusky.entity.FilterV1
|
||||||
import com.keylesspalace.tusky.util.viewBinding
|
import com.keylesspalace.tusky.util.viewBinding
|
||||||
import dagger.android.DispatchingAndroidInjector
|
import dagger.android.DispatchingAndroidInjector
|
||||||
import dagger.android.HasAndroidInjector
|
import dagger.android.HasAndroidInjector
|
||||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import net.accelf.yuito.QuickTootViewModel
|
import net.accelf.yuito.QuickTootViewModel
|
||||||
|
import retrofit2.HttpException
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
|
class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
|
||||||
|
@ -65,6 +63,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
|
||||||
private var unmuteTagItem: MenuItem? = null
|
private var unmuteTagItem: MenuItem? = null
|
||||||
|
|
||||||
/** The filter muting hashtag, null if unknown or hashtag is not filtered */
|
/** The filter muting hashtag, null if unknown or hashtag is not filtered */
|
||||||
|
private var mutedFilterV1: FilterV1? = null
|
||||||
private var mutedFilter: Filter? = null
|
private var mutedFilter: Filter? = null
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
@ -104,10 +103,9 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
|
||||||
|
|
||||||
binding.viewQuickToot.attachViewModel(quickTootViewModel, this)
|
binding.viewQuickToot.attachViewModel(quickTootViewModel, this)
|
||||||
|
|
||||||
eventHub.events
|
lifecycleScope.launch {
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
eventHub.events.collect(binding.viewQuickToot::handleEvent)
|
||||||
.autoDispose(AndroidLifecycleScopeProvider.from(this, Lifecycle.Event.ON_DESTROY))
|
}
|
||||||
.subscribe(binding.viewQuickToot::handleEvent)
|
|
||||||
binding.floatingBtn.setOnClickListener(binding.viewQuickToot::onFABClicked)
|
binding.floatingBtn.setOnClickListener(binding.viewQuickToot::onFABClicked)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -193,49 +191,89 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
mastodonApi.getFilters().fold(
|
mastodonApi.getFilters().fold(
|
||||||
{ filters ->
|
{ filters ->
|
||||||
for (filter in filters) {
|
mutedFilter = filters.firstOrNull { filter ->
|
||||||
if ((tag == filter.phrase) and filter.context.contains(Filter.HOME)) {
|
filter.context.contains(Filter.Kind.HOME.kind) && filter.keywords.any {
|
||||||
Log.d(TAG, "Tag $hashtag is filtered")
|
it.keyword == tag
|
||||||
muteTagItem?.isVisible = false
|
|
||||||
unmuteTagItem?.isVisible = true
|
|
||||||
mutedFilter = filter
|
|
||||||
return@fold
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
updateTagMuteState(mutedFilter != null)
|
||||||
Log.d(TAG, "Tag $hashtag is not filtered")
|
|
||||||
mutedFilter = null
|
|
||||||
muteTagItem?.isEnabled = true
|
|
||||||
muteTagItem?.isVisible = true
|
|
||||||
muteTagItem?.isVisible = true
|
|
||||||
},
|
},
|
||||||
{ throwable ->
|
{ throwable ->
|
||||||
Log.e(TAG, "Error getting filters: $throwable")
|
if (throwable is HttpException && throwable.code() == 404) {
|
||||||
|
mastodonApi.getFiltersV1().fold(
|
||||||
|
{ filters ->
|
||||||
|
mutedFilterV1 = filters.firstOrNull { filter ->
|
||||||
|
tag == filter.phrase && filter.context.contains(FilterV1.HOME)
|
||||||
|
}
|
||||||
|
updateTagMuteState(mutedFilterV1 != null)
|
||||||
|
},
|
||||||
|
{ throwable ->
|
||||||
|
Log.e(TAG, "Error getting filters: $throwable")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Log.e(TAG, "Error getting filters: $throwable")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun updateTagMuteState(muted: Boolean) {
|
||||||
|
if (muted) {
|
||||||
|
muteTagItem?.isVisible = false
|
||||||
|
muteTagItem?.isEnabled = false
|
||||||
|
unmuteTagItem?.isVisible = true
|
||||||
|
} else {
|
||||||
|
unmuteTagItem?.isVisible = false
|
||||||
|
muteTagItem?.isEnabled = true
|
||||||
|
muteTagItem?.isVisible = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun muteTag(): Boolean {
|
private fun muteTag(): Boolean {
|
||||||
val tag = hashtag ?: return true
|
val tag = hashtag ?: return true
|
||||||
|
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
mastodonApi.createFilter(
|
mastodonApi.createFilter(
|
||||||
tag,
|
title = "#$tag",
|
||||||
listOf(Filter.HOME),
|
context = listOf(FilterV1.HOME),
|
||||||
irreversible = false,
|
filterAction = Filter.Action.WARN.action,
|
||||||
wholeWord = true,
|
|
||||||
expiresInSeconds = null
|
expiresInSeconds = null
|
||||||
).fold(
|
).fold(
|
||||||
{ filter ->
|
{ filter ->
|
||||||
mutedFilter = filter
|
if (mastodonApi.addFilterKeyword(filterId = filter.id, keyword = tag, wholeWord = true).isSuccess) {
|
||||||
muteTagItem?.isVisible = false
|
mutedFilter = filter
|
||||||
unmuteTagItem?.isVisible = true
|
updateTagMuteState(true)
|
||||||
eventHub.dispatch(PreferenceChangedEvent(filter.context[0]))
|
eventHub.dispatch(PreferenceChangedEvent(filter.context[0]))
|
||||||
|
} else {
|
||||||
|
Snackbar.make(binding.root, getString(R.string.error_muting_hashtag_format, tag), Snackbar.LENGTH_SHORT).show()
|
||||||
|
Log.e(TAG, "Failed to mute #$tag")
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{ throwable ->
|
||||||
Snackbar.make(binding.root, getString(R.string.error_muting_hashtag_format, tag), Snackbar.LENGTH_SHORT).show()
|
if (throwable is HttpException && throwable.code() == 404) {
|
||||||
Log.e(TAG, "Failed to mute #$tag", it)
|
mastodonApi.createFilterV1(
|
||||||
|
tag,
|
||||||
|
listOf(FilterV1.HOME),
|
||||||
|
irreversible = false,
|
||||||
|
wholeWord = true,
|
||||||
|
expiresInSeconds = null
|
||||||
|
).fold(
|
||||||
|
{ filter ->
|
||||||
|
mutedFilterV1 = filter
|
||||||
|
updateTagMuteState(true)
|
||||||
|
eventHub.dispatch(PreferenceChangedEvent(filter.context[0]))
|
||||||
|
},
|
||||||
|
{ throwable ->
|
||||||
|
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()
|
||||||
|
Log.e(TAG, "Failed to mute #$tag", throwable)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -244,19 +282,49 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun unmuteTag(): Boolean {
|
private fun unmuteTag(): Boolean {
|
||||||
val filter = mutedFilter ?: return true
|
|
||||||
|
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
mastodonApi.deleteFilter(filter.id).fold(
|
val tag = hashtag
|
||||||
|
val result = if (mutedFilter != null) {
|
||||||
|
val filter = mutedFilter!!
|
||||||
|
if (filter.context.size > 1) {
|
||||||
|
// This filter exists in multiple contexts, just remove the home context
|
||||||
|
mastodonApi.updateFilter(
|
||||||
|
id = filter.id,
|
||||||
|
context = filter.context.filter { it != Filter.Kind.HOME.kind }
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
mastodonApi.deleteFilter(filter.id)
|
||||||
|
}
|
||||||
|
} else if (mutedFilterV1 != null) {
|
||||||
|
mutedFilterV1?.let { filter ->
|
||||||
|
if (filter.context.size > 1) {
|
||||||
|
// This filter exists in multiple contexts, just remove the home context
|
||||||
|
mastodonApi.updateFilterV1(
|
||||||
|
id = filter.id,
|
||||||
|
phrase = filter.phrase,
|
||||||
|
context = filter.context.filter { it != FilterV1.HOME },
|
||||||
|
irreversible = null,
|
||||||
|
wholeWord = null,
|
||||||
|
expiresInSeconds = null
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
mastodonApi.deleteFilterV1(filter.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
result?.fold(
|
||||||
{
|
{
|
||||||
muteTagItem?.isVisible = true
|
updateTagMuteState(false)
|
||||||
unmuteTagItem?.isVisible = false
|
eventHub.dispatch(PreferenceChangedEvent(Filter.Kind.HOME.kind))
|
||||||
eventHub.dispatch(PreferenceChangedEvent(filter.context[0]))
|
mutedFilterV1 = null
|
||||||
mutedFilter = null
|
mutedFilter = null
|
||||||
},
|
},
|
||||||
{
|
{ throwable ->
|
||||||
Snackbar.make(binding.root, getString(R.string.error_unmuting_hashtag_format, filter.phrase), 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 #${filter.phrase}", it)
|
Log.e(TAG, "Failed to unmute #$tag", throwable)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,9 +20,11 @@ import androidx.annotation.DrawableRes
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import com.keylesspalace.tusky.components.conversation.ConversationsFragment
|
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.TimelineFragment
|
||||||
import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel
|
import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel
|
||||||
import com.keylesspalace.tusky.fragment.NotificationsFragment
|
import com.keylesspalace.tusky.components.trending.TrendingFragment
|
||||||
|
import java.util.Objects
|
||||||
|
|
||||||
/** this would be a good case for a sealed class, but that does not work nice with Room */
|
/** this would be a good case for a sealed class, but that does not work nice with Room */
|
||||||
|
|
||||||
|
@ -31,6 +33,7 @@ const val NOTIFICATIONS = "Notifications"
|
||||||
const val LOCAL = "Local"
|
const val LOCAL = "Local"
|
||||||
const val FEDERATED = "Federated"
|
const val FEDERATED = "Federated"
|
||||||
const val DIRECT = "Direct"
|
const val DIRECT = "Direct"
|
||||||
|
const val TRENDING = "Trending"
|
||||||
const val HASHTAG = "Hashtag"
|
const val HASHTAG = "Hashtag"
|
||||||
const val LIST = "List"
|
const val LIST = "List"
|
||||||
|
|
||||||
|
@ -44,60 +47,82 @@ data class TabData(
|
||||||
val arguments: List<String> = emptyList(),
|
val arguments: List<String> = emptyList(),
|
||||||
val title: (Context) -> String = { context -> context.getString(text) },
|
val title: (Context) -> String = { context -> context.getString(text) },
|
||||||
val enableStreaming: Boolean = false,
|
val enableStreaming: Boolean = false,
|
||||||
)
|
) {
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (javaClass != other?.javaClass) return false
|
||||||
|
|
||||||
|
other as TabData
|
||||||
|
|
||||||
|
if (id != other.id) return false
|
||||||
|
if (arguments != other.arguments) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode() = Objects.hash(id, arguments)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun List<TabData>.hasTab(id: String): Boolean = this.find { it.id == id } != null
|
||||||
|
|
||||||
fun createTabDataFromId(id: String, arguments: List<String> = emptyList()): TabData {
|
fun createTabDataFromId(id: String, arguments: List<String> = emptyList()): TabData {
|
||||||
val enableStreaming = id.endsWith(STREAMING)
|
val enableStreaming = id.endsWith(STREAMING)
|
||||||
return when (if (enableStreaming) id.slice(IntRange(0, id.length - 4)) else id) {
|
return when (if (enableStreaming) id.slice(IntRange(0, id.length - 4)) else id) {
|
||||||
HOME -> TabData(
|
HOME -> TabData(
|
||||||
HOME,
|
id = HOME,
|
||||||
R.string.title_home,
|
text = R.string.title_home,
|
||||||
R.drawable.ic_home_24dp,
|
icon = R.drawable.ic_home_24dp,
|
||||||
{ TimelineFragment.newInstance(TimelineViewModel.Kind.HOME, enableStreaming = enableStreaming) },
|
fragment = { TimelineFragment.newInstance(TimelineViewModel.Kind.HOME, enableStreaming = enableStreaming) },
|
||||||
enableStreaming = enableStreaming
|
enableStreaming = enableStreaming,
|
||||||
)
|
)
|
||||||
NOTIFICATIONS -> TabData(
|
NOTIFICATIONS -> TabData(
|
||||||
NOTIFICATIONS,
|
id = NOTIFICATIONS,
|
||||||
R.string.title_notifications,
|
text = R.string.title_notifications,
|
||||||
R.drawable.ic_notifications_24dp,
|
icon = R.drawable.ic_notifications_24dp,
|
||||||
{ NotificationsFragment.newInstance() }
|
fragment = { NotificationsFragment.newInstance() }
|
||||||
)
|
)
|
||||||
LOCAL -> TabData(
|
LOCAL -> TabData(
|
||||||
LOCAL,
|
id = LOCAL,
|
||||||
R.string.title_public_local,
|
text = R.string.title_public_local,
|
||||||
R.drawable.ic_local_24dp,
|
icon = R.drawable.ic_local_24dp,
|
||||||
{ TimelineFragment.newInstance(TimelineViewModel.Kind.PUBLIC_LOCAL, enableStreaming = enableStreaming) },
|
fragment = { TimelineFragment.newInstance(TimelineViewModel.Kind.PUBLIC_LOCAL, enableStreaming = enableStreaming) },
|
||||||
enableStreaming = enableStreaming
|
enableStreaming = enableStreaming,
|
||||||
)
|
)
|
||||||
FEDERATED -> TabData(
|
FEDERATED -> TabData(
|
||||||
FEDERATED,
|
id = FEDERATED,
|
||||||
R.string.title_public_federated,
|
text = R.string.title_public_federated,
|
||||||
R.drawable.ic_public_24dp,
|
icon = R.drawable.ic_public_24dp,
|
||||||
{ TimelineFragment.newInstance(TimelineViewModel.Kind.PUBLIC_FEDERATED, enableStreaming = enableStreaming) },
|
fragment = { TimelineFragment.newInstance(TimelineViewModel.Kind.PUBLIC_FEDERATED, enableStreaming = enableStreaming) },
|
||||||
enableStreaming = enableStreaming
|
enableStreaming = enableStreaming,
|
||||||
)
|
)
|
||||||
DIRECT -> TabData(
|
DIRECT -> TabData(
|
||||||
DIRECT,
|
id = DIRECT,
|
||||||
R.string.title_direct_messages,
|
text = R.string.title_direct_messages,
|
||||||
R.drawable.ic_reblog_direct_24dp,
|
icon = R.drawable.ic_reblog_direct_24dp,
|
||||||
{ ConversationsFragment.newInstance() }
|
fragment = { ConversationsFragment.newInstance() }
|
||||||
|
)
|
||||||
|
TRENDING -> TabData(
|
||||||
|
id = TRENDING,
|
||||||
|
text = R.string.title_public_trending_hashtags,
|
||||||
|
icon = R.drawable.ic_trending_up_24px,
|
||||||
|
fragment = { TrendingFragment.newInstance() }
|
||||||
)
|
)
|
||||||
HASHTAG -> TabData(
|
HASHTAG -> TabData(
|
||||||
HASHTAG,
|
id = HASHTAG,
|
||||||
R.string.hashtags,
|
text = R.string.hashtags,
|
||||||
R.drawable.ic_hashtag,
|
icon = R.drawable.ic_hashtag,
|
||||||
{ args -> TimelineFragment.newHashtagInstance(args) },
|
fragment = { args -> TimelineFragment.newHashtagInstance(args) },
|
||||||
arguments,
|
arguments = arguments,
|
||||||
{ 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(
|
LIST -> TabData(
|
||||||
LIST,
|
id = LIST,
|
||||||
R.string.list,
|
text = R.string.list,
|
||||||
R.drawable.ic_list,
|
icon = R.drawable.ic_list,
|
||||||
{ args -> TimelineFragment.newInstance(TimelineViewModel.Kind.LIST, args.getOrNull(0).orEmpty(), true, enableStreaming) },
|
fragment = { args -> TimelineFragment.newInstance(TimelineViewModel.Kind.LIST, args.getOrNull(0).orEmpty(), enableStreaming = enableStreaming) },
|
||||||
arguments,
|
arguments = arguments,
|
||||||
{ arguments.getOrNull(1).orEmpty() },
|
title = { arguments.getOrNull(1).orEmpty() },
|
||||||
enableStreaming
|
enableStreaming = enableStreaming,
|
||||||
)
|
)
|
||||||
else -> throw IllegalArgumentException("unknown tab type")
|
else -> throw IllegalArgumentException("unknown tab type")
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,16 +15,21 @@
|
||||||
|
|
||||||
package com.keylesspalace.tusky
|
package com.keylesspalace.tusky
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import android.view.Gravity
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.ProgressBar
|
||||||
|
import android.widget.TextView
|
||||||
import androidx.activity.OnBackPressedCallback
|
import androidx.activity.OnBackPressedCallback
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.appcompat.widget.AppCompatEditText
|
import androidx.appcompat.widget.AppCompatEditText
|
||||||
import androidx.core.view.updatePadding
|
import androidx.core.view.updatePadding
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.core.widget.doOnTextChanged
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.recyclerview.widget.DividerItemDecoration
|
import androidx.recyclerview.widget.DividerItemDecoration
|
||||||
import androidx.recyclerview.widget.ItemTouchHelper
|
import androidx.recyclerview.widget.ItemTouchHelper
|
||||||
|
@ -33,8 +38,7 @@ import androidx.recyclerview.widget.RecyclerView
|
||||||
import androidx.transition.TransitionManager
|
import androidx.transition.TransitionManager
|
||||||
import at.connyduck.calladapter.networkresult.fold
|
import at.connyduck.calladapter.networkresult.fold
|
||||||
import at.connyduck.sparkbutton.helpers.Utils
|
import at.connyduck.sparkbutton.helpers.Utils
|
||||||
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from
|
import com.google.android.material.snackbar.Snackbar
|
||||||
import autodispose2.autoDispose
|
|
||||||
import com.google.android.material.transition.MaterialArcMotion
|
import com.google.android.material.transition.MaterialArcMotion
|
||||||
import com.google.android.material.transition.MaterialContainerTransform
|
import com.google.android.material.transition.MaterialContainerTransform
|
||||||
import com.keylesspalace.tusky.adapter.ItemInteractionListener
|
import com.keylesspalace.tusky.adapter.ItemInteractionListener
|
||||||
|
@ -45,11 +49,16 @@ import com.keylesspalace.tusky.appstore.MainTabsChangedEvent
|
||||||
import com.keylesspalace.tusky.databinding.ActivityTabPreferenceBinding
|
import com.keylesspalace.tusky.databinding.ActivityTabPreferenceBinding
|
||||||
import com.keylesspalace.tusky.di.Injectable
|
import com.keylesspalace.tusky.di.Injectable
|
||||||
import com.keylesspalace.tusky.network.MastodonApi
|
import com.keylesspalace.tusky.network.MastodonApi
|
||||||
import com.keylesspalace.tusky.util.onTextChanged
|
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.viewBinding
|
||||||
import com.keylesspalace.tusky.util.visible
|
import com.keylesspalace.tusky.util.visible
|
||||||
import io.reactivex.rxjava3.core.Single
|
import kotlinx.coroutines.CoroutineStart
|
||||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.awaitCancellation
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.util.regex.Pattern
|
import java.util.regex.Pattern
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
@ -58,6 +67,7 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var mastodonApi: MastodonApi
|
lateinit var mastodonApi: MastodonApi
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var eventHub: EventHub
|
lateinit var eventHub: EventHub
|
||||||
|
|
||||||
|
@ -70,9 +80,9 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
|
||||||
|
|
||||||
private var tabsChanged = false
|
private var tabsChanged = false
|
||||||
|
|
||||||
private val selectedItemElevation by lazy { 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 lazy { 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) {
|
private val onFabDismissedCallback = object : OnBackPressedCallback(false) {
|
||||||
override fun handleOnBackPressed() {
|
override fun handleOnBackPressed() {
|
||||||
|
@ -160,7 +170,6 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onTabAdded(tab: TabData) {
|
override fun onTabAdded(tab: TabData) {
|
||||||
|
|
||||||
if (currentTabs.size >= MAX_TAB_COUNT) {
|
if (currentTabs.size >= MAX_TAB_COUNT) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -222,7 +231,6 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showAddHashtagDialog(tab: TabData? = null, tabPosition: Int = 0) {
|
private fun showAddHashtagDialog(tab: TabData? = null, tabPosition: Int = 0) {
|
||||||
|
|
||||||
val frameLayout = FrameLayout(this)
|
val frameLayout = FrameLayout(this)
|
||||||
val padding = Utils.dpToPx(this, 8)
|
val padding = Utils.dpToPx(this, 8)
|
||||||
frameLayout.updatePadding(left = padding, right = padding)
|
frameLayout.updatePadding(left = padding, right = padding)
|
||||||
|
@ -254,7 +262,7 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
|
||||||
}
|
}
|
||||||
.create()
|
.create()
|
||||||
|
|
||||||
editText.onTextChanged { s, _, _, _ ->
|
editText.doOnTextChanged { s, _, _, _ ->
|
||||||
dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = validateHashtag(s)
|
dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = validateHashtag(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -265,19 +273,30 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
|
||||||
|
|
||||||
private fun showSelectListDialog() {
|
private fun showSelectListDialog() {
|
||||||
val adapter = ListSelectionAdapter(this)
|
val adapter = ListSelectionAdapter(this)
|
||||||
lifecycleScope.launch {
|
|
||||||
mastodonApi.getLists().fold(
|
|
||||||
{ lists ->
|
|
||||||
adapter.addAll(lists)
|
|
||||||
},
|
|
||||||
{ throwable ->
|
|
||||||
Log.e("TabPreferenceActivity", "failed to load lists", throwable)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
AlertDialog.Builder(this)
|
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)
|
.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 ->
|
.setAdapter(adapter) { _, position ->
|
||||||
val list = adapter.getItem(position)
|
val list = adapter.getItem(position)
|
||||||
val newTab = createTabDataFromId(LIST, listOf(list!!.id, list.title))
|
val newTab = createTabDataFromId(LIST, listOf(list!!.id, list.title))
|
||||||
|
@ -286,7 +305,40 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
|
||||||
updateAvailableTabs()
|
updateAvailableTabs()
|
||||||
saveTabs()
|
saveTabs()
|
||||||
}
|
}
|
||||||
.show()
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getProgressBarJob(progressView: View, delayMs: Long) = this.lifecycleScope.launch(
|
||||||
|
start = CoroutineStart.LAZY
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
delay(delayMs)
|
||||||
|
progressView.show()
|
||||||
|
awaitCancellation()
|
||||||
|
} finally {
|
||||||
|
progressView.hide()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun validateHashtag(input: CharSequence?): Boolean {
|
private fun validateHashtag(input: CharSequence?): Boolean {
|
||||||
|
@ -317,6 +369,10 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
|
||||||
if (!currentTabs.contains(directMessagesTab)) {
|
if (!currentTabs.contains(directMessagesTab)) {
|
||||||
addableTabs.add(directMessagesTab)
|
addableTabs.add(directMessagesTab)
|
||||||
}
|
}
|
||||||
|
val trendingTab = createTabDataFromId(TRENDING)
|
||||||
|
if (!currentTabs.contains(trendingTab)) {
|
||||||
|
addableTabs.add(trendingTab)
|
||||||
|
}
|
||||||
|
|
||||||
addableTabs.add(createTabDataFromId(HASHTAG))
|
addableTabs.add(createTabDataFromId(HASHTAG))
|
||||||
addableTabs.add(createTabDataFromId(LIST))
|
addableTabs.add(createTabDataFromId(LIST))
|
||||||
|
@ -337,13 +393,10 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
|
||||||
|
|
||||||
private fun saveTabs() {
|
private fun saveTabs() {
|
||||||
accountManager.activeAccount?.let {
|
accountManager.activeAccount?.let {
|
||||||
Single.fromCallable {
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
it.tabPreferences = currentTabs
|
it.tabPreferences = currentTabs
|
||||||
accountManager.saveAccount(it)
|
accountManager.saveAccount(it)
|
||||||
}
|
}
|
||||||
.subscribeOn(Schedulers.io())
|
|
||||||
.autoDispose(from(this, Lifecycle.Event.ON_DESTROY))
|
|
||||||
.subscribe()
|
|
||||||
}
|
}
|
||||||
tabsChanged = true
|
tabsChanged = true
|
||||||
}
|
}
|
||||||
|
@ -351,7 +404,9 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
|
||||||
override fun onPause() {
|
override fun onPause() {
|
||||||
super.onPause()
|
super.onPause()
|
||||||
if (tabsChanged) {
|
if (tabsChanged) {
|
||||||
eventHub.dispatch(MainTabsChangedEvent(currentTabs))
|
lifecycleScope.launch {
|
||||||
|
eventHub.dispatch(MainTabsChangedEvent(currentTabs))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -16,12 +16,14 @@
|
||||||
package com.keylesspalace.tusky
|
package com.keylesspalace.tusky
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import android.content.SharedPreferences
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.preference.PreferenceManager
|
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import autodispose2.AutoDisposePlugins
|
import autodispose2.AutoDisposePlugins
|
||||||
import com.keylesspalace.tusky.components.notifications.NotificationWorkerFactory
|
import com.keylesspalace.tusky.components.notifications.NotificationWorkerFactory
|
||||||
import com.keylesspalace.tusky.di.AppInjector
|
import com.keylesspalace.tusky.di.AppInjector
|
||||||
|
import com.keylesspalace.tusky.settings.PrefKeys
|
||||||
|
import com.keylesspalace.tusky.settings.SCHEMA_VERSION
|
||||||
import com.keylesspalace.tusky.util.APP_THEME_DEFAULT
|
import com.keylesspalace.tusky.util.APP_THEME_DEFAULT
|
||||||
import com.keylesspalace.tusky.util.LocaleManager
|
import com.keylesspalace.tusky.util.LocaleManager
|
||||||
import com.keylesspalace.tusky.util.setAppNightMode
|
import com.keylesspalace.tusky.util.setAppNightMode
|
||||||
|
@ -36,7 +38,6 @@ import java.security.Security
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class TuskyApplication : Application(), HasAndroidInjector {
|
class TuskyApplication : Application(), HasAndroidInjector {
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var androidInjector: DispatchingAndroidInjector<Any>
|
lateinit var androidInjector: DispatchingAndroidInjector<Any>
|
||||||
|
|
||||||
|
@ -46,6 +47,9 @@ class TuskyApplication : Application(), HasAndroidInjector {
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var localeManager: LocaleManager
|
lateinit var localeManager: LocaleManager
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var sharedPreferences: SharedPreferences
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
// Uncomment me to get StrictMode violation logs
|
// Uncomment me to get StrictMode violation logs
|
||||||
// if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
|
// if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
|
||||||
|
@ -65,7 +69,11 @@ class TuskyApplication : Application(), HasAndroidInjector {
|
||||||
|
|
||||||
AppInjector.init(this)
|
AppInjector.init(this)
|
||||||
|
|
||||||
val preferences = PreferenceManager.getDefaultSharedPreferences(this)
|
// Migrate shared preference keys and defaults from version to version.
|
||||||
|
val oldVersion = sharedPreferences.getInt(PrefKeys.SCHEMA_VERSION, 0)
|
||||||
|
if (oldVersion != SCHEMA_VERSION) {
|
||||||
|
upgradeSharedPreferences(oldVersion, SCHEMA_VERSION)
|
||||||
|
}
|
||||||
|
|
||||||
// In this case, we want to have the emoji preferences merged with the other ones
|
// In this case, we want to have the emoji preferences merged with the other ones
|
||||||
// Copied from PreferenceManager.getDefaultSharedPreferenceName
|
// Copied from PreferenceManager.getDefaultSharedPreferenceName
|
||||||
|
@ -73,7 +81,7 @@ class TuskyApplication : Application(), HasAndroidInjector {
|
||||||
EmojiPackHelper.init(this, DefaultEmojiPackList.get(this), allowPackImports = false)
|
EmojiPackHelper.init(this, DefaultEmojiPackList.get(this), allowPackImports = false)
|
||||||
|
|
||||||
// init night mode
|
// init night mode
|
||||||
val theme = preferences.getString("appTheme", APP_THEME_DEFAULT)
|
val theme = sharedPreferences.getString("appTheme", APP_THEME_DEFAULT)
|
||||||
setAppNightMode(theme)
|
setAppNightMode(theme)
|
||||||
|
|
||||||
localeManager.setLocale()
|
localeManager.setLocale()
|
||||||
|
@ -91,4 +99,24 @@ class TuskyApplication : Application(), HasAndroidInjector {
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun androidInjector() = androidInjector
|
override fun androidInjector() = androidInjector
|
||||||
|
|
||||||
|
private fun upgradeSharedPreferences(oldVersion: Int, newVersion: Int) {
|
||||||
|
Log.d(TAG, "Upgrading shared preferences: $oldVersion -> $newVersion")
|
||||||
|
val editor = sharedPreferences.edit()
|
||||||
|
|
||||||
|
if (oldVersion < 2023022701) {
|
||||||
|
// These preferences are (now) handled in AccountPreferenceHandler. Remove them from shared for clarity.
|
||||||
|
|
||||||
|
editor.remove(PrefKeys.ALWAYS_OPEN_SPOILER)
|
||||||
|
editor.remove(PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA)
|
||||||
|
editor.remove(PrefKeys.MEDIA_PREVIEW_ENABLED)
|
||||||
|
}
|
||||||
|
|
||||||
|
editor.putInt(PrefKeys.SCHEMA_VERSION, newVersion)
|
||||||
|
editor.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "TuskyApplication"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -306,8 +306,9 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
|
||||||
isCreating = false
|
isCreating = false
|
||||||
invalidateOptionsMenu()
|
invalidateOptionsMenu()
|
||||||
binding.progressBarShare.visibility = View.GONE
|
binding.progressBarShare.visibility = View.GONE
|
||||||
if (result)
|
if (result) {
|
||||||
shareFile(file, "image/png")
|
shareFile(file, "image/png")
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{ error ->
|
{ error ->
|
||||||
isCreating = false
|
isCreating = false
|
||||||
|
|
|
@ -15,10 +15,9 @@
|
||||||
|
|
||||||
package com.keylesspalace.tusky.adapter
|
package com.keylesspalace.tusky.adapter
|
||||||
|
|
||||||
import android.text.Editable
|
|
||||||
import android.text.TextWatcher
|
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import androidx.core.widget.doAfterTextChanged
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.keylesspalace.tusky.databinding.ItemEditFieldBinding
|
import com.keylesspalace.tusky.databinding.ItemEditFieldBinding
|
||||||
import com.keylesspalace.tusky.entity.StringField
|
import com.keylesspalace.tusky.entity.StringField
|
||||||
|
@ -81,25 +80,13 @@ class AccountFieldEditAdapter : RecyclerView.Adapter<BindingHolder<ItemEditField
|
||||||
holder.binding.accountFieldValueTextLayout.counterMaxLength = it
|
holder.binding.accountFieldValueTextLayout.counterMaxLength = it
|
||||||
}
|
}
|
||||||
|
|
||||||
holder.binding.accountFieldNameText.addTextChangedListener(object : TextWatcher {
|
holder.binding.accountFieldNameText.doAfterTextChanged { newText ->
|
||||||
override fun afterTextChanged(newText: Editable) {
|
fieldData[holder.bindingAdapterPosition].first = newText.toString()
|
||||||
fieldData[holder.bindingAdapterPosition].first = newText.toString()
|
}
|
||||||
}
|
|
||||||
|
|
||||||
override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {}
|
holder.binding.accountFieldValueText.doAfterTextChanged { newText ->
|
||||||
|
fieldData[holder.bindingAdapterPosition].second = newText.toString()
|
||||||
override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
holder.binding.accountFieldValueText.addTextChangedListener(object : TextWatcher {
|
|
||||||
override fun afterTextChanged(newText: Editable) {
|
|
||||||
fieldData[holder.bindingAdapterPosition].second = newText.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {}
|
|
||||||
|
|
||||||
override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class MutableStringPair(var first: String, var second: String)
|
class MutableStringPair(var first: String, var second: String)
|
||||||
|
|
|
@ -1,82 +0,0 @@
|
||||||
/* Copyright 2017 Andrew Dawson
|
|
||||||
*
|
|
||||||
* This file is a part of Tusky.
|
|
||||||
*
|
|
||||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
|
||||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
|
||||||
* License, or (at your option) any later version.
|
|
||||||
*
|
|
||||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
|
||||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
|
||||||
* Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
|
||||||
* see <http://www.gnu.org/licenses>. */
|
|
||||||
package com.keylesspalace.tusky.adapter
|
|
||||||
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import android.widget.ImageButton
|
|
||||||
import android.widget.ImageView
|
|
||||||
import android.widget.TextView
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import com.keylesspalace.tusky.R
|
|
||||||
import com.keylesspalace.tusky.entity.TimelineAccount
|
|
||||||
import com.keylesspalace.tusky.interfaces.AccountActionListener
|
|
||||||
import com.keylesspalace.tusky.util.emojify
|
|
||||||
import com.keylesspalace.tusky.util.loadAvatar
|
|
||||||
|
|
||||||
/** Displays a list of blocked accounts. */
|
|
||||||
class BlocksAdapter(
|
|
||||||
accountActionListener: AccountActionListener,
|
|
||||||
animateAvatar: Boolean,
|
|
||||||
animateEmojis: Boolean,
|
|
||||||
showBotOverlay: Boolean,
|
|
||||||
) : AccountAdapter<BlocksAdapter.BlockedUserViewHolder>(
|
|
||||||
accountActionListener,
|
|
||||||
animateAvatar,
|
|
||||||
animateEmojis,
|
|
||||||
showBotOverlay
|
|
||||||
) {
|
|
||||||
override fun createAccountViewHolder(parent: ViewGroup): BlockedUserViewHolder {
|
|
||||||
val view = LayoutInflater.from(parent.context)
|
|
||||||
.inflate(R.layout.item_blocked_user, parent, false)
|
|
||||||
return BlockedUserViewHolder(view)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onBindAccountViewHolder(viewHolder: BlockedUserViewHolder, position: Int) {
|
|
||||||
viewHolder.setupWithAccount(accountList[position], animateAvatar, animateEmojis)
|
|
||||||
viewHolder.setupActionListener(accountActionListener)
|
|
||||||
}
|
|
||||||
|
|
||||||
class BlockedUserViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
|
||||||
private val avatar: ImageView = itemView.findViewById(R.id.blocked_user_avatar)
|
|
||||||
private val username: TextView = itemView.findViewById(R.id.blocked_user_username)
|
|
||||||
private val displayName: TextView = itemView.findViewById(R.id.blocked_user_display_name)
|
|
||||||
private val unblock: ImageButton = itemView.findViewById(R.id.blocked_user_unblock)
|
|
||||||
private var id: String? = null
|
|
||||||
|
|
||||||
fun setupWithAccount(account: TimelineAccount, animateAvatar: Boolean, animateEmojis: Boolean) {
|
|
||||||
id = account.id
|
|
||||||
val emojifiedName = account.name.emojify(account.emojis, displayName, animateEmojis)
|
|
||||||
displayName.text = emojifiedName
|
|
||||||
val format = username.context.getString(R.string.post_username_format)
|
|
||||||
val formattedUsername = String.format(format, account.username)
|
|
||||||
username.text = formattedUsername
|
|
||||||
val avatarRadius = avatar.context.resources
|
|
||||||
.getDimensionPixelSize(R.dimen.avatar_radius_48dp)
|
|
||||||
loadAvatar(account.avatar, avatar, avatarRadius, animateAvatar)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setupActionListener(listener: AccountActionListener) {
|
|
||||||
unblock.setOnClickListener {
|
|
||||||
val position = bindingAdapterPosition
|
|
||||||
if (position != RecyclerView.NO_POSITION) {
|
|
||||||
listener.onBlock(false, id, position)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
itemView.setOnClickListener { listener.onViewAccount(id) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -21,18 +21,47 @@ import android.text.Spanned
|
||||||
import android.text.style.StyleSpan
|
import android.text.style.StyleSpan
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.keylesspalace.tusky.R
|
import com.keylesspalace.tusky.R
|
||||||
|
import com.keylesspalace.tusky.components.notifications.NotificationsPagingAdapter
|
||||||
import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding
|
import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding
|
||||||
import com.keylesspalace.tusky.entity.TimelineAccount
|
import com.keylesspalace.tusky.entity.TimelineAccount
|
||||||
import com.keylesspalace.tusky.interfaces.AccountActionListener
|
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.emojify
|
||||||
|
import com.keylesspalace.tusky.util.hide
|
||||||
import com.keylesspalace.tusky.util.loadAvatar
|
import com.keylesspalace.tusky.util.loadAvatar
|
||||||
|
import com.keylesspalace.tusky.util.parseAsMastodonHtml
|
||||||
|
import com.keylesspalace.tusky.util.setClickableText
|
||||||
|
import com.keylesspalace.tusky.util.show
|
||||||
import com.keylesspalace.tusky.util.unicodeWrap
|
import com.keylesspalace.tusky.util.unicodeWrap
|
||||||
import com.keylesspalace.tusky.util.visible
|
import com.keylesspalace.tusky.util.visible
|
||||||
|
import com.keylesspalace.tusky.viewdata.NotificationViewData
|
||||||
|
|
||||||
class FollowRequestViewHolder(
|
class FollowRequestViewHolder(
|
||||||
private val binding: ItemFollowRequestBinding,
|
private val binding: ItemFollowRequestBinding,
|
||||||
|
private val accountActionListener: AccountActionListener,
|
||||||
|
private val linkListener: LinkListener,
|
||||||
private val showHeader: Boolean
|
private val showHeader: Boolean
|
||||||
) : RecyclerView.ViewHolder(binding.root) {
|
) : 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)
|
||||||
|
}
|
||||||
|
|
||||||
fun setupWithAccount(
|
fun setupWithAccount(
|
||||||
account: TimelineAccount,
|
account: TimelineAccount,
|
||||||
|
@ -41,20 +70,41 @@ class FollowRequestViewHolder(
|
||||||
showBotOverlay: Boolean
|
showBotOverlay: Boolean
|
||||||
) {
|
) {
|
||||||
val wrappedName = account.name.unicodeWrap()
|
val wrappedName = account.name.unicodeWrap()
|
||||||
val emojifiedName: CharSequence = wrappedName.emojify(account.emojis, itemView, animateEmojis)
|
val emojifiedName: CharSequence = wrappedName.emojify(
|
||||||
|
account.emojis,
|
||||||
|
itemView,
|
||||||
|
animateEmojis
|
||||||
|
)
|
||||||
binding.displayNameTextView.text = emojifiedName
|
binding.displayNameTextView.text = emojifiedName
|
||||||
if (showHeader) {
|
if (showHeader) {
|
||||||
val wholeMessage: String = itemView.context.getString(R.string.notification_follow_request_format, wrappedName)
|
val wholeMessage: String = itemView.context.getString(
|
||||||
|
R.string.notification_follow_request_format,
|
||||||
|
wrappedName
|
||||||
|
)
|
||||||
binding.notificationTextView.text = SpannableStringBuilder(wholeMessage).apply {
|
binding.notificationTextView.text = SpannableStringBuilder(wholeMessage).apply {
|
||||||
setSpan(StyleSpan(Typeface.BOLD), 0, wrappedName.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
setSpan(
|
||||||
|
StyleSpan(Typeface.BOLD),
|
||||||
|
0,
|
||||||
|
wrappedName.length,
|
||||||
|
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||||
|
)
|
||||||
}.emojify(account.emojis, itemView, animateEmojis)
|
}.emojify(account.emojis, itemView, animateEmojis)
|
||||||
}
|
}
|
||||||
binding.notificationTextView.visible(showHeader)
|
binding.notificationTextView.visible(showHeader)
|
||||||
val format = itemView.context.getString(R.string.post_username_format)
|
val formattedUsername = itemView.context.getString(R.string.post_username_format, account.username)
|
||||||
val formattedUsername = String.format(format, account.username)
|
|
||||||
binding.usernameTextView.text = formattedUsername
|
binding.usernameTextView.text = formattedUsername
|
||||||
|
if (account.note.isEmpty()) {
|
||||||
|
binding.accountNote.hide()
|
||||||
|
} else {
|
||||||
|
binding.accountNote.show()
|
||||||
|
|
||||||
|
val emojifiedNote = account.note.parseAsMastodonHtml()
|
||||||
|
.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)
|
loadAvatar(account.avatar, binding.avatar, avatarRadius, animateAvatar)
|
||||||
|
binding.avatarBadge.visible(showBotOverlay && account.bot)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setupActionListener(listener: AccountActionListener, accountId: String) {
|
fun setupActionListener(listener: AccountActionListener, accountId: String) {
|
||||||
|
|
|
@ -26,7 +26,6 @@ import com.keylesspalace.tusky.entity.MastoList
|
||||||
|
|
||||||
class ListSelectionAdapter(context: Context) : ArrayAdapter<MastoList>(context, R.layout.item_picker_list) {
|
class ListSelectionAdapter(context: Context) : ArrayAdapter<MastoList>(context, R.layout.item_picker_list) {
|
||||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||||
|
|
||||||
val binding = if (convertView == null) {
|
val binding = if (convertView == null) {
|
||||||
ItemPickerListBinding.inflate(LayoutInflater.from(context), parent, false)
|
ItemPickerListBinding.inflate(LayoutInflater.from(context), parent, false)
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -1,714 +0,0 @@
|
||||||
/* Copyright 2021 Tusky Contributors
|
|
||||||
*
|
|
||||||
* This file is a part of Tusky.
|
|
||||||
*
|
|
||||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
|
||||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
|
||||||
* License, or (at your option) any later version.
|
|
||||||
*
|
|
||||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
|
||||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
|
||||||
* Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
|
||||||
* see <http://www.gnu.org/licenses>. */
|
|
||||||
|
|
||||||
package com.keylesspalace.tusky.adapter;
|
|
||||||
|
|
||||||
import android.content.Context;
|
|
||||||
import android.graphics.Color;
|
|
||||||
import android.graphics.PorterDuff;
|
|
||||||
import android.graphics.Typeface;
|
|
||||||
import android.graphics.drawable.Drawable;
|
|
||||||
import android.text.InputFilter;
|
|
||||||
import android.text.SpannableStringBuilder;
|
|
||||||
import android.text.Spanned;
|
|
||||||
import android.text.TextUtils;
|
|
||||||
import android.text.style.StyleSpan;
|
|
||||||
import android.view.LayoutInflater;
|
|
||||||
import android.view.View;
|
|
||||||
import android.view.ViewGroup;
|
|
||||||
import android.widget.Button;
|
|
||||||
import android.widget.ImageView;
|
|
||||||
import android.widget.TextView;
|
|
||||||
|
|
||||||
import androidx.annotation.ColorRes;
|
|
||||||
import androidx.annotation.DrawableRes;
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import androidx.annotation.Nullable;
|
|
||||||
import androidx.constraintlayout.widget.ConstraintLayout;
|
|
||||||
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.databinding.ViewQuoteInlineBinding;
|
|
||||||
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 net.accelf.yuito.QuoteInlineHelper;
|
|
||||||
|
|
||||||
import java.util.Date;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
import at.connyduck.sparkbutton.helpers.Utils;
|
|
||||||
|
|
||||||
public class NotificationsAdapter extends RecyclerView.Adapter {
|
|
||||||
|
|
||||||
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 String accountId;
|
|
||||||
private StatusDisplayOptions statusDisplayOptions;
|
|
||||||
private StatusActionListener statusListener;
|
|
||||||
private NotificationActionListener notificationActionListener;
|
|
||||||
private AccountActionListener accountActionListener;
|
|
||||||
private 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, true);
|
|
||||||
}
|
|
||||||
case VIEW_TYPE_PLACEHOLDER: {
|
|
||||||
View view = inflater
|
|
||||||
.inflate(R.layout.item_status_placeholder, parent, false);
|
|
||||||
return new PlaceholderViewHolder(view);
|
|
||||||
}
|
|
||||||
case VIEW_TYPE_REPORT: {
|
|
||||||
ItemReportNotificationBinding binding = ItemReportNotificationBinding.inflate(inflater, parent, false);
|
|
||||||
return new ReportNotificationViewHolder(binding);
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
case VIEW_TYPE_UNKNOWN: {
|
|
||||||
View view = new View(parent.getContext());
|
|
||||||
view.setLayoutParams(
|
|
||||||
new ViewGroup.LayoutParams(
|
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
|
||||||
Utils.dpToPx(parent.getContext(), 24)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
return new RecyclerView.ViewHolder(view) {
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) {
|
|
||||||
bindViewHolder(viewHolder, position, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position, @NonNull List payloads) {
|
|
||||||
bindViewHolder(viewHolder, position, payloads);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void bindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position, @Nullable List payloads) {
|
|
||||||
Object payloadForHolder = payloads != null && !payloads.isEmpty() ? payloads.get(0) : null;
|
|
||||||
if (position < this.dataSource.getItemCount()) {
|
|
||||||
NotificationViewData notification = dataSource.getItemAt(position);
|
|
||||||
if (notification instanceof NotificationViewData.Placeholder) {
|
|
||||||
if (payloadForHolder == null) {
|
|
||||||
NotificationViewData.Placeholder placeholder = ((NotificationViewData.Placeholder) notification);
|
|
||||||
PlaceholderViewHolder holder = (PlaceholderViewHolder) viewHolder;
|
|
||||||
holder.setup(statusListener, placeholder.isLoading());
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
NotificationViewData.Concrete concreteNotification =
|
|
||||||
(NotificationViewData.Concrete) notification;
|
|
||||||
switch (viewHolder.getItemViewType()) {
|
|
||||||
case VIEW_TYPE_STATUS: {
|
|
||||||
StatusViewHolder holder = (StatusViewHolder) viewHolder;
|
|
||||||
StatusViewData.Concrete status = concreteNotification.getStatusViewData();
|
|
||||||
if (status == null) {
|
|
||||||
/* in some very rare cases servers sends null status even though they should not,
|
|
||||||
* we have to handle it somehow */
|
|
||||||
holder.showStatusContent(false);
|
|
||||||
} else {
|
|
||||||
if (payloads == null) {
|
|
||||||
holder.showStatusContent(true);
|
|
||||||
}
|
|
||||||
holder.setupWithStatus(status, statusListener, statusDisplayOptions, payloadForHolder);
|
|
||||||
}
|
|
||||||
if (concreteNotification.getType() == Notification.Type.POLL) {
|
|
||||||
holder.setPollInfo(accountId.equals(concreteNotification.getAccount().getId()));
|
|
||||||
} else {
|
|
||||||
holder.hideStatusInfo();
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case VIEW_TYPE_STATUS_NOTIFICATION: {
|
|
||||||
StatusNotificationViewHolder holder = (StatusNotificationViewHolder) viewHolder;
|
|
||||||
StatusViewData.Concrete statusViewData = concreteNotification.getStatusViewData();
|
|
||||||
if (payloadForHolder == null) {
|
|
||||||
if (statusViewData == null) {
|
|
||||||
/* in some very rare cases servers sends null status even though they should not,
|
|
||||||
* we have to handle it somehow */
|
|
||||||
holder.showNotificationContent(false);
|
|
||||||
} else {
|
|
||||||
holder.showNotificationContent(true);
|
|
||||||
|
|
||||||
Status status = statusViewData.getActionable();
|
|
||||||
holder.setDisplayName(status.getAccount().getName(), 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.quoteEnabled()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isMediaPreviewEnabled() {
|
|
||||||
return this.statusDisplayOptions.mediaPreviewEnabled();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public int getItemViewType(int position) {
|
|
||||||
NotificationViewData notification = dataSource.getItemAt(position);
|
|
||||||
if (notification instanceof NotificationViewData.Concrete) {
|
|
||||||
NotificationViewData.Concrete concrete = ((NotificationViewData.Concrete) notification);
|
|
||||||
switch (concrete.getType()) {
|
|
||||||
case MENTION:
|
|
||||||
case POLL: {
|
|
||||||
return VIEW_TYPE_STATUS;
|
|
||||||
}
|
|
||||||
case STATUS:
|
|
||||||
case FAVOURITE:
|
|
||||||
case REBLOG:
|
|
||||||
case UPDATE: {
|
|
||||||
return VIEW_TYPE_STATUS_NOTIFICATION;
|
|
||||||
}
|
|
||||||
case FOLLOW:
|
|
||||||
case SIGN_UP: {
|
|
||||||
return VIEW_TYPE_FOLLOW;
|
|
||||||
}
|
|
||||||
case FOLLOW_REQUEST: {
|
|
||||||
return VIEW_TYPE_FOLLOW_REQUEST;
|
|
||||||
}
|
|
||||||
case REPORT: {
|
|
||||||
return VIEW_TYPE_REPORT;
|
|
||||||
}
|
|
||||||
default: {
|
|
||||||
return VIEW_TYPE_UNKNOWN;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (notification instanceof NotificationViewData.Placeholder) {
|
|
||||||
return VIEW_TYPE_PLACEHOLDER;
|
|
||||||
} else {
|
|
||||||
throw new AssertionError("Unknown notification type");
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
public interface NotificationActionListener {
|
|
||||||
void onViewAccount(String id);
|
|
||||||
|
|
||||||
void onViewStatusForNotificationId(String notificationId);
|
|
||||||
|
|
||||||
void onViewReport(String reportId);
|
|
||||||
|
|
||||||
void onExpandedChange(boolean expanded, int position);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when the status {@link android.widget.ToggleButton} responsible for collapsing long
|
|
||||||
* status content is interacted with.
|
|
||||||
*
|
|
||||||
* @param isCollapsed Whether the status content is shown in a collapsed state or fully.
|
|
||||||
* @param position The position of the status in the list.
|
|
||||||
*/
|
|
||||||
void onNotificationContentCollapsedChange(boolean isCollapsed, int position);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static class FollowViewHolder extends RecyclerView.ViewHolder {
|
|
||||||
private TextView message;
|
|
||||||
private TextView usernameView;
|
|
||||||
private TextView displayNameView;
|
|
||||||
private ImageView avatar;
|
|
||||||
private 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());
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
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 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 ConstraintLayout quoteContainer;
|
|
||||||
private StatusDisplayOptions statusDisplayOptions;
|
|
||||||
private final AbsoluteTimeFormatter absoluteTimeFormatter;
|
|
||||||
|
|
||||||
private String accountId;
|
|
||||||
private String notificationId;
|
|
||||||
private NotificationActionListener notificationActionListener;
|
|
||||||
private StatusViewData.Concrete statusViewData;
|
|
||||||
|
|
||||||
private int avatarRadius48dp;
|
|
||||||
private int avatarRadius36dp;
|
|
||||||
private 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);
|
|
||||||
this.statusDisplayOptions = statusDisplayOptions;
|
|
||||||
this.absoluteTimeFormatter = absoluteTimeFormatter;
|
|
||||||
|
|
||||||
quoteContainer = itemView.findViewById(R.id.status_quote_inline_container);
|
|
||||||
|
|
||||||
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());
|
|
||||||
|
|
||||||
if (statusDisplayOptions.showBotOverlay() && isBot) {
|
|
||||||
notificationAvatar.setVisibility(View.VISIBLE);
|
|
||||||
Glide.with(notificationAvatar)
|
|
||||||
.load(ContextCompat.getDrawable(notificationAvatar.getContext(), 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());
|
|
||||||
|
|
||||||
notificationAvatar.setVisibility(View.VISIBLE);
|
|
||||||
ImageLoadingHelper.loadAvatar(notificationAvatarUrl, notificationAvatar,
|
|
||||||
avatarRadius24dp, statusDisplayOptions.animateAvatars());
|
|
||||||
}
|
|
||||||
|
|
||||||
private void setQuoteContainer(StatusViewData.Concrete quote, final LinkListener listener, StatusDisplayOptions statusDisplayOptions) {
|
|
||||||
if (quote != null) {
|
|
||||||
quoteContainer.setVisibility(View.VISIBLE);
|
|
||||||
ViewQuoteInlineBinding binding = ViewQuoteInlineBinding.bind(quoteContainer);
|
|
||||||
new QuoteInlineHelper(binding, listener,
|
|
||||||
quoteContainer.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_24dp),
|
|
||||||
statusDisplayOptions)
|
|
||||||
.setupQuoteContainer(quote);
|
|
||||||
} else {
|
|
||||||
quoteContainer.setVisibility(View.GONE);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onClick(View v) {
|
|
||||||
switch (v.getId()) {
|
|
||||||
case R.id.notification_container:
|
|
||||||
case R.id.notification_content:
|
|
||||||
if (notificationActionListener != null)
|
|
||||||
notificationActionListener.onViewStatusForNotificationId(notificationId);
|
|
||||||
break;
|
|
||||||
case R.id.notification_top_text:
|
|
||||||
if (notificationActionListener != null)
|
|
||||||
notificationActionListener.onViewAccount(accountId);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
if (statusViewData.getSpoilerText() != null) {
|
|
||||||
emojifiedContentWarning = CustomEmojiHelper.emojify(
|
|
||||||
statusViewData.getSpoilerText(),
|
|
||||||
statusViewData.getActionable().getEmojis(),
|
|
||||||
contentWarningDescriptionTextView,
|
|
||||||
statusDisplayOptions.animateEmojis()
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
emojifiedContentWarning = "";
|
|
||||||
}
|
|
||||||
contentWarningDescriptionTextView.setText(emojifiedContentWarning);
|
|
||||||
|
|
||||||
setQuoteContainer(statusViewData.getQuoteViewData(), listener, statusDisplayOptions);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -75,7 +75,6 @@ class PollAdapter : RecyclerView.Adapter<BindingHolder<ItemPollBinding>>() {
|
||||||
override fun getItemCount() = pollOptions.size
|
override fun getItemCount() = pollOptions.size
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: BindingHolder<ItemPollBinding>, position: Int) {
|
override fun onBindViewHolder(holder: BindingHolder<ItemPollBinding>, position: Int) {
|
||||||
|
|
||||||
val option = pollOptions[position]
|
val option = pollOptions[position]
|
||||||
|
|
||||||
val resultTextView = holder.binding.statusPollOptionResult
|
val resultTextView = holder.binding.statusPollOptionResult
|
||||||
|
|
|
@ -20,28 +20,76 @@ import androidx.core.content.ContextCompat
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import at.connyduck.sparkbutton.helpers.Utils
|
import at.connyduck.sparkbutton.helpers.Utils
|
||||||
import com.keylesspalace.tusky.R
|
import com.keylesspalace.tusky.R
|
||||||
import com.keylesspalace.tusky.adapter.NotificationsAdapter.NotificationActionListener
|
import com.keylesspalace.tusky.components.notifications.NotificationActionListener
|
||||||
|
import com.keylesspalace.tusky.components.notifications.NotificationsPagingAdapter
|
||||||
import com.keylesspalace.tusky.databinding.ItemReportNotificationBinding
|
import com.keylesspalace.tusky.databinding.ItemReportNotificationBinding
|
||||||
import com.keylesspalace.tusky.entity.Report
|
import com.keylesspalace.tusky.entity.Report
|
||||||
import com.keylesspalace.tusky.entity.TimelineAccount
|
import com.keylesspalace.tusky.entity.TimelineAccount
|
||||||
|
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
||||||
import com.keylesspalace.tusky.util.emojify
|
import com.keylesspalace.tusky.util.emojify
|
||||||
import com.keylesspalace.tusky.util.getRelativeTimeSpanString
|
import com.keylesspalace.tusky.util.getRelativeTimeSpanString
|
||||||
import com.keylesspalace.tusky.util.loadAvatar
|
import com.keylesspalace.tusky.util.loadAvatar
|
||||||
import com.keylesspalace.tusky.util.unicodeWrap
|
import com.keylesspalace.tusky.util.unicodeWrap
|
||||||
|
import com.keylesspalace.tusky.viewdata.NotificationViewData
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
|
||||||
class ReportNotificationViewHolder(
|
class ReportNotificationViewHolder(
|
||||||
private val binding: ItemReportNotificationBinding,
|
private val binding: ItemReportNotificationBinding,
|
||||||
) : RecyclerView.ViewHolder(binding.root) {
|
private val notificationActionListener: NotificationActionListener
|
||||||
|
) : NotificationsPagingAdapter.ViewHolder, RecyclerView.ViewHolder(binding.root) {
|
||||||
|
|
||||||
fun setupWithReport(reporter: TimelineAccount, report: Report, animateAvatar: Boolean, animateEmojis: Boolean) {
|
override fun bind(
|
||||||
val reporterName = reporter.name.unicodeWrap().emojify(reporter.emojis, itemView, animateEmojis)
|
viewData: NotificationViewData,
|
||||||
val reporteeName = report.targetAccount.name.unicodeWrap().emojify(report.targetAccount.emojis, itemView, animateEmojis)
|
payloads: List<*>?,
|
||||||
val icon = ContextCompat.getDrawable(itemView.context, R.drawable.ic_flag_24dp)
|
statusDisplayOptions: StatusDisplayOptions
|
||||||
|
) {
|
||||||
|
// Skip updates with payloads. That indicates a timestamp update, and
|
||||||
|
// this view does not have timestamps.
|
||||||
|
if (!payloads.isNullOrEmpty()) return
|
||||||
|
|
||||||
|
setupWithReport(
|
||||||
|
viewData.account,
|
||||||
|
viewData.report!!,
|
||||||
|
statusDisplayOptions.animateAvatars,
|
||||||
|
statusDisplayOptions.animateEmojis
|
||||||
|
)
|
||||||
|
setupActionListener(
|
||||||
|
notificationActionListener,
|
||||||
|
viewData.report.targetAccount.id,
|
||||||
|
viewData.account.id,
|
||||||
|
viewData.report.id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupWithReport(
|
||||||
|
reporter: TimelineAccount,
|
||||||
|
report: Report,
|
||||||
|
animateAvatar: Boolean,
|
||||||
|
animateEmojis: Boolean
|
||||||
|
) {
|
||||||
|
val reporterName = reporter.name.unicodeWrap().emojify(
|
||||||
|
reporter.emojis,
|
||||||
|
binding.root,
|
||||||
|
animateEmojis
|
||||||
|
)
|
||||||
|
val reporteeName = report.targetAccount.name.unicodeWrap().emojify(
|
||||||
|
report.targetAccount.emojis,
|
||||||
|
itemView,
|
||||||
|
animateEmojis
|
||||||
|
)
|
||||||
|
val icon = ContextCompat.getDrawable(binding.root.context, R.drawable.ic_flag_24dp)
|
||||||
|
|
||||||
binding.notificationTopText.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null)
|
binding.notificationTopText.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null)
|
||||||
binding.notificationTopText.text = itemView.context.getString(R.string.notification_header_report_format, reporterName, reporteeName)
|
binding.notificationTopText.text = itemView.context.getString(
|
||||||
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)
|
R.string.notification_header_report_format,
|
||||||
|
reporterName,
|
||||||
|
reporteeName
|
||||||
|
)
|
||||||
|
binding.notificationSummary.text = itemView.context.getString(
|
||||||
|
R.string.notification_summary_report_format,
|
||||||
|
getRelativeTimeSpanString(itemView.context, report.createdAt.time, Date().time),
|
||||||
|
report.status_ids?.size ?: 0
|
||||||
|
)
|
||||||
binding.notificationCategory.text = getTranslatedCategory(itemView.context, report.category)
|
binding.notificationCategory.text = getTranslatedCategory(itemView.context, report.category)
|
||||||
|
|
||||||
// Fancy avatar inset
|
// Fancy avatar inset
|
||||||
|
@ -52,17 +100,22 @@ class ReportNotificationViewHolder(
|
||||||
report.targetAccount.avatar,
|
report.targetAccount.avatar,
|
||||||
binding.notificationReporteeAvatar,
|
binding.notificationReporteeAvatar,
|
||||||
itemView.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp),
|
itemView.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp),
|
||||||
animateAvatar,
|
animateAvatar
|
||||||
)
|
)
|
||||||
loadAvatar(
|
loadAvatar(
|
||||||
reporter.avatar,
|
reporter.avatar,
|
||||||
binding.notificationReporterAvatar,
|
binding.notificationReporterAvatar,
|
||||||
itemView.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_24dp),
|
itemView.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_24dp),
|
||||||
animateAvatar,
|
animateAvatar
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setupActionListener(listener: NotificationActionListener, reporteeId: String, reporterId: String, reportId: String) {
|
private fun setupActionListener(
|
||||||
|
listener: NotificationActionListener,
|
||||||
|
reporteeId: String,
|
||||||
|
reporterId: String,
|
||||||
|
reportId: String
|
||||||
|
) {
|
||||||
binding.notificationReporteeAvatar.setOnClickListener {
|
binding.notificationReporteeAvatar.setOnClickListener {
|
||||||
val position = bindingAdapterPosition
|
val position = bindingAdapterPosition
|
||||||
if (position != RecyclerView.NO_POSITION) {
|
if (position != RecyclerView.NO_POSITION) {
|
||||||
|
|
|
@ -13,6 +13,7 @@ import android.text.TextUtils;
|
||||||
import android.text.format.DateUtils;
|
import android.text.format.DateUtils;
|
||||||
import android.text.style.DynamicDrawableSpan;
|
import android.text.style.DynamicDrawableSpan;
|
||||||
import android.text.style.ImageSpan;
|
import android.text.style.ImageSpan;
|
||||||
|
import android.view.Menu;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
import android.view.ViewGroup;
|
import android.view.ViewGroup;
|
||||||
import android.widget.Button;
|
import android.widget.Button;
|
||||||
|
@ -25,12 +26,11 @@ import android.widget.Toast;
|
||||||
import androidx.annotation.DrawableRes;
|
import androidx.annotation.DrawableRes;
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
import androidx.appcompat.app.AlertDialog;
|
|
||||||
import androidx.appcompat.content.res.AppCompatResources;
|
import androidx.appcompat.content.res.AppCompatResources;
|
||||||
|
import androidx.appcompat.widget.PopupMenu;
|
||||||
import androidx.constraintlayout.widget.ConstraintLayout;
|
import androidx.constraintlayout.widget.ConstraintLayout;
|
||||||
import androidx.core.content.ContextCompat;
|
import androidx.core.content.ContextCompat;
|
||||||
import androidx.core.text.HtmlCompat;
|
import androidx.core.text.HtmlCompat;
|
||||||
import androidx.core.view.ViewKt;
|
|
||||||
import androidx.recyclerview.widget.DefaultItemAnimator;
|
import androidx.recyclerview.widget.DefaultItemAnimator;
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||||
import androidx.recyclerview.widget.RecyclerView;
|
import androidx.recyclerview.widget.RecyclerView;
|
||||||
|
@ -50,6 +50,8 @@ import com.keylesspalace.tusky.entity.Attachment.Focus;
|
||||||
import com.keylesspalace.tusky.entity.Attachment.MetaData;
|
import com.keylesspalace.tusky.entity.Attachment.MetaData;
|
||||||
import com.keylesspalace.tusky.entity.Card;
|
import com.keylesspalace.tusky.entity.Card;
|
||||||
import com.keylesspalace.tusky.entity.Emoji;
|
import com.keylesspalace.tusky.entity.Emoji;
|
||||||
|
import com.keylesspalace.tusky.entity.Filter;
|
||||||
|
import com.keylesspalace.tusky.entity.FilterResult;
|
||||||
import com.keylesspalace.tusky.entity.HashTag;
|
import com.keylesspalace.tusky.entity.HashTag;
|
||||||
import com.keylesspalace.tusky.entity.Status;
|
import com.keylesspalace.tusky.entity.Status;
|
||||||
import com.keylesspalace.tusky.interfaces.StatusActionListener;
|
import com.keylesspalace.tusky.interfaces.StatusActionListener;
|
||||||
|
@ -59,6 +61,7 @@ import com.keylesspalace.tusky.util.CardViewMode;
|
||||||
import com.keylesspalace.tusky.util.CustomEmojiHelper;
|
import com.keylesspalace.tusky.util.CustomEmojiHelper;
|
||||||
import com.keylesspalace.tusky.util.ImageLoadingHelper;
|
import com.keylesspalace.tusky.util.ImageLoadingHelper;
|
||||||
import com.keylesspalace.tusky.util.LinkHelper;
|
import com.keylesspalace.tusky.util.LinkHelper;
|
||||||
|
import com.keylesspalace.tusky.util.NumberUtils;
|
||||||
import com.keylesspalace.tusky.util.StatusDisplayOptions;
|
import com.keylesspalace.tusky.util.StatusDisplayOptions;
|
||||||
import com.keylesspalace.tusky.util.TimestampUtils;
|
import com.keylesspalace.tusky.util.TimestampUtils;
|
||||||
import com.keylesspalace.tusky.util.TouchDelegateHelper;
|
import com.keylesspalace.tusky.util.TouchDelegateHelper;
|
||||||
|
@ -84,49 +87,55 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
||||||
public static final String KEY_CREATED = "created";
|
public static final String KEY_CREATED = "created";
|
||||||
}
|
}
|
||||||
|
|
||||||
private TextView displayName;
|
private final String TAG = "StatusBaseViewHolder";
|
||||||
private TextView username;
|
|
||||||
private ImageButton replyButton;
|
|
||||||
private TextView replyCountLabel;
|
|
||||||
private SparkButton reblogButton;
|
|
||||||
private SparkButton favouriteButton;
|
|
||||||
private ImageButton quoteButton;
|
|
||||||
private SparkButton bookmarkButton;
|
|
||||||
private ImageButton moreButton;
|
|
||||||
private ConstraintLayout mediaContainer;
|
|
||||||
protected MediaPreviewLayout mediaPreview;
|
|
||||||
private TextView sensitiveMediaWarning;
|
|
||||||
private View sensitiveMediaShow;
|
|
||||||
protected TextView[] mediaLabels;
|
|
||||||
protected CharSequence[] mediaDescriptions;
|
|
||||||
private MaterialButton contentWarningButton;
|
|
||||||
private ImageView avatarInset;
|
|
||||||
|
|
||||||
public ImageView avatar;
|
private final TextView displayName;
|
||||||
public TextView metaInfo;
|
private final TextView username;
|
||||||
public TextView content;
|
private final ImageButton replyButton;
|
||||||
public TextView contentWarningDescription;
|
private final TextView replyCountLabel;
|
||||||
|
private final SparkButton reblogButton;
|
||||||
|
private final SparkButton favouriteButton;
|
||||||
|
private final ImageButton quoteButton;
|
||||||
|
private final SparkButton bookmarkButton;
|
||||||
|
private final ImageButton moreButton;
|
||||||
|
private final ConstraintLayout mediaContainer;
|
||||||
|
protected final MediaPreviewLayout mediaPreview;
|
||||||
|
private final TextView sensitiveMediaWarning;
|
||||||
|
private final View sensitiveMediaShow;
|
||||||
|
protected final TextView[] mediaLabels;
|
||||||
|
protected final CharSequence[] mediaDescriptions;
|
||||||
|
private final MaterialButton contentWarningButton;
|
||||||
|
private final ImageView avatarInset;
|
||||||
|
|
||||||
private ConstraintLayout quoteContainer;
|
private final ConstraintLayout quoteContainer;
|
||||||
|
|
||||||
private RecyclerView pollOptions;
|
public final ImageView avatar;
|
||||||
private TextView pollDescription;
|
public final TextView metaInfo;
|
||||||
private Button pollButton;
|
public final TextView content;
|
||||||
|
public final TextView contentWarningDescription;
|
||||||
|
|
||||||
private LinearLayout cardView;
|
private final RecyclerView pollOptions;
|
||||||
private LinearLayout cardInfo;
|
private final TextView pollDescription;
|
||||||
private ShapeableImageView cardImage;
|
private final Button pollButton;
|
||||||
private TextView cardTitle;
|
|
||||||
private TextView cardDescription;
|
private final LinearLayout cardView;
|
||||||
private TextView cardUrl;
|
private final LinearLayout cardInfo;
|
||||||
private PollAdapter pollAdapter;
|
private final ShapeableImageView cardImage;
|
||||||
|
private final TextView cardTitle;
|
||||||
|
private final TextView cardDescription;
|
||||||
|
private final TextView cardUrl;
|
||||||
|
private final PollAdapter pollAdapter;
|
||||||
|
protected LinearLayout filteredPlaceholder;
|
||||||
|
protected TextView filteredPlaceholderLabel;
|
||||||
|
protected Button filteredPlaceholderShowButton;
|
||||||
|
protected ConstraintLayout statusContainer;
|
||||||
|
|
||||||
private final NumberFormat numberFormat = NumberFormat.getNumberInstance();
|
private final NumberFormat numberFormat = NumberFormat.getNumberInstance();
|
||||||
private final AbsoluteTimeFormatter absoluteTimeFormatter = new AbsoluteTimeFormatter();
|
private final AbsoluteTimeFormatter absoluteTimeFormatter = new AbsoluteTimeFormatter();
|
||||||
|
|
||||||
protected int avatarRadius48dp;
|
protected final int avatarRadius48dp;
|
||||||
private int avatarRadius36dp;
|
private final int avatarRadius36dp;
|
||||||
private int avatarRadius24dp;
|
private final int avatarRadius24dp;
|
||||||
|
|
||||||
private final Drawable mediaPreviewUnloaded;
|
private final Drawable mediaPreviewUnloaded;
|
||||||
|
|
||||||
|
@ -175,6 +184,11 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
||||||
cardDescription = itemView.findViewById(R.id.card_description);
|
cardDescription = itemView.findViewById(R.id.card_description);
|
||||||
cardUrl = itemView.findViewById(R.id.card_link);
|
cardUrl = itemView.findViewById(R.id.card_link);
|
||||||
|
|
||||||
|
filteredPlaceholder = itemView.findViewById(R.id.status_filtered_placeholder);
|
||||||
|
filteredPlaceholderLabel = itemView.findViewById(R.id.status_filter_label);
|
||||||
|
filteredPlaceholderShowButton = itemView.findViewById(R.id.status_filter_show_anyway);
|
||||||
|
statusContainer = itemView.findViewById(R.id.status_container);
|
||||||
|
|
||||||
pollAdapter = new PollAdapter();
|
pollAdapter = new PollAdapter();
|
||||||
pollOptions.setAdapter(pollAdapter);
|
pollOptions.setAdapter(pollAdapter);
|
||||||
pollOptions.setLayoutManager(new LinearLayoutManager(pollOptions.getContext()));
|
pollOptions.setLayoutManager(new LinearLayoutManager(pollOptions.getContext()));
|
||||||
|
@ -206,16 +220,17 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
||||||
contentWarningButton.performClick();
|
contentWarningButton.performClick();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected void setSpoilerAndContent(boolean expanded,
|
protected void setSpoilerAndContent(@NonNull StatusViewData.Concrete status,
|
||||||
@NonNull Spanned content,
|
|
||||||
@Nullable String spoilerText,
|
|
||||||
@Nullable List<Status.Mention> mentions,
|
|
||||||
@Nullable List<HashTag> tags,
|
|
||||||
@NonNull List<Emoji> emojis,
|
|
||||||
@Nullable PollViewData poll,
|
|
||||||
@NonNull StatusDisplayOptions statusDisplayOptions,
|
@NonNull StatusDisplayOptions statusDisplayOptions,
|
||||||
final StatusActionListener listener) {
|
final StatusActionListener listener) {
|
||||||
|
|
||||||
|
Status actionable = status.getActionable();
|
||||||
|
String spoilerText = status.getSpoilerText();
|
||||||
|
List<Emoji> emojis = actionable.getEmojis();
|
||||||
|
|
||||||
boolean sensitive = !TextUtils.isEmpty(spoilerText);
|
boolean sensitive = !TextUtils.isEmpty(spoilerText);
|
||||||
|
boolean expanded = status.isExpanded();
|
||||||
|
|
||||||
if (sensitive) {
|
if (sensitive) {
|
||||||
CharSequence emojiSpoiler = CustomEmojiHelper.emojify(
|
CharSequence emojiSpoiler = CustomEmojiHelper.emojify(
|
||||||
spoilerText, emojis, contentWarningDescription, statusDisplayOptions.animateEmojis()
|
spoilerText, emojis, contentWarningDescription, statusDisplayOptions.animateEmojis()
|
||||||
|
@ -224,20 +239,12 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
||||||
contentWarningDescription.setVisibility(View.VISIBLE);
|
contentWarningDescription.setVisibility(View.VISIBLE);
|
||||||
contentWarningButton.setVisibility(View.VISIBLE);
|
contentWarningButton.setVisibility(View.VISIBLE);
|
||||||
setContentWarningButtonText(expanded);
|
setContentWarningButtonText(expanded);
|
||||||
contentWarningButton.setOnClickListener(view -> {
|
contentWarningButton.setOnClickListener(view -> toggleExpandedState(true, !expanded, status, statusDisplayOptions, listener));
|
||||||
contentWarningDescription.invalidate();
|
this.setTextVisible(true, expanded, status, statusDisplayOptions, listener);
|
||||||
if (getBindingAdapterPosition() != RecyclerView.NO_POSITION) {
|
|
||||||
listener.onExpandedChange(!expanded, getBindingAdapterPosition());
|
|
||||||
}
|
|
||||||
setContentWarningButtonText(!expanded);
|
|
||||||
|
|
||||||
this.setTextVisible(sensitive, !expanded, content, mentions, tags, emojis, poll, statusDisplayOptions, listener);
|
|
||||||
});
|
|
||||||
this.setTextVisible(sensitive, expanded, content, mentions, tags, emojis, poll, statusDisplayOptions, listener);
|
|
||||||
} else {
|
} else {
|
||||||
contentWarningDescription.setVisibility(View.GONE);
|
contentWarningDescription.setVisibility(View.GONE);
|
||||||
contentWarningButton.setVisibility(View.GONE);
|
contentWarningButton.setVisibility(View.GONE);
|
||||||
this.setTextVisible(sensitive, true, content, mentions, tags, emojis, poll, statusDisplayOptions, listener);
|
this.setTextVisible(false, true, status, statusDisplayOptions, listener);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -249,20 +256,42 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected void toggleExpandedState(boolean sensitive,
|
||||||
|
boolean expanded,
|
||||||
|
@NonNull final StatusViewData.Concrete status,
|
||||||
|
@NonNull final StatusDisplayOptions statusDisplayOptions,
|
||||||
|
@NonNull final StatusActionListener listener) {
|
||||||
|
|
||||||
|
contentWarningDescription.invalidate();
|
||||||
|
int adapterPosition = getBindingAdapterPosition();
|
||||||
|
if (adapterPosition != RecyclerView.NO_POSITION) {
|
||||||
|
listener.onExpandedChange(expanded, adapterPosition);
|
||||||
|
}
|
||||||
|
setContentWarningButtonText(expanded);
|
||||||
|
|
||||||
|
this.setTextVisible(sensitive, expanded, status, statusDisplayOptions, listener);
|
||||||
|
|
||||||
|
setupCard(status, expanded, statusDisplayOptions.cardViewMode(), statusDisplayOptions, listener);
|
||||||
|
}
|
||||||
|
|
||||||
private void setTextVisible(boolean sensitive,
|
private void setTextVisible(boolean sensitive,
|
||||||
boolean expanded,
|
boolean expanded,
|
||||||
Spanned content,
|
@NonNull final StatusViewData.Concrete status,
|
||||||
List<Status.Mention> mentions,
|
@NonNull final StatusDisplayOptions statusDisplayOptions,
|
||||||
List<HashTag> tags,
|
|
||||||
List<Emoji> emojis,
|
|
||||||
@Nullable PollViewData poll,
|
|
||||||
StatusDisplayOptions statusDisplayOptions,
|
|
||||||
final StatusActionListener listener) {
|
final StatusActionListener listener) {
|
||||||
|
|
||||||
|
Status actionable = status.getActionable();
|
||||||
|
Spanned content = status.getContent();
|
||||||
|
List<Status.Mention> mentions = actionable.getMentions();
|
||||||
|
List<HashTag> tags =actionable.getTags();
|
||||||
|
List<Emoji> emojis = actionable.getEmojis();
|
||||||
|
PollViewData poll = PollViewDataKt.toViewData(actionable.getPoll());
|
||||||
|
|
||||||
if (expanded) {
|
if (expanded) {
|
||||||
CharSequence emojifiedText = CustomEmojiHelper.emojify(content, emojis, this.content, statusDisplayOptions.animateEmojis());
|
CharSequence emojifiedText = CustomEmojiHelper.emojify(content, emojis, this.content, statusDisplayOptions.animateEmojis());
|
||||||
LinkHelper.setClickableText(this.content, emojifiedText, mentions, tags, listener);
|
LinkHelper.setClickableText(this.content, emojifiedText, mentions, tags, listener);
|
||||||
for (int i = 0; i < mediaLabels.length; ++i) {
|
for (int i = 0; i < mediaLabels.length; ++i) {
|
||||||
updateMediaLabel(i, sensitive, expanded);
|
updateMediaLabel(i, sensitive, true);
|
||||||
}
|
}
|
||||||
if (poll != null) {
|
if (poll != null) {
|
||||||
setupPoll(poll, emojis, statusDisplayOptions, listener);
|
setupPoll(poll, emojis, statusDisplayOptions, listener);
|
||||||
|
@ -287,7 +316,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
||||||
}
|
}
|
||||||
|
|
||||||
private void setAvatar(String url,
|
private void setAvatar(String url,
|
||||||
@Nullable String rebloggedUrl,
|
@Nullable String rebloggedUrl,
|
||||||
boolean isBot,
|
boolean isBot,
|
||||||
StatusDisplayOptions statusDisplayOptions) {
|
StatusDisplayOptions statusDisplayOptions) {
|
||||||
|
|
||||||
|
@ -298,8 +327,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
||||||
if (statusDisplayOptions.showBotOverlay() && isBot) {
|
if (statusDisplayOptions.showBotOverlay() && isBot) {
|
||||||
avatarInset.setVisibility(View.VISIBLE);
|
avatarInset.setVisibility(View.VISIBLE);
|
||||||
Glide.with(avatarInset)
|
Glide.with(avatarInset)
|
||||||
// passing the drawable id directly into .load() ignores night mode https://github.com/bumptech/glide/issues/4692
|
.load(R.drawable.bot_badge)
|
||||||
.load(ContextCompat.getDrawable(avatarInset.getContext(), R.drawable.bot_badge))
|
|
||||||
.into(avatarInset);
|
.into(avatarInset);
|
||||||
} else {
|
} else {
|
||||||
avatarInset.setVisibility(View.GONE);
|
avatarInset.setVisibility(View.GONE);
|
||||||
|
@ -356,8 +384,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
||||||
} else {
|
} else {
|
||||||
long then = createdAt.getTime();
|
long then = createdAt.getTime();
|
||||||
long now = System.currentTimeMillis();
|
long now = System.currentTimeMillis();
|
||||||
String readout = TimestampUtils.getRelativeTimeSpanString(metaInfo.getContext(), then, now);
|
timestampText = TimestampUtils.getRelativeTimeSpanString(metaInfo.getContext(), then, now);
|
||||||
timestampText = readout;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -444,11 +471,18 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void setReplyCount(int repliesCount) {
|
protected void setReplyCount(int repliesCount, boolean fullStats) {
|
||||||
// This label only exists in the non-detailed view (to match the web ui)
|
// This label only exists in the non-detailed view (to match the web ui)
|
||||||
if (replyCountLabel != null) {
|
if (replyCountLabel == null) return;
|
||||||
replyCountLabel.setText((repliesCount > 1 ? replyCountLabel.getContext().getString(R.string.status_count_one_plus) : Integer.toString(repliesCount)));
|
|
||||||
|
if (fullStats) {
|
||||||
|
replyCountLabel.setText(NumberUtils.shortNumber(repliesCount));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show "0", "1", or "1+" for replies otherwise, so the user knows if there is a thread
|
||||||
|
// that they can click through to read.
|
||||||
|
replyCountLabel.setText((repliesCount > 1 ? replyCountLabel.getContext().getString(R.string.status_count_one_plus) : Integer.toString(repliesCount)));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void setReblogged(boolean reblogged) {
|
private void setReblogged(boolean reblogged) {
|
||||||
|
@ -727,7 +761,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
||||||
int position = getBindingAdapterPosition();
|
int position = getBindingAdapterPosition();
|
||||||
if (position != RecyclerView.NO_POSITION) {
|
if (position != RecyclerView.NO_POSITION) {
|
||||||
if (statusDisplayOptions.confirmReblogs()) {
|
if (statusDisplayOptions.confirmReblogs()) {
|
||||||
showConfirmReblogDialog(listener, statusContent, buttonState, position);
|
showConfirmReblog(listener, buttonState, position);
|
||||||
return false;
|
return false;
|
||||||
} else {
|
} else {
|
||||||
listener.onReblog(!buttonState, position);
|
listener.onReblog(!buttonState, position);
|
||||||
|
@ -739,12 +773,13 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
favouriteButton.setEventListener((button, buttonState) -> {
|
favouriteButton.setEventListener((button, buttonState) -> {
|
||||||
// return true to play animation
|
// return true to play animation
|
||||||
int position = getBindingAdapterPosition();
|
int position = getBindingAdapterPosition();
|
||||||
if (position != RecyclerView.NO_POSITION) {
|
if (position != RecyclerView.NO_POSITION) {
|
||||||
if (statusDisplayOptions.confirmFavourites()) {
|
if (statusDisplayOptions.confirmFavourites()) {
|
||||||
showConfirmFavouriteDialog(listener, statusContent, buttonState, position);
|
showConfirmFavourite(listener, buttonState, position);
|
||||||
return false;
|
return false;
|
||||||
} else {
|
} else {
|
||||||
listener.onFavourite(!buttonState, position);
|
listener.onFavourite(!buttonState, position);
|
||||||
|
@ -801,38 +836,46 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
||||||
itemView.setOnClickListener(viewThreadListener);
|
itemView.setOnClickListener(viewThreadListener);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void showConfirmReblogDialog(StatusActionListener listener,
|
private void showConfirmReblog(StatusActionListener listener,
|
||||||
String statusContent,
|
boolean buttonState,
|
||||||
boolean buttonState,
|
int position) {
|
||||||
int position) {
|
PopupMenu popup = new PopupMenu(itemView.getContext(), reblogButton);
|
||||||
int okButtonTextId = buttonState ? R.string.action_unreblog : R.string.action_reblog;
|
popup.inflate(R.menu.status_reblog);
|
||||||
new AlertDialog.Builder(reblogButton.getContext())
|
Menu menu = popup.getMenu();
|
||||||
.setMessage(statusContent)
|
if (buttonState) {
|
||||||
.setPositiveButton(okButtonTextId, (__, ___) -> {
|
menu.findItem(R.id.menu_action_reblog).setVisible(false);
|
||||||
listener.onReblog(!buttonState, position);
|
} else {
|
||||||
if (!buttonState) {
|
menu.findItem(R.id.menu_action_unreblog).setVisible(false);
|
||||||
// Play animation only when it's reblog, not unreblog
|
}
|
||||||
reblogButton.playAnimation();
|
popup.setOnMenuItemClickListener(item -> {
|
||||||
}
|
listener.onReblog(!buttonState, position);
|
||||||
})
|
if(!buttonState) {
|
||||||
.show();
|
reblogButton.playAnimation();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
popup.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void showConfirmFavouriteDialog(StatusActionListener listener,
|
private void showConfirmFavourite(StatusActionListener listener,
|
||||||
String statusContent,
|
boolean buttonState,
|
||||||
boolean buttonState,
|
int position) {
|
||||||
int position) {
|
PopupMenu popup = new PopupMenu(itemView.getContext(), favouriteButton);
|
||||||
int okButtonTextId = buttonState ? R.string.action_unfavourite : R.string.action_favourite;
|
popup.inflate(R.menu.status_favourite);
|
||||||
new AlertDialog.Builder(favouriteButton.getContext())
|
Menu menu = popup.getMenu();
|
||||||
.setMessage(statusContent)
|
if (buttonState) {
|
||||||
.setPositiveButton(okButtonTextId, (__, ___) -> {
|
menu.findItem(R.id.menu_action_favourite).setVisible(false);
|
||||||
listener.onFavourite(!buttonState, position);
|
} else {
|
||||||
if (!buttonState) {
|
menu.findItem(R.id.menu_action_unfavourite).setVisible(false);
|
||||||
// Play animation only when it's favourite, not unfavourite
|
}
|
||||||
favouriteButton.playAnimation();
|
popup.setOnMenuItemClickListener(item -> {
|
||||||
}
|
listener.onFavourite(!buttonState, position);
|
||||||
})
|
if(!buttonState) {
|
||||||
.show();
|
favouriteButton.playAnimation();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
popup.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setupWithStatus(StatusViewData.Concrete status, final StatusActionListener listener,
|
public void setupWithStatus(StatusViewData.Concrete status, final StatusActionListener listener,
|
||||||
|
@ -850,7 +893,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
||||||
setUsername(status.getUsername());
|
setUsername(status.getUsername());
|
||||||
setMetaData(status, statusDisplayOptions, listener);
|
setMetaData(status, statusDisplayOptions, listener);
|
||||||
setIsReply(actionable.getInReplyToId() != null);
|
setIsReply(actionable.getInReplyToId() != null);
|
||||||
setReplyCount(actionable.getRepliesCount());
|
setReplyCount(actionable.getRepliesCount(), statusDisplayOptions.showStatsInline());
|
||||||
setAvatar(actionable.getAccount().getAvatar(), status.getRebloggedAvatar(),
|
setAvatar(actionable.getAccount().getAvatar(), status.getRebloggedAvatar(),
|
||||||
actionable.getAccount().getBot(), statusDisplayOptions);
|
actionable.getAccount().getBot(), statusDisplayOptions);
|
||||||
setReblogged(actionable.getReblogged());
|
setReblogged(actionable.getReblogged());
|
||||||
|
@ -876,19 +919,16 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
||||||
hideSensitiveMediaWarning();
|
hideSensitiveMediaWarning();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cardView != null) {
|
setupCard(status, status.isExpanded(), statusDisplayOptions.cardViewMode(), statusDisplayOptions, listener);
|
||||||
setupCard(status, statusDisplayOptions.cardViewMode(), statusDisplayOptions, listener);
|
|
||||||
}
|
|
||||||
|
|
||||||
setupButtons(listener, actionable.getAccount().getId(), status.getContent().toString(),
|
setupButtons(listener, actionable.getAccount().getId(), status.getContent().toString(),
|
||||||
actionable.isNotestock(), statusDisplayOptions);
|
actionable.isNotestock(), statusDisplayOptions);
|
||||||
setRebloggingEnabled(actionable.rebloggingAllowed(), actionable.getVisibility());
|
setRebloggingEnabled(actionable.rebloggingAllowed(), actionable.getVisibility());
|
||||||
setQuoteEnabled(actionable.rebloggingAllowed() && !actionable.isNotestock(), actionable.getVisibility());
|
setQuoteEnabled(actionable.rebloggingAllowed() && !actionable.isNotestock(), actionable.getVisibility());
|
||||||
|
|
||||||
setSpoilerAndContent(status.isExpanded(), status.getContent(), status.getSpoilerText(),
|
setSpoilerAndContent(status, statusDisplayOptions, listener);
|
||||||
actionable.getMentions(), actionable.getTags(), actionable.getEmojis(),
|
|
||||||
PollViewDataKt.toViewData(actionable.getPoll()), statusDisplayOptions,
|
setupFilterPlaceholder(status, listener, statusDisplayOptions);
|
||||||
listener);
|
|
||||||
|
|
||||||
setDescriptionForStatus(status, statusDisplayOptions);
|
setDescriptionForStatus(status, statusDisplayOptions);
|
||||||
|
|
||||||
|
@ -909,6 +949,30 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void setupFilterPlaceholder(StatusViewData.Concrete status, StatusActionListener listener, StatusDisplayOptions displayOptions) {
|
||||||
|
if (status.getFilterAction() != Filter.Action.WARN) {
|
||||||
|
showFilteredPlaceholder(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showFilteredPlaceholder(true);
|
||||||
|
|
||||||
|
Filter matchedFilter = null;
|
||||||
|
|
||||||
|
for (FilterResult result : status.getActionable().getFiltered()) {
|
||||||
|
Filter filter = result.getFilter();
|
||||||
|
if (filter.getAction() == Filter.Action.WARN) {
|
||||||
|
matchedFilter = filter;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
filteredPlaceholderLabel.setText(itemView.getContext().getString(R.string.status_filter_placeholder_label_format, matchedFilter.getTitle()));
|
||||||
|
filteredPlaceholderShowButton.setOnClickListener(view -> {
|
||||||
|
listener.clearWarningAction(getBindingAdapterPosition());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
protected static boolean hasPreviewableAttachment(List<Attachment> attachments) {
|
protected static boolean hasPreviewableAttachment(List<Attachment> attachments) {
|
||||||
for (Attachment attachment : attachments) {
|
for (Attachment attachment : attachments) {
|
||||||
if (attachment.getType() == Attachment.Type.AUDIO || attachment.getType() == Attachment.Type.UNKNOWN) {
|
if (attachment.getType() == Attachment.Type.AUDIO || attachment.getType() == Attachment.Type.UNKNOWN) {
|
||||||
|
@ -1143,20 +1207,27 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
||||||
}
|
}
|
||||||
|
|
||||||
protected void setupCard(
|
protected void setupCard(
|
||||||
StatusViewData.Concrete status,
|
final StatusViewData.Concrete status,
|
||||||
CardViewMode cardViewMode,
|
boolean expanded,
|
||||||
StatusDisplayOptions statusDisplayOptions,
|
final CardViewMode cardViewMode,
|
||||||
|
final StatusDisplayOptions statusDisplayOptions,
|
||||||
final StatusActionListener listener
|
final StatusActionListener listener
|
||||||
) {
|
) {
|
||||||
|
if (cardView == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final Status actionable = status.getActionable();
|
final Status actionable = status.getActionable();
|
||||||
final Card card = actionable.getCard();
|
final Card card = actionable.getCard();
|
||||||
|
|
||||||
if (cardViewMode != CardViewMode.NONE &&
|
if (cardViewMode != CardViewMode.NONE &&
|
||||||
actionable.getAttachments().size() == 0 &&
|
actionable.getAttachments().size() == 0 &&
|
||||||
actionable.getPoll() == null &&
|
actionable.getPoll() == null &&
|
||||||
card != null &&
|
card != null &&
|
||||||
!TextUtils.isEmpty(card.getUrl()) &&
|
!TextUtils.isEmpty(card.getUrl()) &&
|
||||||
(!actionable.getSensitive() || status.isExpanded()) &&
|
(!actionable.getSensitive() || expanded) &&
|
||||||
(!status.isCollapsible() || !status.isCollapsed())) {
|
(!status.isCollapsible() || !status.isCollapsed())) {
|
||||||
|
|
||||||
cardView.setVisibility(View.VISIBLE);
|
cardView.setVisibility(View.VISIBLE);
|
||||||
cardTitle.setText(card.getTitle());
|
cardTitle.setText(card.getTitle());
|
||||||
if (TextUtils.isEmpty(card.getDescription()) && TextUtils.isEmpty(card.getAuthorName())) {
|
if (TextUtils.isEmpty(card.getDescription()) && TextUtils.isEmpty(card.getAuthorName())) {
|
||||||
|
@ -1249,7 +1320,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
||||||
cardImage.setScaleType(ImageView.ScaleType.CENTER);
|
cardImage.setScaleType(ImageView.ScaleType.CENTER);
|
||||||
|
|
||||||
Glide.with(cardImage.getContext())
|
Glide.with(cardImage.getContext())
|
||||||
.load(ContextCompat.getDrawable(cardImage.getContext(), R.drawable.card_image_placeholder))
|
.load(R.drawable.card_image_placeholder)
|
||||||
.into(cardImage);
|
.into(cardImage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1288,4 +1359,13 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
||||||
bookmarkButton.setVisibility(visibility);
|
bookmarkButton.setVisibility(visibility);
|
||||||
moreButton.setVisibility(visibility);
|
moreButton.setVisibility(visibility);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void showFilteredPlaceholder(boolean show) {
|
||||||
|
if (statusContainer != null) {
|
||||||
|
statusContainer.setVisibility(show ? View.GONE : View.VISIBLE);
|
||||||
|
}
|
||||||
|
if (filteredPlaceholder != null) {
|
||||||
|
filteredPlaceholder.setVisibility(show ? View.VISIBLE : View.GONE);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -160,7 +160,7 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder {
|
||||||
status;
|
status;
|
||||||
|
|
||||||
super.setupWithStatus(uncollapsedStatus, listener, statusDisplayOptions, payloads);
|
super.setupWithStatus(uncollapsedStatus, listener, statusDisplayOptions, payloads);
|
||||||
setupCard(uncollapsedStatus, CardViewMode.FULL_WIDTH, statusDisplayOptions, listener); // Always show card for detailed status
|
setupCard(uncollapsedStatus, status.isExpanded(), CardViewMode.FULL_WIDTH, statusDisplayOptions, listener); // Always show card for detailed status
|
||||||
if (payloads == null) {
|
if (payloads == null) {
|
||||||
Status actionable = uncollapsedStatus.getActionable();
|
Status actionable = uncollapsedStatus.getActionable();
|
||||||
|
|
||||||
|
|
|
@ -28,9 +28,11 @@ import androidx.recyclerview.widget.RecyclerView;
|
||||||
|
|
||||||
import com.keylesspalace.tusky.R;
|
import com.keylesspalace.tusky.R;
|
||||||
import com.keylesspalace.tusky.entity.Emoji;
|
import com.keylesspalace.tusky.entity.Emoji;
|
||||||
|
import com.keylesspalace.tusky.entity.Filter;
|
||||||
import com.keylesspalace.tusky.entity.Status;
|
import com.keylesspalace.tusky.entity.Status;
|
||||||
import com.keylesspalace.tusky.interfaces.StatusActionListener;
|
import com.keylesspalace.tusky.interfaces.StatusActionListener;
|
||||||
import com.keylesspalace.tusky.util.CustomEmojiHelper;
|
import com.keylesspalace.tusky.util.CustomEmojiHelper;
|
||||||
|
import com.keylesspalace.tusky.util.NumberUtils;
|
||||||
import com.keylesspalace.tusky.util.SmartLengthInputFilter;
|
import com.keylesspalace.tusky.util.SmartLengthInputFilter;
|
||||||
import com.keylesspalace.tusky.util.StatusDisplayOptions;
|
import com.keylesspalace.tusky.util.StatusDisplayOptions;
|
||||||
import com.keylesspalace.tusky.util.StringUtils;
|
import com.keylesspalace.tusky.util.StringUtils;
|
||||||
|
@ -44,13 +46,17 @@ public class StatusViewHolder extends StatusBaseViewHolder {
|
||||||
private static final InputFilter[] COLLAPSE_INPUT_FILTER = new InputFilter[]{SmartLengthInputFilter.INSTANCE};
|
private static final InputFilter[] COLLAPSE_INPUT_FILTER = new InputFilter[]{SmartLengthInputFilter.INSTANCE};
|
||||||
private static final InputFilter[] NO_INPUT_FILTER = new InputFilter[0];
|
private static final InputFilter[] NO_INPUT_FILTER = new InputFilter[0];
|
||||||
|
|
||||||
private TextView statusInfo;
|
private final TextView statusInfo;
|
||||||
private Button contentCollapseButton;
|
private final Button contentCollapseButton;
|
||||||
|
private final TextView favouritedCountLabel;
|
||||||
|
private final TextView reblogsCountLabel;
|
||||||
|
|
||||||
public StatusViewHolder(View itemView) {
|
public StatusViewHolder(View itemView) {
|
||||||
super(itemView);
|
super(itemView);
|
||||||
statusInfo = itemView.findViewById(R.id.status_info);
|
statusInfo = itemView.findViewById(R.id.status_info);
|
||||||
contentCollapseButton = itemView.findViewById(R.id.button_toggle_content);
|
contentCollapseButton = itemView.findViewById(R.id.button_toggle_content);
|
||||||
|
favouritedCountLabel = itemView.findViewById(R.id.status_favourites_count);
|
||||||
|
reblogsCountLabel = itemView.findViewById(R.id.status_insets);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -60,10 +66,13 @@ public class StatusViewHolder extends StatusBaseViewHolder {
|
||||||
@Nullable Object payloads) {
|
@Nullable Object payloads) {
|
||||||
if (payloads == null) {
|
if (payloads == null) {
|
||||||
|
|
||||||
setupCollapsedState(status, listener);
|
boolean sensitive = !TextUtils.isEmpty(status.getActionable().getSpoilerText());
|
||||||
|
boolean expanded = status.isExpanded();
|
||||||
|
|
||||||
|
setupCollapsedState(sensitive, expanded, status, listener);
|
||||||
|
|
||||||
Status reblogging = status.getRebloggingStatus();
|
Status reblogging = status.getRebloggingStatus();
|
||||||
if (reblogging == null) {
|
if (reblogging == null || status.getFilterAction() == Filter.Action.WARN) {
|
||||||
hideStatusInfo();
|
hideStatusInfo();
|
||||||
} else {
|
} else {
|
||||||
String rebloggedByDisplayName = reblogging.getAccount().getName();
|
String rebloggedByDisplayName = reblogging.getAccount().getName();
|
||||||
|
@ -73,8 +82,13 @@ public class StatusViewHolder extends StatusBaseViewHolder {
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
super.setupWithStatus(status, listener, statusDisplayOptions, payloads);
|
|
||||||
|
|
||||||
|
reblogsCountLabel.setVisibility(statusDisplayOptions.showStatsInline() ? View.VISIBLE : View.INVISIBLE);
|
||||||
|
favouritedCountLabel.setVisibility(statusDisplayOptions.showStatsInline() ? View.VISIBLE : View.INVISIBLE);
|
||||||
|
setFavouritedCount(status.getActionable().getFavouritesCount());
|
||||||
|
setReblogsCount(status.getActionable().getReblogsCount());
|
||||||
|
|
||||||
|
super.setupWithStatus(status, listener, statusDisplayOptions, payloads);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void setRebloggedByDisplayName(final CharSequence name,
|
private void setRebloggedByDisplayName(final CharSequence name,
|
||||||
|
@ -91,7 +105,7 @@ public class StatusViewHolder extends StatusBaseViewHolder {
|
||||||
}
|
}
|
||||||
|
|
||||||
// don't use this on the same ViewHolder as setRebloggedByDisplayName, will cause recycling issues as paddings are changed
|
// don't use this on the same ViewHolder as setRebloggedByDisplayName, will cause recycling issues as paddings are changed
|
||||||
void setPollInfo(final boolean ownPoll) {
|
protected void setPollInfo(final boolean ownPoll) {
|
||||||
statusInfo.setText(ownPoll ? R.string.poll_ended_created : R.string.poll_ended_voted);
|
statusInfo.setText(ownPoll ? R.string.poll_ended_created : R.string.poll_ended_voted);
|
||||||
statusInfo.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_poll_24dp, 0, 0, 0);
|
statusInfo.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_poll_24dp, 0, 0, 0);
|
||||||
statusInfo.setCompoundDrawablePadding(Utils.dpToPx(statusInfo.getContext(), 10));
|
statusInfo.setCompoundDrawablePadding(Utils.dpToPx(statusInfo.getContext(), 10));
|
||||||
|
@ -99,13 +113,24 @@ public class StatusViewHolder extends StatusBaseViewHolder {
|
||||||
statusInfo.setVisibility(View.VISIBLE);
|
statusInfo.setVisibility(View.VISIBLE);
|
||||||
}
|
}
|
||||||
|
|
||||||
void hideStatusInfo() {
|
protected void setReblogsCount(int reblogsCount) {
|
||||||
|
reblogsCountLabel.setText(NumberUtils.shortNumber(reblogsCount));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void setFavouritedCount(int favouritedCount) {
|
||||||
|
favouritedCountLabel.setText(NumberUtils.shortNumber(favouritedCount));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void hideStatusInfo() {
|
||||||
statusInfo.setVisibility(View.GONE);
|
statusInfo.setVisibility(View.GONE);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void setupCollapsedState(final StatusViewData.Concrete status, final StatusActionListener listener) {
|
private void setupCollapsedState(boolean sensitive,
|
||||||
|
boolean expanded,
|
||||||
|
final StatusViewData.Concrete status,
|
||||||
|
final StatusActionListener listener) {
|
||||||
/* input filter for TextViews have to be set before text */
|
/* input filter for TextViews have to be set before text */
|
||||||
if (status.isCollapsible() && (status.isExpanded() || TextUtils.isEmpty(status.getSpoilerText()))) {
|
if (status.isCollapsible() && (!sensitive || expanded)) {
|
||||||
contentCollapseButton.setOnClickListener(view -> {
|
contentCollapseButton.setOnClickListener(view -> {
|
||||||
int position = getBindingAdapterPosition();
|
int position = getBindingAdapterPosition();
|
||||||
if (position != RecyclerView.NO_POSITION)
|
if (position != RecyclerView.NO_POSITION)
|
||||||
|
@ -130,4 +155,16 @@ public class StatusViewHolder extends StatusBaseViewHolder {
|
||||||
super.showStatusContent(show);
|
super.showStatusContent(show);
|
||||||
contentCollapseButton.setVisibility(show ? View.VISIBLE : View.GONE);
|
contentCollapseButton.setVisibility(show ? View.VISIBLE : View.GONE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void toggleExpandedState(boolean sensitive,
|
||||||
|
boolean expanded,
|
||||||
|
@NonNull StatusViewData.Concrete status,
|
||||||
|
@NonNull StatusDisplayOptions statusDisplayOptions,
|
||||||
|
@NonNull final StatusActionListener listener) {
|
||||||
|
|
||||||
|
setupCollapsedState(sensitive, expanded, status, listener);
|
||||||
|
|
||||||
|
super.toggleExpandedState(sensitive, expanded, status, statusDisplayOptions, listener);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,41 @@
|
||||||
|
/* 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.adapter
|
||||||
|
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.keylesspalace.tusky.R
|
||||||
|
import com.keylesspalace.tusky.databinding.ItemTrendingDateBinding
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.TimeZone
|
||||||
|
|
||||||
|
class TrendingDateViewHolder(
|
||||||
|
private val binding: ItemTrendingDateBinding
|
||||||
|
) : RecyclerView.ViewHolder(binding.root) {
|
||||||
|
|
||||||
|
private val dateFormat = SimpleDateFormat("EEE dd MMM yyyy", Locale.getDefault()).apply {
|
||||||
|
this.timeZone = TimeZone.getDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setup(start: Date, end: Date) {
|
||||||
|
binding.dates.text = itemView.context.getString(
|
||||||
|
R.string.date_range,
|
||||||
|
dateFormat.format(start),
|
||||||
|
dateFormat.format(end)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,94 @@
|
||||||
|
/* 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.adapter
|
||||||
|
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.keylesspalace.tusky.R
|
||||||
|
import com.keylesspalace.tusky.databinding.ItemTrendingCellBinding
|
||||||
|
import com.keylesspalace.tusky.entity.TrendingTagHistory
|
||||||
|
import com.keylesspalace.tusky.interfaces.LinkListener
|
||||||
|
import com.keylesspalace.tusky.viewdata.TrendingViewData
|
||||||
|
import java.text.NumberFormat
|
||||||
|
import kotlin.math.ln
|
||||||
|
import kotlin.math.pow
|
||||||
|
|
||||||
|
class TrendingTagViewHolder(
|
||||||
|
private val binding: ItemTrendingCellBinding
|
||||||
|
) : RecyclerView.ViewHolder(binding.root) {
|
||||||
|
|
||||||
|
fun setup(
|
||||||
|
tagViewData: TrendingViewData.Tag,
|
||||||
|
maxTrendingValue: Long,
|
||||||
|
trendingListener: LinkListener
|
||||||
|
) {
|
||||||
|
val reversedHistory = tagViewData.tag.history.reversed()
|
||||||
|
setGraph(reversedHistory, maxTrendingValue)
|
||||||
|
setTag(tagViewData.tag.name)
|
||||||
|
|
||||||
|
val totalUsage = tagViewData.tag.history.sumOf { it.uses.toLongOrNull() ?: 0 }
|
||||||
|
binding.totalUsage.text = formatNumber(totalUsage)
|
||||||
|
|
||||||
|
val totalAccounts = tagViewData.tag.history.sumOf { it.accounts.toLongOrNull() ?: 0 }
|
||||||
|
binding.totalAccounts.text = formatNumber(totalAccounts)
|
||||||
|
|
||||||
|
binding.currentUsage.text = reversedHistory.last().uses
|
||||||
|
binding.currentAccounts.text = reversedHistory.last().accounts
|
||||||
|
|
||||||
|
itemView.setOnClickListener {
|
||||||
|
trendingListener.onViewTag(tagViewData.tag.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
setAccessibility(totalAccounts, tagViewData.tag.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setGraph(history: List<TrendingTagHistory>, maxTrendingValue: Long) {
|
||||||
|
binding.graph.maxTrendingValue = maxTrendingValue
|
||||||
|
binding.graph.primaryLineData = history
|
||||||
|
.mapNotNull { it.uses.toLongOrNull() }
|
||||||
|
binding.graph.secondaryLineData = history
|
||||||
|
.mapNotNull { it.accounts.toLongOrNull() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setTag(tag: String) {
|
||||||
|
binding.tag.text = binding.root.context.getString(R.string.title_tag, tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setAccessibility(totalAccounts: Long, tag: String) {
|
||||||
|
itemView.contentDescription =
|
||||||
|
itemView.context.getString(R.string.accessibility_talking_about_tag, totalAccounts, tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val numberFormatter: NumberFormat = NumberFormat.getInstance()
|
||||||
|
private val ln_1k = ln(1000.0)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format numbers according to the current locale. Numbers < min have
|
||||||
|
* separators (',', '.', etc) inserted according to the locale.
|
||||||
|
*
|
||||||
|
* Numbers > min are scaled down to that by multiples of 1,000, and
|
||||||
|
* a suffix appropriate to the scaling is appended.
|
||||||
|
*/
|
||||||
|
private fun formatNumber(num: Long, min: Int = 100000): String {
|
||||||
|
if (num < min) return numberFormatter.format(num)
|
||||||
|
|
||||||
|
val exp = (ln(num.toDouble()) / ln_1k).toInt()
|
||||||
|
|
||||||
|
// TODO: is the choice of suffixes here locale-agnostic?
|
||||||
|
return String.format("%.1f %c", num / 1000.0.pow(exp.toDouble()), "KMGTPE"[exp - 1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,45 +3,51 @@ package com.keylesspalace.tusky.appstore
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
import com.keylesspalace.tusky.db.AccountManager
|
import com.keylesspalace.tusky.db.AccountManager
|
||||||
import com.keylesspalace.tusky.db.AppDatabase
|
import com.keylesspalace.tusky.db.AppDatabase
|
||||||
import io.reactivex.rxjava3.disposables.Disposable
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class CacheUpdater @Inject constructor(
|
class CacheUpdater @Inject constructor(
|
||||||
eventHub: EventHub,
|
eventHub: EventHub,
|
||||||
private val accountManager: AccountManager,
|
accountManager: AccountManager,
|
||||||
appDatabase: AppDatabase,
|
appDatabase: AppDatabase,
|
||||||
gson: Gson
|
gson: Gson
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val disposable: Disposable
|
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
|
||||||
init {
|
init {
|
||||||
val timelineDao = appDatabase.timelineDao()
|
val timelineDao = appDatabase.timelineDao()
|
||||||
|
|
||||||
disposable = eventHub.events.subscribe { event ->
|
scope.launch {
|
||||||
val accountId = accountManager.activeAccount?.id ?: return@subscribe
|
eventHub.events.collect { event ->
|
||||||
when (event) {
|
val accountId = accountManager.activeAccount?.id ?: return@collect
|
||||||
is FavoriteEvent ->
|
when (event) {
|
||||||
timelineDao.setFavourited(accountId, event.statusId, event.favourite)
|
is FavoriteEvent ->
|
||||||
is ReblogEvent ->
|
timelineDao.setFavourited(accountId, event.statusId, event.favourite)
|
||||||
timelineDao.setReblogged(accountId, event.statusId, event.reblog)
|
is ReblogEvent ->
|
||||||
is BookmarkEvent ->
|
timelineDao.setReblogged(accountId, event.statusId, event.reblog)
|
||||||
timelineDao.setBookmarked(accountId, event.statusId, event.bookmark)
|
is BookmarkEvent ->
|
||||||
is UnfollowEvent ->
|
timelineDao.setBookmarked(accountId, event.statusId, event.bookmark)
|
||||||
timelineDao.removeAllByUser(accountId, event.accountId)
|
is UnfollowEvent ->
|
||||||
is StatusDeletedEvent ->
|
timelineDao.removeAllByUser(accountId, event.accountId)
|
||||||
timelineDao.delete(accountId, event.statusId)
|
is StatusDeletedEvent ->
|
||||||
is PollVoteEvent -> {
|
timelineDao.delete(accountId, event.statusId)
|
||||||
val pollString = gson.toJson(event.poll)
|
is PollVoteEvent -> {
|
||||||
timelineDao.setVoted(accountId, event.statusId, pollString)
|
val pollString = gson.toJson(event.poll)
|
||||||
|
timelineDao.setVoted(accountId, event.statusId, pollString)
|
||||||
|
}
|
||||||
|
is PinEvent ->
|
||||||
|
timelineDao.setPinned(accountId, event.statusId, event.pinned)
|
||||||
}
|
}
|
||||||
is PinEvent ->
|
|
||||||
timelineDao.setPinned(accountId, event.statusId, event.pinned)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stop() {
|
fun stop() {
|
||||||
this.disposable.dispose()
|
this.scope.cancel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,22 +6,23 @@ import com.keylesspalace.tusky.entity.Poll
|
||||||
import com.keylesspalace.tusky.entity.Status
|
import com.keylesspalace.tusky.entity.Status
|
||||||
import net.accelf.yuito.streaming.Subscription
|
import net.accelf.yuito.streaming.Subscription
|
||||||
|
|
||||||
data class FavoriteEvent(val statusId: String, val favourite: Boolean) : Dispatchable
|
data class FavoriteEvent(val statusId: String, val favourite: Boolean) : Event
|
||||||
data class ReblogEvent(val statusId: String, val reblog: Boolean) : Dispatchable
|
data class ReblogEvent(val statusId: String, val reblog: Boolean) : Event
|
||||||
data class BookmarkEvent(val statusId: String, val bookmark: Boolean) : Dispatchable
|
data class BookmarkEvent(val statusId: String, val bookmark: Boolean) : Event
|
||||||
data class MuteConversationEvent(val statusId: String, val mute: Boolean) : Dispatchable
|
data class MuteConversationEvent(val statusId: String, val mute: Boolean) : Event
|
||||||
data class UnfollowEvent(val accountId: String) : Dispatchable
|
data class UnfollowEvent(val accountId: String) : Event
|
||||||
data class BlockEvent(val accountId: String) : Dispatchable
|
data class BlockEvent(val accountId: String) : Event
|
||||||
data class MuteEvent(val accountId: String) : Dispatchable
|
data class MuteEvent(val accountId: String) : Event
|
||||||
data class StatusDeletedEvent(val statusId: String) : Dispatchable
|
data class StatusDeletedEvent(val statusId: String) : Event
|
||||||
data class StatusComposedEvent(val status: Status) : Dispatchable
|
data class StatusComposedEvent(val status: Status) : Event
|
||||||
data class StatusScheduledEvent(val status: Status) : Dispatchable
|
data class StatusScheduledEvent(val status: Status) : Event
|
||||||
data class ProfileEditedEvent(val newProfileData: Account) : Dispatchable
|
data class StatusEditedEvent(val originalId: String, val status: Status) : Event
|
||||||
data class PreferenceChangedEvent(val preferenceKey: String) : Dispatchable
|
data class ProfileEditedEvent(val newProfileData: Account) : Event
|
||||||
data class MainTabsChangedEvent(val newTabs: List<TabData>) : Dispatchable
|
data class PreferenceChangedEvent(val preferenceKey: String) : Event
|
||||||
data class PollVoteEvent(val statusId: String, val poll: Poll) : Dispatchable
|
data class MainTabsChangedEvent(val newTabs: List<TabData>) : Event
|
||||||
data class DomainMuteEvent(val instance: String) : Dispatchable
|
data class PollVoteEvent(val statusId: String, val poll: Poll) : Event
|
||||||
data class AnnouncementReadEvent(val announcementId: String) : Dispatchable
|
data class DomainMuteEvent(val instance: String) : Event
|
||||||
data class PinEvent(val statusId: String, val pinned: Boolean) : Dispatchable
|
data class AnnouncementReadEvent(val announcementId: String) : Event
|
||||||
data class QuickReplyEvent(val status: Status) : Dispatchable
|
data class PinEvent(val statusId: String, val pinned: Boolean) : Event
|
||||||
data class StreamUpdateEvent(val status: Status, val subscription: Subscription) : Dispatchable
|
data class QuickReplyEvent(val status: Status) : Event
|
||||||
|
data class StreamUpdateEvent(val status: Status, val subscription: Subscription) : Event
|
||||||
|
|
|
@ -1,20 +1,19 @@
|
||||||
package com.keylesspalace.tusky.appstore
|
package com.keylesspalace.tusky.appstore
|
||||||
|
|
||||||
import io.reactivex.rxjava3.core.Observable
|
import kotlinx.coroutines.flow.Flow
|
||||||
import io.reactivex.rxjava3.subjects.PublishSubject
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
interface Event
|
interface Event
|
||||||
interface Dispatchable : Event
|
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
class EventHub @Inject constructor() {
|
class EventHub @Inject constructor() {
|
||||||
|
|
||||||
private val eventsSubject = PublishSubject.create<Event>()
|
private val sharedEventFlow: MutableSharedFlow<Event> = MutableSharedFlow()
|
||||||
val events: Observable<Event> = eventsSubject
|
val events: Flow<Event> = sharedEventFlow
|
||||||
|
|
||||||
fun dispatch(event: Dispatchable) {
|
suspend fun dispatch(event: Event) {
|
||||||
eventsSubject.onNext(event)
|
sharedEventFlow.emit(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,9 +22,11 @@ import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.res.ColorStateList
|
import android.content.res.ColorStateList
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
|
import android.graphics.drawable.LayerDrawable
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.text.Editable
|
import android.text.TextWatcher
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
|
import android.view.MenuInflater
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
@ -32,12 +34,15 @@ import androidx.activity.viewModels
|
||||||
import androidx.annotation.ColorInt
|
import androidx.annotation.ColorInt
|
||||||
import androidx.annotation.Px
|
import androidx.annotation.Px
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.appcompat.content.res.AppCompatResources
|
||||||
import androidx.core.app.ActivityOptionsCompat
|
import androidx.core.app.ActivityOptionsCompat
|
||||||
|
import androidx.core.view.MenuProvider
|
||||||
import androidx.core.view.ViewCompat
|
import androidx.core.view.ViewCompat
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.core.view.WindowInsetsCompat.Type.systemBars
|
import androidx.core.view.WindowInsetsCompat.Type.systemBars
|
||||||
import androidx.core.view.updatePadding
|
import androidx.core.view.updatePadding
|
||||||
|
import androidx.core.widget.doAfterTextChanged
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.viewpager2.widget.MarginPageTransformer
|
import androidx.viewpager2.widget.MarginPageTransformer
|
||||||
|
@ -50,13 +55,13 @@ import com.google.android.material.shape.ShapeAppearanceModel
|
||||||
import com.google.android.material.snackbar.Snackbar
|
import com.google.android.material.snackbar.Snackbar
|
||||||
import com.google.android.material.tabs.TabLayout
|
import com.google.android.material.tabs.TabLayout
|
||||||
import com.google.android.material.tabs.TabLayoutMediator
|
import com.google.android.material.tabs.TabLayoutMediator
|
||||||
import com.keylesspalace.tusky.AccountListActivity
|
|
||||||
import com.keylesspalace.tusky.BottomSheetActivity
|
import com.keylesspalace.tusky.BottomSheetActivity
|
||||||
import com.keylesspalace.tusky.EditProfileActivity
|
import com.keylesspalace.tusky.EditProfileActivity
|
||||||
import com.keylesspalace.tusky.R
|
import com.keylesspalace.tusky.R
|
||||||
import com.keylesspalace.tusky.StatusListActivity
|
import com.keylesspalace.tusky.StatusListActivity
|
||||||
import com.keylesspalace.tusky.ViewMediaActivity
|
import com.keylesspalace.tusky.ViewMediaActivity
|
||||||
import com.keylesspalace.tusky.components.account.list.ListsForAccountFragment
|
import com.keylesspalace.tusky.components.account.list.ListsForAccountFragment
|
||||||
|
import com.keylesspalace.tusky.components.accountlist.AccountListActivity
|
||||||
import com.keylesspalace.tusky.components.compose.ComposeActivity
|
import com.keylesspalace.tusky.components.compose.ComposeActivity
|
||||||
import com.keylesspalace.tusky.components.report.ReportActivity
|
import com.keylesspalace.tusky.components.report.ReportActivity
|
||||||
import com.keylesspalace.tusky.databinding.ActivityAccountBinding
|
import com.keylesspalace.tusky.databinding.ActivityAccountBinding
|
||||||
|
@ -70,7 +75,6 @@ import com.keylesspalace.tusky.interfaces.ActionButtonActivity
|
||||||
import com.keylesspalace.tusky.interfaces.LinkListener
|
import com.keylesspalace.tusky.interfaces.LinkListener
|
||||||
import com.keylesspalace.tusky.interfaces.ReselectableFragment
|
import com.keylesspalace.tusky.interfaces.ReselectableFragment
|
||||||
import com.keylesspalace.tusky.settings.PrefKeys
|
import com.keylesspalace.tusky.settings.PrefKeys
|
||||||
import com.keylesspalace.tusky.util.DefaultTextWatcher
|
|
||||||
import com.keylesspalace.tusky.util.Error
|
import com.keylesspalace.tusky.util.Error
|
||||||
import com.keylesspalace.tusky.util.Loading
|
import com.keylesspalace.tusky.util.Loading
|
||||||
import com.keylesspalace.tusky.util.Success
|
import com.keylesspalace.tusky.util.Success
|
||||||
|
@ -82,9 +86,14 @@ import com.keylesspalace.tusky.util.parseAsMastodonHtml
|
||||||
import com.keylesspalace.tusky.util.reduceSwipeSensitivity
|
import com.keylesspalace.tusky.util.reduceSwipeSensitivity
|
||||||
import com.keylesspalace.tusky.util.setClickableText
|
import com.keylesspalace.tusky.util.setClickableText
|
||||||
import com.keylesspalace.tusky.util.show
|
import com.keylesspalace.tusky.util.show
|
||||||
|
import com.keylesspalace.tusky.util.unsafeLazy
|
||||||
import com.keylesspalace.tusky.util.viewBinding
|
import com.keylesspalace.tusky.util.viewBinding
|
||||||
import com.keylesspalace.tusky.util.visible
|
import com.keylesspalace.tusky.util.visible
|
||||||
import com.keylesspalace.tusky.view.showMuteAccountDialog
|
import com.keylesspalace.tusky.view.showMuteAccountDialog
|
||||||
|
import com.mikepenz.iconics.IconicsDrawable
|
||||||
|
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
|
||||||
|
import com.mikepenz.iconics.utils.colorInt
|
||||||
|
import com.mikepenz.iconics.utils.sizeDp
|
||||||
import dagger.android.DispatchingAndroidInjector
|
import dagger.android.DispatchingAndroidInjector
|
||||||
import dagger.android.HasAndroidInjector
|
import dagger.android.HasAndroidInjector
|
||||||
import java.text.NumberFormat
|
import java.text.NumberFormat
|
||||||
|
@ -94,13 +103,14 @@ import java.util.Locale
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
|
||||||
class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInjector, LinkListener {
|
class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider, HasAndroidInjector, LinkListener {
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector<Any>
|
lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector<Any>
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var viewModelFactory: ViewModelFactory
|
lateinit var viewModelFactory: ViewModelFactory
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var draftsAlert: DraftsAlert
|
lateinit var draftsAlert: DraftsAlert
|
||||||
|
|
||||||
|
@ -110,7 +120,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
||||||
|
|
||||||
private lateinit var accountFieldAdapter: AccountFieldAdapter
|
private lateinit var accountFieldAdapter: AccountFieldAdapter
|
||||||
|
|
||||||
private val preferences by lazy { PreferenceManager.getDefaultSharedPreferences(this) }
|
private val preferences by unsafeLazy { PreferenceManager.getDefaultSharedPreferences(this) }
|
||||||
|
|
||||||
private var followState: FollowState = FollowState.NOT_FOLLOWING
|
private var followState: FollowState = FollowState.NOT_FOLLOWING
|
||||||
private var blocking: Boolean = false
|
private var blocking: Boolean = false
|
||||||
|
@ -150,11 +160,14 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
||||||
|
|
||||||
private lateinit var adapter: AccountPagerAdapter
|
private lateinit var adapter: AccountPagerAdapter
|
||||||
|
|
||||||
|
private var noteWatcher: TextWatcher? = null
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
loadResources()
|
loadResources()
|
||||||
makeNotificationBarTransparent()
|
makeNotificationBarTransparent()
|
||||||
setContentView(binding.root)
|
setContentView(binding.root)
|
||||||
|
addMenuProvider(this)
|
||||||
|
|
||||||
// Obtain information to fill out the profile.
|
// Obtain information to fill out the profile.
|
||||||
viewModel.setAccountInfo(intent.getStringExtra(KEY_ACCOUNT_ID)!!)
|
viewModel.setAccountInfo(intent.getStringExtra(KEY_ACCOUNT_ID)!!)
|
||||||
|
@ -183,9 +196,9 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
||||||
* Load colors and dimensions from resources
|
* Load colors and dimensions from resources
|
||||||
*/
|
*/
|
||||||
private fun loadResources() {
|
private fun loadResources() {
|
||||||
toolbarColor = MaterialColors.getColor(this, R.attr.colorSurface, Color.BLACK)
|
toolbarColor = MaterialColors.getColor(this, com.google.android.material.R.attr.colorSurface, Color.BLACK)
|
||||||
statusBarColorTransparent = getColor(R.color.transparent_statusbar_background)
|
statusBarColorTransparent = getColor(R.color.transparent_statusbar_background)
|
||||||
statusBarColorOpaque = MaterialColors.getColor(this, R.attr.colorPrimaryDark, Color.BLACK)
|
statusBarColorOpaque = MaterialColors.getColor(this, androidx.appcompat.R.attr.colorPrimaryDark, Color.BLACK)
|
||||||
avatarSize = resources.getDimension(R.dimen.account_activity_avatar_size)
|
avatarSize = resources.getDimension(R.dimen.account_activity_avatar_size)
|
||||||
titleVisibleHeight = resources.getDimensionPixelSize(R.dimen.account_activity_scroll_title_visible_height)
|
titleVisibleHeight = resources.getDimensionPixelSize(R.dimen.account_activity_scroll_title_visible_height)
|
||||||
}
|
}
|
||||||
|
@ -303,6 +316,23 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
||||||
toolbarBackground.fillColor = ColorStateList.valueOf(Color.TRANSPARENT)
|
toolbarBackground.fillColor = ColorStateList.valueOf(Color.TRANSPARENT)
|
||||||
binding.accountToolbar.background = toolbarBackground
|
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.accountHeaderInfoContainer.background = MaterialShapeDrawable.createWithElevationOverlay(this, appBarElevation)
|
binding.accountHeaderInfoContainer.background = MaterialShapeDrawable.createWithElevationOverlay(this, appBarElevation)
|
||||||
|
|
||||||
val avatarBackground = MaterialShapeDrawable.createWithElevationOverlay(this, appBarElevation).apply {
|
val avatarBackground = MaterialShapeDrawable.createWithElevationOverlay(this, appBarElevation).apply {
|
||||||
|
@ -318,7 +348,6 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
||||||
binding.accountAppBarLayout.addOnOffsetChangedListener(object : AppBarLayout.OnOffsetChangedListener {
|
binding.accountAppBarLayout.addOnOffsetChangedListener(object : AppBarLayout.OnOffsetChangedListener {
|
||||||
|
|
||||||
override fun onOffsetChanged(appBarLayout: AppBarLayout, verticalOffset: Int) {
|
override fun onOffsetChanged(appBarLayout: AppBarLayout, verticalOffset: Int) {
|
||||||
|
|
||||||
if (verticalOffset == oldOffset) {
|
if (verticalOffset == oldOffset) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -399,14 +428,16 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
||||||
draftsAlert.observeInContext(this, true)
|
draftsAlert.observeInContext(this, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun onRefresh() {
|
||||||
|
viewModel.refresh()
|
||||||
|
adapter.refreshContent()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Setup swipe to refresh layout
|
* Setup swipe to refresh layout
|
||||||
*/
|
*/
|
||||||
private fun setupRefreshLayout() {
|
private fun setupRefreshLayout() {
|
||||||
binding.swipeToRefreshLayout.setOnRefreshListener {
|
binding.swipeToRefreshLayout.setOnRefreshListener { onRefresh() }
|
||||||
viewModel.refresh()
|
|
||||||
adapter.refreshContent()
|
|
||||||
}
|
|
||||||
viewModel.isRefreshing.observe(
|
viewModel.isRefreshing.observe(
|
||||||
this
|
this
|
||||||
) { isRefreshing ->
|
) { isRefreshing ->
|
||||||
|
@ -439,8 +470,8 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
||||||
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)
|
setClickableText(binding.accountNoteTextView, emojifiedNote, emptyList(), null, this)
|
||||||
|
|
||||||
accountFieldAdapter.fields = account.fields ?: emptyList()
|
accountFieldAdapter.fields = account.fields.orEmpty()
|
||||||
accountFieldAdapter.emojis = account.emojis ?: emptyList()
|
accountFieldAdapter.emojis = account.emojis.orEmpty()
|
||||||
accountFieldAdapter.notifyDataSetChanged()
|
accountFieldAdapter.notifyDataSetChanged()
|
||||||
|
|
||||||
binding.accountLockedImageView.visible(account.locked)
|
binding.accountLockedImageView.visible(account.locked)
|
||||||
|
@ -493,18 +524,23 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
||||||
.centerCrop()
|
.centerCrop()
|
||||||
.into(binding.accountHeaderImageView)
|
.into(binding.accountHeaderImageView)
|
||||||
|
|
||||||
binding.accountAvatarImageView.setOnClickListener { avatarView ->
|
binding.accountAvatarImageView.setOnClickListener { view ->
|
||||||
val intent =
|
viewImage(view, account.avatar)
|
||||||
ViewMediaActivity.newSingleImageIntent(avatarView.context, account.avatar)
|
}
|
||||||
|
binding.accountHeaderImageView.setOnClickListener { view ->
|
||||||
avatarView.transitionName = account.avatar
|
viewImage(view, account.header)
|
||||||
val options = ActivityOptionsCompat.makeSceneTransitionAnimation(this, avatarView, account.avatar)
|
|
||||||
|
|
||||||
startActivity(intent, options.toBundle())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun viewImage(view: View, uri: String) {
|
||||||
|
view.transitionName = uri
|
||||||
|
startActivity(
|
||||||
|
ViewMediaActivity.newSingleImageIntent(view.context, uri),
|
||||||
|
ActivityOptionsCompat.makeSceneTransitionAnimation(this, view, uri).toBundle()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update toolbar views for loaded account
|
* Update toolbar views for loaded account
|
||||||
*/
|
*/
|
||||||
|
@ -619,10 +655,11 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
||||||
binding.accountSubscribeButton.setOnClickListener {
|
binding.accountSubscribeButton.setOnClickListener {
|
||||||
viewModel.changeSubscribingState()
|
viewModel.changeSubscribingState()
|
||||||
}
|
}
|
||||||
if (relation.notifying != null)
|
if (relation.notifying != null) {
|
||||||
subscribing = relation.notifying
|
subscribing = relation.notifying
|
||||||
else if (relation.subscribing != null)
|
} else if (relation.subscribing != null) {
|
||||||
subscribing = relation.subscribing
|
subscribing = relation.subscribing
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// remove the listener so it doesn't fire on non-user changes
|
// remove the listener so it doesn't fire on non-user changes
|
||||||
|
@ -631,15 +668,11 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
||||||
binding.accountNoteTextInputLayout.visible(relation.note != null)
|
binding.accountNoteTextInputLayout.visible(relation.note != null)
|
||||||
binding.accountNoteTextInputLayout.editText?.setText(relation.note)
|
binding.accountNoteTextInputLayout.editText?.setText(relation.note)
|
||||||
|
|
||||||
binding.accountNoteTextInputLayout.editText?.addTextChangedListener(noteWatcher)
|
noteWatcher = binding.accountNoteTextInputLayout.editText?.doAfterTextChanged { s ->
|
||||||
|
|
||||||
updateButtons()
|
|
||||||
}
|
|
||||||
|
|
||||||
private val noteWatcher = object : DefaultTextWatcher() {
|
|
||||||
override fun afterTextChanged(s: Editable) {
|
|
||||||
viewModel.noteChanged(s.toString())
|
viewModel.noteChanged(s.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateButtons()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateFollowButton() {
|
private fun updateFollowButton() {
|
||||||
|
@ -704,7 +737,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||||
menuInflater.inflate(R.menu.account_toolbar, menu)
|
menuInflater.inflate(R.menu.account_toolbar, menu)
|
||||||
|
|
||||||
val openAsItem = menu.findItem(R.id.action_open_as)
|
val openAsItem = menu.findItem(R.id.action_open_as)
|
||||||
|
@ -716,7 +749,6 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!viewModel.isSelf) {
|
if (!viewModel.isSelf) {
|
||||||
|
|
||||||
val block = menu.findItem(R.id.action_block)
|
val block = menu.findItem(R.id.action_block)
|
||||||
block.title = if (blocking) {
|
block.title = if (blocking) {
|
||||||
getString(R.string.action_unblock)
|
getString(R.string.action_unblock)
|
||||||
|
@ -769,7 +801,12 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
||||||
menu.removeItem(R.id.action_add_or_remove_from_list)
|
menu.removeItem(R.id.action_add_or_remove_from_list)
|
||||||
}
|
}
|
||||||
|
|
||||||
return super.onCreateOptionsMenu(menu)
|
menu.findItem(R.id.action_search)?.apply {
|
||||||
|
icon = IconicsDrawable(this@AccountActivity, GoogleMaterial.Icon.gmd_search).apply {
|
||||||
|
sizeDp = 20
|
||||||
|
colorInt = MaterialColors.getColor(binding.collapsingToolbar, android.R.attr.textColorPrimary)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showFollowRequestPendingDialog() {
|
private fun showFollowRequestPendingDialog() {
|
||||||
|
@ -857,7 +894,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
||||||
viewUrl(url, text = text)
|
viewUrl(url, text = text)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
override fun onMenuItemSelected(item: MenuItem): Boolean {
|
||||||
when (item.itemId) {
|
when (item.itemId) {
|
||||||
R.id.action_open_in_web -> {
|
R.id.action_open_in_web -> {
|
||||||
// If the account isn't loaded yet, eat the input.
|
// If the account isn't loaded yet, eat the input.
|
||||||
|
@ -869,7 +906,8 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
||||||
R.id.action_open_as -> {
|
R.id.action_open_as -> {
|
||||||
loadedAccount?.let { loadedAccount ->
|
loadedAccount?.let { loadedAccount ->
|
||||||
showAccountChooserDialog(
|
showAccountChooserDialog(
|
||||||
item.title, false,
|
item.title,
|
||||||
|
false,
|
||||||
object : AccountSelectionListener {
|
object : AccountSelectionListener {
|
||||||
override fun onAccountSelected(account: AccountEntity) {
|
override fun onAccountSelected(account: AccountEntity) {
|
||||||
openAsAccount(loadedAccount.url, account)
|
openAsAccount(loadedAccount.url, account)
|
||||||
|
@ -922,6 +960,11 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
||||||
viewModel.changeShowReblogsState()
|
viewModel.changeShowReblogsState()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
R.id.action_refresh -> {
|
||||||
|
binding.swipeToRefreshLayout.isRefreshing = true
|
||||||
|
onRefresh()
|
||||||
|
return true
|
||||||
|
}
|
||||||
R.id.action_report -> {
|
R.id.action_report -> {
|
||||||
loadedAccount?.let { loadedAccount ->
|
loadedAccount?.let { loadedAccount ->
|
||||||
startActivity(ReportActivity.getIntent(this, viewModel.accountId, loadedAccount.username))
|
startActivity(ReportActivity.getIntent(this, viewModel.accountId, loadedAccount.username))
|
||||||
|
@ -929,23 +972,25 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return super.onOptionsItemSelected(item)
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getActionButton(): FloatingActionButton? {
|
override fun getActionButton(): FloatingActionButton? {
|
||||||
return if (!blocking) {
|
return if (!blocking) {
|
||||||
binding.accountFloatingActionButton
|
binding.accountFloatingActionButton
|
||||||
} else null
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getFullUsername(account: Account): String {
|
private fun getFullUsername(account: Account): String {
|
||||||
if (account.isRemote()) {
|
return if (account.isRemote()) {
|
||||||
return "@" + account.username
|
"@" + account.username
|
||||||
} else {
|
} else {
|
||||||
val localUsername = account.localUsername
|
val localUsername = account.localUsername
|
||||||
// Note: !! here will crash if this pane is ever shown to a logged-out user. With AccountActivity this is believed to be impossible.
|
// Note: !! here will crash if this pane is ever shown to a logged-out user. With AccountActivity this is believed to be impossible.
|
||||||
val domain = accountManager.activeAccount!!.domain
|
val domain = accountManager.activeAccount!!.domain
|
||||||
return "@$localUsername@$domain"
|
"@$localUsername@$domain"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,7 +2,9 @@ package com.keylesspalace.tusky.components.account
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import at.connyduck.calladapter.networkresult.fold
|
||||||
import com.keylesspalace.tusky.appstore.BlockEvent
|
import com.keylesspalace.tusky.appstore.BlockEvent
|
||||||
import com.keylesspalace.tusky.appstore.DomainMuteEvent
|
import com.keylesspalace.tusky.appstore.DomainMuteEvent
|
||||||
import com.keylesspalace.tusky.appstore.EventHub
|
import com.keylesspalace.tusky.appstore.EventHub
|
||||||
|
@ -16,22 +18,17 @@ import com.keylesspalace.tusky.network.MastodonApi
|
||||||
import com.keylesspalace.tusky.util.Error
|
import com.keylesspalace.tusky.util.Error
|
||||||
import com.keylesspalace.tusky.util.Loading
|
import com.keylesspalace.tusky.util.Loading
|
||||||
import com.keylesspalace.tusky.util.Resource
|
import com.keylesspalace.tusky.util.Resource
|
||||||
import com.keylesspalace.tusky.util.RxAwareViewModel
|
|
||||||
import com.keylesspalace.tusky.util.Success
|
import com.keylesspalace.tusky.util.Success
|
||||||
import io.reactivex.rxjava3.core.Single
|
import kotlinx.coroutines.Job
|
||||||
import io.reactivex.rxjava3.disposables.Disposable
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import retrofit2.Call
|
|
||||||
import retrofit2.Callback
|
|
||||||
import retrofit2.Response
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class AccountViewModel @Inject constructor(
|
class AccountViewModel @Inject constructor(
|
||||||
private val mastodonApi: MastodonApi,
|
private val mastodonApi: MastodonApi,
|
||||||
private val eventHub: EventHub,
|
private val eventHub: EventHub,
|
||||||
private val accountManager: AccountManager
|
private val accountManager: AccountManager
|
||||||
) : RxAwareViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
val accountData = MutableLiveData<Resource<Account>>()
|
val accountData = MutableLiveData<Resource<Account>>()
|
||||||
val relationshipData = MutableLiveData<Resource<Relationship>>()
|
val relationshipData = MutableLiveData<Resource<Relationship>>()
|
||||||
|
@ -44,15 +41,16 @@ class AccountViewModel @Inject constructor(
|
||||||
lateinit var accountId: String
|
lateinit var accountId: String
|
||||||
var isSelf = false
|
var isSelf = false
|
||||||
|
|
||||||
private var noteDisposable: Disposable? = null
|
private var noteUpdateJob: Job? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
eventHub.events
|
viewModelScope.launch {
|
||||||
.subscribe { event ->
|
eventHub.events.collect { event ->
|
||||||
if (event is ProfileEditedEvent && event.newProfileData.id == accountData.value?.data?.id) {
|
if (event is ProfileEditedEvent && event.newProfileData.id == accountData.value?.data?.id) {
|
||||||
accountData.postValue(Success(event.newProfileData))
|
accountData.postValue(Success(event.newProfileData))
|
||||||
}
|
}
|
||||||
}.autoDispose()
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun obtainAccount(reload: Boolean = false) {
|
private fun obtainAccount(reload: Boolean = false) {
|
||||||
|
@ -60,40 +58,41 @@ class AccountViewModel @Inject constructor(
|
||||||
isDataLoading = true
|
isDataLoading = true
|
||||||
accountData.postValue(Loading())
|
accountData.postValue(Loading())
|
||||||
|
|
||||||
mastodonApi.account(accountId)
|
viewModelScope.launch {
|
||||||
.subscribe(
|
mastodonApi.account(accountId)
|
||||||
{ account ->
|
.fold(
|
||||||
accountData.postValue(Success(account))
|
{ account ->
|
||||||
isDataLoading = false
|
accountData.postValue(Success(account))
|
||||||
isRefreshing.postValue(false)
|
isDataLoading = false
|
||||||
},
|
isRefreshing.postValue(false)
|
||||||
{ t ->
|
},
|
||||||
Log.w(TAG, "failed obtaining account", t)
|
{ t ->
|
||||||
accountData.postValue(Error())
|
Log.w(TAG, "failed obtaining account", t)
|
||||||
isDataLoading = false
|
accountData.postValue(Error(cause = t))
|
||||||
isRefreshing.postValue(false)
|
isDataLoading = false
|
||||||
}
|
isRefreshing.postValue(false)
|
||||||
)
|
}
|
||||||
.autoDispose()
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun obtainRelationship(reload: Boolean = false) {
|
private fun obtainRelationship(reload: Boolean = false) {
|
||||||
if (relationshipData.value == null || reload) {
|
if (relationshipData.value == null || reload) {
|
||||||
|
|
||||||
relationshipData.postValue(Loading())
|
relationshipData.postValue(Loading())
|
||||||
|
|
||||||
mastodonApi.relationships(listOf(accountId))
|
viewModelScope.launch {
|
||||||
.subscribe(
|
mastodonApi.relationships(listOf(accountId))
|
||||||
{ relationships ->
|
.fold(
|
||||||
relationshipData.postValue(Success(relationships[0]))
|
{ relationships ->
|
||||||
},
|
relationshipData.postValue(if (relationships.isNotEmpty()) Success(relationships[0]) else Error())
|
||||||
{ t ->
|
},
|
||||||
Log.w(TAG, "failed obtaining relationships", t)
|
{ t ->
|
||||||
relationshipData.postValue(Error())
|
Log.w(TAG, "failed obtaining relationships", t)
|
||||||
}
|
relationshipData.postValue(Error(cause = t))
|
||||||
)
|
}
|
||||||
.autoDispose()
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -134,42 +133,30 @@ class AccountViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
fun blockDomain(instance: String) {
|
fun blockDomain(instance: String) {
|
||||||
mastodonApi.blockDomain(instance).enqueue(object : Callback<Any> {
|
viewModelScope.launch {
|
||||||
override fun onResponse(call: Call<Any>, response: Response<Any>) {
|
mastodonApi.blockDomain(instance).fold({
|
||||||
if (response.isSuccessful) {
|
eventHub.dispatch(DomainMuteEvent(instance))
|
||||||
eventHub.dispatch(DomainMuteEvent(instance))
|
val relation = relationshipData.value?.data
|
||||||
val relation = relationshipData.value?.data
|
if (relation != null) {
|
||||||
if (relation != null) {
|
relationshipData.postValue(Success(relation.copy(blockingDomain = true)))
|
||||||
relationshipData.postValue(Success(relation.copy(blockingDomain = true)))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Log.e(TAG, "Error muting %s".format(instance))
|
|
||||||
}
|
}
|
||||||
}
|
}, { e ->
|
||||||
|
Log.e(TAG, "Error muting $instance", e)
|
||||||
override fun onFailure(call: Call<Any>, t: Throwable) {
|
})
|
||||||
Log.e(TAG, "Error muting %s".format(instance), t)
|
}
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun unblockDomain(instance: String) {
|
fun unblockDomain(instance: String) {
|
||||||
mastodonApi.unblockDomain(instance).enqueue(object : Callback<Any> {
|
viewModelScope.launch {
|
||||||
override fun onResponse(call: Call<Any>, response: Response<Any>) {
|
mastodonApi.unblockDomain(instance).fold({
|
||||||
if (response.isSuccessful) {
|
val relation = relationshipData.value?.data
|
||||||
val relation = relationshipData.value?.data
|
if (relation != null) {
|
||||||
if (relation != null) {
|
relationshipData.postValue(Success(relation.copy(blockingDomain = false)))
|
||||||
relationshipData.postValue(Success(relation.copy(blockingDomain = false)))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Log.e(TAG, "Error unmuting %s".format(instance))
|
|
||||||
}
|
}
|
||||||
}
|
}, { e ->
|
||||||
|
Log.e(TAG, "Error unmuting $instance", e)
|
||||||
override fun onFailure(call: Call<Any>, t: Throwable) {
|
})
|
||||||
Log.e(TAG, "Error unmuting %s".format(instance), t)
|
}
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun changeShowReblogsState() {
|
fun changeShowReblogsState() {
|
||||||
|
@ -209,84 +196,88 @@ class AccountViewModel @Inject constructor(
|
||||||
RelationShipAction.MUTE -> relation.copy(muting = true)
|
RelationShipAction.MUTE -> relation.copy(muting = true)
|
||||||
RelationShipAction.UNMUTE -> relation.copy(muting = false)
|
RelationShipAction.UNMUTE -> relation.copy(muting = false)
|
||||||
RelationShipAction.SUBSCRIBE -> {
|
RelationShipAction.SUBSCRIBE -> {
|
||||||
if (isMastodon)
|
if (isMastodon) {
|
||||||
relation.copy(notifying = true)
|
relation.copy(notifying = true)
|
||||||
else relation.copy(subscribing = true)
|
} else {
|
||||||
|
relation.copy(subscribing = true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
RelationShipAction.UNSUBSCRIBE -> {
|
RelationShipAction.UNSUBSCRIBE -> {
|
||||||
if (isMastodon)
|
if (isMastodon) {
|
||||||
relation.copy(notifying = false)
|
relation.copy(notifying = false)
|
||||||
else relation.copy(subscribing = false)
|
} else {
|
||||||
|
relation.copy(subscribing = false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
relationshipData.postValue(Loading(newRelation))
|
relationshipData.postValue(Loading(newRelation))
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
val relationshipCall = when (relationshipAction) {
|
||||||
val relationship = when (relationshipAction) {
|
RelationShipAction.FOLLOW -> mastodonApi.followAccount(
|
||||||
RelationShipAction.FOLLOW -> mastodonApi.followAccount(
|
accountId,
|
||||||
accountId,
|
showReblogs = parameter ?: true
|
||||||
showReblogs = parameter ?: true
|
)
|
||||||
)
|
RelationShipAction.UNFOLLOW -> mastodonApi.unfollowAccount(accountId)
|
||||||
RelationShipAction.UNFOLLOW -> mastodonApi.unfollowAccount(accountId)
|
RelationShipAction.BLOCK -> mastodonApi.blockAccount(accountId)
|
||||||
RelationShipAction.BLOCK -> mastodonApi.blockAccount(accountId)
|
RelationShipAction.UNBLOCK -> mastodonApi.unblockAccount(accountId)
|
||||||
RelationShipAction.UNBLOCK -> mastodonApi.unblockAccount(accountId)
|
RelationShipAction.MUTE -> mastodonApi.muteAccount(
|
||||||
RelationShipAction.MUTE -> mastodonApi.muteAccount(
|
accountId,
|
||||||
accountId,
|
parameter ?: true,
|
||||||
parameter ?: true,
|
duration
|
||||||
duration
|
)
|
||||||
)
|
RelationShipAction.UNMUTE -> mastodonApi.unmuteAccount(accountId)
|
||||||
RelationShipAction.UNMUTE -> mastodonApi.unmuteAccount(accountId)
|
RelationShipAction.SUBSCRIBE -> {
|
||||||
RelationShipAction.SUBSCRIBE -> {
|
if (isMastodon) {
|
||||||
if (isMastodon)
|
mastodonApi.followAccount(accountId, notify = true)
|
||||||
mastodonApi.followAccount(accountId, notify = true)
|
} else {
|
||||||
else mastodonApi.subscribeAccount(accountId)
|
mastodonApi.subscribeAccount(accountId)
|
||||||
}
|
|
||||||
RelationShipAction.UNSUBSCRIBE -> {
|
|
||||||
if (isMastodon)
|
|
||||||
mastodonApi.followAccount(accountId, notify = false)
|
|
||||||
else mastodonApi.unsubscribeAccount(accountId)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
RelationShipAction.UNSUBSCRIBE -> {
|
||||||
relationshipData.postValue(Success(relationship))
|
if (isMastodon) {
|
||||||
|
mastodonApi.followAccount(accountId, notify = false)
|
||||||
when (relationshipAction) {
|
} else {
|
||||||
RelationShipAction.UNFOLLOW -> eventHub.dispatch(UnfollowEvent(accountId))
|
mastodonApi.unsubscribeAccount(accountId)
|
||||||
RelationShipAction.BLOCK -> eventHub.dispatch(BlockEvent(accountId))
|
|
||||||
RelationShipAction.MUTE -> eventHub.dispatch(MuteEvent(accountId))
|
|
||||||
else -> {
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (_: Throwable) {
|
|
||||||
relationshipData.postValue(Error(relation))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
relationshipCall.fold(
|
||||||
|
{ relationship ->
|
||||||
|
relationshipData.postValue(Success(relationship))
|
||||||
|
|
||||||
|
when (relationshipAction) {
|
||||||
|
RelationShipAction.UNFOLLOW -> eventHub.dispatch(UnfollowEvent(accountId))
|
||||||
|
RelationShipAction.BLOCK -> eventHub.dispatch(BlockEvent(accountId))
|
||||||
|
RelationShipAction.MUTE -> eventHub.dispatch(MuteEvent(accountId))
|
||||||
|
else -> { }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ t ->
|
||||||
|
Log.w(TAG, "failed loading relationship", t)
|
||||||
|
relationshipData.postValue(Error(relation, cause = t))
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun noteChanged(newNote: String) {
|
fun noteChanged(newNote: String) {
|
||||||
noteSaved.postValue(false)
|
noteSaved.postValue(false)
|
||||||
noteDisposable?.dispose()
|
noteUpdateJob?.cancel()
|
||||||
noteDisposable = Single.timer(1500, TimeUnit.MILLISECONDS)
|
noteUpdateJob = viewModelScope.launch {
|
||||||
.flatMap {
|
delay(1500)
|
||||||
mastodonApi.updateAccountNote(accountId, newNote)
|
mastodonApi.updateAccountNote(accountId, newNote)
|
||||||
}
|
.fold(
|
||||||
.doOnSuccess {
|
{
|
||||||
noteSaved.postValue(true)
|
noteSaved.postValue(true)
|
||||||
}
|
delay(4000)
|
||||||
.delay(4, TimeUnit.SECONDS)
|
noteSaved.postValue(false)
|
||||||
.subscribe(
|
},
|
||||||
{
|
{ t ->
|
||||||
noteSaved.postValue(false)
|
Log.w(TAG, "Error updating note", t)
|
||||||
},
|
}
|
||||||
{
|
)
|
||||||
Log.e(TAG, "Error updating note", it)
|
}
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCleared() {
|
|
||||||
super.onCleared()
|
|
||||||
noteDisposable?.dispose()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh() {
|
fun refresh() {
|
||||||
|
@ -294,12 +285,14 @@ class AccountViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun reload(isReload: Boolean = false) {
|
private fun reload(isReload: Boolean = false) {
|
||||||
if (isDataLoading)
|
if (isDataLoading) {
|
||||||
return
|
return
|
||||||
|
}
|
||||||
accountId.let {
|
accountId.let {
|
||||||
obtainAccount(isReload)
|
obtainAccount(isReload)
|
||||||
if (!isSelf)
|
if (!isSelf) {
|
||||||
obtainRelationship(isReload)
|
obtainRelationship(isReload)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -65,7 +65,7 @@ class ListsForAccountFragment : DialogFragment(), Injectable {
|
||||||
dialog?.apply {
|
dialog?.apply {
|
||||||
window?.setLayout(
|
window?.setLayout(
|
||||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
LinearLayout.LayoutParams.MATCH_PARENT
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -172,7 +172,7 @@ class ListsForAccountFragment : DialogFragment(), Injectable {
|
||||||
ListAdapter<AccountListState, BindingHolder<ItemAddOrRemoveFromListBinding>>(Differ) {
|
ListAdapter<AccountListState, BindingHolder<ItemAddOrRemoveFromListBinding>>(Differ) {
|
||||||
override fun onCreateViewHolder(
|
override fun onCreateViewHolder(
|
||||||
parent: ViewGroup,
|
parent: ViewGroup,
|
||||||
viewType: Int,
|
viewType: Int
|
||||||
): BindingHolder<ItemAddOrRemoveFromListBinding> {
|
): BindingHolder<ItemAddOrRemoveFromListBinding> {
|
||||||
val binding =
|
val binding =
|
||||||
ItemAddOrRemoveFromListBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
ItemAddOrRemoveFromListBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||||
|
|
|
@ -35,23 +35,23 @@ import javax.inject.Inject
|
||||||
|
|
||||||
data class AccountListState(
|
data class AccountListState(
|
||||||
val list: MastoList,
|
val list: MastoList,
|
||||||
val includesAccount: Boolean,
|
val includesAccount: Boolean
|
||||||
)
|
)
|
||||||
|
|
||||||
data class ActionError(
|
data class ActionError(
|
||||||
val error: Throwable,
|
val error: Throwable,
|
||||||
val type: Type,
|
val type: Type,
|
||||||
val listId: String,
|
val listId: String
|
||||||
) : Throwable(error) {
|
) : Throwable(error) {
|
||||||
enum class Type {
|
enum class Type {
|
||||||
ADD,
|
ADD,
|
||||||
REMOVE,
|
REMOVE
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class ListsForAccountViewModel @Inject constructor(
|
class ListsForAccountViewModel @Inject constructor(
|
||||||
private val mastodonApi: MastodonApi,
|
private val mastodonApi: MastodonApi
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private lateinit var accountId: String
|
private lateinit var accountId: String
|
||||||
|
@ -75,14 +75,14 @@ class ListsForAccountViewModel @Inject constructor(
|
||||||
runCatching {
|
runCatching {
|
||||||
val (all, includes) = listOf(
|
val (all, includes) = listOf(
|
||||||
async { mastodonApi.getLists() },
|
async { mastodonApi.getLists() },
|
||||||
async { mastodonApi.getListsIncludesAccount(accountId) },
|
async { mastodonApi.getListsIncludesAccount(accountId) }
|
||||||
).awaitAll()
|
).awaitAll()
|
||||||
|
|
||||||
_states.emit(
|
_states.emit(
|
||||||
all.getOrThrow().map { list ->
|
all.getOrThrow().map { list ->
|
||||||
AccountListState(
|
AccountListState(
|
||||||
list = list,
|
list = list,
|
||||||
includesAccount = includes.getOrThrow().any { it.id == list.id },
|
includesAccount = includes.getOrThrow().any { it.id == list.id }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
@ -16,15 +16,21 @@
|
||||||
package com.keylesspalace.tusky.components.account.media
|
package com.keylesspalace.tusky.components.account.media
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.view.Menu
|
||||||
|
import android.view.MenuInflater
|
||||||
|
import android.view.MenuItem
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import androidx.core.app.ActivityOptionsCompat
|
import androidx.core.app.ActivityOptionsCompat
|
||||||
|
import androidx.core.view.MenuProvider
|
||||||
import androidx.core.view.ViewCompat
|
import androidx.core.view.ViewCompat
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.viewModels
|
import androidx.fragment.app.viewModels
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.paging.LoadState
|
import androidx.paging.LoadState
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import androidx.recyclerview.widget.GridLayoutManager
|
import androidx.recyclerview.widget.GridLayoutManager
|
||||||
|
import com.google.android.material.color.MaterialColors
|
||||||
import com.keylesspalace.tusky.R
|
import com.keylesspalace.tusky.R
|
||||||
import com.keylesspalace.tusky.ViewMediaActivity
|
import com.keylesspalace.tusky.ViewMediaActivity
|
||||||
import com.keylesspalace.tusky.databinding.FragmentTimelineBinding
|
import com.keylesspalace.tusky.databinding.FragmentTimelineBinding
|
||||||
|
@ -39,20 +45,22 @@ import com.keylesspalace.tusky.util.openLink
|
||||||
import com.keylesspalace.tusky.util.show
|
import com.keylesspalace.tusky.util.show
|
||||||
import com.keylesspalace.tusky.util.viewBinding
|
import com.keylesspalace.tusky.util.viewBinding
|
||||||
import com.keylesspalace.tusky.viewdata.AttachmentViewData
|
import com.keylesspalace.tusky.viewdata.AttachmentViewData
|
||||||
|
import com.mikepenz.iconics.IconicsDrawable
|
||||||
|
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
|
||||||
|
import com.mikepenz.iconics.utils.colorInt
|
||||||
|
import com.mikepenz.iconics.utils.sizeDp
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Created by charlag on 26/10/2017.
|
|
||||||
*
|
|
||||||
* Fragment with multiple columns of media previews for the specified account.
|
* Fragment with multiple columns of media previews for the specified account.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
class AccountMediaFragment :
|
class AccountMediaFragment :
|
||||||
Fragment(R.layout.fragment_timeline),
|
Fragment(R.layout.fragment_timeline),
|
||||||
RefreshableFragment,
|
RefreshableFragment,
|
||||||
|
MenuProvider,
|
||||||
Injectable {
|
Injectable {
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
|
@ -73,6 +81,7 @@ class AccountMediaFragment :
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
|
||||||
|
|
||||||
val alwaysShowSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia
|
val alwaysShowSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia
|
||||||
|
|
||||||
|
@ -95,6 +104,8 @@ class AccountMediaFragment :
|
||||||
binding.recyclerView.adapter = adapter
|
binding.recyclerView.adapter = adapter
|
||||||
|
|
||||||
binding.swipeRefreshLayout.isEnabled = false
|
binding.swipeRefreshLayout.isEnabled = false
|
||||||
|
binding.swipeRefreshLayout.setOnRefreshListener { refreshContent() }
|
||||||
|
binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
|
||||||
|
|
||||||
binding.statusView.visibility = View.GONE
|
binding.statusView.visibility = View.GONE
|
||||||
|
|
||||||
|
@ -108,6 +119,10 @@ class AccountMediaFragment :
|
||||||
binding.statusView.hide()
|
binding.statusView.hide()
|
||||||
binding.progressBar.hide()
|
binding.progressBar.hide()
|
||||||
|
|
||||||
|
if (loadState.refresh != LoadState.Loading && loadState.source.refresh != LoadState.Loading) {
|
||||||
|
binding.swipeRefreshLayout.isRefreshing = false
|
||||||
|
}
|
||||||
|
|
||||||
if (adapter.itemCount == 0) {
|
if (adapter.itemCount == 0) {
|
||||||
when (loadState.refresh) {
|
when (loadState.refresh) {
|
||||||
is LoadState.NotLoading -> {
|
is LoadState.NotLoading -> {
|
||||||
|
@ -133,6 +148,27 @@ class AccountMediaFragment :
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||||
|
menuInflater.inflate(R.menu.fragment_account_media, menu)
|
||||||
|
menu.findItem(R.id.action_refresh)?.apply {
|
||||||
|
icon = IconicsDrawable(requireContext(), GoogleMaterial.Icon.gmd_refresh).apply {
|
||||||
|
sizeDp = 20
|
||||||
|
colorInt = MaterialColors.getColor(binding.root, android.R.attr.textColorPrimary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
|
||||||
|
return when (menuItem.itemId) {
|
||||||
|
R.id.action_refresh -> {
|
||||||
|
binding.swipeRefreshLayout.isRefreshing = true
|
||||||
|
refreshContent()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun onAttachmentClick(selected: AttachmentViewData, view: View) {
|
private fun onAttachmentClick(selected: AttachmentViewData, view: View) {
|
||||||
if (!selected.isRevealed) {
|
if (!selected.isRevealed) {
|
||||||
viewModel.revealAttachment(selected)
|
viewModel.revealAttachment(selected)
|
||||||
|
|
|
@ -40,7 +40,7 @@ class AccountMediaGridAdapter(
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val baseItemBackgroundColor = MaterialColors.getColor(context, R.attr.colorSurface, Color.BLACK)
|
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 videoIndicator = AppCompatResources.getDrawable(context, R.drawable.ic_play_indicator)
|
||||||
private val mediaHiddenDrawable = AppCompatResources.getDrawable(context, R.drawable.ic_hide_media_24dp)
|
private val mediaHiddenDrawable = AppCompatResources.getDrawable(context, R.drawable.ic_hide_media_24dp)
|
||||||
|
|
||||||
|
|
|
@ -26,7 +26,6 @@ class AccountMediaPagingSource(
|
||||||
override fun getRefreshKey(state: PagingState<String, AttachmentViewData>): String? = null
|
override fun getRefreshKey(state: PagingState<String, AttachmentViewData>): String? = null
|
||||||
|
|
||||||
override suspend fun load(params: LoadParams<String>): LoadResult<String, AttachmentViewData> {
|
override suspend fun load(params: LoadParams<String>): LoadResult<String, AttachmentViewData> {
|
||||||
|
|
||||||
return if (params is LoadParams.Refresh) {
|
return if (params is LoadParams.Refresh) {
|
||||||
val list = viewModel.attachmentData.toList()
|
val list = viewModel.attachmentData.toList()
|
||||||
LoadResult.Page(list, null, list.lastOrNull()?.statusId)
|
LoadResult.Page(list, null, list.lastOrNull()?.statusId)
|
||||||
|
|
|
@ -34,7 +34,6 @@ class AccountMediaRemoteMediator(
|
||||||
loadType: LoadType,
|
loadType: LoadType,
|
||||||
state: PagingState<String, AttachmentViewData>
|
state: PagingState<String, AttachmentViewData>
|
||||||
): MediatorResult {
|
): MediatorResult {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val statusResponse = when (loadType) {
|
val statusResponse = when (loadType) {
|
||||||
LoadType.REFRESH -> {
|
LoadType.REFRESH -> {
|
||||||
|
|
|
@ -25,7 +25,7 @@ import com.keylesspalace.tusky.network.MastodonApi
|
||||||
import com.keylesspalace.tusky.viewdata.AttachmentViewData
|
import com.keylesspalace.tusky.viewdata.AttachmentViewData
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class AccountMediaViewModel @Inject constructor (
|
class AccountMediaViewModel @Inject constructor(
|
||||||
api: MastodonApi
|
api: MastodonApi
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
|
|
|
@ -13,18 +13,20 @@
|
||||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||||
* see <http://www.gnu.org/licenses>. */
|
* see <http://www.gnu.org/licenses>. */
|
||||||
|
|
||||||
package com.keylesspalace.tusky
|
package com.keylesspalace.tusky.components.accountlist
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import androidx.fragment.app.commit
|
||||||
|
import com.keylesspalace.tusky.BottomSheetActivity
|
||||||
|
import com.keylesspalace.tusky.R
|
||||||
import com.keylesspalace.tusky.databinding.ActivityAccountListBinding
|
import com.keylesspalace.tusky.databinding.ActivityAccountListBinding
|
||||||
import com.keylesspalace.tusky.fragment.AccountListFragment
|
|
||||||
import dagger.android.DispatchingAndroidInjector
|
import dagger.android.DispatchingAndroidInjector
|
||||||
import dagger.android.HasAndroidInjector
|
import dagger.android.HasAndroidInjector
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class AccountListActivity : BaseActivity(), HasAndroidInjector {
|
class AccountListActivity : BottomSheetActivity(), HasAndroidInjector {
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector<Any>
|
lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector<Any>
|
||||||
|
@ -63,10 +65,9 @@ class AccountListActivity : BaseActivity(), HasAndroidInjector {
|
||||||
setDisplayShowHomeEnabled(true)
|
setDisplayShowHomeEnabled(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
supportFragmentManager
|
supportFragmentManager.commit {
|
||||||
.beginTransaction()
|
replace(R.id.fragment_container, AccountListFragment.newInstance(type, id, accountLocked))
|
||||||
.replace(R.id.fragment_container, AccountListFragment.newInstance(type, id, accountLocked))
|
}
|
||||||
.commit()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun androidInjector() = dispatchingAndroidInjector
|
override fun androidInjector() = dispatchingAndroidInjector
|
||||||
|
@ -76,8 +77,6 @@ class AccountListActivity : BaseActivity(), HasAndroidInjector {
|
||||||
private const val EXTRA_ID = "id"
|
private const val EXTRA_ID = "id"
|
||||||
private const val EXTRA_ACCOUNT_LOCKED = "acc_locked"
|
private const val EXTRA_ACCOUNT_LOCKED = "acc_locked"
|
||||||
|
|
||||||
@JvmStatic
|
|
||||||
@JvmOverloads
|
|
||||||
fun newIntent(context: Context, type: Type, id: String? = null, accountLocked: Boolean = false): Intent {
|
fun newIntent(context: Context, type: Type, id: String? = null, accountLocked: Boolean = false): Intent {
|
||||||
return Intent(context, AccountListActivity::class.java).apply {
|
return Intent(context, AccountListActivity::class.java).apply {
|
||||||
putExtra(EXTRA_TYPE, type)
|
putExtra(EXTRA_TYPE, type)
|
|
@ -13,7 +13,7 @@
|
||||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||||
* see <http://www.gnu.org/licenses>. */
|
* see <http://www.gnu.org/licenses>. */
|
||||||
|
|
||||||
package com.keylesspalace.tusky.fragment
|
package com.keylesspalace.tusky.components.accountlist
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
@ -27,25 +27,30 @@ import androidx.recyclerview.widget.DividerItemDecoration
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import androidx.recyclerview.widget.SimpleItemAnimator
|
import androidx.recyclerview.widget.SimpleItemAnimator
|
||||||
|
import at.connyduck.calladapter.networkresult.fold
|
||||||
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from
|
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from
|
||||||
import autodispose2.autoDispose
|
import autodispose2.autoDispose
|
||||||
import com.google.android.material.snackbar.Snackbar
|
import com.google.android.material.snackbar.Snackbar
|
||||||
import com.keylesspalace.tusky.AccountListActivity.Type
|
|
||||||
import com.keylesspalace.tusky.BaseActivity
|
import com.keylesspalace.tusky.BaseActivity
|
||||||
|
import com.keylesspalace.tusky.BottomSheetActivity
|
||||||
|
import com.keylesspalace.tusky.PostLookupFallbackBehavior
|
||||||
import com.keylesspalace.tusky.R
|
import com.keylesspalace.tusky.R
|
||||||
import com.keylesspalace.tusky.adapter.AccountAdapter
|
import com.keylesspalace.tusky.StatusListActivity
|
||||||
import com.keylesspalace.tusky.adapter.BlocksAdapter
|
|
||||||
import com.keylesspalace.tusky.adapter.FollowAdapter
|
|
||||||
import com.keylesspalace.tusky.adapter.FollowRequestsAdapter
|
|
||||||
import com.keylesspalace.tusky.adapter.FollowRequestsHeaderAdapter
|
|
||||||
import com.keylesspalace.tusky.adapter.MutesAdapter
|
|
||||||
import com.keylesspalace.tusky.components.account.AccountActivity
|
import com.keylesspalace.tusky.components.account.AccountActivity
|
||||||
|
import com.keylesspalace.tusky.components.accountlist.AccountListActivity.Type
|
||||||
|
import com.keylesspalace.tusky.components.accountlist.adapter.AccountAdapter
|
||||||
|
import com.keylesspalace.tusky.components.accountlist.adapter.BlocksAdapter
|
||||||
|
import com.keylesspalace.tusky.components.accountlist.adapter.FollowAdapter
|
||||||
|
import com.keylesspalace.tusky.components.accountlist.adapter.FollowRequestsAdapter
|
||||||
|
import com.keylesspalace.tusky.components.accountlist.adapter.FollowRequestsHeaderAdapter
|
||||||
|
import com.keylesspalace.tusky.components.accountlist.adapter.MutesAdapter
|
||||||
import com.keylesspalace.tusky.databinding.FragmentAccountListBinding
|
import com.keylesspalace.tusky.databinding.FragmentAccountListBinding
|
||||||
import com.keylesspalace.tusky.db.AccountManager
|
import com.keylesspalace.tusky.db.AccountManager
|
||||||
import com.keylesspalace.tusky.di.Injectable
|
import com.keylesspalace.tusky.di.Injectable
|
||||||
import com.keylesspalace.tusky.entity.Relationship
|
import com.keylesspalace.tusky.entity.Relationship
|
||||||
import com.keylesspalace.tusky.entity.TimelineAccount
|
import com.keylesspalace.tusky.entity.TimelineAccount
|
||||||
import com.keylesspalace.tusky.interfaces.AccountActionListener
|
import com.keylesspalace.tusky.interfaces.AccountActionListener
|
||||||
|
import com.keylesspalace.tusky.interfaces.LinkListener
|
||||||
import com.keylesspalace.tusky.network.MastodonApi
|
import com.keylesspalace.tusky.network.MastodonApi
|
||||||
import com.keylesspalace.tusky.settings.PrefKeys
|
import com.keylesspalace.tusky.settings.PrefKeys
|
||||||
import com.keylesspalace.tusky.util.HttpHeaderLink
|
import com.keylesspalace.tusky.util.HttpHeaderLink
|
||||||
|
@ -59,10 +64,15 @@ import retrofit2.Response
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountActionListener, Injectable {
|
class AccountListFragment :
|
||||||
|
Fragment(R.layout.fragment_account_list),
|
||||||
|
AccountActionListener,
|
||||||
|
LinkListener,
|
||||||
|
Injectable {
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var api: MastodonApi
|
lateinit var api: MastodonApi
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var accountManager: AccountManager
|
lateinit var accountManager: AccountManager
|
||||||
|
|
||||||
|
@ -83,15 +93,15 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
|
||||||
|
|
||||||
binding.recyclerView.setHasFixedSize(true)
|
binding.recyclerView.setHasFixedSize(true)
|
||||||
val layoutManager = LinearLayoutManager(view.context)
|
val layoutManager = LinearLayoutManager(view.context)
|
||||||
binding.recyclerView.layoutManager = layoutManager
|
binding.recyclerView.layoutManager = layoutManager
|
||||||
(binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
|
(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.tusky_blue)
|
||||||
|
|
||||||
val pm = PreferenceManager.getDefaultSharedPreferences(view.context)
|
val pm = PreferenceManager.getDefaultSharedPreferences(view.context)
|
||||||
val animateAvatar = pm.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false)
|
val animateAvatar = pm.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false)
|
||||||
val animateEmojis = pm.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
|
val animateEmojis = pm.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
|
||||||
|
@ -101,8 +111,11 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct
|
||||||
Type.BLOCKS -> BlocksAdapter(this, animateAvatar, animateEmojis, showBotOverlay)
|
Type.BLOCKS -> BlocksAdapter(this, animateAvatar, animateEmojis, showBotOverlay)
|
||||||
Type.MUTES -> MutesAdapter(this, animateAvatar, animateEmojis, showBotOverlay)
|
Type.MUTES -> MutesAdapter(this, animateAvatar, animateEmojis, showBotOverlay)
|
||||||
Type.FOLLOW_REQUESTS -> {
|
Type.FOLLOW_REQUESTS -> {
|
||||||
val headerAdapter = FollowRequestsHeaderAdapter(accountManager.activeAccount!!.domain, arguments?.getBoolean(ARG_ACCOUNT_LOCKED) == true)
|
val headerAdapter = FollowRequestsHeaderAdapter(
|
||||||
val followRequestsAdapter = FollowRequestsAdapter(this, animateAvatar, animateEmojis, showBotOverlay)
|
instanceName = accountManager.activeAccount!!.domain,
|
||||||
|
accountLocked = arguments?.getBoolean(ARG_ACCOUNT_LOCKED) == true
|
||||||
|
)
|
||||||
|
val followRequestsAdapter = FollowRequestsAdapter(this, this, animateAvatar, animateEmojis, showBotOverlay)
|
||||||
binding.recyclerView.adapter = ConcatAdapter(headerAdapter, followRequestsAdapter)
|
binding.recyclerView.adapter = ConcatAdapter(headerAdapter, followRequestsAdapter)
|
||||||
followRequestsAdapter
|
followRequestsAdapter
|
||||||
}
|
}
|
||||||
|
@ -126,6 +139,11 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct
|
||||||
fetchAccounts()
|
fetchAccounts()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onViewTag(tag: String) {
|
||||||
|
(activity as BaseActivity?)
|
||||||
|
?.startActivityWithSlideInAnimation(StatusListActivity.newHashtagIntent(requireContext(), tag))
|
||||||
|
}
|
||||||
|
|
||||||
override fun onViewAccount(id: String) {
|
override fun onViewAccount(id: String) {
|
||||||
(activity as BaseActivity?)?.let {
|
(activity as BaseActivity?)?.let {
|
||||||
val intent = AccountActivity.getIntent(it, id)
|
val intent = AccountActivity.getIntent(it, id)
|
||||||
|
@ -133,6 +151,10 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onViewUrl(url: String, text: String) {
|
||||||
|
(activity as BottomSheetActivity?)?.viewUrl(url, PostLookupFallbackBehavior.OPEN_IN_BROWSER, text)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onMute(mute: Boolean, id: String, position: Int, notifications: Boolean) {
|
override fun onMute(mute: Boolean, id: String, position: Int, notifications: Boolean) {
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
try {
|
try {
|
||||||
|
@ -225,7 +247,6 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct
|
||||||
accountId: String,
|
accountId: String,
|
||||||
position: Int
|
position: Int
|
||||||
) {
|
) {
|
||||||
|
|
||||||
if (accept) {
|
if (accept) {
|
||||||
api.authorizeFollowRequest(accountId)
|
api.authorizeFollowRequest(accountId)
|
||||||
} else {
|
} else {
|
||||||
|
@ -285,6 +306,7 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fetching = true
|
fetching = true
|
||||||
|
binding.swipeRefreshLayout.isRefreshing = true
|
||||||
|
|
||||||
if (fromId != null) {
|
if (fromId != null) {
|
||||||
binding.recyclerView.post { adapter.setBottomLoading(true) }
|
binding.recyclerView.post { adapter.setBottomLoading(true) }
|
||||||
|
@ -293,6 +315,7 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
try {
|
try {
|
||||||
val response = getFetchCallByListType(fromId)
|
val response = getFetchCallByListType(fromId)
|
||||||
|
|
||||||
if (!response.isSuccessful) {
|
if (!response.isSuccessful) {
|
||||||
onFetchAccountsFailure(Exception(response.message()))
|
onFetchAccountsFailure(Exception(response.message()))
|
||||||
return@launch
|
return@launch
|
||||||
|
@ -315,6 +338,7 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct
|
||||||
|
|
||||||
private fun onFetchAccountsSuccess(accounts: List<TimelineAccount>, linkHeader: String?) {
|
private fun onFetchAccountsSuccess(accounts: List<TimelineAccount>, linkHeader: String?) {
|
||||||
adapter.setBottomLoading(false)
|
adapter.setBottomLoading(false)
|
||||||
|
binding.swipeRefreshLayout.isRefreshing = false
|
||||||
|
|
||||||
val links = HttpHeaderLink.parse(linkHeader)
|
val links = HttpHeaderLink.parse(linkHeader)
|
||||||
val next = HttpHeaderLink.findByRelationType(links, "next")
|
val next = HttpHeaderLink.findByRelationType(links, "next")
|
||||||
|
@ -347,12 +371,12 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun fetchRelationships(ids: List<String>) {
|
private fun fetchRelationships(ids: List<String>) {
|
||||||
api.relationships(ids)
|
lifecycleScope.launch {
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
api.relationships(ids)
|
||||||
.autoDispose(from(this))
|
.fold(::onFetchRelationshipsSuccess) { throwable ->
|
||||||
.subscribe(::onFetchRelationshipsSuccess) {
|
Log.e(TAG, "Fetch failure for relationships of accounts: $ids", throwable)
|
||||||
onFetchRelationshipsFailure(ids)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onFetchRelationshipsSuccess(relationships: List<Relationship>) {
|
private fun onFetchRelationshipsSuccess(relationships: List<Relationship>) {
|
||||||
|
@ -362,12 +386,9 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct
|
||||||
mutesAdapter.updateMutingNotificationsMap(mutingNotificationsMap)
|
mutesAdapter.updateMutingNotificationsMap(mutingNotificationsMap)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onFetchRelationshipsFailure(ids: List<String>) {
|
|
||||||
Log.e(TAG, "Fetch failure for relationships of accounts: $ids")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun onFetchAccountsFailure(throwable: Throwable) {
|
private fun onFetchAccountsFailure(throwable: Throwable) {
|
||||||
fetching = false
|
fetching = false
|
||||||
|
binding.swipeRefreshLayout.isRefreshing = false
|
||||||
Log.e(TAG, "Fetch failure", throwable)
|
Log.e(TAG, "Fetch failure", throwable)
|
||||||
|
|
||||||
if (adapter.itemCount == 0) {
|
if (adapter.itemCount == 0) {
|
|
@ -12,24 +12,26 @@
|
||||||
*
|
*
|
||||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||||
* see <http://www.gnu.org/licenses>. */
|
* see <http://www.gnu.org/licenses>. */
|
||||||
package com.keylesspalace.tusky.adapter
|
package com.keylesspalace.tusky.components.accountlist.adapter
|
||||||
|
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.keylesspalace.tusky.R
|
import com.keylesspalace.tusky.databinding.ItemFooterBinding
|
||||||
import com.keylesspalace.tusky.entity.TimelineAccount
|
import com.keylesspalace.tusky.entity.TimelineAccount
|
||||||
import com.keylesspalace.tusky.interfaces.AccountActionListener
|
import com.keylesspalace.tusky.interfaces.AccountActionListener
|
||||||
|
import com.keylesspalace.tusky.util.BindingHolder
|
||||||
import com.keylesspalace.tusky.util.removeDuplicates
|
import com.keylesspalace.tusky.util.removeDuplicates
|
||||||
|
|
||||||
/** Generic adapter with bottom loading indicator. */
|
/** Generic adapter with bottom loading indicator. */
|
||||||
abstract class AccountAdapter<AVH : RecyclerView.ViewHolder> internal constructor(
|
abstract class AccountAdapter<AVH : RecyclerView.ViewHolder> internal constructor(
|
||||||
var accountActionListener: AccountActionListener,
|
protected val accountActionListener: AccountActionListener,
|
||||||
protected val animateAvatar: Boolean,
|
protected val animateAvatar: Boolean,
|
||||||
protected val animateEmojis: Boolean,
|
protected val animateEmojis: Boolean,
|
||||||
protected val showBotOverlay: Boolean
|
protected val showBotOverlay: Boolean
|
||||||
) : RecyclerView.Adapter<RecyclerView.ViewHolder?>() {
|
) : RecyclerView.Adapter<RecyclerView.ViewHolder?>() {
|
||||||
var accountList = mutableListOf<TimelineAccount>()
|
|
||||||
|
protected var accountList: MutableList<TimelineAccount> = mutableListOf()
|
||||||
private var bottomLoading: Boolean = false
|
private var bottomLoading: Boolean = false
|
||||||
|
|
||||||
override fun getItemCount(): Int {
|
override fun getItemCount(): Int {
|
||||||
|
@ -59,11 +61,10 @@ abstract class AccountAdapter<AVH : RecyclerView.ViewHolder> internal constructo
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createFooterViewHolder(
|
private fun createFooterViewHolder(
|
||||||
parent: ViewGroup,
|
parent: ViewGroup
|
||||||
): RecyclerView.ViewHolder {
|
): RecyclerView.ViewHolder {
|
||||||
val view = LayoutInflater.from(parent.context)
|
val binding = ItemFooterBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||||
.inflate(R.layout.item_footer, parent, false)
|
return BindingHolder(binding)
|
||||||
return LoadingFooterViewHolder(view)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getItemViewType(position: Int): Int {
|
override fun getItemViewType(position: Int): Int {
|
|
@ -0,0 +1,68 @@
|
||||||
|
/* Copyright 2017 Andrew Dawson
|
||||||
|
*
|
||||||
|
* This file is a part of Tusky.
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||||
|
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||||
|
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||||
|
* Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||||
|
* see <http://www.gnu.org/licenses>. */
|
||||||
|
|
||||||
|
package com.keylesspalace.tusky.components.accountlist.adapter
|
||||||
|
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import com.keylesspalace.tusky.R
|
||||||
|
import com.keylesspalace.tusky.databinding.ItemBlockedUserBinding
|
||||||
|
import com.keylesspalace.tusky.interfaces.AccountActionListener
|
||||||
|
import com.keylesspalace.tusky.util.BindingHolder
|
||||||
|
import com.keylesspalace.tusky.util.emojify
|
||||||
|
import com.keylesspalace.tusky.util.loadAvatar
|
||||||
|
import com.keylesspalace.tusky.util.visible
|
||||||
|
|
||||||
|
/** Displays a list of blocked accounts. */
|
||||||
|
class BlocksAdapter(
|
||||||
|
accountActionListener: AccountActionListener,
|
||||||
|
animateAvatar: Boolean,
|
||||||
|
animateEmojis: Boolean,
|
||||||
|
showBotOverlay: Boolean
|
||||||
|
) : AccountAdapter<BindingHolder<ItemBlockedUserBinding>>(
|
||||||
|
accountActionListener = accountActionListener,
|
||||||
|
animateAvatar = animateAvatar,
|
||||||
|
animateEmojis = animateEmojis,
|
||||||
|
showBotOverlay = showBotOverlay
|
||||||
|
) {
|
||||||
|
|
||||||
|
override fun createAccountViewHolder(parent: ViewGroup): BindingHolder<ItemBlockedUserBinding> {
|
||||||
|
val binding = ItemBlockedUserBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||||
|
return BindingHolder(binding)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
binding.blockedUserDisplayName.text = emojifiedName
|
||||||
|
val formattedUsername = context.getString(R.string.post_username_format, account.username)
|
||||||
|
binding.blockedUserUsername.text = formattedUsername
|
||||||
|
|
||||||
|
val avatarRadius = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp)
|
||||||
|
loadAvatar(account.avatar, binding.blockedUserAvatar, avatarRadius, animateAvatar)
|
||||||
|
|
||||||
|
binding.blockedUserBotBadge.visible(showBotOverlay && account.bot)
|
||||||
|
|
||||||
|
binding.blockedUserUnblock.setOnClickListener {
|
||||||
|
accountActionListener.onBlock(false, account.id, position)
|
||||||
|
}
|
||||||
|
binding.root.setOnClickListener {
|
||||||
|
accountActionListener.onViewAccount(account.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -12,10 +12,12 @@
|
||||||
*
|
*
|
||||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||||
* see <http://www.gnu.org/licenses>. */
|
* see <http://www.gnu.org/licenses>. */
|
||||||
package com.keylesspalace.tusky.adapter
|
|
||||||
|
package com.keylesspalace.tusky.components.accountlist.adapter
|
||||||
|
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import com.keylesspalace.tusky.adapter.AccountViewHolder
|
||||||
import com.keylesspalace.tusky.databinding.ItemAccountBinding
|
import com.keylesspalace.tusky.databinding.ItemAccountBinding
|
||||||
import com.keylesspalace.tusky.interfaces.AccountActionListener
|
import com.keylesspalace.tusky.interfaces.AccountActionListener
|
||||||
|
|
||||||
|
@ -26,17 +28,14 @@ class FollowAdapter(
|
||||||
animateEmojis: Boolean,
|
animateEmojis: Boolean,
|
||||||
showBotOverlay: Boolean
|
showBotOverlay: Boolean
|
||||||
) : AccountAdapter<AccountViewHolder>(
|
) : AccountAdapter<AccountViewHolder>(
|
||||||
accountActionListener,
|
accountActionListener = accountActionListener,
|
||||||
animateAvatar,
|
animateAvatar = animateAvatar,
|
||||||
animateEmojis,
|
animateEmojis = animateEmojis,
|
||||||
showBotOverlay
|
showBotOverlay = showBotOverlay
|
||||||
) {
|
) {
|
||||||
|
|
||||||
override fun createAccountViewHolder(parent: ViewGroup): AccountViewHolder {
|
override fun createAccountViewHolder(parent: ViewGroup): AccountViewHolder {
|
||||||
val binding = ItemAccountBinding.inflate(
|
val binding = ItemAccountBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||||
LayoutInflater.from(parent.context),
|
|
||||||
parent,
|
|
||||||
false
|
|
||||||
)
|
|
||||||
return AccountViewHolder(binding)
|
return AccountViewHolder(binding)
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,29 +12,51 @@
|
||||||
*
|
*
|
||||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||||
* see <http://www.gnu.org/licenses>. */
|
* see <http://www.gnu.org/licenses>. */
|
||||||
package com.keylesspalace.tusky.adapter
|
|
||||||
|
package com.keylesspalace.tusky.components.accountlist.adapter
|
||||||
|
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import com.keylesspalace.tusky.adapter.FollowRequestViewHolder
|
||||||
import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding
|
import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding
|
||||||
import com.keylesspalace.tusky.interfaces.AccountActionListener
|
import com.keylesspalace.tusky.interfaces.AccountActionListener
|
||||||
|
import com.keylesspalace.tusky.interfaces.LinkListener
|
||||||
|
|
||||||
/** Displays a list of follow requests with accept/reject buttons. */
|
/** Displays a list of follow requests with accept/reject buttons. */
|
||||||
class FollowRequestsAdapter(
|
class FollowRequestsAdapter(
|
||||||
accountActionListener: AccountActionListener,
|
accountActionListener: AccountActionListener,
|
||||||
|
private val linkListener: LinkListener,
|
||||||
animateAvatar: Boolean,
|
animateAvatar: Boolean,
|
||||||
animateEmojis: Boolean,
|
animateEmojis: Boolean,
|
||||||
showBotOverlay: Boolean
|
showBotOverlay: Boolean
|
||||||
) : AccountAdapter<FollowRequestViewHolder>(accountActionListener, animateAvatar, animateEmojis, showBotOverlay) {
|
) : AccountAdapter<FollowRequestViewHolder>(
|
||||||
|
accountActionListener = accountActionListener,
|
||||||
|
animateAvatar = animateAvatar,
|
||||||
|
animateEmojis = animateEmojis,
|
||||||
|
showBotOverlay = showBotOverlay
|
||||||
|
) {
|
||||||
|
|
||||||
override fun createAccountViewHolder(parent: ViewGroup): FollowRequestViewHolder {
|
override fun createAccountViewHolder(parent: ViewGroup): FollowRequestViewHolder {
|
||||||
val binding = ItemFollowRequestBinding.inflate(
|
val binding = ItemFollowRequestBinding.inflate(
|
||||||
LayoutInflater.from(parent.context), parent, false
|
LayoutInflater.from(parent.context),
|
||||||
|
parent,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
return FollowRequestViewHolder(
|
||||||
|
binding,
|
||||||
|
accountActionListener,
|
||||||
|
linkListener,
|
||||||
|
showHeader = false
|
||||||
)
|
)
|
||||||
return FollowRequestViewHolder(binding, false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBindAccountViewHolder(viewHolder: FollowRequestViewHolder, position: Int) {
|
override fun onBindAccountViewHolder(viewHolder: FollowRequestViewHolder, position: Int) {
|
||||||
viewHolder.setupWithAccount(accountList[position], animateAvatar, animateEmojis, showBotOverlay)
|
viewHolder.setupWithAccount(
|
||||||
|
account = accountList[position],
|
||||||
|
animateAvatar = animateAvatar,
|
||||||
|
animateEmojis = animateEmojis,
|
||||||
|
showBotOverlay = showBotOverlay
|
||||||
|
)
|
||||||
viewHolder.setupActionListener(accountActionListener, accountList[position].id)
|
viewHolder.setupActionListener(accountActionListener, accountList[position].id)
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -13,27 +13,28 @@
|
||||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||||
* see <http://www.gnu.org/licenses>. */
|
* see <http://www.gnu.org/licenses>. */
|
||||||
|
|
||||||
package com.keylesspalace.tusky.adapter
|
package com.keylesspalace.tusky.components.accountlist.adapter
|
||||||
|
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.TextView
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.keylesspalace.tusky.R
|
import com.keylesspalace.tusky.R
|
||||||
|
import com.keylesspalace.tusky.databinding.ItemFollowRequestsHeaderBinding
|
||||||
|
import com.keylesspalace.tusky.util.BindingHolder
|
||||||
|
|
||||||
class FollowRequestsHeaderAdapter(private val instanceName: String, private val accountLocked: Boolean) : RecyclerView.Adapter<HeaderViewHolder>() {
|
class FollowRequestsHeaderAdapter(
|
||||||
|
private val instanceName: String,
|
||||||
|
private val accountLocked: Boolean
|
||||||
|
) : RecyclerView.Adapter<BindingHolder<ItemFollowRequestsHeaderBinding>>() {
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HeaderViewHolder {
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemFollowRequestsHeaderBinding> {
|
||||||
val view = LayoutInflater.from(parent.context)
|
val binding = ItemFollowRequestsHeaderBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||||
.inflate(R.layout.item_follow_requests_header, parent, false) as TextView
|
return BindingHolder(binding)
|
||||||
return HeaderViewHolder(view)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBindViewHolder(viewHolder: HeaderViewHolder, position: Int) {
|
override fun onBindViewHolder(viewHolder: BindingHolder<ItemFollowRequestsHeaderBinding>, position: Int) {
|
||||||
viewHolder.textView.text = viewHolder.textView.context.getString(R.string.follow_requests_info, instanceName)
|
viewHolder.binding.root.text = viewHolder.binding.root.context.getString(R.string.follow_requests_info, instanceName)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getItemCount() = if (accountLocked) 0 else 1
|
override fun getItemCount() = if (accountLocked) 0 else 1
|
||||||
}
|
}
|
||||||
|
|
||||||
class HeaderViewHolder(var textView: TextView) : RecyclerView.ViewHolder(textView)
|
|
|
@ -1,4 +1,19 @@
|
||||||
package com.keylesspalace.tusky.adapter
|
/* 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.accountlist.adapter
|
||||||
|
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
@ -9,22 +24,21 @@ import com.keylesspalace.tusky.interfaces.AccountActionListener
|
||||||
import com.keylesspalace.tusky.util.BindingHolder
|
import com.keylesspalace.tusky.util.BindingHolder
|
||||||
import com.keylesspalace.tusky.util.emojify
|
import com.keylesspalace.tusky.util.emojify
|
||||||
import com.keylesspalace.tusky.util.loadAvatar
|
import com.keylesspalace.tusky.util.loadAvatar
|
||||||
|
import com.keylesspalace.tusky.util.visible
|
||||||
|
|
||||||
/**
|
/** Displays a list of muted accounts with mute/unmute account button and mute/unmute notifications switch */
|
||||||
* Displays a list of muted accounts with mute/unmute account and mute/unmute notifications
|
|
||||||
* buttons.
|
|
||||||
* */
|
|
||||||
class MutesAdapter(
|
class MutesAdapter(
|
||||||
accountActionListener: AccountActionListener,
|
accountActionListener: AccountActionListener,
|
||||||
animateAvatar: Boolean,
|
animateAvatar: Boolean,
|
||||||
animateEmojis: Boolean,
|
animateEmojis: Boolean,
|
||||||
showBotOverlay: Boolean
|
showBotOverlay: Boolean
|
||||||
) : AccountAdapter<BindingHolder<ItemMutedUserBinding>>(
|
) : AccountAdapter<BindingHolder<ItemMutedUserBinding>>(
|
||||||
accountActionListener,
|
accountActionListener = accountActionListener,
|
||||||
animateAvatar,
|
animateAvatar = animateAvatar,
|
||||||
animateEmojis,
|
animateEmojis = animateEmojis,
|
||||||
showBotOverlay
|
showBotOverlay = showBotOverlay
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val mutingNotificationsMap = HashMap<String, Boolean>()
|
private val mutingNotificationsMap = HashMap<String, Boolean>()
|
||||||
|
|
||||||
override fun createAccountViewHolder(parent: ViewGroup): BindingHolder<ItemMutedUserBinding> {
|
override fun createAccountViewHolder(parent: ViewGroup): BindingHolder<ItemMutedUserBinding> {
|
||||||
|
@ -48,6 +62,8 @@ class MutesAdapter(
|
||||||
val avatarRadius = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp)
|
val avatarRadius = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp)
|
||||||
loadAvatar(account.avatar, binding.mutedUserAvatar, avatarRadius, animateAvatar)
|
loadAvatar(account.avatar, binding.mutedUserAvatar, avatarRadius, animateAvatar)
|
||||||
|
|
||||||
|
binding.mutedUserBotBadge.visible(showBotOverlay && account.bot)
|
||||||
|
|
||||||
val unmuteString = context.getString(R.string.action_unmute_desc, formattedUsername)
|
val unmuteString = context.getString(R.string.action_unmute_desc, formattedUsername)
|
||||||
binding.mutedUserUnmute.contentDescription = unmuteString
|
binding.mutedUserUnmute.contentDescription = unmuteString
|
||||||
ViewCompat.setTooltipText(binding.mutedUserUnmute, unmuteString)
|
ViewCompat.setTooltipText(binding.mutedUserUnmute, unmuteString)
|
|
@ -80,7 +80,7 @@ class AnnouncementAdapter(
|
||||||
item.reactions.forEachIndexed { i, reaction ->
|
item.reactions.forEachIndexed { i, reaction ->
|
||||||
(
|
(
|
||||||
chips.getChildAt(i)?.takeUnless { it.id == R.id.addReactionChip } as Chip?
|
chips.getChildAt(i)?.takeUnless { it.id == R.id.addReactionChip } as Chip?
|
||||||
?: Chip(ContextThemeWrapper(chips.context, R.style.Widget_MaterialComponents_Chip_Choice)).apply {
|
?: Chip(ContextThemeWrapper(chips.context, com.google.android.material.R.style.Widget_MaterialComponents_Chip_Choice)).apply {
|
||||||
isCheckable = true
|
isCheckable = true
|
||||||
checkedIcon = null
|
checkedIcon = null
|
||||||
chips.addView(this, i)
|
chips.addView(this, i)
|
||||||
|
|
|
@ -19,12 +19,17 @@ import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.view.Menu
|
||||||
|
import android.view.MenuInflater
|
||||||
|
import android.view.MenuItem
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.widget.PopupWindow
|
import android.widget.PopupWindow
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
|
import androidx.core.view.MenuProvider
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import androidx.recyclerview.widget.DividerItemDecoration
|
import androidx.recyclerview.widget.DividerItemDecoration
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import com.google.android.material.color.MaterialColors
|
||||||
import com.keylesspalace.tusky.BottomSheetActivity
|
import com.keylesspalace.tusky.BottomSheetActivity
|
||||||
import com.keylesspalace.tusky.R
|
import com.keylesspalace.tusky.R
|
||||||
import com.keylesspalace.tusky.StatusListActivity
|
import com.keylesspalace.tusky.StatusListActivity
|
||||||
|
@ -39,11 +44,21 @@ import com.keylesspalace.tusky.util.Loading
|
||||||
import com.keylesspalace.tusky.util.Success
|
import com.keylesspalace.tusky.util.Success
|
||||||
import com.keylesspalace.tusky.util.hide
|
import com.keylesspalace.tusky.util.hide
|
||||||
import com.keylesspalace.tusky.util.show
|
import com.keylesspalace.tusky.util.show
|
||||||
|
import com.keylesspalace.tusky.util.unsafeLazy
|
||||||
import com.keylesspalace.tusky.util.viewBinding
|
import com.keylesspalace.tusky.util.viewBinding
|
||||||
import com.keylesspalace.tusky.view.EmojiPicker
|
import com.keylesspalace.tusky.view.EmojiPicker
|
||||||
|
import com.mikepenz.iconics.IconicsDrawable
|
||||||
|
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
|
||||||
|
import com.mikepenz.iconics.utils.colorInt
|
||||||
|
import com.mikepenz.iconics.utils.sizeDp
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class AnnouncementsActivity : BottomSheetActivity(), AnnouncementActionListener, OnEmojiSelectedListener, Injectable {
|
class AnnouncementsActivity :
|
||||||
|
BottomSheetActivity(),
|
||||||
|
AnnouncementActionListener,
|
||||||
|
OnEmojiSelectedListener,
|
||||||
|
MenuProvider,
|
||||||
|
Injectable {
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var viewModelFactory: ViewModelFactory
|
lateinit var viewModelFactory: ViewModelFactory
|
||||||
|
@ -54,8 +69,8 @@ class AnnouncementsActivity : BottomSheetActivity(), AnnouncementActionListener,
|
||||||
|
|
||||||
private lateinit var adapter: AnnouncementAdapter
|
private lateinit var adapter: AnnouncementAdapter
|
||||||
|
|
||||||
private val picker by lazy { EmojiPicker(this) }
|
private val picker by unsafeLazy { EmojiPicker(this) }
|
||||||
private val pickerDialog by lazy {
|
private val pickerDialog by unsafeLazy {
|
||||||
PopupWindow(this)
|
PopupWindow(this)
|
||||||
.apply {
|
.apply {
|
||||||
contentView = picker
|
contentView = picker
|
||||||
|
@ -70,6 +85,7 @@ class AnnouncementsActivity : BottomSheetActivity(), AnnouncementActionListener,
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContentView(binding.root)
|
setContentView(binding.root)
|
||||||
|
addMenuProvider(this)
|
||||||
|
|
||||||
setSupportActionBar(binding.includedToolbar.toolbar)
|
setSupportActionBar(binding.includedToolbar.toolbar)
|
||||||
supportActionBar?.apply {
|
supportActionBar?.apply {
|
||||||
|
@ -129,6 +145,27 @@ class AnnouncementsActivity : BottomSheetActivity(), AnnouncementActionListener,
|
||||||
binding.progressBar.show()
|
binding.progressBar.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||||
|
menuInflater.inflate(R.menu.activity_announcements, menu)
|
||||||
|
menu.findItem(R.id.action_search)?.apply {
|
||||||
|
icon = IconicsDrawable(this@AnnouncementsActivity, GoogleMaterial.Icon.gmd_search).apply {
|
||||||
|
sizeDp = 20
|
||||||
|
colorInt = MaterialColors.getColor(binding.includedToolbar.toolbar, android.R.attr.textColorPrimary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
|
||||||
|
return when (menuItem.itemId) {
|
||||||
|
R.id.action_refresh -> {
|
||||||
|
binding.swipeRefreshLayout.isRefreshing = true
|
||||||
|
refreshAnnouncements()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun refreshAnnouncements() {
|
private fun refreshAnnouncements() {
|
||||||
viewModel.load()
|
viewModel.load()
|
||||||
binding.swipeRefreshLayout.isRefreshing = true
|
binding.swipeRefreshLayout.isRefreshing = true
|
||||||
|
|
|
@ -107,8 +107,7 @@ class AnnouncementsViewModel @Inject constructor(
|
||||||
} else {
|
} else {
|
||||||
listOf(
|
listOf(
|
||||||
*announcement.reactions.toTypedArray(),
|
*announcement.reactions.toTypedArray(),
|
||||||
emojis.value!!.find { emoji -> emoji.shortcode == name }
|
emojis.value!!.find { emoji -> emoji.shortcode == name }!!.run {
|
||||||
!!.run {
|
|
||||||
Announcement.Reaction(
|
Announcement.Reaction(
|
||||||
name,
|
name,
|
||||||
1,
|
1,
|
||||||
|
|
|
@ -32,6 +32,8 @@ import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
|
import android.text.Spanned
|
||||||
|
import android.text.style.URLSpan
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
|
@ -52,11 +54,13 @@ import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.core.app.ActivityCompat
|
import androidx.core.app.ActivityCompat
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
|
import androidx.core.content.res.use
|
||||||
import androidx.core.view.ContentInfoCompat
|
import androidx.core.view.ContentInfoCompat
|
||||||
import androidx.core.view.OnReceiveContentListener
|
import androidx.core.view.OnReceiveContentListener
|
||||||
import androidx.core.view.isGone
|
import androidx.core.view.isGone
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.core.widget.doAfterTextChanged
|
import androidx.core.widget.doAfterTextChanged
|
||||||
|
import androidx.core.widget.doOnTextChanged
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
@ -92,18 +96,18 @@ import com.keylesspalace.tusky.entity.NewPoll
|
||||||
import com.keylesspalace.tusky.entity.Status
|
import com.keylesspalace.tusky.entity.Status
|
||||||
import com.keylesspalace.tusky.settings.PrefKeys
|
import com.keylesspalace.tusky.settings.PrefKeys
|
||||||
import com.keylesspalace.tusky.util.APP_THEME_DEFAULT
|
import com.keylesspalace.tusky.util.APP_THEME_DEFAULT
|
||||||
|
import com.keylesspalace.tusky.util.MentionSpan
|
||||||
import com.keylesspalace.tusky.util.PickMediaFiles
|
import com.keylesspalace.tusky.util.PickMediaFiles
|
||||||
import com.keylesspalace.tusky.util.afterTextChanged
|
import com.keylesspalace.tusky.util.getInitialLanguages
|
||||||
import com.keylesspalace.tusky.util.getInitialLanguage
|
|
||||||
import com.keylesspalace.tusky.util.getLocaleList
|
import com.keylesspalace.tusky.util.getLocaleList
|
||||||
import com.keylesspalace.tusky.util.getMediaSize
|
import com.keylesspalace.tusky.util.getMediaSize
|
||||||
import com.keylesspalace.tusky.util.hide
|
import com.keylesspalace.tusky.util.hide
|
||||||
import com.keylesspalace.tusky.util.highlightSpans
|
import com.keylesspalace.tusky.util.highlightSpans
|
||||||
import com.keylesspalace.tusky.util.loadAvatar
|
import com.keylesspalace.tusky.util.loadAvatar
|
||||||
import com.keylesspalace.tusky.util.modernLanguageCode
|
import com.keylesspalace.tusky.util.modernLanguageCode
|
||||||
import com.keylesspalace.tusky.util.onTextChanged
|
|
||||||
import com.keylesspalace.tusky.util.setDrawableTint
|
import com.keylesspalace.tusky.util.setDrawableTint
|
||||||
import com.keylesspalace.tusky.util.show
|
import com.keylesspalace.tusky.util.show
|
||||||
|
import com.keylesspalace.tusky.util.unsafeLazy
|
||||||
import com.keylesspalace.tusky.util.viewBinding
|
import com.keylesspalace.tusky.util.viewBinding
|
||||||
import com.keylesspalace.tusky.util.visible
|
import com.keylesspalace.tusky.util.visible
|
||||||
import com.mikepenz.iconics.IconicsDrawable
|
import com.mikepenz.iconics.IconicsDrawable
|
||||||
|
@ -145,7 +149,7 @@ class ComposeActivity :
|
||||||
|
|
||||||
private var photoUploadUri: Uri? = null
|
private var photoUploadUri: Uri? = null
|
||||||
|
|
||||||
private val preferences by lazy { PreferenceManager.getDefaultSharedPreferences(this) }
|
private val preferences by unsafeLazy { PreferenceManager.getDefaultSharedPreferences(this) }
|
||||||
|
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
var maximumTootCharacters = InstanceInfoRepository.DEFAULT_CHARACTER_LIMIT
|
var maximumTootCharacters = InstanceInfoRepository.DEFAULT_CHARACTER_LIMIT
|
||||||
|
@ -228,13 +232,13 @@ class ComposeActivity :
|
||||||
val mediaAdapter = MediaPreviewAdapter(
|
val mediaAdapter = MediaPreviewAdapter(
|
||||||
this,
|
this,
|
||||||
onAddCaption = { item ->
|
onAddCaption = { item ->
|
||||||
CaptionDialog.newInstance(item.localId, item.description, item.uri)
|
CaptionDialog.newInstance(item.localId, item.description, item.uri).show(supportFragmentManager, "caption_dialog")
|
||||||
.show(supportFragmentManager, "caption_dialog")
|
|
||||||
},
|
},
|
||||||
onAddFocus = { item ->
|
onAddFocus = { item ->
|
||||||
makeFocusDialog(item.focus, item.uri) { newFocus ->
|
makeFocusDialog(item.focus, item.uri) { newFocus ->
|
||||||
viewModel.updateFocus(item.localId, newFocus)
|
viewModel.updateFocus(item.localId, newFocus)
|
||||||
}
|
}
|
||||||
|
// TODO this is inconsistent to CaptionDialog (device rotation)?
|
||||||
},
|
},
|
||||||
onEditImage = this::editImageInQueue,
|
onEditImage = this::editImageInQueue,
|
||||||
onRemove = this::removeMediaFromQueue
|
onRemove = this::removeMediaFromQueue
|
||||||
|
@ -273,7 +277,7 @@ class ComposeActivity :
|
||||||
binding.composeScheduleView.setDateTime(composeOptions?.scheduledAt)
|
binding.composeScheduleView.setDateTime(composeOptions?.scheduledAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
setupLanguageSpinner(getInitialLanguage(composeOptions?.language, accountManager.activeAccount))
|
setupLanguageSpinner(getInitialLanguages(composeOptions?.language, accountManager.activeAccount))
|
||||||
setupComposeField(preferences, viewModel.startingText)
|
setupComposeField(preferences, viewModel.startingText)
|
||||||
setupDefaultTagViews(preferences)
|
setupDefaultTagViews(preferences)
|
||||||
setupContentWarningField(composeOptions?.contentWarning)
|
setupContentWarningField(composeOptions?.contentWarning)
|
||||||
|
@ -420,7 +424,7 @@ class ComposeActivity :
|
||||||
if (startingContentWarning != null) {
|
if (startingContentWarning != null) {
|
||||||
binding.composeContentWarningField.setText(startingContentWarning)
|
binding.composeContentWarningField.setText(startingContentWarning)
|
||||||
}
|
}
|
||||||
binding.composeContentWarningField.onTextChanged { _, _, _, _ -> updateVisibleCharactersLeft() }
|
binding.composeContentWarningField.doOnTextChanged { _, _, _, _ -> updateVisibleCharactersLeft() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupComposeField(preferences: SharedPreferences, startingText: String?) {
|
private fun setupComposeField(preferences: SharedPreferences, startingText: String?) {
|
||||||
|
@ -443,8 +447,8 @@ class ComposeActivity :
|
||||||
|
|
||||||
val mentionColour = binding.composeEditField.linkTextColors.defaultColor
|
val mentionColour = binding.composeEditField.linkTextColors.defaultColor
|
||||||
highlightSpans(binding.composeEditField.text, mentionColour)
|
highlightSpans(binding.composeEditField.text, mentionColour)
|
||||||
binding.composeEditField.afterTextChanged { editable ->
|
binding.composeEditField.doAfterTextChanged { editable ->
|
||||||
highlightSpans(editable, mentionColour)
|
highlightSpans(editable!!, mentionColour)
|
||||||
updateVisibleCharactersLeft()
|
updateVisibleCharactersLeft()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -535,7 +539,9 @@ class ComposeActivity :
|
||||||
preferences.edit()
|
preferences.edit()
|
||||||
.putBoolean(PREF_USE_DEFAULT_TAG, isChecked)
|
.putBoolean(PREF_USE_DEFAULT_TAG, isChecked)
|
||||||
.apply()
|
.apply()
|
||||||
eventHub.dispatch(PreferenceChangedEvent(PREF_USE_DEFAULT_TAG))
|
lifecycleScope.launch {
|
||||||
|
eventHub.dispatch(PreferenceChangedEvent(PREF_USE_DEFAULT_TAG))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.editTextDefaultText.setText(preferences.getString(PREF_DEFAULT_TAG, ""))
|
binding.editTextDefaultText.setText(preferences.getString(PREF_DEFAULT_TAG, ""))
|
||||||
|
@ -543,7 +549,9 @@ class ComposeActivity :
|
||||||
preferences.edit()
|
preferences.edit()
|
||||||
.putString(PREF_DEFAULT_TAG, it.toString())
|
.putString(PREF_DEFAULT_TAG, it.toString())
|
||||||
.apply()
|
.apply()
|
||||||
eventHub.dispatch(PreferenceChangedEvent(PREF_DEFAULT_TAG))
|
lifecycleScope.launch {
|
||||||
|
eventHub.dispatch(PreferenceChangedEvent(PREF_DEFAULT_TAG))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -613,7 +621,7 @@ class ComposeActivity :
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupLanguageSpinner(initialLanguage: String) {
|
private fun setupLanguageSpinner(initialLanguages: List<String>) {
|
||||||
binding.composePostLanguageButton.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
|
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
|
viewModel.postLanguage = (parent.adapter.getItem(position) as Locale).modernLanguageCode
|
||||||
|
@ -624,7 +632,7 @@ class ComposeActivity :
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
binding.composePostLanguageButton.apply {
|
binding.composePostLanguageButton.apply {
|
||||||
adapter = LocaleAdapter(context, android.R.layout.simple_spinner_dropdown_item, getLocaleList(initialLanguage))
|
adapter = LocaleAdapter(context, android.R.layout.simple_spinner_dropdown_item, getLocaleList(initialLanguages))
|
||||||
setSelection(0)
|
setSelection(0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -640,10 +648,10 @@ class ComposeActivity :
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupAvatar(activeAccount: AccountEntity) {
|
private fun setupAvatar(activeAccount: AccountEntity) {
|
||||||
val actionBarSizeAttr = intArrayOf(R.attr.actionBarSize)
|
val actionBarSizeAttr = intArrayOf(androidx.appcompat.R.attr.actionBarSize)
|
||||||
val a = obtainStyledAttributes(null, actionBarSizeAttr)
|
val avatarSize = obtainStyledAttributes(null, actionBarSizeAttr).use { a ->
|
||||||
val avatarSize = a.getDimensionPixelSize(0, 1)
|
a.getDimensionPixelSize(0, 1)
|
||||||
a.recycle()
|
}
|
||||||
|
|
||||||
val animateAvatars = preferences.getBoolean("animateGifAvatars", false)
|
val animateAvatars = preferences.getBoolean("animateGifAvatars", false)
|
||||||
loadAvatar(
|
loadAvatar(
|
||||||
|
@ -768,7 +776,7 @@ class ComposeActivity :
|
||||||
|
|
||||||
var oneMediaWithoutDescription = false
|
var oneMediaWithoutDescription = false
|
||||||
for (media in viewModel.media.value) {
|
for (media in viewModel.media.value) {
|
||||||
if (media.description == null || media.description.isEmpty()) {
|
if (media.description.isNullOrEmpty()) {
|
||||||
oneMediaWithoutDescription = true
|
oneMediaWithoutDescription = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
@ -879,25 +887,26 @@ class ComposeActivity :
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onMediaPick() {
|
private fun onMediaPick() {
|
||||||
addMediaBehavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() {
|
addMediaBehavior.addBottomSheetCallback(
|
||||||
override fun onStateChanged(bottomSheet: View, newState: Int) {
|
object : BottomSheetBehavior.BottomSheetCallback() {
|
||||||
// Wait until bottom sheet is not collapsed and show next screen after
|
override fun onStateChanged(bottomSheet: View, newState: Int) {
|
||||||
if (newState == BottomSheetBehavior.STATE_COLLAPSED) {
|
// Wait until bottom sheet is not collapsed and show next screen after
|
||||||
addMediaBehavior.removeBottomSheetCallback(this)
|
if (newState == BottomSheetBehavior.STATE_COLLAPSED) {
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && ContextCompat.checkSelfPermission(this@ComposeActivity, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
|
addMediaBehavior.removeBottomSheetCallback(this)
|
||||||
ActivityCompat.requestPermissions(
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && ContextCompat.checkSelfPermission(this@ComposeActivity, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
|
||||||
this@ComposeActivity,
|
ActivityCompat.requestPermissions(
|
||||||
arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE),
|
this@ComposeActivity,
|
||||||
PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE
|
arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE),
|
||||||
)
|
PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE
|
||||||
} else {
|
)
|
||||||
pickMediaFile.launch(true)
|
} else {
|
||||||
|
pickMediaFile.launch(true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSlide(bottomSheet: View, slideOffset: Float) {}
|
override fun onSlide(bottomSheet: View, slideOffset: Float) {}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
||||||
}
|
}
|
||||||
|
@ -953,23 +962,13 @@ class ComposeActivity :
|
||||||
|
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
fun calculateTextLength(): Int {
|
fun calculateTextLength(): Int {
|
||||||
var offset = 0
|
return statusLength(
|
||||||
val urlSpans = binding.composeEditField.urls
|
binding.composeEditField.text,
|
||||||
if (urlSpans != null) {
|
binding.composeContentWarningField.text,
|
||||||
for (span in urlSpans) {
|
binding.checkboxUseDefaultText.isChecked,
|
||||||
// it's expected that this will be negative
|
binding.editTextDefaultText.text,
|
||||||
// when the url length is less than the reserved character count
|
charactersReservedPerUrl
|
||||||
offset += (span.url.length - charactersReservedPerUrl)
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
var length = binding.composeEditField.length() - offset
|
|
||||||
if (binding.checkboxUseDefaultText.isChecked) {
|
|
||||||
length += 1 + binding.editTextDefaultText.length()
|
|
||||||
}
|
|
||||||
if (viewModel.showContentWarning.value) {
|
|
||||||
length += binding.composeContentWarningField.length()
|
|
||||||
}
|
|
||||||
return length
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
|
@ -1054,7 +1053,8 @@ class ComposeActivity :
|
||||||
pickMediaFile.launch(true)
|
pickMediaFile.launch(true)
|
||||||
} else {
|
} else {
|
||||||
Snackbar.make(
|
Snackbar.make(
|
||||||
binding.activityCompose, R.string.error_media_upload_permission,
|
binding.activityCompose,
|
||||||
|
R.string.error_media_upload_permission,
|
||||||
Snackbar.LENGTH_SHORT
|
Snackbar.LENGTH_SHORT
|
||||||
).apply {
|
).apply {
|
||||||
setAction(R.string.action_retry) { onMediaPick() }
|
setAction(R.string.action_retry) { onMediaPick() }
|
||||||
|
@ -1088,9 +1088,13 @@ class ComposeActivity :
|
||||||
private fun enableButton(button: ImageButton, clickable: Boolean, colorActive: Boolean) {
|
private fun enableButton(button: ImageButton, clickable: Boolean, colorActive: Boolean) {
|
||||||
button.isEnabled = clickable
|
button.isEnabled = clickable
|
||||||
setDrawableTint(
|
setDrawableTint(
|
||||||
this, button.drawable,
|
this,
|
||||||
if (colorActive) android.R.attr.textColorTertiary
|
button.drawable,
|
||||||
else R.attr.textColorDisabled
|
if (colorActive) {
|
||||||
|
android.R.attr.textColorTertiary
|
||||||
|
} else {
|
||||||
|
R.attr.textColorDisabled
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1098,8 +1102,11 @@ class ComposeActivity :
|
||||||
binding.addPollTextActionTextView.isEnabled = enable
|
binding.addPollTextActionTextView.isEnabled = enable
|
||||||
val textColor = MaterialColors.getColor(
|
val textColor = MaterialColors.getColor(
|
||||||
binding.addPollTextActionTextView,
|
binding.addPollTextActionTextView,
|
||||||
if (enable) android.R.attr.textColorTertiary
|
if (enable) {
|
||||||
else R.attr.textColorDisabled
|
android.R.attr.textColorTertiary
|
||||||
|
} else {
|
||||||
|
R.attr.textColorDisabled
|
||||||
|
}
|
||||||
)
|
)
|
||||||
binding.addPollTextActionTextView.setTextColor(textColor)
|
binding.addPollTextActionTextView.setTextColor(textColor)
|
||||||
binding.addPollTextActionTextView.compoundDrawablesRelative[0].colorFilter = PorterDuffColorFilter(textColor, PorterDuff.Mode.SRC_IN)
|
binding.addPollTextActionTextView.compoundDrawablesRelative[0].colorFilter = PorterDuffColorFilter(textColor, PorterDuff.Mode.SRC_IN)
|
||||||
|
@ -1275,8 +1282,11 @@ class ComposeActivity :
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
val dialog = if (viewModel.shouldShowSaveDraftDialog()) {
|
val dialog = if (viewModel.shouldShowSaveDraftDialog()) {
|
||||||
ProgressDialog.show(
|
ProgressDialog.show(
|
||||||
this@ComposeActivity, null,
|
this@ComposeActivity,
|
||||||
getString(R.string.saving_draft), true, false
|
null,
|
||||||
|
getString(R.string.saving_draft),
|
||||||
|
true,
|
||||||
|
false
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
|
@ -1337,11 +1347,7 @@ class ComposeActivity :
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onUpdateDescription(localId: Int, description: String) {
|
override fun onUpdateDescription(localId: Int, description: String) {
|
||||||
lifecycleScope.launch {
|
viewModel.updateDescription(localId, description)
|
||||||
if (!viewModel.updateDescription(localId, description)) {
|
|
||||||
Toast.makeText(this@ComposeActivity, R.string.error_failed_set_caption, Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -1438,5 +1444,57 @@ class ComposeActivity :
|
||||||
fun canHandleMimeType(mimeType: String?): Boolean {
|
fun canHandleMimeType(mimeType: String?): Boolean {
|
||||||
return mimeType != null && (mimeType.startsWith("image/") || mimeType.startsWith("video/") || mimeType.startsWith("audio/") || mimeType == "text/plain")
|
return mimeType != null && (mimeType.startsWith("image/") || mimeType.startsWith("video/") || mimeType.startsWith("audio/") || mimeType == "text/plain")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the effective status length.
|
||||||
|
*
|
||||||
|
* Some text is counted differently:
|
||||||
|
*
|
||||||
|
* In the status body:
|
||||||
|
*
|
||||||
|
* - URLs always count for [urlLength] characters irrespective of their actual length
|
||||||
|
* (https://docs.joinmastodon.org/user/posting/#links)
|
||||||
|
* - Mentions ("@user@some.instance") only count the "@user" part
|
||||||
|
* (https://docs.joinmastodon.org/user/posting/#mentions)
|
||||||
|
* - Hashtags are always treated as their actual length, including the "#"
|
||||||
|
* (https://docs.joinmastodon.org/user/posting/#hashtags)
|
||||||
|
*
|
||||||
|
* Content warning text is always treated as its full length, URLs and other entities
|
||||||
|
* are not treated differently.
|
||||||
|
*
|
||||||
|
* @param body status body text
|
||||||
|
* @param contentWarning optional content warning text
|
||||||
|
* @param urlLength the number of characters attributed to URLs
|
||||||
|
* @return the effective status length
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun statusLength(body: Spanned, contentWarning: Spanned?, useDefault: Boolean, default: Spanned, urlLength: Int): Int {
|
||||||
|
var length = body.length - 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) {
|
||||||
|
is MentionSpan -> {
|
||||||
|
// Ignore everything from the second "@" (if present)
|
||||||
|
span.url.length - (
|
||||||
|
span.url.indexOf("@", 1).takeIf { it >= 0 }
|
||||||
|
?: span.url.length
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
// Expected to be negative if the URL length < maxUrlLength
|
||||||
|
span.url.length - urlLength
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content warning text is treated as is, URLs or mentions there are not special
|
||||||
|
contentWarning?.let { length += it.length }
|
||||||
|
|
||||||
|
if (useDefault) {
|
||||||
|
length += 1 + default.length
|
||||||
|
}
|
||||||
|
|
||||||
|
return length
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -46,7 +46,6 @@ import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.shareIn
|
import kotlinx.coroutines.flow.shareIn
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.flow.updateAndGet
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
@ -141,7 +140,7 @@ class ComposeViewModel @Inject constructor(
|
||||||
): QueuedMedia {
|
): QueuedMedia {
|
||||||
var stashMediaItem: QueuedMedia? = null
|
var stashMediaItem: QueuedMedia? = null
|
||||||
|
|
||||||
media.updateAndGet { mediaValue ->
|
media.update { mediaList ->
|
||||||
val mediaItem = QueuedMedia(
|
val mediaItem = QueuedMedia(
|
||||||
localId = mediaUploader.getNewLocalMediaId(),
|
localId = mediaUploader.getNewLocalMediaId(),
|
||||||
uri = uri,
|
uri = uri,
|
||||||
|
@ -155,11 +154,11 @@ class ComposeViewModel @Inject constructor(
|
||||||
|
|
||||||
if (replaceItem != null) {
|
if (replaceItem != null) {
|
||||||
mediaUploader.cancelUploadScope(replaceItem.localId)
|
mediaUploader.cancelUploadScope(replaceItem.localId)
|
||||||
mediaValue.map {
|
mediaList.map {
|
||||||
if (it.localId == replaceItem.localId) mediaItem else it
|
if (it.localId == replaceItem.localId) mediaItem else it
|
||||||
}
|
}
|
||||||
} else { // Append
|
} else { // Append
|
||||||
mediaValue + mediaItem
|
mediaList + mediaItem
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val mediaItem = stashMediaItem!! // stashMediaItem is always non-null and uncaptured at this point, but Kotlin doesn't know that
|
val mediaItem = stashMediaItem!! // stashMediaItem is always non-null and uncaptured at this point, but Kotlin doesn't know that
|
||||||
|
@ -180,13 +179,13 @@ class ComposeViewModel @Inject constructor(
|
||||||
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 -> {
|
is UploadEvent.ErrorEvent -> {
|
||||||
media.update { mediaValue -> mediaValue.filter { it.localId != mediaItem.localId } }
|
media.update { mediaList -> mediaList.filter { it.localId != mediaItem.localId } }
|
||||||
uploadError.emit(event.error)
|
uploadError.emit(event.error)
|
||||||
return@collect
|
return@collect
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
media.update { mediaValue ->
|
media.update { mediaList ->
|
||||||
mediaValue.map { mediaItem ->
|
mediaList.map { mediaItem ->
|
||||||
if (mediaItem.localId == newMediaItem.localId) {
|
if (mediaItem.localId == newMediaItem.localId) {
|
||||||
newMediaItem
|
newMediaItem
|
||||||
} else {
|
} else {
|
||||||
|
@ -200,7 +199,7 @@ class ComposeViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun addUploadedMedia(id: String, type: QueuedMedia.Type, uri: Uri, description: String?, focus: Attachment.Focus?) {
|
private fun addUploadedMedia(id: String, type: QueuedMedia.Type, uri: Uri, description: String?, focus: Attachment.Focus?) {
|
||||||
media.update { mediaValue ->
|
media.update { mediaList ->
|
||||||
val mediaItem = QueuedMedia(
|
val mediaItem = QueuedMedia(
|
||||||
localId = mediaUploader.getNewLocalMediaId(),
|
localId = mediaUploader.getNewLocalMediaId(),
|
||||||
uri = uri,
|
uri = uri,
|
||||||
|
@ -212,13 +211,13 @@ class ComposeViewModel @Inject constructor(
|
||||||
focus = focus,
|
focus = focus,
|
||||||
state = QueuedMedia.State.PUBLISHED
|
state = QueuedMedia.State.PUBLISHED
|
||||||
)
|
)
|
||||||
mediaValue + mediaItem
|
mediaList + mediaItem
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeMediaFromQueue(item: QueuedMedia) {
|
fun removeMediaFromQueue(item: QueuedMedia) {
|
||||||
mediaUploader.cancelUploadScope(item.localId)
|
mediaUploader.cancelUploadScope(item.localId)
|
||||||
media.update { mediaValue -> mediaValue.filter { it.localId != item.localId } }
|
media.update { mediaList -> mediaList.filter { it.localId != item.localId } }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toggleMarkSensitive() {
|
fun toggleMarkSensitive() {
|
||||||
|
@ -285,7 +284,7 @@ class ComposeViewModel @Inject constructor(
|
||||||
failedToSendAlert = false,
|
failedToSendAlert = false,
|
||||||
scheduledAt = scheduledAt.value,
|
scheduledAt = scheduledAt.value,
|
||||||
language = postLanguage,
|
language = postLanguage,
|
||||||
statusId = originalStatusId,
|
statusId = originalStatusId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -297,7 +296,6 @@ class ComposeViewModel @Inject constructor(
|
||||||
content: String,
|
content: String,
|
||||||
spoilerText: String
|
spoilerText: String
|
||||||
) {
|
) {
|
||||||
|
|
||||||
if (!scheduledTootId.isNullOrEmpty()) {
|
if (!scheduledTootId.isNullOrEmpty()) {
|
||||||
api.deleteScheduledStatus(scheduledTootId!!)
|
api.deleteScheduledStatus(scheduledTootId!!)
|
||||||
}
|
}
|
||||||
|
@ -335,10 +333,9 @@ class ComposeViewModel @Inject constructor(
|
||||||
serviceClient.sendToot(tootToSend)
|
serviceClient.sendToot(tootToSend)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Updates a QueuedMedia item arbitrarily, then sends description and focus to server
|
private fun updateMediaItem(localId: Int, mutator: (QueuedMedia) -> QueuedMedia) {
|
||||||
private suspend fun updateMediaItem(localId: Int, mutator: (QueuedMedia) -> QueuedMedia): Boolean {
|
media.update { mediaList ->
|
||||||
val newMediaList = media.updateAndGet { mediaValue ->
|
mediaList.map { mediaItem ->
|
||||||
mediaValue.map { mediaItem ->
|
|
||||||
if (mediaItem.localId == localId) {
|
if (mediaItem.localId == localId) {
|
||||||
mutator(mediaItem)
|
mutator(mediaItem)
|
||||||
} else {
|
} else {
|
||||||
|
@ -346,30 +343,16 @@ class ComposeViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val updatedItem = newMediaList.find { it.localId == localId }
|
|
||||||
if (updatedItem?.id != null) {
|
|
||||||
val focus = updatedItem.focus
|
|
||||||
val focusString = if (focus != null) "${focus.x},${focus.y}" else null
|
|
||||||
return api.updateMedia(updatedItem.id, updatedItem.description, focusString)
|
|
||||||
.fold({
|
|
||||||
true
|
|
||||||
}, { throwable ->
|
|
||||||
Log.w(TAG, "failed to update media", throwable)
|
|
||||||
false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun updateDescription(localId: Int, description: String): Boolean {
|
fun updateDescription(localId: Int, description: String) {
|
||||||
return updateMediaItem(localId) { mediaItem ->
|
updateMediaItem(localId) { mediaItem ->
|
||||||
mediaItem.copy(description = description)
|
mediaItem.copy(description = description)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun updateFocus(localId: Int, focus: Attachment.Focus): Boolean {
|
fun updateFocus(localId: Int, focus: Attachment.Focus) {
|
||||||
return updateMediaItem(localId) { mediaItem ->
|
updateMediaItem(localId) { mediaItem ->
|
||||||
mediaItem.copy(focus = focus)
|
mediaItem.copy(focus = focus)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -414,7 +397,6 @@ class ComposeViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setup(composeOptions: ComposeActivity.ComposeOptions?, useCache: Boolean) {
|
fun setup(composeOptions: ComposeActivity.ComposeOptions?, useCache: Boolean) {
|
||||||
|
|
||||||
if (setupComplete) {
|
if (setupComplete) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -453,14 +435,16 @@ class ComposeViewModel @Inject constructor(
|
||||||
pickMedia(attachment.uri, attachment.description, attachment.focus)
|
pickMedia(attachment.uri, attachment.description, attachment.focus)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else composeOptions?.mediaAttachments?.forEach { a ->
|
} else {
|
||||||
// when coming from redraft or ScheduledTootActivity
|
composeOptions?.mediaAttachments?.forEach { a ->
|
||||||
val mediaType = when (a.type) {
|
// when coming from redraft or ScheduledTootActivity
|
||||||
Attachment.Type.VIDEO, Attachment.Type.GIFV -> QueuedMedia.Type.VIDEO
|
val mediaType = when (a.type) {
|
||||||
Attachment.Type.UNKNOWN, Attachment.Type.IMAGE -> QueuedMedia.Type.IMAGE
|
Attachment.Type.VIDEO, Attachment.Type.GIFV -> QueuedMedia.Type.VIDEO
|
||||||
Attachment.Type.AUDIO -> QueuedMedia.Type.AUDIO
|
Attachment.Type.UNKNOWN, Attachment.Type.IMAGE -> QueuedMedia.Type.IMAGE
|
||||||
|
Attachment.Type.AUDIO -> QueuedMedia.Type.AUDIO
|
||||||
|
}
|
||||||
|
addUploadedMedia(a.id, mediaType, a.url.toUri(), a.description, a.meta?.focus)
|
||||||
}
|
}
|
||||||
addUploadedMedia(a.id, mediaType, a.url.toUri(), a.description, a.meta?.focus)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
draftId = composeOptions?.draftId ?: 0
|
draftId = composeOptions?.draftId ?: 0
|
||||||
|
|
|
@ -41,7 +41,6 @@ fun downsizeImage(
|
||||||
contentResolver: ContentResolver,
|
contentResolver: ContentResolver,
|
||||||
tempFile: File
|
tempFile: File
|
||||||
): Boolean {
|
): Boolean {
|
||||||
|
|
||||||
val decodeBoundsInputStream = try {
|
val decodeBoundsInputStream = try {
|
||||||
contentResolver.openInputStream(uri)
|
contentResolver.openInputStream(uri)
|
||||||
} catch (e: FileNotFoundException) {
|
} catch (e: FileNotFoundException) {
|
||||||
|
|
|
@ -48,11 +48,12 @@ class MediaPreviewAdapter(
|
||||||
val addFocusId = 2
|
val addFocusId = 2
|
||||||
val editImageId = 3
|
val editImageId = 3
|
||||||
val removeId = 4
|
val removeId = 4
|
||||||
if (item.state != ComposeActivity.QueuedMedia.State.PUBLISHED) {
|
|
||||||
// Already-published items can't have their metadata edited
|
popup.menu.add(0, addCaptionId, 0, R.string.action_set_caption)
|
||||||
popup.menu.add(0, addCaptionId, 0, R.string.action_set_caption)
|
if (item.type == ComposeActivity.QueuedMedia.Type.IMAGE) {
|
||||||
if (item.type == ComposeActivity.QueuedMedia.Type.IMAGE) {
|
popup.menu.add(0, addFocusId, 0, R.string.action_set_focus)
|
||||||
popup.menu.add(0, addFocusId, 0, R.string.action_set_focus)
|
if (item.state != ComposeActivity.QueuedMedia.State.PUBLISHED) {
|
||||||
|
// Already-published items can't be edited
|
||||||
popup.menu.add(0, editImageId, 0, R.string.action_edit_image)
|
popup.menu.add(0, editImageId, 0, R.string.action_edit_image)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -89,10 +90,11 @@ class MediaPreviewAdapter(
|
||||||
val imageView = holder.progressImageView
|
val imageView = holder.progressImageView
|
||||||
val focus = item.focus
|
val focus = item.focus
|
||||||
|
|
||||||
if (focus != null)
|
if (focus != null) {
|
||||||
imageView.setFocalPoint(focus)
|
imageView.setFocalPoint(focus)
|
||||||
else
|
} else {
|
||||||
imageView.removeFocalPoint() // Probably unnecessary since we have no UI for removal once added.
|
imageView.removeFocalPoint() // Probably unnecessary since we have no UI for removal once added.
|
||||||
|
}
|
||||||
|
|
||||||
var glide = Glide.with(holder.itemView.context)
|
var glide = Glide.with(holder.itemView.context)
|
||||||
.load(item.uri)
|
.load(item.uri)
|
||||||
|
@ -100,8 +102,9 @@ class MediaPreviewAdapter(
|
||||||
.dontAnimate()
|
.dontAnimate()
|
||||||
.centerInside()
|
.centerInside()
|
||||||
|
|
||||||
if (focus != null)
|
if (focus != null) {
|
||||||
glide = glide.addListener(imageView)
|
glide = glide.addListener(imageView)
|
||||||
|
}
|
||||||
|
|
||||||
glide.into(imageView)
|
glide.into(imageView)
|
||||||
}
|
}
|
||||||
|
|
|
@ -159,7 +159,6 @@ class MediaUploader @Inject constructor(
|
||||||
try {
|
try {
|
||||||
when (inUri.scheme) {
|
when (inUri.scheme) {
|
||||||
ContentResolver.SCHEME_CONTENT -> {
|
ContentResolver.SCHEME_CONTENT -> {
|
||||||
|
|
||||||
mimeType = contentResolver.getType(uri)
|
mimeType = contentResolver.getType(uri)
|
||||||
|
|
||||||
val suffix = "." + MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType ?: "tmp")
|
val suffix = "." + MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType ?: "tmp")
|
||||||
|
@ -278,7 +277,8 @@ class MediaUploader @Inject constructor(
|
||||||
|
|
||||||
var lastProgress = -1
|
var lastProgress = -1
|
||||||
val fileBody = ProgressRequestBody(
|
val fileBody = ProgressRequestBody(
|
||||||
stream!!, media.mediaSize,
|
stream!!,
|
||||||
|
media.mediaSize,
|
||||||
mimeType.toMediaTypeOrNull()!!
|
mimeType.toMediaTypeOrNull()!!
|
||||||
) { percentage ->
|
) { percentage ->
|
||||||
if (percentage != lastProgress) {
|
if (percentage != lastProgress) {
|
||||||
|
|
|
@ -35,7 +35,6 @@ fun showAddPollDialog(
|
||||||
maxDuration: Int,
|
maxDuration: Int,
|
||||||
onUpdatePoll: (NewPoll) -> Unit
|
onUpdatePoll: (NewPoll) -> Unit
|
||||||
) {
|
) {
|
||||||
|
|
||||||
val binding = DialogAddPollBinding.inflate(LayoutInflater.from(context))
|
val binding = DialogAddPollBinding.inflate(LayoutInflater.from(context))
|
||||||
|
|
||||||
val dialog = AlertDialog.Builder(context)
|
val dialog = AlertDialog.Builder(context)
|
||||||
|
@ -63,7 +62,7 @@ fun showAddPollDialog(
|
||||||
var durations = context.resources.getIntArray(R.array.poll_duration_values).toList()
|
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 {
|
binding.pollDurationSpinner.adapter = ArrayAdapter(context, android.R.layout.simple_spinner_item, durationLabels).apply {
|
||||||
setDropDownViewResource(R.layout.support_simple_spinner_dropdown_item)
|
setDropDownViewResource(androidx.appcompat.R.layout.support_simple_spinner_dropdown_item)
|
||||||
}
|
}
|
||||||
durations = durations.filter { it in minDuration..maxDuration }
|
durations = durations.filter { it in minDuration..maxDuration }
|
||||||
|
|
||||||
|
@ -76,8 +75,10 @@ fun showAddPollDialog(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val DAY_SECONDS = 60 * 60 * 24
|
||||||
|
val desiredDuration = poll?.expiresIn ?: DAY_SECONDS
|
||||||
val pollDurationId = durations.indexOfLast {
|
val pollDurationId = durations.indexOfLast {
|
||||||
it <= (poll?.expiresIn ?: 0)
|
it <= desiredDuration
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.pollDurationSpinner.setSelection(pollDurationId)
|
binding.pollDurationSpinner.setSelection(pollDurationId)
|
||||||
|
|
|
@ -19,11 +19,11 @@ import android.text.InputFilter
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import androidx.core.widget.doOnTextChanged
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.keylesspalace.tusky.R
|
import com.keylesspalace.tusky.R
|
||||||
import com.keylesspalace.tusky.databinding.ItemAddPollOptionBinding
|
import com.keylesspalace.tusky.databinding.ItemAddPollOptionBinding
|
||||||
import com.keylesspalace.tusky.util.BindingHolder
|
import com.keylesspalace.tusky.util.BindingHolder
|
||||||
import com.keylesspalace.tusky.util.onTextChanged
|
|
||||||
import com.keylesspalace.tusky.util.visible
|
import com.keylesspalace.tusky.util.visible
|
||||||
|
|
||||||
class AddPollOptionsAdapter(
|
class AddPollOptionsAdapter(
|
||||||
|
@ -46,7 +46,7 @@ class AddPollOptionsAdapter(
|
||||||
val holder = BindingHolder(binding)
|
val holder = BindingHolder(binding)
|
||||||
binding.optionEditText.filters = arrayOf(InputFilter.LengthFilter(maxOptionLength))
|
binding.optionEditText.filters = arrayOf(InputFilter.LengthFilter(maxOptionLength))
|
||||||
|
|
||||||
binding.optionEditText.onTextChanged { s, _, _, _ ->
|
binding.optionEditText.doOnTextChanged { s, _, _, _ ->
|
||||||
val pos = holder.bindingAdapterPosition
|
val pos = holder.bindingAdapterPosition
|
||||||
if (pos != RecyclerView.NO_POSITION) {
|
if (pos != RecyclerView.NO_POSITION) {
|
||||||
options[pos] = s.toString()
|
options[pos] = s.toString()
|
||||||
|
|
|
@ -21,75 +21,55 @@ import android.graphics.drawable.Drawable
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.text.InputFilter
|
import android.text.InputFilter
|
||||||
import android.text.InputType
|
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
import android.widget.EditText
|
import android.widget.EditText
|
||||||
import android.widget.LinearLayout
|
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.core.os.bundleOf
|
import androidx.core.os.bundleOf
|
||||||
import androidx.fragment.app.DialogFragment
|
import androidx.fragment.app.DialogFragment
|
||||||
import at.connyduck.sparkbutton.helpers.Utils
|
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||||
import com.bumptech.glide.request.target.CustomTarget
|
import com.bumptech.glide.request.target.CustomTarget
|
||||||
import com.bumptech.glide.request.transition.Transition
|
import com.bumptech.glide.request.transition.Transition
|
||||||
import com.github.chrisbanes.photoview.PhotoView
|
|
||||||
import com.keylesspalace.tusky.R
|
import com.keylesspalace.tusky.R
|
||||||
|
import com.keylesspalace.tusky.databinding.DialogImageDescriptionBinding
|
||||||
|
|
||||||
// https://github.com/tootsuite/mastodon/blob/c6904c0d3766a2ea8a81ab025c127169ecb51373/app/models/media_attachment.rb#L32
|
// https://github.com/tootsuite/mastodon/blob/c6904c0d3766a2ea8a81ab025c127169ecb51373/app/models/media_attachment.rb#L32
|
||||||
private const val MEDIA_DESCRIPTION_CHARACTER_LIMIT = 1500
|
private const val MEDIA_DESCRIPTION_CHARACTER_LIMIT = 1500
|
||||||
|
|
||||||
class CaptionDialog : DialogFragment() {
|
class CaptionDialog : DialogFragment() {
|
||||||
|
|
||||||
private lateinit var listener: Listener
|
private lateinit var listener: Listener
|
||||||
private lateinit var input: EditText
|
private lateinit var input: EditText
|
||||||
|
|
||||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||||
val context = requireContext()
|
val context = requireContext()
|
||||||
val dialogLayout = LinearLayout(context)
|
|
||||||
val padding = Utils.dpToPx(context, 8)
|
|
||||||
dialogLayout.setPadding(padding, padding, padding, padding)
|
|
||||||
|
|
||||||
dialogLayout.orientation = LinearLayout.VERTICAL
|
val binding = DialogImageDescriptionBinding.inflate(layoutInflater)
|
||||||
val imageView = PhotoView(context).apply {
|
|
||||||
maximumScale = 6f
|
|
||||||
}
|
|
||||||
|
|
||||||
val margin = Utils.dpToPx(context, 4)
|
input = binding.imageDescriptionText
|
||||||
dialogLayout.addView(imageView)
|
val imageView = binding.imageDescriptionView
|
||||||
(imageView.layoutParams as LinearLayout.LayoutParams).weight = 1f
|
imageView.maximumScale = 6f
|
||||||
imageView.layoutParams.height = 0
|
|
||||||
(imageView.layoutParams as LinearLayout.LayoutParams).setMargins(0, margin, 0, 0)
|
|
||||||
|
|
||||||
input = EditText(context)
|
|
||||||
input.hint = resources.getQuantityString(
|
input.hint = resources.getQuantityString(
|
||||||
R.plurals.hint_describe_for_visually_impaired,
|
R.plurals.hint_describe_for_visually_impaired,
|
||||||
MEDIA_DESCRIPTION_CHARACTER_LIMIT, MEDIA_DESCRIPTION_CHARACTER_LIMIT
|
MEDIA_DESCRIPTION_CHARACTER_LIMIT,
|
||||||
|
MEDIA_DESCRIPTION_CHARACTER_LIMIT
|
||||||
)
|
)
|
||||||
dialogLayout.addView(input)
|
|
||||||
(input.layoutParams as LinearLayout.LayoutParams).setMargins(margin, margin, margin, margin)
|
|
||||||
input.setLines(2)
|
|
||||||
input.inputType = (
|
|
||||||
InputType.TYPE_CLASS_TEXT
|
|
||||||
or InputType.TYPE_TEXT_FLAG_MULTI_LINE
|
|
||||||
or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
|
|
||||||
)
|
|
||||||
input.filters = arrayOf(InputFilter.LengthFilter(MEDIA_DESCRIPTION_CHARACTER_LIMIT))
|
input.filters = arrayOf(InputFilter.LengthFilter(MEDIA_DESCRIPTION_CHARACTER_LIMIT))
|
||||||
input.setText(arguments?.getString(EXISTING_DESCRIPTION_ARG))
|
input.setText(arguments?.getString(EXISTING_DESCRIPTION_ARG))
|
||||||
|
|
||||||
val localId = arguments?.getInt(LOCAL_ID_ARG) ?: error("Missing localId")
|
val localId = arguments?.getInt(LOCAL_ID_ARG) ?: error("Missing localId")
|
||||||
val dialog = AlertDialog.Builder(context)
|
val dialog = AlertDialog.Builder(context)
|
||||||
.setView(dialogLayout)
|
.setView(binding.root)
|
||||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||||
listener.onUpdateDescription(localId, input.text.toString())
|
listener.onUpdateDescription(localId, input.text.toString())
|
||||||
}
|
}
|
||||||
.setNegativeButton(android.R.string.cancel, null)
|
.setNegativeButton(android.R.string.cancel, null)
|
||||||
.create()
|
.create()
|
||||||
|
|
||||||
isCancelable = false
|
isCancelable = true
|
||||||
val window = dialog.window
|
val window = dialog.window
|
||||||
window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
|
window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
|
||||||
|
|
||||||
|
@ -105,7 +85,7 @@ class CaptionDialog : DialogFragment() {
|
||||||
|
|
||||||
override fun onResourceReady(
|
override fun onResourceReady(
|
||||||
resource: Drawable,
|
resource: Drawable,
|
||||||
transition: Transition<in Drawable>?,
|
transition: Transition<in Drawable>?
|
||||||
) {
|
) {
|
||||||
imageView.setImageDrawable(resource)
|
imageView.setImageDrawable(resource)
|
||||||
}
|
}
|
||||||
|
@ -122,7 +102,7 @@ class CaptionDialog : DialogFragment() {
|
||||||
override fun onCreateView(
|
override fun onCreateView(
|
||||||
inflater: LayoutInflater,
|
inflater: LayoutInflater,
|
||||||
container: ViewGroup?,
|
container: ViewGroup?,
|
||||||
savedInstanceState: Bundle?,
|
savedInstanceState: Bundle?
|
||||||
): View? {
|
): View? {
|
||||||
savedInstanceState?.getString(DESCRIPTION_KEY)?.let {
|
savedInstanceState?.getString(DESCRIPTION_KEY)?.let {
|
||||||
input.setText(it)
|
input.setText(it)
|
||||||
|
@ -143,12 +123,12 @@ class CaptionDialog : DialogFragment() {
|
||||||
fun newInstance(
|
fun newInstance(
|
||||||
localId: Int,
|
localId: Int,
|
||||||
existingDescription: String?,
|
existingDescription: String?,
|
||||||
previewUri: Uri,
|
previewUri: Uri
|
||||||
) = CaptionDialog().apply {
|
) = CaptionDialog().apply {
|
||||||
arguments = bundleOf(
|
arguments = bundleOf(
|
||||||
LOCAL_ID_ARG to localId,
|
LOCAL_ID_ARG to localId,
|
||||||
EXISTING_DESCRIPTION_ARG to existingDescription,
|
EXISTING_DESCRIPTION_ARG to existingDescription,
|
||||||
PREVIEW_URI_ARG to previewUri,
|
PREVIEW_URI_ARG to previewUri
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -21,7 +21,6 @@ import android.graphics.drawable.Drawable
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
@ -31,7 +30,6 @@ import com.bumptech.glide.load.engine.GlideException
|
||||||
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||||
import com.bumptech.glide.request.RequestListener
|
import com.bumptech.glide.request.RequestListener
|
||||||
import com.bumptech.glide.request.target.Target
|
import com.bumptech.glide.request.target.Target
|
||||||
import com.keylesspalace.tusky.R
|
|
||||||
import com.keylesspalace.tusky.databinding.DialogFocusBinding
|
import com.keylesspalace.tusky.databinding.DialogFocusBinding
|
||||||
import com.keylesspalace.tusky.entity.Attachment.Focus
|
import com.keylesspalace.tusky.entity.Attachment.Focus
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
@ -39,7 +37,7 @@ import kotlinx.coroutines.launch
|
||||||
fun <T> T.makeFocusDialog(
|
fun <T> T.makeFocusDialog(
|
||||||
existingFocus: Focus?,
|
existingFocus: Focus?,
|
||||||
previewUri: Uri,
|
previewUri: Uri,
|
||||||
onUpdateFocus: suspend (Focus) -> Boolean
|
onUpdateFocus: suspend (Focus) -> Unit
|
||||||
) where T : Activity, T : LifecycleOwner {
|
) where T : Activity, T : LifecycleOwner {
|
||||||
val focus = existingFocus ?: Focus(0.0f, 0.0f) // Default to center
|
val focus = existingFocus ?: Focus(0.0f, 0.0f) // Default to center
|
||||||
|
|
||||||
|
@ -79,9 +77,7 @@ fun <T> T.makeFocusDialog(
|
||||||
|
|
||||||
val okListener = { dialog: DialogInterface, _: Int ->
|
val okListener = { dialog: DialogInterface, _: Int ->
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
if (!onUpdateFocus(dialogBinding.focusIndicator.getFocus())) {
|
onUpdateFocus(dialogBinding.focusIndicator.getFocus())
|
||||||
showFailedFocusMessage()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
dialog.dismiss()
|
dialog.dismiss()
|
||||||
}
|
}
|
||||||
|
@ -99,7 +95,3 @@ fun <T> T.makeFocusDialog(
|
||||||
|
|
||||||
dialog.show()
|
dialog.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Activity.showFailedFocusMessage() {
|
|
||||||
Toast.makeText(this, R.string.error_failed_set_focus, Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
|
|
|
@ -27,14 +27,16 @@ class FocusIndicatorView
|
||||||
|
|
||||||
fun setImageSize(width: Int, height: Int) {
|
fun setImageSize(width: Int, height: Int) {
|
||||||
this.imageSize = Point(width, height)
|
this.imageSize = Point(width, height)
|
||||||
if (focus != null)
|
if (focus != null) {
|
||||||
invalidate()
|
invalidate()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setFocus(focus: Attachment.Focus) {
|
fun setFocus(focus: Attachment.Focus) {
|
||||||
this.focus = focus
|
this.focus = focus
|
||||||
if (imageSize != null)
|
if (imageSize != null) {
|
||||||
invalidate()
|
invalidate()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Assumes setFocus called first
|
// Assumes setFocus called first
|
||||||
|
@ -46,8 +48,9 @@ class FocusIndicatorView
|
||||||
// so base it on the view width/height whenever the first access occurs.
|
// so base it on the view width/height whenever the first access occurs.
|
||||||
private fun getCircleRadius(): Float {
|
private fun getCircleRadius(): Float {
|
||||||
val circleRadius = this.circleRadius
|
val circleRadius = this.circleRadius
|
||||||
if (circleRadius != null)
|
if (circleRadius != null) {
|
||||||
return circleRadius
|
return circleRadius
|
||||||
|
}
|
||||||
val newCircleRadius = min(this.width, this.height).toFloat() / 4.0f
|
val newCircleRadius = min(this.width, this.height).toFloat() / 4.0f
|
||||||
this.circleRadius = newCircleRadius
|
this.circleRadius = newCircleRadius
|
||||||
return newCircleRadius
|
return newCircleRadius
|
||||||
|
@ -67,12 +70,11 @@ class FocusIndicatorView
|
||||||
|
|
||||||
@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 {
|
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||||
if (event.actionMasked == MotionEvent.ACTION_CANCEL)
|
if (event.actionMasked == MotionEvent.ACTION_CANCEL) {
|
||||||
return false
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
val imageSize = this.imageSize
|
val imageSize = this.imageSize ?: return false
|
||||||
if (imageSize == null)
|
|
||||||
return false
|
|
||||||
|
|
||||||
// Convert touch xy to point inside image
|
// Convert touch xy to point inside image
|
||||||
focus = Attachment.Focus(axisToFocus(event.x, imageSize.x, this.width), -axisToFocus(event.y, imageSize.y, this.height))
|
focus = Attachment.Focus(axisToFocus(event.x, imageSize.x, this.width), -axisToFocus(event.y, imageSize.y, this.height))
|
||||||
|
|
|
@ -48,7 +48,6 @@ class TootButton
|
||||||
|
|
||||||
fun setStatusVisibility(visibility: Status.Visibility) {
|
fun setStatusVisibility(visibility: Status.Visibility) {
|
||||||
if (!smallStyle) {
|
if (!smallStyle) {
|
||||||
|
|
||||||
icon = when (visibility) {
|
icon = when (visibility) {
|
||||||
Status.Visibility.PUBLIC -> {
|
Status.Visibility.PUBLIC -> {
|
||||||
setText(R.string.action_send_public)
|
setText(R.string.action_send_public)
|
||||||
|
|
|
@ -64,9 +64,10 @@ data class ConversationAccountEntity(
|
||||||
localUsername = localUsername,
|
localUsername = localUsername,
|
||||||
username = username,
|
username = username,
|
||||||
displayName = displayName,
|
displayName = displayName,
|
||||||
|
note = "",
|
||||||
url = "",
|
url = "",
|
||||||
avatar = avatar,
|
avatar = avatar,
|
||||||
emojis = emojis,
|
emojis = emojis
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -96,7 +97,7 @@ data class ConversationStatusEntity(
|
||||||
val collapsed: Boolean,
|
val collapsed: Boolean,
|
||||||
val muted: Boolean,
|
val muted: Boolean,
|
||||||
val poll: Poll?,
|
val poll: Poll?,
|
||||||
val language: String?,
|
val language: String?
|
||||||
) {
|
) {
|
||||||
|
|
||||||
fun toViewData(): StatusViewData.Concrete {
|
fun toViewData(): StatusViewData.Concrete {
|
||||||
|
@ -130,6 +131,7 @@ data class ConversationStatusEntity(
|
||||||
poll = poll,
|
poll = poll,
|
||||||
card = null,
|
card = null,
|
||||||
language = language,
|
language = language,
|
||||||
|
filtered = null,
|
||||||
quote = null,
|
quote = null,
|
||||||
),
|
),
|
||||||
isExpanded = expanded,
|
isExpanded = expanded,
|
||||||
|
@ -146,7 +148,7 @@ fun TimelineAccount.toEntity() =
|
||||||
username = username,
|
username = username,
|
||||||
displayName = name,
|
displayName = name,
|
||||||
avatar = avatar,
|
avatar = avatar,
|
||||||
emojis = emojis ?: emptyList()
|
emojis = emojis.orEmpty()
|
||||||
)
|
)
|
||||||
|
|
||||||
fun Status.toEntity(
|
fun Status.toEntity(
|
||||||
|
@ -178,7 +180,7 @@ fun Status.toEntity(
|
||||||
collapsed = contentCollapsed,
|
collapsed = contentCollapsed,
|
||||||
muted = muted ?: false,
|
muted = muted ?: false,
|
||||||
poll = poll,
|
poll = poll,
|
||||||
language = language,
|
language = language
|
||||||
)
|
)
|
||||||
|
|
||||||
fun Conversation.toEntity(
|
fun Conversation.toEntity(
|
||||||
|
|
|
@ -87,6 +87,6 @@ fun StatusViewData.Concrete.toConversationStatusEntity(
|
||||||
collapsed = collapsed,
|
collapsed = collapsed,
|
||||||
muted = muted,
|
muted = muted,
|
||||||
poll = poll,
|
poll = poll,
|
||||||
language = status.language,
|
language = status.language
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -36,7 +36,6 @@ import com.keylesspalace.tusky.interfaces.StatusActionListener;
|
||||||
import com.keylesspalace.tusky.util.ImageLoadingHelper;
|
import com.keylesspalace.tusky.util.ImageLoadingHelper;
|
||||||
import com.keylesspalace.tusky.util.SmartLengthInputFilter;
|
import com.keylesspalace.tusky.util.SmartLengthInputFilter;
|
||||||
import com.keylesspalace.tusky.util.StatusDisplayOptions;
|
import com.keylesspalace.tusky.util.StatusDisplayOptions;
|
||||||
import com.keylesspalace.tusky.viewdata.PollViewDataKt;
|
|
||||||
import com.keylesspalace.tusky.viewdata.StatusViewData;
|
import com.keylesspalace.tusky.viewdata.StatusViewData;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
@ -110,9 +109,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
|
||||||
setupButtons(listener, account.getId(), statusViewData.getContent().toString(),
|
setupButtons(listener, account.getId(), statusViewData.getContent().toString(),
|
||||||
false, statusDisplayOptions);
|
false, statusDisplayOptions);
|
||||||
|
|
||||||
setSpoilerAndContent(statusViewData.isExpanded(), statusViewData.getContent(), status.getSpoilerText(),
|
setSpoilerAndContent(statusViewData, statusDisplayOptions, listener);
|
||||||
status.getMentions(), status.getTags(), status.getEmojis(),
|
|
||||||
PollViewDataKt.toViewData(status.getPoll()), statusDisplayOptions, listener);
|
|
||||||
|
|
||||||
setConversationName(conversation.getAccounts());
|
setConversationName(conversation.getAccounts());
|
||||||
|
|
||||||
|
|
|
@ -17,13 +17,18 @@ package com.keylesspalace.tusky.components.conversation
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
|
import android.view.Menu
|
||||||
|
import android.view.MenuInflater
|
||||||
|
import android.view.MenuItem
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.appcompat.widget.PopupMenu
|
import androidx.appcompat.widget.PopupMenu
|
||||||
|
import androidx.core.view.MenuProvider
|
||||||
import androidx.fragment.app.viewModels
|
import androidx.fragment.app.viewModels
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
import androidx.paging.LoadState
|
import androidx.paging.LoadState
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import androidx.recyclerview.widget.DividerItemDecoration
|
import androidx.recyclerview.widget.DividerItemDecoration
|
||||||
|
@ -31,7 +36,7 @@ import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import androidx.recyclerview.widget.SimpleItemAnimator
|
import androidx.recyclerview.widget.SimpleItemAnimator
|
||||||
import at.connyduck.sparkbutton.helpers.Utils
|
import at.connyduck.sparkbutton.helpers.Utils
|
||||||
import autodispose2.androidx.lifecycle.autoDispose
|
import com.google.android.material.color.MaterialColors
|
||||||
import com.keylesspalace.tusky.R
|
import com.keylesspalace.tusky.R
|
||||||
import com.keylesspalace.tusky.StatusListActivity
|
import com.keylesspalace.tusky.StatusListActivity
|
||||||
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
|
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
|
||||||
|
@ -53,7 +58,10 @@ import com.keylesspalace.tusky.util.hide
|
||||||
import com.keylesspalace.tusky.util.show
|
import com.keylesspalace.tusky.util.show
|
||||||
import com.keylesspalace.tusky.util.viewBinding
|
import com.keylesspalace.tusky.util.viewBinding
|
||||||
import com.keylesspalace.tusky.viewdata.AttachmentViewData
|
import com.keylesspalace.tusky.viewdata.AttachmentViewData
|
||||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
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.delay
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
@ -62,7 +70,12 @@ import javax.inject.Inject
|
||||||
import kotlin.time.DurationUnit
|
import kotlin.time.DurationUnit
|
||||||
import kotlin.time.toDuration
|
import kotlin.time.toDuration
|
||||||
|
|
||||||
class ConversationsFragment : SFragment(), StatusActionListener, Injectable, ReselectableFragment {
|
class ConversationsFragment :
|
||||||
|
SFragment(),
|
||||||
|
StatusActionListener,
|
||||||
|
Injectable,
|
||||||
|
ReselectableFragment,
|
||||||
|
MenuProvider {
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var viewModelFactory: ViewModelFactory
|
lateinit var viewModelFactory: ViewModelFactory
|
||||||
|
@ -83,6 +96,8 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
|
||||||
|
|
||||||
val preferences = PreferenceManager.getDefaultSharedPreferences(view.context)
|
val preferences = PreferenceManager.getDefaultSharedPreferences(view.context)
|
||||||
|
|
||||||
val statusDisplayOptions = StatusDisplayOptions(
|
val statusDisplayOptions = StatusDisplayOptions(
|
||||||
|
@ -96,7 +111,10 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
|
||||||
confirmFavourites = preferences.getBoolean("confirmFavourites", false),
|
confirmFavourites = preferences.getBoolean("confirmFavourites", false),
|
||||||
hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false),
|
hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false),
|
||||||
animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false),
|
animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false),
|
||||||
quoteEnabled = accountManager.activeAccount?.domain in CAN_USE_QUOTE_ID,
|
showStatsInline = preferences.getBoolean(PrefKeys.SHOW_STATS_INLINE, false),
|
||||||
|
showSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia,
|
||||||
|
openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler,
|
||||||
|
quoteEnabled = accountManager.activeAccount?.domain in CAN_USE_QUOTE_ID,
|
||||||
)
|
)
|
||||||
|
|
||||||
adapter = ConversationAdapter(statusDisplayOptions, this)
|
adapter = ConversationAdapter(statusDisplayOptions, this)
|
||||||
|
@ -125,9 +143,13 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
|
||||||
binding.statusView.show()
|
binding.statusView.show()
|
||||||
|
|
||||||
if ((loadState.refresh as LoadState.Error).error is IOException) {
|
if ((loadState.refresh as LoadState.Error).error is IOException) {
|
||||||
binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network, null)
|
binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) {
|
||||||
|
refreshContent()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic, null)
|
binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic) {
|
||||||
|
refreshContent()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is LoadState.Loading -> {
|
is LoadState.Loading -> {
|
||||||
|
@ -173,22 +195,48 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
lifecycleScope.launchWhenResumed {
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
val useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false)
|
repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||||
while (!useAbsoluteTime) {
|
val useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false)
|
||||||
adapter.notifyItemRangeChanged(0, adapter.itemCount, listOf(StatusBaseViewHolder.Key.KEY_CREATED))
|
while (!useAbsoluteTime) {
|
||||||
delay(1.toDuration(DurationUnit.MINUTES))
|
adapter.notifyItemRangeChanged(
|
||||||
|
0,
|
||||||
|
adapter.itemCount,
|
||||||
|
listOf(StatusBaseViewHolder.Key.KEY_CREATED)
|
||||||
|
)
|
||||||
|
delay(1.toDuration(DurationUnit.MINUTES))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
eventHub.events
|
lifecycleScope.launch {
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
eventHub.events.collect { event ->
|
||||||
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
|
|
||||||
.subscribe { event ->
|
|
||||||
if (event is PreferenceChangedEvent) {
|
if (event is PreferenceChangedEvent) {
|
||||||
onPreferenceChanged(event.preferenceKey)
|
onPreferenceChanged(event.preferenceKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||||
|
menuInflater.inflate(R.menu.fragment_conversations, menu)
|
||||||
|
menu.findItem(R.id.action_refresh)?.apply {
|
||||||
|
icon = IconicsDrawable(requireContext(), GoogleMaterial.Icon.gmd_refresh).apply {
|
||||||
|
sizeDp = 20
|
||||||
|
colorInt = MaterialColors.getColor(binding.root, android.R.attr.textColorPrimary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
|
||||||
|
return when (menuItem.itemId) {
|
||||||
|
R.id.action_refresh -> {
|
||||||
|
binding.swipeRefreshLayout.isRefreshing = true
|
||||||
|
refreshContent()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupRecyclerView() {
|
private fun setupRecyclerView() {
|
||||||
|
@ -202,10 +250,12 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
|
||||||
binding.recyclerView.adapter = adapter.withLoadStateFooter(ConversationLoadStateAdapter(adapter::retry))
|
binding.recyclerView.adapter = adapter.withLoadStateFooter(ConversationLoadStateAdapter(adapter::retry))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun refreshContent() {
|
||||||
|
adapter.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
private fun initSwipeToRefresh() {
|
private fun initSwipeToRefresh() {
|
||||||
binding.swipeRefreshLayout.setOnRefreshListener {
|
binding.swipeRefreshLayout.setOnRefreshListener { refreshContent() }
|
||||||
adapter.refresh()
|
|
||||||
}
|
|
||||||
binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
|
binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -317,6 +367,9 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun clearWarningAction(position: Int) {
|
||||||
|
}
|
||||||
|
|
||||||
override fun onReselect() {
|
override fun onReselect() {
|
||||||
if (isAdded) {
|
if (isAdded) {
|
||||||
binding.recyclerView.layoutManager?.scrollToPosition(0)
|
binding.recyclerView.layoutManager?.scrollToPosition(0)
|
||||||
|
|
|
@ -15,7 +15,7 @@ import retrofit2.HttpException
|
||||||
class ConversationsRemoteMediator(
|
class ConversationsRemoteMediator(
|
||||||
private val api: MastodonApi,
|
private val api: MastodonApi,
|
||||||
private val db: AppDatabase,
|
private val db: AppDatabase,
|
||||||
accountManager: AccountManager,
|
accountManager: AccountManager
|
||||||
) : RemoteMediator<Int, ConversationEntity>() {
|
) : RemoteMediator<Int, ConversationEntity>() {
|
||||||
|
|
||||||
private var nextKey: String? = null
|
private var nextKey: String? = null
|
||||||
|
@ -28,7 +28,6 @@ class ConversationsRemoteMediator(
|
||||||
loadType: LoadType,
|
loadType: LoadType,
|
||||||
state: PagingState<Int, ConversationEntity>
|
state: PagingState<Int, ConversationEntity>
|
||||||
): MediatorResult {
|
): MediatorResult {
|
||||||
|
|
||||||
if (loadType == LoadType.PREPEND) {
|
if (loadType == LoadType.PREPEND) {
|
||||||
return MediatorResult.Success(endOfPaginationReached = true)
|
return MediatorResult.Success(endOfPaginationReached = true)
|
||||||
}
|
}
|
||||||
|
@ -47,7 +46,6 @@ class ConversationsRemoteMediator(
|
||||||
}
|
}
|
||||||
|
|
||||||
db.withTransaction {
|
db.withTransaction {
|
||||||
|
|
||||||
if (loadType == LoadType.REFRESH) {
|
if (loadType == LoadType.REFRESH) {
|
||||||
db.conversationDao().deleteForAccount(activeAccount.id)
|
db.conversationDao().deleteForAccount(activeAccount.id)
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,6 +23,7 @@ import androidx.paging.Pager
|
||||||
import androidx.paging.PagingConfig
|
import androidx.paging.PagingConfig
|
||||||
import androidx.paging.cachedIn
|
import androidx.paging.cachedIn
|
||||||
import androidx.paging.map
|
import androidx.paging.map
|
||||||
|
import at.connyduck.calladapter.networkresult.fold
|
||||||
import com.keylesspalace.tusky.db.AccountManager
|
import com.keylesspalace.tusky.db.AccountManager
|
||||||
import com.keylesspalace.tusky.db.AppDatabase
|
import com.keylesspalace.tusky.db.AppDatabase
|
||||||
import com.keylesspalace.tusky.network.MastodonApi
|
import com.keylesspalace.tusky.network.MastodonApi
|
||||||
|
@ -30,7 +31,6 @@ import com.keylesspalace.tusky.usecase.TimelineCases
|
||||||
import com.keylesspalace.tusky.util.EmptyPagingSource
|
import com.keylesspalace.tusky.util.EmptyPagingSource
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.rx3.await
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class ConversationsViewModel @Inject constructor(
|
class ConversationsViewModel @Inject constructor(
|
||||||
|
@ -61,51 +61,47 @@ class ConversationsViewModel @Inject constructor(
|
||||||
|
|
||||||
fun favourite(favourite: Boolean, conversation: ConversationViewData) {
|
fun favourite(favourite: Boolean, conversation: ConversationViewData) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
timelineCases.favourite(conversation.lastStatus.id, favourite).fold({
|
||||||
timelineCases.favourite(conversation.lastStatus.id, favourite).await()
|
|
||||||
|
|
||||||
val newConversation = conversation.toEntity(
|
val newConversation = conversation.toEntity(
|
||||||
accountId = accountManager.activeAccount!!.id,
|
accountId = accountManager.activeAccount!!.id,
|
||||||
favourited = favourite
|
favourited = favourite
|
||||||
)
|
)
|
||||||
|
|
||||||
saveConversationToDb(newConversation)
|
saveConversationToDb(newConversation)
|
||||||
} catch (e: Exception) {
|
}, { e ->
|
||||||
Log.w(TAG, "failed to favourite status", e)
|
Log.w(TAG, "failed to favourite status", e)
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun bookmark(bookmark: Boolean, conversation: ConversationViewData) {
|
fun bookmark(bookmark: Boolean, conversation: ConversationViewData) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
timelineCases.bookmark(conversation.lastStatus.id, bookmark).fold({
|
||||||
timelineCases.bookmark(conversation.lastStatus.id, bookmark).await()
|
|
||||||
|
|
||||||
val newConversation = conversation.toEntity(
|
val newConversation = conversation.toEntity(
|
||||||
accountId = accountManager.activeAccount!!.id,
|
accountId = accountManager.activeAccount!!.id,
|
||||||
bookmarked = bookmark
|
bookmarked = bookmark
|
||||||
)
|
)
|
||||||
|
|
||||||
saveConversationToDb(newConversation)
|
saveConversationToDb(newConversation)
|
||||||
} catch (e: Exception) {
|
}, { e ->
|
||||||
Log.w(TAG, "failed to bookmark status", e)
|
Log.w(TAG, "failed to bookmark status", e)
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun voteInPoll(choices: List<Int>, conversation: ConversationViewData) {
|
fun voteInPoll(choices: List<Int>, conversation: ConversationViewData) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
timelineCases.voteInPoll(conversation.lastStatus.id, conversation.lastStatus.status.poll?.id!!, choices)
|
||||||
val poll = timelineCases.voteInPoll(conversation.lastStatus.id, conversation.lastStatus.status.poll?.id!!, choices).await()
|
.fold({ poll ->
|
||||||
val newConversation = conversation.toEntity(
|
val newConversation = conversation.toEntity(
|
||||||
accountId = accountManager.activeAccount!!.id,
|
accountId = accountManager.activeAccount!!.id,
|
||||||
poll = poll
|
poll = poll
|
||||||
)
|
)
|
||||||
|
|
||||||
saveConversationToDb(newConversation)
|
saveConversationToDb(newConversation)
|
||||||
} catch (e: Exception) {
|
}, { e ->
|
||||||
Log.w(TAG, "failed to vote in poll", e)
|
Log.w(TAG, "failed to vote in poll", e)
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -160,7 +156,7 @@ class ConversationsViewModel @Inject constructor(
|
||||||
timelineCases.muteConversation(
|
timelineCases.muteConversation(
|
||||||
conversation.lastStatus.id,
|
conversation.lastStatus.id,
|
||||||
!(conversation.lastStatus.status.muted ?: false)
|
!(conversation.lastStatus.status.muted ?: false)
|
||||||
).await()
|
)
|
||||||
|
|
||||||
val newConversation = conversation.toEntity(
|
val newConversation = conversation.toEntity(
|
||||||
accountId = accountManager.activeAccount!!.id,
|
accountId = accountManager.activeAccount!!.id,
|
||||||
|
|
|
@ -44,7 +44,7 @@ import javax.inject.Inject
|
||||||
|
|
||||||
class DraftHelper @Inject constructor(
|
class DraftHelper @Inject constructor(
|
||||||
val context: Context,
|
val context: Context,
|
||||||
val okHttpClient: OkHttpClient,
|
private val okHttpClient: OkHttpClient,
|
||||||
db: AppDatabase
|
db: AppDatabase
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
@ -66,7 +66,7 @@ class DraftHelper @Inject constructor(
|
||||||
failedToSendAlert: Boolean,
|
failedToSendAlert: Boolean,
|
||||||
scheduledAt: String?,
|
scheduledAt: String?,
|
||||||
language: String?,
|
language: String?,
|
||||||
statusId: String?,
|
statusId: String?
|
||||||
) = withContext(Dispatchers.IO) {
|
) = withContext(Dispatchers.IO) {
|
||||||
val externalFilesDir = context.getExternalFilesDir("Tusky")
|
val externalFilesDir = context.getExternalFilesDir("Tusky")
|
||||||
|
|
||||||
|
@ -127,7 +127,7 @@ class DraftHelper @Inject constructor(
|
||||||
failedToSendNew = failedToSendAlert,
|
failedToSendNew = failedToSendAlert,
|
||||||
scheduledAt = scheduledAt,
|
scheduledAt = scheduledAt,
|
||||||
language = language,
|
language = language,
|
||||||
statusId = statusId,
|
statusId = statusId
|
||||||
)
|
)
|
||||||
|
|
||||||
draftDao.insertOrReplace(draft)
|
draftDao.insertOrReplace(draft)
|
||||||
|
@ -140,7 +140,7 @@ class DraftHelper @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun deleteDraftAndAttachments(draft: DraftEntity) {
|
private suspend fun deleteDraftAndAttachments(draft: DraftEntity) {
|
||||||
deleteAttachments(draft)
|
deleteAttachments(draft)
|
||||||
draftDao.delete(draft.id)
|
draftDao.delete(draft.id)
|
||||||
}
|
}
|
||||||
|
|
|
@ -51,18 +51,20 @@ class DraftMediaAdapter(
|
||||||
holder.imageView.clearFocus()
|
holder.imageView.clearFocus()
|
||||||
holder.imageView.setImageResource(R.drawable.ic_music_box_preview_24dp)
|
holder.imageView.setImageResource(R.drawable.ic_music_box_preview_24dp)
|
||||||
} else {
|
} else {
|
||||||
if (attachment.focus != null)
|
if (attachment.focus != null) {
|
||||||
holder.imageView.setFocalPoint(attachment.focus)
|
holder.imageView.setFocalPoint(attachment.focus)
|
||||||
else
|
} else {
|
||||||
holder.imageView.clearFocus()
|
holder.imageView.clearFocus()
|
||||||
|
}
|
||||||
var glide = Glide.with(holder.itemView.context)
|
var glide = Glide.with(holder.itemView.context)
|
||||||
.load(attachment.uri)
|
.load(attachment.uri)
|
||||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||||
.dontAnimate()
|
.dontAnimate()
|
||||||
.centerInside()
|
.centerInside()
|
||||||
|
|
||||||
if (attachment.focus != null)
|
if (attachment.focus != null) {
|
||||||
glide = glide.addListener(holder.imageView)
|
glide = glide.addListener(holder.imageView)
|
||||||
|
}
|
||||||
|
|
||||||
glide.into(holder.imageView)
|
glide.into(holder.imageView)
|
||||||
}
|
}
|
||||||
|
|
|
@ -48,7 +48,6 @@ class DraftsAdapter(
|
||||||
) {
|
) {
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemDraftBinding> {
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemDraftBinding> {
|
||||||
|
|
||||||
val binding = ItemDraftBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
val binding = ItemDraftBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||||
|
|
||||||
val viewHolder = BindingHolder(binding)
|
val viewHolder = BindingHolder(binding)
|
||||||
|
|
|
@ -33,7 +33,7 @@ class DraftsViewModel @Inject constructor(
|
||||||
val database: AppDatabase,
|
val database: AppDatabase,
|
||||||
val accountManager: AccountManager,
|
val accountManager: AccountManager,
|
||||||
val api: MastodonApi,
|
val api: MastodonApi,
|
||||||
val draftHelper: DraftHelper
|
private val draftHelper: DraftHelper
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
val drafts = Pager(
|
val drafts = Pager(
|
||||||
|
|
|
@ -0,0 +1,274 @@
|
||||||
|
package com.keylesspalace.tusky.components.filters
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.AdapterView
|
||||||
|
import android.widget.ArrayAdapter
|
||||||
|
import androidx.activity.viewModels
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.core.view.size
|
||||||
|
import androidx.core.widget.doAfterTextChanged
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import com.google.android.material.chip.Chip
|
||||||
|
import com.google.android.material.snackbar.Snackbar
|
||||||
|
import com.google.android.material.switchmaterial.SwitchMaterial
|
||||||
|
import com.keylesspalace.tusky.BaseActivity
|
||||||
|
import com.keylesspalace.tusky.R
|
||||||
|
import com.keylesspalace.tusky.appstore.EventHub
|
||||||
|
import com.keylesspalace.tusky.databinding.ActivityEditFilterBinding
|
||||||
|
import com.keylesspalace.tusky.databinding.DialogFilterBinding
|
||||||
|
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||||
|
import com.keylesspalace.tusky.entity.Filter
|
||||||
|
import com.keylesspalace.tusky.entity.FilterKeyword
|
||||||
|
import com.keylesspalace.tusky.network.MastodonApi
|
||||||
|
import com.keylesspalace.tusky.util.viewBinding
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.util.Date
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class EditFilterActivity : BaseActivity() {
|
||||||
|
@Inject
|
||||||
|
lateinit var api: MastodonApi
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var eventHub: EventHub
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var viewModelFactory: ViewModelFactory
|
||||||
|
|
||||||
|
private val binding by viewBinding(ActivityEditFilterBinding::inflate)
|
||||||
|
private val viewModel: EditFilterViewModel by viewModels { viewModelFactory }
|
||||||
|
|
||||||
|
private lateinit var filter: Filter
|
||||||
|
private var originalFilter: Filter? = null
|
||||||
|
private lateinit var contextSwitches: Map<SwitchMaterial, Filter.Kind>
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
originalFilter = intent?.getParcelableExtra(FILTER_TO_EDIT)
|
||||||
|
filter = originalFilter ?: Filter("", "", listOf(), null, Filter.Action.WARN.action, listOf())
|
||||||
|
binding.apply {
|
||||||
|
contextSwitches = mapOf(
|
||||||
|
filterContextHome to Filter.Kind.HOME,
|
||||||
|
filterContextNotifications to Filter.Kind.NOTIFICATIONS,
|
||||||
|
filterContextPublic to Filter.Kind.PUBLIC,
|
||||||
|
filterContextThread to Filter.Kind.THREAD,
|
||||||
|
filterContextAccount to Filter.Kind.ACCOUNT
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
setContentView(binding.root)
|
||||||
|
setSupportActionBar(binding.includedToolbar.toolbar)
|
||||||
|
supportActionBar?.run {
|
||||||
|
// Back button
|
||||||
|
setDisplayHomeAsUpEnabled(true)
|
||||||
|
setDisplayShowHomeEnabled(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
setTitle(
|
||||||
|
if (originalFilter == null) {
|
||||||
|
R.string.filter_addition_title
|
||||||
|
} else {
|
||||||
|
R.string.filter_edit_title
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
binding.actionChip.setOnClickListener { showAddKeywordDialog() }
|
||||||
|
binding.filterSaveButton.setOnClickListener { saveChanges() }
|
||||||
|
for (switch in contextSwitches.keys) {
|
||||||
|
switch.setOnCheckedChangeListener { _, isChecked ->
|
||||||
|
val context = contextSwitches[switch]!!
|
||||||
|
if (isChecked) {
|
||||||
|
viewModel.addContext(context)
|
||||||
|
} else {
|
||||||
|
viewModel.removeContext(context)
|
||||||
|
}
|
||||||
|
validateSaveButton()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
binding.filterTitle.doAfterTextChanged { editable ->
|
||||||
|
viewModel.setTitle(editable.toString())
|
||||||
|
validateSaveButton()
|
||||||
|
}
|
||||||
|
binding.filterActionWarn.setOnCheckedChangeListener { _, checked ->
|
||||||
|
viewModel.setAction(
|
||||||
|
if (checked) {
|
||||||
|
Filter.Action.WARN
|
||||||
|
} else {
|
||||||
|
Filter.Action.HIDE
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
binding.filterDurationSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
|
||||||
|
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
|
||||||
|
viewModel.setDuration(
|
||||||
|
if (originalFilter?.expiresAt == null) {
|
||||||
|
position
|
||||||
|
} else {
|
||||||
|
position - 1
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNothingSelected(parent: AdapterView<*>?) {
|
||||||
|
viewModel.setDuration(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
validateSaveButton()
|
||||||
|
|
||||||
|
if (originalFilter == null) {
|
||||||
|
binding.filterActionWarn.isChecked = true
|
||||||
|
} else {
|
||||||
|
loadFilter()
|
||||||
|
}
|
||||||
|
observeModel()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeModel() {
|
||||||
|
lifecycleScope.launch {
|
||||||
|
viewModel.title.collect { title ->
|
||||||
|
if (title != binding.filterTitle.text.toString()) {
|
||||||
|
// We also get this callback when typing in the field,
|
||||||
|
// which messes with the cursor focus
|
||||||
|
binding.filterTitle.setText(title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lifecycleScope.launch {
|
||||||
|
viewModel.keywords.collect { keywords ->
|
||||||
|
updateKeywords(keywords)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lifecycleScope.launch {
|
||||||
|
viewModel.contexts.collect { contexts ->
|
||||||
|
for (entry in contextSwitches) {
|
||||||
|
entry.key.isChecked = contexts.contains(entry.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lifecycleScope.launch {
|
||||||
|
viewModel.action.collect { action ->
|
||||||
|
when (action) {
|
||||||
|
Filter.Action.HIDE -> binding.filterActionHide.isChecked = true
|
||||||
|
else -> binding.filterActionWarn.isChecked = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate the UI from the filter's members
|
||||||
|
private fun loadFilter() {
|
||||||
|
viewModel.load(filter)
|
||||||
|
if (filter.expiresAt != null) {
|
||||||
|
val durationNames = listOf(getString(R.string.duration_no_change)) + resources.getStringArray(R.array.filter_duration_names)
|
||||||
|
binding.filterDurationSpinner.adapter = ArrayAdapter(this, android.R.layout.simple_list_item_1, durationNames)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateKeywords(newKeywords: List<FilterKeyword>) {
|
||||||
|
newKeywords.forEachIndexed { index, filterKeyword ->
|
||||||
|
val chip = binding.keywordChips.getChildAt(index).takeUnless {
|
||||||
|
it.id == R.id.actionChip
|
||||||
|
} as Chip? ?: Chip(this).apply {
|
||||||
|
setCloseIconResource(R.drawable.ic_cancel_24dp)
|
||||||
|
isCheckable = false
|
||||||
|
binding.keywordChips.addView(this, binding.keywordChips.size - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
chip.text = if (filterKeyword.wholeWord) {
|
||||||
|
binding.root.context.getString(
|
||||||
|
R.string.filter_keyword_display_format,
|
||||||
|
filterKeyword.keyword
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
filterKeyword.keyword
|
||||||
|
}
|
||||||
|
chip.isCloseIconVisible = true
|
||||||
|
chip.setOnClickListener {
|
||||||
|
showEditKeywordDialog(newKeywords[index])
|
||||||
|
}
|
||||||
|
chip.setOnCloseIconClickListener {
|
||||||
|
viewModel.deleteKeyword(newKeywords[index])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (binding.keywordChips.size - 1 > newKeywords.size) {
|
||||||
|
binding.keywordChips.removeViewAt(newKeywords.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
filter = filter.copy(keywords = newKeywords)
|
||||||
|
validateSaveButton()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showAddKeywordDialog() {
|
||||||
|
val binding = DialogFilterBinding.inflate(layoutInflater)
|
||||||
|
binding.phraseWholeWord.isChecked = true
|
||||||
|
AlertDialog.Builder(this)
|
||||||
|
.setTitle(R.string.filter_keyword_addition_title)
|
||||||
|
.setView(binding.root)
|
||||||
|
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||||
|
viewModel.addKeyword(
|
||||||
|
FilterKeyword(
|
||||||
|
"",
|
||||||
|
binding.phraseEditText.text.toString(),
|
||||||
|
binding.phraseWholeWord.isChecked
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.setNegativeButton(android.R.string.cancel, null)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showEditKeywordDialog(keyword: FilterKeyword) {
|
||||||
|
val binding = DialogFilterBinding.inflate(layoutInflater)
|
||||||
|
binding.phraseEditText.setText(keyword.keyword)
|
||||||
|
binding.phraseWholeWord.isChecked = keyword.wholeWord
|
||||||
|
|
||||||
|
AlertDialog.Builder(this)
|
||||||
|
.setTitle(R.string.filter_edit_keyword_title)
|
||||||
|
.setView(binding.root)
|
||||||
|
.setPositiveButton(R.string.filter_dialog_update_button) { _, _ ->
|
||||||
|
viewModel.modifyKeyword(
|
||||||
|
keyword,
|
||||||
|
keyword.copy(
|
||||||
|
keyword = binding.phraseEditText.text.toString(),
|
||||||
|
wholeWord = binding.phraseWholeWord.isChecked
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.setNegativeButton(android.R.string.cancel, null)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validateSaveButton() {
|
||||||
|
binding.filterSaveButton.isEnabled = viewModel.validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveChanges() {
|
||||||
|
// TODO use a progress bar here (see EditProfileActivity/activity_edit_profile.xml for example)?
|
||||||
|
|
||||||
|
lifecycleScope.launch {
|
||||||
|
if (viewModel.saveChanges(this@EditFilterActivity)) {
|
||||||
|
finish()
|
||||||
|
} else {
|
||||||
|
Snackbar.make(binding.root, "Error saving filter '${viewModel.title.value}'", Snackbar.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val FILTER_TO_EDIT = "FilterToEdit"
|
||||||
|
|
||||||
|
// Mastodon *stores* the absolute date in the filter,
|
||||||
|
// but create/edit take a number of seconds (relative to the time the operation is posted)
|
||||||
|
fun getSecondsForDurationIndex(index: Int, context: Context?, default: Date? = null): Int? {
|
||||||
|
return when (index) {
|
||||||
|
-1 -> if (default == null) { default } else { ((default.time - System.currentTimeMillis()) / 1000).toInt() }
|
||||||
|
0 -> null
|
||||||
|
else -> context?.resources?.getIntArray(R.array.filter_duration_values)?.get(index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,186 @@
|
||||||
|
package com.keylesspalace.tusky.components.filters
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import at.connyduck.calladapter.networkresult.fold
|
||||||
|
import com.keylesspalace.tusky.appstore.EventHub
|
||||||
|
import com.keylesspalace.tusky.entity.Filter
|
||||||
|
import com.keylesspalace.tusky.entity.FilterKeyword
|
||||||
|
import com.keylesspalace.tusky.network.MastodonApi
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import retrofit2.HttpException
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub: EventHub) : ViewModel() {
|
||||||
|
private var originalFilter: Filter? = null
|
||||||
|
val title = MutableStateFlow("")
|
||||||
|
val keywords = MutableStateFlow(listOf<FilterKeyword>())
|
||||||
|
val action = MutableStateFlow(Filter.Action.WARN)
|
||||||
|
val duration = MutableStateFlow(0)
|
||||||
|
val contexts = MutableStateFlow(listOf<Filter.Kind>())
|
||||||
|
|
||||||
|
fun load(filter: Filter) {
|
||||||
|
originalFilter = filter
|
||||||
|
title.value = filter.title
|
||||||
|
keywords.value = filter.keywords
|
||||||
|
action.value = filter.action
|
||||||
|
duration.value = if (filter.expiresAt == null) {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
-1
|
||||||
|
}
|
||||||
|
contexts.value = filter.kinds
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addKeyword(keyword: FilterKeyword) {
|
||||||
|
keywords.value += keyword
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteKeyword(keyword: FilterKeyword) {
|
||||||
|
keywords.value = keywords.value.filterNot { it == keyword }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun modifyKeyword(original: FilterKeyword, updated: FilterKeyword) {
|
||||||
|
val index = keywords.value.indexOf(original)
|
||||||
|
if (index >= 0) {
|
||||||
|
keywords.value = keywords.value.toMutableList().apply {
|
||||||
|
set(index, updated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setTitle(title: String) {
|
||||||
|
this.title.value = title
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setDuration(index: Int) {
|
||||||
|
duration.value = index
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setAction(action: Filter.Action) {
|
||||||
|
this.action.value = action
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addContext(context: Filter.Kind) {
|
||||||
|
if (!contexts.value.contains(context)) {
|
||||||
|
contexts.value += context
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeContext(context: Filter.Kind) {
|
||||||
|
contexts.value = contexts.value.filter { it != context }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun validate(): Boolean {
|
||||||
|
return title.value.isNotBlank() &&
|
||||||
|
keywords.value.isNotEmpty() &&
|
||||||
|
contexts.value.isNotEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveChanges(context: Context): Boolean {
|
||||||
|
val contexts = contexts.value.map { it.kind }
|
||||||
|
val title = title.value
|
||||||
|
val durationIndex = duration.value
|
||||||
|
val action = action.value.action
|
||||||
|
|
||||||
|
return withContext(viewModelScope.coroutineContext) {
|
||||||
|
originalFilter?.let { filter ->
|
||||||
|
updateFilter(filter, title, contexts, action, durationIndex, context)
|
||||||
|
} ?: createFilter(title, contexts, action, durationIndex, context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun createFilter(title: String, contexts: List<String>, action: String, durationIndex: Int, context: Context): Boolean {
|
||||||
|
val expiresInSeconds = EditFilterActivity.getSecondsForDurationIndex(durationIndex, context)
|
||||||
|
api.createFilter(
|
||||||
|
title = title,
|
||||||
|
context = contexts,
|
||||||
|
filterAction = action,
|
||||||
|
expiresInSeconds = expiresInSeconds
|
||||||
|
).fold(
|
||||||
|
{ newFilter ->
|
||||||
|
// This is _terrible_, but the all-in-one update filter api Just Doesn't Work
|
||||||
|
return keywords.value.map { keyword ->
|
||||||
|
api.addFilterKeyword(filterId = newFilter.id, keyword = keyword.keyword, wholeWord = keyword.wholeWord)
|
||||||
|
}.none { it.isFailure }
|
||||||
|
},
|
||||||
|
{ throwable ->
|
||||||
|
return (
|
||||||
|
throwable is HttpException && throwable.code() == 404 &&
|
||||||
|
// Endpoint not found, fall back to v1 api
|
||||||
|
createFilterV1(contexts, expiresInSeconds)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun updateFilter(originalFilter: Filter, title: String, contexts: List<String>, action: String, durationIndex: Int, context: Context): Boolean {
|
||||||
|
val expiresInSeconds = EditFilterActivity.getSecondsForDurationIndex(durationIndex, context)
|
||||||
|
api.updateFilter(
|
||||||
|
id = originalFilter.id,
|
||||||
|
title = title,
|
||||||
|
context = contexts,
|
||||||
|
filterAction = action,
|
||||||
|
expiresInSeconds = expiresInSeconds
|
||||||
|
).fold(
|
||||||
|
{
|
||||||
|
// This is _terrible_, but the all-in-one update filter api Just Doesn't Work
|
||||||
|
val results = keywords.value.map { keyword ->
|
||||||
|
if (keyword.id.isEmpty()) {
|
||||||
|
api.addFilterKeyword(filterId = originalFilter.id, keyword = keyword.keyword, wholeWord = keyword.wholeWord)
|
||||||
|
} else {
|
||||||
|
api.updateFilterKeyword(keywordId = keyword.id, keyword = keyword.keyword, wholeWord = keyword.wholeWord)
|
||||||
|
}
|
||||||
|
} + originalFilter.keywords.filter { keyword ->
|
||||||
|
// Deleted keywords
|
||||||
|
keywords.value.none { it.id == keyword.id }
|
||||||
|
}.map { api.deleteFilterKeyword(it.id) }
|
||||||
|
|
||||||
|
return results.none { it.isFailure }
|
||||||
|
},
|
||||||
|
{ throwable ->
|
||||||
|
if (throwable is HttpException && throwable.code() == 404) {
|
||||||
|
// Endpoint not found, fall back to v1 api
|
||||||
|
if (updateFilterV1(contexts, expiresInSeconds)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun createFilterV1(context: List<String>, expiresInSeconds: Int?): Boolean {
|
||||||
|
return keywords.value.map { keyword ->
|
||||||
|
api.createFilterV1(keyword.keyword, context, false, keyword.wholeWord, expiresInSeconds)
|
||||||
|
}.none { it.isFailure }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun updateFilterV1(context: List<String>, expiresInSeconds: Int?): Boolean {
|
||||||
|
val results = keywords.value.map { keyword ->
|
||||||
|
if (originalFilter == null) {
|
||||||
|
api.createFilterV1(
|
||||||
|
phrase = keyword.keyword,
|
||||||
|
context = context,
|
||||||
|
irreversible = false,
|
||||||
|
wholeWord = keyword.wholeWord,
|
||||||
|
expiresInSeconds = expiresInSeconds
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
api.updateFilterV1(
|
||||||
|
id = originalFilter!!.id,
|
||||||
|
phrase = keyword.keyword,
|
||||||
|
context = context,
|
||||||
|
irreversible = false,
|
||||||
|
wholeWord = keyword.wholeWord,
|
||||||
|
expiresInSeconds = expiresInSeconds
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Don't handle deleted keywords here because there's only one keyword per v1 filter anyway
|
||||||
|
|
||||||
|
return results.none { it.isFailure }
|
||||||
|
}
|
||||||
|
}
|