diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..47d830da --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..411c0777 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +* text=auto eol=lf + +*.bat text eol=crlf +*.jar binary diff --git a/.gitignore b/.gitignore index 79132d48..b40257dd 100644 --- a/.gitignore +++ b/.gitignore @@ -3,8 +3,8 @@ /local.properties /.idea .DS_Store -/build +build /captures .externalNativeBuild app/release -.vscode +app-release.apk \ No newline at end of file diff --git a/.idea/icon.png b/.idea/icon.png new file mode 100644 index 00000000..3132225d Binary files /dev/null and b/.idea/icon.png differ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..1328cd7f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,203 @@ +# Tusky changelog + +## Unreleased or Tusky Nightly + +### New features and other improvements + +### Significant bug fixes + +## v23.0 + +### New features and other improvements + +- **New preference to scale UI text**, [PR#3248](https://github.com/tuskyapp/Tusky/pull/3248) by [@nikclayton](https://mastodon.social/@nikclayton) + +### Significant bug fixes + +- **Save account information correctly**, [PR#3720](https://github.com/tuskyapp/Tusky/pull/3720) by [@connyduck](https://chaos.social/@ConnyDuck) + - If you were logged in with multiple accounts it was possible to switch accounts in a way that the UI showed the new account, but database operations were happening using the old account. +- **"pull" notifications on devices running Android versions <= 11**, [PR#3649](https://github.com/tuskyapp/Tusky/pull/3649) by [@nikclayton](https://mastodon.social/@nikclayton) + - Pull notifications (i.e., not using ntfy.sh) could silently fail on devices running Android 11 and below +- **Work around Android bug where text fields could "forget" they can copy/paste**, [PR#3707](https://github.com/tuskyapp/Tusky/pull/3707) by [@nikclayton](https://mastodon.social/@nikclayton) +- **Viewing "diffs" in edit history will not extend off screen edge**, [PR#3431](https://github.com/tuskyapp/Tusky/pull/3431) by [@nikclayton](https://mastodon.social/@nikclayton) +- **Don't crash if your server has no post edit history**, [PR#3747](https://github.com/tuskyapp/Tusky/pull/3747) by [@nikclayton](https://mastodon.social/@nikclayton) + - Your Mastodon server might know that a post has been edited, but not know the details of those edits. Trying to view the history of those statuses no longer crashes. +- **Add a "Delete" button when editing a filter**, [PR#3553](https://github.com/tuskyapp/Tusky/pull/3553) by [@Tak](https://mastodon.gamedev.place/@Tak) +- **Show non-square emoji correctly**, [PR#3711](https://github.com/tuskyapp/Tusky/pull/3711) by [@connyduck](https://chaos.social/@ConnyDuck) +- **Potential crash when editing profile fields**, [PR#3808](https://github.com/tuskyapp/Tusky/pull/3808) by [@nikclayton](https://mastodon.social/@nikclayton) +- **Oversized context menu when editing image descriptions**, [PR#3787](https://github.com/tuskyapp/Tusky/pull/3787) by [@connyduck](https://chaos.social/@ConnyDuck) + +## v23.0 beta 2 + +### Significant bug fixes + +- **Potential crash when editing profile fields**, [PR#3808](https://github.com/tuskyapp/Tusky/pull/3808) by [@nikclayton](https://mastodon.social/@nikclayton) +- **Oversized context menu when editing image descriptions**, [PR#3787](https://github.com/tuskyapp/Tusky/pull/3787) by [@connyduck](https://chaos.social/@ConnyDuck) + +## v23.0 beta 1 + +### New features and other improvements + +- **New preference to scale UI text**, [PR#3248](https://github.com/tuskyapp/Tusky/pull/3248) by [@nikclayton](https://mastodon.social/@nikclayton) + +### Significant bug fixes + +- **Save account information correctly**, [PR#3720](https://github.com/tuskyapp/Tusky/pull/3720) by [@connyduck](https://chaos.social/@ConnyDuck) + - If you were logged in with multiple accounts it was possible to switch accounts in a way that the UI showed the new account, but database operations were happening using the old account. +- **"pull" notifications on devices running Android versions <= 11**, [PR#3649](https://github.com/tuskyapp/Tusky/pull/3649) by [@nikclayton](https://mastodon.social/@nikclayton) + - Pull notifications (i.e., not using ntfy.sh) could silently fail on devices running Android 11 and below +- **Work around Android bug where text fields could "forget" they can copy/paste**, [PR#3707](https://github.com/tuskyapp/Tusky/pull/3707) by [@nikclayton](https://mastodon.social/@nikclayton) +- **Viewing "diffs" in edit history will not extend off screen edge**, [PR#3431](https://github.com/tuskyapp/Tusky/pull/3431) by [@nikclayton](https://mastodon.social/@nikclayton) +- **Don't crash if your server has no post edit history**, [PR#3747](https://github.com/tuskyapp/Tusky/pull/3747) by [@nikclayton](https://mastodon.social/@nikclayton) + - Your Mastodon server might know that a post has been edited, but not know the details of those edits. Trying to view the history of those statuses no longer crashes. +- **Add a "Delete" button when editing a filter**, [PR#3553](https://github.com/tuskyapp/Tusky/pull/3553) by [@Tak](https://mastodon.gamedev.place/@Tak) +- **Show non-square emoji correctly**, [PR#3711](https://github.com/tuskyapp/Tusky/pull/3711) by [@connyduck](https://chaos.social/@ConnyDuck) + +## v22.0 + +### 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. +- **Clicking "Compose" from a notification would set the wrong account**, [PR#3688](https://github.com/tuskyapp/Tusky/pull/3688) + +## v22.0 beta 7 + +### Significant bug fixes + +- **Fetch all outstanding Mastodon notifications when creating Android notifications**, [PR#3700](https://github.com/tuskyapp/Tusky/pull/3700) +- **Clicking "Compose" from a notification would set the wrong account**, [PR#3688](https://github.com/tuskyapp/Tusky/pull/3688) +- **Ensure "last read notification ID" is saved to the correct account**, [PR#3697](https://github.com/tuskyapp/Tusky/pull/3697) + +## 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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 20abafe6..0960c91e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,51 +1,53 @@ # Contributing -## Getting 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//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) +Thanks for your interest in contributing to Tusky! Here are some informations to help you get started. -## 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 -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```. +## Contributing translations -### Translation -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. +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. +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 -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). You can check the codestyle by running `./gradlew ktlintCheck`. -### Java -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. +### Text +All English text that will be visible to users must be put in `app/src/main/res/values/strings.xml` so it is translateable into other languages. +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 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. ### 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 -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: -``` -git add . -git commit -m "Describe the changes in this commit here." -``` +### Accessibility +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. -## Submitting Your Changes -1. Make sure your branch is up-to-date with the ```develop``` branch. Run: -``` -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```. +### Supported servers +Tusky is primarily a Mastodon client and aims to always support the newest Mastodon version. Other platforms implementing the Mastodon Api, e.g. Akkoma, GoToSocial or Pixelfed should also work with Tusky but no special effort is made to support their quirks or additional features. -2. Push your local branch to your fork on GitHub by running ```git push origin your-change-name```. -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 +## Troubleshooting / FAQ -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/) diff --git a/Release.md b/Release.md index 230a4024..338d2729 100644 --- a/Release.md +++ b/Release.md @@ -7,20 +7,21 @@ This approach of having ~500 user on the nightly releases and ~5000 users on the ## 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. -- 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). - 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. -- 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` - Create a new [GitHub release](https://github.com/tuskyapp/Tusky/releases). - Tag the head of `main`. - 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. +- 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.) -- Upload the release to the Open Testing track on Google Play. - Announce the 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. - 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` -- 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` - Create a new [GitHub release](https://github.com/tuskyapp/Tusky/releases). - Tag the head of `main`. - - Resuse 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 + - Reuse the changelog from the beta release, or create a new one if this is only a minor 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)) - Announce the release diff --git a/app/.gitignore b/app/.gitignore deleted file mode 100644 index 3f1ce47a..00000000 --- a/app/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -/build -app-release.apk diff --git a/app/build.gradle b/app/build.gradle index b25dd459..fe7f676f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,32 +1,38 @@ -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' -apply plugin: 'kotlin-kapt' -apply plugin: '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() +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.google.ksp) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.kapt) + alias(libs.plugins.kotlin.parcelize) } +apply from: 'getGitSha.gradle' + +final def gitSha = ext.getGitSha() + +// The app name +final def APP_NAME = "Chinwag Social" +// The application id. Must be unique, e.g. based on your domain +final def APP_ID = "org.chinwag.socialapp" +// 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 = "chinwag.org" +// link to your support account. Will be linked on the about page when not empty. +final def SUPPORT_ACCOUNT_URL = "https://social.chinwag.org/@ChinwagNews" +// New account registration link +final def REGISTER_ACCOUNT_URL = "https://chinwag.org/auth/sign_up" + android { - compileSdkVersion 33 + compileSdk 33 + namespace "com.keylesspalace.tusky" defaultConfig { applicationId APP_ID namespace "com.keylesspalace.tusky" - minSdkVersion 23 - targetSdkVersion 33 - versionCode 89 - versionName "21.0-CW2" + minSdk 23 + targetSdk 33 + versionCode 90 + versionName "23.0-CW0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true @@ -36,12 +42,6 @@ android { buildConfigField("String", "CUSTOM_INSTANCE", "\"$CUSTOM_INSTANCE\"") buildConfigField("String", "SUPPORT_ACCOUNT_URL", "\"$SUPPORT_ACCOUNT_URL\"") buildConfigField("String", "REGISTER_ACCOUNT_URL", "\"$REGISTER_ACCOUNT_URL\"") - - kapt { - arguments { - arg("room.schemaLocation", "$projectDir/schemas") - } - } } buildTypes { release { @@ -49,23 +49,28 @@ android { shrinkResources true proguardFiles 'proguard-rules.pro' } - debug {} } - flavorDimensions "color" + flavorDimensions += "color" productFlavors { blue {} green { resValue "string", "app_name", APP_NAME + " Test" applicationIdSuffix ".test" - versionNameSuffix "-" + getGitSha() + versionNameSuffix "-" + gitSha } } - lintOptions { - disable 'MissingTranslation' + lint { + lintConfig file("lint.xml") + // Regenerate by deleting app/lint-baseline.xml, then run: + // ./gradlew lintBlueDebug + baseline = file("lint-baseline.xml") } + buildFeatures { + buildConfig true + resValues true viewBinding true } testOptions { @@ -75,17 +80,19 @@ android { } unitTests.all { systemProperty 'robolectric.logging.enabled', 'true' - } + systemProperty 'robolectric.lazyload', 'ON' + } } sourceSets { androidTest.assets.srcDirs += files("$projectDir/schemas".toString()) } - packagingOptions { - // Exclude unneeded files added by libraries - exclude 'LICENSE_OFL' - exclude 'LICENSE_UNICODE' - } + // Exclude unneeded files added by libraries + packagingOptions.resources.excludes += [ + 'LICENSE_OFL', + 'LICENSE_UNICODE', + ] + bundle { language { // bundle all languages in every apk so the dynamic language switching works @@ -96,6 +103,30 @@ android { includeInApk false includeInBundle false } + // Can remove this once https://issuetracker.google.com/issues/260059413 is fixed. + // https://kotlinlang.org/docs/gradle-configure-project.html#gradle-java-toolchains-support + compileOptions { + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 + } + applicationVariants.configureEach { variant -> + variant.outputs.configureEach { + outputFileName = "Tusky_${variant.versionName}_${variant.versionCode}_${gitSha}_" + + "${variant.flavorName}_${buildType.name}.apk" + } + } +} + +ksp { + 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 @@ -105,7 +136,7 @@ dependencies { implementation libs.bundles.androidx implementation libs.bundles.room - kapt libs.androidx.room.compiler + ksp libs.androidx.room.compiler implementation libs.android.material @@ -133,11 +164,7 @@ dependencies { implementation libs.photoview implementation libs.bundles.material.drawer - implementation libs.material.typeface, { - artifact { - type = "aar" - } - } + implementation libs.material.typeface implementation libs.image.cropper @@ -146,6 +173,8 @@ dependencies { implementation libs.bouncycastle implementation libs.unified.push + implementation libs.bundles.xmldiff + testImplementation libs.androidx.test.junit testImplementation libs.robolectric testImplementation libs.bundles.mockito @@ -153,9 +182,10 @@ dependencies { testImplementation libs.androidx.core.testing testImplementation libs.kotlinx.coroutines.test testImplementation libs.androidx.work.testing + testImplementation libs.truth + testImplementation libs.turbine androidTestImplementation libs.espresso.core androidTestImplementation libs.androidx.room.testing androidTestImplementation libs.androidx.test.junit - } diff --git a/app/getGitSha.gradle b/app/getGitSha.gradle new file mode 100644 index 00000000..53315b2b --- /dev/null +++ b/app/getGitSha.gradle @@ -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 { + @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() +} diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml new file mode 100644 index 00000000..502aac30 --- /dev/null +++ b/app/lint-baseline.xml @@ -0,0 +1,7420 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/lint.xml b/app/lint.xml new file mode 100644 index 00000000..0f87c735 --- /dev/null +++ b/app/lint.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/48.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/48.json new file mode 100644 index 00000000..8503ae7c --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/48.json @@ -0,0 +1,995 @@ +{ + "formatVersion": 1, + "database": { + "version": 48, + "identityHash": "a394ca5b45df9358fdc4d2eaae69cce3", + "entities": [ + { + "tableName": "DraftEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountId` INTEGER NOT NULL, `inReplyToId` TEXT, `content` TEXT, `contentWarning` TEXT, `sensitive` INTEGER NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `poll` TEXT, `failedToSend` INTEGER NOT NULL, `failedToSendNew` INTEGER NOT NULL, `scheduledAt` TEXT, `language` TEXT, `statusId` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentWarning", + "columnName": "contentWarning", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "failedToSend", + "columnName": "failedToSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "failedToSendNew", + "columnName": "failedToSendNew", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scheduledAt", + "columnName": "scheduledAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "statusId", + "columnName": "statusId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `clientId` TEXT, `clientSecret` TEXT, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsSubscriptions` INTEGER NOT NULL, `notificationsSignUps` INTEGER NOT NULL, `notificationsUpdates` INTEGER NOT NULL, `notificationsReports` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `defaultPostLanguage` TEXT NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL, `oauthScopes` TEXT NOT NULL, `unifiedPushUrl` TEXT NOT NULL, `pushPubKey` TEXT NOT NULL, `pushPrivKey` TEXT NOT NULL, `pushAuth` TEXT NOT NULL, `pushServerKey` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "clientSecret", + "columnName": "clientSecret", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePictureUrl", + "columnName": "profilePictureUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsEnabled", + "columnName": "notificationsEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsMentioned", + "columnName": "notificationsMentioned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowed", + "columnName": "notificationsFollowed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowRequested", + "columnName": "notificationsFollowRequested", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReblogged", + "columnName": "notificationsReblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFavorited", + "columnName": "notificationsFavorited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsPolls", + "columnName": "notificationsPolls", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSubscriptions", + "columnName": "notificationsSubscriptions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSignUps", + "columnName": "notificationsSignUps", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsUpdates", + "columnName": "notificationsUpdates", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReports", + "columnName": "notificationsReports", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationSound", + "columnName": "notificationSound", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationVibration", + "columnName": "notificationVibration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationLight", + "columnName": "notificationLight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostPrivacy", + "columnName": "defaultPostPrivacy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultMediaSensitivity", + "columnName": "defaultMediaSensitivity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostLanguage", + "columnName": "defaultPostLanguage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "alwaysShowSensitiveMedia", + "columnName": "alwaysShowSensitiveMedia", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysOpenSpoiler", + "columnName": "alwaysOpenSpoiler", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaPreviewEnabled", + "columnName": "mediaPreviewEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastNotificationId", + "columnName": "lastNotificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activeNotifications", + "columnName": "activeNotifications", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabPreferences", + "columnName": "tabPreferences", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsFilter", + "columnName": "notificationsFilter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "oauthScopes", + "columnName": "oauthScopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unifiedPushUrl", + "columnName": "unifiedPushUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPubKey", + "columnName": "pushPubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPrivKey", + "columnName": "pushPrivKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushAuth", + "columnName": "pushAuth", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushServerKey", + "columnName": "pushServerKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_AccountEntity_domain_accountId", + "unique": true, + "columnNames": [ + "domain", + "accountId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "InstanceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `minPollDuration` INTEGER, `maxPollDuration` INTEGER, `charactersReservedPerUrl` INTEGER, `version` TEXT, `videoSizeLimit` INTEGER, `imageSizeLimit` INTEGER, `imageMatrixLimit` INTEGER, `maxMediaAttachments` INTEGER, `maxFields` INTEGER, `maxFieldNameLength` INTEGER, `maxFieldValueLength` INTEGER, PRIMARY KEY(`instance`))", + "fields": [ + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojiList", + "columnName": "emojiList", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maximumTootCharacters", + "columnName": "maximumTootCharacters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptions", + "columnName": "maxPollOptions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptionLength", + "columnName": "maxPollOptionLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "minPollDuration", + "columnName": "minPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollDuration", + "columnName": "maxPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "charactersReservedPerUrl", + "columnName": "charactersReservedPerUrl", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "videoSizeLimit", + "columnName": "videoSizeLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "imageSizeLimit", + "columnName": "imageSizeLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "imageMatrixLimit", + "columnName": "imageMatrixLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMediaAttachments", + "columnName": "maxMediaAttachments", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFields", + "columnName": "maxFields", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFieldNameLength", + "columnName": "maxFieldNameLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFieldValueLength", + "columnName": "maxFieldValueLength", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "instance" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `editedAt` INTEGER, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `repliesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT, `mentions` TEXT, `tags` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `card` TEXT, `language` TEXT, `filtered` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "editedAt", + "columnName": "editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogsCount", + "columnName": "reblogsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favouritesCount", + "columnName": "favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "repliesCount", + "columnName": "repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reblogged", + "columnName": "reblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarked", + "columnName": "bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favourited", + "columnName": "favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogServerId", + "columnName": "reblogServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "muted", + "columnName": "muted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "expanded", + "columnName": "expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentCollapsed", + "columnName": "contentCollapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentShowing", + "columnName": "contentShowing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pinned", + "columnName": "pinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "card", + "columnName": "card", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filtered", + "columnName": "filtered", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "timelineUserId" + ], + "referencedColumns": [ + "serverId", + "timelineUserId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUsername", + "columnName": "localUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `order` INTEGER NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_editedAt` INTEGER, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_repliesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_tags` TEXT, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, `s_language` TEXT, PRIMARY KEY(`id`, `accountId`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accounts", + "columnName": "accounts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.id", + "columnName": "s_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.url", + "columnName": "s_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToId", + "columnName": "s_inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToAccountId", + "columnName": "s_inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.account", + "columnName": "s_account", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.content", + "columnName": "s_content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.createdAt", + "columnName": "s_createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.editedAt", + "columnName": "s_editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastStatus.emojis", + "columnName": "s_emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.favouritesCount", + "columnName": "s_favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.repliesCount", + "columnName": "s_repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.favourited", + "columnName": "s_favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.bookmarked", + "columnName": "s_bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.sensitive", + "columnName": "s_sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.spoilerText", + "columnName": "s_spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.attachments", + "columnName": "s_attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.mentions", + "columnName": "s_mentions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.tags", + "columnName": "s_tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.showingHiddenContent", + "columnName": "s_showingHiddenContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.expanded", + "columnName": "s_expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsed", + "columnName": "s_collapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.muted", + "columnName": "s_muted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.poll", + "columnName": "s_poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.language", + "columnName": "s_language", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "accountId" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a394ca5b45df9358fdc4d2eaae69cce3')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/49.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/49.json new file mode 100644 index 00000000..339e372c --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/49.json @@ -0,0 +1,1001 @@ +{ + "formatVersion": 1, + "database": { + "version": 49, + "identityHash": "e7085677596f03c64da3d26e05321a08", + "entities": [ + { + "tableName": "DraftEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountId` INTEGER NOT NULL, `inReplyToId` TEXT, `content` TEXT, `contentWarning` TEXT, `sensitive` INTEGER NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `poll` TEXT, `failedToSend` INTEGER NOT NULL, `failedToSendNew` INTEGER NOT NULL, `scheduledAt` TEXT, `language` TEXT, `statusId` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentWarning", + "columnName": "contentWarning", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "failedToSend", + "columnName": "failedToSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "failedToSendNew", + "columnName": "failedToSendNew", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scheduledAt", + "columnName": "scheduledAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "statusId", + "columnName": "statusId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `clientId` TEXT, `clientSecret` TEXT, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsSubscriptions` INTEGER NOT NULL, `notificationsSignUps` INTEGER NOT NULL, `notificationsUpdates` INTEGER NOT NULL, `notificationsReports` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `defaultPostLanguage` TEXT NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL, `oauthScopes` TEXT NOT NULL, `unifiedPushUrl` TEXT NOT NULL, `pushPubKey` TEXT NOT NULL, `pushPrivKey` TEXT NOT NULL, `pushAuth` TEXT NOT NULL, `pushServerKey` TEXT NOT NULL, `lastVisibleHomeTimelineStatusId` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "clientSecret", + "columnName": "clientSecret", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePictureUrl", + "columnName": "profilePictureUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsEnabled", + "columnName": "notificationsEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsMentioned", + "columnName": "notificationsMentioned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowed", + "columnName": "notificationsFollowed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowRequested", + "columnName": "notificationsFollowRequested", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReblogged", + "columnName": "notificationsReblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFavorited", + "columnName": "notificationsFavorited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsPolls", + "columnName": "notificationsPolls", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSubscriptions", + "columnName": "notificationsSubscriptions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSignUps", + "columnName": "notificationsSignUps", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsUpdates", + "columnName": "notificationsUpdates", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReports", + "columnName": "notificationsReports", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationSound", + "columnName": "notificationSound", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationVibration", + "columnName": "notificationVibration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationLight", + "columnName": "notificationLight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostPrivacy", + "columnName": "defaultPostPrivacy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultMediaSensitivity", + "columnName": "defaultMediaSensitivity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostLanguage", + "columnName": "defaultPostLanguage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "alwaysShowSensitiveMedia", + "columnName": "alwaysShowSensitiveMedia", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysOpenSpoiler", + "columnName": "alwaysOpenSpoiler", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaPreviewEnabled", + "columnName": "mediaPreviewEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastNotificationId", + "columnName": "lastNotificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activeNotifications", + "columnName": "activeNotifications", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabPreferences", + "columnName": "tabPreferences", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsFilter", + "columnName": "notificationsFilter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "oauthScopes", + "columnName": "oauthScopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unifiedPushUrl", + "columnName": "unifiedPushUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPubKey", + "columnName": "pushPubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPrivKey", + "columnName": "pushPrivKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushAuth", + "columnName": "pushAuth", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushServerKey", + "columnName": "pushServerKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastVisibleHomeTimelineStatusId", + "columnName": "lastVisibleHomeTimelineStatusId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_AccountEntity_domain_accountId", + "unique": true, + "columnNames": [ + "domain", + "accountId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "InstanceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `minPollDuration` INTEGER, `maxPollDuration` INTEGER, `charactersReservedPerUrl` INTEGER, `version` TEXT, `videoSizeLimit` INTEGER, `imageSizeLimit` INTEGER, `imageMatrixLimit` INTEGER, `maxMediaAttachments` INTEGER, `maxFields` INTEGER, `maxFieldNameLength` INTEGER, `maxFieldValueLength` INTEGER, PRIMARY KEY(`instance`))", + "fields": [ + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojiList", + "columnName": "emojiList", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maximumTootCharacters", + "columnName": "maximumTootCharacters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptions", + "columnName": "maxPollOptions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptionLength", + "columnName": "maxPollOptionLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "minPollDuration", + "columnName": "minPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollDuration", + "columnName": "maxPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "charactersReservedPerUrl", + "columnName": "charactersReservedPerUrl", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "videoSizeLimit", + "columnName": "videoSizeLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "imageSizeLimit", + "columnName": "imageSizeLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "imageMatrixLimit", + "columnName": "imageMatrixLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMediaAttachments", + "columnName": "maxMediaAttachments", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFields", + "columnName": "maxFields", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFieldNameLength", + "columnName": "maxFieldNameLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFieldValueLength", + "columnName": "maxFieldValueLength", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "instance" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `editedAt` INTEGER, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `repliesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT, `mentions` TEXT, `tags` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `card` TEXT, `language` TEXT, `filtered` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "editedAt", + "columnName": "editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogsCount", + "columnName": "reblogsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favouritesCount", + "columnName": "favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "repliesCount", + "columnName": "repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reblogged", + "columnName": "reblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarked", + "columnName": "bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favourited", + "columnName": "favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogServerId", + "columnName": "reblogServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "muted", + "columnName": "muted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "expanded", + "columnName": "expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentCollapsed", + "columnName": "contentCollapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentShowing", + "columnName": "contentShowing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pinned", + "columnName": "pinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "card", + "columnName": "card", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filtered", + "columnName": "filtered", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "timelineUserId" + ], + "referencedColumns": [ + "serverId", + "timelineUserId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUsername", + "columnName": "localUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `order` INTEGER NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_editedAt` INTEGER, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_repliesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_tags` TEXT, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, `s_language` TEXT, PRIMARY KEY(`id`, `accountId`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accounts", + "columnName": "accounts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.id", + "columnName": "s_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.url", + "columnName": "s_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToId", + "columnName": "s_inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToAccountId", + "columnName": "s_inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.account", + "columnName": "s_account", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.content", + "columnName": "s_content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.createdAt", + "columnName": "s_createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.editedAt", + "columnName": "s_editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastStatus.emojis", + "columnName": "s_emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.favouritesCount", + "columnName": "s_favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.repliesCount", + "columnName": "s_repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.favourited", + "columnName": "s_favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.bookmarked", + "columnName": "s_bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.sensitive", + "columnName": "s_sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.spoilerText", + "columnName": "s_spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.attachments", + "columnName": "s_attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.mentions", + "columnName": "s_mentions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.tags", + "columnName": "s_tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.showingHiddenContent", + "columnName": "s_showingHiddenContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.expanded", + "columnName": "s_expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsed", + "columnName": "s_collapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.muted", + "columnName": "s_muted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.poll", + "columnName": "s_poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.language", + "columnName": "s_language", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "accountId" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e7085677596f03c64da3d26e05321a08')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/50.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/50.json new file mode 100644 index 00000000..6b1a5461 --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/50.json @@ -0,0 +1,995 @@ +{ + "formatVersion": 1, + "database": { + "version": 50, + "identityHash": "4eaf69e915d4a15f021547b725101acd", + "entities": [ + { + "tableName": "DraftEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountId` INTEGER NOT NULL, `inReplyToId` TEXT, `content` TEXT, `contentWarning` TEXT, `sensitive` INTEGER NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `poll` TEXT, `failedToSend` INTEGER NOT NULL, `failedToSendNew` INTEGER NOT NULL, `scheduledAt` TEXT, `language` TEXT, `statusId` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentWarning", + "columnName": "contentWarning", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "failedToSend", + "columnName": "failedToSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "failedToSendNew", + "columnName": "failedToSendNew", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scheduledAt", + "columnName": "scheduledAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "statusId", + "columnName": "statusId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `clientId` TEXT, `clientSecret` TEXT, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsSubscriptions` INTEGER NOT NULL, `notificationsSignUps` INTEGER NOT NULL, `notificationsUpdates` INTEGER NOT NULL, `notificationsReports` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `defaultPostLanguage` TEXT NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL, `oauthScopes` TEXT NOT NULL, `unifiedPushUrl` TEXT NOT NULL, `pushPubKey` TEXT NOT NULL, `pushPrivKey` TEXT NOT NULL, `pushAuth` TEXT NOT NULL, `pushServerKey` TEXT NOT NULL, `lastVisibleHomeTimelineStatusId` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "clientSecret", + "columnName": "clientSecret", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePictureUrl", + "columnName": "profilePictureUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsEnabled", + "columnName": "notificationsEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsMentioned", + "columnName": "notificationsMentioned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowed", + "columnName": "notificationsFollowed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowRequested", + "columnName": "notificationsFollowRequested", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReblogged", + "columnName": "notificationsReblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFavorited", + "columnName": "notificationsFavorited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsPolls", + "columnName": "notificationsPolls", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSubscriptions", + "columnName": "notificationsSubscriptions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSignUps", + "columnName": "notificationsSignUps", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsUpdates", + "columnName": "notificationsUpdates", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReports", + "columnName": "notificationsReports", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationSound", + "columnName": "notificationSound", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationVibration", + "columnName": "notificationVibration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationLight", + "columnName": "notificationLight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostPrivacy", + "columnName": "defaultPostPrivacy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultMediaSensitivity", + "columnName": "defaultMediaSensitivity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostLanguage", + "columnName": "defaultPostLanguage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "alwaysShowSensitiveMedia", + "columnName": "alwaysShowSensitiveMedia", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysOpenSpoiler", + "columnName": "alwaysOpenSpoiler", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaPreviewEnabled", + "columnName": "mediaPreviewEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastNotificationId", + "columnName": "lastNotificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabPreferences", + "columnName": "tabPreferences", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsFilter", + "columnName": "notificationsFilter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "oauthScopes", + "columnName": "oauthScopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unifiedPushUrl", + "columnName": "unifiedPushUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPubKey", + "columnName": "pushPubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPrivKey", + "columnName": "pushPrivKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushAuth", + "columnName": "pushAuth", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushServerKey", + "columnName": "pushServerKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastVisibleHomeTimelineStatusId", + "columnName": "lastVisibleHomeTimelineStatusId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_AccountEntity_domain_accountId", + "unique": true, + "columnNames": [ + "domain", + "accountId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "InstanceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `minPollDuration` INTEGER, `maxPollDuration` INTEGER, `charactersReservedPerUrl` INTEGER, `version` TEXT, `videoSizeLimit` INTEGER, `imageSizeLimit` INTEGER, `imageMatrixLimit` INTEGER, `maxMediaAttachments` INTEGER, `maxFields` INTEGER, `maxFieldNameLength` INTEGER, `maxFieldValueLength` INTEGER, PRIMARY KEY(`instance`))", + "fields": [ + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojiList", + "columnName": "emojiList", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maximumTootCharacters", + "columnName": "maximumTootCharacters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptions", + "columnName": "maxPollOptions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptionLength", + "columnName": "maxPollOptionLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "minPollDuration", + "columnName": "minPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollDuration", + "columnName": "maxPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "charactersReservedPerUrl", + "columnName": "charactersReservedPerUrl", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "videoSizeLimit", + "columnName": "videoSizeLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "imageSizeLimit", + "columnName": "imageSizeLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "imageMatrixLimit", + "columnName": "imageMatrixLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMediaAttachments", + "columnName": "maxMediaAttachments", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFields", + "columnName": "maxFields", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFieldNameLength", + "columnName": "maxFieldNameLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFieldValueLength", + "columnName": "maxFieldValueLength", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "instance" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `editedAt` INTEGER, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `repliesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT, `mentions` TEXT, `tags` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `card` TEXT, `language` TEXT, `filtered` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "editedAt", + "columnName": "editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogsCount", + "columnName": "reblogsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favouritesCount", + "columnName": "favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "repliesCount", + "columnName": "repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reblogged", + "columnName": "reblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarked", + "columnName": "bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favourited", + "columnName": "favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogServerId", + "columnName": "reblogServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "muted", + "columnName": "muted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "expanded", + "columnName": "expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentCollapsed", + "columnName": "contentCollapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentShowing", + "columnName": "contentShowing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pinned", + "columnName": "pinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "card", + "columnName": "card", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filtered", + "columnName": "filtered", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "timelineUserId" + ], + "referencedColumns": [ + "serverId", + "timelineUserId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUsername", + "columnName": "localUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `order` INTEGER NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_editedAt` INTEGER, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_repliesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_tags` TEXT, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, `s_language` TEXT, PRIMARY KEY(`id`, `accountId`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accounts", + "columnName": "accounts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.id", + "columnName": "s_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.url", + "columnName": "s_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToId", + "columnName": "s_inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToAccountId", + "columnName": "s_inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.account", + "columnName": "s_account", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.content", + "columnName": "s_content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.createdAt", + "columnName": "s_createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.editedAt", + "columnName": "s_editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastStatus.emojis", + "columnName": "s_emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.favouritesCount", + "columnName": "s_favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.repliesCount", + "columnName": "s_repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.favourited", + "columnName": "s_favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.bookmarked", + "columnName": "s_bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.sensitive", + "columnName": "s_sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.spoilerText", + "columnName": "s_spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.attachments", + "columnName": "s_attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.mentions", + "columnName": "s_mentions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.tags", + "columnName": "s_tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.showingHiddenContent", + "columnName": "s_showingHiddenContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.expanded", + "columnName": "s_expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsed", + "columnName": "s_collapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.muted", + "columnName": "s_muted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.poll", + "columnName": "s_poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.language", + "columnName": "s_language", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "accountId" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4eaf69e915d4a15f021547b725101acd')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/51.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/51.json new file mode 100644 index 00000000..32c5eeee --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/51.json @@ -0,0 +1,1002 @@ +{ + "formatVersion": 1, + "database": { + "version": 51, + "identityHash": "446158bf571fbd08787628bb829fa3c0", + "entities": [ + { + "tableName": "DraftEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountId` INTEGER NOT NULL, `inReplyToId` TEXT, `content` TEXT, `contentWarning` TEXT, `sensitive` INTEGER NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `poll` TEXT, `failedToSend` INTEGER NOT NULL, `failedToSendNew` INTEGER NOT NULL, `scheduledAt` TEXT, `language` TEXT, `statusId` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentWarning", + "columnName": "contentWarning", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "failedToSend", + "columnName": "failedToSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "failedToSendNew", + "columnName": "failedToSendNew", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scheduledAt", + "columnName": "scheduledAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "statusId", + "columnName": "statusId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `clientId` TEXT, `clientSecret` TEXT, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsSubscriptions` INTEGER NOT NULL, `notificationsSignUps` INTEGER NOT NULL, `notificationsUpdates` INTEGER NOT NULL, `notificationsReports` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `defaultPostLanguage` TEXT NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `notificationMarkerId` TEXT NOT NULL DEFAULT '0', `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL, `oauthScopes` TEXT NOT NULL, `unifiedPushUrl` TEXT NOT NULL, `pushPubKey` TEXT NOT NULL, `pushPrivKey` TEXT NOT NULL, `pushAuth` TEXT NOT NULL, `pushServerKey` TEXT NOT NULL, `lastVisibleHomeTimelineStatusId` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "clientSecret", + "columnName": "clientSecret", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePictureUrl", + "columnName": "profilePictureUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsEnabled", + "columnName": "notificationsEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsMentioned", + "columnName": "notificationsMentioned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowed", + "columnName": "notificationsFollowed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowRequested", + "columnName": "notificationsFollowRequested", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReblogged", + "columnName": "notificationsReblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFavorited", + "columnName": "notificationsFavorited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsPolls", + "columnName": "notificationsPolls", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSubscriptions", + "columnName": "notificationsSubscriptions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSignUps", + "columnName": "notificationsSignUps", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsUpdates", + "columnName": "notificationsUpdates", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReports", + "columnName": "notificationsReports", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationSound", + "columnName": "notificationSound", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationVibration", + "columnName": "notificationVibration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationLight", + "columnName": "notificationLight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostPrivacy", + "columnName": "defaultPostPrivacy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultMediaSensitivity", + "columnName": "defaultMediaSensitivity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostLanguage", + "columnName": "defaultPostLanguage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "alwaysShowSensitiveMedia", + "columnName": "alwaysShowSensitiveMedia", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysOpenSpoiler", + "columnName": "alwaysOpenSpoiler", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaPreviewEnabled", + "columnName": "mediaPreviewEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastNotificationId", + "columnName": "lastNotificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationMarkerId", + "columnName": "notificationMarkerId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'0'" + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabPreferences", + "columnName": "tabPreferences", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsFilter", + "columnName": "notificationsFilter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "oauthScopes", + "columnName": "oauthScopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unifiedPushUrl", + "columnName": "unifiedPushUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPubKey", + "columnName": "pushPubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPrivKey", + "columnName": "pushPrivKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushAuth", + "columnName": "pushAuth", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushServerKey", + "columnName": "pushServerKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastVisibleHomeTimelineStatusId", + "columnName": "lastVisibleHomeTimelineStatusId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_AccountEntity_domain_accountId", + "unique": true, + "columnNames": [ + "domain", + "accountId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "InstanceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `minPollDuration` INTEGER, `maxPollDuration` INTEGER, `charactersReservedPerUrl` INTEGER, `version` TEXT, `videoSizeLimit` INTEGER, `imageSizeLimit` INTEGER, `imageMatrixLimit` INTEGER, `maxMediaAttachments` INTEGER, `maxFields` INTEGER, `maxFieldNameLength` INTEGER, `maxFieldValueLength` INTEGER, PRIMARY KEY(`instance`))", + "fields": [ + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojiList", + "columnName": "emojiList", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maximumTootCharacters", + "columnName": "maximumTootCharacters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptions", + "columnName": "maxPollOptions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptionLength", + "columnName": "maxPollOptionLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "minPollDuration", + "columnName": "minPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollDuration", + "columnName": "maxPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "charactersReservedPerUrl", + "columnName": "charactersReservedPerUrl", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "videoSizeLimit", + "columnName": "videoSizeLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "imageSizeLimit", + "columnName": "imageSizeLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "imageMatrixLimit", + "columnName": "imageMatrixLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMediaAttachments", + "columnName": "maxMediaAttachments", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFields", + "columnName": "maxFields", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFieldNameLength", + "columnName": "maxFieldNameLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFieldValueLength", + "columnName": "maxFieldValueLength", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "instance" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `editedAt` INTEGER, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `repliesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT, `mentions` TEXT, `tags` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `card` TEXT, `language` TEXT, `filtered` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "editedAt", + "columnName": "editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogsCount", + "columnName": "reblogsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favouritesCount", + "columnName": "favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "repliesCount", + "columnName": "repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reblogged", + "columnName": "reblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarked", + "columnName": "bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favourited", + "columnName": "favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogServerId", + "columnName": "reblogServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "muted", + "columnName": "muted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "expanded", + "columnName": "expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentCollapsed", + "columnName": "contentCollapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentShowing", + "columnName": "contentShowing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pinned", + "columnName": "pinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "card", + "columnName": "card", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filtered", + "columnName": "filtered", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "timelineUserId" + ], + "referencedColumns": [ + "serverId", + "timelineUserId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUsername", + "columnName": "localUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `order` INTEGER NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_editedAt` INTEGER, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_repliesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_tags` TEXT, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, `s_language` TEXT, PRIMARY KEY(`id`, `accountId`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accounts", + "columnName": "accounts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.id", + "columnName": "s_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.url", + "columnName": "s_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToId", + "columnName": "s_inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToAccountId", + "columnName": "s_inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.account", + "columnName": "s_account", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.content", + "columnName": "s_content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.createdAt", + "columnName": "s_createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.editedAt", + "columnName": "s_editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastStatus.emojis", + "columnName": "s_emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.favouritesCount", + "columnName": "s_favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.repliesCount", + "columnName": "s_repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.favourited", + "columnName": "s_favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.bookmarked", + "columnName": "s_bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.sensitive", + "columnName": "s_sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.spoilerText", + "columnName": "s_spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.attachments", + "columnName": "s_attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.mentions", + "columnName": "s_mentions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.tags", + "columnName": "s_tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.showingHiddenContent", + "columnName": "s_showingHiddenContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.expanded", + "columnName": "s_expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsed", + "columnName": "s_collapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.muted", + "columnName": "s_muted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.poll", + "columnName": "s_poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.language", + "columnName": "s_language", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "accountId" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '446158bf571fbd08787628bb829fa3c0')" + ] + } +} \ No newline at end of file diff --git a/app/src/green/res/mipmap-hdpi/ic_launcher.png b/app/src/green/res/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index 08960b92..00000000 Binary files a/app/src/green/res/mipmap-hdpi/ic_launcher.png and /dev/null differ diff --git a/app/src/green/res/mipmap-hdpi/ic_launcher.webp b/app/src/green/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 00000000..231a1585 Binary files /dev/null and b/app/src/green/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/green/res/mipmap-mdpi/ic_launcher.png b/app/src/green/res/mipmap-mdpi/ic_launcher.png deleted file mode 100644 index 1a769a71..00000000 Binary files a/app/src/green/res/mipmap-mdpi/ic_launcher.png and /dev/null differ diff --git a/app/src/green/res/mipmap-mdpi/ic_launcher.webp b/app/src/green/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 00000000..f792c868 Binary files /dev/null and b/app/src/green/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/green/res/mipmap-xhdpi/ic_launcher.png b/app/src/green/res/mipmap-xhdpi/ic_launcher.png deleted file mode 100644 index 33714c71..00000000 Binary files a/app/src/green/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ diff --git a/app/src/green/res/mipmap-xhdpi/ic_launcher.webp b/app/src/green/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 00000000..4631f6da Binary files /dev/null and b/app/src/green/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/green/res/mipmap-xxhdpi/ic_launcher.png b/app/src/green/res/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index 5de70d9f..00000000 Binary files a/app/src/green/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ diff --git a/app/src/green/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/green/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 00000000..aa0e7d50 Binary files /dev/null and b/app/src/green/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/green/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/green/res/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index 0297f2e1..00000000 Binary files a/app/src/green/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/app/src/green/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/green/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 00000000..2823f33a Binary files /dev/null and b/app/src/green/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 88a0611e..7ff58a00 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -122,7 +122,7 @@ - + - + + + - @@ -63,10 +63,10 @@ class AccountsInListFragment : DialogFragment(), Injectable { private val adapter = Adapter() private val searchAdapter = SearchAdapter() - private val radius by lazy { resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp) } - private val pm by lazy { PreferenceManager.getDefaultSharedPreferences(requireContext()) } - private val animateAvatar by lazy { pm.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false) } - private val animateEmojis by lazy { pm.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) } + private val radius by unsafeLazy { resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp) } + private val pm by unsafeLazy { PreferenceManager.getDefaultSharedPreferences(requireContext()) } + private val animateAvatar by unsafeLazy { pm.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false) } + private val animateEmojis by unsafeLazy { pm.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -113,7 +113,7 @@ class AccountsInListFragment : DialogFragment(), Injectable { binding.searchView.isSubmitButtonEnabled = true binding.searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String?): Boolean { - viewModel.search(query ?: "") + viewModel.search(query.orEmpty()) return true } @@ -145,21 +145,10 @@ class AccountsInListFragment : DialogFragment(), Injectable { private fun handleError(error: Throwable) { binding.messageView.show() - val retryAction = { _: View -> + binding.messageView.setup(error) { _: View -> binding.messageView.hide() viewModel.load(listId) } - if (error is IOException) { - binding.messageView.setup( - R.drawable.elephant_offline, - R.string.error_network, retryAction - ) - } else { - binding.messageView.setup( - R.drawable.elephant_error, - R.string.error_generic, retryAction - ) - } } private fun onRemoveFromList(accountId: String) { diff --git a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java index ad173170..62709d7c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java @@ -16,9 +16,11 @@ package com.keylesspalace.tusky; import android.app.ActivityManager; +import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.content.pm.PackageManager; +import android.content.res.Configuration; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.Color; @@ -45,6 +47,7 @@ import com.keylesspalace.tusky.db.AccountManager; import com.keylesspalace.tusky.di.Injectable; import com.keylesspalace.tusky.interfaces.AccountSelectionListener; import com.keylesspalace.tusky.interfaces.PermissionRequester; +import com.keylesspalace.tusky.settings.PrefKeys; import com.keylesspalace.tusky.util.ThemeUtils; import java.util.ArrayList; @@ -54,6 +57,7 @@ import java.util.List; import javax.inject.Inject; public abstract class BaseActivity extends AppCompatActivity implements Injectable { + private static final String TAG = "BaseActivity"; @Inject public AccountManager accountManager; @@ -79,7 +83,7 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab /* set the taskdescription programmatically, the theme would turn it blue */ String appName = getString(R.string.app_name); Bitmap appIcon = BitmapFactory.decodeResource(getResources(), R.mipmap.ic_launcher); - int recentsBackgroundColor = MaterialColors.getColor(this, 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)); @@ -93,6 +97,44 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab requesters = new HashMap<>(); } + @Override + protected void attachBaseContext(Context newBase) { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(newBase); + + // Scale text in the UI from PrefKeys.UI_TEXT_SCALE_RATIO + float uiScaleRatio = preferences.getFloat(PrefKeys.UI_TEXT_SCALE_RATIO, 100F); + + Configuration configuration = newBase.getResources().getConfiguration(); + + // Adjust `fontScale` in the configuration. + // + // You can't repeatedly adjust the `fontScale` in `newBase` because that will contain the + // result of previous adjustments. E.g., going from 100% to 80% to 100% does not return + // you to the original 100%, it leaves it at 80%. + // + // Instead, calculate the new scale from the application context. This is unaffected by + // changes to the base context. It does contain contain any changes to the font scale from + // "Settings > Display > Font size" in the device settings, so scaling performed here + // is in addition to any scaling in the device settings. + Configuration appConfiguration = newBase.getApplicationContext().getResources().getConfiguration(); + + // This only adjusts the fonts, anything measured in `dp` is unaffected by this. + // You can try to adjust `densityDpi` as shown in the commented out code below. This + // works, to a point. However, dialogs do not react well to this. Beyond a certain + // scale (~ 120%) the right hand edge of the dialog will clip off the right of the + // screen. + // + // So for now, just adjust the font scale + // + // val displayMetrics = appContext.resources.displayMetrics + // configuration.densityDpi = ((displayMetrics.densityDpi * uiScaleRatio).toInt()) + configuration.fontScale = appConfiguration.fontScale * uiScaleRatio / 100F; + + Context fontScaleContext = newBase.createConfigurationContext(configuration); + + super.attachBaseContext(fontScaleContext); + } + protected boolean requiresLogin() { return true; } @@ -213,7 +255,7 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab } public void openAsAccount(@NonNull String url, @NonNull AccountEntity account) { - accountManager.setActiveAccount(account); + accountManager.setActiveAccount(account.getId()); Intent intent = new Intent(this, MainActivity.class); intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); intent.putExtra(MainActivity.REDIRECT_URL, url); @@ -239,8 +281,6 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab } if (permissionsToRequest.isEmpty()) { int[] permissionsAlreadyGranted = new int[permissions.length]; - for (int i = 0; i < permissionsAlreadyGranted.length; ++i) - permissionsAlreadyGranted[i] = PackageManager.PERMISSION_GRANTED; requester.onRequestPermissionsResult(permissions, permissionsAlreadyGranted); return; } diff --git a/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt b/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt index 60d1966d..d62b5c1d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt @@ -86,8 +86,11 @@ abstract class BottomSheetActivity : BaseActivity() { if (statuses.isNotEmpty()) { viewThread(statuses[0].id, statuses[0].url) 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 } @@ -174,5 +177,5 @@ abstract class BottomSheetActivity : BaseActivity() { enum class PostLookupFallbackBehavior { OPEN_IN_BROWSER, - DISPLAY_ERROR, + DISPLAY_ERROR } diff --git a/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt b/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt index 369f6926..190421e6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt @@ -34,6 +34,7 @@ import com.bumptech.glide.Glide import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.load.resource.bitmap.FitCenter import com.bumptech.glide.load.resource.bitmap.RoundedCorners +import com.canhub.cropper.CropImage import com.canhub.cropper.CropImageContract import com.canhub.cropper.options import com.google.android.material.snackbar.Snackbar @@ -80,14 +81,18 @@ class EditProfileActivity : BaseActivity(), Injectable { } private val cropImage = registerForActivityResult(CropImageContract()) { result -> - if (result.isSuccessful) { - if (result.uriContent == viewModel.getAvatarUri()) { - viewModel.newAvatarPicked() - } else { - viewModel.newHeaderPicked() - } + if (result is CropImage.CancelledResult) { + return@registerForActivityResult + } + + if (!result.isSuccessful) { + return@registerForActivityResult onPickFailure(result.error) + } + + if (result.uriContent == viewModel.getAvatarUri()) { + viewModel.newAvatarPicked() } else { - onPickFailure(result.error) + viewModel.newHeaderPicked() } } @@ -131,12 +136,11 @@ class EditProfileActivity : BaseActivity(), Injectable { is Success -> { val me = profileRes.data if (me != null) { - binding.displayNameEditText.setText(me.displayName) binding.noteEditText.setText(me.source?.note) binding.lockedCheckBox.isChecked = me.locked - accountFieldEditAdapter.setFields(me.source?.fields ?: emptyList()) + accountFieldEditAdapter.setFields(me.source?.fields.orEmpty()) binding.addFieldButton.isVisible = (me.source?.fields?.size ?: 0) < maxAccountFields diff --git a/app/src/main/java/com/keylesspalace/tusky/FiltersActivity.kt b/app/src/main/java/com/keylesspalace/tusky/FiltersActivity.kt deleted file mode 100644 index bbb5bc6a..00000000 --- a/app/src/main/java/com/keylesspalace/tusky/FiltersActivity.kt +++ /dev/null @@ -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 - - 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, 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" - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/LicenseActivity.kt b/app/src/main/java/com/keylesspalace/tusky/LicenseActivity.kt index 3099bd00..ca81d244 100644 --- a/app/src/main/java/com/keylesspalace/tusky/LicenseActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/LicenseActivity.kt @@ -44,7 +44,6 @@ class LicenseActivity : BaseActivity() { } private fun loadFileIntoTextView(@RawRes fileId: Int, textView: TextView) { - val sb = StringBuilder() val br = BufferedReader(InputStreamReader(resources.openRawResource(fileId))) diff --git a/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt b/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt index 436bef7a..e3de3102 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt @@ -31,6 +31,7 @@ import android.widget.TextView import androidx.activity.viewModels import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog +import androidx.core.widget.doOnTextChanged import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DividerItemDecoration @@ -45,7 +46,6 @@ import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.entity.MastoList import com.keylesspalace.tusky.util.hide -import com.keylesspalace.tusky.util.onTextChanged import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible @@ -101,6 +101,9 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { DividerItemDecoration(this, DividerItemDecoration.VERTICAL) ) + binding.swipeRefreshLayout.setOnRefreshListener { viewModel.retryLoading() } + binding.swipeRefreshLayout.setColorSchemeResources(R.color.chinwag_green) + lifecycleScope.launch { viewModel.state.collect(this@ListsActivity::update) } @@ -113,7 +116,6 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { lifecycleScope.launch { viewModel.events.collect { event -> - @Suppress("WHEN_ENUM_CAN_BE_NULL_IN_JAVA") when (event) { Event.CREATE_ERROR -> showMessage(R.string.error_create_list) Event.RENAME_ERROR -> showMessage(R.string.error_rename_list) @@ -135,8 +137,11 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { val dialog = AlertDialog.Builder(this) .setView(layout) .setPositiveButton( - if (list == null) R.string.action_create_list - else R.string.action_rename_list + if (list == null) { + R.string.action_create_list + } else { + R.string.action_rename_list + } ) { _, _ -> onPickedDialogName(editText.text, list?.id) } @@ -144,8 +149,8 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { .show() val positiveButton = dialog.getButton(Dialog.BUTTON_POSITIVE) - editText.onTextChanged { s, _, _, _ -> - positiveButton.isEnabled = s.isNotBlank() + editText.doOnTextChanged { s, _, _, _ -> + positiveButton.isEnabled = s?.isNotBlank() == true } editText.setText(list?.title) editText.text?.let { editText.setSelection(it.length) } @@ -164,6 +169,7 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { private fun update(state: ListsViewModel.State) { adapter.submitList(state.lists) binding.progressBar.visible(state.loadingState == LOADING) + binding.swipeRefreshLayout.isRefreshing = state.loadingState == LOADING when (state.loadingState) { INITIAL, LOADING -> binding.messageView.hide() ERROR_NETWORK -> { @@ -182,7 +188,8 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { if (state.lists.isEmpty()) { binding.messageView.show() binding.messageView.setup( - R.drawable.elephant_friend_empty, R.string.message_empty, + R.drawable.elephant_friend_empty, + R.string.message_empty, null ) } else { @@ -193,7 +200,9 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { private fun showMessage(@StringRes messageId: Int) { Snackbar.make( - binding.listsRecycler, messageId, Snackbar.LENGTH_SHORT + binding.listsRecycler, + messageId, + Snackbar.LENGTH_SHORT ).show() } diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt index e3eb81a8..c70e8368 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt @@ -27,24 +27,27 @@ import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.Drawable import android.net.Uri import android.os.Bundle +import android.text.TextUtils import android.util.Log import android.view.KeyEvent +import android.view.Menu +import android.view.MenuInflater import android.view.MenuItem import android.view.View import android.widget.ImageView import androidx.activity.OnBackPressedCallback import androidx.appcompat.app.AlertDialog +import androidx.appcompat.content.res.AppCompatResources import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.view.GravityCompat -import androidx.lifecycle.Lifecycle +import androidx.core.view.MenuProvider import androidx.lifecycle.lifecycleScope import androidx.preference.PreferenceManager import androidx.viewpager2.widget.MarginPageTransformer import at.connyduck.calladapter.networkresult.fold -import autodispose2.androidx.lifecycle.autoDispose import com.bumptech.glide.Glide import com.bumptech.glide.RequestManager import com.bumptech.glide.load.resource.bitmap.RoundedCorners @@ -57,11 +60,11 @@ import com.google.android.material.tabs.TabLayout.OnTabSelectedListener import com.google.android.material.tabs.TabLayoutMediator import com.keylesspalace.tusky.appstore.AnnouncementReadEvent import com.keylesspalace.tusky.appstore.CacheUpdater -import com.keylesspalace.tusky.appstore.Event import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.MainTabsChangedEvent import com.keylesspalace.tusky.appstore.ProfileEditedEvent import com.keylesspalace.tusky.components.account.AccountActivity +import com.keylesspalace.tusky.components.accountlist.AccountListActivity import com.keylesspalace.tusky.components.announcements.AnnouncementsActivity import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.components.compose.ComposeActivity.Companion.canHandleMimeType @@ -74,6 +77,7 @@ import com.keylesspalace.tusky.components.notifications.showMigrationNoticeIfNec import com.keylesspalace.tusky.components.preference.PreferencesActivity import com.keylesspalace.tusky.components.scheduled.ScheduledStatusActivity import com.keylesspalace.tusky.components.search.SearchActivity +import com.keylesspalace.tusky.components.trending.TrendingActivity import com.keylesspalace.tusky.databinding.ActivityMainBinding import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.DraftsAlert @@ -81,6 +85,7 @@ import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.interfaces.AccountSelectionListener import com.keylesspalace.tusky.interfaces.ActionButtonActivity +import com.keylesspalace.tusky.interfaces.FabFragment import com.keylesspalace.tusky.interfaces.ReselectableFragment import com.keylesspalace.tusky.pager.MainPagerAdapter import com.keylesspalace.tusky.settings.PrefKeys @@ -92,6 +97,7 @@ import com.keylesspalace.tusky.util.getDimension import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.reduceSwipeSensitivity import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.util.unsafeLazy import com.keylesspalace.tusky.util.updateShortcut import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible @@ -125,12 +131,11 @@ import com.mikepenz.materialdrawer.widget.AccountHeaderView import dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector import de.c1710.filemojicompat_ui.helpers.EMOJI_PREFERENCE -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.schedulers.Schedulers import kotlinx.coroutines.launch import javax.inject.Inject -class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInjector { +class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInjector, MenuProvider { @Inject lateinit var androidInjector: DispatchingAndroidInjector @@ -158,7 +163,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje 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 @@ -167,6 +172,12 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje // We need to know if the emoji pack has been changed 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?) { super.onCreate(savedInstanceState) @@ -201,7 +212,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje } else { // No account was provided, show the chooser showAccountChooserDialog( - getString(R.string.action_share_as), true, + getString(R.string.action_share_as), + true, object : AccountSelectionListener { override fun onAccountSelected(account: AccountEntity) { val requestedId = account.id @@ -233,6 +245,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje } window.statusBarColor = Color.TRANSPARENT // don't draw a status bar, the DrawerLayout and the MaterialDrawerLayout have their own setContentView(binding.root) + setSupportActionBar(binding.mainToolbar) glide = Glide.with(this) @@ -246,21 +259,15 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje loadDrawerAvatar(activeAccount.profilePictureUrl, true) - binding.mainToolbar.menu.add(R.string.action_search).apply { - 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 - } - } + addMenuProvider(this) 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 * drawer, though, because its callback touches the header in the drawer. */ @@ -268,21 +275,35 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje fetchAnnouncements() + // 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) - eventHub.events - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(this, Lifecycle.Event.ON_DESTROY) - .subscribe { event: Event? -> + lifecycleScope.launch { + eventHub.events.collect { event -> when (event) { is ProfileEditedEvent -> onFetchUserInfoSuccess(event.newProfileData) - is MainTabsChangedEvent -> setupTabs(false) + is MainTabsChangedEvent -> { + refreshMainDrawerItems( + addSearchButton = hideTopToolbar, + addTrendingButton = !event.newTabs.hasTab(TRENDING) + ) + + setupTabs(false) + } + is AnnouncementReadEvent -> { unreadAnnouncementsCount-- updateAnnouncementsBadge() } } } + } Schedulers.io().scheduleDirect { // Flush old media that was cached for sharing @@ -322,9 +343,28 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje draftsAlert.observeInContext(this, true) } + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.activity_main, menu) + 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() { super.onResume() - NotificationHelper.clearNotificationsForActiveAccount(this, accountManager) val currentEmojiPack = preferences.getString(EMOJI_PREFERENCE, "") if (currentEmojiPack != selectedEmojiPack) { Log.d( @@ -364,7 +404,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje // FIXME: blackberry keyONE raises SHIFT key event even CTRL IS PRESSED when (keyCode) { KeyEvent.KEYCODE_N -> { - // open compose activity by pressing SHIFT + N (or CTRL + N) val composeIntent = Intent(applicationContext, ComposeActivity::class.java) startActivity(composeIntent) @@ -396,8 +435,11 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje finish() } - private fun setupDrawer(savedInstanceState: Bundle?, addSearchButton: Boolean) { - + private fun setupDrawer( + savedInstanceState: Bundle?, + addSearchButton: Boolean, + addTrendingButton: Boolean + ) { val drawerOpenClickListener = View.OnClickListener { binding.mainDrawerLayout.open() } binding.mainToolbar.setNavigationOnClickListener(drawerOpenClickListener) @@ -422,6 +464,9 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje closeDrawerOnProfileListClick = true } + header.currentProfileName.maxLines = 1 + header.currentProfileName.ellipsize = TextUtils.TruncateAt.END + header.accountHeaderBackground.setColorFilter(getColor(R.color.headerBackgroundFilter)) header.accountHeaderBackground.setBackgroundColor(MaterialColors.getColor(header, R.attr.colorBackgroundAccent)) val animateAvatars = preferences.getBoolean("animateGifAvatars", false) @@ -454,6 +499,14 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje }) binding.mainDrawer.apply { + refreshMainDrawerItems(addSearchButton, addTrendingButton) + setSavedInstance(savedInstanceState) + } + } + + private fun refreshMainDrawerItems(addSearchButton: Boolean, addTrendingButton: Boolean) { + binding.mainDrawer.apply { + itemAdapter.clear() tintStatusBar = true addItems( primaryDrawerItem { @@ -519,8 +572,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje startActivityWithSlideInAnimation(AnnouncementsActivity.newIntent(context)) } badgeStyle = BadgeStyle().apply { - textColor = ColorHolder.fromColor(MaterialColors.getColor(binding.mainDrawer, R.attr.colorOnPrimary)) - color = ColorHolder.fromColor(MaterialColors.getColor(binding.mainDrawer, R.attr.colorPrimary)) + textColor = ColorHolder.fromColor(MaterialColors.getColor(binding.mainDrawer, com.google.android.material.R.attr.colorOnPrimary)) + color = ColorHolder.fromColor(MaterialColors.getColor(binding.mainDrawer, com.google.android.material.R.attr.colorPrimary)) } }, DividerDrawerItem(), @@ -568,7 +621,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) { @@ -617,8 +681,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje } private fun setupTabs(selectNotificationTab: Boolean) { - val activeTabLayout = if (preferences.getString("mainNavPosition", "top") == "bottom") { - val actionBarSize = getDimension(this, R.attr.actionBarSize) + val activeTabLayout = if (preferences.getString(PrefKeys.MAIN_NAV_POSITION, "top") == "bottom") { + val actionBarSize = getDimension(this, androidx.appcompat.R.attr.actionBarSize) val fabMargin = resources.getDimensionPixelSize(R.dimen.fabMargin) (binding.composeButton.layoutParams as CoordinatorLayout.LayoutParams).bottomMargin = actionBarSize + fabMargin binding.topNav.hide() @@ -630,29 +694,36 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje binding.tabLayout } + // Save the previous tab so it can be restored later + val previousTab = tabAdapter.tabs.getOrNull(binding.viewPager.currentItem) + val tabs = accountManager.activeAccount!!.tabPreferences - val adapter = MainPagerAdapter(tabs, this) - binding.viewPager.adapter = adapter - TabLayoutMediator(activeTabLayout, binding.viewPager) { _: TabLayout.Tab?, _: Int -> }.attach() - activeTabLayout.removeAllTabs() - for (i in tabs.indices) { - val tab = activeTabLayout.newTab() - .setIcon(tabs[i].icon) - if (tabs[i].id == LIST) { - tab.contentDescription = tabs[i].arguments[1] - } else { - tab.setContentDescription(tabs[i].text) - } - activeTabLayout.addTab(tab) + // Detach any existing mediator before changing tab contents and attaching a new mediator + tabLayoutMediator?.detach() - if (tabs[i].id == NOTIFICATIONS) { - notificationTabPosition = i - if (selectNotificationTab) { - tab.select() - } + tabAdapter.tabs = tabs + tabAdapter.notifyItemRangeChanged(0, tabs.size) + + tabLayoutMediator = TabLayoutMediator(activeTabLayout, binding.viewPager, true) { + tab: TabLayout.Tab, position: Int -> + tab.icon = AppCompatResources.getDrawable(this@MainActivity, tabs[position].icon) + tab.contentDescription = when (tabs[position].id) { + LIST -> tabs[position].arguments[1] + else -> getString(tabs[position].text) } - } + }.also { it.attach() } + + // Selected tab is either + // - Notification tab (if appropriate) + // - The previously selected tab (if it hasn't been removed) + // - 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) binding.viewPager.setPageTransformer(MarginPageTransformer(pageMargin)) @@ -666,34 +737,48 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje onTabSelectedListener = object : OnTabSelectedListener { override fun onTabSelected(tab: TabLayout.Tab) { - if (tab.position == notificationTabPosition) { - NotificationHelper.clearNotificationsForActiveAccount(this@MainActivity, accountManager) - } + binding.mainToolbar.title = tab.contentDescription - binding.mainToolbar.title = tabs[tab.position].title(this@MainActivity) + refreshComposeButtonState(tabAdapter, tab.position) } override fun onTabUnselected(tab: TabLayout.Tab) {} override fun onTabReselected(tab: TabLayout.Tab) { - val fragment = adapter.getFragment(tab.position) + val fragment = tabAdapter.getFragment(tab.position) if (fragment is ReselectableFragment) { (fragment as ReselectableFragment).onReselect() } + + refreshComposeButtonState(tabAdapter, tab.position) } }.also { activeTabLayout.addOnTabSelectedListener(it) } 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 { - (adapter.getFragment(activeTabLayout.selectedTabPosition) as? ReselectableFragment)?.onReselect() + (tabAdapter.getFragment(activeTabLayout.selectedTabPosition) as? ReselectableFragment)?.onReselect() } updateProfiles() } + 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 { val activeAccount = accountManager.activeAccount @@ -794,7 +879,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje } private fun loadDrawerAvatar(avatarUrl: String, showPlaceholder: Boolean) { - val hideTopToolbar = preferences.getBoolean(PrefKeys.HIDE_TOP_TOOLBAR, false) val animateAvatars = preferences.getBoolean("animateGifAvatars", false) @@ -822,7 +906,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje .into(avatarView) } } else { - binding.bottomNavAvatar.hide() binding.topNavAvatar.hide() @@ -929,16 +1012,17 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje private fun updateProfiles() { val animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) - val profiles: MutableList = accountManager.getAllAccountsOrderedByActive().map { acc -> - ProfileDrawerItem().apply { - isSelected = acc.isActive - nameText = acc.displayName.emojify(acc.emojis, header, animateEmojis) - iconUrl = acc.profilePictureUrl - isNameShown = true - identifier = acc.id - descriptionText = acc.fullName - } - }.toMutableList() + val profiles: MutableList = + accountManager.getAllAccountsOrderedByActive().map { acc -> + ProfileDrawerItem().apply { + isSelected = acc.isActive + nameText = acc.displayName.emojify(acc.emojis, header, animateEmojis) + iconUrl = acc.profilePictureUrl + isNameShown = true + identifier = acc.id + descriptionText = acc.fullName + } + }.toMutableList() // reuse the already existing "add account" item for (profile in header.profiles.orEmpty()) { @@ -952,7 +1036,9 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje header.setActiveProfile(accountManager.activeAccount!!.id) binding.mainToolbar.subtitle = if (accountManager.shouldDisplaySelfUsername(this)) { accountManager.activeAccount!!.fullName - } else null + } else { + null + } } override fun getActionButton() = binding.composeButton diff --git a/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt b/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt index f7fe5c1c..c055043d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt @@ -31,10 +31,12 @@ import com.keylesspalace.tusky.components.timeline.TimelineFragment import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel.Kind import com.keylesspalace.tusky.databinding.ActivityStatuslistBinding import com.keylesspalace.tusky.entity.Filter +import com.keylesspalace.tusky.entity.FilterV1 import com.keylesspalace.tusky.util.viewBinding import dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector import kotlinx.coroutines.launch +import retrofit2.HttpException import javax.inject.Inject class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { @@ -54,6 +56,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { private var unmuteTagItem: MenuItem? = null /** The filter muting hashtag, null if unknown or hashtag is not filtered */ + private var mutedFilterV1: FilterV1? = null private var mutedFilter: Filter? = null override fun onCreate(savedInstanceState: Bundle?) { @@ -174,49 +177,89 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { lifecycleScope.launch { mastodonApi.getFilters().fold( { filters -> - for (filter in filters) { - if ((tag == filter.phrase) and filter.context.contains(Filter.HOME)) { - Log.d(TAG, "Tag $hashtag is filtered") - muteTagItem?.isVisible = false - unmuteTagItem?.isVisible = true - mutedFilter = filter - return@fold + mutedFilter = filters.firstOrNull { filter -> + filter.context.contains(Filter.Kind.HOME.kind) && filter.keywords.any { + it.keyword == tag } } - - Log.d(TAG, "Tag $hashtag is not filtered") - mutedFilter = null - muteTagItem?.isEnabled = true - muteTagItem?.isVisible = true - muteTagItem?.isVisible = true + updateTagMuteState(mutedFilter != null) }, { 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 { val tag = hashtag ?: return true lifecycleScope.launch { mastodonApi.createFilter( - tag, - listOf(Filter.HOME), - irreversible = false, - wholeWord = true, + title = "#$tag", + context = listOf(FilterV1.HOME), + filterAction = Filter.Action.WARN.action, expiresInSeconds = null ).fold( { filter -> - mutedFilter = filter - muteTagItem?.isVisible = false - unmuteTagItem?.isVisible = true - eventHub.dispatch(PreferenceChangedEvent(filter.context[0])) + if (mastodonApi.addFilterKeyword(filterId = filter.id, keyword = tag, wholeWord = true).isSuccess) { + mutedFilter = filter + updateTagMuteState(true) + 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") + } }, - { - Snackbar.make(binding.root, getString(R.string.error_muting_hashtag_format, tag), Snackbar.LENGTH_SHORT).show() - Log.e(TAG, "Failed to mute #$tag", it) + { throwable -> + if (throwable is HttpException && throwable.code() == 404) { + 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) + } } ) } @@ -225,19 +268,49 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { } private fun unmuteTag(): Boolean { - val filter = mutedFilter ?: return true - 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 - unmuteTagItem?.isVisible = false - eventHub.dispatch(PreferenceChangedEvent(filter.context[0])) + updateTagMuteState(false) + eventHub.dispatch(PreferenceChangedEvent(Filter.Kind.HOME.kind)) + mutedFilterV1 = null mutedFilter = null }, - { - Snackbar.make(binding.root, getString(R.string.error_unmuting_hashtag_format, filter.phrase), Snackbar.LENGTH_SHORT).show() - Log.e(TAG, "Failed to unmute #${filter.phrase}", it) + { throwable -> + Snackbar.make(binding.root, getString(R.string.error_unmuting_hashtag_format, tag), Snackbar.LENGTH_SHORT).show() + Log.e(TAG, "Failed to unmute #$tag", throwable) } ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/TabData.kt b/app/src/main/java/com/keylesspalace/tusky/TabData.kt index 0db85211..09d1f1cf 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TabData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TabData.kt @@ -20,9 +20,11 @@ import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.fragment.app.Fragment import com.keylesspalace.tusky.components.conversation.ConversationsFragment +import com.keylesspalace.tusky.components.notifications.NotificationsFragment import com.keylesspalace.tusky.components.timeline.TimelineFragment import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel -import com.keylesspalace.tusky.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 */ @@ -31,6 +33,7 @@ const val NOTIFICATIONS = "Notifications" const val LOCAL = "Local" const val FEDERATED = "Federated" const val DIRECT = "Direct" +const val TRENDING = "Trending" const val HASHTAG = "Hashtag" const val LIST = "List" @@ -41,55 +44,77 @@ data class TabData( val fragment: (List) -> Fragment, val arguments: List = emptyList(), val title: (Context) -> String = { context -> context.getString(text) } -) +) { + 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.hasTab(id: String): Boolean = this.find { it.id == id } != null fun createTabDataFromId(id: String, arguments: List = emptyList()): TabData { return when (id) { HOME -> TabData( - HOME, - R.string.title_home, - R.drawable.ic_home_24dp, - { TimelineFragment.newInstance(TimelineViewModel.Kind.HOME) } + id = HOME, + text = R.string.title_home, + icon = R.drawable.ic_home_24dp, + fragment = { TimelineFragment.newInstance(TimelineViewModel.Kind.HOME) } ) NOTIFICATIONS -> TabData( - NOTIFICATIONS, - R.string.title_notifications, - R.drawable.ic_notifications_24dp, - { NotificationsFragment.newInstance() } + id = NOTIFICATIONS, + text = R.string.title_notifications, + icon = R.drawable.ic_notifications_24dp, + fragment = { NotificationsFragment.newInstance() } ) LOCAL -> TabData( - LOCAL, - R.string.title_public_local, - R.drawable.ic_local_24dp, - { TimelineFragment.newInstance(TimelineViewModel.Kind.PUBLIC_LOCAL) } + id = LOCAL, + text = R.string.title_public_local, + icon = R.drawable.ic_local_24dp, + fragment = { TimelineFragment.newInstance(TimelineViewModel.Kind.PUBLIC_LOCAL) } ) FEDERATED -> TabData( - FEDERATED, - R.string.title_public_federated, - R.drawable.ic_public_24dp, - { TimelineFragment.newInstance(TimelineViewModel.Kind.PUBLIC_FEDERATED) } + id = FEDERATED, + text = R.string.title_public_federated, + icon = R.drawable.ic_public_24dp, + fragment = { TimelineFragment.newInstance(TimelineViewModel.Kind.PUBLIC_FEDERATED) } ) DIRECT -> TabData( - DIRECT, - R.string.title_direct_messages, - R.drawable.ic_reblog_direct_24dp, - { ConversationsFragment.newInstance() } + id = DIRECT, + text = R.string.title_direct_messages, + icon = R.drawable.ic_reblog_direct_24dp, + 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, - R.string.hashtags, - R.drawable.ic_hashtag, - { args -> TimelineFragment.newHashtagInstance(args) }, - arguments, - { context -> arguments.joinToString(separator = " ") { context.getString(R.string.title_tag, it) } } + id = HASHTAG, + text = R.string.hashtags, + icon = R.drawable.ic_hashtag, + fragment = { args -> TimelineFragment.newHashtagInstance(args) }, + arguments = arguments, + title = { context -> arguments.joinToString(separator = " ") { context.getString(R.string.title_tag, it) } } ) LIST -> TabData( - LIST, - R.string.list, - R.drawable.ic_list, - { args -> TimelineFragment.newInstance(TimelineViewModel.Kind.LIST, args.getOrNull(0).orEmpty()) }, - arguments, - { arguments.getOrNull(1).orEmpty() } + id = LIST, + text = R.string.list, + icon = R.drawable.ic_list, + fragment = { args -> TimelineFragment.newInstance(TimelineViewModel.Kind.LIST, args.getOrNull(0).orEmpty()) }, + arguments = arguments, + title = { arguments.getOrNull(1).orEmpty() } ) else -> throw IllegalArgumentException("unknown tab type") } diff --git a/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt b/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt index 0f20a785..096fe336 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt @@ -15,16 +15,23 @@ package com.keylesspalace.tusky +import android.content.Intent import android.graphics.Color import android.os.Bundle import android.util.Log +import android.view.Gravity import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter import android.widget.FrameLayout +import android.widget.LinearLayout +import android.widget.ProgressBar +import android.widget.TextView import androidx.activity.OnBackPressedCallback import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.AppCompatEditText import androidx.core.view.updatePadding -import androidx.lifecycle.Lifecycle +import androidx.core.widget.doOnTextChanged import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.ItemTouchHelper @@ -33,23 +40,27 @@ import androidx.recyclerview.widget.RecyclerView import androidx.transition.TransitionManager import at.connyduck.calladapter.networkresult.fold import at.connyduck.sparkbutton.helpers.Utils -import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from -import autodispose2.autoDispose +import com.google.android.material.snackbar.Snackbar import com.google.android.material.transition.MaterialArcMotion import com.google.android.material.transition.MaterialContainerTransform import com.keylesspalace.tusky.adapter.ItemInteractionListener -import com.keylesspalace.tusky.adapter.ListSelectionAdapter import com.keylesspalace.tusky.adapter.TabAdapter import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.MainTabsChangedEvent import com.keylesspalace.tusky.databinding.ActivityTabPreferenceBinding import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.entity.MastoList import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.util.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.visible -import io.reactivex.rxjava3.core.Single -import io.reactivex.rxjava3.schedulers.Schedulers +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.util.regex.Pattern import javax.inject.Inject @@ -58,6 +69,7 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene @Inject lateinit var mastodonApi: MastodonApi + @Inject lateinit var eventHub: EventHub @@ -70,9 +82,9 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene 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) { override fun handleOnBackPressed() { @@ -160,7 +172,6 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene } override fun onTabAdded(tab: TabData) { - if (currentTabs.size >= MAX_TAB_COUNT) { return } @@ -222,7 +233,6 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene } private fun showAddHashtagDialog(tab: TabData? = null, tabPosition: Int = 0) { - val frameLayout = FrameLayout(this) val padding = Utils.dpToPx(this, 8) frameLayout.updatePadding(left = padding, right = padding) @@ -254,7 +264,7 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene } .create() - editText.onTextChanged { s, _, _, _ -> + editText.doOnTextChanged { s, _, _, _ -> dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = validateHashtag(s) } @@ -264,29 +274,80 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene } private fun showSelectListDialog() { - val adapter = ListSelectionAdapter(this) + val adapter = object : ArrayAdapter(this, android.R.layout.simple_list_item_1) { + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + val view = super.getView(position, convertView, parent) + getItem(position)?.let { item -> (view as TextView).text = item.title } + return view + } + } + + val statusLayout = LinearLayout(this) + statusLayout.gravity = Gravity.CENTER + val progress = ProgressBar(this) + val preferredPadding = getDimension(this, androidx.appcompat.R.attr.dialogPreferredPadding) + progress.setPadding(preferredPadding, 0, preferredPadding, 0) + progress.visible(false) + + val noListsText = TextView(this) + noListsText.setPadding(preferredPadding, 0, preferredPadding, 0) + noListsText.text = getText(R.string.select_list_empty) + noListsText.visible(false) + + statusLayout.addView(progress) + statusLayout.addView(noListsText) + + val dialogBuilder = AlertDialog.Builder(this) + .setTitle(R.string.select_list_title) + .setNeutralButton(R.string.select_list_manage) { _, _ -> + val listIntent = Intent(applicationContext, ListsActivity::class.java) + startActivity(listIntent) + } + .setNegativeButton(android.R.string.cancel, null) + .setView(statusLayout) + .setAdapter(adapter) { _, position -> + adapter.getItem(position)?.let { item -> + val newTab = createTabDataFromId(LIST, listOf(item.id, item.title)) + currentTabs.add(newTab) + currentTabsAdapter.notifyItemInserted(currentTabs.size - 1) + updateAvailableTabs() + saveTabs() + } + } + + val showProgressBarJob = getProgressBarJob(progress, 500) + showProgressBarJob.start() + + val dialog = dialogBuilder.show() + lifecycleScope.launch { mastodonApi.getLists().fold( { lists -> + showProgressBarJob.cancel() adapter.addAll(lists) + if (lists.isEmpty()) { + noListsText.show() + } }, { throwable -> + dialog.hide() Log.e("TabPreferenceActivity", "failed to load lists", throwable) + Snackbar.make(binding.root, R.string.error_list_load, Snackbar.LENGTH_LONG).show() } ) } + } - AlertDialog.Builder(this) - .setTitle(R.string.select_list_title) - .setAdapter(adapter) { _, position -> - val list = adapter.getItem(position) - val newTab = createTabDataFromId(LIST, listOf(list!!.id, list.title)) - currentTabs.add(newTab) - currentTabsAdapter.notifyItemInserted(currentTabs.size - 1) - updateAvailableTabs() - saveTabs() - } - .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 { @@ -317,6 +378,10 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene if (!currentTabs.contains(directMessagesTab)) { addableTabs.add(directMessagesTab) } + val trendingTab = createTabDataFromId(TRENDING) + if (!currentTabs.contains(trendingTab)) { + addableTabs.add(trendingTab) + } addableTabs.add(createTabDataFromId(HASHTAG)) addableTabs.add(createTabDataFromId(LIST)) @@ -337,13 +402,10 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene private fun saveTabs() { accountManager.activeAccount?.let { - Single.fromCallable { + lifecycleScope.launch(Dispatchers.IO) { it.tabPreferences = currentTabs accountManager.saveAccount(it) } - .subscribeOn(Schedulers.io()) - .autoDispose(from(this, Lifecycle.Event.ON_DESTROY)) - .subscribe() } tabsChanged = true } @@ -351,7 +413,9 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene override fun onPause() { super.onPause() if (tabsChanged) { - eventHub.dispatch(MainTabsChangedEvent(currentTabs)) + lifecycleScope.launch { + eventHub.dispatch(MainTabsChangedEvent(currentTabs)) + } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt index 4c7aeca9..e7c64699 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt @@ -16,15 +16,22 @@ package com.keylesspalace.tusky import android.app.Application +import android.content.SharedPreferences import android.util.Log -import androidx.preference.PreferenceManager +import androidx.work.Constraints +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import autodispose2.AutoDisposePlugins -import com.keylesspalace.tusky.components.notifications.NotificationWorkerFactory +import com.keylesspalace.tusky.components.notifications.NotificationHelper 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.LocaleManager import com.keylesspalace.tusky.util.setAppNightMode +import com.keylesspalace.tusky.worker.PruneCacheWorker +import com.keylesspalace.tusky.worker.WorkerFactory import dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector import de.c1710.filemojicompat_defaults.DefaultEmojiPackList @@ -33,19 +40,22 @@ import de.c1710.filemojicompat_ui.helpers.EmojiPreference import io.reactivex.rxjava3.plugins.RxJavaPlugins import org.conscrypt.Conscrypt import java.security.Security +import java.util.concurrent.TimeUnit import javax.inject.Inject class TuskyApplication : Application(), HasAndroidInjector { - @Inject lateinit var androidInjector: DispatchingAndroidInjector @Inject - lateinit var notificationWorkerFactory: NotificationWorkerFactory + lateinit var workerFactory: WorkerFactory @Inject lateinit var localeManager: LocaleManager + @Inject + lateinit var sharedPreferences: SharedPreferences + override fun onCreate() { // Uncomment me to get StrictMode violation logs // if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { @@ -65,7 +75,11 @@ class TuskyApplication : Application(), HasAndroidInjector { 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 // Copied from PreferenceManager.getDefaultSharedPreferenceName @@ -73,7 +87,7 @@ class TuskyApplication : Application(), HasAndroidInjector { EmojiPackHelper.init(this, DefaultEmojiPackList.get(this), allowPackImports = false) // init night mode - val theme = preferences.getString("appTheme", APP_THEME_DEFAULT) + val theme = sharedPreferences.getString("appTheme", APP_THEME_DEFAULT) setAppNightMode(theme) localeManager.setLocale() @@ -82,13 +96,45 @@ class TuskyApplication : Application(), HasAndroidInjector { Log.w("RxJava", "undeliverable exception", it) } + NotificationHelper.createWorkerNotificationChannel(this) + WorkManager.initialize( this, androidx.work.Configuration.Builder() - .setWorkerFactory(notificationWorkerFactory) + .setWorkerFactory(workerFactory) .build() ) + + // Prune the database every ~ 12 hours when the device is idle. + val pruneCacheWorker = PeriodicWorkRequestBuilder(12, TimeUnit.HOURS) + .setConstraints(Constraints.Builder().setRequiresDeviceIdle(true).build()) + .build() + WorkManager.getInstance(this).enqueueUniquePeriodicWork( + PruneCacheWorker.PERIODIC_WORK_TAG, + ExistingPeriodicWorkPolicy.KEEP, + pruneCacheWorker + ) } 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" + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt b/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt index 8c7dff59..1e017827 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt @@ -39,6 +39,7 @@ import android.webkit.MimeTypeMap import android.widget.Toast import androidx.core.app.ShareCompat import androidx.core.content.FileProvider +import androidx.core.content.IntentCompat import androidx.fragment.app.FragmentActivity import androidx.lifecycle.Lifecycle import androidx.viewpager2.adapter.FragmentStateAdapter @@ -96,7 +97,7 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener supportPostponeEnterTransition() // Gather the parameters. - attachments = intent.getParcelableArrayListExtra(EXTRA_ATTACHMENTS) + attachments = IntentCompat.getParcelableArrayListExtra(intent, EXTRA_ATTACHMENTS, AttachmentViewData::class.java) val initialPosition = intent.getIntExtra(EXTRA_ATTACHMENT_INDEX, 0) // Adapter is actually of existential type PageAdapter & SharedElementsTransitionListener @@ -306,8 +307,9 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener isCreating = false invalidateOptionsMenu() binding.progressBarShare.visibility = View.GONE - if (result) + if (result) { shareFile(file, "image/png") + } }, { error -> isCreating = false diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldEditAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldEditAdapter.kt index 30cf6309..a57c0aba 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldEditAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldEditAdapter.kt @@ -15,14 +15,14 @@ package com.keylesspalace.tusky.adapter -import android.text.Editable -import android.text.TextWatcher import android.view.LayoutInflater import android.view.ViewGroup +import androidx.core.widget.doAfterTextChanged import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.databinding.ItemEditFieldBinding import com.keylesspalace.tusky.entity.StringField import com.keylesspalace.tusky.util.BindingHolder +import com.keylesspalace.tusky.util.fixTextSelection class AccountFieldEditAdapter : RecyclerView.Adapter>() { @@ -81,25 +81,17 @@ class AccountFieldEditAdapter : RecyclerView.Adapter + fieldData.getOrNull(holder.bindingAdapterPosition)?.first = newText.toString() + } - override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {} + holder.binding.accountFieldValueText.doAfterTextChanged { newText -> + fieldData.getOrNull(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) {} - }) + // Ensure the textview contents are selectable + holder.binding.accountFieldNameText.fixTextSelection() + holder.binding.accountFieldValueText.fixTextSelection() } class MutableStringPair(var first: String, var second: String) diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/BlocksAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/BlocksAdapter.kt deleted file mode 100644 index c4da2ab8..00000000 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/BlocksAdapter.kt +++ /dev/null @@ -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 . */ -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( - 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) } - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt index 60770dda..446536e6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt @@ -21,18 +21,47 @@ import android.text.Spanned import android.text.style.StyleSpan import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.components.notifications.NotificationsPagingAdapter import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.interfaces.AccountActionListener +import com.keylesspalace.tusky.interfaces.LinkListener +import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.emojify +import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.loadAvatar +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.visible +import com.keylesspalace.tusky.viewdata.NotificationViewData class FollowRequestViewHolder( private val binding: ItemFollowRequestBinding, + private val accountActionListener: AccountActionListener, + private val linkListener: LinkListener, private val showHeader: Boolean -) : 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( account: TimelineAccount, @@ -41,20 +70,41 @@ class FollowRequestViewHolder( showBotOverlay: Boolean ) { val wrappedName = account.name.unicodeWrap() - val emojifiedName: CharSequence = wrappedName.emojify(account.emojis, itemView, animateEmojis) + val emojifiedName: CharSequence = wrappedName.emojify( + account.emojis, + itemView, + animateEmojis + ) binding.displayNameTextView.text = emojifiedName if (showHeader) { - val wholeMessage: String = itemView.context.getString(R.string.notification_follow_request_format, wrappedName) + val wholeMessage: String = itemView.context.getString( + R.string.notification_follow_request_format, + wrappedName + ) binding.notificationTextView.text = SpannableStringBuilder(wholeMessage).apply { - setSpan(StyleSpan(Typeface.BOLD), 0, wrappedName.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + setSpan( + StyleSpan(Typeface.BOLD), + 0, + wrappedName.length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) }.emojify(account.emojis, itemView, animateEmojis) } binding.notificationTextView.visible(showHeader) - val format = itemView.context.getString(R.string.post_username_format) - val formattedUsername = String.format(format, account.username) + val formattedUsername = itemView.context.getString(R.string.post_username_format, account.username) binding.usernameTextView.text = formattedUsername + if (account.note.isEmpty()) { + binding.accountNote.hide() + } 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) loadAvatar(account.avatar, binding.avatar, avatarRadius, animateAvatar) + binding.avatarBadge.visible(showBotOverlay && account.bot) } fun setupActionListener(listener: AccountActionListener, accountId: String) { diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/ListSelectionAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/ListSelectionAdapter.kt deleted file mode 100644 index d6478427..00000000 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/ListSelectionAdapter.kt +++ /dev/null @@ -1,42 +0,0 @@ -/* Copyright 2019 kyori19 - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . */ - -package com.keylesspalace.tusky.adapter - -import android.content.Context -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ArrayAdapter -import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.databinding.ItemPickerListBinding -import com.keylesspalace.tusky.entity.MastoList - -class ListSelectionAdapter(context: Context) : ArrayAdapter(context, R.layout.item_picker_list) { - override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { - - val binding = if (convertView == null) { - ItemPickerListBinding.inflate(LayoutInflater.from(context), parent, false) - } else { - ItemPickerListBinding.bind(convertView) - } - - getItem(position)?.let { list -> - binding.root.text = list.title - } - - return binding.root - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java deleted file mode 100644 index b8d4aed3..00000000 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java +++ /dev/null @@ -1,691 +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 . */ - -package com.keylesspalace.tusky.adapter; - -import android.content.Context; -import android.graphics.Color; -import android.graphics.PorterDuff; -import android.graphics.Typeface; -import android.graphics.drawable.Drawable; -import android.text.InputFilter; -import android.text.SpannableStringBuilder; -import android.text.Spanned; -import android.text.TextUtils; -import android.text.style.StyleSpan; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.Button; -import android.widget.ImageView; -import android.widget.TextView; - -import androidx.annotation.ColorRes; -import androidx.annotation.DrawableRes; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.content.ContextCompat; -import androidx.recyclerview.widget.RecyclerView; - -import com.bumptech.glide.Glide; -import com.keylesspalace.tusky.R; -import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding; -import com.keylesspalace.tusky.databinding.ItemReportNotificationBinding; -import com.keylesspalace.tusky.entity.Emoji; -import com.keylesspalace.tusky.entity.Notification; -import com.keylesspalace.tusky.entity.Status; -import com.keylesspalace.tusky.entity.TimelineAccount; -import com.keylesspalace.tusky.interfaces.AccountActionListener; -import com.keylesspalace.tusky.interfaces.LinkListener; -import com.keylesspalace.tusky.interfaces.StatusActionListener; -import com.keylesspalace.tusky.util.AbsoluteTimeFormatter; -import com.keylesspalace.tusky.util.CardViewMode; -import com.keylesspalace.tusky.util.CustomEmojiHelper; -import com.keylesspalace.tusky.util.ImageLoadingHelper; -import com.keylesspalace.tusky.util.LinkHelper; -import com.keylesspalace.tusky.util.SmartLengthInputFilter; -import com.keylesspalace.tusky.util.StatusDisplayOptions; -import com.keylesspalace.tusky.util.StringUtils; -import com.keylesspalace.tusky.util.TimestampUtils; -import com.keylesspalace.tusky.viewdata.NotificationViewData; -import com.keylesspalace.tusky.viewdata.StatusViewData; - -import java.util.Date; -import java.util.List; - -import at.connyduck.sparkbutton.helpers.Utils; - -public class NotificationsAdapter extends RecyclerView.Adapter { - - public interface AdapterDataSource { - int getItemCount(); - - T getItemAt(int pos); - } - - - private static final int VIEW_TYPE_STATUS = 0; - private static final int VIEW_TYPE_STATUS_NOTIFICATION = 1; - private static final int VIEW_TYPE_FOLLOW = 2; - private static final int VIEW_TYPE_FOLLOW_REQUEST = 3; - private static final int VIEW_TYPE_PLACEHOLDER = 4; - private static final int VIEW_TYPE_REPORT = 5; - private static final int VIEW_TYPE_UNKNOWN = 6; - - private static final InputFilter[] COLLAPSE_INPUT_FILTER = new InputFilter[]{SmartLengthInputFilter.INSTANCE}; - private static final InputFilter[] NO_INPUT_FILTER = new InputFilter[0]; - - private String accountId; - private StatusDisplayOptions statusDisplayOptions; - private StatusActionListener statusListener; - private NotificationActionListener notificationActionListener; - private AccountActionListener accountActionListener; - private AdapterDataSource dataSource; - private final AbsoluteTimeFormatter absoluteTimeFormatter = new AbsoluteTimeFormatter(); - - public NotificationsAdapter(String accountId, - AdapterDataSource dataSource, - StatusDisplayOptions statusDisplayOptions, - StatusActionListener statusListener, - NotificationActionListener notificationActionListener, - AccountActionListener accountActionListener) { - - this.accountId = accountId; - this.dataSource = dataSource; - this.statusDisplayOptions = statusDisplayOptions; - this.statusListener = statusListener; - this.notificationActionListener = notificationActionListener; - this.accountActionListener = accountActionListener; - } - - @NonNull - @Override - public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - LayoutInflater inflater = LayoutInflater.from(parent.getContext()); - switch (viewType) { - case VIEW_TYPE_STATUS: { - View view = inflater - .inflate(R.layout.item_status, parent, false); - return new StatusViewHolder(view); - } - case VIEW_TYPE_STATUS_NOTIFICATION: { - View view = inflater - .inflate(R.layout.item_status_notification, parent, false); - return new StatusNotificationViewHolder(view, statusDisplayOptions, absoluteTimeFormatter); - } - case VIEW_TYPE_FOLLOW: { - View view = inflater - .inflate(R.layout.item_follow, parent, false); - return new FollowViewHolder(view, statusDisplayOptions); - } - case VIEW_TYPE_FOLLOW_REQUEST: { - ItemFollowRequestBinding binding = ItemFollowRequestBinding.inflate(inflater, parent, false); - return new FollowRequestViewHolder(binding, true); - } - case VIEW_TYPE_PLACEHOLDER: { - View view = inflater - .inflate(R.layout.item_status_placeholder, parent, false); - return new PlaceholderViewHolder(view); - } - case VIEW_TYPE_REPORT: { - ItemReportNotificationBinding binding = ItemReportNotificationBinding.inflate(inflater, parent, false); - return new ReportNotificationViewHolder(binding); - } - default: - case VIEW_TYPE_UNKNOWN: { - View view = new View(parent.getContext()); - view.setLayoutParams( - new ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - Utils.dpToPx(parent.getContext(), 24) - ) - ); - return new RecyclerView.ViewHolder(view) { - }; - } - } - } - - @Override - public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) { - bindViewHolder(viewHolder, position, null); - } - - @Override - public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position, @NonNull List payloads) { - bindViewHolder(viewHolder, position, payloads); - } - - private void bindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position, @Nullable List payloads) { - Object payloadForHolder = payloads != null && !payloads.isEmpty() ? payloads.get(0) : null; - if (position < this.dataSource.getItemCount()) { - NotificationViewData notification = dataSource.getItemAt(position); - if (notification instanceof NotificationViewData.Placeholder) { - if (payloadForHolder == null) { - NotificationViewData.Placeholder placeholder = ((NotificationViewData.Placeholder) notification); - PlaceholderViewHolder holder = (PlaceholderViewHolder) viewHolder; - holder.setup(statusListener, placeholder.isLoading()); - } - return; - } - NotificationViewData.Concrete concreteNotification = - (NotificationViewData.Concrete) notification; - switch (viewHolder.getItemViewType()) { - case VIEW_TYPE_STATUS: { - StatusViewHolder holder = (StatusViewHolder) viewHolder; - StatusViewData.Concrete status = concreteNotification.getStatusViewData(); - if (status == null) { - /* in some very rare cases servers sends null status even though they should not, - * we have to handle it somehow */ - holder.showStatusContent(false); - } else { - if (payloads == null) { - holder.showStatusContent(true); - } - holder.setupWithStatus(status, statusListener, statusDisplayOptions, payloadForHolder); - } - if (concreteNotification.getType() == Notification.Type.POLL) { - holder.setPollInfo(accountId.equals(concreteNotification.getAccount().getId())); - } else { - holder.hideStatusInfo(); - } - break; - } - case VIEW_TYPE_STATUS_NOTIFICATION: { - StatusNotificationViewHolder holder = (StatusNotificationViewHolder) viewHolder; - StatusViewData.Concrete statusViewData = concreteNotification.getStatusViewData(); - if (payloadForHolder == null) { - if (statusViewData == null) { - /* in some very rare cases servers sends null status even though they should not, - * we have to handle it somehow */ - holder.showNotificationContent(false); - } else { - holder.showNotificationContent(true); - - Status status = statusViewData.getActionable(); - holder.setDisplayName(status.getAccount().getDisplayName(), status.getAccount().getEmojis()); - holder.setUsername(status.getAccount().getUsername()); - holder.setCreatedAt(status.getCreatedAt()); - - if (concreteNotification.getType() == Notification.Type.STATUS || - concreteNotification.getType() == Notification.Type.UPDATE) { - holder.setAvatar(status.getAccount().getAvatar(), status.getAccount().getBot()); - } else { - holder.setAvatars(status.getAccount().getAvatar(), - concreteNotification.getAccount().getAvatar()); - } - } - - holder.setMessage(concreteNotification, statusListener); - holder.setupButtons(notificationActionListener, - concreteNotification.getAccount().getId(), - concreteNotification.getId()); - } else { - if (payloadForHolder instanceof List) - for (Object item : (List) payloadForHolder) { - if (StatusBaseViewHolder.Key.KEY_CREATED.equals(item) && statusViewData != null) { - holder.setCreatedAt(statusViewData.getStatus().getActionableStatus().getCreatedAt()); - } - } - } - break; - } - case VIEW_TYPE_FOLLOW: { - if (payloadForHolder == null) { - FollowViewHolder holder = (FollowViewHolder) viewHolder; - holder.setMessage(concreteNotification.getAccount(), concreteNotification.getType() == Notification.Type.SIGN_UP); - holder.setupButtons(notificationActionListener, concreteNotification.getAccount().getId()); - } - break; - } - case VIEW_TYPE_FOLLOW_REQUEST: { - if (payloadForHolder == null) { - FollowRequestViewHolder holder = (FollowRequestViewHolder) viewHolder; - holder.setupWithAccount(concreteNotification.getAccount(), statusDisplayOptions.animateAvatars(), statusDisplayOptions.animateEmojis(), statusDisplayOptions.showBotOverlay()); - holder.setupActionListener(accountActionListener, concreteNotification.getAccount().getId()); - } - break; - } - case VIEW_TYPE_REPORT: { - if (payloadForHolder == null) { - ReportNotificationViewHolder holder = (ReportNotificationViewHolder) viewHolder; - holder.setupWithReport(concreteNotification.getAccount(), concreteNotification.getReport(), statusDisplayOptions.animateAvatars(), statusDisplayOptions.animateEmojis()); - holder.setupActionListener(notificationActionListener, concreteNotification.getReport().getTargetAccount().getId(), concreteNotification.getAccount().getId(), concreteNotification.getReport().getId()); - } - } - default: - } - } - } - - @Override - public int getItemCount() { - return dataSource.getItemCount(); - } - - public void setMediaPreviewEnabled(boolean mediaPreviewEnabled) { - this.statusDisplayOptions = statusDisplayOptions.copy( - statusDisplayOptions.animateAvatars(), - mediaPreviewEnabled, - statusDisplayOptions.useAbsoluteTime(), - statusDisplayOptions.showBotOverlay(), - statusDisplayOptions.useBlurhash(), - CardViewMode.NONE, - statusDisplayOptions.confirmReblogs(), - statusDisplayOptions.confirmFavourites(), - statusDisplayOptions.hideStats(), - statusDisplayOptions.animateEmojis() - ); - } - - 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 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; - - int darkerFilter = Color.rgb(123, 123, 123); - statusAvatar.setColorFilter(darkerFilter, PorterDuff.Mode.MULTIPLY); - notificationAvatar.setColorFilter(darkerFilter, PorterDuff.Mode.MULTIPLY); - - itemView.setOnClickListener(this); - message.setOnClickListener(this); - statusContent.setOnClickListener(this); - - this.avatarRadius48dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_48dp); - this.avatarRadius36dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_36dp); - this.avatarRadius24dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_24dp); - } - - private void showNotificationContent(boolean show) { - statusNameBar.setVisibility(show ? View.VISIBLE : View.GONE); - contentWarningDescriptionTextView.setVisibility(show ? View.VISIBLE : View.GONE); - contentWarningButton.setVisibility(show ? View.VISIBLE : View.GONE); - statusContent.setVisibility(show ? View.VISIBLE : View.GONE); - statusAvatar.setVisibility(show ? View.VISIBLE : View.GONE); - notificationAvatar.setVisibility(show ? View.VISIBLE : View.GONE); - } - - private void setDisplayName(String name, List emojis) { - CharSequence emojifiedName = CustomEmojiHelper.emojify(name, emojis, displayName, statusDisplayOptions.animateEmojis()); - displayName.setText(emojifiedName); - } - - private void setUsername(String name) { - Context context = username.getContext(); - String format = context.getString(R.string.post_username_format); - String usernameText = String.format(format, name); - username.setText(usernameText); - } - - protected void setCreatedAt(@Nullable Date createdAt) { - if (statusDisplayOptions.useAbsoluteTime()) { - timestampInfo.setText(absoluteTimeFormatter.format(createdAt, true)); - } else { - // This is the visible timestampInfo. - String readout; - /* This one is for screen-readers. Frequently, they would mispronounce timestamps like "17m" - * as 17 meters instead of minutes. */ - CharSequence readoutAloud; - if (createdAt != null) { - long then = createdAt.getTime(); - long now = new Date().getTime(); - readout = TimestampUtils.getRelativeTimeSpanString(timestampInfo.getContext(), then, now); - readoutAloud = android.text.format.DateUtils.getRelativeTimeSpanString(then, now, - android.text.format.DateUtils.SECOND_IN_MILLIS, - android.text.format.DateUtils.FORMAT_ABBREV_RELATIVE); - } else { - // unknown minutes~ - readout = "?m"; - readoutAloud = "? minutes"; - } - timestampInfo.setText(readout); - timestampInfo.setContentDescription(readoutAloud); - } - } - - Drawable getIconWithColor(Context context, @DrawableRes int drawable, @ColorRes int color) { - Drawable icon = ContextCompat.getDrawable(context, drawable); - if (icon != null) { - icon.setColorFilter(context.getColor(color), PorterDuff.Mode.SRC_ATOP); - } - return icon; - } - - void setMessage(NotificationViewData.Concrete notificationViewData, LinkListener listener) { - this.statusViewData = notificationViewData.getStatusViewData(); - - String displayName = StringUtils.unicodeWrap(notificationViewData.getAccount().getName()); - Notification.Type type = notificationViewData.getType(); - - Context context = message.getContext(); - String format; - Drawable icon; - switch (type) { - default: - case FAVOURITE: { - icon = getIconWithColor(context, R.drawable.ic_star_24dp, R.color.tusky_orange); - format = context.getString(R.string.notification_favourite_format); - break; - } - case REBLOG: { - icon = getIconWithColor(context, R.drawable.ic_repeat_24dp, R.color.chinwag_green); - format = context.getString(R.string.notification_reblog_format); - break; - } - case STATUS: { - icon = getIconWithColor(context, R.drawable.ic_home_24dp, R.color.chinwag_green); - format = context.getString(R.string.notification_subscription_format); - break; - } - case UPDATE: { - icon = getIconWithColor(context, R.drawable.ic_edit_24dp, R.color.chinwag_green); - format = context.getString(R.string.notification_update_format); - break; - } - } - message.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null); - String wholeMessage = String.format(format, displayName); - final SpannableStringBuilder str = new SpannableStringBuilder(wholeMessage); - int displayNameIndex = format.indexOf("%s"); - str.setSpan( - new StyleSpan(Typeface.BOLD), - displayNameIndex, - displayNameIndex + displayName.length(), - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE - ); - CharSequence emojifiedText = CustomEmojiHelper.emojify( - str, notificationViewData.getAccount().getEmojis(), message, statusDisplayOptions.animateEmojis() - ); - message.setText(emojifiedText); - - if (statusViewData != null) { - boolean hasSpoiler = !TextUtils.isEmpty(statusViewData.getStatus().getSpoilerText()); - contentWarningDescriptionTextView.setVisibility(hasSpoiler ? View.VISIBLE : View.GONE); - contentWarningButton.setVisibility(hasSpoiler ? View.VISIBLE : View.GONE); - if (statusViewData.isExpanded()) { - contentWarningButton.setText(R.string.post_content_warning_show_less); - } else { - contentWarningButton.setText(R.string.post_content_warning_show_more); - } - - contentWarningButton.setOnClickListener(view -> { - if (getBindingAdapterPosition() != RecyclerView.NO_POSITION) { - notificationActionListener.onExpandedChange(!statusViewData.isExpanded(), getBindingAdapterPosition()); - } - statusContent.setVisibility(statusViewData.isExpanded() ? View.GONE : View.VISIBLE); - }); - - setupContentAndSpoiler(listener); - } - - } - - void setupButtons(final NotificationActionListener listener, final String accountId, - final String notificationId) { - this.notificationActionListener = listener; - this.accountId = accountId; - this.notificationId = notificationId; - } - - void setAvatar(@Nullable String statusAvatarUrl, boolean isBot) { - statusAvatar.setPaddingRelative(0, 0, 0, 0); - - ImageLoadingHelper.loadAvatar(statusAvatarUrl, - statusAvatar, avatarRadius48dp, statusDisplayOptions.animateAvatars()); - - 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()); - } - - @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 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); - } - - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/PollAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/PollAdapter.kt index 596c9432..3e4e1dad 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/PollAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/PollAdapter.kt @@ -75,7 +75,6 @@ class PollAdapter : RecyclerView.Adapter>() { override fun getItemCount() = pollOptions.size override fun onBindViewHolder(holder: BindingHolder, position: Int) { - val option = pollOptions[position] val resultTextView = holder.binding.statusPollOptionResult diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/ReportNotificationViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/ReportNotificationViewHolder.kt index db2f79a9..7502c24e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/ReportNotificationViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/ReportNotificationViewHolder.kt @@ -20,28 +20,76 @@ import androidx.core.content.ContextCompat import androidx.recyclerview.widget.RecyclerView import at.connyduck.sparkbutton.helpers.Utils import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.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.entity.Report import com.keylesspalace.tusky.entity.TimelineAccount +import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.getRelativeTimeSpanString import com.keylesspalace.tusky.util.loadAvatar import com.keylesspalace.tusky.util.unicodeWrap +import com.keylesspalace.tusky.viewdata.NotificationViewData import java.util.Date class ReportNotificationViewHolder( private val binding: ItemReportNotificationBinding, -) : RecyclerView.ViewHolder(binding.root) { + private val notificationActionListener: NotificationActionListener +) : NotificationsPagingAdapter.ViewHolder, RecyclerView.ViewHolder(binding.root) { - fun setupWithReport(reporter: TimelineAccount, report: Report, animateAvatar: Boolean, animateEmojis: Boolean) { - val reporterName = reporter.name.unicodeWrap().emojify(reporter.emojis, itemView, animateEmojis) - val reporteeName = report.targetAccount.name.unicodeWrap().emojify(report.targetAccount.emojis, itemView, animateEmojis) - val icon = ContextCompat.getDrawable(itemView.context, R.drawable.ic_flag_24dp) + override fun bind( + viewData: NotificationViewData, + payloads: List<*>?, + statusDisplayOptions: StatusDisplayOptions + ) { + // Skip updates with payloads. That indicates a timestamp update, and + // this view does not have timestamps. + if (!payloads.isNullOrEmpty()) return + + setupWithReport( + viewData.account, + viewData.report!!, + statusDisplayOptions.animateAvatars, + statusDisplayOptions.animateEmojis + ) + setupActionListener( + notificationActionListener, + viewData.report.targetAccount.id, + viewData.account.id, + viewData.report.id + ) + } + + private fun setupWithReport( + reporter: TimelineAccount, + report: Report, + animateAvatar: Boolean, + animateEmojis: Boolean + ) { + val reporterName = reporter.name.unicodeWrap().emojify( + reporter.emojis, + binding.root, + animateEmojis + ) + val reporteeName = report.targetAccount.name.unicodeWrap().emojify( + report.targetAccount.emojis, + itemView, + animateEmojis + ) + val icon = ContextCompat.getDrawable(binding.root.context, R.drawable.ic_flag_24dp) binding.notificationTopText.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null) - binding.notificationTopText.text = itemView.context.getString(R.string.notification_header_report_format, reporterName, reporteeName) - binding.notificationSummary.text = itemView.context.getString(R.string.notification_summary_report_format, getRelativeTimeSpanString(itemView.context, report.createdAt.time, Date().time), report.status_ids?.size ?: 0) + binding.notificationTopText.text = itemView.context.getString( + R.string.notification_header_report_format, + reporterName, + reporteeName + ) + binding.notificationSummary.text = itemView.context.getString( + R.string.notification_summary_report_format, + getRelativeTimeSpanString(itemView.context, report.createdAt.time, Date().time), + report.status_ids?.size ?: 0 + ) binding.notificationCategory.text = getTranslatedCategory(itemView.context, report.category) // Fancy avatar inset @@ -52,17 +100,22 @@ class ReportNotificationViewHolder( report.targetAccount.avatar, binding.notificationReporteeAvatar, itemView.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp), - animateAvatar, + animateAvatar ) loadAvatar( reporter.avatar, binding.notificationReporterAvatar, itemView.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_24dp), - animateAvatar, + animateAvatar ) } - fun setupActionListener(listener: NotificationActionListener, reporteeId: String, reporterId: String, reportId: String) { + private fun setupActionListener( + listener: NotificationActionListener, + reporteeId: String, + reporterId: String, + reportId: String + ) { binding.notificationReporteeAvatar.setOnClickListener { val position = bindingAdapterPosition if (position != RecyclerView.NO_POSITION) { diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java index e568a5a0..de4d2380 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java @@ -9,6 +9,7 @@ import android.graphics.drawable.Drawable; import android.text.Spanned; import android.text.TextUtils; import android.text.format.DateUtils; +import android.view.Menu; import android.view.View; import android.view.ViewGroup; import android.widget.Button; @@ -21,11 +22,10 @@ import android.widget.Toast; import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.widget.PopupMenu; import androidx.constraintlayout.widget.ConstraintLayout; import androidx.core.content.ContextCompat; import androidx.core.text.HtmlCompat; -import androidx.core.view.ViewKt; import androidx.recyclerview.widget.DefaultItemAnimator; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; @@ -44,6 +44,8 @@ import com.keylesspalace.tusky.entity.Attachment.Focus; import com.keylesspalace.tusky.entity.Attachment.MetaData; import com.keylesspalace.tusky.entity.Card; import com.keylesspalace.tusky.entity.Emoji; +import com.keylesspalace.tusky.entity.Filter; +import com.keylesspalace.tusky.entity.FilterResult; import com.keylesspalace.tusky.entity.HashTag; import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.interfaces.StatusActionListener; @@ -53,6 +55,7 @@ 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.NumberUtils; import com.keylesspalace.tusky.util.StatusDisplayOptions; import com.keylesspalace.tusky.util.TimestampUtils; import com.keylesspalace.tusky.util.TouchDelegateHelper; @@ -76,46 +79,52 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { public static final String KEY_CREATED = "created"; } - private TextView displayName; - private TextView username; - private ImageButton replyButton; - private TextView replyCountLabel; - private SparkButton reblogButton; - private SparkButton favouriteButton; - 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; + private final String TAG = "StatusBaseViewHolder"; - public ImageView avatar; - public TextView metaInfo; - public TextView content; - public TextView contentWarningDescription; + private final TextView displayName; + private final TextView username; + private final ImageButton replyButton; + private final TextView replyCountLabel; + private final SparkButton reblogButton; + private final SparkButton favouriteButton; + 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 RecyclerView pollOptions; - private TextView pollDescription; - private Button pollButton; + public final ImageView avatar; + public final TextView metaInfo; + public final TextView content; + public final TextView contentWarningDescription; - private LinearLayout cardView; - private LinearLayout cardInfo; - private ShapeableImageView cardImage; - private TextView cardTitle; - private TextView cardDescription; - private TextView cardUrl; - private PollAdapter pollAdapter; + private final RecyclerView pollOptions; + private final TextView pollDescription; + private final Button pollButton; + + private final LinearLayout cardView; + private final LinearLayout cardInfo; + 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 AbsoluteTimeFormatter absoluteTimeFormatter = new AbsoluteTimeFormatter(); - protected int avatarRadius48dp; - private int avatarRadius36dp; - private int avatarRadius24dp; + protected final int avatarRadius48dp; + private final int avatarRadius36dp; + private final int avatarRadius24dp; private final Drawable mediaPreviewUnloaded; @@ -161,6 +170,11 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { cardDescription = itemView.findViewById(R.id.card_description); 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(); pollOptions.setAdapter(pollAdapter); pollOptions.setLayoutManager(new LinearLayoutManager(pollOptions.getContext())); @@ -192,16 +206,17 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { contentWarningButton.performClick(); } - protected void setSpoilerAndContent(boolean expanded, - @NonNull Spanned content, - @Nullable String spoilerText, - @Nullable List mentions, - @Nullable List tags, - @NonNull List emojis, - @Nullable PollViewData poll, + protected void setSpoilerAndContent(@NonNull StatusViewData.Concrete status, @NonNull StatusDisplayOptions statusDisplayOptions, final StatusActionListener listener) { + + Status actionable = status.getActionable(); + String spoilerText = status.getSpoilerText(); + List emojis = actionable.getEmojis(); + boolean sensitive = !TextUtils.isEmpty(spoilerText); + boolean expanded = status.isExpanded(); + if (sensitive) { CharSequence emojiSpoiler = CustomEmojiHelper.emojify( spoilerText, emojis, contentWarningDescription, statusDisplayOptions.animateEmojis() @@ -210,20 +225,12 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { contentWarningDescription.setVisibility(View.VISIBLE); contentWarningButton.setVisibility(View.VISIBLE); setContentWarningButtonText(expanded); - contentWarningButton.setOnClickListener(view -> { - contentWarningDescription.invalidate(); - 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); + contentWarningButton.setOnClickListener(view -> toggleExpandedState(true, !expanded, status, statusDisplayOptions, listener)); + this.setTextVisible(true, expanded, status, statusDisplayOptions, listener); } else { contentWarningDescription.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); } } @@ -235,20 +242,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, boolean expanded, - Spanned content, - List mentions, - List tags, - List emojis, - @Nullable PollViewData poll, - StatusDisplayOptions statusDisplayOptions, + @NonNull final StatusViewData.Concrete status, + @NonNull final StatusDisplayOptions statusDisplayOptions, final StatusActionListener listener) { + + Status actionable = status.getActionable(); + Spanned content = status.getContent(); + List mentions = actionable.getMentions(); + List tags =actionable.getTags(); + List emojis = actionable.getEmojis(); + PollViewData poll = PollViewDataKt.toViewData(actionable.getPoll()); + if (expanded) { CharSequence emojifiedText = CustomEmojiHelper.emojify(content, emojis, this.content, statusDisplayOptions.animateEmojis()); LinkHelper.setClickableText(this.content, emojifiedText, mentions, tags, listener); for (int i = 0; i < mediaLabels.length; ++i) { - updateMediaLabel(i, sensitive, expanded); + updateMediaLabel(i, sensitive, true); } if (poll != null) { setupPoll(poll, emojis, statusDisplayOptions, listener); @@ -273,7 +302,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } private void setAvatar(String url, - @Nullable String rebloggedUrl, + @Nullable String rebloggedUrl, boolean isBot, StatusDisplayOptions statusDisplayOptions) { @@ -284,8 +313,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { if (statusDisplayOptions.showBotOverlay() && isBot) { avatarInset.setVisibility(View.VISIBLE); Glide.with(avatarInset) - // passing the drawable id directly into .load() ignores night mode https://github.com/bumptech/glide/issues/4692 - .load(ContextCompat.getDrawable(avatarInset.getContext(), R.drawable.bot_badge)) + .load(R.drawable.bot_badge) .into(avatarInset); } else { avatarInset.setVisibility(View.GONE); @@ -325,8 +353,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } else { long then = createdAt.getTime(); long now = System.currentTimeMillis(); - String readout = TimestampUtils.getRelativeTimeSpanString(metaInfo.getContext(), then, now); - timestampText = readout; + timestampText = TimestampUtils.getRelativeTimeSpanString(metaInfo.getContext(), then, now); } } @@ -365,11 +392,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) - if (replyCountLabel != null) { - replyCountLabel.setText((repliesCount > 1 ? replyCountLabel.getContext().getString(R.string.status_count_one_plus) : Integer.toString(repliesCount))); + if (replyCountLabel == null) return; + + if (fullStats) { + replyCountLabel.setText(NumberUtils.formatNumber(repliesCount, 1000)); + 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) { @@ -598,9 +632,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { final String accountId, final String statusContent, StatusDisplayOptions statusDisplayOptions) { - View.OnClickListener profileButtonClickListener = button -> { - listener.onViewAccount(accountId); - }; + View.OnClickListener profileButtonClickListener = button -> listener.onViewAccount(accountId); avatar.setOnClickListener(profileButtonClickListener); displayName.setOnClickListener(profileButtonClickListener); @@ -611,13 +643,15 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { listener.onReply(position); } }); + + if (reblogButton != null) { reblogButton.setEventListener((button, buttonState) -> { // return true to play animation int position = getBindingAdapterPosition(); if (position != RecyclerView.NO_POSITION) { if (statusDisplayOptions.confirmReblogs()) { - showConfirmReblogDialog(listener, statusContent, buttonState, position); + showConfirmReblog(listener, buttonState, position); return false; } else { listener.onReblog(!buttonState, position); @@ -629,12 +663,13 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { }); } + favouriteButton.setEventListener((button, buttonState) -> { // return true to play animation int position = getBindingAdapterPosition(); if (position != RecyclerView.NO_POSITION) { if (statusDisplayOptions.confirmFavourites()) { - showConfirmFavouriteDialog(listener, statusContent, buttonState, position); + showConfirmFavourite(listener, buttonState, position); return false; } else { listener.onFavourite(!buttonState, position); @@ -673,38 +708,46 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { itemView.setOnClickListener(viewThreadListener); } - private void showConfirmReblogDialog(StatusActionListener listener, - String statusContent, - boolean buttonState, - int position) { - int okButtonTextId = buttonState ? R.string.action_unreblog : R.string.action_reblog; - new AlertDialog.Builder(reblogButton.getContext()) - .setMessage(statusContent) - .setPositiveButton(okButtonTextId, (__, ___) -> { - listener.onReblog(!buttonState, position); - if (!buttonState) { - // Play animation only when it's reblog, not unreblog - reblogButton.playAnimation(); - } - }) - .show(); + private void showConfirmReblog(StatusActionListener listener, + boolean buttonState, + int position) { + PopupMenu popup = new PopupMenu(itemView.getContext(), reblogButton); + popup.inflate(R.menu.status_reblog); + Menu menu = popup.getMenu(); + if (buttonState) { + menu.findItem(R.id.menu_action_reblog).setVisible(false); + } else { + menu.findItem(R.id.menu_action_unreblog).setVisible(false); + } + popup.setOnMenuItemClickListener(item -> { + listener.onReblog(!buttonState, position); + if(!buttonState) { + reblogButton.playAnimation(); + } + return true; + }); + popup.show(); } - private void showConfirmFavouriteDialog(StatusActionListener listener, - String statusContent, - boolean buttonState, - int position) { - int okButtonTextId = buttonState ? R.string.action_unfavourite : R.string.action_favourite; - new AlertDialog.Builder(favouriteButton.getContext()) - .setMessage(statusContent) - .setPositiveButton(okButtonTextId, (__, ___) -> { - listener.onFavourite(!buttonState, position); - if (!buttonState) { - // Play animation only when it's favourite, not unfavourite - favouriteButton.playAnimation(); - } - }) - .show(); + private void showConfirmFavourite(StatusActionListener listener, + boolean buttonState, + int position) { + PopupMenu popup = new PopupMenu(itemView.getContext(), favouriteButton); + popup.inflate(R.menu.status_favourite); + Menu menu = popup.getMenu(); + if (buttonState) { + menu.findItem(R.id.menu_action_favourite).setVisible(false); + } else { + menu.findItem(R.id.menu_action_unfavourite).setVisible(false); + } + popup.setOnMenuItemClickListener(item -> { + listener.onFavourite(!buttonState, position); + if(!buttonState) { + favouriteButton.playAnimation(); + } + return true; + }); + popup.show(); } public void setupWithStatus(StatusViewData.Concrete status, final StatusActionListener listener, @@ -722,7 +765,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { setUsername(status.getUsername()); setMetaData(status, statusDisplayOptions, listener); setIsReply(actionable.getInReplyToId() != null); - setReplyCount(actionable.getRepliesCount()); + setReplyCount(actionable.getRepliesCount(), statusDisplayOptions.showStatsInline()); setAvatar(actionable.getAccount().getAvatar(), status.getRebloggedAvatar(), actionable.getAccount().getBot(), statusDisplayOptions); setReblogged(actionable.getReblogged()); @@ -747,18 +790,15 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { hideSensitiveMediaWarning(); } - if (cardView != null) { - setupCard(status, statusDisplayOptions.cardViewMode(), statusDisplayOptions, listener); - } + setupCard(status, status.isExpanded(), statusDisplayOptions.cardViewMode(), statusDisplayOptions, listener); setupButtons(listener, actionable.getAccount().getId(), status.getContent().toString(), statusDisplayOptions); setRebloggingEnabled(actionable.rebloggingAllowed(), actionable.getVisibility()); - setSpoilerAndContent(status.isExpanded(), status.getContent(), status.getSpoilerText(), - actionable.getMentions(), actionable.getTags(), actionable.getEmojis(), - PollViewDataKt.toViewData(actionable.getPoll()), statusDisplayOptions, - listener); + setSpoilerAndContent(status, statusDisplayOptions, listener); + + setupFilterPlaceholder(status, listener, statusDisplayOptions); setDescriptionForStatus(status, statusDisplayOptions); @@ -779,6 +819,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 attachments) { for (Attachment attachment : attachments) { if (attachment.getType() == Attachment.Type.AUDIO || attachment.getType() == Attachment.Type.UNKNOWN) { @@ -1013,20 +1077,27 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } protected void setupCard( - StatusViewData.Concrete status, - CardViewMode cardViewMode, - StatusDisplayOptions statusDisplayOptions, + final StatusViewData.Concrete status, + boolean expanded, + final CardViewMode cardViewMode, + final StatusDisplayOptions statusDisplayOptions, final StatusActionListener listener ) { + if (cardView == null) { + return; + } + final Status actionable = status.getActionable(); final Card card = actionable.getCard(); + if (cardViewMode != CardViewMode.NONE && - actionable.getAttachments().size() == 0 && - actionable.getPoll() == null && - card != null && - !TextUtils.isEmpty(card.getUrl()) && - (!actionable.getSensitive() || status.isExpanded()) && - (!status.isCollapsible() || !status.isCollapsed())) { + actionable.getAttachments().size() == 0 && + actionable.getPoll() == null && + card != null && + !TextUtils.isEmpty(card.getUrl()) && + (!actionable.getSensitive() || expanded) && + (!status.isCollapsible() || !status.isCollapsed())) { + cardView.setVisibility(View.VISIBLE); cardTitle.setText(card.getTitle()); if (TextUtils.isEmpty(card.getDescription()) && TextUtils.isEmpty(card.getAuthorName())) { @@ -1119,7 +1190,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { cardImage.setScaleType(ImageView.ScaleType.CENTER); Glide.with(cardImage.getContext()) - .load(ContextCompat.getDrawable(cardImage.getContext(), R.drawable.card_image_placeholder)) + .load(R.drawable.card_image_placeholder) .into(cardImage); } @@ -1158,4 +1229,13 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { bookmarkButton.setVisibility(visibility); moreButton.setVisibility(visibility); } + + public void showFilteredPlaceholder(boolean show) { + if (statusContainer != null) { + statusContainer.setVisibility(show ? View.GONE : View.VISIBLE); + } + if (filteredPlaceholder != null) { + filteredPlaceholder.setVisibility(show ? View.VISIBLE : View.GONE); + } + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java index 1725dac9..76eda110 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java @@ -159,7 +159,7 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder { status; 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) { Status actionable = uncollapsedStatus.getActionable(); diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java index 76d12917..304cf93a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java @@ -28,9 +28,11 @@ import androidx.recyclerview.widget.RecyclerView; import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.entity.Emoji; +import com.keylesspalace.tusky.entity.Filter; import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.util.CustomEmojiHelper; +import com.keylesspalace.tusky.util.NumberUtils; import com.keylesspalace.tusky.util.SmartLengthInputFilter; import com.keylesspalace.tusky.util.StatusDisplayOptions; 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[] NO_INPUT_FILTER = new InputFilter[0]; - private TextView statusInfo; - private Button contentCollapseButton; + private final TextView statusInfo; + private final Button contentCollapseButton; + private final TextView favouritedCountLabel; + private final TextView reblogsCountLabel; public StatusViewHolder(View itemView) { super(itemView); statusInfo = itemView.findViewById(R.id.status_info); contentCollapseButton = itemView.findViewById(R.id.button_toggle_content); + favouritedCountLabel = itemView.findViewById(R.id.status_favourites_count); + reblogsCountLabel = itemView.findViewById(R.id.status_insets); } @Override @@ -60,10 +66,13 @@ public class StatusViewHolder extends StatusBaseViewHolder { @Nullable Object payloads) { 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(); - if (reblogging == null) { + if (reblogging == null || status.getFilterAction() == Filter.Action.WARN) { hideStatusInfo(); } else { 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, @@ -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 - 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.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_poll_24dp, 0, 0, 0); statusInfo.setCompoundDrawablePadding(Utils.dpToPx(statusInfo.getContext(), 10)); @@ -99,13 +113,24 @@ public class StatusViewHolder extends StatusBaseViewHolder { statusInfo.setVisibility(View.VISIBLE); } - void hideStatusInfo() { + protected void setReblogsCount(int reblogsCount) { + reblogsCountLabel.setText(NumberUtils.formatNumber(reblogsCount, 1000)); + } + + protected void setFavouritedCount(int favouritedCount) { + favouritedCountLabel.setText(NumberUtils.formatNumber(favouritedCount, 1000)); + } + + protected void hideStatusInfo() { 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 */ - if (status.isCollapsible() && (status.isExpanded() || TextUtils.isEmpty(status.getSpoilerText()))) { + if (status.isCollapsible() && (!sensitive || expanded)) { contentCollapseButton.setOnClickListener(view -> { int position = getBindingAdapterPosition(); if (position != RecyclerView.NO_POSITION) @@ -130,4 +155,16 @@ public class StatusViewHolder extends StatusBaseViewHolder { super.showStatusContent(show); 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); + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/appstore/CacheUpdater.kt b/app/src/main/java/com/keylesspalace/tusky/appstore/CacheUpdater.kt index 66ae898b..9515457b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/appstore/CacheUpdater.kt +++ b/app/src/main/java/com/keylesspalace/tusky/appstore/CacheUpdater.kt @@ -3,45 +3,51 @@ package com.keylesspalace.tusky.appstore import com.google.gson.Gson import com.keylesspalace.tusky.db.AccountManager 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 class CacheUpdater @Inject constructor( eventHub: EventHub, - private val accountManager: AccountManager, + accountManager: AccountManager, appDatabase: AppDatabase, gson: Gson ) { - private val disposable: Disposable + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) init { val timelineDao = appDatabase.timelineDao() - disposable = eventHub.events.subscribe { event -> - val accountId = accountManager.activeAccount?.id ?: return@subscribe - when (event) { - is FavoriteEvent -> - timelineDao.setFavourited(accountId, event.statusId, event.favourite) - is ReblogEvent -> - timelineDao.setReblogged(accountId, event.statusId, event.reblog) - is BookmarkEvent -> - timelineDao.setBookmarked(accountId, event.statusId, event.bookmark) - is UnfollowEvent -> - timelineDao.removeAllByUser(accountId, event.accountId) - is StatusDeletedEvent -> - timelineDao.delete(accountId, event.statusId) - is PollVoteEvent -> { - val pollString = gson.toJson(event.poll) - timelineDao.setVoted(accountId, event.statusId, pollString) + scope.launch { + eventHub.events.collect { event -> + val accountId = accountManager.activeAccount?.id ?: return@collect + when (event) { + is FavoriteEvent -> + timelineDao.setFavourited(accountId, event.statusId, event.favourite) + is ReblogEvent -> + timelineDao.setReblogged(accountId, event.statusId, event.reblog) + is BookmarkEvent -> + timelineDao.setBookmarked(accountId, event.statusId, event.bookmark) + is UnfollowEvent -> + timelineDao.removeAllByUser(accountId, event.accountId) + is StatusDeletedEvent -> + timelineDao.delete(accountId, event.statusId) + is PollVoteEvent -> { + val pollString = gson.toJson(event.poll) + timelineDao.setVoted(accountId, event.statusId, pollString) + } + is PinEvent -> + timelineDao.setPinned(accountId, event.statusId, event.pinned) } - is PinEvent -> - timelineDao.setPinned(accountId, event.statusId, event.pinned) } } } fun stop() { - this.disposable.dispose() + this.scope.cancel() } } diff --git a/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt b/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt index aef4525c..494d6797 100644 --- a/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt +++ b/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt @@ -5,20 +5,21 @@ import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Status -data class FavoriteEvent(val statusId: String, val favourite: Boolean) : Dispatchable -data class ReblogEvent(val statusId: String, val reblog: Boolean) : Dispatchable -data class BookmarkEvent(val statusId: String, val bookmark: Boolean) : Dispatchable -data class MuteConversationEvent(val statusId: String, val mute: Boolean) : Dispatchable -data class UnfollowEvent(val accountId: String) : Dispatchable -data class BlockEvent(val accountId: String) : Dispatchable -data class MuteEvent(val accountId: String) : Dispatchable -data class StatusDeletedEvent(val statusId: String) : Dispatchable -data class StatusComposedEvent(val status: Status) : Dispatchable -data class StatusScheduledEvent(val status: Status) : Dispatchable -data class ProfileEditedEvent(val newProfileData: Account) : Dispatchable -data class PreferenceChangedEvent(val preferenceKey: String) : Dispatchable -data class MainTabsChangedEvent(val newTabs: List) : Dispatchable -data class PollVoteEvent(val statusId: String, val poll: Poll) : Dispatchable -data class DomainMuteEvent(val instance: String) : Dispatchable -data class AnnouncementReadEvent(val announcementId: String) : Dispatchable -data class PinEvent(val statusId: String, val pinned: Boolean) : Dispatchable +data class FavoriteEvent(val statusId: String, val favourite: Boolean) : Event +data class ReblogEvent(val statusId: String, val reblog: Boolean) : Event +data class BookmarkEvent(val statusId: String, val bookmark: Boolean) : Event +data class MuteConversationEvent(val statusId: String, val mute: Boolean) : Event +data class UnfollowEvent(val accountId: String) : Event +data class BlockEvent(val accountId: String) : Event +data class MuteEvent(val accountId: String) : Event +data class StatusDeletedEvent(val statusId: String) : Event +data class StatusComposedEvent(val status: Status) : Event +data class StatusScheduledEvent(val status: Status) : Event +data class StatusEditedEvent(val originalId: String, val status: Status) : Event +data class ProfileEditedEvent(val newProfileData: Account) : Event +data class PreferenceChangedEvent(val preferenceKey: String) : Event +data class MainTabsChangedEvent(val newTabs: List) : Event +data class PollVoteEvent(val statusId: String, val poll: Poll) : Event +data class DomainMuteEvent(val instance: String) : Event +data class AnnouncementReadEvent(val announcementId: String) : Event +data class PinEvent(val statusId: String, val pinned: Boolean) : Event diff --git a/app/src/main/java/com/keylesspalace/tusky/appstore/EventsHub.kt b/app/src/main/java/com/keylesspalace/tusky/appstore/EventsHub.kt index 7fb1f05b..4030b116 100644 --- a/app/src/main/java/com/keylesspalace/tusky/appstore/EventsHub.kt +++ b/app/src/main/java/com/keylesspalace/tusky/appstore/EventsHub.kt @@ -1,20 +1,19 @@ package com.keylesspalace.tusky.appstore -import io.reactivex.rxjava3.core.Observable -import io.reactivex.rxjava3.subjects.PublishSubject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow import javax.inject.Inject import javax.inject.Singleton interface Event -interface Dispatchable : Event @Singleton class EventHub @Inject constructor() { - private val eventsSubject = PublishSubject.create() - val events: Observable = eventsSubject + private val sharedEventFlow: MutableSharedFlow = MutableSharedFlow() + val events: Flow = sharedEventFlow - fun dispatch(event: Dispatchable) { - eventsSubject.onNext(event) + suspend fun dispatch(event: Event) { + sharedEventFlow.emit(event) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt index cd504412..42a07c0b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt @@ -22,9 +22,11 @@ import android.content.Context import android.content.Intent import android.content.res.ColorStateList import android.graphics.Color +import android.graphics.drawable.LayerDrawable import android.os.Bundle -import android.text.Editable +import android.text.TextWatcher import android.view.Menu +import android.view.MenuInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup @@ -32,12 +34,15 @@ import androidx.activity.viewModels import androidx.annotation.ColorInt import androidx.annotation.Px import androidx.appcompat.app.AlertDialog +import androidx.appcompat.content.res.AppCompatResources import androidx.core.app.ActivityOptionsCompat +import androidx.core.view.MenuProvider import androidx.core.view.ViewCompat import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat.Type.systemBars import androidx.core.view.updatePadding +import androidx.core.widget.doAfterTextChanged import androidx.preference.PreferenceManager import androidx.recyclerview.widget.LinearLayoutManager 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.tabs.TabLayout import com.google.android.material.tabs.TabLayoutMediator -import com.keylesspalace.tusky.AccountListActivity import com.keylesspalace.tusky.BottomSheetActivity import com.keylesspalace.tusky.EditProfileActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.StatusListActivity import com.keylesspalace.tusky.ViewMediaActivity import com.keylesspalace.tusky.components.account.list.ListsForAccountFragment +import com.keylesspalace.tusky.components.accountlist.AccountListActivity import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.components.report.ReportActivity 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.ReselectableFragment import com.keylesspalace.tusky.settings.PrefKeys -import com.keylesspalace.tusky.util.DefaultTextWatcher import com.keylesspalace.tusky.util.Error import com.keylesspalace.tusky.util.Loading 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.setClickableText import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.util.unsafeLazy import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible import com.keylesspalace.tusky.view.showMuteAccountDialog +import com.mikepenz.iconics.IconicsDrawable +import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial +import com.mikepenz.iconics.utils.colorInt +import com.mikepenz.iconics.utils.sizeDp import dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector import java.text.NumberFormat @@ -94,12 +103,14 @@ import java.util.Locale import javax.inject.Inject import kotlin.math.abs -class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInjector, LinkListener { +class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider, HasAndroidInjector, LinkListener { @Inject lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector + @Inject lateinit var viewModelFactory: ViewModelFactory + @Inject lateinit var draftsAlert: DraftsAlert @@ -109,7 +120,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI 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 blocking: Boolean = false @@ -125,14 +136,18 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI // fields for scroll animation private var hideFab: Boolean = false private var oldOffset: Int = 0 + @ColorInt private var toolbarColor: Int = 0 + @ColorInt private var statusBarColorTransparent: Int = 0 + @ColorInt private var statusBarColorOpaque: Int = 0 private var avatarSize: Float = 0f + @Px private var titleVisibleHeight: Int = 0 private lateinit var domain: String @@ -145,11 +160,14 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI private lateinit var adapter: AccountPagerAdapter + private var noteWatcher: TextWatcher? = null + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) loadResources() makeNotificationBarTransparent() setContentView(binding.root) + addMenuProvider(this) // Obtain information to fill out the profile. viewModel.setAccountInfo(intent.getStringExtra(KEY_ACCOUNT_ID)!!) @@ -178,9 +196,9 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI * Load colors and dimensions from resources */ 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) - 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) titleVisibleHeight = resources.getDimensionPixelSize(R.dimen.account_activity_scroll_title_visible_height) } @@ -298,6 +316,23 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI toolbarBackground.fillColor = ColorStateList.valueOf(Color.TRANSPARENT) binding.accountToolbar.background = toolbarBackground + // Provide a non-transparent background to the navigation and overflow icons to ensure + // they remain visible over whatever the profile background image might be. + val backgroundCircle = AppCompatResources.getDrawable(this, R.drawable.background_circle)!! + backgroundCircle.alpha = 210 // Any lower than this and the backgrounds interfere + binding.accountToolbar.navigationIcon = LayerDrawable( + arrayOf( + backgroundCircle, + binding.accountToolbar.navigationIcon + ) + ) + binding.accountToolbar.overflowIcon = LayerDrawable( + arrayOf( + backgroundCircle, + binding.accountToolbar.overflowIcon + ) + ) + binding.accountHeaderInfoContainer.background = MaterialShapeDrawable.createWithElevationOverlay(this, appBarElevation) val avatarBackground = MaterialShapeDrawable.createWithElevationOverlay(this, appBarElevation).apply { @@ -313,7 +348,6 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI binding.accountAppBarLayout.addOnOffsetChangedListener(object : AppBarLayout.OnOffsetChangedListener { override fun onOffsetChanged(appBarLayout: AppBarLayout, verticalOffset: Int) { - if (verticalOffset == oldOffset) { return } @@ -394,14 +428,16 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI draftsAlert.observeInContext(this, true) } + private fun onRefresh() { + viewModel.refresh() + adapter.refreshContent() + } + /** * Setup swipe to refresh layout */ private fun setupRefreshLayout() { - binding.swipeToRefreshLayout.setOnRefreshListener { - viewModel.refresh() - adapter.refreshContent() - } + binding.swipeToRefreshLayout.setOnRefreshListener { onRefresh() } viewModel.isRefreshing.observe( this ) { isRefreshing -> @@ -434,8 +470,8 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI val emojifiedNote = account.note.parseAsMastodonHtml().emojify(account.emojis, binding.accountNoteTextView, animateEmojis) setClickableText(binding.accountNoteTextView, emojifiedNote, emptyList(), null, this) - accountFieldAdapter.fields = account.fields ?: emptyList() - accountFieldAdapter.emojis = account.emojis ?: emptyList() + accountFieldAdapter.fields = account.fields.orEmpty() + accountFieldAdapter.emojis = account.emojis.orEmpty() accountFieldAdapter.notifyDataSetChanged() binding.accountLockedImageView.visible(account.locked) @@ -488,18 +524,23 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI .centerCrop() .into(binding.accountHeaderImageView) - binding.accountAvatarImageView.setOnClickListener { avatarView -> - val intent = - ViewMediaActivity.newSingleImageIntent(avatarView.context, account.avatar) - - avatarView.transitionName = account.avatar - val options = ActivityOptionsCompat.makeSceneTransitionAnimation(this, avatarView, account.avatar) - - startActivity(intent, options.toBundle()) + binding.accountAvatarImageView.setOnClickListener { view -> + viewImage(view, account.avatar) + } + binding.accountHeaderImageView.setOnClickListener { view -> + viewImage(view, account.header) } } } + 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 */ @@ -614,10 +655,11 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI binding.accountSubscribeButton.setOnClickListener { viewModel.changeSubscribingState() } - if (relation.notifying != null) + if (relation.notifying != null) { subscribing = relation.notifying - else if (relation.subscribing != null) + } else if (relation.subscribing != null) { subscribing = relation.subscribing + } } // remove the listener so it doesn't fire on non-user changes @@ -626,15 +668,11 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI binding.accountNoteTextInputLayout.visible(relation.note != null) binding.accountNoteTextInputLayout.editText?.setText(relation.note) - binding.accountNoteTextInputLayout.editText?.addTextChangedListener(noteWatcher) - - updateButtons() - } - - private val noteWatcher = object : DefaultTextWatcher() { - override fun afterTextChanged(s: Editable) { + noteWatcher = binding.accountNoteTextInputLayout.editText?.doAfterTextChanged { s -> viewModel.noteChanged(s.toString()) } + + updateButtons() } private fun updateFollowButton() { @@ -685,7 +723,6 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI invalidateOptionsMenu() if (loadedAccount?.moved == null) { - binding.accountFollowButton.show() updateFollowButton() updateSubscribeButton() @@ -706,7 +743,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) val openAsItem = menu.findItem(R.id.action_open_as) @@ -718,7 +755,6 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI } if (!viewModel.isSelf) { - val block = menu.findItem(R.id.action_block) block.title = if (blocking) { getString(R.string.action_unblock) @@ -771,7 +807,12 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI 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() { @@ -859,7 +900,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI viewUrl(url) } - override fun onOptionsItemSelected(item: MenuItem): Boolean { + override fun onMenuItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.action_open_in_web -> { // If the account isn't loaded yet, eat the input. @@ -871,7 +912,8 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI R.id.action_open_as -> { loadedAccount?.let { loadedAccount -> showAccountChooserDialog( - item.title, false, + item.title, + false, object : AccountSelectionListener { override fun onAccountSelected(account: AccountEntity) { openAsAccount(loadedAccount.url, account) @@ -924,6 +966,11 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI viewModel.changeShowReblogsState() return true } + R.id.action_refresh -> { + binding.swipeToRefreshLayout.isRefreshing = true + onRefresh() + return true + } R.id.action_report -> { loadedAccount?.let { loadedAccount -> startActivity(ReportActivity.getIntent(this, viewModel.accountId, loadedAccount.username)) @@ -931,23 +978,25 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI return true } } - return super.onOptionsItemSelected(item) + return false } override fun getActionButton(): FloatingActionButton? { return if (!blocking) { binding.accountFloatingActionButton - } else null + } else { + null + } } private fun getFullUsername(account: Account): String { - if (account.isRemote()) { - return "@" + account.username + return if (account.isRemote()) { + "@" + account.username } else { 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. val domain = accountManager.activeAccount!!.domain - return "@$localUsername@$domain" + "@$localUsername@$domain" } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountViewModel.kt index 1b4aa7f0..5b32e340 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountViewModel.kt @@ -2,7 +2,9 @@ package com.keylesspalace.tusky.components.account import android.util.Log import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import at.connyduck.calladapter.networkresult.fold import com.keylesspalace.tusky.appstore.BlockEvent import com.keylesspalace.tusky.appstore.DomainMuteEvent 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.Loading import com.keylesspalace.tusky.util.Resource -import com.keylesspalace.tusky.util.RxAwareViewModel import com.keylesspalace.tusky.util.Success -import io.reactivex.rxjava3.core.Single -import io.reactivex.rxjava3.disposables.Disposable +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import retrofit2.Call -import retrofit2.Callback -import retrofit2.Response -import java.util.concurrent.TimeUnit import javax.inject.Inject class AccountViewModel @Inject constructor( private val mastodonApi: MastodonApi, private val eventHub: EventHub, private val accountManager: AccountManager -) : RxAwareViewModel() { +) : ViewModel() { val accountData = MutableLiveData>() val relationshipData = MutableLiveData>() @@ -44,15 +41,16 @@ class AccountViewModel @Inject constructor( lateinit var accountId: String var isSelf = false - private var noteDisposable: Disposable? = null + private var noteUpdateJob: Job? = null init { - eventHub.events - .subscribe { event -> + viewModelScope.launch { + eventHub.events.collect { event -> if (event is ProfileEditedEvent && event.newProfileData.id == accountData.value?.data?.id) { accountData.postValue(Success(event.newProfileData)) } - }.autoDispose() + } + } } private fun obtainAccount(reload: Boolean = false) { @@ -60,40 +58,41 @@ class AccountViewModel @Inject constructor( isDataLoading = true accountData.postValue(Loading()) - mastodonApi.account(accountId) - .subscribe( - { account -> - accountData.postValue(Success(account)) - isDataLoading = false - isRefreshing.postValue(false) - }, - { t -> - Log.w(TAG, "failed obtaining account", t) - accountData.postValue(Error()) - isDataLoading = false - isRefreshing.postValue(false) - } - ) - .autoDispose() + viewModelScope.launch { + mastodonApi.account(accountId) + .fold( + { account -> + accountData.postValue(Success(account)) + isDataLoading = false + isRefreshing.postValue(false) + }, + { t -> + Log.w(TAG, "failed obtaining account", t) + accountData.postValue(Error(cause = t)) + isDataLoading = false + isRefreshing.postValue(false) + } + ) + } } } private fun obtainRelationship(reload: Boolean = false) { if (relationshipData.value == null || reload) { - relationshipData.postValue(Loading()) - mastodonApi.relationships(listOf(accountId)) - .subscribe( - { relationships -> - relationshipData.postValue(Success(relationships[0])) - }, - { t -> - Log.w(TAG, "failed obtaining relationships", t) - relationshipData.postValue(Error()) - } - ) - .autoDispose() + viewModelScope.launch { + mastodonApi.relationships(listOf(accountId)) + .fold( + { relationships -> + relationshipData.postValue(if (relationships.isNotEmpty()) Success(relationships[0]) else Error()) + }, + { t -> + Log.w(TAG, "failed obtaining relationships", t) + relationshipData.postValue(Error(cause = t)) + } + ) + } } } @@ -134,42 +133,30 @@ class AccountViewModel @Inject constructor( } fun blockDomain(instance: String) { - mastodonApi.blockDomain(instance).enqueue(object : Callback { - override fun onResponse(call: Call, response: Response) { - if (response.isSuccessful) { - eventHub.dispatch(DomainMuteEvent(instance)) - val relation = relationshipData.value?.data - if (relation != null) { - relationshipData.postValue(Success(relation.copy(blockingDomain = true))) - } - } else { - Log.e(TAG, "Error muting %s".format(instance)) + viewModelScope.launch { + mastodonApi.blockDomain(instance).fold({ + eventHub.dispatch(DomainMuteEvent(instance)) + val relation = relationshipData.value?.data + if (relation != null) { + relationshipData.postValue(Success(relation.copy(blockingDomain = true))) } - } - - override fun onFailure(call: Call, t: Throwable) { - Log.e(TAG, "Error muting %s".format(instance), t) - } - }) + }, { e -> + Log.e(TAG, "Error muting $instance", e) + }) + } } fun unblockDomain(instance: String) { - mastodonApi.unblockDomain(instance).enqueue(object : Callback { - override fun onResponse(call: Call, response: Response) { - if (response.isSuccessful) { - val relation = relationshipData.value?.data - if (relation != null) { - relationshipData.postValue(Success(relation.copy(blockingDomain = false))) - } - } else { - Log.e(TAG, "Error unmuting %s".format(instance)) + viewModelScope.launch { + mastodonApi.unblockDomain(instance).fold({ + val relation = relationshipData.value?.data + if (relation != null) { + relationshipData.postValue(Success(relation.copy(blockingDomain = false))) } - } - - override fun onFailure(call: Call, t: Throwable) { - Log.e(TAG, "Error unmuting %s".format(instance), t) - } - }) + }, { e -> + Log.e(TAG, "Error unmuting $instance", e) + }) + } } fun changeShowReblogsState() { @@ -209,84 +196,88 @@ class AccountViewModel @Inject constructor( RelationShipAction.MUTE -> relation.copy(muting = true) RelationShipAction.UNMUTE -> relation.copy(muting = false) RelationShipAction.SUBSCRIBE -> { - if (isMastodon) + if (isMastodon) { relation.copy(notifying = true) - else relation.copy(subscribing = true) + } else { + relation.copy(subscribing = true) + } } RelationShipAction.UNSUBSCRIBE -> { - if (isMastodon) + if (isMastodon) { relation.copy(notifying = false) - else relation.copy(subscribing = false) + } else { + relation.copy(subscribing = false) + } } } relationshipData.postValue(Loading(newRelation)) } - try { - val relationship = when (relationshipAction) { - RelationShipAction.FOLLOW -> mastodonApi.followAccount( - accountId, - showReblogs = parameter ?: true - ) - RelationShipAction.UNFOLLOW -> mastodonApi.unfollowAccount(accountId) - RelationShipAction.BLOCK -> mastodonApi.blockAccount(accountId) - RelationShipAction.UNBLOCK -> mastodonApi.unblockAccount(accountId) - RelationShipAction.MUTE -> mastodonApi.muteAccount( - accountId, - parameter ?: true, - duration - ) - RelationShipAction.UNMUTE -> mastodonApi.unmuteAccount(accountId) - RelationShipAction.SUBSCRIBE -> { - if (isMastodon) - mastodonApi.followAccount(accountId, notify = true) - else mastodonApi.subscribeAccount(accountId) - } - RelationShipAction.UNSUBSCRIBE -> { - if (isMastodon) - mastodonApi.followAccount(accountId, notify = false) - else mastodonApi.unsubscribeAccount(accountId) + val relationshipCall = when (relationshipAction) { + RelationShipAction.FOLLOW -> mastodonApi.followAccount( + accountId, + showReblogs = parameter ?: true + ) + RelationShipAction.UNFOLLOW -> mastodonApi.unfollowAccount(accountId) + RelationShipAction.BLOCK -> mastodonApi.blockAccount(accountId) + RelationShipAction.UNBLOCK -> mastodonApi.unblockAccount(accountId) + RelationShipAction.MUTE -> mastodonApi.muteAccount( + accountId, + parameter ?: true, + duration + ) + RelationShipAction.UNMUTE -> mastodonApi.unmuteAccount(accountId) + RelationShipAction.SUBSCRIBE -> { + if (isMastodon) { + mastodonApi.followAccount(accountId, notify = true) + } else { + mastodonApi.subscribeAccount(accountId) } } - - 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 -> { + RelationShipAction.UNSUBSCRIBE -> { + if (isMastodon) { + mastodonApi.followAccount(accountId, notify = false) + } else { + mastodonApi.unsubscribeAccount(accountId) } } - } 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) { noteSaved.postValue(false) - noteDisposable?.dispose() - noteDisposable = Single.timer(1500, TimeUnit.MILLISECONDS) - .flatMap { - mastodonApi.updateAccountNote(accountId, newNote) - } - .doOnSuccess { - noteSaved.postValue(true) - } - .delay(4, TimeUnit.SECONDS) - .subscribe( - { - noteSaved.postValue(false) - }, - { - Log.e(TAG, "Error updating note", it) - } - ) - } - - override fun onCleared() { - super.onCleared() - noteDisposable?.dispose() + noteUpdateJob?.cancel() + noteUpdateJob = viewModelScope.launch { + delay(1500) + mastodonApi.updateAccountNote(accountId, newNote) + .fold( + { + noteSaved.postValue(true) + delay(4000) + noteSaved.postValue(false) + }, + { t -> + Log.w(TAG, "Error updating note", t) + } + ) + } } fun refresh() { @@ -294,12 +285,14 @@ class AccountViewModel @Inject constructor( } private fun reload(isReload: Boolean = false) { - if (isDataLoading) + if (isDataLoading) { return + } accountId.let { obtainAccount(isReload) - if (!isSelf) + if (!isSelf) { obtainRelationship(isReload) + } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/list/ListsForAccountFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/list/ListsForAccountFragment.kt index 874d4b9f..08c93756 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/list/ListsForAccountFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/list/ListsForAccountFragment.kt @@ -40,7 +40,6 @@ import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -import java.io.IOException import javax.inject.Inject class ListsForAccountFragment : DialogFragment(), Injectable { @@ -65,7 +64,7 @@ class ListsForAccountFragment : DialogFragment(), Injectable { dialog?.apply { window?.setLayout( LinearLayout.LayoutParams.MATCH_PARENT, - LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.MATCH_PARENT ) } } @@ -103,16 +102,7 @@ class ListsForAccountFragment : DialogFragment(), Injectable { binding.listsView.hide() binding.messageView.apply { show() - - if (error is IOException) { - setup(R.drawable.elephant_offline, R.string.error_network) { - load() - } - } else { - setup(R.drawable.elephant_error, R.string.error_generic) { - load() - } - } + setup(error) { load() } } } } @@ -172,7 +162,7 @@ class ListsForAccountFragment : DialogFragment(), Injectable { ListAdapter>(Differ) { override fun onCreateViewHolder( parent: ViewGroup, - viewType: Int, + viewType: Int ): BindingHolder { val binding = ItemAddOrRemoveFromListBinding.inflate(LayoutInflater.from(parent.context), parent, false) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/list/ListsForAccountViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/list/ListsForAccountViewModel.kt index b571390e..11009696 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/list/ListsForAccountViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/list/ListsForAccountViewModel.kt @@ -35,23 +35,23 @@ import javax.inject.Inject data class AccountListState( val list: MastoList, - val includesAccount: Boolean, + val includesAccount: Boolean ) data class ActionError( val error: Throwable, val type: Type, - val listId: String, + val listId: String ) : Throwable(error) { enum class Type { ADD, - REMOVE, + REMOVE } } @OptIn(ExperimentalCoroutinesApi::class) class ListsForAccountViewModel @Inject constructor( - private val mastodonApi: MastodonApi, + private val mastodonApi: MastodonApi ) : ViewModel() { private lateinit var accountId: String @@ -75,14 +75,14 @@ class ListsForAccountViewModel @Inject constructor( runCatching { val (all, includes) = listOf( async { mastodonApi.getLists() }, - async { mastodonApi.getListsIncludesAccount(accountId) }, + async { mastodonApi.getListsIncludesAccount(accountId) } ).awaitAll() _states.emit( all.getOrThrow().map { list -> AccountListState( list = list, - includesAccount = includes.getOrThrow().any { it.id == list.id }, + includesAccount = includes.getOrThrow().any { it.id == list.id } ) } ) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaFragment.kt index 457cda7b..680e598f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaFragment.kt @@ -16,15 +16,21 @@ package com.keylesspalace.tusky.components.account.media import android.os.Bundle +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem import android.view.View import androidx.core.app.ActivityOptionsCompat +import androidx.core.view.MenuProvider import androidx.core.view.ViewCompat import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.paging.LoadState import androidx.preference.PreferenceManager import androidx.recyclerview.widget.GridLayoutManager +import com.google.android.material.color.MaterialColors import com.keylesspalace.tusky.R import com.keylesspalace.tusky.ViewMediaActivity import com.keylesspalace.tusky.databinding.FragmentTimelineBinding @@ -39,20 +45,21 @@ import com.keylesspalace.tusky.util.openLink import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.viewdata.AttachmentViewData +import com.mikepenz.iconics.IconicsDrawable +import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial +import com.mikepenz.iconics.utils.colorInt +import com.mikepenz.iconics.utils.sizeDp import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -import java.io.IOException import javax.inject.Inject /** - * Created by charlag on 26/10/2017. - * * Fragment with multiple columns of media previews for the specified account. */ - class AccountMediaFragment : Fragment(R.layout.fragment_timeline), RefreshableFragment, + MenuProvider, Injectable { @Inject @@ -73,6 +80,7 @@ class AccountMediaFragment : } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) val alwaysShowSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia @@ -95,6 +103,8 @@ class AccountMediaFragment : binding.recyclerView.adapter = adapter binding.swipeRefreshLayout.isEnabled = false + binding.swipeRefreshLayout.setOnRefreshListener { refreshContent() } + binding.swipeRefreshLayout.setColorSchemeResources(R.color.chinwag_green) binding.statusView.visibility = View.GONE @@ -108,6 +118,10 @@ class AccountMediaFragment : binding.statusView.hide() binding.progressBar.hide() + if (loadState.refresh != LoadState.Loading && loadState.source.refresh != LoadState.Loading) { + binding.swipeRefreshLayout.isRefreshing = false + } + if (adapter.itemCount == 0) { when (loadState.refresh) { is LoadState.NotLoading -> { @@ -118,12 +132,7 @@ class AccountMediaFragment : } is LoadState.Error -> { binding.statusView.show() - - if ((loadState.refresh as LoadState.Error).error is IOException) { - binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network, null) - } else { - binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic, null) - } + binding.statusView.setup((loadState.refresh as LoadState.Error).error) } is LoadState.Loading -> { binding.progressBar.show() @@ -133,6 +142,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) { if (!selected.isRevealed) { viewModel.revealAttachment(selected) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaGridAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaGridAdapter.kt index fcc3bcf9..48b3a21d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaGridAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaGridAdapter.kt @@ -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 mediaHiddenDrawable = AppCompatResources.getDrawable(context, R.drawable.ic_hide_media_24dp) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaPagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaPagingSource.kt index 60c76743..0ed67cf4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaPagingSource.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaPagingSource.kt @@ -26,7 +26,6 @@ class AccountMediaPagingSource( override fun getRefreshKey(state: PagingState): String? = null override suspend fun load(params: LoadParams): LoadResult { - return if (params is LoadParams.Refresh) { val list = viewModel.attachmentData.toList() LoadResult.Page(list, null, list.lastOrNull()?.statusId) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaRemoteMediator.kt index 81865b0f..315b0380 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaRemoteMediator.kt @@ -34,7 +34,6 @@ class AccountMediaRemoteMediator( loadType: LoadType, state: PagingState ): MediatorResult { - try { val statusResponse = when (loadType) { LoadType.REFRESH -> { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaViewModel.kt index 5c3528e9..ddbcb71d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaViewModel.kt @@ -25,7 +25,7 @@ import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.viewdata.AttachmentViewData import javax.inject.Inject -class AccountMediaViewModel @Inject constructor ( +class AccountMediaViewModel @Inject constructor( api: MastodonApi ) : ViewModel() { diff --git a/app/src/main/java/com/keylesspalace/tusky/AccountListActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListActivity.kt similarity index 88% rename from app/src/main/java/com/keylesspalace/tusky/AccountListActivity.kt rename to app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListActivity.kt index ca23f791..f5981c0b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/AccountListActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListActivity.kt @@ -13,18 +13,20 @@ * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ -package com.keylesspalace.tusky +package com.keylesspalace.tusky.components.accountlist import android.content.Context import android.content.Intent 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.fragment.AccountListFragment import dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector import javax.inject.Inject -class AccountListActivity : BaseActivity(), HasAndroidInjector { +class AccountListActivity : BottomSheetActivity(), HasAndroidInjector { @Inject lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector @@ -63,10 +65,9 @@ class AccountListActivity : BaseActivity(), HasAndroidInjector { setDisplayShowHomeEnabled(true) } - supportFragmentManager - .beginTransaction() - .replace(R.id.fragment_container, AccountListFragment.newInstance(type, id, accountLocked)) - .commit() + supportFragmentManager.commit { + replace(R.id.fragment_container, AccountListFragment.newInstance(type, id, accountLocked)) + } } override fun androidInjector() = dispatchingAndroidInjector @@ -76,8 +77,6 @@ class AccountListActivity : BaseActivity(), HasAndroidInjector { private const val EXTRA_ID = "id" private const val EXTRA_ACCOUNT_LOCKED = "acc_locked" - @JvmStatic - @JvmOverloads fun newIntent(context: Context, type: Type, id: String? = null, accountLocked: Boolean = false): Intent { return Intent(context, AccountListActivity::class.java).apply { putExtra(EXTRA_TYPE, type) diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/AccountListFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListFragment.kt similarity index 85% rename from app/src/main/java/com/keylesspalace/tusky/fragment/AccountListFragment.kt rename to app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListFragment.kt index 9fa321d3..0287bfb4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/AccountListFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListFragment.kt @@ -13,7 +13,7 @@ * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ -package com.keylesspalace.tusky.fragment +package com.keylesspalace.tusky.components.accountlist import android.os.Bundle import android.util.Log @@ -27,25 +27,30 @@ import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.SimpleItemAnimator +import at.connyduck.calladapter.networkresult.fold import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from import autodispose2.autoDispose import com.google.android.material.snackbar.Snackbar -import com.keylesspalace.tusky.AccountListActivity.Type import com.keylesspalace.tusky.BaseActivity +import com.keylesspalace.tusky.BottomSheetActivity +import com.keylesspalace.tusky.PostLookupFallbackBehavior import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.adapter.AccountAdapter -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.StatusListActivity 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.db.AccountManager import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.entity.Relationship import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.interfaces.AccountActionListener +import com.keylesspalace.tusky.interfaces.LinkListener import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.util.HttpHeaderLink @@ -59,10 +64,15 @@ import retrofit2.Response import java.io.IOException 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 lateinit var api: MastodonApi + @Inject lateinit var accountManager: AccountManager @@ -83,15 +93,15 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.recyclerView.setHasFixedSize(true) val layoutManager = LinearLayoutManager(view.context) binding.recyclerView.layoutManager = layoutManager (binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false - binding.recyclerView.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL)) + binding.swipeRefreshLayout.setOnRefreshListener { fetchAccounts() } + binding.swipeRefreshLayout.setColorSchemeResources(R.color.chinwag_green) + val pm = PreferenceManager.getDefaultSharedPreferences(view.context) val animateAvatar = pm.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false) val animateEmojis = pm.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) @@ -101,8 +111,11 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct Type.BLOCKS -> BlocksAdapter(this, animateAvatar, animateEmojis, showBotOverlay) Type.MUTES -> MutesAdapter(this, animateAvatar, animateEmojis, showBotOverlay) Type.FOLLOW_REQUESTS -> { - val headerAdapter = FollowRequestsHeaderAdapter(accountManager.activeAccount!!.domain, arguments?.getBoolean(ARG_ACCOUNT_LOCKED) == true) - val followRequestsAdapter = FollowRequestsAdapter(this, animateAvatar, animateEmojis, showBotOverlay) + val headerAdapter = FollowRequestsHeaderAdapter( + 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) followRequestsAdapter } @@ -126,6 +139,11 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct fetchAccounts() } + override fun onViewTag(tag: String) { + (activity as BaseActivity?) + ?.startActivityWithSlideInAnimation(StatusListActivity.newHashtagIntent(requireContext(), tag)) + } + override fun onViewAccount(id: String) { (activity as BaseActivity?)?.let { val intent = AccountActivity.getIntent(it, id) @@ -133,6 +151,10 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct } } + override fun onViewUrl(url: String) { + (activity as BottomSheetActivity?)?.viewUrl(url, PostLookupFallbackBehavior.OPEN_IN_BROWSER) + } + override fun onMute(mute: Boolean, id: String, position: Int, notifications: Boolean) { viewLifecycleOwner.lifecycleScope.launch { try { @@ -225,7 +247,6 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct accountId: String, position: Int ) { - if (accept) { api.authorizeFollowRequest(accountId) } else { @@ -285,6 +306,7 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct return } fetching = true + binding.swipeRefreshLayout.isRefreshing = true if (fromId != null) { binding.recyclerView.post { adapter.setBottomLoading(true) } @@ -293,6 +315,7 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct viewLifecycleOwner.lifecycleScope.launch { try { val response = getFetchCallByListType(fromId) + if (!response.isSuccessful) { onFetchAccountsFailure(Exception(response.message())) return@launch @@ -315,6 +338,7 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct private fun onFetchAccountsSuccess(accounts: List, linkHeader: String?) { adapter.setBottomLoading(false) + binding.swipeRefreshLayout.isRefreshing = false val links = HttpHeaderLink.parse(linkHeader) val next = HttpHeaderLink.findByRelationType(links, "next") @@ -347,12 +371,12 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct } private fun fetchRelationships(ids: List) { - api.relationships(ids) - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(from(this)) - .subscribe(::onFetchRelationshipsSuccess) { - onFetchRelationshipsFailure(ids) - } + lifecycleScope.launch { + api.relationships(ids) + .fold(::onFetchRelationshipsSuccess) { throwable -> + Log.e(TAG, "Fetch failure for relationships of accounts: $ids", throwable) + } + } } private fun onFetchRelationshipsSuccess(relationships: List) { @@ -362,26 +386,16 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct mutesAdapter.updateMutingNotificationsMap(mutingNotificationsMap) } - private fun onFetchRelationshipsFailure(ids: List) { - Log.e(TAG, "Fetch failure for relationships of accounts: $ids") - } - private fun onFetchAccountsFailure(throwable: Throwable) { fetching = false + binding.swipeRefreshLayout.isRefreshing = false Log.e(TAG, "Fetch failure", throwable) if (adapter.itemCount == 0) { binding.messageView.show() - if (throwable is IOException) { - binding.messageView.setup(R.drawable.elephant_offline, R.string.error_network) { - binding.messageView.hide() - this.fetchAccounts(null) - } - } else { - binding.messageView.setup(R.drawable.elephant_error, R.string.error_generic) { - binding.messageView.hide() - this.fetchAccounts(null) - } + binding.messageView.setup(throwable) { + binding.messageView.hide() + this.fetchAccounts(null) } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/AccountAdapter.kt similarity index 89% rename from app/src/main/java/com/keylesspalace/tusky/adapter/AccountAdapter.kt rename to app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/AccountAdapter.kt index bbd83df3..7d050e7e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/AccountAdapter.kt @@ -12,24 +12,26 @@ * * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ -package com.keylesspalace.tusky.adapter +package com.keylesspalace.tusky.components.accountlist.adapter import android.view.LayoutInflater import android.view.ViewGroup 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.interfaces.AccountActionListener +import com.keylesspalace.tusky.util.BindingHolder import com.keylesspalace.tusky.util.removeDuplicates /** Generic adapter with bottom loading indicator. */ abstract class AccountAdapter internal constructor( - var accountActionListener: AccountActionListener, + protected val accountActionListener: AccountActionListener, protected val animateAvatar: Boolean, protected val animateEmojis: Boolean, protected val showBotOverlay: Boolean ) : RecyclerView.Adapter() { - var accountList = mutableListOf() + + protected var accountList: MutableList = mutableListOf() private var bottomLoading: Boolean = false override fun getItemCount(): Int { @@ -59,11 +61,10 @@ abstract class AccountAdapter internal constructo } private fun createFooterViewHolder( - parent: ViewGroup, + parent: ViewGroup ): RecyclerView.ViewHolder { - val view = LayoutInflater.from(parent.context) - .inflate(R.layout.item_footer, parent, false) - return LoadingFooterViewHolder(view) + val binding = ItemFooterBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return BindingHolder(binding) } override fun getItemViewType(position: Int): Int { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/BlocksAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/BlocksAdapter.kt new file mode 100644 index 00000000..2ef520d5 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/BlocksAdapter.kt @@ -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 . */ + +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>( + accountActionListener = accountActionListener, + animateAvatar = animateAvatar, + animateEmojis = animateEmojis, + showBotOverlay = showBotOverlay +) { + + override fun createAccountViewHolder(parent: ViewGroup): BindingHolder { + val binding = ItemBlockedUserBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return BindingHolder(binding) + } + + override fun onBindAccountViewHolder(viewHolder: BindingHolder, 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) + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowAdapter.kt similarity index 80% rename from app/src/main/java/com/keylesspalace/tusky/adapter/FollowAdapter.kt rename to app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowAdapter.kt index 5c546305..87b62486 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowAdapter.kt @@ -12,10 +12,12 @@ * * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ -package com.keylesspalace.tusky.adapter + +package com.keylesspalace.tusky.components.accountlist.adapter import android.view.LayoutInflater import android.view.ViewGroup +import com.keylesspalace.tusky.adapter.AccountViewHolder import com.keylesspalace.tusky.databinding.ItemAccountBinding import com.keylesspalace.tusky.interfaces.AccountActionListener @@ -26,17 +28,14 @@ class FollowAdapter( animateEmojis: Boolean, showBotOverlay: Boolean ) : AccountAdapter( - accountActionListener, - animateAvatar, - animateEmojis, - showBotOverlay + accountActionListener = accountActionListener, + animateAvatar = animateAvatar, + animateEmojis = animateEmojis, + showBotOverlay = showBotOverlay ) { + override fun createAccountViewHolder(parent: ViewGroup): AccountViewHolder { - val binding = ItemAccountBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) + val binding = ItemAccountBinding.inflate(LayoutInflater.from(parent.context), parent, false) return AccountViewHolder(binding) } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsAdapter.kt similarity index 62% rename from app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestsAdapter.kt rename to app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsAdapter.kt index 95d944bd..fc860e59 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestsAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsAdapter.kt @@ -12,29 +12,51 @@ * * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ -package com.keylesspalace.tusky.adapter + +package com.keylesspalace.tusky.components.accountlist.adapter import android.view.LayoutInflater import android.view.ViewGroup +import com.keylesspalace.tusky.adapter.FollowRequestViewHolder import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding import com.keylesspalace.tusky.interfaces.AccountActionListener +import com.keylesspalace.tusky.interfaces.LinkListener /** Displays a list of follow requests with accept/reject buttons. */ class FollowRequestsAdapter( accountActionListener: AccountActionListener, + private val linkListener: LinkListener, animateAvatar: Boolean, animateEmojis: Boolean, showBotOverlay: Boolean -) : AccountAdapter(accountActionListener, animateAvatar, animateEmojis, showBotOverlay) { +) : AccountAdapter( + accountActionListener = accountActionListener, + animateAvatar = animateAvatar, + animateEmojis = animateEmojis, + showBotOverlay = showBotOverlay +) { + override fun createAccountViewHolder(parent: ViewGroup): FollowRequestViewHolder { 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) { - viewHolder.setupWithAccount(accountList[position], animateAvatar, animateEmojis, showBotOverlay) + viewHolder.setupWithAccount( + account = accountList[position], + animateAvatar = animateAvatar, + animateEmojis = animateEmojis, + showBotOverlay = showBotOverlay + ) viewHolder.setupActionListener(accountActionListener, accountList[position].id) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestsHeaderAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsHeaderAdapter.kt similarity index 54% rename from app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestsHeaderAdapter.kt rename to app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsHeaderAdapter.kt index 2480086e..85cf4e20 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestsHeaderAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsHeaderAdapter.kt @@ -13,27 +13,28 @@ * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ -package com.keylesspalace.tusky.adapter +package com.keylesspalace.tusky.components.accountlist.adapter import android.view.LayoutInflater import android.view.ViewGroup -import android.widget.TextView import androidx.recyclerview.widget.RecyclerView 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() { +class FollowRequestsHeaderAdapter( + private val instanceName: String, + private val accountLocked: Boolean +) : RecyclerView.Adapter>() { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HeaderViewHolder { - val view = LayoutInflater.from(parent.context) - .inflate(R.layout.item_follow_requests_header, parent, false) as TextView - return HeaderViewHolder(view) + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder { + val binding = ItemFollowRequestsHeaderBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return BindingHolder(binding) } - override fun onBindViewHolder(viewHolder: HeaderViewHolder, position: Int) { - viewHolder.textView.text = viewHolder.textView.context.getString(R.string.follow_requests_info, instanceName) + override fun onBindViewHolder(viewHolder: BindingHolder, position: Int) { + viewHolder.binding.root.text = viewHolder.binding.root.context.getString(R.string.follow_requests_info, instanceName) } override fun getItemCount() = if (accountLocked) 0 else 1 } - -class HeaderViewHolder(var textView: TextView) : RecyclerView.ViewHolder(textView) diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/MutesAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/MutesAdapter.kt similarity index 74% rename from app/src/main/java/com/keylesspalace/tusky/adapter/MutesAdapter.kt rename to app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/MutesAdapter.kt index 42e19c65..288d1339 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/MutesAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/MutesAdapter.kt @@ -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 . */ + +package com.keylesspalace.tusky.components.accountlist.adapter import android.view.LayoutInflater 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.emojify import com.keylesspalace.tusky.util.loadAvatar +import com.keylesspalace.tusky.util.visible -/** - * Displays a list of muted accounts with mute/unmute account and mute/unmute notifications - * buttons. - * */ +/** Displays a list of muted accounts with mute/unmute account button and mute/unmute notifications switch */ class MutesAdapter( accountActionListener: AccountActionListener, animateAvatar: Boolean, animateEmojis: Boolean, showBotOverlay: Boolean ) : AccountAdapter>( - accountActionListener, - animateAvatar, - animateEmojis, - showBotOverlay + accountActionListener = accountActionListener, + animateAvatar = animateAvatar, + animateEmojis = animateEmojis, + showBotOverlay = showBotOverlay ) { + private val mutingNotificationsMap = HashMap() override fun createAccountViewHolder(parent: ViewGroup): BindingHolder { @@ -48,6 +62,8 @@ class MutesAdapter( val avatarRadius = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp) loadAvatar(account.avatar, binding.mutedUserAvatar, avatarRadius, animateAvatar) + binding.mutedUserBotBadge.visible(showBotOverlay && account.bot) + val unmuteString = context.getString(R.string.action_unmute_desc, formattedUsername) binding.mutedUserUnmute.contentDescription = unmuteString ViewCompat.setTooltipText(binding.mutedUserUnmute, unmuteString) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementAdapter.kt index 6ebe76b7..8f30c5e4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementAdapter.kt @@ -80,7 +80,7 @@ class AnnouncementAdapter( item.reactions.forEachIndexed { i, reaction -> ( 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 checkedIcon = null chips.addView(this, i) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsActivity.kt index 869eba64..73fd84a6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsActivity.kt @@ -19,12 +19,17 @@ import android.content.Context import android.content.Intent import android.content.SharedPreferences import android.os.Bundle +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem import android.view.View import android.widget.PopupWindow import androidx.activity.viewModels +import androidx.core.view.MenuProvider import androidx.preference.PreferenceManager import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.color.MaterialColors import com.keylesspalace.tusky.BottomSheetActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.StatusListActivity @@ -39,11 +44,21 @@ import com.keylesspalace.tusky.util.Loading import com.keylesspalace.tusky.util.Success import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.util.unsafeLazy import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.view.EmojiPicker +import com.mikepenz.iconics.IconicsDrawable +import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial +import com.mikepenz.iconics.utils.colorInt +import com.mikepenz.iconics.utils.sizeDp import javax.inject.Inject -class AnnouncementsActivity : BottomSheetActivity(), AnnouncementActionListener, OnEmojiSelectedListener, Injectable { +class AnnouncementsActivity : + BottomSheetActivity(), + AnnouncementActionListener, + OnEmojiSelectedListener, + MenuProvider, + Injectable { @Inject lateinit var viewModelFactory: ViewModelFactory @@ -54,8 +69,8 @@ class AnnouncementsActivity : BottomSheetActivity(), AnnouncementActionListener, private lateinit var adapter: AnnouncementAdapter - private val picker by lazy { EmojiPicker(this) } - private val pickerDialog by lazy { + private val picker by unsafeLazy { EmojiPicker(this) } + private val pickerDialog by unsafeLazy { PopupWindow(this) .apply { contentView = picker @@ -70,6 +85,7 @@ class AnnouncementsActivity : BottomSheetActivity(), AnnouncementActionListener, override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(binding.root) + addMenuProvider(this) setSupportActionBar(binding.includedToolbar.toolbar) supportActionBar?.apply { @@ -129,6 +145,27 @@ class AnnouncementsActivity : BottomSheetActivity(), AnnouncementActionListener, 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() { viewModel.load() binding.swipeRefreshLayout.isRefreshing = true diff --git a/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsViewModel.kt index c7e6781a..8abad91a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsViewModel.kt @@ -107,8 +107,7 @@ class AnnouncementsViewModel @Inject constructor( } else { listOf( *announcement.reactions.toTypedArray(), - emojis.value!!.find { emoji -> emoji.shortcode == name } - !!.run { + emojis.value!!.find { emoji -> emoji.shortcode == name }!!.run { Announcement.Reaction( name, 1, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt index 1df1280a..c0c4579c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt @@ -31,6 +31,8 @@ import android.os.Build import android.os.Bundle import android.os.Parcelable import android.provider.MediaStore +import android.text.Spanned +import android.text.style.URLSpan import android.util.Log import android.view.KeyEvent import android.view.MenuItem @@ -51,10 +53,15 @@ import androidx.appcompat.app.AlertDialog import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.core.content.FileProvider +import androidx.core.content.IntentCompat +import androidx.core.content.res.use +import androidx.core.os.BundleCompat import androidx.core.view.ContentInfoCompat import androidx.core.view.OnReceiveContentListener import androidx.core.view.isGone import androidx.core.view.isVisible +import androidx.core.widget.doAfterTextChanged +import androidx.core.widget.doOnTextChanged import androidx.lifecycle.lifecycleScope import androidx.preference.PreferenceManager import androidx.recyclerview.widget.LinearLayoutManager @@ -71,6 +78,7 @@ import com.keylesspalace.tusky.R import com.keylesspalace.tusky.adapter.EmojiAdapter import com.keylesspalace.tusky.adapter.LocaleAdapter import com.keylesspalace.tusky.adapter.OnEmojiSelectedListener +import com.keylesspalace.tusky.components.compose.ComposeViewModel.ConfirmationKind import com.keylesspalace.tusky.components.compose.dialog.CaptionDialog import com.keylesspalace.tusky.components.compose.dialog.makeFocusDialog import com.keylesspalace.tusky.components.compose.dialog.showAddPollDialog @@ -88,18 +96,18 @@ import com.keylesspalace.tusky.entity.NewPoll import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.settings.PrefKeys 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.afterTextChanged -import com.keylesspalace.tusky.util.getInitialLanguage +import com.keylesspalace.tusky.util.getInitialLanguages import com.keylesspalace.tusky.util.getLocaleList import com.keylesspalace.tusky.util.getMediaSize import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.highlightSpans import com.keylesspalace.tusky.util.loadAvatar import com.keylesspalace.tusky.util.modernLanguageCode -import com.keylesspalace.tusky.util.onTextChanged import com.keylesspalace.tusky.util.setDrawableTint import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.util.unsafeLazy import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible import com.mikepenz.iconics.IconicsDrawable @@ -137,9 +145,12 @@ class ComposeActivity : private lateinit var emojiBehavior: BottomSheetBehavior<*> private lateinit var scheduleBehavior: BottomSheetBehavior<*> + /** The account that is being used to compose the status */ + private lateinit var activeAccount: AccountEntity + private var photoUploadUri: Uri? = null - private val preferences by lazy { PreferenceManager.getDefaultSharedPreferences(this) } + private val preferences by unsafeLazy { PreferenceManager.getDefaultSharedPreferences(this) } @VisibleForTesting var maximumTootCharacters = InstanceInfoRepository.DEFAULT_CHARACTER_LIMIT @@ -203,10 +214,15 @@ class ComposeActivity : notificationManager.cancel(notificationId) } - val accountId = intent.getLongExtra(ACCOUNT_ID_EXTRA, -1) - if (accountId != -1L) { - accountManager.setActiveAccount(accountId) - } + // If started from an intent then compose as the account ID from the intent. + // Otherwise use the active account. If null then the user is not logged in, + // and return from the activity. + val intentAccountId = intent.getLongExtra(ACCOUNT_ID_EXTRA, -1) + activeAccount = if (intentAccountId != -1L) { + accountManager.getAccountById(intentAccountId) + } else { + accountManager.activeAccount + } ?: return val theme = preferences.getString("appTheme", APP_THEME_DEFAULT) if (theme == "black") { @@ -215,20 +231,18 @@ class ComposeActivity : setContentView(binding.root) setupActionBar() - // do not do anything when not logged in, activity will be finished in super.onCreate() anyway - val activeAccount = accountManager.activeAccount ?: return setupAvatar(activeAccount) val mediaAdapter = MediaPreviewAdapter( this, onAddCaption = { item -> - CaptionDialog.newInstance(item.localId, item.description, item.uri) - .show(supportFragmentManager, "caption_dialog") + CaptionDialog.newInstance(item.localId, item.description, item.uri).show(supportFragmentManager, "caption_dialog") }, onAddFocus = { item -> makeFocusDialog(item.focus, item.uri) { newFocus -> viewModel.updateFocus(item.localId, newFocus) } + // TODO this is inconsistent to CaptionDialog (device rotation)? }, onEditImage = this::editImageInQueue, onRemove = this::removeMediaFromQueue @@ -240,7 +254,7 @@ class ComposeActivity : /* If the composer is started up as a reply to another post, override the "starting" state * based on what the intent from the reply request passes. */ - val composeOptions: ComposeOptions? = intent.getParcelableExtra(COMPOSE_OPTIONS_EXTRA) + val composeOptions: ComposeOptions? = IntentCompat.getParcelableExtra(intent, COMPOSE_OPTIONS_EXTRA, ComposeOptions::class.java) viewModel.setup(composeOptions) setupButtons() @@ -266,7 +280,7 @@ class ComposeActivity : binding.composeScheduleView.setDateTime(composeOptions?.scheduledAt) } - setupLanguageSpinner(getInitialLanguage(composeOptions?.language, accountManager.activeAccount)) + setupLanguageSpinner(getInitialLanguages(composeOptions?.language, accountManager.activeAccount)) setupComposeField(preferences, viewModel.startingText) setupContentWarningField(composeOptions?.contentWarning) setupPollView() @@ -274,7 +288,7 @@ class ComposeActivity : /* Finally, overwrite state with data from saved instance state. */ savedInstanceState?.let { - photoUploadUri = it.getParcelable(PHOTO_UPLOAD_URI_KEY) + photoUploadUri = BundleCompat.getParcelable(it, PHOTO_UPLOAD_URI_KEY, Uri::class.java) (it.getSerializable(VISIBILITY_KEY) as Status.Visibility).apply { setStatusVisibility(this) @@ -303,12 +317,12 @@ class ComposeActivity : if (type.startsWith("image/") || type.startsWith("video/") || type.startsWith("audio/")) { when (intent.action) { Intent.ACTION_SEND -> { - intent.getParcelableExtra(Intent.EXTRA_STREAM)?.let { uri -> + IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)?.let { uri -> pickMedia(uri) } } Intent.ACTION_SEND_MULTIPLE -> { - intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)?.forEach { uri -> + IntentCompat.getParcelableArrayListExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)?.forEach { uri -> pickMedia(uri) } } @@ -368,7 +382,7 @@ class ComposeActivity : if (startingContentWarning != null) { binding.composeContentWarningField.setText(startingContentWarning) } - binding.composeContentWarningField.onTextChanged { _, _, _, _ -> updateVisibleCharactersLeft() } + binding.composeContentWarningField.doOnTextChanged { _, _, _, _ -> updateVisibleCharactersLeft() } } private fun setupComposeField(preferences: SharedPreferences, startingText: String?) { @@ -391,8 +405,8 @@ class ComposeActivity : val mentionColour = binding.composeEditField.linkTextColors.defaultColor highlightSpans(binding.composeEditField.text, mentionColour) - binding.composeEditField.afterTextChanged { editable -> - highlightSpans(editable, mentionColour) + binding.composeEditField.doAfterTextChanged { editable -> + highlightSpans(editable!!, mentionColour) updateVisibleCharactersLeft() } @@ -542,7 +556,7 @@ class ComposeActivity : ) } - private fun setupLanguageSpinner(initialLanguage: String) { + private fun setupLanguageSpinner(initialLanguages: List) { binding.composePostLanguageButton.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) { viewModel.postLanguage = (parent.adapter.getItem(position) as Locale).modernLanguageCode @@ -553,7 +567,7 @@ class ComposeActivity : } } 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) } } @@ -569,10 +583,10 @@ class ComposeActivity : } private fun setupAvatar(activeAccount: AccountEntity) { - val actionBarSizeAttr = intArrayOf(R.attr.actionBarSize) - val a = obtainStyledAttributes(null, actionBarSizeAttr) - val avatarSize = a.getDimensionPixelSize(0, 1) - a.recycle() + val actionBarSizeAttr = intArrayOf(androidx.appcompat.R.attr.actionBarSize) + val avatarSize = obtainStyledAttributes(null, actionBarSizeAttr).use { a -> + a.getDimensionPixelSize(0, 1) + } val animateAvatars = preferences.getBoolean("animateGifAvatars", false) loadAvatar( @@ -697,7 +711,7 @@ class ComposeActivity : var oneMediaWithoutDescription = false for (media in viewModel.media.value) { - if (media.description == null || media.description.isEmpty()) { + if (media.description.isNullOrEmpty()) { oneMediaWithoutDescription = true break } @@ -807,25 +821,26 @@ class ComposeActivity : } private fun onMediaPick() { - addMediaBehavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() { - override fun onStateChanged(bottomSheet: View, newState: Int) { - // Wait until bottom sheet is not collapsed and show next screen after - if (newState == BottomSheetBehavior.STATE_COLLAPSED) { - addMediaBehavior.removeBottomSheetCallback(this) - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && ContextCompat.checkSelfPermission(this@ComposeActivity, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { - ActivityCompat.requestPermissions( - this@ComposeActivity, - arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), - PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE - ) - } else { - pickMediaFile.launch(true) + addMediaBehavior.addBottomSheetCallback( + object : BottomSheetBehavior.BottomSheetCallback() { + override fun onStateChanged(bottomSheet: View, newState: Int) { + // Wait until bottom sheet is not collapsed and show next screen after + if (newState == BottomSheetBehavior.STATE_COLLAPSED) { + addMediaBehavior.removeBottomSheetCallback(this) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && ContextCompat.checkSelfPermission(this@ComposeActivity, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { + ActivityCompat.requestPermissions( + this@ComposeActivity, + arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), + PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE + ) + } else { + pickMediaFile.launch(true) + } } } - } - override fun onSlide(bottomSheet: View, slideOffset: Float) {} - } + override fun onSlide(bottomSheet: View, slideOffset: Float) {} + } ) addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED } @@ -881,20 +896,11 @@ class ComposeActivity : @VisibleForTesting fun calculateTextLength(): Int { - var offset = 0 - val urlSpans = binding.composeEditField.urls - if (urlSpans != null) { - for (span in urlSpans) { - // it's expected that this will be negative - // when the url length is less than the reserved character count - offset += (span.url.length - charactersReservedPerUrl) - } - } - var length = binding.composeEditField.length() - offset - if (viewModel.showContentWarning.value) { - length += binding.composeContentWarningField.length() - } - return length + return statusLength( + binding.composeEditField.text, + binding.composeContentWarningField.text, + charactersReservedPerUrl + ) } @VisibleForTesting @@ -937,7 +943,10 @@ class ComposeActivity : val split = contentInfo.partition { item: ClipData.Item -> item.uri != null } split.first?.let { content -> for (i in 0 until content.clip.itemCount) { - pickMedia(content.clip.getItemAt(i).uri) + pickMedia( + content.clip.getItemAt(i).uri, + contentInfo.clip.description.label as String? + ) } } return split.second @@ -957,9 +966,8 @@ class ComposeActivity : binding.composeEditField.error = getString(R.string.error_empty) enableButtons(true, viewModel.editing) } else if (characterCount <= maximumTootCharacters) { - lifecycleScope.launch { - viewModel.sendStatus(contentText, spoilerText) + viewModel.sendStatus(contentText, spoilerText, activeAccount.id) deleteDraftAndFinish() } } else { @@ -976,7 +984,8 @@ class ComposeActivity : pickMediaFile.launch(true) } else { Snackbar.make( - binding.activityCompose, R.string.error_media_upload_permission, + binding.activityCompose, + R.string.error_media_upload_permission, Snackbar.LENGTH_SHORT ).apply { setAction(R.string.action_retry) { onMediaPick() } @@ -1010,9 +1019,13 @@ class ComposeActivity : private fun enableButton(button: ImageButton, clickable: Boolean, colorActive: Boolean) { button.isEnabled = clickable setDrawableTint( - this, button.drawable, - if (colorActive) android.R.attr.textColorTertiary - else R.attr.textColorDisabled + this, + button.drawable, + if (colorActive) { + android.R.attr.textColorTertiary + } else { + R.attr.textColorDisabled + } ) } @@ -1020,8 +1033,11 @@ class ComposeActivity : binding.addPollTextActionTextView.isEnabled = enable val textColor = MaterialColors.getColor( binding.addPollTextActionTextView, - if (enable) android.R.attr.textColorTertiary - else R.attr.textColorDisabled + if (enable) { + android.R.attr.textColorTertiary + } else { + R.attr.textColorDisabled + } ) binding.addPollTextActionTextView.setTextColor(textColor) binding.addPollTextActionTextView.compoundDrawablesRelative[0].colorFilter = PorterDuffColorFilter(textColor, PorterDuff.Mode.SRC_IN) @@ -1051,9 +1067,9 @@ class ComposeActivity : viewModel.removeMediaFromQueue(item) } - private fun pickMedia(uri: Uri) { + private fun pickMedia(uri: Uri, description: String? = null) { lifecycleScope.launch { - viewModel.pickMedia(uri).onFailure { throwable -> + viewModel.pickMedia(uri, description).onFailure { throwable -> val errorString = when (throwable) { is FileSizeException -> { val decimalFormat = DecimalFormat("0.##") @@ -1114,16 +1130,19 @@ class ComposeActivity : private fun handleCloseButton() { val contentText = binding.composeEditField.text.toString() val contentWarning = binding.composeContentWarningField.text.toString() - if (viewModel.didChange(contentText, contentWarning)) { - when (viewModel.composeKind) { - ComposeKind.NEW -> getSaveAsDraftOrDiscardDialog(contentText, contentWarning) - ComposeKind.EDIT_DRAFT -> getUpdateDraftOrDiscardDialog(contentText, contentWarning) - ComposeKind.EDIT_POSTED -> getContinueEditingOrDiscardDialog() - ComposeKind.EDIT_SCHEDULED -> getContinueEditingOrDiscardDialog() - }.show() - } else { - viewModel.stopUploads() - finishWithoutSlideOutAnimation() + when (viewModel.handleCloseButton(contentText, contentWarning)) { + ConfirmationKind.NONE -> { + viewModel.stopUploads() + finishWithoutSlideOutAnimation() + } + ConfirmationKind.SAVE_OR_DISCARD -> + getSaveAsDraftOrDiscardDialog(contentText, contentWarning).show() + ConfirmationKind.UPDATE_OR_DISCARD -> + getUpdateDraftOrDiscardDialog(contentText, contentWarning).show() + ConfirmationKind.CONTINUE_EDITING_OR_DISCARD_CHANGES -> + getContinueEditingOrDiscardDialog().show() + ConfirmationKind.CONTINUE_EDITING_OR_DISCARD_DRAFT -> + getDeleteEmptyDraftOrContinueEditing().show() } } @@ -1188,6 +1207,23 @@ class ComposeActivity : } } + /** + * User is editing an existing draft and making it empty. + * The user can either delete the empty draft or go back to editing. + */ + private fun getDeleteEmptyDraftOrContinueEditing(): AlertDialog.Builder { + return AlertDialog.Builder(this) + .setMessage(R.string.compose_delete_draft) + .setPositiveButton(R.string.action_delete) { _, _ -> + viewModel.deleteDraft() + viewModel.stopUploads() + finishWithoutSlideOutAnimation() + } + .setNegativeButton(R.string.action_continue_edit) { _, _ -> + // Do nothing, dialog will dismiss, user can continue editing + } + } + private fun deleteDraftAndFinish() { viewModel.deleteDraft() finishWithoutSlideOutAnimation() @@ -1197,8 +1233,11 @@ class ComposeActivity : lifecycleScope.launch { val dialog = if (viewModel.shouldShowSaveDraftDialog()) { ProgressDialog.show( - this@ComposeActivity, null, - getString(R.string.saving_draft), true, false + this@ComposeActivity, + null, + getString(R.string.saving_draft), + true, + false ) } else { null @@ -1259,11 +1298,7 @@ class ComposeActivity : } override fun onUpdateDescription(localId: Int, description: String) { - lifecycleScope.launch { - if (!viewModel.updateDescription(localId, description)) { - Toast.makeText(this@ComposeActivity, R.string.error_failed_set_caption, Toast.LENGTH_SHORT).show() - } - } + viewModel.updateDescription(localId, description) } /** @@ -1350,5 +1385,53 @@ class ComposeActivity : fun canHandleMimeType(mimeType: String?): Boolean { 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?, 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 } + + return length + } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt index fea92b5f..90389d28 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt @@ -21,6 +21,7 @@ import androidx.core.net.toUri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import at.connyduck.calladapter.networkresult.fold +import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeKind import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia import com.keylesspalace.tusky.components.compose.ComposeAutoCompleteAdapter.AutocompleteResult import com.keylesspalace.tusky.components.drafts.DraftHelper @@ -48,7 +49,6 @@ import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.update -import kotlinx.coroutines.flow.updateAndGet import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import javax.inject.Inject @@ -95,7 +95,7 @@ class ComposeViewModel @Inject constructor( val media: MutableStateFlow> = MutableStateFlow(emptyList()) val uploadError = MutableSharedFlow(replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) - lateinit var composeKind: ComposeActivity.ComposeKind + lateinit var composeKind: ComposeKind // Used in ComposeActivity to pass state to result function when cropImage contract inflight var cropImageItemOld: QueuedMedia? = null @@ -130,7 +130,7 @@ class ComposeViewModel @Inject constructor( ): QueuedMedia { var stashMediaItem: QueuedMedia? = null - media.updateAndGet { mediaValue -> + media.update { mediaList -> val mediaItem = QueuedMedia( localId = mediaUploader.getNewLocalMediaId(), uri = uri, @@ -144,11 +144,11 @@ class ComposeViewModel @Inject constructor( if (replaceItem != null) { mediaUploader.cancelUploadScope(replaceItem.localId) - mediaValue.map { + mediaList.map { if (it.localId == replaceItem.localId) mediaItem else it } } 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 @@ -169,13 +169,13 @@ class ComposeViewModel @Inject constructor( state = if (event.processed) { QueuedMedia.State.PROCESSED } else { QueuedMedia.State.UNPROCESSED } ) 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) return@collect } } - media.update { mediaValue -> - mediaValue.map { mediaItem -> + media.update { mediaList -> + mediaList.map { mediaItem -> if (mediaItem.localId == newMediaItem.localId) { newMediaItem } else { @@ -189,7 +189,7 @@ class ComposeViewModel @Inject constructor( } private fun addUploadedMedia(id: String, type: QueuedMedia.Type, uri: Uri, description: String?, focus: Attachment.Focus?) { - media.update { mediaValue -> + media.update { mediaList -> val mediaItem = QueuedMedia( localId = mediaUploader.getNewLocalMediaId(), uri = uri, @@ -201,20 +201,41 @@ class ComposeViewModel @Inject constructor( focus = focus, state = QueuedMedia.State.PUBLISHED ) - mediaValue + mediaItem + mediaList + mediaItem } } fun removeMediaFromQueue(item: QueuedMedia) { mediaUploader.cancelUploadScope(item.localId) - media.update { mediaValue -> mediaValue.filter { it.localId != item.localId } } + media.update { mediaList -> mediaList.filter { it.localId != item.localId } } } fun toggleMarkSensitive() { this.markMediaAsSensitive.value = this.markMediaAsSensitive.value != true } - fun didChange(content: String?, contentWarning: String?): Boolean { + fun handleCloseButton(contentText: String?, contentWarning: String?): ConfirmationKind { + return if (didChange(contentText, contentWarning)) { + when (composeKind) { + ComposeKind.NEW -> if (isEmpty(contentText, contentWarning)) { + ConfirmationKind.NONE + } else { + ConfirmationKind.SAVE_OR_DISCARD + } + ComposeKind.EDIT_DRAFT -> if (isEmpty(contentText, contentWarning)) { + ConfirmationKind.CONTINUE_EDITING_OR_DISCARD_DRAFT + } else { + ConfirmationKind.UPDATE_OR_DISCARD + } + ComposeKind.EDIT_POSTED -> ConfirmationKind.CONTINUE_EDITING_OR_DISCARD_CHANGES + ComposeKind.EDIT_SCHEDULED -> ConfirmationKind.CONTINUE_EDITING_OR_DISCARD_CHANGES + } + } else { + ConfirmationKind.NONE + } + } + + private fun didChange(content: String?, contentWarning: String?): Boolean { val textChanged = content.orEmpty() != startingText.orEmpty() val contentWarningChanged = contentWarning.orEmpty() != startingContentWarning val mediaChanged = media.value.isNotEmpty() @@ -224,6 +245,10 @@ class ComposeViewModel @Inject constructor( return modifiedInitialState || textChanged || contentWarningChanged || mediaChanged || pollChanged || didScheduledTimeChange } + private fun isEmpty(content: String?, contentWarning: String?): Boolean { + return !modifiedInitialState && (content.isNullOrBlank() && contentWarning.isNullOrBlank() && media.value.isEmpty() && poll.value == null) + } + fun contentWarningChanged(value: Boolean) { showContentWarning.value = value contentWarningStateChanged = true @@ -274,7 +299,7 @@ class ComposeViewModel @Inject constructor( failedToSendAlert = false, scheduledAt = scheduledAt.value, language = postLanguage, - statusId = originalStatusId, + statusId = originalStatusId ) } @@ -284,9 +309,9 @@ class ComposeViewModel @Inject constructor( */ suspend fun sendStatus( content: String, - spoilerText: String + spoilerText: String, + accountId: Long ) { - if (!scheduledTootId.isNullOrEmpty()) { api.deleteScheduledStatus(scheduledTootId!!) } @@ -312,7 +337,7 @@ class ComposeViewModel @Inject constructor( poll = poll.value, replyingStatusContent = null, replyingStatusAuthorUsername = null, - accountId = accountManager.activeAccount!!.id, + accountId = accountId, draftId = draftId, idempotencyKey = randomAlphanumericString(16), retries = 0, @@ -323,10 +348,9 @@ class ComposeViewModel @Inject constructor( serviceClient.sendToot(tootToSend) } - // Updates a QueuedMedia item arbitrarily, then sends description and focus to server - private suspend fun updateMediaItem(localId: Int, mutator: (QueuedMedia) -> QueuedMedia): Boolean { - val newMediaList = media.updateAndGet { mediaValue -> - mediaValue.map { mediaItem -> + private fun updateMediaItem(localId: Int, mutator: (QueuedMedia) -> QueuedMedia) { + media.update { mediaList -> + mediaList.map { mediaItem -> if (mediaItem.localId == localId) { mutator(mediaItem) } else { @@ -334,30 +358,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 { - return updateMediaItem(localId) { mediaItem -> + fun updateDescription(localId: Int, description: String) { + updateMediaItem(localId) { mediaItem -> mediaItem.copy(description = description) } } - suspend fun updateFocus(localId: Int, focus: Attachment.Focus): Boolean { - return updateMediaItem(localId) { mediaItem -> + fun updateFocus(localId: Int, focus: Attachment.Focus) { + updateMediaItem(localId) { mediaItem -> mediaItem.copy(focus = focus) } } @@ -402,12 +412,11 @@ class ComposeViewModel @Inject constructor( } fun setup(composeOptions: ComposeActivity.ComposeOptions?) { - if (setupComplete) { return } - composeKind = composeOptions?.kind ?: ComposeActivity.ComposeKind.NEW + composeKind = composeOptions?.kind ?: ComposeKind.NEW val preferredVisibility = accountManager.activeAccount!!.defaultPostPrivacy @@ -437,14 +446,16 @@ class ComposeViewModel @Inject constructor( pickMedia(attachment.uri, attachment.description, attachment.focus) } } - } else composeOptions?.mediaAttachments?.forEach { a -> - // when coming from redraft or ScheduledTootActivity - val mediaType = when (a.type) { - Attachment.Type.VIDEO, Attachment.Type.GIFV -> QueuedMedia.Type.VIDEO - Attachment.Type.UNKNOWN, Attachment.Type.IMAGE -> QueuedMedia.Type.IMAGE - Attachment.Type.AUDIO -> QueuedMedia.Type.AUDIO + } else { + composeOptions?.mediaAttachments?.forEach { a -> + // when coming from redraft or ScheduledTootActivity + val mediaType = when (a.type) { + Attachment.Type.VIDEO, Attachment.Type.GIFV -> QueuedMedia.Type.VIDEO + 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 @@ -501,6 +512,14 @@ class ComposeViewModel @Inject constructor( private companion object { const val TAG = "ComposeViewModel" } + + enum class ConfirmationKind { + NONE, // just close + SAVE_OR_DISCARD, + UPDATE_OR_DISCARD, + CONTINUE_EDITING_OR_DISCARD_CHANGES, // editing post + CONTINUE_EDITING_OR_DISCARD_DRAFT // edit draft + } } /** diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ImageDownsizer.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ImageDownsizer.kt index 4976ae0c..39b44468 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/ImageDownsizer.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ImageDownsizer.kt @@ -41,7 +41,6 @@ fun downsizeImage( contentResolver: ContentResolver, tempFile: File ): Boolean { - val decodeBoundsInputStream = try { contentResolver.openInputStream(uri) } catch (e: FileNotFoundException) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaPreviewAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaPreviewAdapter.kt index cababaf0..6cd590d7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaPreviewAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaPreviewAdapter.kt @@ -48,11 +48,12 @@ class MediaPreviewAdapter( val addFocusId = 2 val editImageId = 3 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) - if (item.type == ComposeActivity.QueuedMedia.Type.IMAGE) { - popup.menu.add(0, addFocusId, 0, R.string.action_set_focus) + + popup.menu.add(0, addCaptionId, 0, R.string.action_set_caption) + if (item.type == ComposeActivity.QueuedMedia.Type.IMAGE) { + 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) } } @@ -89,10 +90,11 @@ class MediaPreviewAdapter( val imageView = holder.progressImageView val focus = item.focus - if (focus != null) + if (focus != null) { imageView.setFocalPoint(focus) - else + } else { imageView.removeFocalPoint() // Probably unnecessary since we have no UI for removal once added. + } var glide = Glide.with(holder.itemView.context) .load(item.uri) @@ -100,8 +102,9 @@ class MediaPreviewAdapter( .dontAnimate() .centerInside() - if (focus != null) + if (focus != null) { glide = glide.addListener(imageView) + } glide.into(imageView) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt index eac6d93f..58c3cf2a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt @@ -159,7 +159,6 @@ class MediaUploader @Inject constructor( try { when (inUri.scheme) { ContentResolver.SCHEME_CONTENT -> { - mimeType = contentResolver.getType(uri) val suffix = "." + MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType ?: "tmp") @@ -278,7 +277,8 @@ class MediaUploader @Inject constructor( var lastProgress = -1 val fileBody = ProgressRequestBody( - stream!!, media.mediaSize, + stream!!, + media.mediaSize, mimeType.toMediaTypeOrNull()!! ) { percentage -> if (percentage != lastProgress) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/AddPollDialog.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/AddPollDialog.kt index 005e6729..2d76e2d0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/AddPollDialog.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/AddPollDialog.kt @@ -35,7 +35,6 @@ fun showAddPollDialog( maxDuration: Int, onUpdatePoll: (NewPoll) -> Unit ) { - val binding = DialogAddPollBinding.inflate(LayoutInflater.from(context)) val dialog = AlertDialog.Builder(context) @@ -63,7 +62,7 @@ fun showAddPollDialog( 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 } 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 } @@ -76,8 +75,10 @@ fun showAddPollDialog( } } + val DAY_SECONDS = 60 * 60 * 24 + val desiredDuration = poll?.expiresIn ?: DAY_SECONDS val pollDurationId = durations.indexOfLast { - it <= (poll?.expiresIn ?: 0) + it <= desiredDuration } binding.pollDurationSpinner.setSelection(pollDurationId) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/AddPollOptionsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/AddPollOptionsAdapter.kt index 3640ffa9..ded1b7cf 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/AddPollOptionsAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/AddPollOptionsAdapter.kt @@ -19,11 +19,11 @@ import android.text.InputFilter import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.core.widget.doOnTextChanged import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ItemAddPollOptionBinding import com.keylesspalace.tusky.util.BindingHolder -import com.keylesspalace.tusky.util.onTextChanged import com.keylesspalace.tusky.util.visible class AddPollOptionsAdapter( @@ -46,7 +46,7 @@ class AddPollOptionsAdapter( val holder = BindingHolder(binding) binding.optionEditText.filters = arrayOf(InputFilter.LengthFilter(maxOptionLength)) - binding.optionEditText.onTextChanged { s, _, _, _ -> + binding.optionEditText.doOnTextChanged { s, _, _, _ -> val pos = holder.bindingAdapterPosition if (pos != RecyclerView.NO_POSITION) { options[pos] = s.toString() diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/CaptionDialog.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/CaptionDialog.kt index d5ece95f..a1dc032c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/CaptionDialog.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/CaptionDialog.kt @@ -21,79 +21,60 @@ import android.graphics.drawable.Drawable import android.net.Uri import android.os.Bundle import android.text.InputFilter -import android.text.InputType import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.WindowManager import android.widget.EditText -import android.widget.LinearLayout import androidx.appcompat.app.AlertDialog +import androidx.core.os.BundleCompat import androidx.core.os.bundleOf import androidx.fragment.app.DialogFragment -import at.connyduck.sparkbutton.helpers.Utils import com.bumptech.glide.Glide import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy import com.bumptech.glide.request.target.CustomTarget import com.bumptech.glide.request.transition.Transition -import com.github.chrisbanes.photoview.PhotoView import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.databinding.DialogImageDescriptionBinding // https://github.com/tootsuite/mastodon/blob/c6904c0d3766a2ea8a81ab025c127169ecb51373/app/models/media_attachment.rb#L32 private const val MEDIA_DESCRIPTION_CHARACTER_LIMIT = 1500 class CaptionDialog : DialogFragment() { - private lateinit var listener: Listener private lateinit var input: EditText override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { val context = requireContext() - val dialogLayout = LinearLayout(context) - val padding = Utils.dpToPx(context, 8) - dialogLayout.setPadding(padding, padding, padding, padding) - dialogLayout.orientation = LinearLayout.VERTICAL - val imageView = PhotoView(context).apply { - maximumScale = 6f - } + val binding = DialogImageDescriptionBinding.inflate(layoutInflater) - val margin = Utils.dpToPx(context, 4) - dialogLayout.addView(imageView) - (imageView.layoutParams as LinearLayout.LayoutParams).weight = 1f - imageView.layoutParams.height = 0 - (imageView.layoutParams as LinearLayout.LayoutParams).setMargins(0, margin, 0, 0) + input = binding.imageDescriptionText + val imageView = binding.imageDescriptionView + imageView.maximumScale = 6f - input = EditText(context) input.hint = resources.getQuantityString( 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.setText(arguments?.getString(EXISTING_DESCRIPTION_ARG)) val localId = arguments?.getInt(LOCAL_ID_ARG) ?: error("Missing localId") val dialog = AlertDialog.Builder(context) - .setView(dialogLayout) + .setView(binding.root) .setPositiveButton(android.R.string.ok) { _, _ -> listener.onUpdateDescription(localId, input.text.toString()) } .setNegativeButton(android.R.string.cancel, null) .create() - isCancelable = false + isCancelable = true val window = dialog.window window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) - val previewUri = arguments?.getParcelable(PREVIEW_URI_ARG) ?: error("Preview Uri is null") + val previewUri = BundleCompat.getParcelable(requireArguments(), PREVIEW_URI_ARG, Uri::class.java) ?: error("Preview Uri is null") // Load the image and manually set it into the ImageView because it doesn't have a fixed size. Glide.with(this) .load(previewUri) @@ -105,7 +86,7 @@ class CaptionDialog : DialogFragment() { override fun onResourceReady( resource: Drawable, - transition: Transition?, + transition: Transition? ) { imageView.setImageDrawable(resource) } @@ -122,7 +103,7 @@ class CaptionDialog : DialogFragment() { override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle?, + savedInstanceState: Bundle? ): View? { savedInstanceState?.getString(DESCRIPTION_KEY)?.let { input.setText(it) @@ -143,12 +124,12 @@ class CaptionDialog : DialogFragment() { fun newInstance( localId: Int, existingDescription: String?, - previewUri: Uri, + previewUri: Uri ) = CaptionDialog().apply { arguments = bundleOf( LOCAL_ID_ARG to localId, EXISTING_DESCRIPTION_ARG to existingDescription, - PREVIEW_URI_ARG to previewUri, + PREVIEW_URI_ARG to previewUri ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/FocusDialog.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/FocusDialog.kt index 4764ec54..93c99ee6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/FocusDialog.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/FocusDialog.kt @@ -15,14 +15,13 @@ package com.keylesspalace.tusky.components.compose.dialog -import android.app.Activity import android.content.DialogInterface import android.graphics.drawable.Drawable import android.net.Uri import android.view.WindowManager import android.widget.FrameLayout -import android.widget.Toast import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope import com.bumptech.glide.Glide @@ -31,7 +30,6 @@ import com.bumptech.glide.load.engine.GlideException import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy import com.bumptech.glide.request.RequestListener import com.bumptech.glide.request.target.Target -import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.DialogFocusBinding import com.keylesspalace.tusky.entity.Attachment.Focus import kotlinx.coroutines.launch @@ -39,8 +37,8 @@ import kotlinx.coroutines.launch fun T.makeFocusDialog( existingFocus: Focus?, previewUri: Uri, - onUpdateFocus: suspend (Focus) -> Boolean -) where T : Activity, T : LifecycleOwner { + onUpdateFocus: suspend (Focus) -> Unit +) where T : AppCompatActivity, T : LifecycleOwner { val focus = existingFocus ?: Focus(0.0f, 0.0f) // Default to center val dialogBinding = DialogFocusBinding.inflate(layoutInflater) @@ -79,9 +77,7 @@ fun T.makeFocusDialog( val okListener = { dialog: DialogInterface, _: Int -> lifecycleScope.launch { - if (!onUpdateFocus(dialogBinding.focusIndicator.getFocus())) { - showFailedFocusMessage() - } + onUpdateFocus(dialogBinding.focusIndicator.getFocus()) } dialog.dismiss() } @@ -99,7 +95,3 @@ fun T.makeFocusDialog( dialog.show() } - -private fun Activity.showFailedFocusMessage() { - Toast.makeText(this, R.string.error_failed_set_focus, Toast.LENGTH_SHORT).show() -} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/FocusIndicatorView.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/FocusIndicatorView.kt index 9a3e4b00..6e0b83dc 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/FocusIndicatorView.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/FocusIndicatorView.kt @@ -27,14 +27,16 @@ class FocusIndicatorView fun setImageSize(width: Int, height: Int) { this.imageSize = Point(width, height) - if (focus != null) + if (focus != null) { invalidate() + } } fun setFocus(focus: Attachment.Focus) { this.focus = focus - if (imageSize != null) + if (imageSize != null) { invalidate() + } } // Assumes setFocus called first @@ -46,8 +48,9 @@ class FocusIndicatorView // so base it on the view width/height whenever the first access occurs. private fun getCircleRadius(): Float { val circleRadius = this.circleRadius - if (circleRadius != null) + if (circleRadius != null) { return circleRadius + } val newCircleRadius = min(this.width, this.height).toFloat() / 4.0f this.circleRadius = 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. override fun onTouchEvent(event: MotionEvent): Boolean { - if (event.actionMasked == MotionEvent.ACTION_CANCEL) + if (event.actionMasked == MotionEvent.ACTION_CANCEL) { return false + } - val imageSize = this.imageSize - if (imageSize == null) - return false + val imageSize = this.imageSize ?: return false // Convert touch xy to point inside image focus = Attachment.Focus(axisToFocus(event.x, imageSize.x, this.width), -axisToFocus(event.y, imageSize.y, this.height)) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/TootButton.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/TootButton.kt index 24f4130b..43e8f6ef 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/TootButton.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/TootButton.kt @@ -48,7 +48,6 @@ class TootButton fun setStatusVisibility(visibility: Status.Visibility) { if (!smallStyle) { - icon = when (visibility) { Status.Visibility.PUBLIC -> { setText(R.string.action_send_public) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt index 0a75654b..f830b5bd 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt @@ -64,9 +64,10 @@ data class ConversationAccountEntity( localUsername = localUsername, username = username, displayName = displayName, + note = "", url = "", avatar = avatar, - emojis = emojis, + emojis = emojis ) } } @@ -88,7 +89,7 @@ data class ConversationStatusEntity( val bookmarked: Boolean, val sensitive: Boolean, val spoilerText: String, - val attachments: ArrayList, + val attachments: List, val mentions: List, val tags: List?, val showingHiddenContent: Boolean, @@ -96,7 +97,7 @@ data class ConversationStatusEntity( val collapsed: Boolean, val muted: Boolean, val poll: Poll?, - val language: String?, + val language: String? ) { fun toViewData(): StatusViewData.Concrete { @@ -130,6 +131,7 @@ data class ConversationStatusEntity( poll = poll, card = null, language = language, + filtered = null ), isExpanded = expanded, isShowingContent = showingHiddenContent, @@ -145,7 +147,7 @@ fun TimelineAccount.toEntity() = username = username, displayName = name, avatar = avatar, - emojis = emojis ?: emptyList() + emojis = emojis.orEmpty() ) fun Status.toEntity( @@ -177,7 +179,7 @@ fun Status.toEntity( collapsed = contentCollapsed, muted = muted ?: false, poll = poll, - language = language, + language = language ) fun Conversation.toEntity( diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewData.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewData.kt index 04e69d4c..b197084d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewData.kt @@ -87,6 +87,6 @@ fun StatusViewData.Concrete.toConversationStatusEntity( collapsed = collapsed, muted = muted, poll = poll, - language = status.language, + language = status.language ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java index 64b42eaa..722a9f3c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java @@ -36,7 +36,6 @@ import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.util.ImageLoadingHelper; import com.keylesspalace.tusky.util.SmartLengthInputFilter; import com.keylesspalace.tusky.util.StatusDisplayOptions; -import com.keylesspalace.tusky.viewdata.PollViewDataKt; import com.keylesspalace.tusky.viewdata.StatusViewData; import java.util.List; @@ -110,9 +109,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder { setupButtons(listener, account.getId(), statusViewData.getContent().toString(), statusDisplayOptions); - setSpoilerAndContent(statusViewData.isExpanded(), statusViewData.getContent(), status.getSpoilerText(), - status.getMentions(), status.getTags(), status.getEmojis(), - PollViewDataKt.toViewData(status.getPoll()), statusDisplayOptions, listener); + setSpoilerAndContent(statusViewData, statusDisplayOptions, listener); setConversationName(conversation.getAccounts()); diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt index 79950140..13226350 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt @@ -17,13 +17,18 @@ package com.keylesspalace.tusky.components.conversation import android.os.Bundle import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.PopupMenu +import androidx.core.view.MenuProvider import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.paging.LoadState import androidx.preference.PreferenceManager import androidx.recyclerview.widget.DividerItemDecoration @@ -31,7 +36,7 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.SimpleItemAnimator 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.StatusListActivity import com.keylesspalace.tusky.adapter.StatusBaseViewHolder @@ -52,16 +57,23 @@ import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.viewBinding 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.flow.collectLatest import kotlinx.coroutines.launch -import java.io.IOException import javax.inject.Inject import kotlin.time.DurationUnit import kotlin.time.toDuration -class ConversationsFragment : SFragment(), StatusActionListener, Injectable, ReselectableFragment { +class ConversationsFragment : + SFragment(), + StatusActionListener, + Injectable, + ReselectableFragment, + MenuProvider { @Inject lateinit var viewModelFactory: ViewModelFactory @@ -82,6 +94,8 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) + val preferences = PreferenceManager.getDefaultSharedPreferences(view.context) val statusDisplayOptions = StatusDisplayOptions( @@ -94,7 +108,10 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res confirmReblogs = preferences.getBoolean("confirmReblogs", true), confirmFavourites = preferences.getBoolean("confirmFavourites", 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), + showStatsInline = preferences.getBoolean(PrefKeys.SHOW_STATS_INLINE, false), + showSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia, + openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler ) adapter = ConversationAdapter(statusDisplayOptions, this) @@ -121,12 +138,7 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res } is LoadState.Error -> { binding.statusView.show() - - if ((loadState.refresh as LoadState.Error).error is IOException) { - binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network, null) - } else { - binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic, null) - } + binding.statusView.setup((loadState.refresh as LoadState.Error).error) { refreshContent() } } is LoadState.Loading -> { binding.progressBar.show() @@ -171,22 +183,48 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res } } - lifecycleScope.launchWhenResumed { - val useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false) - while (!useAbsoluteTime) { - adapter.notifyItemRangeChanged(0, adapter.itemCount, listOf(StatusBaseViewHolder.Key.KEY_CREATED)) - delay(1.toDuration(DurationUnit.MINUTES)) + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + val useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false) + while (!useAbsoluteTime) { + adapter.notifyItemRangeChanged( + 0, + adapter.itemCount, + listOf(StatusBaseViewHolder.Key.KEY_CREATED) + ) + delay(1.toDuration(DurationUnit.MINUTES)) + } } } - eventHub.events - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(this, Lifecycle.Event.ON_DESTROY) - .subscribe { event -> + lifecycleScope.launch { + eventHub.events.collect { event -> if (event is PreferenceChangedEvent) { 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() { @@ -200,10 +238,12 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res binding.recyclerView.adapter = adapter.withLoadStateFooter(ConversationLoadStateAdapter(adapter::retry)) } + private fun refreshContent() { + adapter.refresh() + } + private fun initSwipeToRefresh() { - binding.swipeRefreshLayout.setOnRefreshListener { - adapter.refresh() - } + binding.swipeRefreshLayout.setOnRefreshListener { refreshContent() } binding.swipeRefreshLayout.setColorSchemeResources(R.color.chinwag_green) } @@ -311,6 +351,9 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res } } + override fun clearWarningAction(position: Int) { + } + override fun onReselect() { if (isAdded) { binding.recyclerView.layoutManager?.scrollToPosition(0) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRemoteMediator.kt index 921d694b..b00c99a9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRemoteMediator.kt @@ -15,7 +15,7 @@ import retrofit2.HttpException class ConversationsRemoteMediator( private val api: MastodonApi, private val db: AppDatabase, - accountManager: AccountManager, + accountManager: AccountManager ) : RemoteMediator() { private var nextKey: String? = null @@ -28,7 +28,6 @@ class ConversationsRemoteMediator( loadType: LoadType, state: PagingState ): MediatorResult { - if (loadType == LoadType.PREPEND) { return MediatorResult.Success(endOfPaginationReached = true) } @@ -47,7 +46,6 @@ class ConversationsRemoteMediator( } db.withTransaction { - if (loadType == LoadType.REFRESH) { db.conversationDao().deleteForAccount(activeAccount.id) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt index 4e140b76..78bb7c8e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt @@ -23,6 +23,7 @@ import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.cachedIn import androidx.paging.map +import at.connyduck.calladapter.networkresult.fold import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.network.MastodonApi @@ -30,7 +31,6 @@ import com.keylesspalace.tusky.usecase.TimelineCases import com.keylesspalace.tusky.util.EmptyPagingSource import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch -import kotlinx.coroutines.rx3.await import javax.inject.Inject class ConversationsViewModel @Inject constructor( @@ -61,51 +61,47 @@ class ConversationsViewModel @Inject constructor( fun favourite(favourite: Boolean, conversation: ConversationViewData) { viewModelScope.launch { - try { - timelineCases.favourite(conversation.lastStatus.id, favourite).await() - + timelineCases.favourite(conversation.lastStatus.id, favourite).fold({ val newConversation = conversation.toEntity( accountId = accountManager.activeAccount!!.id, favourited = favourite ) saveConversationToDb(newConversation) - } catch (e: Exception) { + }, { e -> Log.w(TAG, "failed to favourite status", e) - } + }) } } fun bookmark(bookmark: Boolean, conversation: ConversationViewData) { viewModelScope.launch { - try { - timelineCases.bookmark(conversation.lastStatus.id, bookmark).await() - + timelineCases.bookmark(conversation.lastStatus.id, bookmark).fold({ val newConversation = conversation.toEntity( accountId = accountManager.activeAccount!!.id, bookmarked = bookmark ) saveConversationToDb(newConversation) - } catch (e: Exception) { + }, { e -> Log.w(TAG, "failed to bookmark status", e) - } + }) } } fun voteInPoll(choices: List, conversation: ConversationViewData) { viewModelScope.launch { - try { - val poll = timelineCases.voteInPoll(conversation.lastStatus.id, conversation.lastStatus.status.poll?.id!!, choices).await() - val newConversation = conversation.toEntity( - accountId = accountManager.activeAccount!!.id, - poll = poll - ) + timelineCases.voteInPoll(conversation.lastStatus.id, conversation.lastStatus.status.poll?.id!!, choices) + .fold({ poll -> + val newConversation = conversation.toEntity( + accountId = accountManager.activeAccount!!.id, + poll = poll + ) - saveConversationToDb(newConversation) - } catch (e: Exception) { - Log.w(TAG, "failed to vote in poll", e) - } + saveConversationToDb(newConversation) + }, { e -> + Log.w(TAG, "failed to vote in poll", e) + }) } } @@ -160,7 +156,7 @@ class ConversationsViewModel @Inject constructor( timelineCases.muteConversation( conversation.lastStatus.id, !(conversation.lastStatus.status.muted ?: false) - ).await() + ) val newConversation = conversation.toEntity( accountId = accountManager.activeAccount!!.id, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftHelper.kt b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftHelper.kt index 5d2d852a..2dc802e6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftHelper.kt @@ -44,7 +44,7 @@ import javax.inject.Inject class DraftHelper @Inject constructor( val context: Context, - val okHttpClient: OkHttpClient, + private val okHttpClient: OkHttpClient, db: AppDatabase ) { @@ -66,7 +66,7 @@ class DraftHelper @Inject constructor( failedToSendAlert: Boolean, scheduledAt: String?, language: String?, - statusId: String?, + statusId: String? ) = withContext(Dispatchers.IO) { val externalFilesDir = context.getExternalFilesDir("Tusky") @@ -127,7 +127,7 @@ class DraftHelper @Inject constructor( failedToSendNew = failedToSendAlert, scheduledAt = scheduledAt, language = language, - statusId = statusId, + statusId = statusId ) draftDao.insertOrReplace(draft) @@ -140,7 +140,7 @@ class DraftHelper @Inject constructor( } } - suspend fun deleteDraftAndAttachments(draft: DraftEntity) { + private suspend fun deleteDraftAndAttachments(draft: DraftEntity) { deleteAttachments(draft) draftDao.delete(draft.id) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftMediaAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftMediaAdapter.kt index 98a288b4..2165f7e0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftMediaAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftMediaAdapter.kt @@ -51,18 +51,20 @@ class DraftMediaAdapter( holder.imageView.clearFocus() holder.imageView.setImageResource(R.drawable.ic_music_box_preview_24dp) } else { - if (attachment.focus != null) + if (attachment.focus != null) { holder.imageView.setFocalPoint(attachment.focus) - else + } else { holder.imageView.clearFocus() + } var glide = Glide.with(holder.itemView.context) .load(attachment.uri) .diskCacheStrategy(DiskCacheStrategy.NONE) .dontAnimate() .centerInside() - if (attachment.focus != null) + if (attachment.focus != null) { glide = glide.addListener(holder.imageView) + } glide.into(holder.imageView) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsAdapter.kt index 18621fd3..1edf354d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsAdapter.kt @@ -48,7 +48,6 @@ class DraftsAdapter( ) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder { - val binding = ItemDraftBinding.inflate(LayoutInflater.from(parent.context), parent, false) val viewHolder = BindingHolder(binding) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsViewModel.kt index 69439803..e748aebb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsViewModel.kt @@ -33,7 +33,7 @@ class DraftsViewModel @Inject constructor( val database: AppDatabase, val accountManager: AccountManager, val api: MastodonApi, - val draftHelper: DraftHelper + private val draftHelper: DraftHelper ) : ViewModel() { val drafts = Pager( diff --git a/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterActivity.kt new file mode 100644 index 00000000..2ef7902c --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterActivity.kt @@ -0,0 +1,307 @@ +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.content.IntentCompat +import androidx.core.view.size +import androidx.core.widget.doAfterTextChanged +import androidx.lifecycle.lifecycleScope +import at.connyduck.calladapter.networkresult.fold +import com.google.android.material.chip.Chip +import com.google.android.material.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 com.keylesspalace.tusky.util.visible +import kotlinx.coroutines.launch +import retrofit2.HttpException +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 + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + originalFilter = IntentCompat.getParcelableExtra(intent, FILTER_TO_EDIT, Filter::class.java) + 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() } + binding.filterDeleteButton.setOnClickListener { deleteFilter() } + binding.filterDeleteButton.visible(originalFilter != null) + + 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) { + 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() + } + } + } + + private fun deleteFilter() { + originalFilter?.let { filter -> + lifecycleScope.launch { + api.deleteFilter(filter.id).fold( + { + finish() + }, + { throwable -> + if (throwable is HttpException && throwable.code() == 404) { + api.deleteFilterV1(filter.id).fold( + { + finish() + }, + { + Snackbar.make(binding.root, "Error deleting filter '${filter.title}'", Snackbar.LENGTH_SHORT).show() + } + ) + } else { + Snackbar.make(binding.root, "Error deleting filter '${filter.title}'", 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) + } + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterViewModel.kt new file mode 100644 index 00000000..d33031d6 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterViewModel.kt @@ -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()) + val action = MutableStateFlow(Filter.Action.WARN) + val duration = MutableStateFlow(0) + val contexts = MutableStateFlow(listOf()) + + 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, 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, 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, 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, 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 } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersActivity.kt new file mode 100644 index 00000000..54c82295 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersActivity.kt @@ -0,0 +1,113 @@ +package com.keylesspalace.tusky.components.filters + +import android.content.Intent +import android.os.Bundle +import androidx.activity.viewModels +import androidx.lifecycle.lifecycleScope +import com.keylesspalace.tusky.BaseActivity +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.databinding.ActivityFiltersBinding +import com.keylesspalace.tusky.di.ViewModelFactory +import com.keylesspalace.tusky.entity.Filter +import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.util.viewBinding +import com.keylesspalace.tusky.util.visible +import kotlinx.coroutines.launch +import javax.inject.Inject + +class FiltersActivity : BaseActivity(), FiltersListener { + @Inject + lateinit var viewModelFactory: ViewModelFactory + + private val binding by viewBinding(ActivityFiltersBinding::inflate) + private val viewModel: FiltersViewModel by viewModels { viewModelFactory } + + 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 { + launchEditFilterActivity() + } + + binding.swipeRefreshLayout.setOnRefreshListener { loadFilters() } + binding.swipeRefreshLayout.setColorSchemeResources(R.color.chinwag_green) + + setTitle(R.string.pref_title_timeline_filters) + } + + override fun onResume() { + super.onResume() + loadFilters() + observeViewModel() + } + + private fun observeViewModel() { + lifecycleScope.launch { + viewModel.state.collect { state -> + binding.progressBar.visible(state.loadingState == FiltersViewModel.LoadingState.LOADING) + binding.swipeRefreshLayout.isRefreshing = state.loadingState == FiltersViewModel.LoadingState.LOADING + binding.addFilterButton.visible(state.loadingState == FiltersViewModel.LoadingState.LOADED) + + when (state.loadingState) { + FiltersViewModel.LoadingState.INITIAL, FiltersViewModel.LoadingState.LOADING -> binding.messageView.hide() + FiltersViewModel.LoadingState.ERROR_NETWORK -> { + binding.messageView.setup(R.drawable.elephant_offline, R.string.error_network) { + loadFilters() + } + binding.messageView.show() + } + FiltersViewModel.LoadingState.ERROR_OTHER -> { + binding.messageView.setup(R.drawable.elephant_error, R.string.error_generic) { + loadFilters() + } + binding.messageView.show() + } + FiltersViewModel.LoadingState.LOADED -> { + if (state.filters.isEmpty()) { + binding.messageView.setup( + R.drawable.elephant_friend_empty, + R.string.message_empty, + null + ) + binding.messageView.show() + } else { + binding.messageView.hide() + binding.filtersList.adapter = FiltersAdapter(this@FiltersActivity, state.filters) + } + } + } + } + } + } + + private fun loadFilters() { + viewModel.load() + } + + private fun launchEditFilterActivity(filter: Filter? = null) { + val intent = Intent(this, EditFilterActivity::class.java).apply { + if (filter != null) { + putExtra(EditFilterActivity.FILTER_TO_EDIT, filter) + } + } + startActivity(intent) + overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left) + } + + override fun deleteFilter(filter: Filter) { + viewModel.deleteFilter(filter, binding.root) + } + + override fun updateFilter(updatedFilter: Filter) { + launchEditFilterActivity(updatedFilter) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersAdapter.kt new file mode 100644 index 00000000..f6e6791a --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersAdapter.kt @@ -0,0 +1,52 @@ +package com.keylesspalace.tusky.components.filters + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.databinding.ItemRemovableBinding +import com.keylesspalace.tusky.entity.Filter +import com.keylesspalace.tusky.util.BindingHolder +import com.keylesspalace.tusky.util.getRelativeTimeSpanString + +class FiltersAdapter(val listener: FiltersListener, val filters: List) : + RecyclerView.Adapter>() { + + override fun getItemCount(): Int = filters.size + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder { + return BindingHolder(ItemRemovableBinding.inflate(LayoutInflater.from(parent.context), parent, false)) + } + + override fun onBindViewHolder(holder: BindingHolder, position: Int) { + val binding = holder.binding + val resources = binding.root.resources + val actions = resources.getStringArray(R.array.filter_actions) + val contexts = resources.getStringArray(R.array.filter_contexts) + + val filter = filters[position] + val context = binding.root.context + binding.textPrimary.text = if (filter.expiresAt == null) { + filter.title + } else { + context.getString( + R.string.filter_expiration_format, + filter.title, + getRelativeTimeSpanString(binding.root.context, filter.expiresAt.time, System.currentTimeMillis()) + ) + } + binding.textSecondary.text = context.getString( + R.string.filter_description_format, + actions.getOrNull(filter.action.ordinal - 1), + filter.context.map { contexts.getOrNull(Filter.Kind.from(it).ordinal) }.joinToString("/") + ) + + binding.delete.setOnClickListener { + listener.deleteFilter(filter) + } + + binding.root.setOnClickListener { + listener.updateFilter(filter) + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersListener.kt b/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersListener.kt new file mode 100644 index 00000000..a102b0d6 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersListener.kt @@ -0,0 +1,8 @@ +package com.keylesspalace.tusky.components.filters + +import com.keylesspalace.tusky.entity.Filter + +interface FiltersListener { + fun deleteFilter(filter: Filter) + fun updateFilter(updatedFilter: Filter) +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersViewModel.kt new file mode 100644 index 00000000..e28d251b --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersViewModel.kt @@ -0,0 +1,87 @@ +package com.keylesspalace.tusky.components.filters + +import android.view.View +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import at.connyduck.calladapter.networkresult.fold +import com.google.android.material.snackbar.Snackbar +import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.appstore.PreferenceChangedEvent +import com.keylesspalace.tusky.entity.Filter +import com.keylesspalace.tusky.network.MastodonApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import retrofit2.HttpException +import javax.inject.Inject + +class FiltersViewModel @Inject constructor( + private val api: MastodonApi, + private val eventHub: EventHub +) : ViewModel() { + + enum class LoadingState { + INITIAL, LOADING, LOADED, ERROR_NETWORK, ERROR_OTHER + } + + data class State(val filters: List, val loadingState: LoadingState) + + val state: Flow get() = _state + private val _state = MutableStateFlow(State(emptyList(), LoadingState.INITIAL)) + + fun load() { + this@FiltersViewModel._state.value = _state.value.copy(loadingState = LoadingState.LOADING) + + viewModelScope.launch { + api.getFilters().fold( + { filters -> + this@FiltersViewModel._state.value = State(filters, LoadingState.LOADED) + }, + { throwable -> + if (throwable is HttpException && throwable.code() == 404) { + api.getFiltersV1().fold( + { filters -> + this@FiltersViewModel._state.value = State(filters.map { it.toFilter() }, LoadingState.LOADED) + }, + { throwable -> + // TODO log errors (also below) + + this@FiltersViewModel._state.value = _state.value.copy(loadingState = LoadingState.ERROR_OTHER) + } + ) + this@FiltersViewModel._state.value = _state.value.copy(loadingState = LoadingState.ERROR_OTHER) + } else { + this@FiltersViewModel._state.value = _state.value.copy(loadingState = LoadingState.ERROR_NETWORK) + } + } + ) + } + } + + fun deleteFilter(filter: Filter, parent: View) { + viewModelScope.launch { + api.deleteFilter(filter.id).fold( + { + this@FiltersViewModel._state.value = State(this@FiltersViewModel._state.value.filters.filter { it.id != filter.id }, LoadingState.LOADED) + for (context in filter.context) { + eventHub.dispatch(PreferenceChangedEvent(context)) + } + }, + { throwable -> + if (throwable is HttpException && throwable.code() == 404) { + api.deleteFilterV1(filter.id).fold( + { + this@FiltersViewModel._state.value = State(this@FiltersViewModel._state.value.filters.filter { it.id != filter.id }, LoadingState.LOADED) + }, + { + Snackbar.make(parent, "Error deleting filter '${filter.title}'", Snackbar.LENGTH_SHORT).show() + } + ) + } else { + Snackbar.make(parent, "Error deleting filter '${filter.title}'", Snackbar.LENGTH_SHORT).show() + } + } + ) + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsActivity.kt index 0b8e7a5c..b6b56d4a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsActivity.kt @@ -1,37 +1,51 @@ package com.keylesspalace.tusky.components.followedtags +import android.app.Dialog +import android.content.DialogInterface +import android.content.SharedPreferences import android.os.Bundle import android.util.Log +import android.widget.AutoCompleteTextView import androidx.activity.viewModels +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.DialogFragment import androidx.lifecycle.lifecycleScope import androidx.paging.LoadState import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.SimpleItemAnimator import at.connyduck.calladapter.networkresult.fold import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.components.compose.ComposeAutoCompleteAdapter import com.keylesspalace.tusky.databinding.ActivityFollowedTagsBinding import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.interfaces.HashtagActionListener import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -import java.io.IOException import javax.inject.Inject -class FollowedTagsActivity : BaseActivity(), HashtagActionListener { +class FollowedTagsActivity : + BaseActivity(), + HashtagActionListener, + ComposeAutoCompleteAdapter.AutocompletionProvider { @Inject lateinit var api: MastodonApi @Inject lateinit var viewModelFactory: ViewModelFactory + @Inject + lateinit var sharedPreferences: SharedPreferences + private val binding by viewBinding(ActivityFollowedTagsBinding::inflate) private val viewModel: FollowedTagsViewModel by viewModels { viewModelFactory } @@ -47,6 +61,11 @@ class FollowedTagsActivity : BaseActivity(), HashtagActionListener { setDisplayShowHomeEnabled(true) } + binding.fab.setOnClickListener { + val dialog: DialogFragment = FollowTagDialog.newInstance() + dialog.show(supportFragmentManager, "dialog") + } + setupAdapter().let { adapter -> setupRecyclerView(adapter) @@ -64,6 +83,19 @@ class FollowedTagsActivity : BaseActivity(), HashtagActionListener { binding.followedTagsView.layoutManager = LinearLayoutManager(this) binding.followedTagsView.addItemDecoration(DividerItemDecoration(this, DividerItemDecoration.VERTICAL)) (binding.followedTagsView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false + + val hideFab = sharedPreferences.getBoolean(PrefKeys.FAB_HIDE, false) + if (hideFab) { + binding.followedTagsView.addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + if (dy > 0 && binding.fab.isShown) { + binding.fab.hide() + } else if (dy < 0 && !binding.fab.isShown) { + binding.fab.show() + } + } + }) + } } private fun setupAdapter(): FollowedTagsAdapter { @@ -75,11 +107,7 @@ class FollowedTagsActivity : BaseActivity(), HashtagActionListener { binding.followedTagsView.hide() binding.followedTagsMessageView.show() val errorState = loadState.refresh as LoadState.Error - if (errorState.error is IOException) { - binding.followedTagsMessageView.setup(R.drawable.elephant_offline, R.string.error_network) { retry() } - } else { - binding.followedTagsMessageView.setup(R.drawable.elephant_error, R.string.error_generic) { retry() } - } + binding.followedTagsMessageView.setup(errorState.error) { retry() } Log.w(TAG, "error loading followed hashtags", errorState.error) } else { binding.followedTagsView.show() @@ -89,11 +117,15 @@ class FollowedTagsActivity : BaseActivity(), HashtagActionListener { } } - private fun follow(tagName: String, position: Int) { + private fun follow(tagName: String, position: Int = -1) { lifecycleScope.launch { api.followTag(tagName).fold( { - viewModel.tags.add(position, it) + if (position == -1) { + viewModel.tags.add(it) + } else { + viewModel.tags.add(position, it) + } viewModel.currentSource?.invalidate() }, { @@ -142,7 +174,41 @@ class FollowedTagsActivity : BaseActivity(), HashtagActionListener { } } + override fun search(token: String): List { + return viewModel.searchAutocompleteSuggestions(token) + } + companion object { const val TAG = "FollowedTagsActivity" } + + class FollowTagDialog : DialogFragment() { + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val layout = layoutInflater.inflate(R.layout.dialog_follow_hashtag, null) + val autoCompleteTextView = layout.findViewById(R.id.hashtag)!! + autoCompleteTextView.setAdapter( + ComposeAutoCompleteAdapter( + requireActivity() as FollowedTagsActivity, + animateAvatar = false, + animateEmojis = false, + showBotBadge = false + ) + ) + + return AlertDialog.Builder(requireActivity()) + .setTitle(R.string.dialog_follow_hashtag_title) + .setView(layout) + .setPositiveButton(android.R.string.ok) { _, _ -> + (requireActivity() as FollowedTagsActivity).follow( + autoCompleteTextView.text.toString().removePrefix("#") + ) + } + .setNegativeButton(android.R.string.cancel) { _: DialogInterface, _: Int -> } + .create() + } + + companion object { + fun newInstance(): FollowTagDialog = FollowTagDialog() + } + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsAdapter.kt index 36590088..4cdc9f97 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsAdapter.kt @@ -13,7 +13,7 @@ import com.keylesspalace.tusky.util.BindingHolder class FollowedTagsAdapter( private val actionListener: HashtagActionListener, - private val viewModel: FollowedTagsViewModel, + private val viewModel: FollowedTagsViewModel ) : PagingDataAdapter>(STRING_COMPARATOR) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder = BindingHolder(ItemFollowedHashtagBinding.inflate(LayoutInflater.from(parent.context), parent, false)) @@ -22,7 +22,7 @@ class FollowedTagsAdapter( viewModel.tags[position].let { tag -> holder.itemView.findViewById(R.id.followed_tag).text = tag.name holder.itemView.findViewById(R.id.followed_tag_unfollow).setOnClickListener { - actionListener.unfollow(tag.name, position) + actionListener.unfollow(tag.name, holder.bindingAdapterPosition) } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsRemoteMediator.kt index 649ca583..00239a75 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsRemoteMediator.kt @@ -13,7 +13,7 @@ import retrofit2.Response @OptIn(ExperimentalPagingApi::class) class FollowedTagsRemoteMediator( private val api: MastodonApi, - private val viewModel: FollowedTagsViewModel, + private val viewModel: FollowedTagsViewModel ) : RemoteMediator() { override suspend fun load( loadType: LoadType, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsViewModel.kt index efe5661a..1a1b794b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsViewModel.kt @@ -1,18 +1,22 @@ package com.keylesspalace.tusky.components.followedtags +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.ExperimentalPagingApi import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.cachedIn +import at.connyduck.calladapter.networkresult.fold +import com.keylesspalace.tusky.components.compose.ComposeAutoCompleteAdapter +import com.keylesspalace.tusky.components.search.SearchType import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.entity.HashTag import com.keylesspalace.tusky.network.MastodonApi import javax.inject.Inject -class FollowedTagsViewModel @Inject constructor ( - api: MastodonApi +class FollowedTagsViewModel @Inject constructor( + private val api: MastodonApi ) : ViewModel(), Injectable { val tags: MutableList = mutableListOf() var nextKey: String? = null @@ -28,6 +32,20 @@ class FollowedTagsViewModel @Inject constructor ( ).also { source -> currentSource = source } - }, + } ).flow.cachedIn(viewModelScope) + + fun searchAutocompleteSuggestions(token: String): List { + return api.searchSync(query = token, type = SearchType.Hashtag.apiParameter, limit = 10) + .fold({ searchResult -> + searchResult.hashtags.map { ComposeAutoCompleteAdapter.AutocompleteResult.HashtagResult(it.name) } + }, { e -> + Log.e(TAG, "Autocomplete search for $token failed.", e) + emptyList() + }) + } + + companion object { + private const val TAG = "FollowedTagsViewModel" + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/instancemute/adapter/DomainMutesAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/instancemute/adapter/DomainMutesAdapter.kt index 509c9561..13d8f2d8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/instancemute/adapter/DomainMutesAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/instancemute/adapter/DomainMutesAdapter.kt @@ -30,8 +30,9 @@ class DomainMutesAdapter( override fun getItemCount(): Int { var count = instances.size - if (bottomLoading) + if (bottomLoading) { ++count + } return count } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/instancemute/fragment/InstanceListFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/instancemute/fragment/InstanceListFragment.kt index ccfe52b3..1da0a2b7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/instancemute/fragment/InstanceListFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/instancemute/fragment/InstanceListFragment.kt @@ -5,9 +5,11 @@ import android.util.Log import android.view.View import androidx.fragment.app.Fragment import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import at.connyduck.calladapter.networkresult.fold import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from import autodispose2.autoDispose import com.google.android.material.snackbar.Snackbar @@ -23,10 +25,7 @@ import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.view.EndlessOnScrollListener import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import retrofit2.Call -import retrofit2.Callback -import retrofit2.Response -import java.io.IOException +import kotlinx.coroutines.launch import javax.inject.Inject class InstanceListFragment : Fragment(R.layout.fragment_instance_list), Injectable, InstanceActionListener { @@ -64,39 +63,25 @@ class InstanceListFragment : Fragment(R.layout.fragment_instance_list), Injectab } override fun mute(mute: Boolean, instance: String, position: Int) { - if (mute) { - api.blockDomain(instance).enqueue(object : Callback { - override fun onFailure(call: Call, t: Throwable) { - Log.e(TAG, "Error muting domain $instance") - } - - override fun onResponse(call: Call, response: Response) { - if (response.isSuccessful) { - adapter.addItem(instance) - } else { - Log.e(TAG, "Error muting domain $instance") - } - } - }) - } else { - api.unblockDomain(instance).enqueue(object : Callback { - override fun onFailure(call: Call, t: Throwable) { - Log.e(TAG, "Error unmuting domain $instance") - } - - override fun onResponse(call: Call, response: Response) { - if (response.isSuccessful) { - adapter.removeItem(position) - Snackbar.make(binding.recyclerView, getString(R.string.confirmation_domain_unmuted, instance), Snackbar.LENGTH_LONG) - .setAction(R.string.action_undo) { - mute(true, instance, position) - } - .show() - } else { - Log.e(TAG, "Error unmuting domain $instance") - } - } - }) + viewLifecycleOwner.lifecycleScope.launch { + if (mute) { + api.blockDomain(instance).fold({ + adapter.addItem(instance) + }, { e -> + Log.e(TAG, "Error muting domain $instance", e) + }) + } else { + api.unblockDomain(instance).fold({ + adapter.removeItem(position) + Snackbar.make(binding.recyclerView, getString(R.string.confirmation_domain_unmuted, instance), Snackbar.LENGTH_LONG) + .setAction(R.string.action_undo) { + mute(true, instance, position) + } + .show() + }, { e -> + Log.e(TAG, "Error unmuting domain $instance", e) + }) + } } } @@ -160,16 +145,9 @@ class InstanceListFragment : Fragment(R.layout.fragment_instance_list), Injectab if (adapter.itemCount == 0) { binding.messageView.show() - if (throwable is IOException) { - binding.messageView.setup(R.drawable.elephant_offline, R.string.error_network) { - binding.messageView.hide() - this.fetchInstances(null) - } - } else { - binding.messageView.setup(R.drawable.elephant_error, R.string.error_generic) { - binding.messageView.hide() - this.fetchInstances(null) - } + binding.messageView.setup(throwable) { + binding.messageView.hide() + this.fetchInstances(null) } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/login/LoginActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/login/LoginActivity.kt index 8bdaa083..6cda99d0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/login/LoginActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/login/LoginActivity.kt @@ -100,7 +100,8 @@ class LoginActivity : BaseActivity(), Injectable { } preferences = getSharedPreferences( - getString(R.string.preferences_file_key), Context.MODE_PRIVATE + getString(R.string.preferences_file_key), + Context.MODE_PRIVATE ) binding.loginButton.setOnClickListener { onLoginClick(true) } @@ -182,8 +183,11 @@ class LoginActivity : BaseActivity(), Injectable { lifecycleScope.launch { mastodonApi.authenticateApp( - domain, getString(R.string.app_name), oauthRedirectUri, - OAUTH_SCOPES, getString(R.string.tusky_website) + domain, + getString(R.string.app_name), + oauthRedirectUri, + OAUTH_SCOPES, + getString(R.string.tusky_website) ).fold( { credentials -> // Before we open browser page we save the data. @@ -287,7 +291,12 @@ class LoginActivity : BaseActivity(), Injectable { setLoading(true) mastodonApi.fetchOAuthToken( - domain, clientId, clientSecret, oauthRedirectUri, code, "authorization_code" + domain, + clientId, + clientSecret, + oauthRedirectUri, + code, + "authorization_code" ).fold( { accessToken -> fetchAccountDetails(accessToken, domain, clientId, clientSecret) @@ -307,7 +316,6 @@ class LoginActivity : BaseActivity(), Injectable { clientId: String, clientSecret: String ) { - mastodonApi.accountVerifyCredentials( domain = domain, auth = "Bearer ${accessToken.accessToken}" @@ -363,6 +371,7 @@ class LoginActivity : BaseActivity(), Injectable { const val MODE_DEFAULT = 0 const val MODE_ADDITIONAL_LOGIN = 1 + // "Migration" is used to update the OAuth scope granted to the client const val MODE_MIGRATION = 2 diff --git a/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewActivity.kt index b69f81e7..be7e323a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewActivity.kt @@ -33,6 +33,7 @@ import android.webkit.WebViewClient import androidx.activity.result.contract.ActivityResultContract import androidx.activity.viewModels import androidx.appcompat.app.AlertDialog +import androidx.core.content.IntentCompat import androidx.core.net.toUri import androidx.lifecycle.lifecycleScope import com.keylesspalace.tusky.BaseActivity @@ -61,7 +62,9 @@ class OauthLogin : ActivityResultContract() { return if (resultCode == Activity.RESULT_CANCELED) { LoginResult.Cancel } else { - intent!!.getParcelableExtra(RESULT_EXTRA)!! + intent?.let { + IntentCompat.getParcelableExtra(it, RESULT_EXTRA, LoginResult::class.java) + } ?: LoginResult.Err("failed parsing LoginWebViewActivity result") } } @@ -70,7 +73,7 @@ class OauthLogin : ActivityResultContract() { private const val DATA_EXTRA = "data" fun parseData(intent: Intent): LoginData { - return intent.getParcelableExtra(DATA_EXTRA)!! + return IntentCompat.getParcelableExtra(intent, DATA_EXTRA, LoginData::class.java)!! } fun makeResultIntent(result: LoginResult): Intent { @@ -85,7 +88,7 @@ class OauthLogin : ActivityResultContract() { data class LoginData( val domain: String, val url: Uri, - val oauthRedirectUrl: Uri, + val oauthRedirectUrl: Uri ) : Parcelable sealed class LoginResult : Parcelable { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/FollowViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/FollowViewHolder.kt new file mode 100644 index 00000000..70f564c0 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/FollowViewHolder.kt @@ -0,0 +1,111 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.components.notifications + +import androidx.recyclerview.widget.RecyclerView +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.databinding.ItemFollowBinding +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.entity.TimelineAccount +import com.keylesspalace.tusky.interfaces.LinkListener +import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.util.emojify +import com.keylesspalace.tusky.util.loadAvatar +import com.keylesspalace.tusky.util.parseAsMastodonHtml +import com.keylesspalace.tusky.util.setClickableText +import com.keylesspalace.tusky.util.unicodeWrap +import com.keylesspalace.tusky.viewdata.NotificationViewData + +class FollowViewHolder( + private val binding: ItemFollowBinding, + private val notificationActionListener: NotificationActionListener, + private val linkListener: LinkListener +) : NotificationsPagingAdapter.ViewHolder, RecyclerView.ViewHolder(binding.root) { + private val avatarRadius42dp = itemView.context.resources.getDimensionPixelSize( + R.dimen.avatar_radius_42dp + ) + + override fun bind( + viewData: NotificationViewData, + payloads: List<*>?, + statusDisplayOptions: StatusDisplayOptions + ) { + // Skip updates with payloads. That indicates a timestamp update, and + // this view does not have timestamps. + if (!payloads.isNullOrEmpty()) return + + setMessage( + viewData.account, + viewData.type === Notification.Type.SIGN_UP, + statusDisplayOptions.animateAvatars, + statusDisplayOptions.animateEmojis + ) + setupButtons(notificationActionListener, viewData.account.id) + } + + private fun setMessage( + account: TimelineAccount, + isSignUp: Boolean, + animateAvatars: Boolean, + animateEmojis: Boolean + ) { + val context = binding.notificationText.context + val format = + context.getString( + if (isSignUp) { + R.string.notification_sign_up_format + } else { + R.string.notification_follow_format + } + ) + val wrappedDisplayName = account.name.unicodeWrap() + val wholeMessage = String.format(format, wrappedDisplayName) + val emojifiedMessage = + wholeMessage.emojify( + account.emojis, + binding.notificationText, + animateEmojis + ) + binding.notificationText.text = emojifiedMessage + val username = context.getString(R.string.post_username_format, account.username) + binding.notificationUsername.text = username + val emojifiedDisplayName = wrappedDisplayName.emojify( + account.emojis, + binding.notificationUsername, + animateEmojis + ) + binding.notificationDisplayName.text = emojifiedDisplayName + loadAvatar( + account.avatar, + binding.notificationAvatar, + avatarRadius42dp, + animateAvatars + ) + + val emojifiedNote = account.note.parseAsMastodonHtml().emojify( + account.emojis, + binding.notificationAccountNote, + animateEmojis + ) + setClickableText(binding.notificationAccountNote, emojifiedNote, emptyList(), null, linkListener) + } + + private fun setupButtons(listener: NotificationActionListener, accountId: String) { + binding.root.setOnClickListener { listener.onViewAccount(accountId) } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationFetcher.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationFetcher.kt index 1af9b3d7..633ca08f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationFetcher.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationFetcher.kt @@ -1,75 +1,190 @@ package com.keylesspalace.tusky.components.notifications +import android.app.NotificationManager import android.content.Context import android.util.Log +import androidx.annotation.WorkerThread +import com.keylesspalace.tusky.components.notifications.NotificationHelper.filterNotification import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Marker import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.isLessThan +import kotlinx.coroutines.delay import javax.inject.Inject +import kotlin.math.min +import kotlin.time.Duration.Companion.milliseconds +/** + * Fetch Mastodon notifications and show Android notifications, with summaries, for them. + * + * Should only be called by a worker thread. + * + * @see NotificationWorker + * @see Background worker + */ +@WorkerThread class NotificationFetcher @Inject constructor( private val mastodonApi: MastodonApi, private val accountManager: AccountManager, private val context: Context ) { - fun fetchAndShow() { + suspend fun fetchAndShow() { for (account in accountManager.getAllAccountsOrderedByActive()) { if (account.notificationsEnabled) { try { - val notifications = fetchNotifications(account) - notifications.forEachIndexed { index, notification -> - NotificationHelper.make(context, notification, account, index == 0) + val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + // Create sorted list of new notifications + val notifications = fetchNewNotifications(account) + .filter { filterNotification(notificationManager, account, it) } + .sortedWith(compareBy({ it.id.length }, { it.id })) // oldest notifications first + .toMutableList() + + // There's a maximum limit on the number of notifications an Android app + // can display. If the total number of notifications (current notifications, + // plus new ones) exceeds this then some newer notifications will be dropped. + // + // Err on the side of removing *older* notifications to make room for newer + // notifications. + val currentAndroidNotifications = notificationManager.activeNotifications + .sortedWith(compareBy({ it.tag.length }, { it.tag })) // oldest notifications first + + // Check to see if any notifications need to be removed + val toRemove = currentAndroidNotifications.size + notifications.size - MAX_NOTIFICATIONS + if (toRemove > 0) { + // Prefer to cancel old notifications first + currentAndroidNotifications.subList(0, min(toRemove, currentAndroidNotifications.size)) + .forEach { notificationManager.cancel(it.tag, it.id) } + + // Still got notifications to remove? Trim the list of new notifications, + // starting with the oldest. + while (notifications.size > MAX_NOTIFICATIONS) { + notifications.removeAt(0) + } } + + // Make and send the new notifications + // TODO: Use the batch notification API available in NotificationManagerCompat + // 1.11 and up (https://developer.android.com/jetpack/androidx/releases/core#1.11.0-alpha01) + // when it is released. + notifications.forEachIndexed { index, notification -> + val androidNotification = NotificationHelper.make( + context, + notificationManager, + notification, + account, + index == 0 + ) + notificationManager.notify(notification.id, account.id.toInt(), androidNotification) + // Android will rate limit / drop notifications if they're posted too + // quickly. There is no indication to the user that this happened. + // See https://github.com/tuskyapp/Tusky/pull/3626#discussion_r1192963664 + delay(1000.milliseconds) + } + + NotificationHelper.updateSummaryNotifications( + context, + notificationManager, + account + ) + accountManager.saveAccount(account) } catch (e: Exception) { - Log.w(TAG, "Error while fetching notifications", e) + Log.e(TAG, "Error while fetching notifications", e) } } } } - private fun fetchNotifications(account: AccountEntity): MutableList { + /** + * Fetch new Mastodon Notifications and update the marker position. + * + * Here, "new" means "notifications with IDs newer than notifications the user has already + * seen." + * + * The "water mark" for Mastodon Notification IDs are stored in three places. + * + * - acccount.lastNotificationId -- the ID of the top-most notification when the user last + * left the Notifications tab. + * - The Mastodon "marker" API -- the ID of the most recent notification fetched here. + * - account.notificationMarkerId -- local version of the value from the Mastodon marker + * API, in case the Mastodon server does not implement that API. + * + * The user may have refreshed the "Notifications" tab and seen notifications newer than the + * ones that were last fetched here. So `lastNotificationId` takes precedence if it is greater + * than the marker. + */ + private suspend fun fetchNewNotifications(account: AccountEntity): List { val authHeader = String.format("Bearer %s", account.accessToken) - // We fetch marker to not load/show notifications which user has already seen - val marker = fetchMarker(authHeader, account) - if (marker != null && account.lastNotificationId.isLessThan(marker.lastReadId)) { - account.lastNotificationId = marker.lastReadId - } - Log.d(TAG, "getting Notifications for " + account.fullName) - val notifications = mastodonApi.notificationsWithAuth( - authHeader, - account.domain, - account.lastNotificationId - ).blockingGet() - val newId = account.lastNotificationId - var newestId = "" - val result = mutableListOf() - for (notification in notifications.reversed()) { - val currentId = notification.id - if (newestId.isLessThan(currentId)) { - newestId = currentId - account.lastNotificationId = currentId - } - if (newId.isLessThan(currentId)) { - result.add(notification) + // Figure out where to read from. Choose the most recent notification ID from: + // + // - The Mastodon marker API (if the server supports it) + // - account.notificationMarkerId + // - account.lastNotificationId + Log.d(TAG, "getting notification marker for ${account.fullName}") + val remoteMarkerId = fetchMarker(authHeader, account)?.lastReadId ?: "0" + val localMarkerId = account.notificationMarkerId + val markerId = if (remoteMarkerId.isLessThan(localMarkerId)) localMarkerId else remoteMarkerId + val readingPosition = account.lastNotificationId + + var minId: String? = if (readingPosition.isLessThan(markerId)) markerId else readingPosition + Log.d(TAG, " remoteMarkerId: $remoteMarkerId") + Log.d(TAG, " localMarkerId: $localMarkerId") + Log.d(TAG, " readingPosition: $readingPosition") + + Log.d(TAG, "getting Notifications for ${account.fullName}, min_id: $minId") + + // Fetch all outstanding notifications + val notifications = buildList { + while (minId != null) { + val response = mastodonApi.notificationsWithAuth( + authHeader, + account.domain, + minId = minId + ) + if (!response.isSuccessful) break + + // Notifications are returned in the page in order, newest first, + // (https://github.com/mastodon/documentation/issues/1226), insert the + // new page at the head of the list. + response.body()?.let { addAll(0, it) } + + // Get the previous page, which will be chronologically newer + // notifications. If it doesn't exist this is null and the loop + // will exit. + val links = Links.from(response.headers()["link"]) + minId = links.prev } } - return result + + // Save the newest notification ID in the marker. + notifications.firstOrNull()?.let { + val newMarkerId = notifications.first().id + Log.d(TAG, "updating notification marker for ${account.fullName} to: $newMarkerId") + mastodonApi.updateMarkersWithAuth( + auth = authHeader, + domain = account.domain, + notificationsLastReadId = newMarkerId + ) + account.notificationMarkerId = newMarkerId + accountManager.saveAccount(account) + } + + return notifications } - private fun fetchMarker(authHeader: String, account: AccountEntity): Marker? { + private suspend fun fetchMarker(authHeader: String, account: AccountEntity): Marker? { return try { val allMarkers = mastodonApi.markersWithAuth( authHeader, account.domain, listOf("notifications") - ).blockingGet() + ) val notificationMarker = allMarkers["notifications"] - Log.d(TAG, "Fetched marker: $notificationMarker") + Log.d(TAG, "Fetched marker for ${account.fullName}: $notificationMarker") notificationMarker } catch (e: Exception) { Log.e(TAG, "Failed to fetch marker", e) @@ -78,6 +193,13 @@ class NotificationFetcher @Inject constructor( } companion object { - const val TAG = "NotificationFetcher" + private const val TAG = "NotificationFetcher" + + // There's a system limit on the maximum number of notifications an app + // can show, NotificationManagerService.MAX_PACKAGE_NOTIFICATIONS. Unfortunately + // that's not available to client code or via the NotificationManager API. + // The current value in the Android source code is 50, set 40 here to both + // be conservative, and allow some headroom for summary notifications. + private const val MAX_NOTIFICATIONS = 40 } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java index b855049d..6a42e897 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java @@ -16,6 +16,7 @@ package com.keylesspalace.tusky.components.notifications; +import static com.keylesspalace.tusky.BuildConfig.APPLICATION_ID; import static com.keylesspalace.tusky.util.StatusParsingHelper.parseAsMastodonHtml; import static com.keylesspalace.tusky.viewdata.PollViewDataKt.buildDescription; @@ -28,18 +29,22 @@ import android.content.Intent; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.os.Build; +import android.os.Bundle; import android.provider.Settings; +import android.service.notification.StatusBarNotification; import android.text.TextUtils; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.StringRes; import androidx.core.app.NotificationCompat; -import androidx.core.app.NotificationManagerCompat; import androidx.core.app.RemoteInput; import androidx.core.app.TaskStackBuilder; import androidx.work.Constraints; import androidx.work.NetworkType; +import androidx.work.OneTimeWorkRequest; +import androidx.work.OutOfQuotaPolicy; import androidx.work.PeriodicWorkRequest; import androidx.work.WorkManager; import androidx.work.WorkRequest; @@ -56,35 +61,36 @@ import com.keylesspalace.tusky.entity.Notification; import com.keylesspalace.tusky.entity.Poll; import com.keylesspalace.tusky.entity.PollOption; import com.keylesspalace.tusky.entity.Status; -import com.keylesspalace.tusky.receiver.NotificationClearBroadcastReceiver; import com.keylesspalace.tusky.receiver.SendStatusBroadcastReceiver; import com.keylesspalace.tusky.util.StringUtils; import com.keylesspalace.tusky.viewdata.PollViewDataKt; - -import org.json.JSONArray; -import org.json.JSONException; +import com.keylesspalace.tusky.worker.NotificationWorker; import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; -import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.schedulers.Schedulers; - public class NotificationHelper { - private static int notificationId = 0; + /** ID of notification shown when fetching notifications */ + public static final int NOTIFICATION_ID_FETCH_NOTIFICATION = 0; + /** ID of notification shown when pruning the cache */ + public static final int NOTIFICATION_ID_PRUNE_CACHE = 1; + /** Dynamic notification IDs start here */ + private static int notificationId = NOTIFICATION_ID_PRUNE_CACHE + 1; /** * constants used in Intents */ public static final String ACCOUNT_ID = "account_id"; - public static final String TYPE = "type"; + public static final String TYPE = APPLICATION_ID + ".notification.type"; private static final String TAG = "NotificationHelper"; @@ -121,57 +127,60 @@ public class NotificationHelper { public static final String CHANNEL_SIGN_UP = "CHANNEL_SIGN_UP"; public static final String CHANNEL_UPDATES = "CHANNEL_UPDATES"; public static final String CHANNEL_REPORT = "CHANNEL_REPORT"; + public static final String CHANNEL_BACKGROUND_TASKS = "CHANNEL_BACKGROUND_TASKS"; /** * WorkManager Tag */ private static final String NOTIFICATION_PULL_TAG = "pullNotifications"; + /** Tag for the summary notification */ + private static final String GROUP_SUMMARY_TAG = APPLICATION_ID + ".notification.group_summary"; + + /** The name of the account that caused the notification, for use in a summary */ + private static final String EXTRA_ACCOUNT_NAME = APPLICATION_ID + ".notification.extra.account_name"; + + /** The notification's type (string representation of a Notification.Type) */ + private static final String EXTRA_NOTIFICATION_TYPE = APPLICATION_ID + ".notification.extra.notification_type"; + /** - * Takes a given Mastodon notification and either creates a new Android notification or updates - * the state of the existing notification to reflect the new interaction. + * Takes a given Mastodon notification and creates a new Android notification or updates the + * existing Android notification. + *

+ * The Android notification has it's tag set to the Mastodon notification ID, and it's ID set + * to the ID of the account that received the notification. * * @param context to access application preferences and services * @param body a new Mastodon notification * @param account the account for which the notification should be shown + * @return the new notification */ - - public static void make(final Context context, Notification body, AccountEntity account, boolean isFirstOfBatch) { + @NonNull + public static android.app.Notification make(final Context context, NotificationManager notificationManager, Notification body, AccountEntity account, boolean isFirstOfBatch) { body = body.rewriteToStatusTypeIfNeeded(account.getAccountId()); + String mastodonNotificationId = body.getId(); + int accountId = (int) account.getId(); - if (!filterNotification(account, body, context)) { - return; - } - - String rawCurrentNotifications = account.getActiveNotifications(); - JSONArray currentNotifications; - - try { - currentNotifications = new JSONArray(rawCurrentNotifications); - } catch (JSONException e) { - currentNotifications = new JSONArray(); - } - - for (int i = 0; i < currentNotifications.length(); i++) { - try { - if (currentNotifications.getString(i).equals(body.getAccount().getName())) { - currentNotifications.remove(i); - break; - } - } catch (JSONException e) { - Log.d(TAG, Log.getStackTraceString(e)); + // Check for an existing notification with this Mastodon Notification ID + android.app.Notification existingAndroidNotification = null; + StatusBarNotification[] activeNotifications = notificationManager.getActiveNotifications(); + for (StatusBarNotification androidNotification : activeNotifications) { + if (mastodonNotificationId.equals(androidNotification.getTag()) && accountId == androidNotification.getId()) { + existingAndroidNotification = androidNotification.getNotification(); } } - currentNotifications.put(body.getAccount().getName()); - - account.setActiveNotifications(currentNotifications.toString()); - // Notification group member // ========================= - final NotificationCompat.Builder builder = newNotification(context, body, account, false); notificationId++; + // Create the notification -- either create a new one, or use the existing one. + NotificationCompat.Builder builder; + if (existingAndroidNotification == null) { + builder = newAndroidNotification(context, body, account); + } else { + builder = new NotificationCompat.Builder(context, existingAndroidNotification); + } builder.setContentTitle(titleForType(context, body, account)) .setContentText(bodyForType(body, context, account.getAlwaysOpenSpoiler())); @@ -233,51 +242,136 @@ public class NotificationHelper { builder.setCategory(NotificationCompat.CATEGORY_SOCIAL); builder.setOnlyAlertOnce(true); - // only alert for the first notification of a batch to avoid multiple alerts at once + Bundle extras = new Bundle(); + // Add the sending account's name, so it can be used when summarising this notification + extras.putString(EXTRA_ACCOUNT_NAME, body.getAccount().getName()); + extras.putString(EXTRA_NOTIFICATION_TYPE, body.getType().toString()); + builder.addExtras(extras); + + // Only alert for the first notification of a batch to avoid multiple alerts at once if(!isFirstOfBatch) { builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY); } - // Summary - final NotificationCompat.Builder summaryBuilder = newNotification(context, body, account, true); + return builder.build(); + } - if (currentNotifications.length() != 1) { - try { - String title = context.getResources().getQuantityString(R.plurals.notification_title_summary, currentNotifications.length(), currentNotifications.length()); - String text = joinNames(context, currentNotifications); - summaryBuilder.setContentTitle(title) - .setContentText(text); - } catch (JSONException e) { - Log.d(TAG, Log.getStackTraceString(e)); - } + /** + * Updates the summary notifications for each notification group. + *

+ * Notifications are sent to channels. Within each channel they may be grouped, and the group + * may have a summary. + *

+ * Tusky uses N notification channels for each account, each channel corresponds to a type + * of notification (follow, reblog, mention, etc). Therefore each channel also has exactly + * 0 or 1 summary notifications along with its regular notifications. + *

+ * The group key is the same as the channel ID. + *

+ * Regnerates the summary notifications for all active Tusky notifications for `account`. + * This may delete the summary notification if there are no active notifications for that + * account in a group. + * + * @see Create a + * notification group + * @param context to access application preferences and services + * @param notificationManager the system's NotificationManager + * @param account the account for which the notification should be shown + */ + public static void updateSummaryNotifications(Context context, NotificationManager notificationManager, AccountEntity account) { + // Map from the channel ID to a list of notifications in that channel. Those are the + // notifications that will be summarised. + Map> channelGroups = new HashMap<>(); + int accountId = (int) account.getId(); + + // Initialise the map with all channel IDs. + for (Notification.Type ty : Notification.Type.values()) { + channelGroups.put(getChannelId(account, ty), new ArrayList<>()); } - summaryBuilder.setSubText(account.getFullName()); - summaryBuilder.setVisibility(NotificationCompat.VISIBILITY_PRIVATE); - summaryBuilder.setCategory(NotificationCompat.CATEGORY_SOCIAL); - summaryBuilder.setOnlyAlertOnce(true); - summaryBuilder.setGroupSummary(true); + // Fetch all existing notifications. Add them to the map, ignoring notifications that: + // - belong to a different account + // - are summary notifications + for (StatusBarNotification sn : notificationManager.getActiveNotifications()) { + if (sn.getId() != accountId) continue; - NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); + String channelId = sn.getNotification().getGroup(); + String summaryTag = GROUP_SUMMARY_TAG + "." + channelId; + if (summaryTag.equals(sn.getTag())) continue; - notificationManager.notify(notificationId, builder.build()); - if (currentNotifications.length() == 1) { - notificationManager.notify((int) account.getId(), builder.setGroupSummary(true).build()); - } else { - notificationManager.notify((int) account.getId(), summaryBuilder.build()); + // TODO: API 26 supports getting the channel ID directly (sn.getNotification().getChannelId()). + // This works here because the channelId and the groupKey are the same. + List members = channelGroups.get(channelId); + if (members == null) { // can't happen, but just in case... + Log.e(TAG, "members == null for channel ID " + channelId); + continue; + } + members.add(sn); + } + + // Create, update, or cancel the summary notifications for each group. + for (Map.Entry> channelGroup : channelGroups.entrySet()) { + String channelId = channelGroup.getKey(); + List members = channelGroup.getValue(); + String summaryTag = GROUP_SUMMARY_TAG + "." + channelId; + + // If there are 0-1 notifications in this group then the additional summary + // notification is not needed and can be cancelled. + if (members.size() <= 1) { + notificationManager.cancel(summaryTag, accountId); + continue; + } + + // Create a notification that summarises the other notifications in this group + + // All notifications in this group have the same type, so get it from the first. + String notificationType = members.get(0).getNotification().extras.getString(EXTRA_NOTIFICATION_TYPE); + + Intent summaryResultIntent = new Intent(context, MainActivity.class); + summaryResultIntent.putExtra(ACCOUNT_ID, (long) accountId); + summaryResultIntent.putExtra(TYPE, notificationType); + TaskStackBuilder summaryStackBuilder = TaskStackBuilder.create(context); + summaryStackBuilder.addParentStack(MainActivity.class); + summaryStackBuilder.addNextIntent(summaryResultIntent); + + PendingIntent summaryResultPendingIntent = summaryStackBuilder.getPendingIntent((int) (notificationId + account.getId() * 10000), + pendingIntentFlags(false)); + + String title = context.getResources().getQuantityString(R.plurals.notification_title_summary, members.size(), members.size()); + String text = joinNames(context, members); + + NotificationCompat.Builder summaryBuilder = new NotificationCompat.Builder(context, channelId) + .setSmallIcon(R.drawable.ic_notify) + .setContentIntent(summaryResultPendingIntent) + .setColor(context.getColor(R.color.notification_color)) + .setAutoCancel(true) + .setShortcutId(Long.toString(account.getId())) + .setDefaults(0) // So it doesn't ring twice, notify only in Target callback + .setContentTitle(title) + .setContentText(text) + .setSubText(account.getFullName()) + .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) + .setCategory(NotificationCompat.CATEGORY_SOCIAL) + .setOnlyAlertOnce(true) + .setGroup(channelId) + .setGroupSummary(true); + + setSoundVibrationLight(account, summaryBuilder); + + // TODO: Use the batch notification API available in NotificationManagerCompat + // 1.11 and up (https://developer.android.com/jetpack/androidx/releases/core#1.11.0-alpha01) + // when it is released. + notificationManager.notify(summaryTag, accountId, summaryBuilder.build()); + + // Android will rate limit / drop notifications if they're posted too + // quickly. There is no indication to the user that this happened. + // See https://github.com/tuskyapp/Tusky/pull/3626#discussion_r1192963664 + try { Thread.sleep(1000); } catch (InterruptedException ignored) { } } } - private static NotificationCompat.Builder newNotification(Context context, Notification body, AccountEntity account, boolean summary) { - Intent summaryResultIntent = new Intent(context, MainActivity.class); - summaryResultIntent.putExtra(ACCOUNT_ID, account.getId()); - summaryResultIntent.putExtra(TYPE, body.getType().name()); - TaskStackBuilder summaryStackBuilder = TaskStackBuilder.create(context); - summaryStackBuilder.addParentStack(MainActivity.class); - summaryStackBuilder.addNextIntent(summaryResultIntent); - PendingIntent summaryResultPendingIntent = summaryStackBuilder.getPendingIntent((int) (notificationId + account.getId() * 10000), - pendingIntentFlags(false)); + private static NotificationCompat.Builder newAndroidNotification(Context context, Notification body, AccountEntity account) { // we have to switch account here Intent eventResultIntent = new Intent(context, MainActivity.class); @@ -290,22 +384,19 @@ public class NotificationHelper { PendingIntent eventResultPendingIntent = eventStackBuilder.getPendingIntent((int) account.getId(), pendingIntentFlags(false)); - Intent deleteIntent = new Intent(context, NotificationClearBroadcastReceiver.class); - deleteIntent.putExtra(ACCOUNT_ID, account.getId()); - PendingIntent deletePendingIntent = PendingIntent.getBroadcast(context, summary ? (int) account.getId() : notificationId, deleteIntent, - pendingIntentFlags(false)); + String channelId = getChannelId(account, body); + assert channelId != null; - NotificationCompat.Builder builder = new NotificationCompat.Builder(context, getChannelId(account, body)) + NotificationCompat.Builder builder = new NotificationCompat.Builder(context, channelId) .setSmallIcon(R.drawable.ic_notify) - .setContentIntent(summary ? summaryResultPendingIntent : eventResultPendingIntent) - .setDeleteIntent(deletePendingIntent) + .setContentIntent(eventResultPendingIntent) .setColor(context.getColor(R.color.notification_color)) - .setGroup(account.getAccountId()) + .setGroup(channelId) .setAutoCancel(true) .setShortcutId(Long.toString(account.getId())) .setDefaults(0); // So it doesn't ring twice, notify only in Target callback - setupPreferences(account, builder); + setSoundVibrationLight(account, builder); return builder; } @@ -388,6 +479,49 @@ public class NotificationHelper { pendingIntentFlags(false)); } + /** + * Creates a notification channel for notifications for background work that should not + * disturb the user. + * + * @param context context + */ + public static void createWorkerNotificationChannel(@NonNull Context context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return; + + NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + + NotificationChannel channel = new NotificationChannel( + CHANNEL_BACKGROUND_TASKS, + context.getString(R.string.notification_listenable_worker_name), + NotificationManager.IMPORTANCE_NONE + ); + + channel.setDescription(context.getString(R.string.notification_listenable_worker_description)); + channel.enableLights(false); + channel.enableVibration(false); + channel.setShowBadge(false); + + notificationManager.createNotificationChannel(channel); + } + + /** + * Creates a notification for a background worker. + * + * @param context context + * @param titleResource String resource to use as the notification's title + * @return the notification + */ + @NonNull + public static android.app.Notification createWorkerNotification(@NonNull Context context, @StringRes int titleResource) { + String title = context.getString(titleResource); + return new NotificationCompat.Builder(context, CHANNEL_BACKGROUND_TASKS) + .setContentTitle(title) + .setTicker(title) + .setSmallIcon(R.drawable.ic_notify) + .setOngoing(true) + .build(); + } + public static void createNotificationChannelsForAccount(@NonNull AccountEntity account, @NonNull Context context) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { @@ -453,7 +587,6 @@ public class NotificationHelper { } notificationManager.createNotificationChannels(channels); - } } @@ -495,6 +628,15 @@ public class NotificationHelper { WorkManager workManager = WorkManager.getInstance(context); workManager.cancelAllWorkByTag(NOTIFICATION_PULL_TAG); + // Periodic work requests are supposed to start running soon after being enqueued. In + // practice that may not be soon enough, so create and enqueue an expedited one-time + // request to get new notifications immediately. + WorkRequest fetchNotifications = new OneTimeWorkRequest.Builder(NotificationWorker.class) + .setExpedited(OutOfQuotaPolicy.DROP_WORK_REQUEST) + .setConstraints(new Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) + .build(); + workManager.enqueue(fetchNotifications); + WorkRequest workRequest = new PeriodicWorkRequest.Builder( NotificationWorker.class, PeriodicWorkRequest.MIN_PERIODIC_INTERVAL_MILLIS, TimeUnit.MILLISECONDS, @@ -502,6 +644,7 @@ public class NotificationHelper { ) .addTag(NOTIFICATION_PULL_TAG) .setConstraints(new Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) + .setInitialDelay(5, TimeUnit.MINUTES) .build(); workManager.enqueue(workRequest); @@ -514,33 +657,23 @@ public class NotificationHelper { Log.d(TAG, "disabled notification checks"); } - public static void clearNotificationsForActiveAccount(@NonNull Context context, @NonNull AccountManager accountManager) { - AccountEntity account = accountManager.getActiveAccount(); - if (account != null && !account.getActiveNotifications().equals("[]")) { - Single.fromCallable(() -> { - account.setActiveNotifications("[]"); - accountManager.saveAccount(account); + public static void clearNotificationsForAccount(@NonNull Context context, @NonNull AccountEntity account) { + int accountId = (int) account.getId(); - NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); - notificationManager.cancel((int) account.getId()); - return true; - }) - .subscribeOn(Schedulers.io()) - .subscribe(); + NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + for (StatusBarNotification androidNotification : notificationManager.getActiveNotifications()) { + if (accountId == androidNotification.getId()) { + notificationManager.cancel(androidNotification.getTag(), androidNotification.getId()); + } } } - public static boolean filterNotification(AccountEntity account, Notification notification, - Context context) { - return filterNotification(account, notification.getType(), context); + public static boolean filterNotification(NotificationManager notificationManager, AccountEntity account, @NonNull Notification notification) { + return filterNotification(notificationManager, account, notification.getType()); } - public static boolean filterNotification(AccountEntity account, Notification.Type type, - Context context) { - + public static boolean filterNotification(@NonNull NotificationManager notificationManager, @NonNull AccountEntity account, @NonNull Notification.Type type) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); - String channelId = getChannelId(account, type); if(channelId == null) { // unknown notificationtype @@ -610,9 +743,7 @@ public class NotificationHelper { } - private static void setupPreferences(AccountEntity account, - NotificationCompat.Builder builder) { - + private static void setSoundVibrationLight(AccountEntity account, NotificationCompat.Builder builder) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { return; //do nothing on Android O or newer, the system uses the channel settings anyway } @@ -630,28 +761,29 @@ public class NotificationHelper { } } - private static String wrapItemAt(JSONArray array, int index) throws JSONException { - return StringUtils.unicodeWrap(array.get(index).toString()); + private static String wrapItemAt(StatusBarNotification notification) { + return StringUtils.unicodeWrap(notification.getNotification().extras.getString(EXTRA_ACCOUNT_NAME));//getAccount().getName()); } @Nullable - private static String joinNames(Context context, JSONArray array) throws JSONException { - if (array.length() > 3) { - int length = array.length(); + private static String joinNames(Context context, List notifications) { + if (notifications.size() > 3) { + int length = notifications.size(); + //notifications.get(0).getNotification().extras.getString(EXTRA_ACCOUNT_NAME); return String.format(context.getString(R.string.notification_summary_large), - wrapItemAt(array, length - 1), - wrapItemAt(array, length - 2), - wrapItemAt(array, length - 3), + wrapItemAt(notifications.get(length - 1)), + wrapItemAt(notifications.get(length - 2)), + wrapItemAt(notifications.get(length - 3)), length - 3); - } else if (array.length() == 3) { + } else if (notifications.size() == 3) { return String.format(context.getString(R.string.notification_summary_medium), - wrapItemAt(array, 2), - wrapItemAt(array, 1), - wrapItemAt(array, 0)); - } else if (array.length() == 2) { + wrapItemAt(notifications.get(2)), + wrapItemAt(notifications.get(1)), + wrapItemAt(notifications.get(0))); + } else if (notifications.size() == 2) { return String.format(context.getString(R.string.notification_summary_small), - wrapItemAt(array, 1), - wrapItemAt(array, 0)); + wrapItemAt(notifications.get(1)), + wrapItemAt(notifications.get(0))); } return null; diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationWorker.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationWorker.kt deleted file mode 100644 index 42b9c869..00000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationWorker.kt +++ /dev/null @@ -1,51 +0,0 @@ -/* Copyright 2020 Tusky Contributors - * - * This file is part of Tusky. - * - * Tusky is free software: you can redistribute it and/or modify it under the terms of the GNU - * Lesser 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 Lesser - * General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License along with Tusky. If - * not, see . */ - -package com.keylesspalace.tusky.components.notifications - -import android.content.Context -import androidx.work.ListenableWorker -import androidx.work.Worker -import androidx.work.WorkerFactory -import androidx.work.WorkerParameters -import javax.inject.Inject - -class NotificationWorker( - context: Context, - params: WorkerParameters, - private val notificationsFetcher: NotificationFetcher -) : Worker(context, params) { - - override fun doWork(): Result { - notificationsFetcher.fetchAndShow() - return Result.success() - } -} - -class NotificationWorkerFactory @Inject constructor( - private val notificationsFetcher: NotificationFetcher -) : WorkerFactory() { - - override fun createWorker( - appContext: Context, - workerClassName: String, - workerParameters: WorkerParameters - ): ListenableWorker? { - if (workerClassName == NotificationWorker::class.java.name) { - return NotificationWorker(appContext, workerParameters, notificationsFetcher) - } - return null - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt new file mode 100644 index 00000000..baa66e5b --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt @@ -0,0 +1,692 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.components.notifications + +import android.app.Dialog +import android.content.DialogInterface +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AlertDialog +import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.view.MenuProvider +import androidx.core.view.isVisible +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.paging.LoadState +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.NO_POSITION +import androidx.recyclerview.widget.RecyclerView.SCROLL_STATE_IDLE +import androidx.recyclerview.widget.SimpleItemAnimator +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener +import at.connyduck.sparkbutton.helpers.Utils +import com.google.android.material.appbar.AppBarLayout.ScrollingViewBehavior +import com.google.android.material.color.MaterialColors +import com.google.android.material.snackbar.Snackbar +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.adapter.StatusBaseViewHolder +import com.keylesspalace.tusky.databinding.FragmentTimelineNotificationsBinding +import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.di.ViewModelFactory +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.fragment.SFragment +import com.keylesspalace.tusky.interfaces.AccountActionListener +import com.keylesspalace.tusky.interfaces.ActionButtonActivity +import com.keylesspalace.tusky.interfaces.ReselectableFragment +import com.keylesspalace.tusky.interfaces.StatusActionListener +import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate +import com.keylesspalace.tusky.util.openLink +import com.keylesspalace.tusky.util.viewBinding +import com.keylesspalace.tusky.viewdata.AttachmentViewData.Companion.list +import com.keylesspalace.tusky.viewdata.NotificationViewData +import com.mikepenz.iconics.IconicsDrawable +import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial +import com.mikepenz.iconics.utils.colorInt +import com.mikepenz.iconics.utils.sizeDp +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import java.io.IOException +import javax.inject.Inject + +class NotificationsFragment : + SFragment(), + StatusActionListener, + NotificationActionListener, + AccountActionListener, + OnRefreshListener, + MenuProvider, + Injectable, + ReselectableFragment { + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + private val viewModel: NotificationsViewModel by viewModels { viewModelFactory } + + private val binding by viewBinding(FragmentTimelineNotificationsBinding::bind) + + private lateinit var adapter: NotificationsPagingAdapter + + private lateinit var layoutManager: LinearLayoutManager + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + adapter = NotificationsPagingAdapter( + notificationDiffCallback, + accountId = viewModel.account.accountId, + statusActionListener = this, + notificationActionListener = this, + accountActionListener = this, + statusDisplayOptions = viewModel.statusDisplayOptions.value + ) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return inflater.inflate(R.layout.fragment_timeline_notifications, container, false) + } + + private fun updateFilterVisibility(showFilter: Boolean) { + val params = binding.swipeRefreshLayout.layoutParams as CoordinatorLayout.LayoutParams + if (showFilter) { + binding.appBarOptions.setExpanded(true, false) + binding.appBarOptions.visibility = View.VISIBLE + // Set content behaviour to hide filter on scroll + params.behavior = ScrollingViewBehavior() + } else { + binding.appBarOptions.setExpanded(false, false) + binding.appBarOptions.visibility = View.GONE + // Clear behaviour to hide app bar + params.behavior = null + } + } + + private fun confirmClearNotifications() { + AlertDialog.Builder(requireContext()) + .setMessage(R.string.notification_clear_text) + .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> clearNotifications() } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) + + // Setup the SwipeRefreshLayout. + binding.swipeRefreshLayout.setOnRefreshListener(this) + binding.swipeRefreshLayout.setColorSchemeResources(R.color.chinwag_green) + + // Setup the RecyclerView. + binding.recyclerView.setHasFixedSize(true) + layoutManager = LinearLayoutManager(context) + binding.recyclerView.layoutManager = layoutManager + binding.recyclerView.setAccessibilityDelegateCompat( + ListStatusAccessibilityDelegate( + binding.recyclerView, + this + ) { pos: Int -> + val notification = adapter.snapshot().getOrNull(pos) + // We support replies only for now + if (notification is NotificationViewData) { + notification.statusViewData + } else { + null + } + } + ) + binding.recyclerView.addItemDecoration( + DividerItemDecoration( + context, + DividerItemDecoration.VERTICAL + ) + ) + + binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { + val actionButton = (activity as ActionButtonActivity).actionButton + + override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) { + actionButton?.let { fab -> + if (!viewModel.uiState.value.showFabWhileScrolling) { + if (dy > 0 && fab.isShown) { + fab.hide() // Hide when scrolling down + } else if (dy < 0 && !fab.isShown) { + fab.show() // Show when scrolling up + } + } else if (!fab.isShown) { + fab.show() + } + } + } + + @Suppress("SyntheticAccessor") + override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { + newState != SCROLL_STATE_IDLE && return + + // Save the ID of the first notification visible in the list, so the user's + // reading position is always restorable. + layoutManager.findFirstVisibleItemPosition().takeIf { it != NO_POSITION }?.let { position -> + adapter.snapshot().getOrNull(position)?.id?.let { id -> + viewModel.accept(InfallibleUiAction.SaveVisibleId(visibleId = id)) + } + } + } + }) + + binding.recyclerView.adapter = adapter.withLoadStateHeaderAndFooter( + header = NotificationsLoadStateAdapter { adapter.retry() }, + footer = NotificationsLoadStateAdapter { adapter.retry() } + ) + + binding.buttonClear.setOnClickListener { confirmClearNotifications() } + binding.buttonFilter.setOnClickListener { showFilterDialog() } + (binding.recyclerView.itemAnimator as SimpleItemAnimator?)!!.supportsChangeAnimations = + false + + // Signal the user that a refresh has loaded new items above their current position + // by scrolling up slightly to disclose the new content + adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { + override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { + if (positionStart == 0 && adapter.itemCount != itemCount) { + binding.recyclerView.post { + if (getView() != null) { + binding.recyclerView.scrollBy(0, Utils.dpToPx(requireContext(), -30)) + } + } + } + } + }) + + // update post timestamps + val updateTimestampFlow = flow { + while (true) { + delay(60000) + emit(Unit) + } + }.onEach { + adapter.notifyItemRangeChanged(0, adapter.itemCount, listOf(StatusBaseViewHolder.Key.KEY_CREATED)) + } + + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + viewModel.pagingData.collectLatest { pagingData -> + Log.d(TAG, "Submitting data to adapter") + adapter.submitData(pagingData) + } + } + + // Show errors from the view model as snack bars. + // + // Errors are shown: + // - Indefinitely, so the user has a chance to read and understand + // the message + // - With a max of 5 text lines, to allow space for longer errors. + // E.g., on a typical device, an error message like "Bookmarking + // post failed: Unable to resolve host 'mastodon.social': No + // address associated with hostname" is 3 lines. + // - With a "Retry" option if the error included a UiAction to retry. + launch { + viewModel.uiError.collect { error -> + Log.d(TAG, error.toString()) + val message = getString( + error.message, + error.throwable.localizedMessage + ?: getString(R.string.ui_error_unknown) + ) + val snackbar = Snackbar.make( + // Without this the FAB will not move out of the way + (activity as ActionButtonActivity).actionButton ?: binding.root, + message, + Snackbar.LENGTH_INDEFINITE + ).setTextMaxLines(5) + error.action?.let { action -> + snackbar.setAction(R.string.action_retry) { + viewModel.accept(action) + } + } + snackbar.show() + + // The status view has pre-emptively updated its state to show + // that the action succeeded. Since it hasn't, re-bind the view + // to show the correct data. + error.action?.let { action -> + action is StatusAction || return@let + + val position = adapter.snapshot().indexOfFirst { + it?.statusViewData?.status?.id == (action as StatusAction).statusViewData.id + } + if (position != RecyclerView.NO_POSITION) { + adapter.notifyItemChanged(position) + } + } + } + } + + // Show successful notification action as brief snackbars, so the + // user is clear the action has happened. + launch { + viewModel.uiSuccess + .filterIsInstance() + .collect { + Snackbar.make( + (activity as ActionButtonActivity).actionButton ?: binding.root, + getString(it.msg), + Snackbar.LENGTH_SHORT + ).show() + + when (it) { + // The follow request is no longer valid, refresh the adapter to + // remove it. + is NotificationActionSuccess.AcceptFollowRequest, + is NotificationActionSuccess.RejectFollowRequest -> adapter.refresh() + } + } + } + + // Update adapter data when status actions are successful, and re-bind to update + // the UI. + launch { + viewModel.uiSuccess + .filterIsInstance() + .collect { + val indexedViewData = adapter.snapshot() + .withIndex() + .firstOrNull { notificationViewData -> + notificationViewData.value?.statusViewData?.status?.id == + it.action.statusViewData.id + } ?: return@collect + + val statusViewData = + indexedViewData.value?.statusViewData ?: return@collect + + val status = when (it) { + is StatusActionSuccess.Bookmark -> + statusViewData.status.copy(bookmarked = it.action.state) + is StatusActionSuccess.Favourite -> + statusViewData.status.copy(favourited = it.action.state) + is StatusActionSuccess.Reblog -> + statusViewData.status.copy(reblogged = it.action.state) + is StatusActionSuccess.VoteInPoll -> + statusViewData.status.copy( + poll = it.action.poll.votedCopy(it.action.choices) + ) + } + indexedViewData.value?.statusViewData = statusViewData.copy( + status = status + ) + + adapter.notifyItemChanged(indexedViewData.index) + } + } + + // Refresh adapter on mutes and blocks + launch { + viewModel.uiSuccess.collectLatest { + when (it) { + is UiSuccess.Block, is UiSuccess.Mute, is UiSuccess.MuteConversation -> + adapter.refresh() + else -> { /* nothing to do */ + } + } + } + } + + // Update filter option visibility from uiState + launch { + viewModel.uiState.collectLatest { updateFilterVisibility(it.showFilterOptions) } + } + + // Update status display from statusDisplayOptions. If the new options request + // relative time display collect the flow to periodically update the timestamp in the list gui elements. + launch { + viewModel.statusDisplayOptions + .collectLatest { + // NOTE this this also triggered (emitted?) on resume. + + adapter.statusDisplayOptions = it + adapter.notifyItemRangeChanged(0, adapter.itemCount, null) + + if (!it.useAbsoluteTime) { + updateTimestampFlow.collect() + } + } + } + + // Update the UI from the loadState + adapter.loadStateFlow + .distinctUntilChangedBy { it.refresh } + .collect { loadState -> + binding.recyclerView.isVisible = true + binding.progressBar.isVisible = loadState.refresh is LoadState.Loading && + !binding.swipeRefreshLayout.isRefreshing + binding.swipeRefreshLayout.isRefreshing = + loadState.refresh is LoadState.Loading && !binding.progressBar.isVisible + + binding.statusView.isVisible = false + if (loadState.refresh is LoadState.NotLoading) { + if (adapter.itemCount == 0) { + binding.statusView.setup( + R.drawable.elephant_friend_empty, + R.string.message_empty + ) + binding.recyclerView.isVisible = false + binding.statusView.isVisible = true + } else { + binding.statusView.isVisible = false + } + } + + if (loadState.refresh is LoadState.Error) { + when ((loadState.refresh as LoadState.Error).error) { + is IOException -> { + binding.statusView.setup( + R.drawable.elephant_offline, + R.string.error_network + ) { adapter.retry() } + } + else -> { + binding.statusView.setup( + R.drawable.elephant_error, + R.string.error_generic + ) { adapter.retry() } + } + } + binding.recyclerView.isVisible = false + binding.statusView.isVisible = true + } + } + } + } + } + + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.fragment_notifications, menu) + menu.findItem(R.id.action_refresh)?.apply { + icon = IconicsDrawable(requireContext(), GoogleMaterial.Icon.gmd_refresh).apply { + sizeDp = 20 + colorInt = MaterialColors.getColor(binding.root, android.R.attr.textColorPrimary) + } + } + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + return when (menuItem.itemId) { + R.id.action_refresh -> { + binding.swipeRefreshLayout.isRefreshing = true + onRefresh() + true + } + R.id.load_newest -> { + viewModel.accept(InfallibleUiAction.LoadNewest) + true + } + else -> false + } + } + + override fun onRefresh() { + binding.progressBar.isVisible = false + adapter.refresh() + NotificationHelper.clearNotificationsForAccount(requireContext(), viewModel.account) + } + + override fun onPause() { + super.onPause() + + // Save the ID of the first notification visible in the list + val position = layoutManager.findFirstVisibleItemPosition() + if (position >= 0) { + adapter.snapshot().getOrNull(position)?.id?.let { id -> + viewModel.accept(InfallibleUiAction.SaveVisibleId(visibleId = id)) + } + } + } + + override fun onResume() { + super.onResume() + NotificationHelper.clearNotificationsForAccount(requireContext(), viewModel.account) + } + + override fun onReply(position: Int) { + val status = adapter.peek(position)?.statusViewData?.status ?: return + super.reply(status) + } + + override fun onReblog(reblog: Boolean, position: Int) { + val statusViewData = adapter.peek(position)?.statusViewData ?: return + viewModel.accept(StatusAction.Reblog(reblog, statusViewData)) + } + + override fun onFavourite(favourite: Boolean, position: Int) { + val statusViewData = adapter.peek(position)?.statusViewData ?: return + viewModel.accept(StatusAction.Favourite(favourite, statusViewData)) + } + + override fun onBookmark(bookmark: Boolean, position: Int) { + val statusViewData = adapter.peek(position)?.statusViewData ?: return + viewModel.accept(StatusAction.Bookmark(bookmark, statusViewData)) + } + + override fun onVoteInPoll(position: Int, choices: List) { + val statusViewData = adapter.peek(position)?.statusViewData ?: return + val poll = statusViewData.status.poll ?: return + viewModel.accept(StatusAction.VoteInPoll(poll, choices, statusViewData)) + } + + override fun onMore(view: View, position: Int) { + val status = adapter.peek(position)?.statusViewData?.status ?: return + super.more(status, view, position) + } + + override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { + val status = adapter.peek(position)?.statusViewData?.status ?: return + super.viewMedia(attachmentIndex, list(status), view) + } + + override fun onViewThread(position: Int) { + val status = adapter.peek(position)?.statusViewData?.status ?: return + super.viewThread(status.actionableId, status.actionableStatus.url) + } + + override fun onOpenReblog(position: Int) { + val account = adapter.peek(position)?.account!! + onViewAccount(account.id) + } + + override fun onExpandedChange(expanded: Boolean, position: Int) { + val notificationViewData = adapter.snapshot()[position] ?: return + notificationViewData.statusViewData = notificationViewData.statusViewData?.copy( + isExpanded = expanded + ) + adapter.notifyItemChanged(position) + } + + override fun onContentHiddenChange(isShowing: Boolean, position: Int) { + val notificationViewData = adapter.snapshot()[position] ?: return + notificationViewData.statusViewData = notificationViewData.statusViewData?.copy( + isShowingContent = isShowing + ) + adapter.notifyItemChanged(position) + } + + override fun onLoadMore(position: Int) { + // Empty -- this fragment doesn't show placeholders + } + + override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { + val notificationViewData = adapter.snapshot()[position] ?: return + notificationViewData.statusViewData = notificationViewData.statusViewData?.copy( + isCollapsed = isCollapsed + ) + adapter.notifyItemChanged(position) + } + + override fun onNotificationContentCollapsedChange(isCollapsed: Boolean, position: Int) { + onContentCollapsedChange(isCollapsed, position) + } + + override fun clearWarningAction(position: Int) { + } + + private fun clearNotifications() { + binding.swipeRefreshLayout.isRefreshing = false + binding.progressBar.isVisible = false + viewModel.accept(FallibleUiAction.ClearNotifications) + } + + private fun showFilterDialog() { + FilterDialogFragment(viewModel.uiState.value.activeFilter) { filter -> + if (viewModel.uiState.value.activeFilter != filter) { + viewModel.accept(InfallibleUiAction.ApplyFilter(filter)) + } + } + .show(parentFragmentManager, "dialogFilter") + } + + override fun onViewTag(tag: String) { + super.viewTag(tag) + } + + override fun onViewAccount(id: String) { + super.viewAccount(id) + } + + override fun onMute(mute: Boolean, id: String, position: Int, notifications: Boolean) { + adapter.refresh() + } + + override fun onBlock(block: Boolean, id: String, position: Int) { + adapter.refresh() + } + + override fun onRespondToFollowRequest(accept: Boolean, accountId: String, position: Int) { + if (accept) { + viewModel.accept(NotificationAction.AcceptFollowRequest(accountId)) + } else { + viewModel.accept(NotificationAction.RejectFollowRequest(accountId)) + } + } + + override fun onViewThreadForStatus(status: Status) { + super.viewThread(status.actionableId, status.actionableStatus.url) + } + + override fun onViewReport(reportId: String) { + requireContext().openLink( + "https://${viewModel.account.domain}/admin/reports/$reportId" + ) + } + + public override fun removeItem(position: Int) { + // Empty -- this fragment doesn't remove items + } + + override fun onReselect() { + if (isAdded) { + binding.appBarOptions.setExpanded(true, false) + layoutManager.scrollToPosition(0) + } + } + + companion object { + private const val TAG = "NotificationsFragment" + fun newInstance() = NotificationsFragment() + + private val notificationDiffCallback: DiffUtil.ItemCallback = + object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: NotificationViewData, + newItem: NotificationViewData + ): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame( + oldItem: NotificationViewData, + newItem: NotificationViewData + ): Boolean { + return false + } + + override fun getChangePayload( + oldItem: NotificationViewData, + newItem: NotificationViewData + ): Any? { + return if (oldItem == newItem) { + // If items are equal - update timestamp only + listOf(StatusBaseViewHolder.Key.KEY_CREATED) + } else { + // If items are different - update a whole view holder + null + } + } + } + } +} + +class FilterDialogFragment( + private val activeFilter: Set, + private val listener: ((filter: Set) -> Unit) +) : DialogFragment() { + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val context = requireContext() + + val items = Notification.Type.visibleTypes.map { getString(it.uiString) }.toTypedArray() + val checkedItems = Notification.Type.visibleTypes.map { + !activeFilter.contains(it) + }.toBooleanArray() + + val builder = AlertDialog.Builder(context) + .setTitle(R.string.notifications_apply_filter) + .setMultiChoiceItems(items, checkedItems) { _, which, isChecked -> + checkedItems[which] = isChecked + } + .setPositiveButton(android.R.string.ok) { _, _ -> + val excludes: MutableSet = HashSet() + for (i in Notification.Type.visibleTypes.indices) { + if (!checkedItems[i]) excludes.add(Notification.Type.visibleTypes[i]) + } + listener(excludes) + } + .setNegativeButton(android.R.string.cancel) { _, _ -> } + return builder.create() + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateAdapter.kt new file mode 100644 index 00000000..0a281ccd --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateAdapter.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.components.notifications + +import android.view.ViewGroup +import androidx.paging.LoadState +import androidx.paging.LoadStateAdapter + +/** Show load state and retry options when loading notifications */ +class NotificationsLoadStateAdapter( + private val retry: () -> Unit +) : LoadStateAdapter() { + override fun onCreateViewHolder( + parent: ViewGroup, + loadState: LoadState + ): NotificationsLoadStateViewHolder { + return NotificationsLoadStateViewHolder.create(parent, retry) + } + + override fun onBindViewHolder(holder: NotificationsLoadStateViewHolder, loadState: LoadState) { + holder.bind(loadState) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateViewHolder.kt new file mode 100644 index 00000000..f3c006d3 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateViewHolder.kt @@ -0,0 +1,73 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.components.notifications + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.paging.LoadState +import androidx.recyclerview.widget.RecyclerView +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.databinding.ItemNotificationsLoadStateFooterViewBinding +import java.net.SocketTimeoutException + +/** + * Display the header/footer loading state to the user. + * + * Either: + * + * 1. A page is being loaded, display a progress view, or + * 2. An error occurred, display an error message with a "retry" button + * + * @param retry function to invoke if the user clicks the "retry" button + */ +class NotificationsLoadStateViewHolder( + private val binding: ItemNotificationsLoadStateFooterViewBinding, + retry: () -> Unit +) : RecyclerView.ViewHolder(binding.root) { + init { + binding.retryButton.setOnClickListener { retry.invoke() } + } + + fun bind(loadState: LoadState) { + if (loadState is LoadState.Error) { + val ctx = binding.root.context + binding.errorMsg.text = when (loadState.error) { + is SocketTimeoutException -> ctx.getString(R.string.socket_timeout_exception) + // Other exceptions to consider: + // - UnknownHostException, default text is: + // Unable to resolve "%s": No address associated with hostname + else -> loadState.error.localizedMessage + } + } + binding.progressBar.isVisible = loadState is LoadState.Loading + binding.retryButton.isVisible = loadState is LoadState.Error + binding.errorMsg.isVisible = loadState is LoadState.Error + } + + companion object { + fun create(parent: ViewGroup, retry: () -> Unit): NotificationsLoadStateViewHolder { + val binding = ItemNotificationsLoadStateFooterViewBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + return NotificationsLoadStateViewHolder(binding, retry) + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingAdapter.kt new file mode 100644 index 00000000..faa1aefc --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingAdapter.kt @@ -0,0 +1,207 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.components.notifications + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.paging.PagingDataAdapter +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import com.keylesspalace.tusky.adapter.FollowRequestViewHolder +import com.keylesspalace.tusky.adapter.ReportNotificationViewHolder +import com.keylesspalace.tusky.databinding.ItemFollowBinding +import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding +import com.keylesspalace.tusky.databinding.ItemReportNotificationBinding +import com.keylesspalace.tusky.databinding.ItemStatusBinding +import com.keylesspalace.tusky.databinding.ItemStatusNotificationBinding +import com.keylesspalace.tusky.databinding.SimpleListItem1Binding +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.interfaces.AccountActionListener +import com.keylesspalace.tusky.interfaces.StatusActionListener +import com.keylesspalace.tusky.util.AbsoluteTimeFormatter +import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.viewdata.NotificationViewData + +/** How to present the notification in the UI */ +enum class NotificationViewKind { + /** View as the original status */ + STATUS, + + /** View as the original status, with the interaction type above */ + NOTIFICATION, + FOLLOW, + FOLLOW_REQUEST, + REPORT, + UNKNOWN; + + companion object { + fun from(kind: Notification.Type?): NotificationViewKind { + return when (kind) { + Notification.Type.MENTION, + Notification.Type.POLL, + Notification.Type.UNKNOWN -> STATUS + Notification.Type.FAVOURITE, + Notification.Type.REBLOG, + Notification.Type.STATUS, + Notification.Type.UPDATE -> NOTIFICATION + Notification.Type.FOLLOW, + Notification.Type.SIGN_UP -> FOLLOW + Notification.Type.FOLLOW_REQUEST -> FOLLOW_REQUEST + Notification.Type.REPORT -> REPORT + null -> UNKNOWN + } + } + } +} + +interface NotificationActionListener { + fun onViewAccount(id: String) + fun onViewThreadForStatus(status: Status) + fun onViewReport(reportId: String) + + /** + * Called when the status has a content warning and the visibility of the content behind + * the warning is being changed. + * + * @param expanded the desired state of the content behind the content warning + * @param position the adapter position of the view + * + */ + fun onExpandedChange(expanded: Boolean, position: Int) + + /** + * Called when the status [android.widget.ToggleButton] responsible for collapsing long + * status content is interacted with. + * + * @param isCollapsed Whether the status content is shown in a collapsed state or fully. + * @param position The position of the status in the list. + */ + fun onNotificationContentCollapsedChange(isCollapsed: Boolean, position: Int) +} + +class NotificationsPagingAdapter( + diffCallback: DiffUtil.ItemCallback, + /** ID of the the account that notifications are being displayed for */ + private val accountId: String, + private val statusActionListener: StatusActionListener, + private val notificationActionListener: NotificationActionListener, + private val accountActionListener: AccountActionListener, + var statusDisplayOptions: StatusDisplayOptions +) : PagingDataAdapter(diffCallback) { + + private val absoluteTimeFormatter = AbsoluteTimeFormatter() + + /** View holders in this adapter must implement this interface */ + interface ViewHolder { + /** Bind the data from the notification and payloads to the view */ + fun bind( + viewData: NotificationViewData, + payloads: List<*>?, + statusDisplayOptions: StatusDisplayOptions + ) + } + + override fun getItemViewType(position: Int): Int { + return NotificationViewKind.from(getItem(position)?.type).ordinal + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val inflater = LayoutInflater.from(parent.context) + + return when (NotificationViewKind.values()[viewType]) { + NotificationViewKind.STATUS -> { + StatusViewHolder( + ItemStatusBinding.inflate(inflater, parent, false), + statusActionListener, + accountId + ) + } + NotificationViewKind.NOTIFICATION -> { + StatusNotificationViewHolder( + ItemStatusNotificationBinding.inflate(inflater, parent, false), + statusActionListener, + notificationActionListener, + absoluteTimeFormatter + ) + } + NotificationViewKind.FOLLOW -> { + FollowViewHolder( + ItemFollowBinding.inflate(inflater, parent, false), + notificationActionListener, + statusActionListener + ) + } + NotificationViewKind.FOLLOW_REQUEST -> { + FollowRequestViewHolder( + ItemFollowRequestBinding.inflate(inflater, parent, false), + accountActionListener, + statusActionListener, + showHeader = true + ) + } + NotificationViewKind.REPORT -> { + ReportNotificationViewHolder( + ItemReportNotificationBinding.inflate(inflater, parent, false), + notificationActionListener + ) + } + else -> { + FallbackNotificationViewHolder( + SimpleListItem1Binding.inflate(inflater, parent, false) + ) + } + } + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + bindViewHolder(holder, position, null) + } + + override fun onBindViewHolder( + holder: RecyclerView.ViewHolder, + position: Int, + payloads: MutableList + ) { + bindViewHolder(holder, position, payloads) + } + + private fun bindViewHolder( + holder: RecyclerView.ViewHolder, + position: Int, + payloads: List<*>? + ) { + getItem(position)?.let { (holder as ViewHolder).bind(it, payloads, statusDisplayOptions) } + } + + /** + * Notification view holder to use if no other type is appropriate. Should never normally + * be used, but is useful when migrating code. + */ + private class FallbackNotificationViewHolder( + val binding: SimpleListItem1Binding + ) : ViewHolder, RecyclerView.ViewHolder(binding.root) { + override fun bind( + viewData: NotificationViewData, + payloads: List<*>?, + statusDisplayOptions: StatusDisplayOptions + ) { + binding.text1.text = viewData.statusViewData?.content + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingSource.kt new file mode 100644 index 00000000..b754989d --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingSource.kt @@ -0,0 +1,216 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.components.notifications + +import android.util.Log +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.google.gson.Gson +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.HttpHeaderLink +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import okhttp3.Headers +import retrofit2.Response +import javax.inject.Inject + +/** Models next/prev links from the "Links" header in an API response */ +data class Links(val next: String?, val prev: String?) { + companion object { + fun from(linkHeader: String?): Links { + val links = HttpHeaderLink.parse(linkHeader) + return Links( + next = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter( + "max_id" + ), + prev = HttpHeaderLink.findByRelationType(links, "prev")?.uri?.getQueryParameter( + "min_id" + ) + ) + } + } +} + +/** [PagingSource] for Mastodon Notifications, identified by the Notification ID */ +class NotificationsPagingSource @Inject constructor( + private val mastodonApi: MastodonApi, + private val gson: Gson, + private val notificationFilter: Set +) : PagingSource() { + override suspend fun load(params: LoadParams): LoadResult { + Log.d(TAG, "load() with ${params.javaClass.simpleName} for key: ${params.key}") + + try { + val response = when (params) { + is LoadParams.Refresh -> { + getInitialPage(params) + } + is LoadParams.Append -> mastodonApi.notifications( + maxId = params.key, + limit = params.loadSize, + excludes = notificationFilter + ) + is LoadParams.Prepend -> mastodonApi.notifications( + minId = params.key, + limit = params.loadSize, + excludes = notificationFilter + ) + } + + if (!response.isSuccessful) { + val code = response.code() + + val msg = response.errorBody()?.string()?.let { errorBody -> + if (errorBody.isBlank()) return@let "no reason given" + + val error = try { + gson.fromJson(errorBody, com.keylesspalace.tusky.entity.Error::class.java) + } catch (e: Exception) { + return@let "$errorBody ($e)" + } + + when (val desc = error.error_description) { + null -> error.error + else -> "${error.error}: $desc" + } + } ?: "no reason given" + return LoadResult.Error(Throwable("HTTP $code: $msg")) + } + + val links = Links.from(response.headers()["link"]) + return LoadResult.Page( + data = response.body()!!, + nextKey = links.next, + prevKey = links.prev + ) + } catch (e: Exception) { + return LoadResult.Error(e) + } + } + + /** + * Fetch the initial page of notifications, using params.key as the ID of the initial + * notification to fetch. + * + * - If there is no key, a page of the most recent notifications is returned + * - If the notification exists, and is not filtered, a page of notifications is returned + * - If the notification does not exist, or is filtered, the page of notifications immediately + * before is returned (if non-empty) + * - If there is no page of notifications immediately before then the page immediately after + * is returned (if non-empty) + * - Finally, fall back to the most recent notifications + */ + private suspend fun getInitialPage(params: LoadParams): Response> = coroutineScope { + // If the key is null this is straightforward, just return the most recent notifications. + val key = params.key + ?: return@coroutineScope mastodonApi.notifications( + limit = params.loadSize, + excludes = notificationFilter + ) + + // It's important to return *something* from this state. If an empty page is returned + // (even with next/prev links) Pager3 assumes there is no more data to load and stops. + // + // In addition, the Mastodon API does not let you fetch a page that contains a given key. + // You can fetch the page immediately before the key, or the page immediately after, but + // you can not fetch the page itself. + + // First, try and get the notification itself, and the notifications immediately before + // it. This is so that a full page of results can be returned. Returning just the + // single notification means the displayed list can jump around a bit as more data is + // loaded. + // + // Make both requests, and wait for the first to complete. + val deferredNotification = async { mastodonApi.notification(id = key) } + val deferredNotificationPage = async { + mastodonApi.notifications(maxId = key, limit = params.loadSize, excludes = notificationFilter) + } + + val notification = deferredNotification.await() + if (notification.isSuccessful) { + // If this was successful we must still check that the user is not filtering this type + // of notification, as fetching a single notification ignores filters. Returning this + // notification if the user is filtering the type is wrong. + notification.body()?.let { body -> + if (!notificationFilter.contains(body.type)) { + // Notification is *not* filtered. We can return this, but need the next page of + // notifications as well + + // Collect all notifications in to this list + val notifications = mutableListOf(body) + val notificationPage = deferredNotificationPage.await() + if (notificationPage.isSuccessful) { + notificationPage.body()?.let { + notifications.addAll(it) + } + } + + // "notifications" now contains at least one notification we can return, and + // hopefully a full page. + + // Build correct max_id and min_id links for the response. The "min_id" to use + // when fetching the next page is the same as "key". The "max_id" is the ID of + // the oldest notification in the list. + val maxId = notifications.last().id + val headers = Headers.Builder() + .add("link: ; rel=\"next\", ; rel=\"prev\"") + .build() + + return@coroutineScope Response.success(notifications, headers) + } + } + } + + // The user's last read notification was missing or is filtered. Use the page of + // notifications chronologically older than their desired notification. This page must + // *not* be empty (as noted earlier, if it is, paging stops). + deferredNotificationPage.await().let { response -> + if (response.isSuccessful) { + if (!response.body().isNullOrEmpty()) return@coroutineScope response + } + } + + // There were no notifications older than the user's desired notification. Return the page + // of notifications immediately newer than their desired notification. This page must + // *not* be empty (as noted earlier, if it is, paging stops). + mastodonApi.notifications(minId = key, limit = params.loadSize, excludes = notificationFilter).let { response -> + if (response.isSuccessful) { + if (!response.body().isNullOrEmpty()) return@coroutineScope response + } + } + + // Everything failed -- fallback to fetching the most recent notifications + return@coroutineScope mastodonApi.notifications( + limit = params.loadSize, + excludes = notificationFilter + ) + } + + override fun getRefreshKey(state: PagingState): String? { + return state.anchorPosition?.let { anchorPosition -> + val id = state.closestItemToPosition(anchorPosition)?.id + Log.d(TAG, " getRefreshKey returning $id") + return id + } + } + + companion object { + private const val TAG = "NotificationsPagingSource" + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRepository.kt new file mode 100644 index 00000000..4bec1aa3 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRepository.kt @@ -0,0 +1,76 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.components.notifications + +import android.util.Log +import androidx.paging.InvalidatingPagingSourceFactory +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import androidx.paging.PagingSource +import com.google.gson.Gson +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.network.MastodonApi +import kotlinx.coroutines.flow.Flow +import okhttp3.ResponseBody +import retrofit2.Response +import javax.inject.Inject + +class NotificationsRepository @Inject constructor( + private val mastodonApi: MastodonApi, + private val gson: Gson +) { + private var factory: InvalidatingPagingSourceFactory? = null + + /** + * @return flow of Mastodon [Notification], excluding all types in [filter]. + * Notifications are loaded in [pageSize] increments. + */ + fun getNotificationsStream( + filter: Set, + pageSize: Int = PAGE_SIZE, + initialKey: String? = null + ): Flow> { + Log.d(TAG, "getNotificationsStream(), filtering: $filter") + + factory = InvalidatingPagingSourceFactory { + NotificationsPagingSource(mastodonApi, gson, filter) + } + + return Pager( + config = PagingConfig(pageSize = pageSize, initialLoadSize = pageSize), + initialKey = initialKey, + pagingSourceFactory = factory!! + ).flow + } + + /** Invalidate the active paging source, see [PagingSource.invalidate] */ + fun invalidate() { + factory?.invalidate() + } + + /** Clear notifications */ + suspend fun clearNotifications(): Response { + return mastodonApi.clearNotifications() + } + + companion object { + private const val TAG = "NotificationsRepository" + private const val PAGE_SIZE = 30 + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt new file mode 100644 index 00000000..d1d41a94 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt @@ -0,0 +1,557 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.components.notifications + +import android.content.SharedPreferences +import android.util.Log +import androidx.annotation.StringRes +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn +import androidx.paging.map +import at.connyduck.calladapter.networkresult.getOrThrow +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.appstore.BlockEvent +import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.appstore.MuteConversationEvent +import com.keylesspalace.tusky.appstore.MuteEvent +import com.keylesspalace.tusky.appstore.PreferenceChangedEvent +import com.keylesspalace.tusky.components.timeline.util.ifExpected +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.entity.Poll +import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.usecase.TimelineCases +import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.util.deserialize +import com.keylesspalace.tusky.util.serialize +import com.keylesspalace.tusky.util.throttleFirst +import com.keylesspalace.tusky.util.toViewData +import com.keylesspalace.tusky.viewdata.NotificationViewData +import com.keylesspalace.tusky.viewdata.StatusViewData +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.getAndUpdate +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.rx3.await +import retrofit2.HttpException +import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.ExperimentalTime + +data class UiState( + /** Filtered notification types */ + val activeFilter: Set = emptySet(), + + /** True if the UI to filter and clear notifications should be shown */ + val showFilterOptions: Boolean = false, + + /** True if the FAB should be shown while scrolling */ + val showFabWhileScrolling: Boolean = true +) + +/** Preferences the UI reacts to */ +data class UiPrefs( + val showFabWhileScrolling: Boolean, + val showFilter: Boolean +) { + companion object { + /** Relevant preference keys. Changes to any of these trigger a display update */ + val prefKeys = setOf( + PrefKeys.FAB_HIDE, + PrefKeys.SHOW_NOTIFICATIONS_FILTER + ) + } +} + +/** Parent class for all UI actions, fallible or infallible. */ +sealed class UiAction + +/** Actions the user can trigger from the UI. These actions may fail. */ +sealed class FallibleUiAction : UiAction() { + /** Clear all notifications */ + object ClearNotifications : FallibleUiAction() +} + +/** + * Actions the user can trigger from the UI that either cannot fail, or if they do fail, + * do not show an error. + */ +sealed class InfallibleUiAction : UiAction() { + /** Apply a new filter to the notification list */ + // This saves the list to the local database, which triggers a refresh of the data. + // Saving the data can't fail, which is why this is infallible. Refreshing the + // data may fail, but that's handled by the paging system / adapter refresh logic. + data class ApplyFilter(val filter: Set) : InfallibleUiAction() + + /** + * User is leaving the fragment, save the ID of the visible notification. + * + * Infallible because if it fails there's nowhere to show the error, and nothing the user + * can do. + */ + data class SaveVisibleId(val visibleId: String) : InfallibleUiAction() + + /** Ignore the saved reading position, load the page with the newest items */ + // Resets the account's `lastNotificationId`, which can't fail, which is why this is + // infallible. Reloading the data may fail, but that's handled by the paging system / + // adapter refresh logic. + object LoadNewest : InfallibleUiAction() +} + +/** Actions the user can trigger on an individual notification. These may fail. */ +sealed class NotificationAction : FallibleUiAction() { + data class AcceptFollowRequest(val accountId: String) : NotificationAction() + + data class RejectFollowRequest(val accountId: String) : NotificationAction() +} + +sealed class UiSuccess { + // These three are from menu items on the status. Currently they don't come to the + // viewModel as actions, they're noticed when events are posted. That will change, + // but for the moment we can still report them to the UI. Typically, receiving any + // of these three should trigger the UI to refresh. + + /** A user was blocked */ + object Block : UiSuccess() + + /** A user was muted */ + object Mute : UiSuccess() + + /** A conversation was muted */ + object MuteConversation : UiSuccess() +} + +/** The result of a successful action on a notification */ +sealed class NotificationActionSuccess( + /** String resource with an error message to show the user */ + @StringRes val msg: Int, + + /** + * The original action, in case additional information is required from it to display the + * message. + */ + open val action: NotificationAction +) : UiSuccess() { + data class AcceptFollowRequest(override val action: NotificationAction) : + NotificationActionSuccess(R.string.ui_success_accepted_follow_request, action) + data class RejectFollowRequest(override val action: NotificationAction) : + NotificationActionSuccess(R.string.ui_success_rejected_follow_request, action) + + companion object { + fun from(action: NotificationAction) = when (action) { + is NotificationAction.AcceptFollowRequest -> AcceptFollowRequest(action) + is NotificationAction.RejectFollowRequest -> RejectFollowRequest(action) + } + } +} + +/** Actions the user can trigger on an individual status */ +sealed class StatusAction( + open val statusViewData: StatusViewData.Concrete +) : FallibleUiAction() { + /** Set the bookmark state for a status */ + data class Bookmark(val state: Boolean, override val statusViewData: StatusViewData.Concrete) : + StatusAction(statusViewData) + + /** Set the favourite state for a status */ + data class Favourite(val state: Boolean, override val statusViewData: StatusViewData.Concrete) : + StatusAction(statusViewData) + + /** Set the reblog state for a status */ + data class Reblog(val state: Boolean, override val statusViewData: StatusViewData.Concrete) : + StatusAction(statusViewData) + + /** Vote in a poll */ + data class VoteInPoll( + val poll: Poll, + val choices: List, + override val statusViewData: StatusViewData.Concrete + ) : StatusAction(statusViewData) +} + +/** Changes to a status' visible state after API calls */ +sealed class StatusActionSuccess(open val action: StatusAction) : UiSuccess() { + data class Bookmark(override val action: StatusAction.Bookmark) : + StatusActionSuccess(action) + + data class Favourite(override val action: StatusAction.Favourite) : + StatusActionSuccess(action) + + data class Reblog(override val action: StatusAction.Reblog) : + StatusActionSuccess(action) + + data class VoteInPoll(override val action: StatusAction.VoteInPoll) : + StatusActionSuccess(action) + + companion object { + fun from(action: StatusAction) = when (action) { + is StatusAction.Bookmark -> Bookmark(action) + is StatusAction.Favourite -> Favourite(action) + is StatusAction.Reblog -> Reblog(action) + is StatusAction.VoteInPoll -> VoteInPoll(action) + } + } +} + +/** Errors from fallible view model actions that the UI will need to show */ +sealed class UiError( + /** The exception associated with the error */ + open val throwable: Throwable, + + /** String resource with an error message to show the user */ + @StringRes val message: Int, + + /** The action that failed. Can be resent to retry the action */ + open val action: UiAction? = null +) { + data class ClearNotifications(override val throwable: Throwable) : UiError( + throwable, + R.string.ui_error_clear_notifications + ) + + data class Bookmark( + override val throwable: Throwable, + override val action: StatusAction.Bookmark + ) : UiError(throwable, R.string.ui_error_bookmark, action) + + data class Favourite( + override val throwable: Throwable, + override val action: StatusAction.Favourite + ) : UiError(throwable, R.string.ui_error_favourite, action) + + data class Reblog( + override val throwable: Throwable, + override val action: StatusAction.Reblog + ) : UiError(throwable, R.string.ui_error_reblog, action) + + data class VoteInPoll( + override val throwable: Throwable, + override val action: StatusAction.VoteInPoll + ) : UiError(throwable, R.string.ui_error_vote, action) + + data class AcceptFollowRequest( + override val throwable: Throwable, + override val action: NotificationAction.AcceptFollowRequest + ) : UiError(throwable, R.string.ui_error_accept_follow_request, action) + + data class RejectFollowRequest( + override val throwable: Throwable, + override val action: NotificationAction.RejectFollowRequest + ) : UiError(throwable, R.string.ui_error_reject_follow_request, action) + + companion object { + fun make(throwable: Throwable, action: FallibleUiAction) = when (action) { + is StatusAction.Bookmark -> Bookmark(throwable, action) + is StatusAction.Favourite -> Favourite(throwable, action) + is StatusAction.Reblog -> Reblog(throwable, action) + is StatusAction.VoteInPoll -> VoteInPoll(throwable, action) + is NotificationAction.AcceptFollowRequest -> AcceptFollowRequest(throwable, action) + is NotificationAction.RejectFollowRequest -> RejectFollowRequest(throwable, action) + FallibleUiAction.ClearNotifications -> ClearNotifications(throwable) + } + } +} + +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class, ExperimentalTime::class) +class NotificationsViewModel @Inject constructor( + private val repository: NotificationsRepository, + private val preferences: SharedPreferences, + private val accountManager: AccountManager, + private val timelineCases: TimelineCases, + private val eventHub: EventHub +) : ViewModel() { + /** The account to display notifications for */ + val account = accountManager.activeAccount!! + + val uiState: StateFlow + + /** Flow of changes to statusDisplayOptions, for use by the UI */ + val statusDisplayOptions: StateFlow + + val pagingData: Flow> + + /** Flow of user actions received from the UI */ + private val uiAction = MutableSharedFlow() + + /** Flow that can be used to trigger a full reload */ + private val reload = MutableStateFlow(0) + + /** Flow of successful action results */ + // Note: This is a SharedFlow instead of a StateFlow because success state does not need to be + // retained. A message is shown once to a user and then dismissed. Re-collecting the flow + // (e.g., after a device orientation change) should not re-show the most recent success + // message, as it will be confusing to the user. + val uiSuccess = MutableSharedFlow() + + /** Channel for error results */ + // Errors are sent to a channel to ensure that any errors that occur *before* there are any + // subscribers are retained. If this was a SharedFlow any errors would be dropped, and if it + // was a StateFlow any errors would be retained, and there would need to be an explicit + // mechanism to dismiss them. + private val _uiErrorChannel = Channel() + + /** Expose UI errors as a flow */ + val uiError = _uiErrorChannel.receiveAsFlow() + + /** Accept UI actions in to actionStateFlow */ + val accept: (UiAction) -> Unit = { action -> + viewModelScope.launch { uiAction.emit(action) } + } + + init { + // Handle changes to notification filters + val notificationFilter = uiAction + .filterIsInstance() + .distinctUntilChanged() + // Save each change back to the active account + .onEach { action -> + Log.d(TAG, "notificationFilter: $action") + account.notificationsFilter = serialize(action.filter) + accountManager.saveAccount(account) + } + // Load the initial filter from the active account + .onStart { + emit( + InfallibleUiAction.ApplyFilter( + filter = deserialize(account.notificationsFilter) + ) + ) + } + + // Reset the last notification ID to "0" to fetch the newest notifications, and + // increment `reload` to trigger creation of a new PagingSource. + viewModelScope.launch { + uiAction + .filterIsInstance() + .collectLatest { + account.lastNotificationId = "0" + accountManager.saveAccount(account) + reload.getAndUpdate { it + 1 } + } + } + + // Save the visible notification ID + viewModelScope.launch { + uiAction + .filterIsInstance() + .distinctUntilChanged() + .collectLatest { action -> + Log.d(TAG, "Saving visible ID: ${action.visibleId}, active account = ${account.id}") + account.lastNotificationId = action.visibleId + accountManager.saveAccount(account) + } + } + + // Set initial status display options from the user's preferences. + // + // Then collect future preference changes and emit new values in to + // statusDisplayOptions if necessary. + statusDisplayOptions = MutableStateFlow( + StatusDisplayOptions.from( + preferences, + account + ) + ) + + viewModelScope.launch { + eventHub.events + .filterIsInstance() + .filter { StatusDisplayOptions.prefKeys.contains(it.preferenceKey) } + .map { + statusDisplayOptions.value.make( + preferences, + it.preferenceKey, + account + ) + } + .collect { + statusDisplayOptions.emit(it) + } + } + + // Handle UiAction.ClearNotifications + viewModelScope.launch { + uiAction.filterIsInstance() + .collectLatest { + try { + repository.clearNotifications().apply { + if (this.isSuccessful) { + repository.invalidate() + } else { + _uiErrorChannel.send(UiError.make(HttpException(this), it)) + } + } + } catch (e: Exception) { + ifExpected(e) { _uiErrorChannel.send(UiError.make(e, it)) } + } + } + } + + // Handle NotificationAction.* + viewModelScope.launch { + uiAction.filterIsInstance() + .throttleFirst(THROTTLE_TIMEOUT) + .collect { action -> + try { + when (action) { + is NotificationAction.AcceptFollowRequest -> + timelineCases.acceptFollowRequest(action.accountId).await() + is NotificationAction.RejectFollowRequest -> + timelineCases.rejectFollowRequest(action.accountId).await() + } + uiSuccess.emit(NotificationActionSuccess.from(action)) + } catch (e: Exception) { + ifExpected(e) { _uiErrorChannel.send(UiError.make(e, action)) } + } + } + } + + // Handle StatusAction.* + viewModelScope.launch { + uiAction.filterIsInstance() + .throttleFirst(THROTTLE_TIMEOUT) // avoid double-taps + .collect { action -> + try { + when (action) { + is StatusAction.Bookmark -> + timelineCases.bookmark( + action.statusViewData.actionableId, + action.state + ) + is StatusAction.Favourite -> + timelineCases.favourite( + action.statusViewData.actionableId, + action.state + ) + is StatusAction.Reblog -> + timelineCases.reblog( + action.statusViewData.actionableId, + action.state + ) + is StatusAction.VoteInPoll -> + timelineCases.voteInPoll( + action.statusViewData.actionableId, + action.poll.id, + action.choices + ) + }.getOrThrow() + uiSuccess.emit(StatusActionSuccess.from(action)) + } catch (t: Throwable) { + _uiErrorChannel.send(UiError.make(t, action)) + } + } + } + + // Handle events that should refresh the list + viewModelScope.launch { + eventHub.events.collectLatest { + when (it) { + is BlockEvent -> uiSuccess.emit(UiSuccess.Block) + is MuteEvent -> uiSuccess.emit(UiSuccess.Mute) + is MuteConversationEvent -> uiSuccess.emit(UiSuccess.MuteConversation) + } + } + } + + // Re-fetch notifications if either of `notificationFilter` or `reload` flows have + // new items. + pagingData = combine(notificationFilter, reload) { action, _ -> action } + .flatMapLatest { action -> + getNotifications(filters = action.filter, initialKey = getInitialKey()) + }.cachedIn(viewModelScope) + + uiState = combine(notificationFilter, getUiPrefs()) { filter, prefs -> + UiState( + activeFilter = filter.filter, + showFilterOptions = prefs.showFilter, + showFabWhileScrolling = prefs.showFabWhileScrolling + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000), + initialValue = UiState() + ) + } + + private fun getNotifications( + filters: Set, + initialKey: String? = null + ): Flow> { + return repository.getNotificationsStream(filter = filters, initialKey = initialKey) + .map { pagingData -> + pagingData.map { notification -> + notification.toViewData( + isShowingContent = statusDisplayOptions.value.showSensitiveMedia || + !(notification.status?.actionableStatus?.sensitive ?: false), + isExpanded = statusDisplayOptions.value.openSpoiler, + isCollapsed = true + ) + } + } + } + + // The database stores "0" as the last notification ID if notifications have not been + // fetched. Convert to null to ensure a full fetch in this case + private fun getInitialKey(): String? { + val initialKey = when (val id = account.lastNotificationId) { + "0" -> null + else -> id + } + Log.d(TAG, "Restoring at $initialKey") + return initialKey + } + + /** + * @return Flow of relevant preferences that change the UI + */ + // TODO: Preferences should be in a repository + private fun getUiPrefs() = eventHub.events + .filterIsInstance() + .filter { UiPrefs.prefKeys.contains(it.preferenceKey) } + .map { toPrefs() } + .onStart { emit(toPrefs()) } + + private fun toPrefs() = UiPrefs( + showFabWhileScrolling = !preferences.getBoolean(PrefKeys.FAB_HIDE, false), + showFilter = preferences.getBoolean(PrefKeys.SHOW_NOTIFICATIONS_FILTER, true) + ) + + companion object { + private const val TAG = "NotificationsViewModel" + private val THROTTLE_TIMEOUT = 500.milliseconds + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/PushNotificationHelper.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/PushNotificationHelper.kt index cf1dd438..f61307e3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/PushNotificationHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/PushNotificationHelper.kt @@ -14,6 +14,7 @@ * see . */ @file:JvmName("PushNotificationHelper") + package com.keylesspalace.tusky.components.notifications import android.app.NotificationManager @@ -150,8 +151,9 @@ fun disableAllNotifications(context: Context, accountManager: AccountManager) { private fun buildSubscriptionData(context: Context, account: AccountEntity): Map = buildMap { - Notification.Type.asList.forEach { - put("data[alerts][${it.presentation}]", NotificationHelper.filterNotification(account, it, context)) + val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + Notification.Type.visibleTypes.forEach { + put("data[alerts][${it.presentation}]", NotificationHelper.filterNotification(notificationManager, account, it)) } } @@ -163,7 +165,6 @@ suspend fun registerUnifiedPushEndpoint( account: AccountEntity, endpoint: String ) = withContext(Dispatchers.IO) { - // Generate a prime256v1 key pair for WebPush // Decryption is unimplemented for now, since Mastodon uses an old WebPush // standard which does not send needed information for decryption in the payload @@ -173,8 +174,11 @@ suspend fun registerUnifiedPushEndpoint( val auth = CryptoUtil.secureRandomBytesEncoded(16) api.subscribePushNotifications( - "Bearer ${account.accessToken}", account.domain, - endpoint, keyPair.pubkey, auth, + "Bearer ${account.accessToken}", + account.domain, + endpoint, + keyPair.pubkey, + auth, buildSubscriptionData(context, account) ).onFailure { throwable -> Log.w(TAG, "Error setting push endpoint for account ${account.id}", throwable) @@ -195,7 +199,8 @@ suspend fun registerUnifiedPushEndpoint( suspend fun updateUnifiedPushSubscription(context: Context, api: MastodonApi, accountManager: AccountManager, account: AccountEntity) { withContext(Dispatchers.IO) { api.updatePushNotificationSubscription( - "Bearer ${account.accessToken}", account.domain, + "Bearer ${account.accessToken}", + account.domain, buildSubscriptionData(context, account) ).onSuccess { Log.d(TAG, "UnifiedPush subscription updated for account ${account.id}") diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusNotificationViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusNotificationViewHolder.kt new file mode 100644 index 00000000..c433e73c --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusNotificationViewHolder.kt @@ -0,0 +1,387 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.components.notifications + +import android.content.Context +import android.graphics.PorterDuff +import android.graphics.Typeface +import android.graphics.drawable.Drawable +import android.text.InputFilter +import android.text.SpannableStringBuilder +import android.text.Spanned +import android.text.TextUtils +import android.text.format.DateUtils +import android.text.style.StyleSpan +import android.view.View +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.RecyclerView +import at.connyduck.sparkbutton.helpers.Utils +import com.bumptech.glide.Glide +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.adapter.StatusBaseViewHolder +import com.keylesspalace.tusky.databinding.ItemStatusNotificationBinding +import com.keylesspalace.tusky.entity.Emoji +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.interfaces.LinkListener +import com.keylesspalace.tusky.interfaces.StatusActionListener +import com.keylesspalace.tusky.util.AbsoluteTimeFormatter +import com.keylesspalace.tusky.util.SmartLengthInputFilter +import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.util.emojify +import com.keylesspalace.tusky.util.getRelativeTimeSpanString +import com.keylesspalace.tusky.util.loadAvatar +import com.keylesspalace.tusky.util.setClickableText +import com.keylesspalace.tusky.util.unicodeWrap +import com.keylesspalace.tusky.viewdata.NotificationViewData +import com.keylesspalace.tusky.viewdata.StatusViewData +import java.util.Date + +/** + * View holder for a status with an activity to be notified about (posted, boosted, + * favourited, or edited, per [NotificationViewKind.from]). + * + * Shows a line with the activity, and who initiated the activity. Clicking this should + * go to the profile page for the initiator. + * + * Displays the original status below that. Clicking this should go to the original + * status in context. + */ +internal class StatusNotificationViewHolder( + private val binding: ItemStatusNotificationBinding, + private val statusActionListener: StatusActionListener, + private val notificationActionListener: NotificationActionListener, + private val absoluteTimeFormatter: AbsoluteTimeFormatter +) : NotificationsPagingAdapter.ViewHolder, RecyclerView.ViewHolder(binding.root) { + private val avatarRadius48dp = itemView.context.resources.getDimensionPixelSize( + R.dimen.avatar_radius_48dp + ) + private val avatarRadius36dp = itemView.context.resources.getDimensionPixelSize( + R.dimen.avatar_radius_36dp + ) + private val avatarRadius24dp = itemView.context.resources.getDimensionPixelSize( + R.dimen.avatar_radius_24dp + ) + + override fun bind( + viewData: NotificationViewData, + payloads: List<*>?, + statusDisplayOptions: StatusDisplayOptions + ) { + val statusViewData = viewData.statusViewData + if (payloads.isNullOrEmpty()) { + // Hide null statuses. Shouldn't happen according to the spec, but some servers + // have been seen to do this (https://github.com/tuskyapp/Tusky/issues/2252) + if (statusViewData == null) { + showNotificationContent(false) + } else { + showNotificationContent(true) + val (_, _, account, _, _, _, _, createdAt) = statusViewData.actionable + setDisplayName(account.name, account.emojis, statusDisplayOptions.animateEmojis) + setUsername(account.username) + setCreatedAt(createdAt, statusDisplayOptions.useAbsoluteTime) + if (viewData.type == Notification.Type.STATUS || + viewData.type == Notification.Type.UPDATE + ) { + setAvatar( + account.avatar, + account.bot, + statusDisplayOptions.animateAvatars, + statusDisplayOptions.showBotOverlay + ) + } else { + setAvatars( + account.avatar, + viewData.account.avatar, + statusDisplayOptions.animateAvatars + ) + } + + binding.notificationContainer.setOnClickListener { + notificationActionListener.onViewThreadForStatus(statusViewData.status) + } + binding.notificationContent.setOnClickListener { + notificationActionListener.onViewThreadForStatus(statusViewData.status) + } + binding.notificationTopText.setOnClickListener { + notificationActionListener.onViewAccount(viewData.account.id) + } + } + setMessage(viewData, statusActionListener, statusDisplayOptions.animateEmojis) + } else { + for (item in payloads) { + if (StatusBaseViewHolder.Key.KEY_CREATED == item && statusViewData != null) { + setCreatedAt( + statusViewData.status.actionableStatus.createdAt, + statusDisplayOptions.useAbsoluteTime + ) + } + } + } + } + + private fun showNotificationContent(show: Boolean) { + binding.statusDisplayName.visibility = if (show) View.VISIBLE else View.GONE + binding.statusUsername.visibility = if (show) View.VISIBLE else View.GONE + binding.statusMetaInfo.visibility = if (show) View.VISIBLE else View.GONE + binding.notificationContentWarningDescription.visibility = + if (show) View.VISIBLE else View.GONE + binding.notificationContentWarningButton.visibility = + if (show) View.VISIBLE else View.GONE + binding.notificationContent.visibility = if (show) View.VISIBLE else View.GONE + binding.notificationStatusAvatar.visibility = if (show) View.VISIBLE else View.GONE + binding.notificationNotificationAvatar.visibility = if (show) View.VISIBLE else View.GONE + } + + private fun setDisplayName(name: String, emojis: List?, animateEmojis: Boolean) { + val emojifiedName = name.emojify(emojis, binding.statusDisplayName, animateEmojis) + binding.statusDisplayName.text = emojifiedName + } + + private fun setUsername(name: String) { + val context = binding.statusUsername.context + val format = context.getString(R.string.post_username_format) + val usernameText = String.format(format, name) + binding.statusUsername.text = usernameText + } + + private fun setCreatedAt(createdAt: Date?, useAbsoluteTime: Boolean) { + if (useAbsoluteTime) { + binding.statusMetaInfo.text = absoluteTimeFormatter.format(createdAt, true) + } else { + // This is the visible timestampInfo. + val readout: String + /* This one is for screen-readers. Frequently, they would mispronounce timestamps like "17m" + * as 17 meters instead of minutes. */ + val readoutAloud: CharSequence + if (createdAt != null) { + val then = createdAt.time + val now = Date().time + readout = getRelativeTimeSpanString(binding.statusMetaInfo.context, then, now) + readoutAloud = DateUtils.getRelativeTimeSpanString( + then, + now, + DateUtils.SECOND_IN_MILLIS, + DateUtils.FORMAT_ABBREV_RELATIVE + ) + } else { + // unknown minutes~ + readout = "?m" + readoutAloud = "? minutes" + } + binding.statusMetaInfo.text = readout + binding.statusMetaInfo.contentDescription = readoutAloud + } + } + + private fun getIconWithColor( + context: Context, + @DrawableRes drawable: Int, + @ColorRes color: Int + ): Drawable? { + val icon = ContextCompat.getDrawable(context, drawable) + icon?.setColorFilter(context.getColor(color), PorterDuff.Mode.SRC_ATOP) + return icon + } + + private fun setAvatar(statusAvatarUrl: String?, isBot: Boolean, animateAvatars: Boolean, showBotOverlay: Boolean) { + binding.notificationStatusAvatar.setPaddingRelative(0, 0, 0, 0) + loadAvatar( + statusAvatarUrl, + binding.notificationStatusAvatar, + avatarRadius48dp, + animateAvatars + ) + if (showBotOverlay && isBot) { + binding.notificationNotificationAvatar.visibility = View.VISIBLE + Glide.with(binding.notificationNotificationAvatar) + .load(R.drawable.bot_badge) + .into(binding.notificationNotificationAvatar) + } else { + binding.notificationNotificationAvatar.visibility = View.GONE + } + } + + private fun setAvatars(statusAvatarUrl: String?, notificationAvatarUrl: String?, animateAvatars: Boolean) { + val padding = Utils.dpToPx(binding.notificationStatusAvatar.context, 12) + binding.notificationStatusAvatar.setPaddingRelative(0, 0, padding, padding) + loadAvatar( + statusAvatarUrl, + binding.notificationStatusAvatar, + avatarRadius36dp, + animateAvatars + ) + binding.notificationNotificationAvatar.visibility = View.VISIBLE + loadAvatar( + notificationAvatarUrl, + binding.notificationNotificationAvatar, + avatarRadius24dp, + animateAvatars + ) + } + + fun setMessage( + notificationViewData: NotificationViewData, + listener: LinkListener, + animateEmojis: Boolean + ) { + val statusViewData = notificationViewData.statusViewData + val displayName = notificationViewData.account.name.unicodeWrap() + val type = notificationViewData.type + val context = binding.notificationTopText.context + val format: String + val icon: Drawable? + when (type) { + Notification.Type.FAVOURITE -> { + icon = getIconWithColor(context, R.drawable.ic_star_24dp, R.color.tusky_orange) + format = context.getString(R.string.notification_favourite_format) + } + Notification.Type.REBLOG -> { + icon = getIconWithColor(context, R.drawable.ic_repeat_24dp, R.color.chinwag_green) + format = context.getString(R.string.notification_reblog_format) + } + Notification.Type.STATUS -> { + icon = getIconWithColor(context, R.drawable.ic_home_24dp, R.color.chinwag_green) + format = context.getString(R.string.notification_subscription_format) + } + Notification.Type.UPDATE -> { + icon = getIconWithColor(context, R.drawable.ic_edit_24dp, R.color.chinwag_green) + format = context.getString(R.string.notification_update_format) + } + else -> { + icon = getIconWithColor(context, R.drawable.ic_star_24dp, R.color.tusky_orange) + format = context.getString(R.string.notification_favourite_format) + } + } + binding.notificationTopText.setCompoundDrawablesWithIntrinsicBounds( + icon, + null, + null, + null + ) + val wholeMessage = String.format(format, displayName) + val str = SpannableStringBuilder(wholeMessage) + val displayNameIndex = format.indexOf("%s") + str.setSpan( + StyleSpan(Typeface.BOLD), + displayNameIndex, + displayNameIndex + displayName.length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + val emojifiedText = str.emojify( + notificationViewData.account.emojis, + binding.notificationTopText, + animateEmojis + ) + binding.notificationTopText.text = emojifiedText + if (statusViewData != null) { + val hasSpoiler = !TextUtils.isEmpty(statusViewData.status.spoilerText) + binding.notificationContentWarningDescription.visibility = + if (hasSpoiler) View.VISIBLE else View.GONE + binding.notificationContentWarningButton.visibility = + if (hasSpoiler) View.VISIBLE else View.GONE + if (statusViewData.isExpanded) { + binding.notificationContentWarningButton.setText( + R.string.post_content_warning_show_less + ) + } else { + binding.notificationContentWarningButton.setText( + R.string.post_content_warning_show_more + ) + } + binding.notificationContentWarningButton.setOnClickListener { + if (bindingAdapterPosition != RecyclerView.NO_POSITION) { + notificationActionListener.onExpandedChange( + !statusViewData.isExpanded, + bindingAdapterPosition + ) + } + binding.notificationContent.visibility = + if (statusViewData.isExpanded) View.GONE else View.VISIBLE + } + setupContentAndSpoiler(listener, statusViewData, animateEmojis) + } + } + + private fun setupContentAndSpoiler( + listener: LinkListener, + statusViewData: StatusViewData.Concrete, + animateEmojis: Boolean + ) { + val shouldShowContentIfSpoiler = statusViewData.isExpanded + val hasSpoiler = !TextUtils.isEmpty(statusViewData.status.spoilerText) + if (!shouldShowContentIfSpoiler && hasSpoiler) { + binding.notificationContent.visibility = View.GONE + } else { + binding.notificationContent.visibility = View.VISIBLE + } + val content = statusViewData.content + val emojis = statusViewData.actionable.emojis + if (statusViewData.isCollapsible && (statusViewData.isExpanded || !hasSpoiler)) { + binding.buttonToggleNotificationContent.setOnClickListener { + val position = bindingAdapterPosition + if (position != RecyclerView.NO_POSITION) { + notificationActionListener.onNotificationContentCollapsedChange( + !statusViewData.isCollapsed, + position + ) + } + } + binding.buttonToggleNotificationContent.visibility = View.VISIBLE + if (statusViewData.isCollapsed) { + binding.buttonToggleNotificationContent.setText( + R.string.post_content_warning_show_more + ) + binding.notificationContent.filters = COLLAPSE_INPUT_FILTER + } else { + binding.buttonToggleNotificationContent.setText( + R.string.post_content_warning_show_less + ) + binding.notificationContent.filters = NO_INPUT_FILTER + } + } else { + binding.buttonToggleNotificationContent.visibility = View.GONE + binding.notificationContent.filters = NO_INPUT_FILTER + } + val emojifiedText = + content.emojify( + emojis, + binding.notificationContent, + animateEmojis + ) + setClickableText( + binding.notificationContent, + emojifiedText, + statusViewData.actionable.mentions, + statusViewData.actionable.tags, + listener + ) + val emojifiedContentWarning: CharSequence = statusViewData.spoilerText.emojify( + statusViewData.actionable.emojis, + binding.notificationContentWarningDescription, + animateEmojis + ) + binding.notificationContentWarningDescription.text = emojifiedContentWarning + } + + companion object { + private val COLLAPSE_INPUT_FILTER = arrayOf(SmartLengthInputFilter) + private val NO_INPUT_FILTER = arrayOfNulls(0) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusViewHolder.kt new file mode 100644 index 00000000..c719c084 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusViewHolder.kt @@ -0,0 +1,60 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.components.notifications + +import com.keylesspalace.tusky.adapter.StatusViewHolder +import com.keylesspalace.tusky.databinding.ItemStatusBinding +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.interfaces.StatusActionListener +import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.viewdata.NotificationViewData + +internal class StatusViewHolder( + binding: ItemStatusBinding, + private val statusActionListener: StatusActionListener, + private val accountId: String +) : NotificationsPagingAdapter.ViewHolder, StatusViewHolder(binding.root) { + + override fun bind( + viewData: NotificationViewData, + payloads: List<*>?, + statusDisplayOptions: StatusDisplayOptions + ) { + val statusViewData = viewData.statusViewData + if (statusViewData == null) { + // Hide null statuses. Shouldn't happen according to the spec, but some servers + // have been seen to do this (https://github.com/tuskyapp/Tusky/issues/2252) + showStatusContent(false) + } else { + if (payloads.isNullOrEmpty()) { + showStatusContent(true) + } + setupWithStatus( + statusViewData, + statusActionListener, + statusDisplayOptions, + payloads?.firstOrNull() + ) + } + if (viewData.type == Notification.Type.POLL) { + setPollInfo(accountId == viewData.account.id) + } else { + hideStatusInfo() + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt index 3c919327..b0fedb2b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt @@ -24,35 +24,34 @@ import androidx.annotation.DrawableRes import androidx.preference.PreferenceFragmentCompat import com.google.android.material.color.MaterialColors import com.google.android.material.snackbar.Snackbar -import com.keylesspalace.tusky.AccountListActivity import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.BuildConfig -import com.keylesspalace.tusky.FiltersActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.TabPreferenceActivity import com.keylesspalace.tusky.appstore.EventHub -import com.keylesspalace.tusky.appstore.PreferenceChangedEvent +import com.keylesspalace.tusky.components.accountlist.AccountListActivity +import com.keylesspalace.tusky.components.filters.FiltersActivity import com.keylesspalace.tusky.components.followedtags.FollowedTagsActivity import com.keylesspalace.tusky.components.instancemute.InstanceListActivity import com.keylesspalace.tusky.components.login.LoginActivity import com.keylesspalace.tusky.components.notifications.currentAccountNeedsMigration -import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.entity.Account -import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.settings.AccountPreferenceDataStore import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.settings.listPreference import com.keylesspalace.tusky.settings.makePreferenceScreen import com.keylesspalace.tusky.settings.preference import com.keylesspalace.tusky.settings.preferenceCategory import com.keylesspalace.tusky.settings.switchPreference -import com.keylesspalace.tusky.util.getInitialLanguage +import com.keylesspalace.tusky.util.getInitialLanguages import com.keylesspalace.tusky.util.getLocaleList import com.keylesspalace.tusky.util.getTuskyDisplayName import com.keylesspalace.tusky.util.makeIcon +import com.keylesspalace.tusky.util.unsafeLazy import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.utils.colorInt @@ -72,7 +71,10 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { @Inject lateinit var eventHub: EventHub - private val iconSize by lazy { resources.getDimensionPixelSize(R.dimen.preference_icon_size) } + @Inject + lateinit var accountPreferenceDataStore: AccountPreferenceDataStore + + private val iconSize by unsafeLazy { resources.getDimensionPixelSize(R.dimen.preference_icon_size) } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { val context = requireContext() @@ -84,7 +86,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { colorInt = MaterialColors.getColor(context, R.attr.iconColor, Color.BLACK) } setOnPreferenceClickListener { - openNotificationPrefs() + openNotificationSystemPrefs() true } } @@ -176,6 +178,15 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { } } + preference { + setTitle(R.string.pref_title_timeline_filters) + setIcon(R.drawable.ic_filter_24dp) + setOnPreferenceClickListener { + launchFilterActivity() + true + } + } + preferenceCategory(R.string.pref_publishing) { listPreference { setTitle(R.string.pref_default_post_privacy) @@ -183,20 +194,18 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { setEntryValues(R.array.post_privacy_values) key = PrefKeys.DEFAULT_POST_PRIVACY setSummaryProvider { entry } - val visibility = accountManager.activeAccount?.defaultPostPrivacy - ?: Status.Visibility.PUBLIC + val visibility = accountManager.activeAccount?.defaultPostPrivacy ?: Status.Visibility.PUBLIC value = visibility.serverString() setIcon(getIconForVisibility(visibility)) setOnPreferenceChangeListener { _, newValue -> setIcon(getIconForVisibility(Status.Visibility.byString(newValue as String))) syncWithServer(visibility = newValue) - eventHub.dispatch(PreferenceChangedEvent(key)) true } } listPreference { - val locales = getLocaleList(getInitialLanguage(null, accountManager.activeAccount)) + val locales = getLocaleList(getInitialLanguages(null, accountManager.activeAccount)) setTitle(R.string.pref_default_post_language) // Explicitly add "System default" to the start of the list entries = ( @@ -207,13 +216,12 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { entryValues = (listOf("") + locales.map { it.language }).toTypedArray() key = PrefKeys.DEFAULT_POST_LANGUAGE icon = makeIcon(requireContext(), GoogleMaterial.Icon.gmd_translate, iconSize) - value = accountManager.activeAccount?.defaultPostLanguage ?: "" + value = accountManager.activeAccount?.defaultPostLanguage.orEmpty() isPersistent = false // This will be entirely server-driven setSummaryProvider { entry } setOnPreferenceChangeListener { _, newValue -> syncWithServer(language = (newValue as String)) - eventHub.dispatch(PreferenceChangedEvent(key)) true } } @@ -223,102 +231,40 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { setIcon(R.drawable.ic_eye_24dp) key = PrefKeys.DEFAULT_MEDIA_SENSITIVITY isSingleLineTitle = false - val sensitivity = accountManager.activeAccount?.defaultMediaSensitivity - ?: false + val sensitivity = accountManager.activeAccount?.defaultMediaSensitivity ?: false setDefaultValue(sensitivity) setIcon(getIconForSensitivity(sensitivity)) setOnPreferenceChangeListener { _, newValue -> setIcon(getIconForSensitivity(newValue as Boolean)) syncWithServer(sensitive = newValue) - eventHub.dispatch(PreferenceChangedEvent(key)) true } } } preferenceCategory(R.string.pref_title_timelines) { + // TODO having no activeAccount in this fragment does not really make sense, enforce it? + // All other locations here make it optional, however. + switchPreference { key = PrefKeys.MEDIA_PREVIEW_ENABLED setTitle(R.string.pref_title_show_media_preview) isSingleLineTitle = false - isChecked = accountManager.activeAccount?.mediaPreviewEnabled ?: true - setOnPreferenceChangeListener { _, newValue -> - updateAccount { it.mediaPreviewEnabled = newValue as Boolean } - eventHub.dispatch(PreferenceChangedEvent(key)) - true - } + preferenceDataStore = accountPreferenceDataStore } switchPreference { key = PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA setTitle(R.string.pref_title_alway_show_sensitive_media) isSingleLineTitle = false - isChecked = accountManager.activeAccount?.alwaysShowSensitiveMedia ?: false - setOnPreferenceChangeListener { _, newValue -> - updateAccount { it.alwaysShowSensitiveMedia = newValue as Boolean } - eventHub.dispatch(PreferenceChangedEvent(key)) - true - } + preferenceDataStore = accountPreferenceDataStore } switchPreference { key = PrefKeys.ALWAYS_OPEN_SPOILER setTitle(R.string.pref_title_alway_open_spoiler) isSingleLineTitle = false - isChecked = accountManager.activeAccount?.alwaysOpenSpoiler ?: false - setOnPreferenceChangeListener { _, newValue -> - updateAccount { it.alwaysOpenSpoiler = newValue as Boolean } - eventHub.dispatch(PreferenceChangedEvent(key)) - true - } - } - } - - preferenceCategory(R.string.pref_title_timeline_filters) { - preference { - setTitle(R.string.pref_title_public_filter_keywords) - setOnPreferenceClickListener { - launchFilterActivity( - Filter.PUBLIC, - R.string.pref_title_public_filter_keywords - ) - true - } - } - - preference { - setTitle(R.string.title_notifications) - setOnPreferenceClickListener { - launchFilterActivity(Filter.NOTIFICATIONS, R.string.title_notifications) - true - } - } - - preference { - setTitle(R.string.title_home) - setOnPreferenceClickListener { - launchFilterActivity(Filter.HOME, R.string.title_home) - true - } - } - - preference { - setTitle(R.string.pref_title_thread_filter_keywords) - setOnPreferenceClickListener { - launchFilterActivity( - Filter.THREAD, - R.string.pref_title_thread_filter_keywords - ) - true - } - } - - preference { - setTitle(R.string.title_accounts) - setOnPreferenceClickListener { - launchFilterActivity(Filter.ACCOUNT, R.string.title_accounts) - true - } + preferenceDataStore = accountPreferenceDataStore } } } @@ -329,7 +275,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { requireActivity().setTitle(R.string.action_view_account_preferences) } - private fun openNotificationPrefs() { + private fun openNotificationSystemPrefs() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val intent = Intent() intent.action = "android.settings.APP_NOTIFICATION_SETTINGS" @@ -344,25 +290,19 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { } } - private inline fun updateAccount(changer: (AccountEntity) -> Unit) { - accountManager.activeAccount?.let { account -> - changer(account) - accountManager.saveAccount(account) - } - } - private fun syncWithServer(visibility: String? = null, sensitive: Boolean? = null, language: String? = null) { + // TODO these could also be "datastore backed" preferences (a ServerPreferenceDataStore); follow-up of issue #3204 + mastodonApi.accountUpdateSource(visibility, sensitive, language) .enqueue(object : Callback { override fun onResponse(call: Call, response: Response) { val account = response.body() if (response.isSuccessful && account != null) { - accountManager.activeAccount?.let { it.defaultPostPrivacy = account.source?.privacy ?: Status.Visibility.PUBLIC it.defaultMediaSensitivity = account.source?.sensitive ?: false - it.defaultPostLanguage = language ?: "" + it.defaultPostLanguage = language.orEmpty() accountManager.saveAccount(it) } } else { @@ -406,10 +346,8 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { } } - private fun launchFilterActivity(filterContext: String, titleResource: Int) { + private fun launchFilterActivity() { val intent = Intent(context, FiltersActivity::class.java) - intent.putExtra(FiltersActivity.FILTERS_CONTEXT, filterContext) - intent.putExtra(FiltersActivity.FILTERS_TITLE, getString(titleResource)) activity?.startActivity(intent) activity?.overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesActivity.kt index 1fdc7a65..b47df159 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesActivity.kt @@ -23,6 +23,7 @@ import android.util.Log import androidx.activity.OnBackPressedCallback import androidx.fragment.app.Fragment import androidx.fragment.app.commit +import androidx.lifecycle.lifecycleScope import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceManager @@ -38,6 +39,7 @@ import com.keylesspalace.tusky.util.getNonNullString import com.keylesspalace.tusky.util.setAppNightMode import dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector +import kotlinx.coroutines.launch import javax.inject.Inject class PreferencesActivity : @@ -93,7 +95,9 @@ class PreferencesActivity : } onBackPressedDispatcher.addCallback(this, restartActivitiesOnBackPressedCallback) - restartActivitiesOnBackPressedCallback.isEnabled = savedInstanceState?.getBoolean(EXTRA_RESTART_ON_BACK, false) ?: false + restartActivitiesOnBackPressedCallback.isEnabled = intent.extras?.getBoolean( + EXTRA_RESTART_ON_BACK + ) ?: savedInstanceState?.getBoolean(EXTRA_RESTART_ON_BACK, false) ?: false } override fun onPreferenceStartFragment( @@ -149,14 +153,19 @@ class PreferencesActivity : restartActivitiesOnBackPressedCallback.isEnabled = true this.restartCurrentActivity() } + PrefKeys.UI_TEXT_SCALE_RATIO -> { + restartActivitiesOnBackPressedCallback.isEnabled = true + this.restartCurrentActivity() + } "statusTextSize", "absoluteTimeView", "showBotOverlay", "animateGifAvatars", "useBlurhash", "showSelfUsername", "showCardsInTimelines", "confirmReblogs", "confirmFavourites", - "enableSwipeForTabs", "mainNavPosition", PrefKeys.HIDE_TOP_TOOLBAR -> { + "enableSwipeForTabs", "mainNavPosition", PrefKeys.HIDE_TOP_TOOLBAR, PrefKeys.SHOW_STATS_INLINE -> { restartActivitiesOnBackPressedCallback.isEnabled = true } } - - eventHub.dispatch(PreferenceChangedEvent(key)) + lifecycleScope.launch { + eventHub.dispatch(PreferenceChangedEvent(key)) + } } private fun restartCurrentActivity() { @@ -172,7 +181,8 @@ class PreferencesActivity : override fun androidInjector() = androidInjector companion object { - + @Suppress("unused") + private const val TAG = "PreferencesActivity" const val GENERAL_PREFERENCES = 0 const val ACCOUNT_PREFERENCES = 1 const val NOTIFICATION_PREFERENCES = 2 diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt index a2a92d64..b07de091 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt @@ -29,11 +29,13 @@ import com.keylesspalace.tusky.settings.listPreference import com.keylesspalace.tusky.settings.makePreferenceScreen import com.keylesspalace.tusky.settings.preference import com.keylesspalace.tusky.settings.preferenceCategory +import com.keylesspalace.tusky.settings.sliderPreference import com.keylesspalace.tusky.settings.switchPreference import com.keylesspalace.tusky.util.LocaleManager import com.keylesspalace.tusky.util.deserialize import com.keylesspalace.tusky.util.makeIcon import com.keylesspalace.tusky.util.serialize +import com.keylesspalace.tusky.util.unsafeLazy import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import de.c1710.filemojicompat_ui.views.picker.preference.EmojiPickerPreference @@ -47,7 +49,7 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable { @Inject lateinit var localeManager: LocaleManager - private val iconSize by lazy { resources.getDimensionPixelSize(R.dimen.preference_icon_size) } + private val iconSize by unsafeLazy { resources.getDimensionPixelSize(R.dimen.preference_icon_size) } enum class ReadingOrder { /** User scrolls up, reading statuses oldest to newest */ @@ -98,6 +100,19 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable { preferenceDataStore = localeManager } + sliderPreference { + key = PrefKeys.UI_TEXT_SCALE_RATIO + setDefaultValue(100F) + valueTo = 150F + valueFrom = 50F + stepSize = 5F + setTitle(R.string.pref_ui_text_size) + format = "%.0f%%" + decrementIcon = makeIcon(GoogleMaterial.Icon.gmd_zoom_out) + incrementIcon = makeIcon(GoogleMaterial.Icon.gmd_zoom_in) + icon = makeIcon(GoogleMaterial.Icon.gmd_format_size) + } + listPreference { setDefaultValue("medium") setEntries(R.array.post_text_size_names) @@ -220,6 +235,13 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable { setTitle(R.string.pref_title_enable_swipe_for_tabs) isSingleLineTitle = false } + + switchPreference { + setDefaultValue(false) + key = PrefKeys.SHOW_STATS_INLINE + setTitle(R.string.pref_title_show_stat_inline) + isSingleLineTitle = false + } } preferenceCategory(R.string.pref_title_browser_settings) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/ProxyPreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/ProxyPreferencesFragment.kt index 22318440..da63db12 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/ProxyPreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/ProxyPreferencesFragment.kt @@ -55,8 +55,8 @@ class ProxyPreferencesFragment : PreferenceFragmentCompat() { val portErrorMessage = getString( R.string.pref_title_http_proxy_port_message, - ProxyConfiguration.MIN_PROXY_PORT, - ProxyConfiguration.MAX_PROXY_PORT + MIN_PROXY_PORT, + MAX_PROXY_PORT ) validatedEditTextPreference(portErrorMessage, ProxyConfiguration::isValidProxyPort) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/ReportActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/ReportActivity.kt index 82526706..6f1ee6dc 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/ReportActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/ReportActivity.kt @@ -71,6 +71,11 @@ class ReportActivity : BottomSheetActivity(), HasAndroidInjector { private fun initViewPager() { binding.wizard.isUserInputEnabled = false + + // Odd workaround for text field losing focus on first focus + // (unfixed old bug: https://github.com/material-components/material-components-android/issues/500) + binding.wizard.offscreenPageLimit = 1 + binding.wizard.adapter = ReportPagerAdapter(this) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/ReportViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/ReportViewModel.kt index fe9215a4..f30726a2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/ReportViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/ReportViewModel.kt @@ -17,11 +17,13 @@ package com.keylesspalace.tusky.components.report import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.cachedIn import androidx.paging.map +import at.connyduck.calladapter.networkresult.fold import com.keylesspalace.tusky.appstore.BlockEvent import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.MuteEvent @@ -33,11 +35,8 @@ import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.Error import com.keylesspalace.tusky.util.Loading import com.keylesspalace.tusky.util.Resource -import com.keylesspalace.tusky.util.RxAwareViewModel import com.keylesspalace.tusky.util.Success import com.keylesspalace.tusky.util.toViewData -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.schedulers.Schedulers import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.flatMapLatest @@ -48,7 +47,7 @@ import javax.inject.Inject class ReportViewModel @Inject constructor( private val mastodonApi: MastodonApi, private val eventHub: EventHub -) : RxAwareViewModel() { +) : ViewModel() { private val navigationMutable = MutableLiveData() val navigation: LiveData = navigationMutable @@ -128,10 +127,8 @@ class ReportViewModel @Inject constructor( val ids = listOf(accountId) muteStateMutable.value = Loading() blockStateMutable.value = Loading() - mastodonApi.relationships(ids) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( + viewModelScope.launch { + mastodonApi.relationships(ids).fold( { data -> updateRelationship(data.getOrNull(0)) }, @@ -139,7 +136,7 @@ class ReportViewModel @Inject constructor( updateRelationship(null) } ) - .autoDispose() + } } private fun updateRelationship(relationship: Relationship?) { @@ -155,21 +152,22 @@ class ReportViewModel @Inject constructor( fun toggleMute() { val alreadyMuted = muteStateMutable.value?.data == true viewModelScope.launch { - try { - val relationship = if (alreadyMuted) { - mastodonApi.unmuteAccount(accountId) - } else { - mastodonApi.muteAccount(accountId) + if (alreadyMuted) { + mastodonApi.unmuteAccount(accountId) + } else { + mastodonApi.muteAccount(accountId) + }.fold( + { relationship -> + val muting = relationship.muting + muteStateMutable.value = Success(muting) + if (muting) { + eventHub.dispatch(MuteEvent(accountId)) + } + }, + { t -> + muteStateMutable.value = Error(false, t.message) } - - val muting = relationship.muting - muteStateMutable.value = Success(muting) - if (muting) { - eventHub.dispatch(MuteEvent(accountId)) - } - } catch (t: Throwable) { - muteStateMutable.value = Error(false, t.message) - } + ) } muteStateMutable.value = Loading() @@ -178,39 +176,33 @@ class ReportViewModel @Inject constructor( fun toggleBlock() { val alreadyBlocked = blockStateMutable.value?.data == true viewModelScope.launch { - try { - val relationship = if (alreadyBlocked) { - mastodonApi.unblockAccount(accountId) - } else { - mastodonApi.blockAccount(accountId) - } - + if (alreadyBlocked) { + mastodonApi.unblockAccount(accountId) + } else { + mastodonApi.blockAccount(accountId) + }.fold({ relationship -> val blocking = relationship.blocking blockStateMutable.value = Success(blocking) if (blocking) { eventHub.dispatch(BlockEvent(accountId)) } - } catch (t: Throwable) { + }, { t -> blockStateMutable.value = Error(false, t.message) - } + }) } blockStateMutable.value = Loading() } fun doReport() { reportingStateMutable.value = Loading() - mastodonApi.reportObservable(accountId, selectedIds.toList(), reportNote, if (isRemoteAccount) isRemoteNotify else null) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - { + viewModelScope.launch { + mastodonApi.report(accountId, selectedIds.toList(), reportNote, if (isRemoteAccount) isRemoteNotify else null) + .fold({ reportingStateMutable.value = Success(true) - }, - { error -> + }, { error -> reportingStateMutable.value = Error(cause = error) - } - ) - .autoDispose() + }) + } } fun checkClickedUrl(url: String?) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt index 4212046a..0ee7ec5d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt @@ -85,8 +85,11 @@ class StatusViewHolder( val sensitive = viewData.status.sensitive statusViewHelper.setMediasPreview( - statusDisplayOptions, viewData.status.attachments, - sensitive, previewListener, viewState.isMediaShow(viewData.id, viewData.status.sensitive), + statusDisplayOptions, + viewData.status.attachments, + sensitive, + previewListener, + viewState.isMediaShow(viewData.id, viewData.status.sensitive), mediaViewHeight ) @@ -97,8 +100,10 @@ class StatusViewHolder( private fun updateTextView() { viewdata()?.let { viewdata -> setupCollapsedState( - shouldTrimStatus(viewdata.content), viewState.isCollapsed(viewdata.id, true), - viewState.isContentShow(viewdata.id, viewdata.status.sensitive), viewdata.spoilerText + shouldTrimStatus(viewdata.content), + viewState.isCollapsed(viewdata.id, true), + viewState.isContentShow(viewdata.id, viewdata.status.sensitive), + viewdata.spoilerText ) if (viewdata.spoilerText.isBlank()) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesAdapter.kt index 314513eb..7e2c2417 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesAdapter.kt @@ -38,7 +38,10 @@ class StatusesAdapter( override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StatusViewHolder { val binding = ItemReportStatusBinding.inflate(LayoutInflater.from(parent.context), parent, false) return StatusViewHolder( - binding, statusDisplayOptions, statusViewState, adapterHandler, + binding, + statusDisplayOptions, + statusViewState, + adapterHandler, statusForPosition ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportNoteFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportNoteFragment.kt index 56f812a0..d77685e6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportNoteFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportNoteFragment.kt @@ -54,7 +54,7 @@ class ReportNoteFragment : Fragment(R.layout.fragment_report_note), Injectable { private fun handleChanges() { binding.editNote.doAfterTextChanged { - viewModel.reportNote = it?.toString() ?: "" + viewModel.reportNote = it?.toString().orEmpty() } binding.checkIsNotifyRemote.setOnCheckedChangeListener { _, isChecked -> viewModel.isRemoteNotify = isChecked @@ -72,8 +72,9 @@ class ReportNoteFragment : Fragment(R.layout.fragment_report_note), Injectable { binding.reportDescriptionRemoteInstance.hide() } - if (viewModel.isRemoteAccount) + if (viewModel.isRemoteAccount) { binding.checkIsNotifyRemote.text = getString(R.string.report_remote_instance, viewModel.remoteServer) + } binding.checkIsNotifyRemote.isChecked = viewModel.isRemoteNotify } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt index fbad8431..a1cd5fdf 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt @@ -16,17 +16,24 @@ package com.keylesspalace.tusky.components.report.fragments import android.os.Bundle +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem import android.view.View import androidx.core.app.ActivityOptionsCompat +import androidx.core.view.MenuProvider import androidx.core.view.ViewCompat import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.paging.LoadState import androidx.preference.PreferenceManager import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.SimpleItemAnimator +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener +import com.google.android.material.color.MaterialColors import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.R import com.keylesspalace.tusky.StatusListActivity @@ -48,11 +55,20 @@ import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible 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.launch import javax.inject.Inject -class ReportStatusesFragment : Fragment(R.layout.fragment_report_statuses), Injectable, AdapterHandler { +class ReportStatusesFragment : + Fragment(R.layout.fragment_report_statuses), + Injectable, + OnRefreshListener, + MenuProvider, + AdapterHandler { @Inject lateinit var viewModelFactory: ViewModelFactory @@ -90,18 +106,42 @@ class ReportStatusesFragment : Fragment(R.layout.fragment_report_statuses), Inje } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) handleClicks() initStatusesView() setupSwipeRefreshLayout() } + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.fragment_report_statuses, menu) + menu.findItem(R.id.action_refresh)?.apply { + icon = IconicsDrawable(requireContext(), GoogleMaterial.Icon.gmd_refresh).apply { + sizeDp = 20 + colorInt = MaterialColors.getColor(binding.root, android.R.attr.textColorPrimary) + } + } + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + return when (menuItem.itemId) { + R.id.action_refresh -> { + binding.swipeRefreshLayout.isRefreshing = true + onRefresh() + true + } + else -> false + } + } + + override fun onRefresh() { + snackbarErrorRetry?.dismiss() + adapter.refresh() + } + private fun setupSwipeRefreshLayout() { binding.swipeRefreshLayout.setColorSchemeResources(R.color.chinwag_green) - binding.swipeRefreshLayout.setOnRefreshListener { - snackbarErrorRetry?.dismiss() - adapter.refresh() - } + binding.swipeRefreshLayout.setOnRefreshListener(this) } private fun initStatusesView() { @@ -116,7 +156,10 @@ class ReportStatusesFragment : Fragment(R.layout.fragment_report_statuses), Inje confirmReblogs = preferences.getBoolean("confirmReblogs", true), confirmFavourites = preferences.getBoolean("confirmFavourites", 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), + showStatsInline = preferences.getBoolean(PrefKeys.SHOW_STATS_INLINE, false), + showSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia, + openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler ) adapter = StatusesAdapter(statusDisplayOptions, viewModel.statusViewState, this) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusActivity.kt index c6a64a76..f5df8051 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusActivity.kt @@ -18,13 +18,17 @@ package com.keylesspalace.tusky.components.scheduled import android.content.Context import android.content.Intent import android.os.Bundle +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem import androidx.activity.viewModels import androidx.appcompat.app.AlertDialog +import androidx.core.view.MenuProvider import androidx.lifecycle.lifecycleScope import androidx.paging.LoadState import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager -import autodispose2.androidx.lifecycle.autoDispose +import com.google.android.material.color.MaterialColors import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.appstore.EventHub @@ -36,12 +40,20 @@ import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.entity.ScheduledStatus import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.show -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import com.keylesspalace.tusky.util.viewBinding +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.launch import javax.inject.Inject -class ScheduledStatusActivity : BaseActivity(), ScheduledStatusActionListener, Injectable { +class ScheduledStatusActivity : + BaseActivity(), + ScheduledStatusActionListener, + MenuProvider, + Injectable { @Inject lateinit var viewModelFactory: ViewModelFactory @@ -51,13 +63,15 @@ class ScheduledStatusActivity : BaseActivity(), ScheduledStatusActionListener, I private val viewModel: ScheduledStatusViewModel by viewModels { viewModelFactory } + private val binding by viewBinding(ActivityScheduledStatusBinding::inflate) + private val adapter = ScheduledStatusAdapter(this) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val binding = ActivityScheduledStatusBinding.inflate(layoutInflater) setContentView(binding.root) + addMenuProvider(this) setSupportActionBar(binding.includedToolbar.toolbar) supportActionBar?.run { @@ -84,10 +98,10 @@ class ScheduledStatusActivity : BaseActivity(), ScheduledStatusActionListener, I adapter.addLoadStateListener { loadState -> if (loadState.refresh is LoadState.Error) { binding.progressBar.hide() - binding.errorMessageView.setup(R.drawable.elephant_error, R.string.error_generic) { - refreshStatuses() - } binding.errorMessageView.show() + + val errorState = loadState.refresh as LoadState.Error + binding.errorMessageView.setup(errorState.error) { refreshStatuses() } } if (loadState.refresh != LoadState.Loading) { binding.swipeRefreshLayout.isRefreshing = false @@ -103,14 +117,34 @@ class ScheduledStatusActivity : BaseActivity(), ScheduledStatusActionListener, I } } - eventHub.events - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(this) - .subscribe { event -> + lifecycleScope.launch { + eventHub.events.collect { event -> if (event is StatusScheduledEvent) { adapter.refresh() } } + } + } + + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.activity_announcements, menu) + menu.findItem(R.id.action_search)?.apply { + icon = IconicsDrawable(this@ScheduledStatusActivity, 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 + refreshStatuses() + true + } + else -> false + } } private fun refreshStatuses() { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchActivity.kt index 209548e8..176fb807 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchActivity.kt @@ -20,8 +20,11 @@ import android.content.Context import android.content.Intent import android.os.Bundle import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem import androidx.activity.viewModels import androidx.appcompat.widget.SearchView +import androidx.core.view.MenuProvider import androidx.preference.PreferenceManager import com.google.android.material.tabs.TabLayoutMediator import com.keylesspalace.tusky.BottomSheetActivity @@ -31,12 +34,13 @@ import com.keylesspalace.tusky.databinding.ActivitySearchBinding import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.util.reduceSwipeSensitivity +import com.keylesspalace.tusky.util.unsafeLazy import com.keylesspalace.tusky.util.viewBinding import dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector import javax.inject.Inject -class SearchActivity : BottomSheetActivity(), HasAndroidInjector { +class SearchActivity : BottomSheetActivity(), HasAndroidInjector, MenuProvider { @Inject lateinit var androidInjector: DispatchingAndroidInjector @@ -47,7 +51,7 @@ class SearchActivity : BottomSheetActivity(), HasAndroidInjector { private val binding by viewBinding(ActivitySearchBinding::inflate) - private val preferences by lazy { PreferenceManager.getDefaultSharedPreferences(this) } + private val preferences by unsafeLazy { PreferenceManager.getDefaultSharedPreferences(this) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -58,6 +62,7 @@ class SearchActivity : BottomSheetActivity(), HasAndroidInjector { setDisplayShowHomeEnabled(true) setDisplayShowTitleEnabled(false) } + addMenuProvider(this) setupPages() handleIntent(intent) } @@ -70,7 +75,7 @@ class SearchActivity : BottomSheetActivity(), HasAndroidInjector { binding.pages.isUserInputEnabled = enableSwipeForTabs TabLayoutMediator(binding.tabs, binding.pages) { - tab, position -> + tab, position -> tab.text = getPageTitle(position) }.attach() } @@ -80,17 +85,18 @@ class SearchActivity : BottomSheetActivity(), HasAndroidInjector { handleIntent(intent) } - override fun onCreateOptionsMenu(menu: Menu): Boolean { - super.onCreateOptionsMenu(menu) - + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.search_toolbar, menu) - val searchView = menu.findItem(R.id.action_search) - .actionView as SearchView + val searchViewMenuItem = menu.findItem(R.id.action_search) + searchViewMenuItem.expandActionView() + val searchView = searchViewMenuItem.actionView as SearchView setupSearchView(searchView) searchView.setQuery(viewModel.currentQuery, false) + } - return true + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + return false } override fun finish() { @@ -108,24 +114,49 @@ class SearchActivity : BottomSheetActivity(), HasAndroidInjector { private fun handleIntent(intent: Intent) { if (Intent.ACTION_SEARCH == intent.action) { - viewModel.currentQuery = intent.getStringExtra(SearchManager.QUERY) ?: "" + viewModel.currentQuery = intent.getStringExtra(SearchManager.QUERY).orEmpty() viewModel.search(viewModel.currentQuery) } } private fun setupSearchView(searchView: SearchView) { searchView.setIconifiedByDefault(false) - searchView.setSearchableInfo((getSystemService(Context.SEARCH_SERVICE) as? SearchManager)?.getSearchableInfo(componentName)) - searchView.requestFocus() + // SearchView has a bug. If it's displayed 'app:showAsAction="always"' it's too wide, + // pushing other icons (including the options menu '...' icon) off the edge of the + // screen. + // + // E.g., see: + // + // - https://stackoverflow.com/questions/41662373/android-toolbar-searchview-too-wide-to-move-other-items + // - https://stackoverflow.com/questions/51525088/how-to-control-size-of-a-searchview-in-toolbar + // - https://stackoverflow.com/questions/36976163/push-icons-away-when-expandig-searchview-in-android-toolbar + // - https://issuetracker.google.com/issues/36976484 + // + // The fix is to use 'app:showAsAction="ifRoom|collapseActionView"' and then immediately + // expand it after inflating. That sets the width correctly. + // + // But if you do that code in AppCompatDelegateImpl activates, and when the user presses + // the "Back" button the SearchView is first set to its collapsed state. The user has to + // press "Back" again to exit the activity. This is clearly unacceptable. + // + // It appears to be impossible to override this behaviour on API level < 33. + // + // SearchView does allow you to specify the maximum width. So take the screen width, + // subtract 48dp * 2 (for the menu icon and back icon on either side), convert to pixels, + // and use that. + val pxScreenWidth = resources.displayMetrics.widthPixels + val pxBuffer = ((48 * 2) * resources.displayMetrics.density).toInt() + searchView.maxWidth = pxScreenWidth - pxBuffer - searchView.maxWidth = Integer.MAX_VALUE + searchView.requestFocus() } override fun androidInjector() = androidInjector companion object { + const val TAG = "SearchActivity" fun getIntent(context: Context) = Intent(context, SearchActivity::class.java) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt index 2a8154b9..a43776dd 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt @@ -16,11 +16,14 @@ package com.keylesspalace.tusky.components.search import android.util.Log +import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.cachedIn import at.connyduck.calladapter.networkresult.NetworkResult +import at.connyduck.calladapter.networkresult.fold +import at.connyduck.calladapter.networkresult.onFailure import com.keylesspalace.tusky.components.search.adapter.SearchPagingSourceFactory import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountManager @@ -28,10 +31,8 @@ import com.keylesspalace.tusky.entity.DeletedStatus import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.usecase.TimelineCases -import com.keylesspalace.tusky.util.RxAwareViewModel import com.keylesspalace.tusky.util.toViewData import com.keylesspalace.tusky.viewdata.StatusViewData -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import kotlinx.coroutines.Deferred import kotlinx.coroutines.async import kotlinx.coroutines.launch @@ -41,15 +42,12 @@ class SearchViewModel @Inject constructor( mastodonApi: MastodonApi, private val timelineCases: TimelineCases, private val accountManager: AccountManager -) : RxAwareViewModel() { +) : ViewModel() { var currentQuery: String = "" - var activeAccount: AccountEntity? + val activeAccount: AccountEntity? get() = accountManager.activeAccount - set(value) { - accountManager.activeAccount = value - } val mediaPreviewEnabled = activeAccount?.mediaPreviewEnabled ?: false val alwaysShowSensitiveMedia = activeAccount?.alwaysShowSensitiveMedia ?: false @@ -115,22 +113,18 @@ class SearchViewModel @Inject constructor( } fun reblog(statusViewData: StatusViewData.Concrete, reblog: Boolean) { - timelineCases.reblog(statusViewData.id, reblog) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - { setRebloggedForStatus(statusViewData, reblog) }, - { t -> Log.d(TAG, "Failed to reblog status ${statusViewData.id}", t) } - ) - .autoDispose() - } - - private fun setRebloggedForStatus(statusViewData: StatusViewData.Concrete, reblog: Boolean) { - updateStatus( - statusViewData.status.copy( - reblogged = reblog, - reblog = statusViewData.status.reblog?.copy(reblogged = reblog) - ) - ) + viewModelScope.launch { + timelineCases.reblog(statusViewData.id, reblog).fold({ + updateStatus( + statusViewData.status.copy( + reblogged = reblog, + reblog = statusViewData.status.reblog?.copy(reblogged = reblog) + ) + ) + }, { t -> + Log.d(TAG, "Failed to reblog status ${statusViewData.id}", t) + }) + } } fun contentHiddenChange(statusViewData: StatusViewData.Concrete, isShowing: Boolean) { @@ -144,27 +138,24 @@ class SearchViewModel @Inject constructor( fun voteInPoll(statusViewData: StatusViewData.Concrete, choices: MutableList) { val votedPoll = statusViewData.status.actionableStatus.poll!!.votedCopy(choices) updateStatus(statusViewData.status.copy(poll = votedPoll)) - timelineCases.voteInPoll(statusViewData.id, votedPoll.id, choices) - .observeOn(AndroidSchedulers.mainThread()) - .doOnError { t -> Log.d(TAG, "Failed to vote in poll: ${statusViewData.id}", t) } - .subscribe() - .autoDispose() + viewModelScope.launch { + timelineCases.voteInPoll(statusViewData.id, votedPoll.id, choices) + .onFailure { t -> Log.d(TAG, "Failed to vote in poll: ${statusViewData.id}", t) } + } } fun favorite(statusViewData: StatusViewData.Concrete, isFavorited: Boolean) { updateStatus(statusViewData.status.copy(favourited = isFavorited)) - timelineCases.favourite(statusViewData.id, isFavorited) - .onErrorReturnItem(statusViewData.status) - .subscribe() - .autoDispose() + viewModelScope.launch { + timelineCases.favourite(statusViewData.id, isFavorited) + } } fun bookmark(statusViewData: StatusViewData.Concrete, isBookmarked: Boolean) { updateStatus(statusViewData.status.copy(bookmarked = isBookmarked)) - timelineCases.bookmark(statusViewData.id, isBookmarked) - .onErrorReturnItem(statusViewData.status) - .subscribe() - .autoDispose() + viewModelScope.launch { + timelineCases.bookmark(statusViewData.id, isBookmarked) + } } fun muteAccount(accountId: String, notifications: Boolean, duration: Int?) { @@ -174,7 +165,9 @@ class SearchViewModel @Inject constructor( } fun pinAccount(status: Status, isPin: Boolean) { - timelineCases.pin(status.id, isPin) + viewModelScope.launch { + timelineCases.pin(status.id, isPin) + } } fun blockAccount(accountId: String) { @@ -191,10 +184,9 @@ class SearchViewModel @Inject constructor( fun muteConversation(statusViewData: StatusViewData.Concrete, mute: Boolean) { updateStatus(statusViewData.status.copy(muted = mute)) - timelineCases.muteConversation(statusViewData.id, mute) - .onErrorReturnItem(statusViewData.status) - .subscribe() - .autoDispose() + viewModelScope.launch { + timelineCases.muteConversation(statusViewData.id, mute) + } } private fun updateStatusViewData(newStatusViewData: StatusViewData.Concrete) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchPagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchPagingSource.kt index 5ced4403..10339536 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchPagingSource.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchPagingSource.kt @@ -54,7 +54,6 @@ class SearchPagingSource( val currentKey = params.key ?: 0 try { - val data = mastodonApi.searchObservable( query = searchRequest, type = searchType.apiParameter, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchAccountsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchAccountsFragment.kt index 87be6980..1bcfaaaa 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchAccountsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchAccountsFragment.kt @@ -15,15 +15,28 @@ package com.keylesspalace.tusky.components.search.fragments +import android.os.Bundle +import android.view.View import androidx.paging.PagingData import androidx.paging.PagingDataAdapter import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.DividerItemDecoration import com.keylesspalace.tusky.components.search.adapter.SearchAccountsAdapter import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.settings.PrefKeys import kotlinx.coroutines.flow.Flow class SearchAccountsFragment : SearchFragment() { + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.searchRecyclerView.addItemDecoration( + DividerItemDecoration( + binding.searchRecyclerView.context, + DividerItemDecoration.VERTICAL + ) + ) + } + override fun createAdapter(): PagingDataAdapter { val preferences = PreferenceManager.getDefaultSharedPreferences(binding.searchRecyclerView.context) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchFragment.kt index d640fe58..4744bbb6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchFragment.kt @@ -1,17 +1,22 @@ package com.keylesspalace.tusky.components.search.fragments import android.os.Bundle +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem import android.view.View +import androidx.core.view.MenuProvider import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.paging.LoadState import androidx.paging.PagingData import androidx.paging.PagingDataAdapter -import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.SimpleItemAnimator import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import com.google.android.material.color.MaterialColors import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.BottomSheetActivity import com.keylesspalace.tusky.R @@ -25,6 +30,10 @@ import com.keylesspalace.tusky.interfaces.LinkListener import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible +import com.mikepenz.iconics.IconicsDrawable +import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial +import com.mikepenz.iconics.utils.colorInt +import com.mikepenz.iconics.utils.sizeDp import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @@ -34,7 +43,8 @@ abstract class SearchFragment : Fragment(R.layout.fragment_search), LinkListener, Injectable, - SwipeRefreshLayout.OnRefreshListener { + SwipeRefreshLayout.OnRefreshListener, + MenuProvider { @Inject lateinit var viewModelFactory: ViewModelFactory @@ -58,6 +68,7 @@ abstract class SearchFragment : override fun onViewCreated(view: View, savedInstanceState: Bundle?) { initAdapter() setupSwipeRefreshLayout() + requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) subscribeObservables() } @@ -95,8 +106,28 @@ abstract class SearchFragment : } } + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.fragment_timeline, menu) + menu.findItem(R.id.action_refresh)?.apply { + icon = IconicsDrawable(requireContext(), GoogleMaterial.Icon.gmd_refresh).apply { + sizeDp = 20 + colorInt = MaterialColors.getColor(binding.root, android.R.attr.textColorPrimary) + } + } + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + return when (menuItem.itemId) { + R.id.action_refresh -> { + binding.swipeRefreshLayout.isRefreshing = true + onRefresh() + true + } + else -> false + } + } + private fun initAdapter() { - binding.searchRecyclerView.addItemDecoration(DividerItemDecoration(binding.searchRecyclerView.context, DividerItemDecoration.VERTICAL)) binding.searchRecyclerView.layoutManager = LinearLayoutManager(binding.searchRecyclerView.context) adapter = createAdapter() binding.searchRecyclerView.adapter = adapter diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchHashtagsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchHashtagsFragment.kt index d0b7e8fa..8c4f41fb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchHashtagsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchHashtagsFragment.kt @@ -15,8 +15,11 @@ package com.keylesspalace.tusky.components.search.fragments +import android.os.Bundle +import android.view.View import androidx.paging.PagingData import androidx.paging.PagingDataAdapter +import androidx.recyclerview.widget.DividerItemDecoration import com.keylesspalace.tusky.components.search.adapter.SearchHashtagsAdapter import com.keylesspalace.tusky.entity.HashTag import kotlinx.coroutines.flow.Flow @@ -26,6 +29,16 @@ class SearchHashtagsFragment : SearchFragment() { override val data: Flow> get() = viewModel.hashtagsFlow + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.searchRecyclerView.addItemDecoration( + DividerItemDecoration( + binding.searchRecyclerView.context, + DividerItemDecoration.VERTICAL + ) + ) + } + override fun createAdapter(): PagingDataAdapter = SearchHashtagsAdapter(this) companion object { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt index 9973f3e8..1144a06d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt @@ -48,6 +48,7 @@ import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeOptions import com.keylesspalace.tusky.components.report.ReportActivity import com.keylesspalace.tusky.components.search.adapter.SearchStatusesAdapter import com.keylesspalace.tusky.db.AccountEntity +import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status.Mention @@ -62,8 +63,11 @@ import com.keylesspalace.tusky.viewdata.AttachmentViewData import com.keylesspalace.tusky.viewdata.StatusViewData import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch +import javax.inject.Inject class SearchStatusesFragment : SearchFragment(), StatusActionListener { + @Inject + lateinit var accountManager: AccountManager override val data: Flow> get() = viewModel.statusesFlow @@ -83,7 +87,10 @@ class SearchStatusesFragment : SearchFragment(), Status confirmReblogs = preferences.getBoolean("confirmReblogs", true), confirmFavourites = preferences.getBoolean("confirmFavourites", 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), + showStatsInline = preferences.getBoolean(PrefKeys.SHOW_STATS_INLINE, false), + showSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia, + openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler ) binding.searchRecyclerView.addItemDecoration(DividerItemDecoration(binding.searchRecyclerView.context, DividerItemDecoration.VERTICAL)) @@ -127,7 +134,8 @@ class SearchStatusesFragment : SearchFragment(), Status Attachment.Type.GIFV, Attachment.Type.VIDEO, Attachment.Type.IMAGE, Attachment.Type.AUDIO -> { val attachments = AttachmentViewData.list(actionable) val intent = ViewMediaActivity.newIntent( - context, attachments, + context, + attachments, attachmentIndex ) if (view != null) { @@ -135,7 +143,8 @@ class SearchStatusesFragment : SearchFragment(), Status ViewCompat.setTransitionName(view, url) val options = ActivityOptionsCompat.makeSceneTransitionAnimation( requireActivity(), - view, url + view, + url ) startActivity(intent, options.toBundle()) } else { @@ -184,6 +193,8 @@ class SearchStatusesFragment : SearchFragment(), Status } } + override fun clearWarningAction(position: Int) {} + private fun removeItem(position: Int) { searchAdapter.peek(position)?.let { viewModel.removeItem(it) @@ -391,7 +402,8 @@ class SearchStatusesFragment : SearchFragment(), Status private fun showOpenAsDialog(statusUrl: String, dialogTitle: CharSequence?) { bottomSheetActivity?.showAccountChooserDialog( - dialogTitle, false, + dialogTitle, + false, object : AccountSelectionListener { override fun onAccountSelected(account: AccountEntity) { bottomSheetActivity?.openAsAccount(statusUrl, account) @@ -468,7 +480,7 @@ class SearchStatusesFragment : SearchFragment(), Status val intent = ComposeActivity.startIntent( requireContext(), ComposeOptions( - content = redraftStatus.text ?: "", + content = redraftStatus.text.orEmpty(), inReplyToId = redraftStatus.inReplyToId, visibility = redraftStatus.visibility, contentWarning = redraftStatus.spoilerText, @@ -507,7 +519,7 @@ class SearchStatusesFragment : SearchFragment(), Status language = status.language, statusId = source.id, poll = status.poll?.toNewPoll(status.createdAt), - kind = ComposeActivity.ComposeKind.EDIT_POSTED, + kind = ComposeActivity.ComposeKind.EDIT_POSTED ) startActivity(ComposeActivity.startIntent(requireContext(), composeOptions)) }, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index 7fd49e31..13486267 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -18,10 +18,14 @@ package com.keylesspalace.tusky.components.timeline import android.os.Bundle import android.util.Log import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.view.accessibility.AccessibilityManager import androidx.core.content.ContextCompat +import androidx.core.view.MenuProvider import androidx.lifecycle.Lifecycle import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope @@ -34,14 +38,16 @@ import androidx.recyclerview.widget.SimpleItemAnimator import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener import at.connyduck.sparkbutton.helpers.Utils import autodispose2.androidx.lifecycle.autoDispose -import com.keylesspalace.tusky.AccountListActivity -import com.keylesspalace.tusky.AccountListActivity.Companion.newIntent +import com.google.android.material.color.MaterialColors import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.adapter.StatusBaseViewHolder import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.appstore.StatusComposedEvent +import com.keylesspalace.tusky.appstore.StatusEditedEvent +import com.keylesspalace.tusky.components.accountlist.AccountListActivity +import com.keylesspalace.tusky.components.accountlist.AccountListActivity.Companion.newIntent import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineViewModel import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel @@ -61,14 +67,18 @@ import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate import com.keylesspalace.tusky.util.StatusDisplayOptions 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.viewdata.AttachmentViewData import com.keylesspalace.tusky.viewdata.StatusViewData +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 io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Observable import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -import java.io.IOException import java.util.concurrent.TimeUnit import javax.inject.Inject @@ -78,7 +88,8 @@ class TimelineFragment : StatusActionListener, Injectable, ReselectableFragment, - RefreshableFragment { + RefreshableFragment, + MenuProvider { @Inject lateinit var viewModelFactory: ViewModelFactory @@ -86,7 +97,7 @@ class TimelineFragment : @Inject lateinit var eventHub: EventHub - private val viewModel: TimelineViewModel by lazy { + private val viewModel: TimelineViewModel by unsafeLazy { if (kind == TimelineViewModel.Kind.HOME) { ViewModelProvider(this, viewModelFactory)[CachedTimelineViewModel::class.java] } else { @@ -158,7 +169,7 @@ class TimelineFragment : viewModel.init( kind, id, - tags, + tags ) isSwipeToRefreshEnabled = arguments.getBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true) @@ -176,11 +187,18 @@ class TimelineFragment : PrefKeys.SHOW_CARDS_IN_TIMELINES, false ) - ) CardViewMode.INDENTED else CardViewMode.NONE, + ) { + CardViewMode.INDENTED + } else { + CardViewMode.NONE + }, confirmReblogs = preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true), confirmFavourites = preferences.getBoolean(PrefKeys.CONFIRM_FAVOURITES, false), hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), - animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) + animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), + showStatsInline = preferences.getBoolean(PrefKeys.SHOW_STATS_INLINE, false), + showSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia, + openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler ) adapter = TimelinePagingAdapter( statusDisplayOptions, @@ -197,6 +215,8 @@ class TimelineFragment : } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) + setupSwipeRefreshLayout() setupRecyclerView() @@ -213,17 +233,15 @@ class TimelineFragment : is LoadState.NotLoading -> { if (loadState.append is LoadState.NotLoading && loadState.source.refresh is LoadState.NotLoading) { binding.statusView.show() - binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null) + binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty) + if (kind == TimelineViewModel.Kind.HOME) { + binding.statusView.showHelp(R.string.help_empty_home) + } } } is LoadState.Error -> { binding.statusView.show() - - if ((loadState.refresh as LoadState.Error).error is IOException) { - binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network, null) - } else { - binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic, null) - } + binding.statusView.setup((loadState.refresh as LoadState.Error).error) { onRefresh() } } is LoadState.Loading -> { binding.progressBar.show() @@ -239,7 +257,9 @@ class TimelineFragment : if (getView() != null) { if (isSwipeToRefreshEnabled) { binding.recyclerView.scrollBy(0, Utils.dpToPx(requireContext(), -30)) - } else binding.recyclerView.scrollToPosition(0) + } else { + binding.recyclerView.scrollToPosition(0) + } } } } @@ -276,10 +296,8 @@ class TimelineFragment : }) } - eventHub.events - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(this, Lifecycle.Event.ON_DESTROY) - .subscribe { event -> + viewLifecycleOwner.lifecycleScope.launch { + eventHub.events.collect { event -> when (event) { is PreferenceChangedEvent -> { onPreferenceChanged(event.preferenceKey) @@ -288,8 +306,41 @@ class TimelineFragment : val status = event.status handleStatusComposeEvent(status) } + is StatusEditedEvent -> { + handleStatusComposeEvent(event.status) + } } } + } + } + + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + if (isSwipeToRefreshEnabled) { + menuInflater.inflate(R.menu.fragment_timeline, 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 -> { + if (isSwipeToRefreshEnabled) { + binding.swipeRefreshLayout.isRefreshing = true + + refreshContent() + true + } else { + false + } + } + else -> false + } } /** @@ -376,6 +427,11 @@ class TimelineFragment : viewModel.voteInPoll(choices, status) } + override fun clearWarningAction(position: Int) { + val status = adapter.peek(position)?.asStatusOrNull() ?: return + viewModel.clearWarning(status) + } + override fun onMore(view: View, position: Int) { val status = adapter.peek(position)?.asStatusOrNull() ?: return super.more(status.status, view, position) @@ -411,7 +467,7 @@ class TimelineFragment : override fun onLoadMore(position: Int) { val placeholder = adapter.peek(position)?.asPlaceholderOrNull() ?: return loadMorePosition = position - statusIdBelowLoadMore = adapter.peek(position + 1)?.id + statusIdBelowLoadMore = if (position + 1 < adapter.itemCount) adapter.peek(position + 1)?.id else null viewModel.loadMore(placeholder.id) } @@ -511,6 +567,17 @@ class TimelineFragment : private var talkBackWasEnabled = false + override fun onPause() { + super.onPause() + (binding.recyclerView.layoutManager as? LinearLayoutManager)?.findFirstVisibleItemPosition()?.let { position -> + if (position != RecyclerView.NO_POSITION) { + adapter.snapshot().getOrNull(position)?.id?.let { statusId -> + viewModel.saveReadingPosition(statusId) + } + } + } + } + override fun onResume() { super.onResume() val a11yManager = diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt index 0ea0b958..a232989b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt @@ -24,6 +24,7 @@ import com.keylesspalace.tusky.R import com.keylesspalace.tusky.adapter.PlaceholderViewHolder import com.keylesspalace.tusky.adapter.StatusBaseViewHolder import com.keylesspalace.tusky.adapter.StatusViewHolder +import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.viewdata.StatusViewData @@ -46,21 +47,16 @@ class TimelinePagingAdapter( } override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val inflater = LayoutInflater.from(viewGroup.context) return when (viewType) { - VIEW_TYPE_STATUS -> { - val view = LayoutInflater.from(viewGroup.context) - .inflate(R.layout.item_status, viewGroup, false) - StatusViewHolder(view) + VIEW_TYPE_STATUS_FILTERED -> { + StatusViewHolder(inflater.inflate(R.layout.item_status_wrapper, viewGroup, false)) } VIEW_TYPE_PLACEHOLDER -> { - val view = LayoutInflater.from(viewGroup.context) - .inflate(R.layout.item_status_placeholder, viewGroup, false) - PlaceholderViewHolder(view) + PlaceholderViewHolder(inflater.inflate(R.layout.item_status_placeholder, viewGroup, false)) } else -> { - val view = LayoutInflater.from(viewGroup.context) - .inflate(R.layout.item_status, viewGroup, false) - StatusViewHolder(view) + StatusViewHolder(inflater.inflate(R.layout.item_status, viewGroup, false)) } } } @@ -98,8 +94,11 @@ class TimelinePagingAdapter( } override fun getItemViewType(position: Int): Int { - return if (getItem(position) is StatusViewData.Placeholder) { + val viewData = getItem(position) + return if (viewData is StatusViewData.Placeholder) { VIEW_TYPE_PLACEHOLDER + } else if (viewData?.filterAction == Filter.Action.WARN) { + VIEW_TYPE_STATUS_FILTERED } else { VIEW_TYPE_STATUS } @@ -107,6 +106,7 @@ class TimelinePagingAdapter( companion object { private const val VIEW_TYPE_STATUS = 0 + private const val VIEW_TYPE_STATUS_FILTERED = 1 private const val VIEW_TYPE_PLACEHOLDER = 2 val TimelineDifferCallback = object : DiffUtil.ItemCallback() { @@ -131,8 +131,10 @@ class TimelinePagingAdapter( return if (oldItem == newItem) { // If items are equal - update timestamp only listOf(StatusBaseViewHolder.Key.KEY_CREATED) - } else // If items are different - update the whole view holder + } else { + // If items are different - update the whole view holder null + } } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt index dc220b3f..bdb3d64d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt @@ -63,6 +63,7 @@ fun TimelineAccountEntity.toAccount(gson: Gson): TimelineAccount { localUsername = localUsername, username = username, displayName = displayName, + note = "", url = url, avatar = avatar, bot = bot, @@ -105,6 +106,7 @@ fun Placeholder.toEntity(timelineUserId: Long): TimelineStatusEntity { card = null, repliesCount = 0, language = null, + filtered = null ) } @@ -149,11 +151,12 @@ fun Status.toEntity( card = actionableStatus.card?.let(gson::toJson), repliesCount = actionableStatus.repliesCount, language = actionableStatus.language, + filtered = actionableStatus.filtered ) } fun TimelineStatusWithAccount.toViewData(gson: Gson, isDetailed: Boolean = false): StatusViewData { - if (this.status.isPlaceholder) { + if (this.account == null) { Log.d(TAG, "Constructing Placeholder(${this.status.serverId}, ${this.status.expanded})") return StatusViewData.Placeholder(this.status.serverId, this.status.expanded) } @@ -196,6 +199,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson, isDetailed: Boolean = false card = card, repliesCount = status.repliesCount, language = status.language, + filtered = status.filtered ) } val status = if (reblog != null) { @@ -228,6 +232,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson, isDetailed: Boolean = false card = null, repliesCount = status.repliesCount, language = status.language, + filtered = status.filtered ) } else { Status( @@ -259,6 +264,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson, isDetailed: Boolean = false card = card, repliesCount = status.repliesCount, language = status.language, + filtered = status.filtered ) } return StatusViewData.Concrete( diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt index a1557764..89afefec 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt @@ -49,7 +49,6 @@ class CachedTimelineRemoteMediator( loadType: LoadType, state: PagingState ): MediatorResult { - if (!activeAccount.isLoggedIn()) { return MediatorResult.Success(endOfPaginationReached = true) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt index a8eaaf32..d33bf7e4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt @@ -41,6 +41,7 @@ import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.TimelineStatusWithAccount +import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.network.FilterModel import com.keylesspalace.tusky.network.MastodonApi @@ -49,14 +50,11 @@ import com.keylesspalace.tusky.util.EmptyPagingSource import com.keylesspalace.tusky.viewdata.StatusViewData import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.asExecutor -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import retrofit2.HttpException import javax.inject.Inject -import kotlin.time.DurationUnit -import kotlin.time.toDuration /** * TimelineViewModel that caches all statuses in a local database @@ -100,22 +98,12 @@ class CachedTimelineViewModel @Inject constructor( pagingData.map(Dispatchers.Default.asExecutor()) { timelineStatus -> timelineStatus.toViewData(gson) }.filter(Dispatchers.Default.asExecutor()) { statusViewData -> - !shouldFilterStatus(statusViewData) + shouldFilterStatus(statusViewData) != Filter.Action.HIDE } } .flowOn(Dispatchers.Default) .cachedIn(viewModelScope) - init { - viewModelScope.launch { - delay(5.toDuration(DurationUnit.SECONDS)) // delay so the db is not locked during initial ui refresh - accountManager.activeAccount?.id?.let { accountId -> - db.timelineDao().cleanup(accountId, MAX_STATUSES_IN_CACHE) - db.timelineDao().cleanupAccounts(accountId) - } - } - } - override fun updatePoll(newPoll: Poll, status: StatusViewData.Concrete) { // handled by CacheUpdater } @@ -152,6 +140,12 @@ class CachedTimelineViewModel @Inject constructor( } } + override fun clearWarning(status: StatusViewData.Concrete) { + viewModelScope.launch { + db.timelineDao().clearWarning(accountManager.activeAccount!!.id, status.actionableId) + } + } + override fun removeStatusWithId(id: String) { // handled by CacheUpdater } @@ -197,7 +191,6 @@ class CachedTimelineViewModel @Inject constructor( } db.withTransaction { - timelineDao.delete(activeAccount.id, placeholderId) val overlappedStatuses = if (statuses.isNotEmpty()) { @@ -283,6 +276,14 @@ class CachedTimelineViewModel @Inject constructor( } } + override fun saveReadingPosition(statusId: String) { + accountManager.activeAccount?.let { account -> + Log.d(TAG, "Saving position at: $statusId") + account.lastVisibleHomeTimelineStatusId = statusId + accountManager.saveAccount(account) + } + } + override suspend fun invalidate() { // invalidating when we don't have statuses yet can cause empty timelines because it cancels the network load if (db.timelineDao().getStatusCount(accountManager.activeAccount!!.id) > 0) { @@ -291,6 +292,7 @@ class CachedTimelineViewModel @Inject constructor( } companion object { + private const val TAG = "CachedTimelineViewModel" private const val MAX_STATUSES_IN_CACHE = 1000 } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt index 56236ecf..d1c9ce24 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt @@ -26,7 +26,6 @@ class NetworkTimelinePagingSource( override fun getRefreshKey(state: PagingState): String? = null override suspend fun load(params: LoadParams): LoadResult { - return if (params is LoadParams.Refresh) { val list = viewModel.statusData.toList() LoadResult.Page(list, null, viewModel.nextKey) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt index 82cfd41d..98da15be 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt @@ -36,7 +36,6 @@ class NetworkTimelineRemoteMediator( loadType: LoadType, state: PagingState ): MediatorResult { - try { val statusResponse = when (loadType) { LoadType.REFRESH -> { @@ -80,7 +79,6 @@ class NetworkTimelineRemoteMediator( } if (loadType == LoadType.REFRESH && viewModel.statusData.isNotEmpty()) { - val insertPlaceholder = if (statuses.isNotEmpty()) { !viewModel.statusData.removeAll { statusViewData -> statuses.any { status -> status.id == statusViewData.asStatusOrNull()?.id } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt index f569b57f..f32443ae 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt @@ -30,6 +30,7 @@ import com.keylesspalace.tusky.appstore.PinEvent import com.keylesspalace.tusky.appstore.ReblogEvent import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.FilterModel @@ -82,7 +83,7 @@ class NetworkTimelineViewModel @Inject constructor( ).flow .map { pagingData -> pagingData.filter(Dispatchers.Default.asExecutor()) { statusViewData -> - !shouldFilterStatus(statusViewData) + shouldFilterStatus(statusViewData) != Filter.Action.HIDE } } .flowOn(Dispatchers.Default) @@ -182,7 +183,7 @@ class NetworkTimelineViewModel @Inject constructor( .copy( isShowingContent = oldStatus!!.isShowingContent, isExpanded = oldStatus.isExpanded, - isCollapsed = oldStatus.isCollapsed, + isCollapsed = oldStatus.isCollapsed ) } @@ -248,6 +249,16 @@ class NetworkTimelineViewModel @Inject constructor( currentSource?.invalidate() } + override fun clearWarning(status: StatusViewData.Concrete) { + updateActionableStatusById(status.actionableId) { + it.copy(filtered = null) + } + } + + override fun saveReadingPosition(statusId: String) { + /** Does nothing for non-cached timelines */ + } + override suspend fun invalidate() { currentSource?.invalidate() } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt index 0b970481..adab92b3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt @@ -20,7 +20,9 @@ import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.PagingData +import at.connyduck.calladapter.networkresult.fold import at.connyduck.calladapter.networkresult.getOrElse +import at.connyduck.calladapter.networkresult.getOrThrow import com.keylesspalace.tusky.appstore.BlockEvent import com.keylesspalace.tusky.appstore.BookmarkEvent import com.keylesspalace.tusky.appstore.DomainMuteEvent @@ -38,6 +40,7 @@ import com.keylesspalace.tusky.components.preference.PreferencesFragment.Reading import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Filter +import com.keylesspalace.tusky.entity.FilterV1 import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.network.FilterModel import com.keylesspalace.tusky.network.MastodonApi @@ -47,15 +50,14 @@ import com.keylesspalace.tusky.viewdata.StatusViewData import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch -import kotlinx.coroutines.rx3.asFlow -import kotlinx.coroutines.rx3.await +import retrofit2.HttpException abstract class TimelineViewModel( private val timelineCases: TimelineCases, private val api: MastodonApi, private val eventHub: EventHub, protected val accountManager: AccountManager, - protected val sharedPreferences: SharedPreferences, + private val sharedPreferences: SharedPreferences, private val filterModel: FilterModel ) : ViewModel() { @@ -69,7 +71,7 @@ abstract class TimelineViewModel( private set protected var alwaysShowSensitiveMedia = false - protected var alwaysOpenSpoilers = false + private var alwaysOpenSpoilers = false private var filterRemoveReplies = false private var filterRemoveReblogs = false protected var readingOrder: ReadingOrder = ReadingOrder.OLDEST_FIRST @@ -82,6 +84,7 @@ abstract class TimelineViewModel( this.kind = kind this.id = id this.tags = tags + filterModel.kind = kind.toFilterKind() if (kind == Kind.HOME) { // Note the variable is "true if filter" but the underlying preference/settings text is "true if show" @@ -97,7 +100,6 @@ abstract class TimelineViewModel( viewModelScope.launch { eventHub.events - .asFlow() .collect { event -> handleEvent(event) } } @@ -106,7 +108,7 @@ abstract class TimelineViewModel( fun reblog(reblog: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch { try { - timelineCases.reblog(status.actionableId, reblog).await() + timelineCases.reblog(status.actionableId, reblog).getOrThrow() } catch (t: Exception) { ifExpected(t) { Log.d(TAG, "Failed to reblog status " + status.actionableId, t) @@ -116,7 +118,7 @@ abstract class TimelineViewModel( fun favorite(favorite: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch { try { - timelineCases.favourite(status.actionableId, favorite).await() + timelineCases.favourite(status.actionableId, favorite).getOrThrow() } catch (t: Exception) { ifExpected(t) { Log.d(TAG, "Failed to favourite status " + status.actionableId, t) @@ -126,7 +128,7 @@ abstract class TimelineViewModel( fun bookmark(bookmark: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch { try { - timelineCases.bookmark(status.actionableId, bookmark).await() + timelineCases.bookmark(status.actionableId, bookmark).getOrThrow() } catch (t: Exception) { ifExpected(t) { Log.d(TAG, "Failed to bookmark status " + status.actionableId, t) @@ -144,7 +146,7 @@ abstract class TimelineViewModel( updatePoll(votedPoll, status) try { - timelineCases.voteInPoll(status.actionableId, poll.id, choices).await() + timelineCases.voteInPoll(status.actionableId, poll.id, choices).getOrThrow() } catch (t: Exception) { ifExpected(t) { Log.d(TAG, "Failed to vote in poll: " + status.actionableId, t) @@ -178,14 +180,25 @@ abstract class TimelineViewModel( abstract fun fullReload() + abstract fun clearWarning(status: StatusViewData.Concrete) + + /** Saves the user's reading position so it can be restored later */ + abstract fun saveReadingPosition(statusId: String) + /** Triggered when currently displayed data must be reloaded. */ protected abstract suspend fun invalidate() - protected fun shouldFilterStatus(statusViewData: StatusViewData): Boolean { - val status = statusViewData.asStatusOrNull()?.status ?: return false - return status.inReplyToId != null && filterRemoveReplies || - status.reblog != null && filterRemoveReblogs || - filterModel.shouldFilterStatus(status.actionableStatus) + protected fun shouldFilterStatus(statusViewData: StatusViewData): Filter.Action { + val status = statusViewData.asStatusOrNull()?.status ?: return Filter.Action.NONE + return if ( + (status.inReplyToId != null && filterRemoveReplies) || + (status.reblog != null && filterRemoveReblogs) + ) { + return Filter.Action.HIDE + } else { + statusViewData.filterAction = filterModel.shouldFilterStatus(status.actionableStatus) + statusViewData.filterAction + } } private fun onPreferenceChanged(key: String) { @@ -206,7 +219,7 @@ abstract class TimelineViewModel( fullReload() } } - Filter.HOME, Filter.NOTIFICATIONS, Filter.THREAD, Filter.PUBLIC, Filter.ACCOUNT -> { + FilterV1.HOME, FilterV1.NOTIFICATIONS, FilterV1.THREAD, FilterV1.PUBLIC, FilterV1.ACCOUNT -> { if (filterContextMatchesKind(kind, listOf(key))) { reloadFilters() } @@ -222,28 +235,6 @@ abstract class TimelineViewModel( } } - private fun filterContextMatchesKind( - kind: Kind, - filterContext: List - ): Boolean { - // home, notifications, public, thread - return when (kind) { - Kind.HOME, Kind.LIST -> filterContext.contains( - Filter.HOME - ) - Kind.PUBLIC_FEDERATED, Kind.PUBLIC_LOCAL, Kind.TAG -> filterContext.contains( - Filter.PUBLIC - ) - Kind.FAVOURITES -> filterContext.contains(Filter.PUBLIC) || filterContext.contains( - Filter.NOTIFICATIONS - ) - Kind.USER, Kind.USER_WITH_REPLIES, Kind.USER_PINNED -> filterContext.contains( - Filter.ACCOUNT - ) - else -> false - } - } - private fun handleEvent(event: Event) { when (event) { is FavoriteEvent -> handleFavEvent(event) @@ -288,27 +279,57 @@ abstract class TimelineViewModel( private fun reloadFilters() { viewModelScope.launch { - val filters = api.getFilters().getOrElse { - Log.e(TAG, "Failed to fetch filters", it) - return@launch - } - filterModel.initWithFilters( - filters.filter { - filterContextMatchesKind(kind, it.context) + api.getFilters().fold( + { + // After the filters are loaded we need to reload displayed content to apply them. + // It can happen during the usage or at startup, when we get statuses before filters. + invalidate() + }, + { throwable -> + if (throwable is HttpException && throwable.code() == 404) { + // Fallback to client-side filter code + val filters = api.getFiltersV1().getOrElse { + Log.e(TAG, "Failed to fetch filters", it) + return@launch + } + filterModel.initWithFilters( + filters.filter { + filterContextMatchesKind(kind, it.context) + } + ) + // After the filters are loaded we need to reload displayed content to apply them. + // It can happen during the usage or at startup, when we get statuses before filters. + invalidate() + } else { + Log.e(TAG, "Error getting filters", throwable) + } } ) - // After the filters are loaded we need to reload displayed content to apply them. - // It can happen during the usage or at startup, when we get statuses before filters. - invalidate() } } companion object { private const val TAG = "TimelineVM" internal const val LOAD_AT_ONCE = 30 + + fun filterContextMatchesKind( + kind: Kind, + filterContext: List + ): Boolean { + return filterContext.contains(kind.toFilterKind().kind) + } } enum class Kind { - HOME, PUBLIC_LOCAL, PUBLIC_FEDERATED, TAG, USER, USER_PINNED, USER_WITH_REPLIES, FAVOURITES, LIST, BOOKMARKS + HOME, PUBLIC_LOCAL, PUBLIC_FEDERATED, TAG, USER, USER_PINNED, USER_WITH_REPLIES, FAVOURITES, LIST, BOOKMARKS; + + fun toFilterKind(): Filter.Kind { + return when (valueOf(name)) { + HOME, LIST -> Filter.Kind.HOME + PUBLIC_FEDERATED, PUBLIC_LOCAL, TAG, FAVOURITES -> Filter.Kind.PUBLIC + USER, USER_WITH_REPLIES, USER_PINNED -> Filter.Kind.ACCOUNT + else -> Filter.Kind.PUBLIC + } + } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingActivity.kt new file mode 100644 index 00000000..3270e9c2 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingActivity.kt @@ -0,0 +1,62 @@ +/* Copyright 2019 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.trending + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.fragment.app.commit +import com.keylesspalace.tusky.BaseActivity +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.databinding.ActivityTrendingBinding +import com.keylesspalace.tusky.util.viewBinding +import dagger.android.DispatchingAndroidInjector +import dagger.android.HasAndroidInjector +import javax.inject.Inject + +class TrendingActivity : BaseActivity(), HasAndroidInjector { + + @Inject + lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector + + private val binding: ActivityTrendingBinding by viewBinding(ActivityTrendingBinding::inflate) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(binding.root) + + setSupportActionBar(binding.includedToolbar.toolbar) + + supportActionBar?.run { + setTitle(R.string.title_public_trending_hashtags) + setDisplayHomeAsUpEnabled(true) + setDisplayShowHomeEnabled(true) + } + + if (supportFragmentManager.findFragmentById(R.id.fragmentContainer) == null) { + supportFragmentManager.commit { + val fragment = TrendingFragment.newInstance() + replace(R.id.fragmentContainer, fragment) + } + } + } + + override fun androidInjector() = dispatchingAndroidInjector + + companion object { + fun getIntent(context: Context) = Intent(context, TrendingActivity::class.java) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingAdapter.kt new file mode 100644 index 00000000..b40d6767 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingAdapter.kt @@ -0,0 +1,92 @@ +/* Copyright 2021 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.trending + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.keylesspalace.tusky.databinding.ItemTrendingCellBinding +import com.keylesspalace.tusky.databinding.ItemTrendingDateBinding +import com.keylesspalace.tusky.viewdata.TrendingViewData + +class TrendingAdapter( + private val onViewTag: (String) -> Unit +) : ListAdapter(TrendingDifferCallback) { + + init { + stateRestorationPolicy = StateRestorationPolicy.PREVENT_WHEN_EMPTY + } + + override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return when (viewType) { + VIEW_TYPE_TAG -> { + val binding = + ItemTrendingCellBinding.inflate(LayoutInflater.from(viewGroup.context)) + TrendingTagViewHolder(binding) + } + else -> { + val binding = + ItemTrendingDateBinding.inflate(LayoutInflater.from(viewGroup.context)) + TrendingDateViewHolder(binding) + } + } + } + + override fun onBindViewHolder(viewHolder: RecyclerView.ViewHolder, position: Int) { + when (val viewData = getItem(position)) { + is TrendingViewData.Tag -> { + val holder = viewHolder as TrendingTagViewHolder + holder.setup(viewData, onViewTag) + } + + is TrendingViewData.Header -> { + val holder = viewHolder as TrendingDateViewHolder + holder.setup(viewData.start, viewData.end) + } + } + } + + override fun getItemViewType(position: Int): Int { + return if (getItem(position) is TrendingViewData.Tag) { + VIEW_TYPE_TAG + } else { + VIEW_TYPE_HEADER + } + } + + companion object { + const val VIEW_TYPE_HEADER = 0 + const val VIEW_TYPE_TAG = 1 + + val TrendingDifferCallback = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: TrendingViewData, + newItem: TrendingViewData + ): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame( + oldItem: TrendingViewData, + newItem: TrendingViewData + ): Boolean { + return oldItem == newItem + } + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingDateViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingDateViewHolder.kt new file mode 100644 index 00000000..5d9e3c3a --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingDateViewHolder.kt @@ -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 . */ + +package com.keylesspalace.tusky.components.trending + +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) + ) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingFragment.kt new file mode 100644 index 00000000..2ff9f2cd --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingFragment.kt @@ -0,0 +1,254 @@ +/* Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.trending + +import android.content.res.Configuration +import android.os.Bundle +import android.util.Log +import android.view.View +import android.view.accessibility.AccessibilityManager +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.GridLayoutManager.SpanSizeLookup +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.SimpleItemAnimator +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener +import at.connyduck.sparkbutton.helpers.Utils +import com.keylesspalace.tusky.BaseActivity +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.StatusListActivity +import com.keylesspalace.tusky.components.trending.viewmodel.TrendingViewModel +import com.keylesspalace.tusky.databinding.FragmentTrendingBinding +import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.di.ViewModelFactory +import com.keylesspalace.tusky.interfaces.ActionButtonActivity +import com.keylesspalace.tusky.interfaces.RefreshableFragment +import com.keylesspalace.tusky.interfaces.ReselectableFragment +import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.util.viewBinding +import com.keylesspalace.tusky.viewdata.TrendingViewData +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import javax.inject.Inject + +class TrendingFragment : + Fragment(R.layout.fragment_trending), + OnRefreshListener, + Injectable, + ReselectableFragment, + RefreshableFragment { + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + private val viewModel: TrendingViewModel by viewModels { viewModelFactory } + + private val binding by viewBinding(FragmentTrendingBinding::bind) + + private val adapter = TrendingAdapter(::onViewTag) + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + val columnCount = + requireContext().resources.getInteger(R.integer.trending_column_count) + setupLayoutManager(columnCount) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + setupSwipeRefreshLayout() + setupRecyclerView() + + adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { + override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { + if (positionStart == 0 && adapter.itemCount != itemCount) { + binding.recyclerView.post { + if (getView() != null) { + binding.recyclerView.scrollBy( + 0, + Utils.dpToPx(requireContext(), -30) + ) + } + } + } + } + }) + + viewLifecycleOwner.lifecycleScope.launch { + viewModel.uiState.collectLatest { trendingState -> + processViewState(trendingState) + } + } + + if (activity is ActionButtonActivity) { + (activity as ActionButtonActivity).actionButton?.visibility = View.GONE + } + } + + private fun setupSwipeRefreshLayout() { + binding.swipeRefreshLayout.setOnRefreshListener(this) + binding.swipeRefreshLayout.setColorSchemeResources(R.color.chinwag_green) + } + + private fun setupLayoutManager(columnCount: Int) { + binding.recyclerView.layoutManager = GridLayoutManager(context, columnCount).apply { + spanSizeLookup = object : SpanSizeLookup() { + override fun getSpanSize(position: Int): Int { + return when (adapter.getItemViewType(position)) { + TrendingAdapter.VIEW_TYPE_HEADER -> columnCount + TrendingAdapter.VIEW_TYPE_TAG -> 1 + else -> -1 + } + } + } + } + } + + private fun setupRecyclerView() { + val columnCount = + requireContext().resources.getInteger(R.integer.trending_column_count) + setupLayoutManager(columnCount) + + binding.recyclerView.setHasFixedSize(true) + + (binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false + binding.recyclerView.adapter = adapter + } + + override fun onRefresh() { + viewModel.invalidate(true) + } + + fun onViewTag(tag: String) { + (requireActivity() as BaseActivity).startActivityWithSlideInAnimation(StatusListActivity.newHashtagIntent(requireContext(), tag)) + } + + private fun processViewState(uiState: TrendingViewModel.TrendingUiState) { + Log.d(TAG, uiState.loadingState.name) + when (uiState.loadingState) { + TrendingViewModel.LoadingState.INITIAL -> clearLoadingState() + TrendingViewModel.LoadingState.LOADING -> applyLoadingState() + TrendingViewModel.LoadingState.REFRESHING -> applyRefreshingState() + TrendingViewModel.LoadingState.LOADED -> applyLoadedState(uiState.trendingViewData) + TrendingViewModel.LoadingState.ERROR_NETWORK -> networkError() + TrendingViewModel.LoadingState.ERROR_OTHER -> otherError() + } + } + + private fun applyLoadedState(viewData: List) { + clearLoadingState() + + adapter.submitList(viewData) + + if (viewData.isEmpty()) { + binding.recyclerView.hide() + binding.messageView.show() + binding.messageView.setup( + R.drawable.elephant_friend_empty, + R.string.message_empty, + null + ) + } else { + binding.recyclerView.show() + binding.messageView.hide() + } + binding.progressBar.hide() + } + + private fun applyRefreshingState() { + binding.swipeRefreshLayout.isRefreshing = true + } + + private fun applyLoadingState() { + binding.recyclerView.hide() + binding.messageView.hide() + binding.progressBar.show() + } + + private fun clearLoadingState() { + binding.swipeRefreshLayout.isRefreshing = false + binding.progressBar.hide() + binding.messageView.hide() + } + + private fun networkError() { + binding.recyclerView.hide() + binding.messageView.show() + binding.progressBar.hide() + + binding.swipeRefreshLayout.isRefreshing = false + binding.messageView.setup( + R.drawable.elephant_offline, + R.string.error_network + ) { refreshContent() } + } + + private fun otherError() { + binding.recyclerView.hide() + binding.messageView.show() + binding.progressBar.hide() + + binding.swipeRefreshLayout.isRefreshing = false + binding.messageView.setup( + R.drawable.elephant_error, + R.string.error_generic + ) { refreshContent() } + } + + private fun actionButtonPresent(): Boolean { + return activity is ActionButtonActivity + } + + private var talkBackWasEnabled = false + + override fun onResume() { + super.onResume() + val a11yManager = + ContextCompat.getSystemService(requireContext(), AccessibilityManager::class.java) + + val wasEnabled = talkBackWasEnabled + talkBackWasEnabled = a11yManager?.isEnabled == true + Log.d(TAG, "talkback was enabled: $wasEnabled, now $talkBackWasEnabled") + if (talkBackWasEnabled && !wasEnabled) { + adapter.notifyItemRangeChanged(0, adapter.itemCount) + } + + if (actionButtonPresent()) { + val composeButton = (activity as ActionButtonActivity).actionButton + composeButton?.hide() + } + } + + override fun onReselect() { + if (isAdded) { + binding.recyclerView.layoutManager?.scrollToPosition(0) + binding.recyclerView.stopScroll() + } + } + + override fun refreshContent() { + onRefresh() + } + + companion object { + private const val TAG = "TrendingFragment" + + fun newInstance() = TrendingFragment() + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingTagViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingTagViewHolder.kt new file mode 100644 index 00000000..58aad9e8 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingTagViewHolder.kt @@ -0,0 +1,57 @@ +/* Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.trending + +import androidx.recyclerview.widget.RecyclerView +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.databinding.ItemTrendingCellBinding +import com.keylesspalace.tusky.util.formatNumber +import com.keylesspalace.tusky.viewdata.TrendingViewData + +class TrendingTagViewHolder( + private val binding: ItemTrendingCellBinding +) : RecyclerView.ViewHolder(binding.root) { + + fun setup( + tagViewData: TrendingViewData.Tag, + onViewTag: (String) -> Unit + ) { + binding.tag.text = binding.root.context.getString(R.string.title_tag, tagViewData.name) + + binding.graph.maxTrendingValue = tagViewData.maxTrendingValue + binding.graph.primaryLineData = tagViewData.usage + binding.graph.secondaryLineData = tagViewData.accounts + + binding.totalUsage.text = formatNumber(tagViewData.usage.sum(), 1000) + + val totalAccounts = tagViewData.accounts.sum() + binding.totalAccounts.text = formatNumber(totalAccounts, 1000) + + binding.currentUsage.text = tagViewData.usage.last().toString() + binding.currentAccounts.text = tagViewData.usage.last().toString() + + itemView.setOnClickListener { + onViewTag(tagViewData.name) + } + + itemView.contentDescription = + itemView.context.getString( + R.string.accessibility_talking_about_tag, + totalAccounts, + tagViewData.name + ) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/trending/viewmodel/TrendingViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/trending/viewmodel/TrendingViewModel.kt new file mode 100644 index 00000000..d1877a2f --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/trending/viewmodel/TrendingViewModel.kt @@ -0,0 +1,116 @@ +/* Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.trending.viewmodel + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import at.connyduck.calladapter.networkresult.fold +import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.appstore.PreferenceChangedEvent +import com.keylesspalace.tusky.entity.Filter +import com.keylesspalace.tusky.entity.end +import com.keylesspalace.tusky.entity.start +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.toViewData +import com.keylesspalace.tusky.viewdata.TrendingViewData +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.launch +import java.io.IOException +import javax.inject.Inject + +class TrendingViewModel @Inject constructor( + private val mastodonApi: MastodonApi, + private val eventHub: EventHub +) : ViewModel() { + enum class LoadingState { + INITIAL, LOADING, REFRESHING, LOADED, ERROR_NETWORK, ERROR_OTHER + } + + data class TrendingUiState( + val trendingViewData: List, + val loadingState: LoadingState + ) + + val uiState: Flow get() = _uiState + private val _uiState = MutableStateFlow(TrendingUiState(listOf(), LoadingState.INITIAL)) + + init { + invalidate() + + // Collect PreferenceChangedEvent, FiltersActivity creates them when a filter is created + // or deleted. Unfortunately, there's nothing in the event to determine if it's a filter + // that was modified, so refresh on every preference change. + viewModelScope.launch { + eventHub.events + .filterIsInstance() + .collect { + invalidate() + } + } + } + + /** + * Invalidate the current list of trending tags and fetch a new list. + * + * A tag is excluded if it is filtered by the user on their home timeline. + */ + fun invalidate(refresh: Boolean = false) = viewModelScope.launch { + if (refresh) { + _uiState.value = TrendingUiState(emptyList(), LoadingState.REFRESHING) + } else { + _uiState.value = TrendingUiState(emptyList(), LoadingState.LOADING) + } + + val deferredFilters = async { mastodonApi.getFilters() } + + mastodonApi.trendingTags().fold( + { tagResponse -> + val homeFilters = deferredFilters.await().getOrNull()?.filter { filter -> + filter.context.contains(Filter.Kind.HOME.kind) + } + val tags = tagResponse + .filter { tag -> + homeFilters?.none { filter -> + filter.keywords.any { keyword -> keyword.keyword.equals(tag.name, ignoreCase = true) } + } ?: false + } + .sortedByDescending { tag -> tag.history.sumOf { it.uses.toLongOrNull() ?: 0 } } + .toViewData() + + val firstTag = tagResponse.first() + val header = TrendingViewData.Header(firstTag.start(), firstTag.end()) + + _uiState.value = TrendingUiState(listOf(header) + tags, LoadingState.LOADED) + }, + { error -> + Log.w(TAG, "failed loading trending tags", error) + if (error is IOException) { + _uiState.value = TrendingUiState(emptyList(), LoadingState.ERROR_NETWORK) + } else { + _uiState.value = TrendingUiState(emptyList(), LoadingState.ERROR_OTHER) + } + } + ) + } + + companion object { + private const val TAG = "TrendingViewModel" + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ThreadAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ThreadAdapter.kt index 7f900de6..1edffcaf 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ThreadAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ThreadAdapter.kt @@ -23,6 +23,7 @@ import com.keylesspalace.tusky.R import com.keylesspalace.tusky.adapter.StatusBaseViewHolder import com.keylesspalace.tusky.adapter.StatusDetailedViewHolder import com.keylesspalace.tusky.adapter.StatusViewHolder +import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.viewdata.StatusViewData @@ -33,16 +34,16 @@ class ThreadAdapter( ) : ListAdapter(ThreadDifferCallback) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StatusBaseViewHolder { + val inflater = LayoutInflater.from(parent.context) return when (viewType) { VIEW_TYPE_STATUS -> { - val view = LayoutInflater.from(parent.context) - .inflate(R.layout.item_status, parent, false) - StatusViewHolder(view) + StatusViewHolder(inflater.inflate(R.layout.item_status, parent, false)) + } + VIEW_TYPE_STATUS_FILTERED -> { + StatusViewHolder(inflater.inflate(R.layout.item_status_wrapper, parent, false)) } VIEW_TYPE_STATUS_DETAILED -> { - val view = LayoutInflater.from(parent.context) - .inflate(R.layout.item_status_detailed, parent, false) - StatusDetailedViewHolder(view) + StatusDetailedViewHolder(inflater.inflate(R.layout.item_status_detailed, parent, false)) } else -> error("Unknown item type: $viewType") } @@ -54,8 +55,11 @@ class ThreadAdapter( } override fun getItemViewType(position: Int): Int { - return if (getItem(position).isDetailed) { + val viewData = getItem(position) + return if (viewData.isDetailed) { VIEW_TYPE_STATUS_DETAILED + } else if (viewData.filterAction == Filter.Action.WARN) { + VIEW_TYPE_STATUS_FILTERED } else { VIEW_TYPE_STATUS } @@ -65,6 +69,7 @@ class ThreadAdapter( private const val TAG = "ThreadAdapter" private const val VIEW_TYPE_STATUS = 0 private const val VIEW_TYPE_STATUS_DETAILED = 1 + private const val VIEW_TYPE_STATUS_FILTERED = 2 val ThreadDifferCallback = object : DiffUtil.ItemCallback() { override fun areItemsTheSame( @@ -88,8 +93,10 @@ class ThreadAdapter( return if (oldItem == newItem) { // If items are equal - update timestamp only listOf(StatusBaseViewHolder.Key.KEY_CREATED) - } else // If items are different - update the whole view holder + } else { + // If items are different - update the whole view holder null + } } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadActivity.kt index ed0393fa..70c0df19 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadActivity.kt @@ -21,18 +21,28 @@ import android.os.Bundle import androidx.fragment.app.commit import com.keylesspalace.tusky.BottomSheetActivity import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.databinding.ActivityViewThreadBinding +import com.keylesspalace.tusky.util.viewBinding import dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector import javax.inject.Inject class ViewThreadActivity : BottomSheetActivity(), HasAndroidInjector { + private val binding by viewBinding(ActivityViewThreadBinding::inflate) + @Inject lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(R.layout.activity_view_thread) + setContentView(binding.root) + setSupportActionBar(binding.toolbar) + supportActionBar?.run { + setDisplayHomeAsUpEnabled(true) + setDisplayShowHomeEnabled(true) + setDisplayShowTitleEnabled(true) + } val id = intent.getStringExtra(ID_EXTRA)!! val url = intent.getStringExtra(URL_EXTRA)!! val fragment = diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt index 5a0e308b..0e9f467e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt @@ -18,10 +18,14 @@ package com.keylesspalace.tusky.components.viewthread import android.os.Bundle import android.util.Log import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.widget.LinearLayout import androidx.annotation.CheckResult +import androidx.core.view.MenuProvider import androidx.fragment.app.commit import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle @@ -32,10 +36,10 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.SimpleItemAnimator import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener import com.google.android.material.snackbar.Snackbar -import com.keylesspalace.tusky.AccountListActivity -import com.keylesspalace.tusky.AccountListActivity.Companion.newIntent import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.components.accountlist.AccountListActivity +import com.keylesspalace.tusky.components.accountlist.AccountListActivity.Companion.newIntent import com.keylesspalace.tusky.components.viewthread.edits.ViewEditsFragment import com.keylesspalace.tusky.databinding.FragmentViewThreadBinding import com.keylesspalace.tusky.di.Injectable @@ -56,10 +60,14 @@ import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import java.io.IOException import javax.inject.Inject -class ViewThreadFragment : SFragment(), OnRefreshListener, StatusActionListener, Injectable { +class ViewThreadFragment : + SFragment(), + OnRefreshListener, + StatusActionListener, + MenuProvider, + Injectable { @Inject lateinit var viewModelFactory: ViewModelFactory @@ -74,6 +82,16 @@ class ViewThreadFragment : SFragment(), OnRefreshListener, StatusActionListener, private var alwaysShowSensitiveMedia = false private var alwaysOpenSpoiler = false + /** + * State of the "reveal" menu item that shows/hides content that is behind a content + * warning. Setting this invalidates the menu to redraw the menu item. + */ + private var revealButtonState = RevealButtonState.NO_BUTTON + set(value) { + field = value + requireActivity().invalidateMenu() + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) thisThreadsStatusId = requireArguments().getString(ID_EXTRA)!! @@ -93,7 +111,10 @@ class ViewThreadFragment : SFragment(), OnRefreshListener, StatusActionListener, confirmReblogs = preferences.getBoolean("confirmReblogs", true), confirmFavourites = preferences.getBoolean("confirmFavourites", 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), + showStatsInline = preferences.getBoolean(PrefKeys.SHOW_STATS_INLINE, false), + showSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia, + openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler ) adapter = ThreadAdapter(statusDisplayOptions, this) } @@ -107,24 +128,7 @@ class ViewThreadFragment : SFragment(), OnRefreshListener, StatusActionListener, } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - - binding.toolbar.setNavigationOnClickListener { - activity?.onBackPressedDispatcher?.onBackPressed() - } - binding.toolbar.inflateMenu(R.menu.view_thread_toolbar) - binding.toolbar.setOnMenuItemClickListener { menuItem -> - when (menuItem.itemId) { - R.id.action_reveal -> { - viewModel.toggleRevealButton() - true - } - R.id.action_open_in_web -> { - context?.openLink(requireArguments().getString(URL_EXTRA)!!) - true - } - else -> false - } - } + requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) binding.swipeRefreshLayout.setOnRefreshListener(this) binding.swipeRefreshLayout.setColorSchemeResources(R.color.chinwag_green) @@ -154,7 +158,7 @@ class ViewThreadFragment : SFragment(), OnRefreshListener, StatusActionListener, viewModel.uiState.collect { uiState -> when (uiState) { is ThreadUiState.Loading -> { - updateRevealButton(RevealButtonState.NO_BUTTON) + revealButtonState = RevealButtonState.NO_BUTTON binding.recyclerView.hide() binding.statusView.hide() @@ -173,9 +177,13 @@ class ViewThreadFragment : SFragment(), OnRefreshListener, StatusActionListener, threadProgressBar = getProgressBarJob(binding.threadProgressBar, 500) threadProgressBar.start() - adapter.submitList(listOf(uiState.statusViewDatum)) + if (viewModel.isInitialLoad) { + adapter.submitList(listOf(uiState.statusViewDatum)) - updateRevealButton(uiState.revealButton) + // else this "submit one and then all on success below" will always center on the one + } + + revealButtonState = uiState.revealButton binding.swipeRefreshLayout.isRefreshing = false binding.recyclerView.show() @@ -186,21 +194,13 @@ class ViewThreadFragment : SFragment(), OnRefreshListener, StatusActionListener, initialProgressBar.cancel() threadProgressBar.cancel() - updateRevealButton(RevealButtonState.NO_BUTTON) + revealButtonState = RevealButtonState.NO_BUTTON binding.swipeRefreshLayout.isRefreshing = false binding.recyclerView.hide() binding.statusView.show() - if (uiState.throwable is IOException) { - binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) { - viewModel.retry(thisThreadsStatusId) - } - } else { - binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic) { - viewModel.retry(thisThreadsStatusId) - } - } + binding.statusView.setup(uiState.throwable) { viewModel.retry(thisThreadsStatusId) } } is ThreadUiState.Success -> { if (uiState.statusViewData.none { viewData -> viewData.isDetailed }) { @@ -216,11 +216,14 @@ class ViewThreadFragment : SFragment(), OnRefreshListener, StatusActionListener, viewModel.isInitialLoad = false // Ensure the top of the status is visible - (binding.recyclerView.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(uiState.detailedStatusPosition, 0) + (binding.recyclerView.layoutManager as LinearLayoutManager).scrollToPositionWithOffset( + uiState.detailedStatusPosition, + 0 + ) } } - updateRevealButton(uiState.revealButton) + revealButtonState = uiState.revealButton binding.swipeRefreshLayout.isRefreshing = false binding.recyclerView.show() @@ -247,6 +250,41 @@ class ViewThreadFragment : SFragment(), OnRefreshListener, StatusActionListener, viewModel.loadThread(thisThreadsStatusId) } + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.fragment_view_thread, menu) + val actionReveal = menu.findItem(R.id.action_reveal) + actionReveal.isVisible = revealButtonState != RevealButtonState.NO_BUTTON + actionReveal.setIcon( + when (revealButtonState) { + RevealButtonState.REVEAL -> R.drawable.ic_eye_24dp + else -> R.drawable.ic_hide_media_24dp + } + ) + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + return when (menuItem.itemId) { + R.id.action_reveal -> { + viewModel.toggleRevealButton() + true + } + R.id.action_open_in_web -> { + context?.openLink(requireArguments().getString(URL_EXTRA)!!) + true + } + R.id.action_refresh -> { + onRefresh() + true + } + else -> false + } + } + + override fun onResume() { + super.onResume() + requireActivity().title = getString(R.string.title_view_thread) + } + /** * Create a job to implement a delayed-visible progress bar. * @@ -256,7 +294,7 @@ class ViewThreadFragment : SFragment(), OnRefreshListener, StatusActionListener, * When started the job will wait `delayMs` then show `view`. If the job is cancelled at * any time `view` is hidden. */ - @CheckResult() + @CheckResult private fun getProgressBarJob(view: View, delayMs: Long) = viewLifecycleOwner.lifecycleScope.launch( start = CoroutineStart.LAZY ) { @@ -269,13 +307,6 @@ class ViewThreadFragment : SFragment(), OnRefreshListener, StatusActionListener, } } - private fun updateRevealButton(state: RevealButtonState) { - val menuItem = binding.toolbar.menu.findItem(R.id.action_reveal) - - menuItem.isVisible = state != RevealButtonState.NO_BUTTON - menuItem.setIcon(if (state == RevealButtonState.REVEAL) R.drawable.ic_eye_24dp else R.drawable.ic_hide_media_24dp) - } - override fun onRefresh() { viewModel.refresh(thisThreadsStatusId) } @@ -370,13 +401,14 @@ class ViewThreadFragment : SFragment(), OnRefreshListener, StatusActionListener, } public override fun removeItem(position: Int) { - val status = adapter.currentList[position] - if (status.isDetailed) { - // the main status we are viewing is being removed, finish the activity - activity?.finish() - return + adapter.currentList.getOrNull(position)?.let { status -> + if (status.isDetailed) { + // the main status we are viewing is being removed, finish the activity + activity?.finish() + return + } + viewModel.removeStatus(status) } - viewModel.removeStatus(status) } override fun onVoteInPoll(position: Int, choices: List) { @@ -395,6 +427,10 @@ class ViewThreadFragment : SFragment(), OnRefreshListener, StatusActionListener, } } + override fun clearWarningAction(position: Int) { + viewModel.clearWarning(adapter.currentList[position]) + } + companion object { private const val TAG = "ViewThreadFragment" diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt index 181b2642..f4b94400 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt @@ -20,6 +20,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import at.connyduck.calladapter.networkresult.fold import at.connyduck.calladapter.networkresult.getOrElse +import at.connyduck.calladapter.networkresult.getOrThrow import com.google.gson.Gson import com.keylesspalace.tusky.appstore.BlockEvent import com.keylesspalace.tusky.appstore.BookmarkEvent @@ -29,11 +30,13 @@ import com.keylesspalace.tusky.appstore.PinEvent import com.keylesspalace.tusky.appstore.ReblogEvent import com.keylesspalace.tusky.appstore.StatusComposedEvent import com.keylesspalace.tusky.appstore.StatusDeletedEvent +import com.keylesspalace.tusky.appstore.StatusEditedEvent import com.keylesspalace.tusky.components.timeline.toViewData import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.entity.Filter +import com.keylesspalace.tusky.entity.FilterV1 import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.FilterModel import com.keylesspalace.tusky.network.MastodonApi @@ -48,8 +51,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.rx3.asFlow -import kotlinx.coroutines.rx3.await +import retrofit2.HttpException import javax.inject.Inject class ViewThreadViewModel @Inject constructor( @@ -82,7 +84,6 @@ class ViewThreadViewModel @Inject constructor( viewModelScope.launch { eventHub.events - .asFlow() .collect { event -> when (event) { is FavoriteEvent -> handleFavEvent(event) @@ -92,6 +93,7 @@ class ViewThreadViewModel @Inject constructor( is BlockEvent -> removeAllByAccountId(event.accountId) is StatusComposedEvent -> handleStatusComposedEvent(event) is StatusDeletedEvent -> handleStatusDeletedEvent(event) + is StatusEditedEvent -> handleStatusEditedEvent(event) } } } @@ -163,7 +165,7 @@ class ViewThreadViewModel @Inject constructor( _uiState.value = ThreadUiState.Success( statusViewData = listOf(detailedStatus), detailedStatusPosition = 0, - revealButton = RevealButtonState.NO_BUTTON, + revealButton = RevealButtonState.NO_BUTTON ) }) } @@ -191,7 +193,7 @@ class ViewThreadViewModel @Inject constructor( fun reblog(reblog: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch { try { - timelineCases.reblog(status.actionableId, reblog).await() + timelineCases.reblog(status.actionableId, reblog).getOrThrow() } catch (t: Exception) { ifExpected(t) { Log.d(TAG, "Failed to reblog status " + status.actionableId, t) @@ -201,7 +203,7 @@ class ViewThreadViewModel @Inject constructor( fun favorite(favorite: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch { try { - timelineCases.favourite(status.actionableId, favorite).await() + timelineCases.favourite(status.actionableId, favorite).getOrThrow() } catch (t: Exception) { ifExpected(t) { Log.d(TAG, "Failed to favourite status " + status.actionableId, t) @@ -211,7 +213,7 @@ class ViewThreadViewModel @Inject constructor( fun bookmark(bookmark: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch { try { - timelineCases.bookmark(status.actionableId, bookmark).await() + timelineCases.bookmark(status.actionableId, bookmark).getOrThrow() } catch (t: Exception) { ifExpected(t) { Log.d(TAG, "Failed to bookmark status " + status.actionableId, t) @@ -231,7 +233,7 @@ class ViewThreadViewModel @Inject constructor( } try { - timelineCases.voteInPoll(status.actionableId, poll.id, choices).await() + timelineCases.voteInPoll(status.actionableId, poll.id, choices).getOrThrow() } catch (t: Exception) { ifExpected(t) { Log.d(TAG, "Failed to vote in poll: " + status.actionableId, t) @@ -327,6 +329,20 @@ class ViewThreadViewModel @Inject constructor( } } + private fun handleStatusEditedEvent(event: StatusEditedEvent) { + updateSuccess { uiState -> + uiState.copy( + statusViewData = uiState.statusViewData.map { status -> + if (status.actionableId == event.originalId) { + event.status.toViewData() + } else { + status + } + } + ) + } + } + private fun handleStatusDeletedEvent(event: StatusDeletedEvent) { updateSuccess { uiState -> uiState.copy( @@ -398,30 +414,48 @@ class ViewThreadViewModel @Inject constructor( private fun loadFilters() { viewModelScope.launch { - val filters = api.getFilters().getOrElse { - Log.w(TAG, "Failed to fetch filters", it) - return@launch - } + api.getFilters().fold( + { + filterModel.kind = Filter.Kind.THREAD + updateStatuses() + }, + { throwable -> + if (throwable is HttpException && throwable.code() == 404) { + val filters = api.getFiltersV1().getOrElse { + Log.w(TAG, "Failed to fetch filters", it) + return@launch + } - filterModel.initWithFilters( - filters.filter { filter -> - filter.context.contains(Filter.THREAD) + filterModel.initWithFilters( + filters.filter { filter -> filter.context.contains(FilterV1.THREAD) } + ) + updateStatuses() + } else { + Log.e(TAG, "Error getting filters", throwable) + } } ) + } + } - updateSuccess { uiState -> - val statuses = uiState.statusViewData.filter() - uiState.copy( - statusViewData = statuses, - revealButton = statuses.getRevealButtonState() - ) - } + private fun updateStatuses() { + updateSuccess { uiState -> + val statuses = uiState.statusViewData.filter() + uiState.copy( + statusViewData = statuses, + revealButton = statuses.getRevealButtonState() + ) } } private fun List.filter(): List { return filter { status -> - status.isDetailed || !filterModel.shouldFilterStatus(status.status) + if (status.isDetailed) { + true + } else { + status.filterAction = filterModel.shouldFilterStatus(status.status) + status.filterAction != Filter.Action.HIDE + } } } @@ -469,6 +503,12 @@ class ViewThreadViewModel @Inject constructor( } } + fun clearWarning(viewData: StatusViewData.Concrete) { + updateStatus(viewData.id) { status -> + status.copy(filtered = null) + } + } + companion object { private const val TAG = "ViewThreadViewModel" } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsAdapter.kt index 931f88b8..33085f82 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsAdapter.kt @@ -1,7 +1,17 @@ package com.keylesspalace.tusky.components.viewthread.edits +import android.content.Context +import android.graphics.Typeface.DEFAULT_BOLD import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Drawable +import android.text.Editable +import android.text.Html +import android.text.Spannable +import android.text.SpannableStringBuilder +import android.text.Spanned +import android.text.TextPaint +import android.text.style.CharacterStyle +import android.util.TypedValue import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -23,12 +33,12 @@ import com.keylesspalace.tusky.util.aspectRatios import com.keylesspalace.tusky.util.decodeBlurHash import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.hide -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.visible import com.keylesspalace.tusky.viewdata.toViewData +import org.xml.sax.XMLReader class ViewEditsAdapter( private val edits: List, @@ -40,41 +50,58 @@ class ViewEditsAdapter( private val absoluteTimeFormatter = AbsoluteTimeFormatter() + /** Size of large text in this theme, in px */ + var largeTextSizePx: Float = 0f + + /** Size of medium text in this theme, in px */ + var mediumTextSizePx: Float = 0f + override fun onCreateViewHolder( parent: ViewGroup, viewType: Int ): BindingHolder { val binding = ItemStatusEditBinding.inflate(LayoutInflater.from(parent.context), parent, false) + binding.statusEditMediaPreview.clipToOutline = true + + val typedValue = TypedValue() + val context = binding.root.context + val displayMetrics = context.resources.displayMetrics + context.theme.resolveAttribute(R.attr.status_text_large, typedValue, true) + largeTextSizePx = typedValue.getDimension(displayMetrics) + context.theme.resolveAttribute(R.attr.status_text_medium, typedValue, true) + mediumTextSizePx = typedValue.getDimension(displayMetrics) + return BindingHolder(binding) } override fun onBindViewHolder(holder: BindingHolder, position: Int) { - val edit = edits[position] val binding = holder.binding val context = binding.root.context - val avatarRadius: Int = context.resources - .getDimensionPixelSize(R.dimen.avatar_radius_48dp) - - loadAvatar(edit.account.avatar, binding.statusEditAvatar, avatarRadius, animateAvatars) - - val infoStringRes = if (position == edits.size - 1) { + val infoStringRes = if (position == edits.lastIndex) { R.string.status_created_info } else { R.string.status_edit_info } + // Show the most recent version of the status using large text to make it clearer for + // the user, and for similarity with thread view. + val variableTextSize = if (position == edits.lastIndex) { + mediumTextSizePx + } else { + largeTextSizePx + } + binding.statusEditContentWarningDescription.setTextSize(TypedValue.COMPLEX_UNIT_PX, variableTextSize) + binding.statusEditContent.setTextSize(TypedValue.COMPLEX_UNIT_PX, variableTextSize) + binding.statusEditMediaSensitivity.setTextSize(TypedValue.COMPLEX_UNIT_PX, variableTextSize) + val timestamp = absoluteTimeFormatter.format(edit.createdAt, false) - binding.statusEditInfo.text = context.getString( - infoStringRes, - edit.account.name, - timestamp - ).emojify(edit.account.emojis, binding.statusEditInfo, animateEmojis) + binding.statusEditInfo.text = context.getString(infoStringRes, timestamp) if (edit.spoilerText.isEmpty()) { binding.statusEditContentWarningDescription.hide() @@ -89,7 +116,11 @@ class ViewEditsAdapter( ) } - val emojifiedText = edit.content.parseAsMastodonHtml().emojify(edit.emojis, binding.statusEditContent, animateEmojis) + val emojifiedText = edit + .content + .parseAsMastodonHtml(TuskyTagHandler(context)) + .emojify(edit.emojis, binding.statusEditContent, animateEmojis) + setClickableText(binding.statusEditContent, emojifiedText, emptyList(), emptyList(), listener) if (edit.poll == null) { @@ -182,4 +213,116 @@ class ViewEditsAdapter( } override fun getItemCount() = edits.size + + companion object { + private const val VIEW_TYPE_EDITS_NEWEST = 0 + private const val VIEW_TYPE_EDITS = 1 + } +} + +/** + * Handle XML tags created by [ViewEditsViewModel] and create custom spans to display inserted or + * deleted text. + */ +class TuskyTagHandler(val context: Context) : Html.TagHandler { + /** Class to mark the start of a span of deleted text */ + class Del + + /** Class to mark the start of a span of inserted text */ + class Ins + + override fun handleTag(opening: Boolean, tag: String, output: Editable, xmlReader: XMLReader) { + when (tag) { + DELETED_TEXT_EL -> { + if (opening) { + start(output as SpannableStringBuilder, Del()) + } else { + end( + output as SpannableStringBuilder, + Del::class.java, + DeletedTextSpan(context) + ) + } + } + INSERTED_TEXT_EL -> { + if (opening) { + start(output as SpannableStringBuilder, Ins()) + } else { + end( + output as SpannableStringBuilder, + Ins::class.java, + InsertedTextSpan(context) + ) + } + } + } + } + + /** @return the last span in [text] of type [kind], or null if that kind is not in text */ + private fun getLast(text: Spanned, kind: Class): Any? { + val spans = text.getSpans(0, text.length, kind) + return spans?.get(spans.size - 1) + } + + /** + * Mark the start of a span of [text] with [mark] so it can be discovered later by [end]. + */ + private fun start(text: SpannableStringBuilder, mark: Any) { + val len = text.length + text.setSpan(mark, len, len, Spannable.SPAN_MARK_MARK) + } + + /** + * Set a [span] over the [text] most from the point recently marked with [mark] to the end + * of the text. + */ + private fun end(text: SpannableStringBuilder, mark: Class, span: Any) { + val len = text.length + val obj = getLast(text, mark) + val where = text.getSpanStart(obj) + text.removeSpan(obj) + if (where != len) { + text.setSpan(span, where, len, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + } + } + + /** Span that signifies deleted text */ + class DeletedTextSpan(context: Context) : CharacterStyle() { + private var bgColor: Int + + init { + bgColor = context.getColor(R.color.view_edits_background_delete) + } + + override fun updateDrawState(tp: TextPaint) { + tp.bgColor = bgColor + tp.isStrikeThruText = true + } + } + + /** Span that signifies inserted text */ + class InsertedTextSpan(context: Context) : CharacterStyle() { + private var bgColor: Int + + init { + bgColor = context.getColor(R.color.view_edits_background_insert) + } + + override fun updateDrawState(tp: TextPaint) { + tp.bgColor = bgColor + tp.typeface = DEFAULT_BOLD + } + } + + companion object { + /** XML element to represent text that has been deleted */ + // Can't be an element that Android's HTML parser recognises, otherwise the tagHandler + // won't be called for it. + const val DELETED_TEXT_EL = "tusky-del" + + /** XML element to represet text that has been inserted */ + // Can't be an element that Android's HTML parser recognises, otherwise the tagHandler + // won't be called for it. + const val INSERTED_TEXT_EL = "tusky-ins" + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsFragment.kt index d02d017d..7404f86a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsFragment.kt @@ -17,49 +17,65 @@ package com.keylesspalace.tusky.components.viewthread.edits import android.os.Bundle import android.util.Log +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem import android.view.View import android.widget.LinearLayout +import androidx.core.view.MenuProvider import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.preference.PreferenceManager import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.SimpleItemAnimator +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener +import com.google.android.material.color.MaterialColors import com.keylesspalace.tusky.BottomSheetActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.StatusListActivity import com.keylesspalace.tusky.components.account.AccountActivity -import com.keylesspalace.tusky.databinding.FragmentViewThreadBinding +import com.keylesspalace.tusky.databinding.FragmentViewEditsBinding import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.interfaces.LinkListener import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.loadAvatar import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.util.unicodeWrap import com.keylesspalace.tusky.util.viewBinding +import com.mikepenz.iconics.IconicsDrawable +import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial +import com.mikepenz.iconics.utils.colorInt +import com.mikepenz.iconics.utils.sizeDp import kotlinx.coroutines.launch -import java.io.IOException import javax.inject.Inject -class ViewEditsFragment : Fragment(R.layout.fragment_view_thread), LinkListener, Injectable { +class ViewEditsFragment : + Fragment(R.layout.fragment_view_edits), + LinkListener, + OnRefreshListener, + MenuProvider, + Injectable { @Inject lateinit var viewModelFactory: ViewModelFactory private val viewModel: ViewEditsViewModel by viewModels { viewModelFactory } - private val binding by viewBinding(FragmentViewThreadBinding::bind) + private val binding by viewBinding(FragmentViewEditsBinding::bind) private lateinit var statusId: String override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) - binding.toolbar.setNavigationOnClickListener { - activity?.onBackPressedDispatcher?.onBackPressed() - } - binding.toolbar.title = getString(R.string.title_edits) - binding.swipeRefreshLayout.isEnabled = false + binding.swipeRefreshLayout.setOnRefreshListener(this) + binding.swipeRefreshLayout.setColorSchemeResources(R.color.chinwag_green) binding.recyclerView.setHasFixedSize(true) binding.recyclerView.layoutManager = LinearLayoutManager(context) @@ -74,6 +90,7 @@ class ViewEditsFragment : Fragment(R.layout.fragment_view_thread), LinkListener, val animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false) val animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) val useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true) + val avatarRadius: Int = requireContext().resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp) viewLifecycleOwner.lifecycleScope.launch { viewModel.uiState.collect { uiState -> @@ -84,24 +101,31 @@ class ViewEditsFragment : Fragment(R.layout.fragment_view_thread), LinkListener, binding.statusView.hide() binding.initialProgressBar.show() } + EditsUiState.Refreshing -> {} is EditsUiState.Error -> { Log.w(TAG, "failed to load edits", uiState.throwable) + binding.swipeRefreshLayout.isRefreshing = false binding.recyclerView.hide() binding.statusView.show() binding.initialProgressBar.hide() - if (uiState.throwable is IOException) { - binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) { - viewModel.loadEdits(statusId, force = true) + when (uiState.throwable) { + is ViewEditsViewModel.MissingEditsException -> { + binding.statusView.setup( + R.drawable.elephant_friend_empty, + R.string.error_missing_edits + ) } - } else { - binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic) { - viewModel.loadEdits(statusId, force = true) + else -> { + binding.statusView.setup(uiState.throwable) { + viewModel.loadEdits(statusId, force = true) + } } } } is EditsUiState.Success -> { + binding.swipeRefreshLayout.isRefreshing = false binding.recyclerView.show() binding.statusView.hide() binding.initialProgressBar.hide() @@ -113,6 +137,15 @@ class ViewEditsFragment : Fragment(R.layout.fragment_view_thread), LinkListener, useBlurhash = useBlurhash, listener = this@ViewEditsFragment ) + + // Focus on the most recent version + (binding.recyclerView.layoutManager as LinearLayoutManager).scrollToPosition(0) + + val account = uiState.edits.first().account + loadAvatar(account.avatar, binding.statusAvatar, avatarRadius, animateAvatars) + + binding.statusDisplayName.text = account.name.unicodeWrap().emojify(account.emojis, binding.statusDisplayName, animateEmojis) + binding.statusUsername.text = account.username } } } @@ -121,6 +154,36 @@ class ViewEditsFragment : Fragment(R.layout.fragment_view_thread), LinkListener, viewModel.loadEdits(statusId) } + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.fragment_view_edits, menu) + menu.findItem(R.id.action_refresh)?.apply { + icon = IconicsDrawable(requireContext(), GoogleMaterial.Icon.gmd_refresh).apply { + sizeDp = 20 + colorInt = MaterialColors.getColor(binding.root, android.R.attr.textColorPrimary) + } + } + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + return when (menuItem.itemId) { + R.id.action_refresh -> { + binding.swipeRefreshLayout.isRefreshing = true + onRefresh() + true + } + else -> false + } + } + + override fun onResume() { + super.onResume() + requireActivity().title = getString(R.string.title_edits) + } + + override fun onRefresh() { + viewModel.loadEdits(statusId, force = true, refreshing = true) + } + override fun onViewAccount(id: String) { bottomSheetActivity?.startActivityWithSlideInAnimation(AccountActivity.getIntent(requireContext(), id)) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsViewModel.kt index a76078ed..93f66358 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsViewModel.kt @@ -17,47 +17,193 @@ package com.keylesspalace.tusky.components.viewthread.edits import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import at.connyduck.calladapter.networkresult.fold +import at.connyduck.calladapter.networkresult.getOrElse +import com.keylesspalace.tusky.components.viewthread.edits.TuskyTagHandler.Companion.DELETED_TEXT_EL +import com.keylesspalace.tusky.components.viewthread.edits.TuskyTagHandler.Companion.INSERTED_TEXT_EL import com.keylesspalace.tusky.entity.StatusEdit import com.keylesspalace.tusky.network.MastodonApi -import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import org.pageseeder.diffx.api.LoadingException +import org.pageseeder.diffx.api.Operator +import org.pageseeder.diffx.config.DiffConfig +import org.pageseeder.diffx.config.TextGranularity +import org.pageseeder.diffx.config.WhiteSpaceProcessing +import org.pageseeder.diffx.core.OptimisticXMLProcessor +import org.pageseeder.diffx.format.XMLDiffOutput +import org.pageseeder.diffx.load.SAXLoader +import org.pageseeder.diffx.token.XMLToken +import org.pageseeder.diffx.token.XMLTokenType +import org.pageseeder.diffx.token.impl.SpaceToken +import org.pageseeder.diffx.xml.NamespaceSet +import org.pageseeder.xmlwriter.XML.NamespaceAware +import org.pageseeder.xmlwriter.XMLStringWriter import javax.inject.Inject -class ViewEditsViewModel @Inject constructor( - private val api: MastodonApi -) : ViewModel() { +class ViewEditsViewModel @Inject constructor(private val api: MastodonApi) : ViewModel() { private val _uiState: MutableStateFlow = MutableStateFlow(EditsUiState.Initial) - val uiState: Flow - get() = _uiState + val uiState: StateFlow = _uiState.asStateFlow() + + /** The API call to fetch edit history returned less than two items */ + object MissingEditsException : Exception() fun loadEdits(statusId: String, force: Boolean = false, refreshing: Boolean = false) { - if (force || _uiState.value is EditsUiState.Initial) { - if (!refreshing) { - _uiState.value = EditsUiState.Loading + if (!force && _uiState.value !is EditsUiState.Initial) return + + if (refreshing) { + _uiState.value = EditsUiState.Refreshing + } else { + _uiState.value = EditsUiState.Loading + } + + viewModelScope.launch { + val edits = api.statusEdits(statusId).getOrElse { + _uiState.value = EditsUiState.Error(it) + return@launch } - viewModelScope.launch { - api.statusEdits(statusId).fold( - { edits -> - val sortedEdits = edits.sortedBy { edit -> edit.createdAt }.reversed() - _uiState.value = EditsUiState.Success(sortedEdits) - }, - { throwable -> - _uiState.value = EditsUiState.Error(throwable) - } + + // `edits` might have fewer than the minimum number of entries because of + // https://github.com/mastodon/mastodon/issues/25398. + if (edits.size < 2) { + _uiState.value = EditsUiState.Error(MissingEditsException) + return@launch + } + + // Diff each status' content against the previous version, producing new + // content with additional `ins` or `del` elements marking inserted or + // deleted content. + // + // This can be CPU intensive depending on the number of edits and the size + // of each, so don't run this on Dispatchers.Main. + viewModelScope.launch(Dispatchers.Default) { + val sortedEdits = edits.sortedBy { it.createdAt } + .reversed() + .toMutableList() + + SAXLoader.setXMLReaderClass("org.xmlpull.v1.sax2.Driver") + val loader = SAXLoader() + loader.config = DiffConfig( + false, + WhiteSpaceProcessing.PRESERVE, + TextGranularity.SPACE_WORD ) + val processor = OptimisticXMLProcessor() + processor.setCoalesce(true) + val output = HtmlDiffOutput() + + try { + // The XML processor expects `br` to be closed + var currentContent = + loader.load(sortedEdits[0].content.replace("
", "
")) + var previousContent = + loader.load(sortedEdits[1].content.replace("
", "
")) + + for (i in 1 until sortedEdits.size) { + processor.diff(previousContent, currentContent, output) + sortedEdits[i - 1] = sortedEdits[i - 1].copy( + content = output.xml.toString() + ) + + if (i < sortedEdits.size - 1) { + currentContent = previousContent + previousContent = loader.load( + sortedEdits[i + 1].content.replace("
", "
") + ) + } + } + _uiState.value = EditsUiState.Success(sortedEdits) + } catch (_: LoadingException) { + // Something failed parsing the XML from the server. Rather than + // show an error just return the sorted edits so the user can at + // least visually scan the differences. + _uiState.value = EditsUiState.Success(sortedEdits) + } } } } + + companion object { + const val TAG = "ViewEditsViewModel" + } } sealed interface EditsUiState { object Initial : EditsUiState object Loading : EditsUiState + + // "Refreshing" state is necessary, otherwise a refresh state transition is Success -> Success, + // and state flows don't emit repeated states, so the UI never updates. + object Refreshing : EditsUiState class Error(val throwable: Throwable) : EditsUiState data class Success( val edits: List ) : EditsUiState } + +/** + * Add elements wrapping inserted or deleted content. + */ +class HtmlDiffOutput : XMLDiffOutput { + /** XML Output */ + lateinit var xml: XMLStringWriter + private set + + override fun start() { + xml = XMLStringWriter(NamespaceAware.Yes) + } + + override fun handle(operator: Operator, token: XMLToken) { + if (operator.isEdit) { + handleEdit(operator, token) + } else { + token.toXML(xml) + } + } + + override fun end() { + xml.flush() + } + + override fun setWriteXMLDeclaration(show: Boolean) { + // This space intentionally left blank + } + + override fun setNamespaces(namespaces: NamespaceSet?) { + // This space intentionally left blank + } + + private fun handleEdit(operator: Operator, token: XMLToken) { + if (token == SpaceToken.NEW_LINE) { + if (operator == Operator.INS) { + token.toXML(xml) + } + return + } + when (token.type) { + XMLTokenType.START_ELEMENT -> token.toXML(xml) + XMLTokenType.END_ELEMENT -> token.toXML(xml) + XMLTokenType.TEXT -> { + // wrap the characters in a element + when (operator) { + Operator.DEL -> DELETED_TEXT_EL + Operator.INS -> INSERTED_TEXT_EL + else -> null + }?.let { + xml.openElement(it, false) + } + token.toXML(xml) + xml.closeElement() + } + else -> { + // Only include inserted content + if (operator === Operator.INS) { + token.toXML(xml) + } + } + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt index 852088f3..cdde765f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt @@ -15,6 +15,7 @@ package com.keylesspalace.tusky.db +import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.Index import androidx.room.PrimaryKey @@ -64,9 +65,25 @@ data class AccountEntity( var alwaysShowSensitiveMedia: Boolean = false, /** True if content behind a content warning is shown by default */ var alwaysOpenSpoiler: Boolean = false, + + /** + * True if the "Download media previews" preference is true. This implies + * that media previews are shown as well as downloaded. + */ var mediaPreviewEnabled: Boolean = true, + /** + * ID of the last notification the user read on the Notification, list, and should be restored + * to view when the user returns to the list. + * + * May not be the ID of the most recent notification if the user has scrolled down the list. + */ var lastNotificationId: String = "0", - var activeNotifications: String = "[]", + /** + * ID of the most recent Mastodon notification that Tusky has fetched to show as an + * Android notification. + */ + @ColumnInfo(defaultValue = "0") + var notificationMarkerId: String = "0", var emojis: List = emptyList(), var tabPreferences: List = defaultTabs(), var notificationsFilter: String = "[\"follow_request\"]", @@ -78,6 +95,12 @@ data class AccountEntity( var pushPrivKey: String = "", var pushAuth: String = "", var pushServerKey: String = "", + + /** + * ID of the status at the top of the visible list in the home timeline when the + * user navigated away. + */ + var lastVisibleHomeTimelineStatusId: String? = null ) { val identifier: String diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt b/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt index bf8d414f..61ce076a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt @@ -37,9 +37,11 @@ class AccountManager @Inject constructor(db: AppDatabase) { @Volatile var activeAccount: AccountEntity? = null + private set var accounts: MutableList = mutableListOf() private set + private val accountDao: AccountDao = db.accountDao() init { @@ -52,7 +54,7 @@ class AccountManager @Inject constructor(db: AppDatabase) { /** * Adds a new account and makes it the active account. * @param accessToken the access token for the new account - * @param domain the domain of the accounts Mastodon instance + * @param domain the domain of the account's Mastodon instance * @param clientId the oauth client id used to sign in the account * @param clientSecret the oauth client secret used to sign in the account * @param oauthScopes the oauth scopes granted to the account @@ -66,7 +68,6 @@ class AccountManager @Inject constructor(db: AppDatabase) { oauthScopes: String, newAccount: Account ) { - activeAccount?.let { it.isActive = false Log.d(TAG, "addAccount: saving account with id " + it.id) @@ -121,7 +122,6 @@ class AccountManager @Inject constructor(db: AppDatabase) { * @return the new active account, or null if no other account was found */ fun logActiveAccountOut(): AccountEntity? { - return activeAccount?.let { account -> account.logout() @@ -153,9 +153,9 @@ class AccountManager @Inject constructor(db: AppDatabase) { it.displayName = account.name it.profilePictureUrl = account.avatar it.defaultPostPrivacy = account.source?.privacy ?: Status.Visibility.PUBLIC - it.defaultPostLanguage = account.source?.language ?: "" + it.defaultPostLanguage = account.source?.language.orEmpty() it.defaultMediaSensitivity = account.source?.sensitive ?: false - it.emojis = account.emojis ?: emptyList() + it.emojis = account.emojis.orEmpty() Log.d(TAG, "updateActiveAccount: saving account with id " + it.id) accountDao.insertOrReplace(it) @@ -167,7 +167,6 @@ class AccountManager @Inject constructor(db: AppDatabase) { * @param accountId the database id of the new active account */ fun setActiveAccount(accountId: Long) { - val newActiveAccount = accounts.find { (id) -> id == accountId } ?: return // invalid accountId passed, do nothing @@ -237,10 +236,12 @@ class AccountManager @Inject constructor(db: AppDatabase) { fun shouldDisplaySelfUsername(context: Context): Boolean { val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) val showUsernamePreference = sharedPreferences.getString(PrefKeys.SHOW_SELF_USERNAME, "disambiguate") - if (showUsernamePreference == "always") + if (showUsernamePreference == "always") { return true - if (showUsernamePreference == "never") + } + if (showUsernamePreference == "never") { return false + } return accounts.size > 1 // "disambiguate" } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java index 7e3ecf73..a5127354 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java +++ b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java @@ -16,8 +16,12 @@ package com.keylesspalace.tusky.db; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.room.AutoMigration; import androidx.room.Database; +import androidx.room.DeleteColumn; import androidx.room.RoomDatabase; +import androidx.room.migration.AutoMigrationSpec; import androidx.room.migration.Migration; import androidx.sqlite.db.SupportSQLiteDatabase; @@ -29,16 +33,29 @@ import java.io.File; /** * DB version & declare DAO */ -@Database(entities = { DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class, - TimelineAccountEntity.class, ConversationEntity.class - }, version = 47) +@Database( + entities = { + DraftEntity.class, + AccountEntity.class, + InstanceEntity.class, + TimelineStatusEntity.class, + TimelineAccountEntity.class, + ConversationEntity.class + }, + version = 51, + autoMigrations = { + @AutoMigration(from = 48, to = 49), + @AutoMigration(from = 49, to = 50, spec = AppDatabase.MIGRATION_49_50.class), + @AutoMigration(from = 50, to = 51) + } +) public abstract class AppDatabase extends RoomDatabase { - public abstract AccountDao accountDao(); - public abstract InstanceDao instanceDao(); - public abstract ConversationsDao conversationDao(); - public abstract TimelineDao timelineDao(); - public abstract DraftDao draftDao(); + @NonNull public abstract AccountDao accountDao(); + @NonNull public abstract InstanceDao instanceDao(); + @NonNull public abstract ConversationsDao conversationDao(); + @NonNull public abstract TimelineDao timelineDao(); + @NonNull public abstract DraftDao draftDao(); public static final Migration MIGRATION_2_3 = new Migration(2, 3) { @Override @@ -339,7 +356,7 @@ public abstract class AppDatabase extends RoomDatabase { database.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `muted` INTEGER"); } }; - + public static final Migration MIGRATION_23_24 = new Migration(23, 24) { @Override public void migrate(@NonNull SupportSQLiteDatabase database) { @@ -370,7 +387,7 @@ public abstract class AppDatabase extends RoomDatabase { private final File oldDraftDirectory; - public Migration25_26(File oldDraftDirectory) { + public Migration25_26(@Nullable File oldDraftDirectory) { super(25, 26); this.oldDraftDirectory = oldDraftDirectory; } @@ -646,4 +663,14 @@ public abstract class AppDatabase extends RoomDatabase { database.execSQL("ALTER TABLE `DraftEntity` ADD COLUMN `failedToSendNew` INTEGER NOT NULL DEFAULT 0"); } }; + + public static final Migration MIGRATION_47_48 = new Migration(47, 48) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `filtered` TEXT"); + } + }; + + @DeleteColumn(tableName = "AccountEntity", columnName = "activeNotifications") + static class MIGRATION_49_50 implements AutoMigrationSpec { } } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt b/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt index d211eb49..491cd53d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt @@ -24,6 +24,7 @@ import com.keylesspalace.tusky.components.conversation.ConversationAccountEntity import com.keylesspalace.tusky.createTabDataFromId import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Emoji +import com.keylesspalace.tusky.entity.FilterResult import com.keylesspalace.tusky.entity.HashTag import com.keylesspalace.tusky.entity.NewPoll import com.keylesspalace.tusky.entity.Poll @@ -36,7 +37,7 @@ import javax.inject.Singleton @ProvidedTypeConverter @Singleton -class Converters @Inject constructor ( +class Converters @Inject constructor( private val gson: Gson ) { @@ -101,8 +102,8 @@ class Converters @Inject constructor ( } @TypeConverter - fun jsonToAttachmentList(attachmentListJson: String?): ArrayList? { - return gson.fromJson(attachmentListJson, object : TypeToken>() {}.type) + fun jsonToAttachmentList(attachmentListJson: String?): List? { + return gson.fromJson(attachmentListJson, object : TypeToken>() {}.type) } @TypeConverter @@ -164,4 +165,14 @@ class Converters @Inject constructor ( fun jsonToDraftAttachmentList(draftAttachmentListJson: String?): List? { return gson.fromJson(draftAttachmentListJson, object : TypeToken>() {}.type) } + + @TypeConverter + fun filterResultListToJson(filterResults: List?): String? { + return gson.toJson(filterResults) + } + + @TypeConverter + fun jsonToFilterResultList(filterResultListJson: String?): List? { + return gson.fromJson(filterResultListJson, object : TypeToken>() {}.type) + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/DraftEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/DraftEntity.kt index 0b38385a..2a479ab3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/DraftEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/DraftEntity.kt @@ -43,7 +43,7 @@ data class DraftEntity( val failedToSendNew: Boolean, val scheduledAt: String?, val language: String?, - val statusId: String?, + val statusId: String? ) /** diff --git a/app/src/main/java/com/keylesspalace/tusky/db/DraftsAlert.kt b/app/src/main/java/com/keylesspalace/tusky/db/DraftsAlert.kt index 917305d1..9d11f47d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/DraftsAlert.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/DraftsAlert.kt @@ -44,7 +44,7 @@ class DraftsAlert @Inject constructor(db: AppDatabase) { @Inject lateinit var accountManager: AccountManager - public fun observeInContext(context: T, showAlert: Boolean) where T : Context, T : LifecycleOwner { + fun observeInContext(context: T, showAlert: Boolean) where T : Context, T : LifecycleOwner { accountManager.activeAccount?.let { activeAccount -> val coroutineScope = context.lifecycleScope @@ -63,7 +63,7 @@ class DraftsAlert @Inject constructor(db: AppDatabase) { AlertDialog.Builder(context) .setTitle(R.string.action_post_failed) .setMessage( - context.getResources().getQuantityString(R.plurals.action_post_failed_detail, count) + context.resources.getQuantityString(R.plurals.action_post_failed_detail, count) ) .setPositiveButton(R.string.action_post_failed_show_drafts) { _: DialogInterface?, _: Int -> clearDraftsAlert(coroutineScope, activeAccountId) // User looked at drafts @@ -78,7 +78,7 @@ class DraftsAlert @Inject constructor(db: AppDatabase) { } } } else { - draftsNeedUserAlert.observe(context) { _ -> + draftsNeedUserAlert.observe(context) { Log.d(TAG, "User id $activeAccountId: Clean out notification-worthy drafts") clearDraftsAlert(coroutineScope, activeAccountId) } @@ -91,7 +91,7 @@ class DraftsAlert @Inject constructor(db: AppDatabase) { /** * Clear drafts alert for specified user */ - fun clearDraftsAlert(coroutineScope: LifecycleCoroutineScope, id: Long) { + private fun clearDraftsAlert(coroutineScope: LifecycleCoroutineScope, id: Long) { coroutineScope.launch { draftDao.draftsClearNeedUserAlert(id) } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/InstanceDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/InstanceDao.kt index 0bf1dc32..317c577e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/InstanceDao.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/InstanceDao.kt @@ -16,41 +16,18 @@ package com.keylesspalace.tusky.db import androidx.room.Dao -import androidx.room.Insert -import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.RewriteQueriesToDropUnusedColumns -import androidx.room.Transaction -import androidx.room.Update +import androidx.room.Upsert @Dao interface InstanceDao { - @Insert(onConflict = OnConflictStrategy.IGNORE, entity = InstanceEntity::class) - suspend fun insertOrIgnore(instance: InstanceInfoEntity): Long + @Upsert(entity = InstanceEntity::class) + suspend fun upsert(instance: InstanceInfoEntity) - @Update(onConflict = OnConflictStrategy.IGNORE, entity = InstanceEntity::class) - suspend fun updateOrIgnore(instance: InstanceInfoEntity) - - @Transaction - suspend fun upsert(instance: InstanceInfoEntity) { - if (insertOrIgnore(instance) == -1L) { - updateOrIgnore(instance) - } - } - - @Insert(onConflict = OnConflictStrategy.IGNORE, entity = InstanceEntity::class) - suspend fun insertOrIgnore(emojis: EmojisEntity): Long - - @Update(onConflict = OnConflictStrategy.IGNORE, entity = InstanceEntity::class) - suspend fun updateOrIgnore(emojis: EmojisEntity) - - @Transaction - suspend fun upsert(emojis: EmojisEntity) { - if (insertOrIgnore(emojis) == -1L) { - updateOrIgnore(emojis) - } - } + @Upsert(entity = InstanceEntity::class) + suspend fun upsert(emojis: EmojisEntity) @RewriteQueriesToDropUnusedColumns @Query("SELECT * FROM InstanceEntity WHERE instance = :instance LIMIT 1") diff --git a/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt index ddf0c955..0bf1267f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt @@ -18,7 +18,7 @@ package com.keylesspalace.tusky.db import androidx.paging.PagingSource import androidx.room.Dao import androidx.room.Insert -import androidx.room.OnConflictStrategy.REPLACE +import androidx.room.OnConflictStrategy.Companion.REPLACE import androidx.room.Query @Dao @@ -36,7 +36,7 @@ SELECT s.serverId, s.url, s.timelineUserId, s.authorServerId, s.inReplyToId, s.inReplyToAccountId, s.createdAt, s.editedAt, s.emojis, s.reblogsCount, s.favouritesCount, s.repliesCount, s.reblogged, s.favourited, s.bookmarked, s.sensitive, s.spoilerText, s.visibility, s.mentions, s.tags, s.application, s.reblogServerId,s.reblogAccountId, -s.content, s.attachments, s.poll, s.card, s.muted, s.expanded, s.contentShowing, s.contentCollapsed, s.pinned, s.language, +s.content, s.attachments, s.poll, s.card, s.muted, s.expanded, s.contentShowing, s.contentCollapsed, s.pinned, s.language, s.filtered, a.serverId as 'a_serverId', a.timelineUserId as 'a_timelineUserId', a.localUsername as 'a_localUsername', a.username as 'a_username', a.displayName as 'a_displayName', a.url as 'a_url', a.avatar as 'a_avatar', @@ -59,7 +59,7 @@ SELECT s.serverId, s.url, s.timelineUserId, s.authorServerId, s.inReplyToId, s.inReplyToAccountId, s.createdAt, s.editedAt, s.emojis, s.reblogsCount, s.favouritesCount, s.repliesCount, s.reblogged, s.favourited, s.bookmarked, s.sensitive, s.spoilerText, s.visibility, s.mentions, s.tags, s.application, s.reblogServerId,s.reblogAccountId, -s.content, s.attachments, s.poll, s.card, s.muted, s.expanded, s.contentShowing, s.contentCollapsed, s.pinned, s.language, +s.content, s.attachments, s.poll, s.card, s.muted, s.expanded, s.contentShowing, s.contentCollapsed, s.pinned, s.language, s.filtered, a.serverId as 'a_serverId', a.timelineUserId as 'a_timelineUserId', a.localUsername as 'a_localUsername', a.username as 'a_username', a.displayName as 'a_displayName', a.url as 'a_url', a.avatar as 'a_avatar', @@ -71,7 +71,7 @@ rb.emojis as 'rb_emojis', rb.bot as 'rb_bot' FROM TimelineStatusEntity s LEFT JOIN TimelineAccountEntity a ON (s.timelineUserId = a.timelineUserId AND s.authorServerId = a.serverId) LEFT JOIN TimelineAccountEntity rb ON (s.timelineUserId = rb.timelineUserId AND s.reblogAccountId = rb.serverId) -WHERE (s.serverId = :statusId OR s.reblogServerId = :statusId) +WHERE (s.serverId = :statusId OR s.reblogServerId = :statusId) AND s.authorServerId IS NOT NULL""" ) abstract suspend fun getStatus(statusId: String): TimelineStatusWithAccount? @@ -89,25 +89,25 @@ AND """UPDATE TimelineStatusEntity SET favourited = :favourited WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""" ) - abstract fun setFavourited(accountId: Long, statusId: String, favourited: Boolean) + abstract suspend fun setFavourited(accountId: Long, statusId: String, favourited: Boolean) @Query( """UPDATE TimelineStatusEntity SET bookmarked = :bookmarked WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""" ) - abstract fun setBookmarked(accountId: Long, statusId: String, bookmarked: Boolean) + abstract suspend fun setBookmarked(accountId: Long, statusId: String, bookmarked: Boolean) @Query( """UPDATE TimelineStatusEntity SET reblogged = :reblogged WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""" ) - abstract fun setReblogged(accountId: Long, statusId: String, reblogged: Boolean) + abstract suspend fun setReblogged(accountId: Long, statusId: String, reblogged: Boolean) @Query( """DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND (authorServerId = :userId OR reblogAccountId = :userId)""" ) - abstract fun removeAllByUser(accountId: Long, userId: String) + abstract suspend fun removeAllByUser(accountId: Long, userId: String) /** * Removes everything in the TimelineStatusEntity and TimelineAccountEntity tables for one user account @@ -128,7 +128,7 @@ WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = """DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND serverId = :statusId""" ) - abstract fun delete(accountId: Long, statusId: String) + abstract suspend fun delete(accountId: Long, statusId: String) /** * Cleans the TimelineStatusEntity and TimelineAccountEntity tables from old entries. @@ -158,8 +158,8 @@ AND serverId = :statusId""" */ @Query( """DELETE FROM TimelineAccountEntity WHERE timelineUserId = :accountId AND serverId NOT IN - (SELECT authorServerId FROM TimelineStatusEntity WHERE timelineUserId = :accountId) - AND serverId NOT IN + (SELECT authorServerId FROM TimelineStatusEntity WHERE timelineUserId = :accountId) + AND serverId NOT IN (SELECT reblogAccountId FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND reblogAccountId IS NOT NULL)""" ) abstract suspend fun cleanupAccounts(accountId: Long) @@ -168,31 +168,31 @@ AND serverId = :statusId""" """UPDATE TimelineStatusEntity SET poll = :poll WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""" ) - abstract fun setVoted(accountId: Long, statusId: String, poll: String) + abstract suspend fun setVoted(accountId: Long, statusId: String, poll: String) @Query( """UPDATE TimelineStatusEntity SET expanded = :expanded WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""" ) - abstract fun setExpanded(accountId: Long, statusId: String, expanded: Boolean) + abstract suspend fun setExpanded(accountId: Long, statusId: String, expanded: Boolean) @Query( """UPDATE TimelineStatusEntity SET contentShowing = :contentShowing WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""" ) - abstract fun setContentShowing(accountId: Long, statusId: String, contentShowing: Boolean) + abstract suspend fun setContentShowing(accountId: Long, statusId: String, contentShowing: Boolean) @Query( """UPDATE TimelineStatusEntity SET contentCollapsed = :contentCollapsed WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""" ) - abstract fun setContentCollapsed(accountId: Long, statusId: String, contentCollapsed: Boolean) + abstract suspend fun setContentCollapsed(accountId: Long, statusId: String, contentCollapsed: Boolean) @Query( """UPDATE TimelineStatusEntity SET pinned = :pinned WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""" ) - abstract fun setPinned(accountId: Long, statusId: String, pinned: Boolean) + abstract suspend fun setPinned(accountId: Long, statusId: String, pinned: Boolean) @Query( """DELETE FROM TimelineStatusEntity @@ -203,6 +203,9 @@ AND timelineUserId = :accountId ) abstract suspend fun deleteAllFromInstance(accountId: Long, instanceDomain: String) + @Query("UPDATE TimelineStatusEntity SET filtered = NULL WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)") + abstract suspend fun clearWarning(accountId: Long, statusId: String): Int + @Query("SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT 1") abstract suspend fun getTopId(accountId: Long): String? diff --git a/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt index 3bc4bc7d..f0f7f98e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt @@ -20,6 +20,7 @@ import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Index import androidx.room.TypeConverters +import com.keylesspalace.tusky.entity.FilterResult import com.keylesspalace.tusky.entity.Status /** @@ -84,6 +85,7 @@ data class TimelineStatusEntity( val pinned: Boolean, val card: String?, val language: String?, + val filtered: List? ) { val isPlaceholder: Boolean get() = this.authorServerId == null @@ -104,11 +106,11 @@ data class TimelineAccountEntity( val bot: Boolean ) -class TimelineStatusWithAccount { +data class TimelineStatusWithAccount( @Embedded - lateinit var status: TimelineStatusEntity + val status: TimelineStatusEntity, @Embedded(prefix = "a_") - lateinit var account: TimelineAccountEntity + val account: TimelineAccountEntity? = null, // null when placeholder @Embedded(prefix = "rb_") - var reblogAccount: TimelineAccountEntity? = null -} + val reblogAccount: TimelineAccountEntity? = null // null when no reblog +) diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt index fbd12d77..2ceb9721 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt @@ -16,10 +16,8 @@ package com.keylesspalace.tusky.di import com.keylesspalace.tusky.AboutActivity -import com.keylesspalace.tusky.AccountListActivity import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.EditProfileActivity -import com.keylesspalace.tusky.FiltersActivity import com.keylesspalace.tusky.LicenseActivity import com.keylesspalace.tusky.ListsActivity import com.keylesspalace.tusky.MainActivity @@ -28,9 +26,12 @@ import com.keylesspalace.tusky.StatusListActivity import com.keylesspalace.tusky.TabPreferenceActivity import com.keylesspalace.tusky.ViewMediaActivity import com.keylesspalace.tusky.components.account.AccountActivity +import com.keylesspalace.tusky.components.accountlist.AccountListActivity import com.keylesspalace.tusky.components.announcements.AnnouncementsActivity import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.components.drafts.DraftsActivity +import com.keylesspalace.tusky.components.filters.EditFilterActivity +import com.keylesspalace.tusky.components.filters.FiltersActivity import com.keylesspalace.tusky.components.followedtags.FollowedTagsActivity import com.keylesspalace.tusky.components.instancemute.InstanceListActivity import com.keylesspalace.tusky.components.login.LoginActivity @@ -39,6 +40,7 @@ import com.keylesspalace.tusky.components.preference.PreferencesActivity import com.keylesspalace.tusky.components.report.ReportActivity import com.keylesspalace.tusky.components.scheduled.ScheduledStatusActivity import com.keylesspalace.tusky.components.search.SearchActivity +import com.keylesspalace.tusky.components.trending.TrendingActivity import com.keylesspalace.tusky.components.viewthread.ViewThreadActivity import dagger.Module import dagger.android.ContributesAndroidInjector @@ -124,4 +126,10 @@ abstract class ActivitiesModule { @ContributesAndroidInjector abstract fun contributesSplashActivity(): SplashActivity + + @ContributesAndroidInjector(modules = [FragmentBuildersModule::class]) + abstract fun contributesTrendingActivity(): TrendingActivity + + @ContributesAndroidInjector + abstract fun contributesEditFilterActivity(): EditFilterActivity } diff --git a/app/src/main/java/com/keylesspalace/tusky/di/AppComponent.kt b/app/src/main/java/com/keylesspalace/tusky/di/AppComponent.kt index 2cf48046..d922ab37 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/AppComponent.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/AppComponent.kt @@ -29,12 +29,14 @@ import javax.inject.Singleton @Component( modules = [ AppModule::class, + CoroutineScopeModule::class, NetworkModule::class, AndroidSupportInjectionModule::class, ActivitiesModule::class, ServicesModule::class, BroadcastReceiverModule::class, - ViewModelModule::class + ViewModelModule::class, + WorkerModule::class ] ) interface AppComponent { diff --git a/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt index 3dacc2d6..bc2c7d75 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt @@ -67,7 +67,8 @@ class AppModule { AppDatabase.MIGRATION_35_36, AppDatabase.MIGRATION_36_37, AppDatabase.MIGRATION_37_38, AppDatabase.MIGRATION_38_39, AppDatabase.MIGRATION_39_40, AppDatabase.MIGRATION_40_41, AppDatabase.MIGRATION_41_42, AppDatabase.MIGRATION_42_43, AppDatabase.MIGRATION_43_44, - AppDatabase.MIGRATION_44_45, AppDatabase.MIGRATION_45_46, AppDatabase.MIGRATION_46_47 + AppDatabase.MIGRATION_44_45, AppDatabase.MIGRATION_45_46, AppDatabase.MIGRATION_46_47, + AppDatabase.MIGRATION_47_48 ) .build() } diff --git a/app/src/main/java/com/keylesspalace/tusky/di/BroadcastReceiverModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/BroadcastReceiverModule.kt index e071fc84..82c83e1d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/BroadcastReceiverModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/BroadcastReceiverModule.kt @@ -17,7 +17,6 @@ package com.keylesspalace.tusky.di import com.keylesspalace.tusky.receiver.NotificationBlockStateBroadcastReceiver -import com.keylesspalace.tusky.receiver.NotificationClearBroadcastReceiver import com.keylesspalace.tusky.receiver.SendStatusBroadcastReceiver import com.keylesspalace.tusky.receiver.UnifiedPushBroadcastReceiver import dagger.Module @@ -28,9 +27,6 @@ abstract class BroadcastReceiverModule { @ContributesAndroidInjector abstract fun contributeSendStatusBroadcastReceiver(): SendStatusBroadcastReceiver - @ContributesAndroidInjector - abstract fun contributeNotificationClearBroadcastReceiver(): NotificationClearBroadcastReceiver - @ContributesAndroidInjector abstract fun contributeUnifiedPushBroadcastReceiver(): UnifiedPushBroadcastReceiver diff --git a/app/src/main/java/com/keylesspalace/tusky/di/CoroutineScopeModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/CoroutineScopeModule.kt new file mode 100644 index 00000000..bee62f7e --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/di/CoroutineScopeModule.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.di + +import dagger.Module +import dagger.Provides +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import javax.inject.Qualifier + +/** + * Scope for potentially long-running tasks that should outlive the viewmodel that + * started them. For example, if the API call to bookmark a status is taking a long + * time, that call should not be cancelled because the user has navigated away from + * the viewmodel that made the call. + * + * @see https://developer.android.com/topic/architecture/data-layer#make_an_operation_live_longer_than_the_screen + */ +@Retention(AnnotationRetention.BINARY) +@Qualifier +annotation class ApplicationScope + +@Module +class CoroutineScopeModule { + @ApplicationScope + @Provides + fun providesApplicationScope() = CoroutineScope(SupervisorJob() + Dispatchers.Default) +} diff --git a/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt index bc202f14..aee1feab 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt @@ -18,8 +18,10 @@ package com.keylesspalace.tusky.di import com.keylesspalace.tusky.AccountsInListFragment import com.keylesspalace.tusky.components.account.list.ListsForAccountFragment import com.keylesspalace.tusky.components.account.media.AccountMediaFragment +import com.keylesspalace.tusky.components.accountlist.AccountListFragment import com.keylesspalace.tusky.components.conversation.ConversationsFragment import com.keylesspalace.tusky.components.instancemute.fragment.InstanceListFragment +import com.keylesspalace.tusky.components.notifications.NotificationsFragment import com.keylesspalace.tusky.components.preference.AccountPreferencesFragment import com.keylesspalace.tusky.components.preference.NotificationPreferencesFragment import com.keylesspalace.tusky.components.preference.PreferencesFragment @@ -30,10 +32,9 @@ import com.keylesspalace.tusky.components.search.fragments.SearchAccountsFragmen import com.keylesspalace.tusky.components.search.fragments.SearchHashtagsFragment import com.keylesspalace.tusky.components.search.fragments.SearchStatusesFragment import com.keylesspalace.tusky.components.timeline.TimelineFragment +import com.keylesspalace.tusky.components.trending.TrendingFragment import com.keylesspalace.tusky.components.viewthread.ViewThreadFragment import com.keylesspalace.tusky.components.viewthread.edits.ViewEditsFragment -import com.keylesspalace.tusky.fragment.AccountListFragment -import com.keylesspalace.tusky.fragment.NotificationsFragment import dagger.Module import dagger.android.ContributesAndroidInjector @@ -99,4 +100,7 @@ abstract class FragmentBuildersModule { @ContributesAndroidInjector abstract fun listsForAccountFragment(): ListsForAccountFragment + + @ContributesAndroidInjector + abstract fun trendingFragment(): TrendingFragment } diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt index aab1fa3d..af1972d5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt @@ -1,3 +1,20 @@ +/* + * 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 . + */ + // from https://proandroiddev.com/viewmodel-with-dagger2-architecture-components-2e06f06c9455 package com.keylesspalace.tusky.di @@ -11,13 +28,17 @@ import com.keylesspalace.tusky.components.announcements.AnnouncementsViewModel import com.keylesspalace.tusky.components.compose.ComposeViewModel import com.keylesspalace.tusky.components.conversation.ConversationsViewModel import com.keylesspalace.tusky.components.drafts.DraftsViewModel +import com.keylesspalace.tusky.components.filters.EditFilterViewModel +import com.keylesspalace.tusky.components.filters.FiltersViewModel import com.keylesspalace.tusky.components.followedtags.FollowedTagsViewModel import com.keylesspalace.tusky.components.login.LoginWebViewViewModel +import com.keylesspalace.tusky.components.notifications.NotificationsViewModel import com.keylesspalace.tusky.components.report.ReportViewModel import com.keylesspalace.tusky.components.scheduled.ScheduledStatusViewModel import com.keylesspalace.tusky.components.search.SearchViewModel import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineViewModel import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel +import com.keylesspalace.tusky.components.trending.viewmodel.TrendingViewModel import com.keylesspalace.tusky.components.viewthread.ViewThreadViewModel import com.keylesspalace.tusky.components.viewthread.edits.ViewEditsViewModel import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel @@ -39,7 +60,7 @@ class ViewModelFactory @Inject constructor(private val viewModels: MutableMap) @@ -144,5 +165,25 @@ abstract class ViewModelModule { @ViewModelKey(ListsForAccountViewModel::class) internal abstract fun listsForAccountViewModel(viewModel: ListsForAccountViewModel): ViewModel + @Binds + @IntoMap + @ViewModelKey(NotificationsViewModel::class) + internal abstract fun notificationsViewModel(viewModel: NotificationsViewModel): ViewModel + + @Binds + @IntoMap + @ViewModelKey(TrendingViewModel::class) + internal abstract fun trendingViewModel(viewModel: TrendingViewModel): ViewModel + + @Binds + @IntoMap + @ViewModelKey(FiltersViewModel::class) + internal abstract fun filtersViewModel(viewModel: FiltersViewModel): ViewModel + + @Binds + @IntoMap + @ViewModelKey(EditFilterViewModel::class) + internal abstract fun editFilterViewModel(viewModel: EditFilterViewModel): ViewModel + // Add more ViewModels here } diff --git a/app/src/main/java/com/keylesspalace/tusky/di/WorkerModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/WorkerModule.kt new file mode 100644 index 00000000..212d4d31 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/di/WorkerModule.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.di + +import androidx.work.ListenableWorker +import com.keylesspalace.tusky.worker.ChildWorkerFactory +import com.keylesspalace.tusky.worker.NotificationWorker +import com.keylesspalace.tusky.worker.PruneCacheWorker +import dagger.Binds +import dagger.MapKey +import dagger.Module +import dagger.multibindings.IntoMap +import kotlin.reflect.KClass + +@Retention(AnnotationRetention.RUNTIME) +@MapKey +annotation class WorkerKey(val value: KClass) + +@Module +abstract class WorkerModule { + @Binds + @IntoMap + @WorkerKey(NotificationWorker::class) + internal abstract fun bindNotificationWorkerFactory(worker: NotificationWorker.Factory): ChildWorkerFactory + + @Binds + @IntoMap + @WorkerKey(PruneCacheWorker::class) + internal abstract fun bindPruneCacheWorkerFactory(worker: PruneCacheWorker.Factory): ChildWorkerFactory +} diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt index fdad077a..43b9bd9c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt @@ -43,7 +43,9 @@ data class Account( val name: String get() = if (displayName.isNullOrEmpty()) { localUsername - } else displayName + } else { + displayName + } fun isRemote(): Boolean = this.username != this.localUsername } @@ -53,7 +55,7 @@ data class AccountSource( val sensitive: Boolean?, val note: String?, val fields: List?, - val language: String?, + val language: String? ) data class Field( diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Attachment.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Attachment.kt index c1368325..0ebd9c37 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Attachment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Attachment.kt @@ -39,12 +39,16 @@ data class Attachment( enum class Type { @SerializedName("image") IMAGE, + @SerializedName("gifv") GIFV, + @SerializedName("video") VIDEO, + @SerializedName("audio") AUDIO, + @SerializedName("unknown") UNKNOWN } @@ -70,7 +74,7 @@ data class Attachment( val focus: Focus?, val duration: Float?, val original: Size?, - val small: Size?, + val small: Size? ) : Parcelable /** @@ -83,7 +87,9 @@ data class Attachment( data class Focus( val x: Float, val y: Float - ) : Parcelable + ) : Parcelable { + fun toMastodonApiString(): String = "$x,$y" + } /** * The size of an image, used to specify the width/height. diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/DeletedStatus.kt b/app/src/main/java/com/keylesspalace/tusky/entity/DeletedStatus.kt index a653cc58..c400a1af 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/DeletedStatus.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/DeletedStatus.kt @@ -16,7 +16,6 @@ package com.keylesspalace.tusky.entity import com.google.gson.annotations.SerializedName -import java.util.ArrayList import java.util.Date data class DeletedStatus( @@ -25,10 +24,10 @@ data class DeletedStatus( @SerializedName("spoiler_text") val spoilerText: String, val visibility: Status.Visibility, val sensitive: Boolean, - @SerializedName("media_attachments") val attachments: ArrayList?, + @SerializedName("media_attachments") val attachments: List?, val poll: Poll?, @SerializedName("created_at") val createdAt: Date, - val language: String?, + val language: String? ) { fun isEmpty(): Boolean { return text == null && attachments == null diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/LoadingFooterViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Error.kt similarity index 70% rename from app/src/main/java/com/keylesspalace/tusky/adapter/LoadingFooterViewHolder.kt rename to app/src/main/java/com/keylesspalace/tusky/entity/Error.kt index 6d5ddbd8..f78cafac 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/LoadingFooterViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Error.kt @@ -1,4 +1,5 @@ -/* Copyright 2018 Conny Duck +/* + * Copyright 2023 Tusky Contributors * * This file is a part of Tusky. * @@ -11,11 +12,13 @@ * Public License for more details. * * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . */ + * see . + */ -package com.keylesspalace.tusky.adapter +package com.keylesspalace.tusky.entity -import android.view.View -import androidx.recyclerview.widget.RecyclerView - -class LoadingFooterViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) +/** @see [Error](https://docs.joinmastodon.org/entities/Error/) */ +data class Error( + val error: String, + val error_description: String? +) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Filter.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Filter.kt index af51a04b..236e216e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Filter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Filter.kt @@ -1,48 +1,44 @@ -/* Copyright 2018 Levi Bard - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . */ - package com.keylesspalace.tusky.entity +import android.os.Parcelable import com.google.gson.annotations.SerializedName +import kotlinx.parcelize.Parcelize import java.util.Date +@Parcelize data class Filter( val id: String, - val phrase: String, + val title: String, val context: List, @SerializedName("expires_at") val expiresAt: Date?, - val irreversible: Boolean, - @SerializedName("whole_word") val wholeWord: Boolean -) { - companion object { - const val HOME = "home" - const val NOTIFICATIONS = "notifications" - const val PUBLIC = "public" - const val THREAD = "thread" - const val ACCOUNT = "account" - } + @SerializedName("filter_action") private val filterAction: String, + val keywords: List + // val statuses: List, +) : Parcelable { + enum class Action(val action: String) { + NONE("none"), + WARN("warn"), + HIDE("hide"); - override fun hashCode(): Int { - return id.hashCode() - } - - override fun equals(other: Any?): Boolean { - if (other !is Filter) { - return false + companion object { + fun from(action: String): Action = values().firstOrNull { it.action == action } ?: WARN } - val filter = other as Filter? - return filter?.id.equals(id) } + enum class Kind(val kind: String) { + HOME("home"), + NOTIFICATIONS("notifications"), + PUBLIC("public"), + THREAD("thread"), + ACCOUNT("account"); + + companion object { + fun from(kind: String): Kind = values().firstOrNull { it.kind == kind } ?: PUBLIC + } + } + + val action: Action + get() = Action.from(filterAction) + + val kinds: List + get() = context.map { Kind.from(it) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/FilterKeyword.kt b/app/src/main/java/com/keylesspalace/tusky/entity/FilterKeyword.kt new file mode 100644 index 00000000..c62ac409 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/FilterKeyword.kt @@ -0,0 +1,12 @@ +package com.keylesspalace.tusky.entity + +import android.os.Parcelable +import com.google.gson.annotations.SerializedName +import kotlinx.parcelize.Parcelize + +@Parcelize +data class FilterKeyword( + val id: String, + val keyword: String, + @SerializedName("whole_word") val wholeWord: Boolean +) : Parcelable diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/FilterResult.kt b/app/src/main/java/com/keylesspalace/tusky/entity/FilterResult.kt new file mode 100644 index 00000000..f51af22f --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/FilterResult.kt @@ -0,0 +1,9 @@ +package com.keylesspalace.tusky.entity + +import com.google.gson.annotations.SerializedName + +data class FilterResult( + val filter: Filter, + @SerializedName("keyword_matches") val keywordMatches: List?, + @SerializedName("status_matches") val statusMatches: List? +) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/FilterV1.kt b/app/src/main/java/com/keylesspalace/tusky/entity/FilterV1.kt new file mode 100644 index 00000000..a93ccff5 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/FilterV1.kt @@ -0,0 +1,65 @@ +/* Copyright 2018 Levi Bard + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.entity + +import com.google.gson.annotations.SerializedName +import java.util.Date + +data class FilterV1( + val id: String, + val phrase: String, + val context: List, + @SerializedName("expires_at") val expiresAt: Date?, + val irreversible: Boolean, + @SerializedName("whole_word") val wholeWord: Boolean +) { + companion object { + const val HOME = "home" + const val NOTIFICATIONS = "notifications" + const val PUBLIC = "public" + const val THREAD = "thread" + const val ACCOUNT = "account" + } + + override fun hashCode(): Int { + return id.hashCode() + } + + override fun equals(other: Any?): Boolean { + if (other !is FilterV1) { + return false + } + val filter = other as FilterV1? + return filter?.id.equals(id) + } + + fun toFilter(): Filter { + return Filter( + id = id, + title = phrase, + context = context, + expiresAt = expiresAt, + filterAction = Filter.Action.WARN.action, + keywords = listOf( + FilterKeyword( + id = id, + keyword = phrase, + wholeWord = wholeWord + ) + ) + ) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt index 67ae8912..77864cfe 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt @@ -54,19 +54,19 @@ data class PollConfiguration( @SerializedName("max_option_chars") val maxOptionChars: Int?, @SerializedName("max_characters_per_option") val maxCharactersPerOption: Int?, @SerializedName("min_expiration") val minExpiration: Int?, - @SerializedName("max_expiration") val maxExpiration: Int?, + @SerializedName("max_expiration") val maxExpiration: Int? ) data class InstanceConfiguration( val statuses: StatusConfiguration?, @SerializedName("media_attachments") val mediaAttachments: MediaAttachmentConfiguration?, - val polls: PollConfiguration?, + val polls: PollConfiguration? ) data class StatusConfiguration( @SerializedName("max_characters") val maxCharacters: Int?, @SerializedName("max_media_attachments") val maxMediaAttachments: Int?, - @SerializedName("characters_reserved_per_url") val charactersReservedPerUrl: Int?, + @SerializedName("characters_reserved_per_url") val charactersReservedPerUrl: Int? ) data class MediaAttachmentConfiguration( @@ -75,7 +75,7 @@ data class MediaAttachmentConfiguration( @SerializedName("image_matrix_limit") val imageMatrixLimit: Int?, @SerializedName("video_size_limit") val videoSizeLimit: Int?, @SerializedName("video_frame_rate_limit") val videoFrameRateLimit: Int?, - @SerializedName("video_matrix_limit") val videoMatrixLimit: Int?, + @SerializedName("video_matrix_limit") val videoMatrixLimit: Int? ) data class PleromaConfiguration( diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/NewStatus.kt b/app/src/main/java/com/keylesspalace/tusky/entity/NewStatus.kt index d11ad5f7..1a353ead 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/NewStatus.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/NewStatus.kt @@ -26,9 +26,10 @@ data class NewStatus( val visibility: String, val sensitive: Boolean, @SerializedName("media_ids") val mediaIds: List?, + @SerializedName("media_attributes") val mediaAttributes: List?, @SerializedName("scheduled_at") val scheduledAt: String?, val poll: NewPoll?, - val language: String?, + val language: String? ) @Parcelize @@ -37,3 +38,13 @@ data class NewPoll( @SerializedName("expires_in") val expiresIn: Int, val multiple: Boolean ) : Parcelable + +// It would be nice if we could reuse MediaToSend, +// but the server requires a different format for focus +@Parcelize +data class MediaAttribute( + val id: String, + val description: String?, + val focus: String?, + val thumbnail: String? +) : Parcelable diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt index b058c4c1..a7007e57 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt @@ -15,46 +15,70 @@ package com.keylesspalace.tusky.entity +import androidx.annotation.StringRes import com.google.gson.JsonDeserializationContext import com.google.gson.JsonDeserializer import com.google.gson.JsonElement import com.google.gson.JsonParseException import com.google.gson.annotations.JsonAdapter +import com.keylesspalace.tusky.R data class Notification( val type: Type, val id: String, val account: TimelineAccount, val status: Status?, - val report: Report?, + val report: Report? ) { + /** From https://docs.joinmastodon.org/entities/Notification/#type */ @JsonAdapter(NotificationTypeAdapter::class) - enum class Type(val presentation: String) { - UNKNOWN("unknown"), - MENTION("mention"), - REBLOG("reblog"), - FAVOURITE("favourite"), - FOLLOW("follow"), - FOLLOW_REQUEST("follow_request"), - POLL("poll"), - STATUS("status"), - SIGN_UP("admin.sign_up"), - UPDATE("update"), - REPORT("admin.report"), - ; + enum class Type(val presentation: String, @StringRes val uiString: Int) { + UNKNOWN("unknown", R.string.notification_unknown_name), + + /** Someone mentioned you */ + MENTION("mention", R.string.notification_mention_name), + + /** Someone boosted one of your statuses */ + REBLOG("reblog", R.string.notification_boost_name), + + /** Someone favourited one of your statuses */ + FAVOURITE("favourite", R.string.notification_favourite_name), + + /** Someone followed you */ + FOLLOW("follow", R.string.notification_follow_name), + + /** Someone requested to follow you */ + FOLLOW_REQUEST("follow_request", R.string.notification_follow_request_name), + + /** A poll you have voted in or created has ended */ + POLL("poll", R.string.notification_poll_name), + + /** Someone you enabled notifications for has posted a status */ + STATUS("status", R.string.notification_subscription_name), + + /** Someone signed up (optionally sent to admins) */ + SIGN_UP("admin.sign_up", R.string.notification_sign_up_name), + + /** A status you interacted with has been updated */ + UPDATE("update", R.string.notification_update_name), + + /** A new report has been filed */ + REPORT("admin.report", R.string.notification_report_name); companion object { - @JvmStatic fun byString(s: String): Type { values().forEach { - if (s == it.presentation) + if (s == it.presentation) { return it + } } return UNKNOWN } - val asList = listOf(MENTION, REBLOG, FAVOURITE, FOLLOW, FOLLOW_REQUEST, POLL, STATUS, SIGN_UP, UPDATE, REPORT) + + /** Notification types for UI display (omits UNKNOWN) */ + val visibleTypes = listOf(MENTION, REBLOG, FAVOURITE, FOLLOW, FOLLOW_REQUEST, POLL, STATUS, SIGN_UP, UPDATE, REPORT) } override fun toString(): String { @@ -86,16 +110,17 @@ data class Notification( } } - /** Helper for Java */ - fun copyWithStatus(status: Status?): Notification = copy(status = status) - // for Pleroma compatibility that uses Mention type fun rewriteToStatusTypeIfNeeded(accountId: String): Notification { if (type == Type.MENTION && status != null) { return if (status.mentions.any { it.id == accountId } - ) this else copy(type = Type.STATUS) + ) { + this + } else { + copy(type = Type.STATUS) + } } return this } diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/NotificationSubscribeResult.kt b/app/src/main/java/com/keylesspalace/tusky/entity/NotificationSubscribeResult.kt index c6eb09be..6bdaa143 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/NotificationSubscribeResult.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/NotificationSubscribeResult.kt @@ -20,5 +20,5 @@ import com.google.gson.annotations.SerializedName data class NotificationSubscribeResult( val id: Int, val endpoint: String, - @SerializedName("server_key") val serverKey: String, + @SerializedName("server_key") val serverKey: String ) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Report.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Report.kt index 0330c102..8de7b957 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Report.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Report.kt @@ -8,5 +8,5 @@ data class Report( val category: String, val status_ids: List?, @SerializedName("created_at") val createdAt: Date, - @SerializedName("target_account") val targetAccount: TimelineAccount, + @SerializedName("target_account") val targetAccount: TimelineAccount ) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt index b7d74c8b..0b9a0796 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt @@ -19,7 +19,6 @@ import android.text.SpannableStringBuilder import android.text.style.URLSpan import com.google.gson.annotations.SerializedName import com.keylesspalace.tusky.util.parseAsMastodonHtml -import java.util.ArrayList import java.util.Date data class Status( @@ -42,7 +41,7 @@ data class Status( val sensitive: Boolean, @SerializedName("spoiler_text") val spoilerText: String, val visibility: Visibility, - @SerializedName("media_attachments") val attachments: ArrayList, + @SerializedName("media_attachments") val attachments: List, val mentions: List, val tags: List?, val application: Application?, @@ -51,6 +50,7 @@ data class Status( val poll: Poll?, val card: Card?, val language: String?, + val filtered: List? ) { val actionableId: String @@ -68,12 +68,16 @@ data class Status( enum class Visibility(val num: Int) { UNKNOWN(0), + @SerializedName("public") PUBLIC(1), + @SerializedName("unlisted") UNLISTED(2), + @SerializedName("private") PRIVATE(3), + @SerializedName("direct") DIRECT(4); @@ -133,11 +137,11 @@ data class Status( attachments = attachments, poll = poll, createdAt = createdAt, - language = language, + language = language ) } - fun getEditableText(): String { + private fun getEditableText(): String { val contentSpanned = content.parseAsMastodonHtml() val builder = SpannableStringBuilder(content.parseAsMastodonHtml()) for (span in contentSpanned.getSpans(0, content.length, URLSpan::class.java)) { diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/StatusSource.kt b/app/src/main/java/com/keylesspalace/tusky/entity/StatusSource.kt index aea6bdd4..98a01d8b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/StatusSource.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/StatusSource.kt @@ -20,5 +20,5 @@ import com.google.gson.annotations.SerializedName data class StatusSource( val id: String, val text: String, - @SerializedName("spoiler_text") val spoilerText: String, + @SerializedName("spoiler_text") val spoilerText: String ) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/TimelineAccount.kt b/app/src/main/java/com/keylesspalace/tusky/entity/TimelineAccount.kt index 224129fe..f801d496 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/TimelineAccount.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/TimelineAccount.kt @@ -28,12 +28,15 @@ data class TimelineAccount( @SerializedName("display_name") val displayName: String?, // should never be null per Api definition, but some servers break the contract val url: String, val avatar: String, + val note: String, val bot: Boolean = false, - val emojis: List? = emptyList(), // nullable for backward compatibility + val emojis: List? = emptyList() // nullable for backward compatibility ) { val name: String get() = if (displayName.isNullOrEmpty()) { localUsername - } else displayName + } else { + displayName + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/TrendingTagsResult.kt b/app/src/main/java/com/keylesspalace/tusky/entity/TrendingTagsResult.kt new file mode 100644 index 00000000..78669555 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/TrendingTagsResult.kt @@ -0,0 +1,47 @@ +/* Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.entity + +import java.util.Date + +/** + * Mastodon API Documentation: https://docs.joinmastodon.org/methods/trends/#tags + * + * @param name The name of the hashtag (after the #). The "caturday" in "#caturday". + * (@param url The URL to your mastodon instance list for this hashtag.) + * @param history A list of [TrendingTagHistory]. Each element contains metrics per day for this hashtag. + * (@param following This is not listed in the APIs at the time of writing, but an instance is delivering it.) + */ +data class TrendingTag( + val name: String, + val history: List +) + +/** + * Mastodon API Documentation: https://docs.joinmastodon.org/methods/trends/#tags + * + * @param day The day that this was posted in Unix Epoch Seconds. + * @param accounts The number of accounts that have posted with this hashtag. + * @param uses The number of posts with this hashtag. + */ +data class TrendingTagHistory( + val day: String, + val accounts: String, + val uses: String +) + +fun TrendingTag.start() = Date(history.last().day.toLong() * 1000L) +fun TrendingTag.end() = Date(history.first().day.toLong() * 1000L) diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java deleted file mode 100644 index 6293cbae..00000000 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java +++ /dev/null @@ -1,1246 +0,0 @@ -/* Copyright 2017 Andrew Dawson - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . */ - -package com.keylesspalace.tusky.fragment; - -import static com.keylesspalace.tusky.util.StringUtils.isLessThan; -import static autodispose2.AutoDispose.autoDisposable; -import static autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from; - -import android.app.Activity; -import android.content.Context; -import android.content.DialogInterface; -import android.content.SharedPreferences; -import android.os.Bundle; -import android.util.Log; -import android.util.SparseBooleanArray; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ArrayAdapter; -import android.widget.ListView; -import android.widget.PopupWindow; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AlertDialog; -import androidx.arch.core.util.Function; -import androidx.coordinatorlayout.widget.CoordinatorLayout; -import androidx.core.util.Pair; -import androidx.lifecycle.Lifecycle; -import androidx.preference.PreferenceManager; -import androidx.recyclerview.widget.AsyncDifferConfig; -import androidx.recyclerview.widget.AsyncListDiffer; -import androidx.recyclerview.widget.DiffUtil; -import androidx.recyclerview.widget.DividerItemDecoration; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.ListUpdateCallback; -import androidx.recyclerview.widget.RecyclerView; -import androidx.recyclerview.widget.SimpleItemAnimator; -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; - -import com.google.android.material.appbar.AppBarLayout; -import com.google.android.material.floatingactionbutton.FloatingActionButton; -import com.keylesspalace.tusky.R; -import com.keylesspalace.tusky.adapter.NotificationsAdapter; -import com.keylesspalace.tusky.adapter.StatusBaseViewHolder; -import com.keylesspalace.tusky.appstore.BlockEvent; -import com.keylesspalace.tusky.appstore.BookmarkEvent; -import com.keylesspalace.tusky.appstore.EventHub; -import com.keylesspalace.tusky.appstore.FavoriteEvent; -import com.keylesspalace.tusky.appstore.PinEvent; -import com.keylesspalace.tusky.appstore.PreferenceChangedEvent; -import com.keylesspalace.tusky.appstore.ReblogEvent; -import com.keylesspalace.tusky.databinding.FragmentTimelineNotificationsBinding; -import com.keylesspalace.tusky.db.AccountEntity; -import com.keylesspalace.tusky.db.AccountManager; -import com.keylesspalace.tusky.di.Injectable; -import com.keylesspalace.tusky.entity.Notification; -import com.keylesspalace.tusky.entity.Poll; -import com.keylesspalace.tusky.entity.Relationship; -import com.keylesspalace.tusky.entity.Status; -import com.keylesspalace.tusky.interfaces.AccountActionListener; -import com.keylesspalace.tusky.interfaces.ActionButtonActivity; -import com.keylesspalace.tusky.interfaces.ReselectableFragment; -import com.keylesspalace.tusky.interfaces.StatusActionListener; -import com.keylesspalace.tusky.settings.PrefKeys; -import com.keylesspalace.tusky.util.CardViewMode; -import com.keylesspalace.tusky.util.Either; -import com.keylesspalace.tusky.util.HttpHeaderLink; -import com.keylesspalace.tusky.util.LinkHelper; -import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate; -import com.keylesspalace.tusky.util.ListUtils; -import com.keylesspalace.tusky.util.NotificationTypeConverterKt; -import com.keylesspalace.tusky.util.PairedList; -import com.keylesspalace.tusky.util.StatusDisplayOptions; -import com.keylesspalace.tusky.util.ViewDataUtils; -import com.keylesspalace.tusky.view.EndlessOnScrollListener; -import com.keylesspalace.tusky.viewdata.AttachmentViewData; -import com.keylesspalace.tusky.viewdata.NotificationViewData; -import com.keylesspalace.tusky.viewdata.StatusViewData; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashSet; -import java.util.Iterator; -import java.util.List; -import java.util.Locale; -import java.util.Objects; -import java.util.Set; -import java.util.concurrent.TimeUnit; - -import javax.inject.Inject; - -import at.connyduck.sparkbutton.helpers.Utils; -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Observable; -import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.disposables.CompositeDisposable; -import io.reactivex.rxjava3.disposables.Disposable; -import kotlin.Unit; -import kotlin.collections.CollectionsKt; -import kotlin.jvm.functions.Function1; - -public class NotificationsFragment extends SFragment implements - SwipeRefreshLayout.OnRefreshListener, - StatusActionListener, - NotificationsAdapter.NotificationActionListener, - AccountActionListener, - Injectable, ReselectableFragment { - private static final String TAG = "NotificationF"; // logging tag - - private static final int LOAD_AT_ONCE = 30; - private int maxPlaceholderId = 0; - - private final Set notificationFilter = new HashSet<>(); - - private final CompositeDisposable disposables = new CompositeDisposable(); - - private enum FetchEnd { - TOP, - BOTTOM, - MIDDLE - } - - /** - * Placeholder for the notificationsEnabled. Consider moving to the separate class to hide constructor - * and reuse in different places as needed. - */ - private static final class Placeholder { - final long id; - - public static Placeholder getInstance(long id) { - return new Placeholder(id); - } - - private Placeholder(long id) { - this.id = id; - } - } - - @Inject - AccountManager accountManager; - @Inject - EventHub eventHub; - - private FragmentTimelineNotificationsBinding binding; - - private LinearLayoutManager layoutManager; - private EndlessOnScrollListener scrollListener; - private NotificationsAdapter adapter; - private boolean hideFab; - private boolean topLoading; - private boolean bottomLoading; - private String bottomId; - private boolean alwaysShowSensitiveMedia; - private boolean alwaysOpenSpoiler; - private boolean showNotificationsFilter; - private boolean showingError; - - // Each element is either a Notification for loading data or a Placeholder - private final PairedList, NotificationViewData> notifications - = new PairedList<>(new Function, NotificationViewData>() { - @Override - public NotificationViewData apply(Either input) { - if (input.isRight()) { - Notification notification = input.asRight() - .rewriteToStatusTypeIfNeeded(accountManager.getActiveAccount().getAccountId()); - - boolean sensitiveStatus = notification.getStatus() != null && notification.getStatus().getActionableStatus().getSensitive(); - - return ViewDataUtils.notificationToViewData( - notification, - alwaysShowSensitiveMedia || !sensitiveStatus, - alwaysOpenSpoiler, - true - ); - } else { - return new NotificationViewData.Placeholder(input.asLeft().id, false); - } - } - }); - - public static NotificationsFragment newInstance() { - NotificationsFragment fragment = new NotificationsFragment(); - Bundle arguments = new Bundle(); - fragment.setArguments(arguments); - return fragment; - } - - @Nullable - @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, - @Nullable Bundle savedInstanceState) { - binding = FragmentTimelineNotificationsBinding.inflate(inflater, container, false); - - @NonNull Context context = inflater.getContext(); // from inflater to silence warning - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(requireContext()); - - boolean showNotificationsFilterSetting = preferences.getBoolean("showNotificationsFilter", true); - // Clear notifications on filter visibility change to force refresh - if (showNotificationsFilterSetting != showNotificationsFilter) - notifications.clear(); - showNotificationsFilter = showNotificationsFilterSetting; - - // Setup the SwipeRefreshLayout. - binding.swipeRefreshLayout.setOnRefreshListener(this); - binding.swipeRefreshLayout.setColorSchemeResources(R.color.chinwag_green); - - loadNotificationsFilter(); - - // Setup the RecyclerView. - binding.recyclerView.setHasFixedSize(true); - layoutManager = new LinearLayoutManager(context); - binding.recyclerView.setLayoutManager(layoutManager); - binding.recyclerView.setAccessibilityDelegateCompat( - new ListStatusAccessibilityDelegate(binding.recyclerView, this, (pos) -> { - NotificationViewData notification = notifications.getPairedItemOrNull(pos); - // We support replies only for now - if (notification instanceof NotificationViewData.Concrete) { - return ((NotificationViewData.Concrete) notification).getStatusViewData(); - } else { - return null; - } - })); - - binding.recyclerView.addItemDecoration(new DividerItemDecoration(context, DividerItemDecoration.VERTICAL)); - - StatusDisplayOptions statusDisplayOptions = new StatusDisplayOptions( - preferences.getBoolean("animateGifAvatars", false), - accountManager.getActiveAccount().getMediaPreviewEnabled(), - preferences.getBoolean("absoluteTimeView", false), - preferences.getBoolean("showBotOverlay", true), - preferences.getBoolean("useBlurhash", true), - CardViewMode.NONE, - preferences.getBoolean("confirmReblogs", true), - preferences.getBoolean("confirmFavourites", false), - preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), - preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) - ); - - adapter = new NotificationsAdapter(accountManager.getActiveAccount().getAccountId(), - dataSource, statusDisplayOptions, this, this, this); - alwaysShowSensitiveMedia = accountManager.getActiveAccount().getAlwaysShowSensitiveMedia(); - alwaysOpenSpoiler = accountManager.getActiveAccount().getAlwaysOpenSpoiler(); - binding.recyclerView.setAdapter(adapter); - - topLoading = false; - bottomLoading = false; - bottomId = null; - - updateAdapter(); - - binding.buttonClear.setOnClickListener(v -> confirmClearNotifications()); - binding.buttonFilter.setOnClickListener(v -> showFilterMenu()); - - if (notifications.isEmpty()) { - binding.swipeRefreshLayout.setEnabled(false); - sendFetchNotificationsRequest(null, null, FetchEnd.BOTTOM, -1); - } else { - binding.progressBar.setVisibility(View.GONE); - } - - ((SimpleItemAnimator) binding.recyclerView.getItemAnimator()).setSupportsChangeAnimations(false); - - updateFilterVisibility(); - - return binding.getRoot(); - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - binding = null; - } - - private void updateFilterVisibility() { - CoordinatorLayout.LayoutParams params = - (CoordinatorLayout.LayoutParams) binding.swipeRefreshLayout.getLayoutParams(); - if (showNotificationsFilter && !showingError) { - binding.appBarOptions.setExpanded(true, false); - binding.appBarOptions.setVisibility(View.VISIBLE); - // Set content behaviour to hide filter on scroll - params.setBehavior(new AppBarLayout.ScrollingViewBehavior()); - } else { - binding.appBarOptions.setExpanded(false, false); - binding.appBarOptions.setVisibility(View.GONE); - // Clear behaviour to hide app bar - params.setBehavior(null); - } - } - - private void confirmClearNotifications() { - new AlertDialog.Builder(requireContext()) - .setMessage(R.string.notification_clear_text) - .setPositiveButton(android.R.string.ok, (DialogInterface dia, int which) -> clearNotifications()) - .setNegativeButton(android.R.string.cancel, null) - .show(); - } - - @Override - public void onActivityCreated(@Nullable Bundle savedInstanceState) { - super.onActivityCreated(savedInstanceState); - Activity activity = getActivity(); - if (activity == null) throw new AssertionError("Activity is null"); - - // This is delayed until onActivityCreated solely because MainActivity.composeButton - // isn't guaranteed to be set until then. - // Use a modified scroll listener that both loads more notificationsEnabled as it - // goes, and hides the compose button on down-scroll. - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(activity); - hideFab = preferences.getBoolean("fabHide", false); - scrollListener = new EndlessOnScrollListener(layoutManager) { - @Override - public void onScrolled(@NonNull RecyclerView view, int dx, int dy) { - super.onScrolled(view, dx, dy); - - ActionButtonActivity activity = (ActionButtonActivity) getActivity(); - FloatingActionButton composeButton = activity.getActionButton(); - - if (composeButton != null) { - if (hideFab) { - if (dy > 0 && composeButton.isShown()) { - composeButton.hide(); // Hides the button if we're scrolling down - } else if (dy < 0 && !composeButton.isShown()) { - composeButton.show(); // Shows it if we are scrolling up - } - } else if (!composeButton.isShown()) { - composeButton.show(); - } - } - } - - @Override - public void onLoadMore(int totalItemsCount, RecyclerView view) { - NotificationsFragment.this.onLoadMore(); - } - }; - - binding.recyclerView.addOnScrollListener(scrollListener); - - eventHub.getEvents() - .observeOn(AndroidSchedulers.mainThread()) - .to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) - .subscribe(event -> { - if (event instanceof FavoriteEvent) { - setFavouriteForStatus(((FavoriteEvent) event).getStatusId(), ((FavoriteEvent) event).getFavourite()); - } else if (event instanceof BookmarkEvent) { - setBookmarkForStatus(((BookmarkEvent) event).getStatusId(), ((BookmarkEvent) event).getBookmark()); - } else if (event instanceof ReblogEvent) { - setReblogForStatus(((ReblogEvent) event).getStatusId(), ((ReblogEvent) event).getReblog()); - } else if (event instanceof PinEvent) { - setPinForStatus(((PinEvent) event).getStatusId(), ((PinEvent) event).getPinned()); - } else if (event instanceof BlockEvent) { - removeAllByAccountId(((BlockEvent) event).getAccountId()); - } else if (event instanceof PreferenceChangedEvent) { - onPreferenceChanged(((PreferenceChangedEvent) event).getPreferenceKey()); - } - }); - } - - @Override - public void onRefresh() { - binding.statusView.setVisibility(View.GONE); - this.showingError = false; - Either first = CollectionsKt.firstOrNull(this.notifications); - String topId; - if (first != null && first.isRight()) { - topId = first.asRight().getId(); - } else { - topId = null; - } - sendFetchNotificationsRequest(null, topId, FetchEnd.TOP, -1); - } - - @Override - public void onReply(int position) { - super.reply(notifications.get(position).asRight().getStatus()); - } - - @Override - public void onReblog(final boolean reblog, final int position) { - final Notification notification = notifications.get(position).asRight(); - final Status status = notification.getStatus(); - Objects.requireNonNull(status, "Reblog on notification without status"); - timelineCases.reblog(status.getId(), reblog) - .observeOn(AndroidSchedulers.mainThread()) - .to(autoDisposable(from(this))) - .subscribe( - (newStatus) -> setReblogForStatus(status.getId(), reblog), - (t) -> Log.d(getClass().getSimpleName(), - "Failed to reblog status: " + status.getId(), t) - ); - } - - private void setReblogForStatus(String statusId, boolean reblog) { - updateStatus(statusId, (s) -> s.copyWithReblogged(reblog)); - } - - @Override - public void onFavourite(final boolean favourite, final int position) { - final Notification notification = notifications.get(position).asRight(); - final Status status = notification.getStatus(); - - timelineCases.favourite(status.getId(), favourite) - .observeOn(AndroidSchedulers.mainThread()) - .to(autoDisposable(from(this))) - .subscribe( - (newStatus) -> setFavouriteForStatus(status.getId(), favourite), - (t) -> Log.d(getClass().getSimpleName(), - "Failed to favourite status: " + status.getId(), t) - ); - } - - private void setFavouriteForStatus(String statusId, boolean favourite) { - updateStatus(statusId, (s) -> s.copyWithFavourited(favourite)); - } - - @Override - public void onBookmark(final boolean bookmark, final int position) { - final Notification notification = notifications.get(position).asRight(); - final Status status = notification.getStatus(); - - timelineCases.bookmark(status.getActionableId(), bookmark) - .observeOn(AndroidSchedulers.mainThread()) - .to(autoDisposable(from(this))) - .subscribe( - (newStatus) -> setBookmarkForStatus(status.getId(), bookmark), - (t) -> Log.d(getClass().getSimpleName(), - "Failed to bookmark status: " + status.getId(), t) - ); - } - - private void setBookmarkForStatus(String statusId, boolean bookmark) { - updateStatus(statusId, (s) -> s.copyWithBookmarked(bookmark)); - } - - public void onVoteInPoll(int position, @NonNull List choices) { - final Notification notification = notifications.get(position).asRight(); - final Status status = notification.getStatus().getActionableStatus(); - timelineCases.voteInPoll(status.getId(), status.getPoll().getId(), choices) - .observeOn(AndroidSchedulers.mainThread()) - .to(autoDisposable(from(this))) - .subscribe( - (newPoll) -> setVoteForPoll(status, newPoll), - (t) -> Log.d(TAG, - "Failed to vote in poll: " + status.getId(), t) - ); - } - - private void setVoteForPoll(Status status, Poll poll) { - updateStatus(status.getId(), (s) -> s.copyWithPoll(poll)); - } - - @Override - public void onMore(@NonNull View view, int position) { - Notification notification = notifications.get(position).asRight(); - super.more(notification.getStatus(), view, position); - } - - @Override - public void onViewMedia(int position, int attachmentIndex, @Nullable View view) { - Notification notification = notifications.get(position).asRightOrNull(); - if (notification == null || notification.getStatus() == null) return; - Status status = notification.getStatus(); - super.viewMedia(attachmentIndex, AttachmentViewData.list(status), view); - } - - @Override - public void onViewThread(int position) { - Notification notification = notifications.get(position).asRight(); - Status status = notification.getStatus(); - if (status == null) return; - ; - super.viewThread(status.getActionableId(), status.getActionableStatus().getUrl()); - } - - @Override - public void onOpenReblog(int position) { - Notification notification = notifications.get(position).asRight(); - onViewAccount(notification.getAccount().getId()); - } - - @Override - public void onExpandedChange(boolean expanded, int position) { - updateViewDataAt(position, (vd) -> vd.copyWithExpanded(expanded)); - } - - @Override - public void onContentHiddenChange(boolean isShowing, int position) { - updateViewDataAt(position, (vd) -> vd.copyWithShowingContent(isShowing)); - } - - private void setPinForStatus(String statusId, boolean pinned) { - updateStatus(statusId, status -> status.copyWithPinned(pinned)); - } - - @Override - public void onLoadMore(int position) { - // Check bounds before accessing list, - if (notifications.size() >= position && position > 0) { - Notification previous = notifications.get(position - 1).asRightOrNull(); - Notification next = notifications.get(position + 1).asRightOrNull(); - if (previous == null || next == null) { - Log.e(TAG, "Failed to load more, invalid placeholder position: " + position); - return; - } - sendFetchNotificationsRequest(previous.getId(), next.getId(), FetchEnd.MIDDLE, position); - Placeholder placeholder = notifications.get(position).asLeft(); - NotificationViewData notificationViewData = - new NotificationViewData.Placeholder(placeholder.id, true); - notifications.setPairedItem(position, notificationViewData); - updateAdapter(); - } else { - Log.d(TAG, "error loading more"); - } - } - - @Override - public void onContentCollapsedChange(boolean isCollapsed, int position) { - updateViewDataAt(position, (vd) -> vd.copyWithCollapsed(isCollapsed)); - } - - private void updateStatus(String statusId, Function mapper) { - int index = CollectionsKt.indexOfFirst(this.notifications, (s) -> s.isRight() && - s.asRight().getStatus() != null && - s.asRight().getStatus().getId().equals(statusId)); - if (index == -1) return; - - // We have quite some graph here: - // - // Notification --------> Status - // ^ - // | - // StatusViewData - // ^ - // | - // NotificationViewData -----+ - // - // So if we have "new" status we need to update all references to be sure that data is - // up-to-date: - // 1. update status - // 2. update notification - // 3. update statusViewData - // 4. update notificationViewData - - Status oldStatus = notifications.get(index).asRight().getStatus(); - NotificationViewData.Concrete oldViewData = - (NotificationViewData.Concrete) this.notifications.getPairedItem(index); - Status newStatus = mapper.apply(oldStatus); - Notification newNotification = this.notifications.get(index).asRight() - .copyWithStatus(newStatus); - StatusViewData.Concrete newStatusViewData = - Objects.requireNonNull(oldViewData.getStatusViewData()).copyWithStatus(newStatus); - NotificationViewData.Concrete newViewData = oldViewData.copyWithStatus(newStatusViewData); - - notifications.set(index, new Either.Right<>(newNotification)); - notifications.setPairedItem(index, newViewData); - - updateAdapter(); - } - - private void updateViewDataAt(int position, - Function mapper) { - if (position < 0 || position >= notifications.size()) { - String message = String.format( - Locale.getDefault(), - "Tried to access out of bounds status position: %d of %d", - position, - notifications.size() - 1 - ); - Log.e(TAG, message); - return; - } - NotificationViewData someViewData = this.notifications.getPairedItem(position); - if (!(someViewData instanceof NotificationViewData.Concrete)) { - return; - } - NotificationViewData.Concrete oldViewData = (NotificationViewData.Concrete) someViewData; - StatusViewData.Concrete oldStatusViewData = oldViewData.getStatusViewData(); - if (oldStatusViewData == null) return; - - NotificationViewData.Concrete newViewData = - oldViewData.copyWithStatus(mapper.apply(oldStatusViewData)); - notifications.setPairedItem(position, newViewData); - - updateAdapter(); - } - - @Override - public void onNotificationContentCollapsedChange(boolean isCollapsed, int position) { - onContentCollapsedChange(isCollapsed, position); - } - - private void clearNotifications() { - // Cancel all ongoing requests - binding.swipeRefreshLayout.setRefreshing(false); - resetNotificationsLoad(); - - // Show friend elephant - binding.statusView.setVisibility(View.VISIBLE); - binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null); - updateFilterVisibility(); - - // Update adapter - updateAdapter(); - - // Execute clear notifications request - mastodonApi.clearNotifications() - .observeOn(AndroidSchedulers.mainThread()) - .to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) - .subscribe( - response -> { - // Nothing to do - }, - throwable -> { - // Reload notifications on failure - fullyRefreshWithProgressBar(true); - }); - } - - private void resetNotificationsLoad() { - disposables.clear(); - bottomLoading = false; - topLoading = false; - - // Disable load more - bottomId = null; - - // Clear exists notifications - notifications.clear(); - } - - - private void showFilterMenu() { - List notificationsList = Notification.Type.Companion.getAsList(); - List list = new ArrayList<>(); - for (Notification.Type type : notificationsList) { - list.add(getNotificationText(type)); - } - - ArrayAdapter adapter = new ArrayAdapter<>(getContext(), android.R.layout.simple_list_item_multiple_choice, list); - PopupWindow window = new PopupWindow(getContext()); - View view = LayoutInflater.from(getContext()).inflate(R.layout.notifications_filter, (ViewGroup) getView(), false); - final ListView listView = view.findViewById(R.id.listView); - view.findViewById(R.id.buttonApply) - .setOnClickListener(v -> { - SparseBooleanArray checkedItems = listView.getCheckedItemPositions(); - Set excludes = new HashSet<>(); - for (int i = 0; i < notificationsList.size(); i++) { - if (!checkedItems.get(i, false)) - excludes.add(notificationsList.get(i)); - } - window.dismiss(); - applyFilterChanges(excludes); - - }); - - listView.setAdapter(adapter); - listView.setChoiceMode(ListView.CHOICE_MODE_MULTIPLE); - for (int i = 0; i < notificationsList.size(); i++) { - if (!notificationFilter.contains(notificationsList.get(i))) - listView.setItemChecked(i, true); - } - window.setContentView(view); - window.setFocusable(true); - window.setWidth(ViewGroup.LayoutParams.WRAP_CONTENT); - window.setHeight(ViewGroup.LayoutParams.WRAP_CONTENT); - window.showAsDropDown(binding.buttonFilter); - - } - - private String getNotificationText(Notification.Type type) { - switch (type) { - case MENTION: - return getString(R.string.notification_mention_name); - case FAVOURITE: - return getString(R.string.notification_favourite_name); - case REBLOG: - return getString(R.string.notification_boost_name); - case FOLLOW: - return getString(R.string.notification_follow_name); - case FOLLOW_REQUEST: - return getString(R.string.notification_follow_request_name); - case POLL: - return getString(R.string.notification_poll_name); - case STATUS: - return getString(R.string.notification_subscription_name); - case SIGN_UP: - return getString(R.string.notification_sign_up_name); - case UPDATE: - return getString(R.string.notification_update_name); - case REPORT: - return getString(R.string.notification_report_name); - default: - return "Unknown"; - } - } - - private void applyFilterChanges(Set newSet) { - List notifications = Notification.Type.Companion.getAsList(); - boolean isChanged = false; - for (Notification.Type type : notifications) { - if (notificationFilter.contains(type) && !newSet.contains(type)) { - notificationFilter.remove(type); - isChanged = true; - } else if (!notificationFilter.contains(type) && newSet.contains(type)) { - notificationFilter.add(type); - isChanged = true; - } - } - if (isChanged) { - saveNotificationsFilter(); - fullyRefreshWithProgressBar(true); - } - - } - - private void loadNotificationsFilter() { - AccountEntity account = accountManager.getActiveAccount(); - if (account != null) { - notificationFilter.clear(); - notificationFilter.addAll(NotificationTypeConverterKt.deserialize( - account.getNotificationsFilter())); - } - } - - private void saveNotificationsFilter() { - AccountEntity account = accountManager.getActiveAccount(); - if (account != null) { - account.setNotificationsFilter(NotificationTypeConverterKt.serialize(notificationFilter)); - accountManager.saveAccount(account); - } - } - - @Override - public void onViewTag(@NonNull String tag) { - super.viewTag(tag); - } - - @Override - public void onViewAccount(@NonNull String id) { - super.viewAccount(id); - } - - @Override - public void onMute(boolean mute, String id, int position, boolean notifications) { - // No muting from notifications yet - } - - @Override - public void onBlock(boolean block, String id, int position) { - // No blocking from notifications yet - } - - @Override - public void onRespondToFollowRequest(boolean accept, String id, int position) { - Single request = accept ? - mastodonApi.authorizeFollowRequest(id) : - mastodonApi.rejectFollowRequest(id); - request.observeOn(AndroidSchedulers.mainThread()) - .to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) - .subscribe( - (relationship) -> fullyRefreshWithProgressBar(true), - (error) -> Log.e(TAG, String.format("Failed to %s account id %s", accept ? "accept" : "reject", id)) - ); - } - - @Override - public void onViewStatusForNotificationId(String notificationId) { - for (Either either : notifications) { - Notification notification = either.asRightOrNull(); - if (notification != null && notification.getId().equals(notificationId)) { - Status status = notification.getStatus(); - if (status != null) { - super.viewThread(status.getActionableId(), status.getActionableStatus().getUrl()); - return; - } - } - } - Log.w(TAG, "Didn't find a notification for ID: " + notificationId); - } - - @Override - public void onViewReport(String reportId) { - LinkHelper.openLink(requireContext(), String.format("https://%s/admin/reports/%s", accountManager.getActiveAccount().getDomain(), reportId)); - } - - private void onPreferenceChanged(String key) { - switch (key) { - case "fabHide": { - hideFab = PreferenceManager.getDefaultSharedPreferences(requireContext()).getBoolean("fabHide", false); - break; - } - case "mediaPreviewEnabled": { - boolean enabled = accountManager.getActiveAccount().getMediaPreviewEnabled(); - if (enabled != adapter.isMediaPreviewEnabled()) { - adapter.setMediaPreviewEnabled(enabled); - fullyRefresh(); - } - break; - } - case "showNotificationsFilter": { - if (isAdded()) { - showNotificationsFilter = PreferenceManager.getDefaultSharedPreferences(requireContext()).getBoolean("showNotificationsFilter", true); - updateFilterVisibility(); - fullyRefreshWithProgressBar(true); - } - break; - } - } - } - - @Override - public void removeItem(int position) { - notifications.remove(position); - updateAdapter(); - } - - private void removeAllByAccountId(String accountId) { - // Using iterator to safely remove items while iterating - Iterator> iterator = notifications.iterator(); - while (iterator.hasNext()) { - Either notification = iterator.next(); - Notification maybeNotification = notification.asRightOrNull(); - if (maybeNotification != null && maybeNotification.getAccount().getId().equals(accountId)) { - iterator.remove(); - } - } - updateAdapter(); - } - - private void onLoadMore() { - if (bottomId == null) { - // Already loaded everything - return; - } - - // Check for out-of-bounds when loading - // This is required to allow full-timeline reloads of collapsible statuses when the settings - // change. - if (notifications.size() > 0) { - Either last = notifications.get(notifications.size() - 1); - if (last.isRight()) { - final Placeholder placeholder = newPlaceholder(); - notifications.add(new Either.Left<>(placeholder)); - NotificationViewData viewData = - new NotificationViewData.Placeholder(placeholder.id, true); - notifications.setPairedItem(notifications.size() - 1, viewData); - updateAdapter(); - } - } - - sendFetchNotificationsRequest(bottomId, null, FetchEnd.BOTTOM, -1); - } - - private Placeholder newPlaceholder() { - Placeholder placeholder = Placeholder.getInstance(maxPlaceholderId); - maxPlaceholderId--; - return placeholder; - } - - private void jumpToTop() { - if (isAdded()) { - binding.appBarOptions.setExpanded(true, false); - layoutManager.scrollToPosition(0); - scrollListener.reset(); - } - } - - private void sendFetchNotificationsRequest(String fromId, String uptoId, - final FetchEnd fetchEnd, final int pos) { - // If there is a fetch already ongoing, record however many fetches are requested and - // fulfill them after it's complete. - if (fetchEnd == FetchEnd.TOP && topLoading) { - return; - } - if (fetchEnd == FetchEnd.BOTTOM && bottomLoading) { - return; - } - if (fetchEnd == FetchEnd.TOP) { - topLoading = true; - } - if (fetchEnd == FetchEnd.BOTTOM) { - bottomLoading = true; - } - - Disposable notificationCall = mastodonApi.notifications(fromId, uptoId, LOAD_AT_ONCE, showNotificationsFilter ? notificationFilter : null) - .observeOn(AndroidSchedulers.mainThread()) - .to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) - .subscribe( - response -> { - if (response.isSuccessful()) { - String linkHeader = response.headers().get("Link"); - onFetchNotificationsSuccess(response.body(), linkHeader, fetchEnd, pos); - } else { - onFetchNotificationsFailure(new Exception(response.message()), fetchEnd, pos); - } - }, - throwable -> onFetchNotificationsFailure(throwable, fetchEnd, pos)); - disposables.add(notificationCall); - } - - private void onFetchNotificationsSuccess(List notifications, String linkHeader, - FetchEnd fetchEnd, int pos) { - List links = HttpHeaderLink.Companion.parse(linkHeader); - HttpHeaderLink next = HttpHeaderLink.Companion.findByRelationType(links, "next"); - String fromId = null; - if (next != null) { - fromId = next.getUri().getQueryParameter("max_id"); - } - - switch (fetchEnd) { - case TOP: { - update(notifications, this.notifications.isEmpty() ? fromId : null); - break; - } - case MIDDLE: { - replacePlaceholderWithNotifications(notifications, pos); - break; - } - case BOTTOM: { - - if (!this.notifications.isEmpty() - && !this.notifications.get(this.notifications.size() - 1).isRight()) { - this.notifications.remove(this.notifications.size() - 1); - updateAdapter(); - } - - if (adapter.getItemCount() > 1) { - addItems(notifications, fromId); - } else { - update(notifications, fromId); - } - - break; - } - } - - saveNewestNotificationId(notifications); - - if (fetchEnd == FetchEnd.TOP) { - topLoading = false; - } - if (fetchEnd == FetchEnd.BOTTOM) { - bottomLoading = false; - } - - if (notifications.size() == 0 && adapter.getItemCount() == 0) { - binding.statusView.setVisibility(View.VISIBLE); - binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null); - } - - updateFilterVisibility(); - binding.swipeRefreshLayout.setEnabled(true); - binding.swipeRefreshLayout.setRefreshing(false); - binding.progressBar.setVisibility(View.GONE); - } - - private void onFetchNotificationsFailure(Throwable throwable, FetchEnd fetchEnd, int position) { - binding.swipeRefreshLayout.setRefreshing(false); - if (fetchEnd == FetchEnd.MIDDLE && !notifications.get(position).isRight()) { - Placeholder placeholder = notifications.get(position).asLeft(); - NotificationViewData placeholderVD = - new NotificationViewData.Placeholder(placeholder.id, false); - notifications.setPairedItem(position, placeholderVD); - updateAdapter(); - } else if (this.notifications.isEmpty()) { - binding.statusView.setVisibility(View.VISIBLE); - binding.swipeRefreshLayout.setEnabled(false); - this.showingError = true; - if (throwable instanceof IOException) { - binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network, __ -> { - binding.progressBar.setVisibility(View.VISIBLE); - this.onRefresh(); - return Unit.INSTANCE; - }); - } else { - binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic, __ -> { - binding.progressBar.setVisibility(View.VISIBLE); - this.onRefresh(); - return Unit.INSTANCE; - }); - } - updateFilterVisibility(); - } - Log.e(TAG, "Fetch failure: " + throwable.getMessage()); - - if (fetchEnd == FetchEnd.TOP) { - topLoading = false; - } - if (fetchEnd == FetchEnd.BOTTOM) { - bottomLoading = false; - } - - binding.progressBar.setVisibility(View.GONE); - } - - private void saveNewestNotificationId(List notifications) { - - AccountEntity account = accountManager.getActiveAccount(); - if (account != null) { - String lastNotificationId = account.getLastNotificationId(); - - for (Notification noti : notifications) { - if (isLessThan(lastNotificationId, noti.getId())) { - lastNotificationId = noti.getId(); - } - } - - if (!account.getLastNotificationId().equals(lastNotificationId)) { - Log.d(TAG, "saving newest noti id: " + lastNotificationId); - account.setLastNotificationId(lastNotificationId); - accountManager.saveAccount(account); - } - } - } - - private void update(@Nullable List newNotifications, @Nullable String fromId) { - if (ListUtils.isEmpty(newNotifications)) { - updateAdapter(); - return; - } - if (fromId != null) { - bottomId = fromId; - } - List> liftedNew = - liftNotificationList(newNotifications); - if (notifications.isEmpty()) { - notifications.addAll(liftedNew); - } else { - int index = notifications.indexOf(liftedNew.get(newNotifications.size() - 1)); - if (index > 0) { - notifications.subList(0, index).clear(); - } - - int newIndex = liftedNew.indexOf(notifications.get(0)); - if (newIndex == -1) { - if (index == -1 && liftedNew.size() >= LOAD_AT_ONCE) { - liftedNew.add(new Either.Left<>(newPlaceholder())); - } - notifications.addAll(0, liftedNew); - } else { - notifications.addAll(0, liftedNew.subList(0, newIndex)); - } - } - updateAdapter(); - } - - private void addItems(List newNotifications, @Nullable String fromId) { - bottomId = fromId; - if (ListUtils.isEmpty(newNotifications)) { - return; - } - int end = notifications.size(); - List> liftedNew = liftNotificationList(newNotifications); - Either last = notifications.get(end - 1); - if (last != null && !liftedNew.contains(last)) { - notifications.addAll(liftedNew); - updateAdapter(); - } - } - - private void replacePlaceholderWithNotifications(List newNotifications, int pos) { - // Remove placeholder - notifications.remove(pos); - - if (ListUtils.isEmpty(newNotifications)) { - updateAdapter(); - return; - } - - List> liftedNew = liftNotificationList(newNotifications); - - // If we fetched less posts than in the limit, it means that the hole is not filled - // If we fetched at least as much it means that there are more posts to load and we should - // insert new placeholder - if (newNotifications.size() >= LOAD_AT_ONCE) { - liftedNew.add(new Either.Left<>(newPlaceholder())); - } - - notifications.addAll(pos, liftedNew); - updateAdapter(); - } - - private final Function1> notificationLifter = - Either.Right::new; - - private List> liftNotificationList(List list) { - return CollectionsKt.map(list, notificationLifter); - } - - private void fullyRefreshWithProgressBar(boolean isShow) { - resetNotificationsLoad(); - if (isShow) { - binding.progressBar.setVisibility(View.VISIBLE); - binding.statusView.setVisibility(View.GONE); - } - updateAdapter(); - sendFetchNotificationsRequest(null, null, FetchEnd.TOP, -1); - } - - private void fullyRefresh() { - fullyRefreshWithProgressBar(false); - } - - @Nullable - private Pair findReplyPosition(@NonNull String statusId) { - for (int i = 0; i < notifications.size(); i++) { - Notification notification = notifications.get(i).asRightOrNull(); - if (notification != null - && notification.getStatus() != null - && notification.getType() == Notification.Type.MENTION - && (statusId.equals(notification.getStatus().getId()) - || (notification.getStatus().getReblog() != null - && statusId.equals(notification.getStatus().getReblog().getId())))) { - return new Pair<>(i, notification); - } - } - return null; - } - - private void updateAdapter() { - differ.submitList(notifications.getPairedCopy()); - } - - private final ListUpdateCallback listUpdateCallback = new ListUpdateCallback() { - @Override - public void onInserted(int position, int count) { - if (isAdded()) { - adapter.notifyItemRangeInserted(position, count); - Context context = getContext(); - // scroll up when new items at the top are loaded while being at the start - // https://github.com/tuskyapp/Tusky/pull/1905#issuecomment-677819724 - if (position == 0 && context != null && adapter.getItemCount() != count) { - binding.recyclerView.scrollBy(0, Utils.dpToPx(context, -30)); - } - } - } - - @Override - public void onRemoved(int position, int count) { - adapter.notifyItemRangeRemoved(position, count); - } - - @Override - public void onMoved(int fromPosition, int toPosition) { - adapter.notifyItemMoved(fromPosition, toPosition); - } - - @Override - public void onChanged(int position, int count, Object payload) { - adapter.notifyItemRangeChanged(position, count, payload); - } - }; - - private final AsyncListDiffer - differ = new AsyncListDiffer<>(listUpdateCallback, - new AsyncDifferConfig.Builder<>(diffCallback).build()); - - private final NotificationsAdapter.AdapterDataSource dataSource = - new NotificationsAdapter.AdapterDataSource() { - @Override - public int getItemCount() { - return differ.getCurrentList().size(); - } - - @Override - public NotificationViewData getItemAt(int pos) { - return differ.getCurrentList().get(pos); - } - }; - - private static final DiffUtil.ItemCallback diffCallback - = new DiffUtil.ItemCallback() { - - @Override - public boolean areItemsTheSame(NotificationViewData oldItem, NotificationViewData newItem) { - return oldItem.getViewDataId() == newItem.getViewDataId(); - } - - @Override - public boolean areContentsTheSame(@NonNull NotificationViewData oldItem, @NonNull NotificationViewData newItem) { - return false; - } - - @Nullable - @Override - public Object getChangePayload(@NonNull NotificationViewData oldItem, @NonNull NotificationViewData newItem) { - if (oldItem.deepEquals(newItem)) { - // If items are equal - update timestamp only - return Collections.singletonList(StatusBaseViewHolder.Key.KEY_CREATED); - } else - // If items are different - update a whole view holder - return null; - } - }; - - @Override - public void onResume() { - super.onResume(); - String rawAccountNotificationFilter = accountManager.getActiveAccount().getNotificationsFilter(); - Set accountNotificationFilter = NotificationTypeConverterKt.deserialize(rawAccountNotificationFilter); - if (!notificationFilter.equals(accountNotificationFilter)) { - loadNotificationsFilter(); - fullyRefreshWithProgressBar(true); - } - startUpdateTimestamp(); - } - - /** - * Start to update adapter every minute to refresh timestamp - * If setting absoluteTimeView is false - * Auto dispose observable on pause - */ - private void startUpdateTimestamp() { - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(requireContext()); - boolean useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false); - if (!useAbsoluteTime) { - Observable.interval(0, 1, TimeUnit.MINUTES) - .observeOn(AndroidSchedulers.mainThread()) - .to(autoDisposable(from(this, Lifecycle.Event.ON_PAUSE))) - .subscribe( - interval -> updateAdapter() - ); - } - - } - - @Override - public void onReselect() { - jumpToTop(); - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.kt index b4284b39..a9923683 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.kt @@ -33,11 +33,9 @@ import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.PopupMenu import androidx.core.app.ActivityOptionsCompat import androidx.fragment.app.Fragment -import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import at.connyduck.calladapter.networkresult.fold -import autodispose2.AutoDispose -import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider +import at.connyduck.calladapter.networkresult.onFailure import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.BottomSheetActivity @@ -61,7 +59,6 @@ import com.keylesspalace.tusky.util.openLink import com.keylesspalace.tusky.util.parseAsMastodonHtml import com.keylesspalace.tusky.view.showMuteAccountDialog import com.keylesspalace.tusky.viewdata.AttachmentViewData -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import kotlinx.coroutines.launch import javax.inject.Inject @@ -276,30 +273,19 @@ abstract class SFragment : Fragment(), Injectable { return@setOnMenuItemClickListener true } R.id.pin -> { - timelineCases.pin(status.id, !status.isPinned()) - .observeOn(AndroidSchedulers.mainThread()) - .doOnError { e: Throwable -> - val message = e.message ?: getString(if (status.isPinned()) R.string.failed_to_unpin else R.string.failed_to_pin) + lifecycleScope.launch { + timelineCases.pin(status.id, !status.isPinned()).onFailure { e: Throwable -> + val message = e.message + ?: getString(if (status.isPinned()) R.string.failed_to_unpin else R.string.failed_to_pin) Snackbar.make(requireView(), message, Snackbar.LENGTH_LONG).show() } - .to( - AutoDispose.autoDisposable( - AndroidLifecycleScopeProvider.from(this, Lifecycle.Event.ON_DESTROY) - ) - ) - .subscribe() + } return@setOnMenuItemClickListener true } R.id.status_mute_conversation -> { - timelineCases.muteConversation(status.id, status.muted != true) - .onErrorReturnItem(status) - .observeOn(AndroidSchedulers.mainThread()) - .to( - AutoDispose.autoDisposable( - AndroidLifecycleScopeProvider.from(this, Lifecycle.Event.ON_DESTROY) - ) - ) - .subscribe() + lifecycleScope.launch { + timelineCases.muteConversation(status.id, status.muted != true) + } return@setOnMenuItemClickListener true } } @@ -309,7 +295,6 @@ abstract class SFragment : Fragment(), Injectable { } private fun onMute(accountId: String, accountUsername: String) { - showMuteAccountDialog(this.requireActivity(), accountUsername) { notifications: Boolean?, duration: Int? -> lifecycleScope.launch { timelineCases.mute(accountId, notifications == true, duration) @@ -339,7 +324,8 @@ abstract class SFragment : Fragment(), Injectable { view.transitionName = url val options = ActivityOptionsCompat.makeSceneTransitionAnimation( requireActivity(), - view, url + view, + url ) startActivity(intent, options.toBundle()) } else { diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewImageFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewImageFragment.kt index 28cf64ca..24c8feb0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewImageFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewImageFragment.kt @@ -26,6 +26,7 @@ import android.view.MotionEvent import android.view.View import android.view.ViewGroup import android.widget.ImageView +import androidx.core.os.BundleCompat import com.bumptech.glide.Glide import com.bumptech.glide.load.DataSource import com.bumptech.glide.load.engine.GlideException @@ -92,7 +93,7 @@ class ViewImageFragment : ViewMediaFragment() { super.onViewCreated(view, savedInstanceState) val arguments = this.requireArguments() - val attachment = arguments.getParcelable(ARG_ATTACHMENT) + val attachment = BundleCompat.getParcelable(arguments, ARG_ATTACHMENT, Attachment::class.java) this.shouldStartTransition = arguments.getBoolean(ARG_START_POSTPONED_TRANSITION) val url: String? var description: String? = null @@ -207,7 +208,7 @@ class ViewImageFragment : ViewMediaFragment() { .dontAnimate() .onlyRetrieveFromCache(true) .let { - if (previewUrl != null) + if (previewUrl != null) { it.thumbnail( glide .load(previewUrl) @@ -216,7 +217,9 @@ class ViewImageFragment : ViewMediaFragment() { .centerInside() .addListener(ImageRequestListener(true, isThumbnailRequest = true)) ) - else it + } else { + it + } } // Request image from the network on fail load image from cache .error( diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewMediaFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewMediaFragment.kt index 686e4fdf..2f8aaf1d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewMediaFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewMediaFragment.kt @@ -42,6 +42,7 @@ abstract class ViewMediaFragment : Fragment() { @JvmStatic protected val ARG_ATTACHMENT = "attach" + @JvmStatic protected val ARG_SINGLE_IMAGE_URL = "singleImageUrl" diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt index b3c0246d..68dc6687 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt @@ -57,10 +57,13 @@ class ViewVideoFragment : ViewMediaFragment() { mediaController.hide() } private lateinit var mediaActivity: ViewMediaActivity - private val TOOLBAR_HIDE_DELAY_MS = 3000L private lateinit var mediaController: MediaController private var isAudio = false + companion object { + private const val TOOLBAR_HIDE_DELAY_MS = 3000L + } + override fun onAttach(context: Context) { super.onAttach(context) videoActionsListener = context as VideoActionsListener @@ -70,8 +73,8 @@ class ViewVideoFragment : ViewMediaFragment() { super.onResume() if (_binding != null) { - if (mediaActivity.isToolbarVisible) { - handler.postDelayed(hideToolbar, TOOLBAR_HIDE_DELAY_MS) + if (mediaActivity.isToolbarVisible && !isAudio) { + hideToolbarAfterDelay(TOOLBAR_HIDE_DELAY_MS) } binding.videoView.start() } @@ -127,18 +130,17 @@ class ViewVideoFragment : ViewMediaFragment() { binding.videoView.setMediaController(mediaController) binding.videoView.requestFocus() binding.videoView.setPlayPauseListener(object : ExposedPlayPauseVideoView.PlayPauseListener { - override fun onPause() { - handler.removeCallbacks(hideToolbar) - } override fun onPlay() { - // Audio doesn't cause the controller to show automatically, - // and we only want to hide the toolbar if it's a video. - if (isAudio) { - mediaController.show() - } else { + if (!isAudio) { hideToolbarAfterDelay(TOOLBAR_HIDE_DELAY_MS) } } + + override fun onPause() { + if (!isAudio) { + handler.removeCallbacks(hideToolbar) + } + } }) binding.videoView.setOnPreparedListener { mp -> val containerWidth = binding.videoContainer.measuredWidth.toFloat() @@ -164,6 +166,11 @@ class ViewVideoFragment : ViewMediaFragment() { false } + // Audio doesn't cause the controller to show automatically + if (isAudio) { + mediaController.show() + } + binding.progressBar.hide() mp.isLooping = true } @@ -188,10 +195,8 @@ class ViewVideoFragment : ViewMediaFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val attachment = arguments?.getParcelable(ARG_ATTACHMENT) + ?: throw IllegalArgumentException("attachment has to be set") - if (attachment == null) { - throw IllegalArgumentException("attachment has to be set") - } val url = attachment.url isAudio = attachment.type == Attachment.Type.AUDIO diff --git a/app/src/main/java/com/keylesspalace/tusky/interfaces/FabFragment.kt b/app/src/main/java/com/keylesspalace/tusky/interfaces/FabFragment.kt new file mode 100644 index 00000000..1189dd3b --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/interfaces/FabFragment.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.interfaces + +interface FabFragment { + fun isFabVisible(): Boolean +} diff --git a/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java b/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java index 9171b420..e142683a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java +++ b/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java @@ -64,5 +64,7 @@ public interface StatusActionListener extends LinkListener { void onVoteInPoll(int position, @NonNull List choices); default void onShowEdits(int position) {} + + void clearWarningAction(int position); } diff --git a/app/src/main/java/com/keylesspalace/tusky/json/Iso8601Utils.kt b/app/src/main/java/com/keylesspalace/tusky/json/Iso8601Utils.kt index bd8df6b5..983333a9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/json/Iso8601Utils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/json/Iso8601Utils.kt @@ -108,7 +108,6 @@ internal fun String.parseIsoDate(): Date { return GregorianCalendar(year, month - 1, day).time } if (hasT) { - // extract hours, minutes, seconds and milliseconds hour = parseInt(this, 1.let { offset += it; offset }, 2.let { offset += it; offset }) if (checkOffset(this, offset, ':')) { diff --git a/app/src/main/java/com/keylesspalace/tusky/network/FilterModel.kt b/app/src/main/java/com/keylesspalace/tusky/network/FilterModel.kt index 16a439d9..26a7141e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/FilterModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/FilterModel.kt @@ -1,7 +1,7 @@ package com.keylesspalace.tusky.network -import android.text.TextUtils import com.keylesspalace.tusky.entity.Filter +import com.keylesspalace.tusky.entity.FilterV1 import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.util.parseAsMastodonHtml import java.util.Date @@ -16,36 +16,49 @@ import javax.inject.Inject */ class FilterModel @Inject constructor() { private var pattern: Pattern? = null + private var v1 = false + lateinit var kind: Filter.Kind - fun initWithFilters(filters: List) { + fun initWithFilters(filters: List) { + v1 = true this.pattern = makeFilter(filters) } - fun shouldFilterStatus(status: Status): Boolean { - // Patterns are expensive and thread-safe, matchers are neither. - val matcher = pattern?.matcher("") ?: return false + fun shouldFilterStatus(status: Status): Filter.Action { + if (v1) { + // Patterns are expensive and thread-safe, matchers are neither. + val matcher = pattern?.matcher("") ?: return Filter.Action.NONE - if (status.poll != null) { - val pollMatches = status.poll.options.any { matcher.reset(it.title).find() } - if (pollMatches) return true + if (status.poll?.options?.any { matcher.reset(it.title).find() } == true) { + return Filter.Action.HIDE + } + + val spoilerText = status.actionableStatus.spoilerText + val attachmentsDescriptions = status.attachments.mapNotNull { it.description } + + return if ( + matcher.reset(status.actionableStatus.content.parseAsMastodonHtml().toString()).find() || + (spoilerText.isNotEmpty() && matcher.reset(spoilerText).find()) || + (attachmentsDescriptions.isNotEmpty() && matcher.reset(attachmentsDescriptions.joinToString("\n")).find()) + ) { + Filter.Action.HIDE + } else { + Filter.Action.NONE + } } - val spoilerText = status.actionableStatus.spoilerText - val attachmentsDescriptions = status.attachments - .mapNotNull { it.description } + val matchingKind = status.filtered?.filter { result -> + result.filter.kinds.contains(kind) + } - return ( - matcher.reset(status.actionableStatus.content.parseAsMastodonHtml().toString()).find() || - (spoilerText.isNotEmpty() && matcher.reset(spoilerText).find()) || - ( - attachmentsDescriptions.isNotEmpty() && - matcher.reset(attachmentsDescriptions.joinToString("\n")) - .find() - ) - ) + return if (matchingKind.isNullOrEmpty()) { + Filter.Action.NONE + } else { + matchingKind.maxOf { it.filter.action } + } } - private fun filterToRegexToken(filter: Filter): String? { + private fun filterToRegexToken(filter: FilterV1): String? { val phrase = filter.phrase val quotedPhrase = Pattern.quote(phrase) return if (filter.wholeWord && ALPHANUMERIC.matcher(phrase).matches()) { @@ -55,14 +68,16 @@ class FilterModel @Inject constructor() { } } - private fun makeFilter(filters: List): Pattern? { + private fun makeFilter(filters: List): Pattern? { val now = Date() val nonExpiredFilters = filters.filter { it.expiresAt?.before(now) != true } if (nonExpiredFilters.isEmpty()) return null val tokens = nonExpiredFilters + .asSequence() .map { filterToRegexToken(it) } + .joinToString("|") - return Pattern.compile(TextUtils.join("|", tokens), Pattern.CASE_INSENSITIVE) + return Pattern.compile(tokens, Pattern.CASE_INSENSITIVE) } companion object { diff --git a/app/src/main/java/com/keylesspalace/tusky/network/InstanceSwitchAuthInterceptor.kt b/app/src/main/java/com/keylesspalace/tusky/network/InstanceSwitchAuthInterceptor.kt index 3ca7a811..6033165f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/InstanceSwitchAuthInterceptor.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/InstanceSwitchAuthInterceptor.kt @@ -34,7 +34,6 @@ class InstanceSwitchAuthInterceptor(private val accountManager: AccountManager) // only switch domains if the request comes from retrofit return if (originalRequest.url.host == MastodonApi.PLACEHOLDER_DOMAIN) { - val builder: Request.Builder = originalRequest.newBuilder() val instanceHeader = originalRequest.header(MastodonApi.DOMAIN_HEADER) diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt index 5633ad3d..a50ca796 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -25,6 +25,8 @@ import com.keylesspalace.tusky.entity.Conversation import com.keylesspalace.tusky.entity.DeletedStatus import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.Filter +import com.keylesspalace.tusky.entity.FilterKeyword +import com.keylesspalace.tusky.entity.FilterV1 import com.keylesspalace.tusky.entity.HashTag import com.keylesspalace.tusky.entity.Instance import com.keylesspalace.tusky.entity.Marker @@ -42,6 +44,7 @@ import com.keylesspalace.tusky.entity.StatusContext import com.keylesspalace.tusky.entity.StatusEdit import com.keylesspalace.tusky.entity.StatusSource import com.keylesspalace.tusky.entity.TimelineAccount +import com.keylesspalace.tusky.entity.TrendingTag import io.reactivex.rxjava3.core.Single import okhttp3.MultipartBody import okhttp3.RequestBody @@ -84,6 +87,9 @@ interface MastodonApi { suspend fun getInstance(@Header(DOMAIN_HEADER) domain: String? = null): NetworkResult @GET("api/v1/filters") + suspend fun getFiltersV1(): NetworkResult> + + @GET("api/v2/filters") suspend fun getFilters(): NetworkResult> @GET("api/v1/timelines/home") @@ -122,29 +128,49 @@ interface MastodonApi { ): Response> @GET("api/v1/notifications") - fun notifications( - @Query("max_id") maxId: String?, - @Query("since_id") sinceId: String?, - @Query("limit") limit: Int?, - @Query("exclude_types[]") excludes: Set? - ): Single>> + suspend fun notifications( + /** Return results older than this ID */ + @Query("max_id") maxId: String? = null, + /** Return results immediately newer than this ID */ + @Query("min_id") minId: String? = null, + /** Maximum number of results to return. Defaults to 15, max is 30 */ + @Query("limit") limit: Int? = null, + /** Types to excludes from the results */ + @Query("exclude_types[]") excludes: Set? = null + ): Response> + + /** Fetch a single notification */ + @GET("api/v1/notifications/{id}") + suspend fun notification( + @Path("id") id: String + ): Response @GET("api/v1/markers") - fun markersWithAuth( + suspend fun markersWithAuth( @Header("Authorization") auth: String, @Header(DOMAIN_HEADER) domain: String, @Query("timeline[]") timelines: List - ): Single> + ): Map - @GET("api/v1/notifications") - fun notificationsWithAuth( + @FormUrlEncoded + @POST("api/v1/markers") + suspend fun updateMarkersWithAuth( @Header("Authorization") auth: String, @Header(DOMAIN_HEADER) domain: String, - @Query("since_id") sinceId: String? - ): Single> + @Field("home[last_read_id]") homeLastReadId: String? = null, + @Field("notifications[last_read_id]") notificationsLastReadId: String? = null + ): NetworkResult + + @GET("api/v1/notifications") + suspend fun notificationsWithAuth( + @Header("Authorization") auth: String, + @Header(DOMAIN_HEADER) domain: String, + /** Return results immediately newer than this ID */ + @Query("min_id") minId: String? + ): Response> @POST("api/v1/notifications/clear") - fun clearNotifications(): Single + suspend fun clearNotifications(): Response @FormUrlEncoded @PUT("api/v1/media/{mediaId}") @@ -178,7 +204,7 @@ interface MastodonApi { @Header("Authorization") auth: String, @Header(DOMAIN_HEADER) domain: String, @Header("Idempotency-Key") idempotencyKey: String, - @Body editedStatus: NewStatus, + @Body editedStatus: NewStatus ): NetworkResult @GET("api/v1/statuses/{id}") @@ -219,54 +245,54 @@ interface MastodonApi { ): NetworkResult @POST("api/v1/statuses/{id}/reblog") - fun reblogStatus( + suspend fun reblogStatus( @Path("id") statusId: String - ): Single + ): NetworkResult @POST("api/v1/statuses/{id}/unreblog") - fun unreblogStatus( + suspend fun unreblogStatus( @Path("id") statusId: String - ): Single + ): NetworkResult @POST("api/v1/statuses/{id}/favourite") - fun favouriteStatus( + suspend fun favouriteStatus( @Path("id") statusId: String - ): Single + ): NetworkResult @POST("api/v1/statuses/{id}/unfavourite") - fun unfavouriteStatus( + suspend fun unfavouriteStatus( @Path("id") statusId: String - ): Single + ): NetworkResult @POST("api/v1/statuses/{id}/bookmark") - fun bookmarkStatus( + suspend fun bookmarkStatus( @Path("id") statusId: String - ): Single + ): NetworkResult @POST("api/v1/statuses/{id}/unbookmark") - fun unbookmarkStatus( + suspend fun unbookmarkStatus( @Path("id") statusId: String - ): Single + ): NetworkResult @POST("api/v1/statuses/{id}/pin") - fun pinStatus( + suspend fun pinStatus( @Path("id") statusId: String - ): Single + ): NetworkResult @POST("api/v1/statuses/{id}/unpin") - fun unpinStatus( + suspend fun unpinStatus( @Path("id") statusId: String - ): Single + ): NetworkResult @POST("api/v1/statuses/{id}/mute") - fun muteConversation( + suspend fun muteConversation( @Path("id") statusId: String - ): Single + ): NetworkResult @POST("api/v1/statuses/{id}/unmute") - fun unmuteConversation( + suspend fun unmuteConversation( @Path("id") statusId: String - ): Single + ): NetworkResult @GET("api/v1/scheduled_statuses") fun scheduledStatuses( @@ -282,7 +308,7 @@ interface MastodonApi { @GET("api/v1/accounts/verify_credentials") suspend fun accountVerifyCredentials( @Header(DOMAIN_HEADER) domain: String? = null, - @Header("Authorization") auth: String? = null, + @Header("Authorization") auth: String? = null ): NetworkResult @FormUrlEncoded @@ -290,7 +316,7 @@ interface MastodonApi { fun accountUpdateSource( @Field("source[privacy]") privacy: String?, @Field("source[sensitive]") sensitive: Boolean?, - @Field("source[language]") language: String?, + @Field("source[language]") language: String? ): Call @Multipart @@ -328,9 +354,9 @@ interface MastodonApi { ): NetworkResult> @GET("api/v1/accounts/{id}") - fun account( + suspend fun account( @Path("id") accountId: String - ): Single + ): NetworkResult /** * Method to fetch statuses for the specified account. @@ -370,22 +396,22 @@ interface MastodonApi { @Path("id") accountId: String, @Field("reblogs") showReblogs: Boolean? = null, @Field("notify") notify: Boolean? = null - ): Relationship + ): NetworkResult @POST("api/v1/accounts/{id}/unfollow") suspend fun unfollowAccount( @Path("id") accountId: String - ): Relationship + ): NetworkResult @POST("api/v1/accounts/{id}/block") suspend fun blockAccount( @Path("id") accountId: String - ): Relationship + ): NetworkResult @POST("api/v1/accounts/{id}/unblock") suspend fun unblockAccount( @Path("id") accountId: String - ): Relationship + ): NetworkResult @FormUrlEncoded @POST("api/v1/accounts/{id}/mute") @@ -393,27 +419,27 @@ interface MastodonApi { @Path("id") accountId: String, @Field("notifications") notifications: Boolean? = null, @Field("duration") duration: Int? = null - ): Relationship + ): NetworkResult @POST("api/v1/accounts/{id}/unmute") suspend fun unmuteAccount( @Path("id") accountId: String - ): Relationship + ): NetworkResult @GET("api/v1/accounts/relationships") - fun relationships( + suspend fun relationships( @Query("id[]") accountIds: List - ): Single> + ): NetworkResult> @POST("api/v1/pleroma/accounts/{id}/subscribe") suspend fun subscribeAccount( @Path("id") accountId: String - ): Relationship + ): NetworkResult @POST("api/v1/pleroma/accounts/{id}/unsubscribe") suspend fun unsubscribeAccount( @Path("id") accountId: String - ): Relationship + ): NetworkResult @GET("api/v1/blocks") suspend fun blocks( @@ -434,14 +460,14 @@ interface MastodonApi { @FormUrlEncoded @POST("api/v1/domain_blocks") - fun blockDomain( + suspend fun blockDomain( @Field("domain") domain: String - ): Call + ): NetworkResult @FormUrlEncoded // @DELETE doesn't support fields @HTTP(method = "DELETE", path = "api/v1/domain_blocks", hasBody = true) - fun unblockDomain(@Field("domain") domain: String): Call + suspend fun unblockDomain(@Field("domain") domain: String): NetworkResult @GET("api/v1/favourites") suspend fun favourites( @@ -561,36 +587,81 @@ interface MastodonApi { @FormUrlEncoded @POST("api/v1/filters") - suspend fun createFilter( + suspend fun createFilterV1( @Field("phrase") phrase: String, @Field("context[]") context: List, @Field("irreversible") irreversible: Boolean?, @Field("whole_word") wholeWord: Boolean?, @Field("expires_in") expiresInSeconds: Int? - ): NetworkResult + ): NetworkResult @FormUrlEncoded @PUT("api/v1/filters/{id}") - suspend fun updateFilter( + suspend fun updateFilterV1( @Path("id") id: String, @Field("phrase") phrase: String, @Field("context[]") context: List, @Field("irreversible") irreversible: Boolean?, @Field("whole_word") wholeWord: Boolean?, @Field("expires_in") expiresInSeconds: Int? - ): NetworkResult + ): NetworkResult @DELETE("api/v1/filters/{id}") + suspend fun deleteFilterV1( + @Path("id") id: String + ): NetworkResult + + @FormUrlEncoded + @POST("api/v2/filters") + suspend fun createFilter( + @Field("title") title: String, + @Field("context[]") context: List, + @Field("filter_action") filterAction: String, + @Field("expires_in") expiresInSeconds: Int? + ): NetworkResult + + @FormUrlEncoded + @PUT("api/v2/filters/{id}") + suspend fun updateFilter( + @Path("id") id: String, + @Field("title") title: String? = null, + @Field("context[]") context: List? = null, + @Field("filter_action") filterAction: String? = null, + @Field("expires_in") expiresInSeconds: Int? = null + ): NetworkResult + + @DELETE("api/v2/filters/{id}") suspend fun deleteFilter( @Path("id") id: String ): NetworkResult + @FormUrlEncoded + @POST("api/v2/filters/{filterId}/keywords") + suspend fun addFilterKeyword( + @Path("filterId") filterId: String, + @Field("keyword") keyword: String, + @Field("whole_word") wholeWord: Boolean + ): NetworkResult + + @FormUrlEncoded + @PUT("api/v2/filters/keywords/{keywordId}") + suspend fun updateFilterKeyword( + @Path("keywordId") keywordId: String, + @Field("keyword") keyword: String, + @Field("whole_word") wholeWord: Boolean + ): NetworkResult + + @DELETE("api/v2/filters/keywords/{keywordId}") + suspend fun deleteFilterKeyword( + @Path("keywordId") keywordId: String + ): NetworkResult + @FormUrlEncoded @POST("api/v1/polls/{id}/votes") - fun voteInPoll( + suspend fun voteInPoll( @Path("id") id: String, @Field("choices[]") choices: List - ): Single + ): NetworkResult @GET("api/v1/announcements") suspend fun listAnnouncements( @@ -616,12 +687,12 @@ interface MastodonApi { @FormUrlEncoded @POST("api/v1/reports") - fun reportObservable( + suspend fun report( @Field("account_id") accountId: String, @Field("status_ids[]") statusIds: List, @Field("comment") comment: String, @Field("forward") isNotifyRemote: Boolean? - ): Single + ): NetworkResult @GET("api/v1/accounts/{id}/statuses") fun accountStatusesObservable( @@ -660,10 +731,10 @@ interface MastodonApi { @FormUrlEncoded @POST("api/v1/accounts/{id}/note") - fun updateAccountNote( + suspend fun updateAccountNote( @Path("id") accountId: String, @Field("comment") note: String - ): Single + ): NetworkResult @FormUrlEncoded @POST("api/v1/push/subscription") @@ -690,7 +761,7 @@ interface MastodonApi { @DELETE("api/v1/push/subscription") suspend fun unsubscribePushNotifications( @Header("Authorization") auth: String, - @Header(DOMAIN_HEADER) domain: String, + @Header(DOMAIN_HEADER) domain: String ): NetworkResult @GET("api/v1/tags/{name}") @@ -701,7 +772,7 @@ interface MastodonApi { @Query("min_id") minId: String? = null, @Query("since_id") sinceId: String? = null, @Query("max_id") maxId: String? = null, - @Query("limit") limit: Int? = null, + @Query("limit") limit: Int? = null ): Response> @POST("api/v1/tags/{name}/follow") @@ -709,4 +780,7 @@ interface MastodonApi { @POST("api/v1/tags/{name}/unfollow") suspend fun unfollowTag(@Path("name") name: String): NetworkResult + + @GET("api/v1/trends/tags") + suspend fun trendingTags(): NetworkResult> } diff --git a/app/src/main/java/com/keylesspalace/tusky/network/ProgressRequestBody.java b/app/src/main/java/com/keylesspalace/tusky/network/ProgressRequestBody.java index d559d354..f499ed5e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/ProgressRequestBody.java +++ b/app/src/main/java/com/keylesspalace/tusky/network/ProgressRequestBody.java @@ -67,8 +67,10 @@ public final class ProgressRequestBody extends RequestBody { uploaded += read; sink.write(buffer, 0, read); } + + uploadListener.onProgressUpdate((int)(100 * uploaded / contentLength)); } finally { content.close(); } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/pager/MainPagerAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/pager/MainPagerAdapter.kt index 4fe92660..c635f929 100644 --- a/app/src/main/java/com/keylesspalace/tusky/pager/MainPagerAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/pager/MainPagerAdapter.kt @@ -20,7 +20,7 @@ import androidx.fragment.app.FragmentActivity import com.keylesspalace.tusky.TabData import com.keylesspalace.tusky.util.CustomFragmentStateAdapter -class MainPagerAdapter(val tabs: List, activity: FragmentActivity) : CustomFragmentStateAdapter(activity) { +class MainPagerAdapter(var tabs: List, activity: FragmentActivity) : CustomFragmentStateAdapter(activity) { override fun createFragment(position: Int): Fragment { val tab = tabs[position] diff --git a/app/src/main/java/com/keylesspalace/tusky/receiver/NotificationClearBroadcastReceiver.kt b/app/src/main/java/com/keylesspalace/tusky/receiver/NotificationClearBroadcastReceiver.kt deleted file mode 100644 index 6d4e9719..00000000 --- a/app/src/main/java/com/keylesspalace/tusky/receiver/NotificationClearBroadcastReceiver.kt +++ /dev/null @@ -1,42 +0,0 @@ -/* Copyright 2018 Conny Duck - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . */ - -package com.keylesspalace.tusky.receiver - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import com.keylesspalace.tusky.components.notifications.NotificationHelper -import com.keylesspalace.tusky.db.AccountManager -import dagger.android.AndroidInjection -import javax.inject.Inject - -class NotificationClearBroadcastReceiver : BroadcastReceiver() { - - @Inject - lateinit var accountManager: AccountManager - - override fun onReceive(context: Context, intent: Intent) { - AndroidInjection.inject(this, context) - - val accountId = intent.getLongExtra(NotificationHelper.ACCOUNT_ID, -1) - - val account = accountManager.getAccountById(accountId) - if (account != null) { - account.activeNotifications = "[]" - accountManager.saveAccount(account) - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt b/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt index a0a77373..f9349870 100644 --- a/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt +++ b/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt @@ -49,8 +49,8 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() { val senderFullName = intent.getStringExtra(NotificationHelper.KEY_SENDER_ACCOUNT_FULL_NAME) val citedStatusId = intent.getStringExtra(NotificationHelper.KEY_CITED_STATUS_ID) val visibility = intent.getSerializableExtra(NotificationHelper.KEY_VISIBILITY) as Status.Visibility - val spoiler = intent.getStringExtra(NotificationHelper.KEY_SPOILER) ?: "" - val mentions = intent.getStringArrayExtra(NotificationHelper.KEY_MENTIONS) ?: emptyArray() + val spoiler = intent.getStringExtra(NotificationHelper.KEY_SPOILER).orEmpty() + val mentions = intent.getStringArrayExtra(NotificationHelper.KEY_MENTIONS).orEmpty() val account = accountManager.getAccountById(senderId) @@ -97,7 +97,7 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() { idempotencyKey = randomAlphanumericString(16), retries = 0, language = null, - statusId = null, + statusId = null ) ) @@ -117,7 +117,8 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() { builder.setCategory(NotificationCompat.CATEGORY_SOCIAL) builder.setOnlyAlertOnce(true) - notificationManager.notify(notificationId, builder.build()) + // There is a separate "I am sending" notification, so simply remove the handled one. + notificationManager.cancel(notificationId) } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/receiver/UnifiedPushBroadcastReceiver.kt b/app/src/main/java/com/keylesspalace/tusky/receiver/UnifiedPushBroadcastReceiver.kt index 45a5ae2b..b95e5310 100644 --- a/app/src/main/java/com/keylesspalace/tusky/receiver/UnifiedPushBroadcastReceiver.kt +++ b/app/src/main/java/com/keylesspalace/tusky/receiver/UnifiedPushBroadcastReceiver.kt @@ -20,11 +20,11 @@ import android.content.Intent import android.util.Log import androidx.work.OneTimeWorkRequest import androidx.work.WorkManager -import com.keylesspalace.tusky.components.notifications.NotificationWorker import com.keylesspalace.tusky.components.notifications.registerUnifiedPushEndpoint import com.keylesspalace.tusky.components.notifications.unregisterUnifiedPushEndpoint import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.worker.NotificationWorker import dagger.android.AndroidInjection import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.GlobalScope diff --git a/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt b/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt index 42365aff..cf03115b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt +++ b/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt @@ -16,11 +16,13 @@ import android.util.Log import androidx.annotation.StringRes import androidx.core.app.NotificationCompat import androidx.core.app.ServiceCompat +import androidx.core.content.IntentCompat import at.connyduck.calladapter.networkresult.fold import com.keylesspalace.tusky.MainActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.StatusComposedEvent +import com.keylesspalace.tusky.appstore.StatusEditedEvent import com.keylesspalace.tusky.appstore.StatusScheduledEvent import com.keylesspalace.tusky.components.compose.MediaUploader import com.keylesspalace.tusky.components.compose.UploadEvent @@ -29,10 +31,12 @@ import com.keylesspalace.tusky.components.notifications.NotificationHelper import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.entity.Attachment +import com.keylesspalace.tusky.entity.MediaAttribute import com.keylesspalace.tusky.entity.NewPoll import com.keylesspalace.tusky.entity.NewStatus import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.unsafeLazy import dagger.android.AndroidInjection import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -50,12 +54,16 @@ class SendStatusService : Service(), Injectable { @Inject lateinit var mastodonApi: MastodonApi + @Inject lateinit var accountManager: AccountManager + @Inject lateinit var eventHub: EventHub + @Inject lateinit var draftHelper: DraftHelper + @Inject lateinit var mediaUploader: MediaUploader @@ -65,7 +73,7 @@ class SendStatusService : Service(), Injectable { private val statusesToSend = ConcurrentHashMap() private val sendJobs = ConcurrentHashMap() - private val notificationManager by lazy { getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager } + private val notificationManager by unsafeLazy { getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager } override fun onCreate() { AndroidInjection.inject(this) @@ -76,7 +84,7 @@ class SendStatusService : Service(), Injectable { override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { if (intent.hasExtra(KEY_STATUS)) { - val statusToSend: StatusToSend = intent.getParcelableExtra(KEY_STATUS) + val statusToSend: StatusToSend = IntentCompat.getParcelableExtra(intent, KEY_STATUS, StatusToSend::class.java) ?: throw IllegalStateException("SendStatusService started without $KEY_STATUS extra") if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { @@ -108,7 +116,6 @@ class SendStatusService : Service(), Injectable { statusesToSend[sendingNotificationId] = statusToSend sendStatus(sendingNotificationId--) } else { - if (intent.hasExtra(KEY_CANCEL)) { cancelSending(intent.getIntExtra(KEY_CANCEL, 0)) } @@ -118,7 +125,6 @@ class SendStatusService : Service(), Injectable { } private fun sendStatus(statusId: Int) { - // when statusToSend == null, sending has been canceled val statusToSend = statusesToSend[statusId] ?: return @@ -135,7 +141,6 @@ class SendStatusService : Service(), Injectable { statusToSend.retries++ sendJobs[statusId] = serviceScope.launch { - // first, wait for media uploads to finish val media = statusToSend.media.map { mediaItem -> if (mediaItem.id == null) { @@ -171,7 +176,7 @@ class SendStatusService : Service(), Injectable { } } } - mediaCheckRetries ++ + mediaCheckRetries++ } } catch (e: Exception) { Log.w(TAG, "failed getting media status", e) @@ -179,6 +184,23 @@ class SendStatusService : Service(), Injectable { return@launch } + val isNew = statusToSend.statusId == null + + if (isNew) { + media.forEach { mediaItem -> + if (mediaItem.processed && (mediaItem.description != null || mediaItem.focus != null)) { + mastodonApi.updateMedia(mediaItem.id!!, mediaItem.description, mediaItem.focus?.toMastodonApiString()) + .fold({ + }, { throwable -> + Log.w(TAG, "failed to update media on status send", throwable) + failOrRetry(throwable, statusId) + + return@launch + }) + } + } + } + // finally, send the new status val newStatus = NewStatus( status = statusToSend.text, @@ -190,9 +212,17 @@ class SendStatusService : Service(), Injectable { scheduledAt = statusToSend.scheduledAt, poll = statusToSend.poll, language = statusToSend.language, + mediaAttributes = media.map { media -> + MediaAttribute( + id = media.id!!, + description = media.description, + focus = media.focus?.toMastodonApiString(), + thumbnail = null + ) + } ) - val sendResult = if (statusToSend.statusId == null) { + val sendResult = if (isNew) { mastodonApi.createStatus( "Bearer " + account.accessToken, account.domain, @@ -201,7 +231,7 @@ class SendStatusService : Service(), Injectable { ) } else { mastodonApi.editStatus( - statusToSend.statusId, + statusToSend.statusId!!, "Bearer " + account.accessToken, account.domain, statusToSend.idempotencyKey, @@ -222,6 +252,8 @@ class SendStatusService : Service(), Injectable { if (scheduled) { eventHub.dispatch(StatusScheduledEvent(sentStatus)) + } else if (!isNew) { + eventHub.dispatch(StatusEditedEvent(statusToSend.statusId!!, sentStatus)) } else { eventHub.dispatch(StatusComposedEvent(sentStatus)) } @@ -229,18 +261,22 @@ class SendStatusService : Service(), Injectable { notificationManager.cancel(statusId) }, { throwable -> Log.w(TAG, "failed sending status", throwable) - if (throwable is HttpException) { - // the server refused to accept the status, save status & show error message - failSending(statusId) - } else { - // a network problem occurred, let's retry sending the status - retrySending(statusId) - } + failOrRetry(throwable, statusId) }) stopSelfWhenDone() } } + private suspend fun failOrRetry(throwable: Throwable, statusId: Int) { + if (throwable is HttpException) { + // the server refused to accept, save status & show error message + failSending(statusId) + } else { + // a network problem occurred, let's retry sending the status + retrySending(statusId) + } + } + private suspend fun retrySending(statusId: Int) { // when statusToSend == null, sending has been canceled val statusToSend = statusesToSend[statusId] ?: return @@ -252,7 +288,6 @@ class SendStatusService : Service(), Injectable { } private fun stopSelfWhenDone() { - if (statusesToSend.isEmpty()) { ServiceCompat.stopForeground(this@SendStatusService, ServiceCompat.STOP_FOREGROUND_REMOVE) stopSelf() @@ -262,7 +297,6 @@ class SendStatusService : Service(), Injectable { private suspend fun failSending(statusId: Int) { val failedStatus = statusesToSend.remove(statusId) if (failedStatus != null) { - mediaUploader.cancelUploadScope(*failedStatus.media.map { it.localId }.toIntArray()) saveStatusToDrafts(failedStatus, failedToSendAlert = true) @@ -277,12 +311,14 @@ class SendStatusService : Service(), Injectable { notificationManager.cancel(statusId) notificationManager.notify(errorNotificationId++, notification) } + + // NOTE only this removes the "Sending..." notification (added with startForeground() above) + stopSelfWhenDone() } private fun cancelSending(statusId: Int) = serviceScope.launch { val statusToCancel = statusesToSend.remove(statusId) if (statusToCancel != null) { - mediaUploader.cancelUploadScope(*statusToCancel.media.map { it.localId }.toIntArray()) val sendJob = sendJobs.remove(statusId) @@ -322,7 +358,7 @@ class SendStatusService : Service(), Injectable { failedToSendAlert = failedToSendAlert, scheduledAt = status.scheduledAt, language = status.language, - statusId = status.statusId, + statusId = status.statusId ) } @@ -343,7 +379,6 @@ class SendStatusService : Service(), Injectable { accountId: Long, statusId: Int ): Notification { - val intent = Intent(this, MainActivity::class.java) intent.putExtra(NotificationHelper.ACCOUNT_ID, accountId) intent.putExtra(MainActivity.OPEN_DRAFTS, true) @@ -428,7 +463,7 @@ data class StatusToSend( val idempotencyKey: String, var retries: Int, val language: String?, - val statusId: String?, + val statusId: String? ) : Parcelable @Parcelize diff --git a/app/src/main/java/com/keylesspalace/tusky/settings/AccountPreferenceDataStore.kt b/app/src/main/java/com/keylesspalace/tusky/settings/AccountPreferenceDataStore.kt new file mode 100644 index 00000000..a9513441 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/settings/AccountPreferenceDataStore.kt @@ -0,0 +1,42 @@ +package com.keylesspalace.tusky.settings + +import androidx.preference.PreferenceDataStore +import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.appstore.PreferenceChangedEvent +import com.keylesspalace.tusky.db.AccountEntity +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.di.ApplicationScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject + +class AccountPreferenceDataStore @Inject constructor( + private val accountManager: AccountManager, + private val eventHub: EventHub, + @ApplicationScope private val externalScope: CoroutineScope +) : PreferenceDataStore() { + private val account: AccountEntity = accountManager.activeAccount!! + + override fun getBoolean(key: String, defValue: Boolean): Boolean { + return when (key) { + PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA -> account.alwaysShowSensitiveMedia + PrefKeys.ALWAYS_OPEN_SPOILER -> account.alwaysOpenSpoiler + PrefKeys.MEDIA_PREVIEW_ENABLED -> account.mediaPreviewEnabled + else -> defValue + } + } + + override fun putBoolean(key: String, value: Boolean) { + when (key) { + PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA -> account.alwaysShowSensitiveMedia = value + PrefKeys.ALWAYS_OPEN_SPOILER -> account.alwaysOpenSpoiler = value + PrefKeys.MEDIA_PREVIEW_ENABLED -> account.mediaPreviewEnabled = value + } + + accountManager.saveAccount(account) + + externalScope.launch { + eventHub.dispatch(PreferenceChangedEvent(key)) + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/settings/ProxyConfiguration.kt b/app/src/main/java/com/keylesspalace/tusky/settings/ProxyConfiguration.kt index f1e04bd5..fbe8084b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/settings/ProxyConfiguration.kt +++ b/app/src/main/java/com/keylesspalace/tusky/settings/ProxyConfiguration.kt @@ -14,9 +14,13 @@ class ProxyConfiguration private constructor( return null } fun isValidProxyPort(value: Any): Boolean = when (value) { - is String -> if (value == "") true else value.runCatching(String::toInt).map( - PROXY_RANGE::contains - ).getOrDefault(false) + is String -> if (value == "") { + true + } else { + value.runCatching(String::toInt).map( + PROXY_RANGE::contains + ).getOrDefault(false) + } is Int -> PROXY_RANGE.contains(value) else -> false } diff --git a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt index 6df811d6..1a64f69b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt +++ b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt @@ -12,10 +12,42 @@ enum class AppTheme(val value: String) { } } +/** + * Current preferences schema version. Format is 4-digit year + 2 digit month (zero padded) + 2 + * digit day (zero padded) + 2 digit counter (zero padded). + * + * If you make an incompatible change to the preferences schema you must: + * + * - Update this value + * - Update the code in + * [TuskyApplication.upgradeSharedPreferences][com.keylesspalace.tusky.TuskyApplication.upgradeSharedPreferences] + * to migrate from the old schema version to the new schema version. + * + * An incompatible change is: + * + * - Deleting a preference. The migration should delete the old preference. + * - Changing a preference's default value (e.g., from true to false, or from one enum value to + * another). The migration should check to see if the user had set an explicit value for + * that preference ([SharedPreferences.contains][android.content.SharedPreferences.contains]); + * if they hadn't then the migration should set the *old* default value as the preference's + * value, so the app behaviour does not unexpectedly change. + * - Changing a preference's type (e.g,. from a boolean to an enum). If you do this you may want + * to give the preference a different name, but you still need to migrate the user's previous + * preference value to the new preference. + * - Renaming a preference key. The migration should copy the user's previous value for the + * preference under the old key to the value for the new, and delete the old preference. + * + * A compatible change is: + * + * - Adding a new preference that does not change the interpretation of an existing preference + */ +const val SCHEMA_VERSION = 2023022701 + object PrefKeys { // Note: not all of these keys are actually used as SharedPreferences keys but we must give // each preference a key for it to work. + const val SCHEMA_VERSION: String = "schema_version" const val APP_THEME = "appTheme" const val EMOJI = "selected_emoji_font" const val FAB_HIDE = "fabHide" @@ -35,6 +67,7 @@ object PrefKeys { const val CONFIRM_FAVOURITES = "confirmFavourites" const val ENABLE_SWIPE_FOR_TABS = "enableSwipeForTabs" const val ANIMATE_CUSTOM_EMOJIS = "animateCustomEmojis" + const val SHOW_STATS_INLINE = "showStatsInline" const val CUSTOM_TABS = "customTabs" const val WELLBEING_LIMITED_NOTIFICATIONS = "wellbeingModeLimitedNotifications" @@ -68,4 +101,7 @@ object PrefKeys { const val TAB_FILTER_HOME_REPLIES = "tabFilterHomeReplies_v2" // This was changed once to reset an unintentionally set default. const val TAB_FILTER_HOME_BOOSTS = "tabFilterHomeBoosts" + + /** UI text scaling factor, stored as float, 100 = 100% = no scaling */ + const val UI_TEXT_SCALE_RATIO = "uiTextScaleRatio" } diff --git a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsDSL.kt b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsDSL.kt index fc7a51c5..720dc817 100644 --- a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsDSL.kt +++ b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsDSL.kt @@ -14,6 +14,7 @@ import androidx.preference.PreferenceCategory import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceScreen import androidx.preference.SwitchPreference +import com.keylesspalace.tusky.view.SliderPreference import de.c1710.filemojicompat_ui.views.picker.preference.EmojiPickerPreference class PreferenceParent( @@ -43,6 +44,15 @@ inline fun PreferenceParent.emojiPreference(activity: A, builder: EmojiPicke return pref } +inline fun PreferenceParent.sliderPreference( + builder: SliderPreference.() -> Unit +): SliderPreference { + val pref = SliderPreference(context) + builder(pref) + addPref(pref) + return pref +} + inline fun PreferenceParent.switchPreference( builder: SwitchPreference.() -> Unit ): SwitchPreference { diff --git a/app/src/main/java/com/keylesspalace/tusky/usecase/TimelineCases.kt b/app/src/main/java/com/keylesspalace/tusky/usecase/TimelineCases.kt index 45842f8e..fc6ccbf3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/usecase/TimelineCases.kt +++ b/app/src/main/java/com/keylesspalace/tusky/usecase/TimelineCases.kt @@ -17,6 +17,7 @@ package com.keylesspalace.tusky.usecase import android.util.Log import at.connyduck.calladapter.networkresult.NetworkResult +import at.connyduck.calladapter.networkresult.fold import at.connyduck.calladapter.networkresult.onFailure import at.connyduck.calladapter.networkresult.onSuccess import com.keylesspalace.tusky.appstore.BlockEvent @@ -31,11 +32,11 @@ import com.keylesspalace.tusky.appstore.ReblogEvent import com.keylesspalace.tusky.appstore.StatusDeletedEvent import com.keylesspalace.tusky.entity.DeletedStatus import com.keylesspalace.tusky.entity.Poll +import com.keylesspalace.tusky.entity.Relationship import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.getServerErrorMessage import io.reactivex.rxjava3.core.Single -import io.reactivex.rxjava3.disposables.CompositeDisposable import javax.inject.Inject /** @@ -47,52 +48,42 @@ class TimelineCases @Inject constructor( private val eventHub: EventHub ) { - /** - * Unused yet but can be use for cancellation later. It's always a good idea to save - * Disposables. - */ - private val cancelDisposable = CompositeDisposable() - - fun reblog(statusId: String, reblog: Boolean): Single { - val call = if (reblog) { + suspend fun reblog(statusId: String, reblog: Boolean): NetworkResult { + return if (reblog) { mastodonApi.reblogStatus(statusId) } else { mastodonApi.unreblogStatus(statusId) - } - return call.doAfterSuccess { + }.onSuccess { eventHub.dispatch(ReblogEvent(statusId, reblog)) } } - fun favourite(statusId: String, favourite: Boolean): Single { - val call = if (favourite) { + suspend fun favourite(statusId: String, favourite: Boolean): NetworkResult { + return if (favourite) { mastodonApi.favouriteStatus(statusId) } else { mastodonApi.unfavouriteStatus(statusId) - } - return call.doAfterSuccess { + }.onSuccess { eventHub.dispatch(FavoriteEvent(statusId, favourite)) } } - fun bookmark(statusId: String, bookmark: Boolean): Single { - val call = if (bookmark) { + suspend fun bookmark(statusId: String, bookmark: Boolean): NetworkResult { + return if (bookmark) { mastodonApi.bookmarkStatus(statusId) } else { mastodonApi.unbookmarkStatus(statusId) - } - return call.doAfterSuccess { + }.onSuccess { eventHub.dispatch(BookmarkEvent(statusId, bookmark)) } } - fun muteConversation(statusId: String, mute: Boolean): Single { - val call = if (mute) { + suspend fun muteConversation(statusId: String, mute: Boolean): NetworkResult { + return if (mute) { mastodonApi.muteConversation(statusId) } else { mastodonApi.unmuteConversation(statusId) - } - return call.doAfterSuccess { + }.onSuccess { eventHub.dispatch(MuteConversationEvent(statusId, mute)) } } @@ -121,30 +112,36 @@ class TimelineCases @Inject constructor( .onFailure { Log.w(TAG, "Failed to delete status", it) } } - fun pin(statusId: String, pin: Boolean): Single { - // Replace with extension method if we use RxKotlin - return (if (pin) mastodonApi.pinStatus(statusId) else mastodonApi.unpinStatus(statusId)) - .doOnError { e -> - Log.w(TAG, "Failed to change pin state", e) - } - .onErrorResumeNext(::convertError) - .doAfterSuccess { - eventHub.dispatch(PinEvent(statusId, pin)) - } + suspend fun pin(statusId: String, pin: Boolean): NetworkResult { + return if (pin) { + mastodonApi.pinStatus(statusId) + } else { + mastodonApi.unpinStatus(statusId) + }.fold({ status -> + eventHub.dispatch(PinEvent(statusId, pin)) + NetworkResult.success(status) + }, { e -> + Log.w(TAG, "Failed to change pin state", e) + NetworkResult.failure(TimelineError(e.getServerErrorMessage())) + }) } - fun voteInPoll(statusId: String, pollId: String, choices: List): Single { + suspend fun voteInPoll(statusId: String, pollId: String, choices: List): NetworkResult { if (choices.isEmpty()) { - return Single.error(IllegalStateException()) + return NetworkResult.failure(IllegalStateException()) } - return mastodonApi.voteInPoll(pollId, choices).doAfterSuccess { - eventHub.dispatch(PollVoteEvent(statusId, it)) + return mastodonApi.voteInPoll(pollId, choices).onSuccess { poll -> + eventHub.dispatch(PollVoteEvent(statusId, poll)) } } - private fun convertError(e: Throwable): Single { - return Single.error(TimelineError(e.getServerErrorMessage())) + fun acceptFollowRequest(accountId: String): Single { + return mastodonApi.authorizeFollowRequest(accountId) + } + + fun rejectFollowRequest(accountId: String): Single { + return mastodonApi.rejectFollowRequest(accountId) } companion object { diff --git a/app/src/main/java/com/keylesspalace/tusky/util/CustomEmojiHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/CustomEmojiHelper.kt index df7b4d9f..bd5facb7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/CustomEmojiHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/CustomEmojiHelper.kt @@ -1,5 +1,5 @@ /* Copyright 2020 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 @@ -14,6 +14,7 @@ * see . */ @file:JvmName("CustomEmojiHelper") + package com.keylesspalace.tusky.util import android.graphics.Canvas @@ -39,8 +40,9 @@ import java.util.regex.Pattern * @return the text with the shortcodes replaced by EmojiSpans */ fun CharSequence.emojify(emojis: List?, view: View, animate: Boolean): CharSequence { - if (emojis.isNullOrEmpty()) + if (emojis.isNullOrEmpty()) { return this + } val builder = SpannableStringBuilder.valueOf(this) @@ -82,11 +84,26 @@ class EmojiSpan(val viewWeakReference: WeakReference) : ReplacementSpan() imageDrawable?.let { drawable -> canvas.save() - val emojiSize = (paint.textSize * 1.1).toInt() - drawable.setBounds(0, 0, emojiSize, emojiSize) + // start with a width relative to the text size + var emojiWidth = paint.textSize * 1.1 - var transY = bottom - drawable.bounds.bottom - transY -= paint.fontMetricsInt.descent / 2 + // calculate the height, keeping the aspect ratio correct + val drawableWidth = drawable.intrinsicWidth + val drawableHeight = drawable.intrinsicHeight + var emojiHeight = emojiWidth / drawableWidth * drawableHeight + + // how much vertical space there is draw the emoji + val drawableSpace = (bottom - top).toDouble() + + // in case the calculated height is bigger than the available space, scale the emoji down, preserving aspect ratio + if (emojiHeight > drawableSpace) { + emojiWidth *= drawableSpace / emojiHeight + emojiHeight = drawableSpace + } + drawable.setBounds(0, 0, emojiWidth.toInt(), emojiHeight.toInt()) + + // vertically center the emoji in the line + val transY = top + (drawableSpace / 2 - emojiHeight / 2) canvas.translate(x, transY.toFloat()) drawable.draw(canvas) diff --git a/app/src/main/java/com/keylesspalace/tusky/util/FlowExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/FlowExtensions.kt new file mode 100644 index 00000000..7fcf7735 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/FlowExtensions.kt @@ -0,0 +1,69 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.util + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlin.time.Duration +import kotlin.time.ExperimentalTime +import kotlin.time.TimeMark +import kotlin.time.TimeSource + +/** + * Returns a flow that mirrors the original flow, but filters out values that occur within + * [timeout] of the previously emitted value. The first value is always emitted. + * + * Example: + * + * ```kotlin + * flow { + * emit(1) + * delay(90.milliseconds) + * emit(2) + * delay(90.milliseconds) + * emit(3) + * delay(1010.milliseconds) + * emit(4) + * delay(1010.milliseconds) + * emit(5) + * }.throttleFirst(1000.milliseconds) + * ``` + * + * produces the following emissions. + * + * ```text + * 1, 4, 5 + * ``` + * + * @see kotlinx.coroutines.flow.debounce(Duration) + * @param timeout Emissions within this duration of the last emission are filtered + * @param timeSource Used to measure elapsed time. Normally only overridden in tests + */ +@OptIn(ExperimentalTime::class) +fun Flow.throttleFirst( + timeout: Duration, + timeSource: TimeSource = TimeSource.Monotonic +) = flow { + var marker: TimeMark? = null + collect { + if (marker == null || marker!!.elapsedNow() >= timeout) { + emit(it) + marker = timeSource.markNow() + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/HttpHeaderLink.kt b/app/src/main/java/com/keylesspalace/tusky/util/HttpHeaderLink.kt index 4f5e9920..a5ccd35b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/HttpHeaderLink.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/HttpHeaderLink.kt @@ -24,7 +24,9 @@ import androidx.core.net.toUri * * @see [RFC5988](https://tools.ietf.org/html/rfc5988) */ -class HttpHeaderLink @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) constructor( +class HttpHeaderLink +@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) +constructor( uri: String ) { data class Parameter(val name: String, val value: String?) diff --git a/app/src/main/java/com/keylesspalace/tusky/util/IOUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/IOUtils.kt index 005554bf..ece76bdf 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/IOUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/IOUtils.kt @@ -36,7 +36,7 @@ fun Closeable?.closeQuietly() { fun Uri.copyToFile( contentResolver: ContentResolver, - file: File, + file: File ): Boolean { val from: InputStream? val to: FileOutputStream diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ImageLoadingHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/ImageLoadingHelper.kt index 1cd9b99a..1430801c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ImageLoadingHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ImageLoadingHelper.kt @@ -14,7 +14,6 @@ import com.keylesspalace.tusky.R private val centerCropTransformation = CenterCrop() fun loadAvatar(url: String?, imageView: ImageView, @Px radius: Int, animate: Boolean) { - if (url.isNullOrBlank()) { Glide.with(imageView) .load(R.drawable.avatar_default) diff --git a/app/src/main/java/com/keylesspalace/tusky/util/Lazy.kt b/app/src/main/java/com/keylesspalace/tusky/util/Lazy.kt new file mode 100644 index 00000000..87445566 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/Lazy.kt @@ -0,0 +1,5 @@ +package com.keylesspalace.tusky.util + +@Suppress("NOTHING_TO_INLINE") +inline fun unsafeLazy(noinline initializer: () -> T): Lazy = + lazy(LazyThreadSafetyMode.NONE, initializer) diff --git a/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt index 3e994d09..04176a11 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt @@ -11,7 +11,8 @@ * Public License for more details. * * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . */ + * see . + */ @file:JvmName("LinkHelper") package com.keylesspalace.tusky.util @@ -21,12 +22,15 @@ import android.content.Context import android.content.Intent import android.graphics.Color import android.net.Uri +import android.text.Spannable import android.text.SpannableStringBuilder import android.text.Spanned import android.text.method.LinkMovementMethod import android.text.style.ClickableSpan import android.text.style.URLSpan import android.util.Log +import android.view.MotionEvent +import android.view.MotionEvent.ACTION_UP import android.view.View import android.widget.TextView import androidx.annotation.VisibleForTesting @@ -68,7 +72,7 @@ fun setClickableText(view: TextView, content: CharSequence, mentions: List= length || subSequence(end, end + 1).toString() == "\n") { - insert(end, "\u200B") - } } @VisibleForTesting @@ -159,7 +156,7 @@ private fun getCustomSpanForMention(mentions: List, span: URLSpan, list } private fun getCustomSpanForMentionUrl(url: String, mentionId: String, listener: LinkListener): ClickableSpan { - return object : NoUnderlineURLSpan(url) { + return object : MentionSpan(url) { override fun onClick(view: View) = listener.onViewAccount(mentionId) } } @@ -199,12 +196,10 @@ fun setClickableMentions(view: TextView, mentions: List?, listener: Lin append("@") append(mention.localUsername) setSpan(customSpan, start, end, flags) - append("\u200B") // same reasoning as in setClickableText - end += 1 // shift position to take the previous character into account start = end } } - view.movementMethod = LinkMovementMethod.getInstance() + view.movementMethod = NoTrailingSpaceLinkMovementMethod.getInstance() } fun createClickableText(text: String, link: String): CharSequence { @@ -253,7 +248,7 @@ private fun openLinkInBrowser(uri: Uri?, context: Context) { * @param context context */ fun openLinkInCustomTab(uri: Uri, context: Context) { - val toolbarColor = MaterialColors.getColor(context, R.attr.colorSurface, Color.BLACK) + val toolbarColor = MaterialColors.getColor(context, com.google.android.material.R.attr.colorSurface, Color.BLACK) val navigationbarColor = MaterialColors.getColor(context, android.R.attr.navigationBarColor, Color.BLACK) val navigationbarDividerColor = MaterialColors.getColor(context, R.attr.dividerColor, Color.BLACK) val colorSchemeParams = CustomTabColorSchemeParams.Builder() @@ -322,3 +317,35 @@ fun looksLikeMastodonUrl(urlString: String): Boolean { } private const val TAG = "LinkHelper" + +/** + * [LinkMovementMethod] that doesn't add a leading/trailing clickable area. + * + * [LinkMovementMethod] has a bug in its calculation of the clickable width of a span on a line. If + * the span is the last thing on the line the clickable area extends to the end of the view. So the + * user can tap what appears to be whitespace and open a link. + * + * Fix this by overriding ACTION_UP touch events and calculating the true start and end of the + * content on the line that was tapped. Then ignore clicks that are outside this area. + * + * See https://github.com/tuskyapp/Tusky/issues/1567. + */ +object NoTrailingSpaceLinkMovementMethod : LinkMovementMethod() { + override fun onTouchEvent(widget: TextView, buffer: Spannable, event: MotionEvent): Boolean { + val action = event.action + if (action != ACTION_UP) return super.onTouchEvent(widget, buffer, event) + + val x = event.x.toInt() + val y = event.y.toInt() - widget.totalPaddingTop + widget.scrollY + val line = widget.layout.getLineForVertical(y) + val lineLeft = widget.layout.getLineLeft(line) + val lineRight = widget.layout.getLineRight(line) + if (x > lineRight || x >= 0 && x < lineLeft) { + return true + } + + return super.onTouchEvent(widget, buffer, event) + } + + fun getInstance() = NoTrailingSpaceLinkMovementMethod +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt b/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt index ed10bd4e..9402edd0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt @@ -38,7 +38,7 @@ class ListStatusAccessibilityDelegate( private val context: Context get() = recyclerView.context - private val itemDelegate = object : RecyclerViewAccessibilityDelegate.ItemDelegate(this) { + private val itemDelegate = object : ItemDelegate(this) { override fun onInitializeAccessibilityNodeInfo( host: View, info: AccessibilityNodeInfoCompat @@ -196,7 +196,8 @@ class ListStatusAccessibilityDelegate( .setAdapter( ArrayAdapter( host.context, - android.R.layout.simple_list_item_1, stringMentions + android.R.layout.simple_list_item_1, + stringMentions ) ) { _, which -> statusActionListener.onViewAccount(mentions[which].id) @@ -213,7 +214,8 @@ class ListStatusAccessibilityDelegate( .setAdapter( ArrayAdapter( host.context, - android.R.layout.simple_list_item_1, tags + android.R.layout.simple_list_item_1, + tags ) ) { _, which -> statusActionListener.onViewTag(tags[which].toString()) diff --git a/app/src/main/java/com/keylesspalace/tusky/util/LocaleExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/LocaleExtensions.kt index caab2192..800e7a4e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/LocaleExtensions.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/LocaleExtensions.kt @@ -30,7 +30,7 @@ val Locale.modernLanguageCode: String fun Locale.getTuskyDisplayName(context: Context): String { return context.getString( R.string.language_display_name_format, - this?.displayLanguage, - this?.getDisplayLanguage(this) + displayLanguage, + getDisplayLanguage(this) ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/LocaleManager.kt b/app/src/main/java/com/keylesspalace/tusky/util/LocaleManager.kt index 6795317b..8a5dc3d2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/LocaleManager.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/LocaleManager.kt @@ -54,7 +54,6 @@ class LocaleManager @Inject constructor( } override fun putString(key: String?, value: String?) { - // if we are on Android < 13 we have to save the selected language so we can apply it at appstart // on Android 13+ the system handles it for us if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { diff --git a/app/src/main/java/com/keylesspalace/tusky/util/LocaleUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/LocaleUtils.kt index 316e14d0..655348b4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/LocaleUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/LocaleUtils.kt @@ -23,67 +23,57 @@ import java.util.Locale private const val TAG: String = "LocaleUtils" -private fun mergeLocaleListCompat(list: MutableList, localeListCompat: LocaleListCompat) { - for (index in 0 until localeListCompat.size()) { - val locale = localeListCompat[index] - if (locale != null && list.none { locale.language == it.language }) { - list.add(locale) - } +private fun LocaleListCompat.toList(): List { + val list = mutableListOf() + for (index in 0 until this.size()) { + this[index]?.let { list.add(it) } } + return list } // Ensure that the locale whose code matches the given language is first in the list -private fun ensureLanguageIsFirst(locales: MutableList, language: String) { - var currentLocaleIndex = locales.indexOfFirst { it.language == language } - if (currentLocaleIndex < 0) { - // Recheck against modern language codes - // This should only happen when replying or when the per-account post language is set - // to a modern code - currentLocaleIndex = locales.indexOfFirst { it.modernLanguageCode == language } - +private fun ensureLanguagesAreFirst(locales: MutableList, languages: List) { + for (language in languages.reversed()) { + // Iterate prioritized languages in reverse to retain the order once bubbled to the top + var currentLocaleIndex = locales.indexOfFirst { it.language == language } if (currentLocaleIndex < 0) { - // This can happen when: - // - Your per-account posting language is set to one android doesn't know (e.g. toki pona) - // - Replying to a post in a language android doesn't know - locales.add(0, Locale(language)) - Log.w(TAG, "Attempting to use unknown language tag '$language'") - return - } - } + // Recheck against modern language codes + // This should only happen when replying or when the per-account post language is set + // to a modern code + currentLocaleIndex = locales.indexOfFirst { it.modernLanguageCode == language } - if (currentLocaleIndex > 0) { - // Move preselected locale to the top - locales.add(0, locales.removeAt(currentLocaleIndex)) + if (currentLocaleIndex < 0) { + // This can happen when: + // - Your per-account posting language is set to one android doesn't know (e.g. toki pona) + // - Replying to a post in a language android doesn't know + locales.add(0, Locale(language)) + Log.w(TAG, "Attempting to use unknown language tag '$language'") + continue + } + } + + if (currentLocaleIndex > 0) { + // Move preselected locale to the top + locales.add(0, locales.removeAt(currentLocaleIndex)) + } } } -fun getInitialLanguage(language: String? = null, activeAccount: AccountEntity? = null): String { - return if (language.isNullOrEmpty()) { - // Account-specific language set on the server - if (activeAccount?.defaultPostLanguage?.isNotEmpty() == true) { - activeAccount.defaultPostLanguage - } else { - // Setting the application ui preference sets the default locale - AppCompatDelegate.getApplicationLocales()[0]?.language - ?: Locale.getDefault().language - } - } else { - language - } +fun getInitialLanguages(language: String? = null, activeAccount: AccountEntity? = null): List { + val selected = listOfNotNull(language, activeAccount?.defaultPostLanguage) + val system = AppCompatDelegate.getApplicationLocales().toList() + + LocaleListCompat.getDefault().toList() + + return (selected + system.map { it.language }).distinct().filter { it.isNotEmpty() } } -fun getLocaleList(initialLanguage: String): List { - val locales = mutableListOf() - mergeLocaleListCompat(locales, AppCompatDelegate.getApplicationLocales()) // configured app languages first - mergeLocaleListCompat(locales, LocaleListCompat.getDefault()) // then configured system languages - locales.addAll( // finally, other languages +fun getLocaleList(initialLanguages: List): List { + val locales = Locale.getAvailableLocales().filter { // Only "base" languages, "en" but not "en_DK" - Locale.getAvailableLocales().filter { - it.country.isNullOrEmpty() && - it.script.isNullOrEmpty() && - it.variant.isNullOrEmpty() - }.sortedBy { it.displayName } - ) - ensureLanguageIsFirst(locales, initialLanguage) + it.country.isNullOrEmpty() && + it.script.isNullOrEmpty() && + it.variant.isNullOrEmpty() + }.sortedBy { it.displayName }.toMutableList() + ensureLanguagesAreFirst(locales, initialLanguages) return locales } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/MediaUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/MediaUtils.kt index 408f6453..b01200bb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/MediaUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/MediaUtils.kt @@ -87,7 +87,6 @@ fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeig var inSampleSize = 1 if (height > reqHeight || width > reqWidth) { - val halfHeight = height / 2 val halfWidth = width / 2 @@ -130,8 +129,13 @@ fun reorientBitmap(bitmap: Bitmap?, orientation: Int): Bitmap? { return try { val result = Bitmap.createBitmap( - bitmap, 0, 0, bitmap.width, - bitmap.height, matrix, true + bitmap, + 0, + 0, + bitmap.width, + bitmap.height, + matrix, + true ) if (!bitmap.sameAs(result)) { bitmap.recycle() diff --git a/app/src/main/java/com/keylesspalace/tusky/util/NoUnderlineURLSpan.kt b/app/src/main/java/com/keylesspalace/tusky/util/NoUnderlineURLSpan.kt index 779a7e6e..12be84d8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/NoUnderlineURLSpan.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/NoUnderlineURLSpan.kt @@ -19,9 +19,14 @@ import android.text.TextPaint import android.text.style.URLSpan import android.view.View -open class NoUnderlineURLSpan( - url: String -) : URLSpan(url) { +open class NoUnderlineURLSpan constructor(val url: String) : URLSpan(url) { + + // This should not be necessary. But if you don't do this the [StatusLengthTest] tests + // fail. Without this, accessing the `url` property, or calling `getUrl()` (which should + // automatically call through to [UrlSpan.getURL]) returns null. + override fun getURL(): String { + return url + } override fun updateDrawState(ds: TextPaint) { super.updateDrawState(ds) @@ -32,3 +37,8 @@ open class NoUnderlineURLSpan( view.context.openLink(url) } } + +/** + * Mentions of other users ("@user@example.org") + */ +open class MentionSpan(url: String) : NoUnderlineURLSpan(url) diff --git a/app/src/main/java/com/keylesspalace/tusky/util/NotificationTypeConverter.kt b/app/src/main/java/com/keylesspalace/tusky/util/NotificationTypeConverter.kt index 34e8924f..969deba4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/NotificationTypeConverter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/NotificationTypeConverter.kt @@ -37,8 +37,9 @@ fun deserialize(data: String?): Set { for (i in 0 until array.length()) { val item = array.getString(i) val type = Notification.Type.byString(item) - if (type != Notification.Type.UNKNOWN) + if (type != Notification.Type.UNKNOWN) { ret.add(type) + } } } return ret diff --git a/app/src/main/java/com/keylesspalace/tusky/util/NumberUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/NumberUtils.kt new file mode 100644 index 00000000..29a2ec67 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/NumberUtils.kt @@ -0,0 +1,28 @@ +@file:JvmName("NumberUtils") + +package com.keylesspalace.tusky.util + +import java.text.NumberFormat +import kotlin.math.abs +import kotlin.math.ln +import kotlin.math.pow + +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. + */ +fun formatNumber(num: Long, min: Int = 100000): String { + val absNum = abs(num) + if (absNum < min) return numberFormatter.format(num) + + val exp = (ln(absNum.toDouble()) / ln_1k).toInt() + + // Suffixes here are locale-agnostic + return String.format("%.1f%c", num / 1000.0.pow(exp.toDouble()), "KMGTPE"[exp - 1]) +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/PairedList.kt b/app/src/main/java/com/keylesspalace/tusky/util/PairedList.kt deleted file mode 100644 index 39a47cc7..00000000 --- a/app/src/main/java/com/keylesspalace/tusky/util/PairedList.kt +++ /dev/null @@ -1,74 +0,0 @@ -package com.keylesspalace.tusky.util - -import androidx.arch.core.util.Function - -/** - * This list implementation can help to keep two lists in sync - like real models and view models. - * - * Every operation on the main list triggers update of the supplementary list (but not vice versa). - * - * This makes sure that the main list is always the source of truth. - * - * Main list is projected to the supplementary list by the passed mapper function. - * - * Paired list is newer actually exposed and clients are provided with `getPairedCopy()`, - * `getPairedItem()` and `setPairedItem()`. This prevents modifications of the - * supplementary list size so lists are always have the same length. - * - * This implementation will not try to recover from exceptional cases so lists may be out of sync - * after the exception. - * - * It is most useful with immutable data because we cannot track changes inside stored objects. - * - * @param T type of elements in the main list - * @param V type of elements in supplementary list - * @param mapper Function, which will be used to translate items from the main list to the - * supplementary one. - * @constructor - */ -class PairedList (private val mapper: Function) : AbstractMutableList() { - private val main: MutableList = ArrayList() - private val synced: MutableList = ArrayList() - - val pairedCopy: List - get() = ArrayList(synced) - - fun getPairedItem(index: Int): V { - return synced[index] - } - - fun getPairedItemOrNull(index: Int): V? { - return synced.getOrNull(index) - } - - fun setPairedItem(index: Int, element: V) { - synced[index] = element - } - - override fun get(index: Int): T { - return main[index] - } - - override fun set(index: Int, element: T): T { - synced[index] = mapper.apply(element) - return main.set(index, element) - } - - override fun add(element: T): Boolean { - synced.add(mapper.apply(element)) - return main.add(element) - } - - override fun add(index: Int, element: T) { - synced.add(index, mapper.apply(element)) - main.add(index, element) - } - - override fun removeAt(index: Int): T { - synced.removeAt(index) - return main.removeAt(index) - } - - override val size: Int - get() = main.size -} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/RxAwareViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/util/RxAwareViewModel.kt deleted file mode 100644 index 0f326743..00000000 --- a/app/src/main/java/com/keylesspalace/tusky/util/RxAwareViewModel.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.keylesspalace.tusky.util - -import androidx.annotation.CallSuper -import androidx.lifecycle.ViewModel -import io.reactivex.rxjava3.disposables.CompositeDisposable -import io.reactivex.rxjava3.disposables.Disposable - -open class RxAwareViewModel : ViewModel() { - val disposables = CompositeDisposable() - - fun Disposable.autoDispose() = disposables.add(this) - - @CallSuper - override fun onCleared() { - super.onCleared() - disposables.clear() - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ShareShortcutHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/ShareShortcutHelper.kt index ee687496..9d8e4b23 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ShareShortcutHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ShareShortcutHelper.kt @@ -35,9 +35,7 @@ import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.schedulers.Schedulers fun updateShortcut(context: Context, account: AccountEntity) { - Single.fromCallable { - val innerSize = context.resources.getDimensionPixelSize(R.dimen.adaptive_bitmap_inner_size) val outerSize = context.resources.getDimensionPixelSize(R.dimen.adaptive_bitmap_outer_size) @@ -94,6 +92,5 @@ fun updateShortcut(context: Context, account: AccountEntity) { } fun removeShortcut(context: Context, account: AccountEntity) { - ShortcutManagerCompat.removeDynamicShortcuts(context, listOf(account.id.toString())) } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/SmartLengthInputFilter.kt b/app/src/main/java/com/keylesspalace/tusky/util/SmartLengthInputFilter.kt index 5e510034..c7b583e5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/SmartLengthInputFilter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/SmartLengthInputFilter.kt @@ -89,7 +89,6 @@ object SmartLengthInputFilter : InputFilter { keep = boundary } else { - // If no runway is allowed simply remove whitespace if present while (source[keep - 1].isWhitespace()) { --keep diff --git a/app/src/main/java/com/keylesspalace/tusky/util/SpanUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/SpanUtils.kt index 7087a165..f89f5fea 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/SpanUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/SpanUtils.kt @@ -1,10 +1,17 @@ package com.keylesspalace.tusky.util +import android.content.Context +import android.os.Build import android.text.Spannable +import android.text.SpannableStringBuilder import android.text.Spanned import android.text.style.CharacterStyle +import android.text.style.DynamicDrawableSpan import android.text.style.ForegroundColorSpan +import android.text.style.ImageSpan import android.text.style.URLSpan +import com.mikepenz.iconics.IconicsDrawable +import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import java.util.regex.Pattern import kotlin.math.max @@ -43,7 +50,7 @@ private enum class FoundMatchType { HTTP_URL, HTTPS_URL, TAG, - MENTION, + MENTION } private class FindCharsResult { @@ -61,6 +68,56 @@ private class PatternFinder( val pattern: Pattern = Pattern.compile(regex, Pattern.CASE_INSENSITIVE) } +/** + * Takes text containing mentions and hashtags and urls and makes them the given colour. + */ +fun highlightSpans(text: Spannable, colour: Int) { + // Strip all existing colour spans. + for (spanClass in spanClasses) { + clearSpans(text, spanClass) + } + + // Colour the mentions and hashtags. + val string = text.toString() + val length = text.length + var start = 0 + var end = 0 + while (end in 0 until length && start >= 0) { + // Search for url first because it can contain the other characters + val found = findPattern(string, end) + start = found.start + end = found.end + if (start in 0 until end) { + text.setSpan(getSpan(found.matchType, string, colour, start, end), start, end, Spanned.SPAN_INCLUSIVE_EXCLUSIVE) + start += finders[found.matchType]!!.searchPrefixWidth + } + } +} + +/** + * Replaces text of the form [iconics name] with their spanned counterparts (ImageSpan). + */ +fun addDrawables(text: CharSequence, color: Int, size: Int, context: Context): Spannable { + val alignment = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) DynamicDrawableSpan.ALIGN_CENTER else DynamicDrawableSpan.ALIGN_BASELINE + + val builder = SpannableStringBuilder(text) + + val pattern = Pattern.compile("\\[iconics ([0-9a-z_]+)\\]") + val matcher = pattern.matcher(builder) + while (matcher.find()) { + val resourceName = matcher.group(1) + ?: continue + + val drawable = IconicsDrawable(context, GoogleMaterial.getIcon(resourceName)) + drawable.setBounds(0, 0, size, size) + drawable.setTint(color) + + builder.setSpan(ImageSpan(drawable, alignment), matcher.start(), matcher.end(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + } + + return builder +} + private fun clearSpans(text: Spannable, spanClass: Class) { for (span in text.getSpans(0, text.length, spanClass)) { text.removeSpan(span) @@ -131,34 +188,11 @@ private fun getSpan(matchType: FoundMatchType, string: String, colour: Int, star return when (matchType) { FoundMatchType.HTTP_URL -> NoUnderlineURLSpan(string.substring(start, end)) FoundMatchType.HTTPS_URL -> NoUnderlineURLSpan(string.substring(start, end)) + FoundMatchType.MENTION -> MentionSpan(string.substring(start, end)) else -> ForegroundColorSpan(colour) } } -/** Takes text containing mentions and hashtags and urls and makes them the given colour. */ -fun highlightSpans(text: Spannable, colour: Int) { - // Strip all existing colour spans. - for (spanClass in spanClasses) { - clearSpans(text, spanClass) - } - - // Colour the mentions and hashtags. - val string = text.toString() - val length = text.length - var start = 0 - var end = 0 - while (end >= 0 && end < length && start >= 0) { - // Search for url first because it can contain the other characters - val found = findPattern(string, end) - start = found.start - end = found.end - if (start >= 0 && end > start) { - text.setSpan(getSpan(found.matchType, string, colour, start, end), start, end, Spanned.SPAN_INCLUSIVE_EXCLUSIVE) - start += finders[found.matchType]!!.searchPrefixWidth - } - } -} - private fun isWordCharacters(codePoint: Int): Boolean { return (codePoint in 0x30..0x39) || // [0-9] (codePoint in 0x41..0x5a) || // [A-Z] diff --git a/app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt b/app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt index cb782107..7151f93a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt @@ -1,5 +1,26 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . + */ + package com.keylesspalace.tusky.util +import android.content.SharedPreferences +import com.keylesspalace.tusky.db.AccountEntity +import com.keylesspalace.tusky.settings.PrefKeys + data class StatusDisplayOptions( @get:JvmName("animateAvatars") val animateAvatars: Boolean, @@ -20,5 +41,89 @@ data class StatusDisplayOptions( @get:JvmName("hideStats") val hideStats: Boolean, @get:JvmName("animateEmojis") - val animateEmojis: Boolean -) + val animateEmojis: Boolean, + @get:JvmName("showStatsInline") + val showStatsInline: Boolean, + @get:JvmName("showSensitiveMedia") + val showSensitiveMedia: Boolean, + @get:JvmName("openSpoiler") + val openSpoiler: Boolean +) { + + /** + * @return a new StatusDisplayOptions adapted to whichever preference changed. + */ + fun make( + preferences: SharedPreferences, + key: String, + account: AccountEntity + ) = when (key) { + PrefKeys.ANIMATE_GIF_AVATARS -> copy( + animateAvatars = preferences.getBoolean(key, false) + ) + PrefKeys.MEDIA_PREVIEW_ENABLED -> copy( + mediaPreviewEnabled = account.mediaPreviewEnabled + ) + PrefKeys.ABSOLUTE_TIME_VIEW -> copy( + useAbsoluteTime = preferences.getBoolean(key, false) + ) + PrefKeys.SHOW_BOT_OVERLAY -> copy( + showBotOverlay = preferences.getBoolean(key, true) + ) + PrefKeys.USE_BLURHASH -> copy( + useBlurhash = preferences.getBoolean(key, true) + ) + PrefKeys.CONFIRM_FAVOURITES -> copy( + confirmFavourites = preferences.getBoolean(key, false) + ) + PrefKeys.CONFIRM_REBLOGS -> copy( + confirmReblogs = preferences.getBoolean(key, true) + ) + PrefKeys.WELLBEING_HIDE_STATS_POSTS -> copy( + hideStats = preferences.getBoolean(key, false) + ) + PrefKeys.ANIMATE_CUSTOM_EMOJIS -> copy( + animateEmojis = preferences.getBoolean(key, false) + ) + PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA -> copy( + showSensitiveMedia = account.alwaysShowSensitiveMedia + ) + PrefKeys.ALWAYS_OPEN_SPOILER -> copy( + openSpoiler = account.alwaysOpenSpoiler + ) + else -> { this } + } + + companion object { + /** Preference keys that, if changed, affect StatusDisplayOptions */ + val prefKeys = setOf( + PrefKeys.ABSOLUTE_TIME_VIEW, + PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA, + PrefKeys.ALWAYS_OPEN_SPOILER, + PrefKeys.ANIMATE_CUSTOM_EMOJIS, + PrefKeys.ANIMATE_GIF_AVATARS, + PrefKeys.CONFIRM_FAVOURITES, + PrefKeys.CONFIRM_REBLOGS, + PrefKeys.MEDIA_PREVIEW_ENABLED, + PrefKeys.SHOW_BOT_OVERLAY, + PrefKeys.USE_BLURHASH, + PrefKeys.WELLBEING_HIDE_STATS_POSTS + ) + + fun from(preferences: SharedPreferences, account: AccountEntity) = StatusDisplayOptions( + animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), + animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), + mediaPreviewEnabled = account.mediaPreviewEnabled, + useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false), + showBotOverlay = preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true), + useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true), + cardViewMode = CardViewMode.NONE, + confirmReblogs = preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true), + confirmFavourites = preferences.getBoolean(PrefKeys.CONFIRM_FAVOURITES, false), + hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), + showStatsInline = preferences.getBoolean(PrefKeys.SHOW_STATS_INLINE, false), + showSensitiveMedia = account.alwaysShowSensitiveMedia, + openSpoiler = account.alwaysOpenSpoiler + ) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/StatusParsingHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/StatusParsingHelper.kt index 2ac4782c..b8c3c6a0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/StatusParsingHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/StatusParsingHelper.kt @@ -14,8 +14,10 @@ * see . */ @file:JvmName("StatusParsingHelper") + package com.keylesspalace.tusky.util +import android.text.Html.TagHandler import android.text.SpannableStringBuilder import android.text.Spanned import androidx.core.text.parseAsHtml @@ -23,12 +25,13 @@ import androidx.core.text.parseAsHtml /** * parse a String containing html from the Mastodon api to Spanned */ -fun String.parseAsMastodonHtml(): Spanned { +@JvmOverloads +fun String.parseAsMastodonHtml(tagHandler: TagHandler? = null): Spanned { return this.replace("
", "
 ") .replace("
", "
 ") .replace("
", "
 ") .replace(" ", "  ") - .parseAsHtml() + .parseAsHtml(tagHandler = tagHandler) /* Html.fromHtml returns trailing whitespace if the html ends in a

tag, which * most status contents do, so it should be trimmed. */ .trimTrailingWhitespace() diff --git a/app/src/main/java/com/keylesspalace/tusky/util/StatusViewHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/StatusViewHelper.kt index 7594e8ed..2148a3b4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/StatusViewHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/StatusViewHelper.kt @@ -53,7 +53,6 @@ class StatusViewHelper(private val itemView: View) { showingContent: Boolean, mediaPreviewHeight: Int ) { - val context = itemView.context val mediaPreviews = arrayOf( itemView.findViewById(R.id.status_media_preview_0), @@ -110,9 +109,11 @@ class StatusViewHelper(private val itemView: View) { .centerInside() .into(mediaPreviews[i]) } else { - val placeholder = if (attachment.blurhash != null) + val placeholder = if (attachment.blurhash != null) { decodeBlurHash(context, attachment.blurhash) - else mediaPreviewUnloaded + } else { + mediaPreviewUnloaded + } val meta = attachment.meta val focus = meta?.focus if (showingContent) { @@ -185,8 +186,12 @@ class StatusViewHelper(private val itemView: View) { v.visibility = View.GONE sensitiveMediaWarning.visibility = View.VISIBLE setMediasPreview( - statusDisplayOptions, attachments, sensitive, previewListener, - false, mediaPreviewHeight + statusDisplayOptions, + attachments, + sensitive, + previewListener, + false, + mediaPreviewHeight ) } sensitiveMediaWarning.setOnClickListener { v -> @@ -194,8 +199,12 @@ class StatusViewHelper(private val itemView: View) { v.visibility = View.GONE sensitiveMediaShow.visibility = View.VISIBLE setMediasPreview( - statusDisplayOptions, attachments, sensitive, previewListener, - true, mediaPreviewHeight + statusDisplayOptions, + attachments, + sensitive, + previewListener, + true, + mediaPreviewHeight ) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ThemeUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/ThemeUtils.kt index 41bec796..fe556e5a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ThemeUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ThemeUtils.kt @@ -22,6 +22,7 @@ import android.graphics.PorterDuff import android.graphics.drawable.Drawable import androidx.annotation.AttrRes import androidx.appcompat.app.AppCompatDelegate +import androidx.core.content.res.use import com.google.android.material.color.MaterialColors /** @@ -37,10 +38,9 @@ private const val THEME_SYSTEM = "auto_system" const val APP_THEME_DEFAULT = THEME_SYSTEM fun getDimension(context: Context, @AttrRes attribute: Int): Int { - val array = context.obtainStyledAttributes(intArrayOf(attribute)) - val dimen = array.getDimensionPixelSize(0, -1) - array.recycle() - return dimen + return context.obtainStyledAttributes(intArrayOf(attribute)).use { array -> + array.getDimensionPixelSize(0, -1) + } } fun setDrawableTint(context: Context, drawable: Drawable, @AttrRes attribute: Int) { diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ThrowableExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/ThrowableExtensions.kt index 26f96255..a3811a35 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ThrowableExtensions.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ThrowableExtensions.kt @@ -1,8 +1,11 @@ package com.keylesspalace.tusky.util +import android.content.Context +import com.keylesspalace.tusky.R import org.json.JSONException import org.json.JSONObject import retrofit2.HttpException +import java.io.IOException /** * checks if this throwable indicates an error causes by a 4xx/5xx server response and @@ -24,3 +27,16 @@ fun Throwable.getServerErrorMessage(): String? { } return null } + +/** @return A drawable resource to accompany the error message for this throwable */ +fun Throwable.getDrawableRes(): Int = when (this) { + is IOException -> R.drawable.elephant_offline + is HttpException -> R.drawable.elephant_offline + else -> R.drawable.elephant_error +} + +/** @return A string error message for this throwable */ +fun Throwable.getErrorString(context: Context): String = getServerErrorMessage() ?: when (this) { + is IOException -> context.getString(R.string.error_network) + else -> context.getString(R.string.error_generic) +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ViewBindingExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/ViewBindingExtensions.kt index 5342cbf3..f398e2b6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ViewBindingExtensions.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ViewBindingExtensions.kt @@ -7,6 +7,7 @@ import androidx.fragment.app.Fragment import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.Observer import androidx.viewbinding.ViewBinding import kotlin.properties.ReadOnlyProperty import kotlin.reflect.KProperty @@ -28,23 +29,26 @@ class FragmentViewBindingDelegate( private var binding: T? = null init { - fragment.lifecycle.addObserver( - object : DefaultLifecycleObserver { - override fun onCreate(owner: LifecycleOwner) { - fragment.viewLifecycleOwnerLiveData.observe( - fragment - ) { t -> - t?.lifecycle?.addObserver( - object : DefaultLifecycleObserver { - override fun onDestroy(owner: LifecycleOwner) { - binding = null - } - } - ) - } + fragment.lifecycle.addObserver(object : DefaultLifecycleObserver { + val viewLifecycleOwnerLiveDataObserver = + Observer { + val viewLifecycleOwner = it ?: return@Observer + + viewLifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + binding = null + } + }) } + + override fun onCreate(owner: LifecycleOwner) { + fragment.viewLifecycleOwnerLiveData.observeForever(viewLifecycleOwnerLiveDataObserver) } - ) + + override fun onDestroy(owner: LifecycleOwner) { + fragment.viewLifecycleOwnerLiveData.removeObserver(viewLifecycleOwnerLiveDataObserver) + } + }) } override fun getValue(thisRef: Fragment, property: KProperty<*>): T { @@ -58,7 +62,7 @@ class FragmentViewBindingDelegate( throw IllegalStateException("Should not attempt to get bindings when Fragment views are destroyed.") } - return viewBindingFactory(thisRef.requireView()).also { this@FragmentViewBindingDelegate.binding = it } + return viewBindingFactory(thisRef.requireView()).also { this.binding = it } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt index bc40cdd6..f6ae9e7d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt @@ -1,3 +1,20 @@ +/* + * 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 . + */ + @file:JvmName("ViewDataUtils") /* Copyright 2017 Andrew Dawson @@ -18,10 +35,11 @@ package com.keylesspalace.tusky.util import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.entity.TrendingTag import com.keylesspalace.tusky.viewdata.NotificationViewData import com.keylesspalace.tusky.viewdata.StatusViewData +import com.keylesspalace.tusky.viewdata.TrendingViewData -@JvmName("statusToViewData") fun Status.toViewData( isShowingContent: Boolean, isExpanded: Boolean, @@ -37,17 +55,34 @@ fun Status.toViewData( ) } -@JvmName("notificationToViewData") fun Notification.toViewData( isShowingContent: Boolean, isExpanded: Boolean, isCollapsed: Boolean -): NotificationViewData.Concrete { - return NotificationViewData.Concrete( +): NotificationViewData { + return NotificationViewData( this.type, this.id, this.account, this.status?.toViewData(isShowingContent, isExpanded, isCollapsed), - this.report, + this.report ) } + +fun List.toViewData(): List { + val maxTrendingValue = flatMap { tag -> tag.history } + .mapNotNull { it.uses.toLongOrNull() } + .maxOrNull() ?: 1 + + return map { tag -> + + val reversedHistory = tag.history.asReversed() + + TrendingViewData.Tag( + name = tag.name, + usage = reversedHistory.mapNotNull { it.uses.toLongOrNull() }, + accounts = reversedHistory.mapNotNull { it.accounts.toLongOrNull() }, + maxTrendingValue = maxTrendingValue + ) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ViewExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/ViewExtensions.kt index 3e56c68b..392f65b7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ViewExtensions.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ViewExtensions.kt @@ -16,11 +16,9 @@ package com.keylesspalace.tusky.util -import android.text.Editable -import android.text.TextWatcher import android.util.Log import android.view.View -import android.widget.EditText +import android.widget.TextView import androidx.recyclerview.widget.RecyclerView import androidx.viewpager2.widget.ViewPager2 @@ -36,37 +34,6 @@ fun View.visible(visible: Boolean, or: Int = View.GONE) { this.visibility = if (visible) View.VISIBLE else or } -open class DefaultTextWatcher : TextWatcher { - override fun afterTextChanged(s: Editable) { - } - - override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) { - } - - override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { - } -} - -inline fun EditText.onTextChanged( - crossinline callback: (s: CharSequence, start: Int, before: Int, count: Int) -> Unit -) { - addTextChangedListener(object : DefaultTextWatcher() { - override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { - callback(s, start, before, count) - } - }) -} - -inline fun EditText.afterTextChanged( - crossinline callback: (s: Editable) -> Unit -) { - addTextChangedListener(object : DefaultTextWatcher() { - override fun afterTextChanged(s: Editable) { - callback(s) - } - }) -} - /** * Reduce ViewPager2's sensitivity to horizontal swipes. */ @@ -100,3 +67,14 @@ fun ViewPager2.reduceSwipeSensitivity() { Log.w("reduceSwipeSensitivity", e) } } + +/** + * TextViews with an ancestor RecyclerView can forget that they are selectable. Toggling + * calls to [TextView.setTextIsSelectable] fixes this. + * + * @see https://issuetracker.google.com/issues/37095917 + */ +fun TextView.fixTextSelection() { + setTextIsSelectable(false) + post { setTextIsSelectable(true) } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/view/BackgroundMessageView.kt b/app/src/main/java/com/keylesspalace/tusky/view/BackgroundMessageView.kt index 82860f9f..650d92cc 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/BackgroundMessageView.kt +++ b/app/src/main/java/com/keylesspalace/tusky/view/BackgroundMessageView.kt @@ -1,20 +1,24 @@ package com.keylesspalace.tusky.view import android.content.Context +import android.text.method.LinkMovementMethod import android.util.AttributeSet import android.view.Gravity import android.view.LayoutInflater import android.view.View import android.widget.LinearLayout +import android.widget.TextView import androidx.annotation.DrawableRes import androidx.annotation.StringRes import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ViewBackgroundMessageBinding +import com.keylesspalace.tusky.util.addDrawables +import com.keylesspalace.tusky.util.getDrawableRes +import com.keylesspalace.tusky.util.getErrorString import com.keylesspalace.tusky.util.visible /** - * This view is used for screens with downloadable content which may fail. - * Can show an image, text and button below them. + * This view is used for screens with content which may be empty or might have failed to download. */ class BackgroundMessageView @JvmOverloads constructor( context: Context, @@ -33,18 +37,40 @@ class BackgroundMessageView @JvmOverloads constructor( } } + fun setup(throwable: Throwable, listener: ((v: View) -> Unit)? = null) { + setup(throwable.getDrawableRes(), throwable.getErrorString(context), listener) + } + + fun setup( + @DrawableRes imageRes: Int, + @StringRes messageRes: Int, + clickListener: ((v: View) -> Unit)? = null + ) = setup(imageRes, context.getString(messageRes), clickListener) + /** * Setup image, message and button. * If [clickListener] is `null` then the button will be hidden. */ fun setup( @DrawableRes imageRes: Int, - @StringRes messageRes: Int, + message: String, clickListener: ((v: View) -> Unit)? = null ) { - binding.messageTextView.setText(messageRes) + binding.messageTextView.text = message + binding.messageTextView.movementMethod = LinkMovementMethod.getInstance() binding.imageView.setImageResource(imageRes) binding.button.setOnClickListener(clickListener) binding.button.visible(clickListener != null) } + + fun showHelp(@StringRes helpRes: Int) { + val size: Int = binding.helpText.textSize.toInt() + 2 + val color = binding.helpText.currentTextColor + val text = context.getText(helpRes) + val textWithDrawables = addDrawables(text, color, size, context) + + binding.helpText.setText(textWithDrawables, TextView.BufferType.SPANNABLE) + + binding.helpText.visible(true) + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/view/ClickableSpanTextView.kt b/app/src/main/java/com/keylesspalace/tusky/view/ClickableSpanTextView.kt new file mode 100644 index 00000000..4caf47c1 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/view/ClickableSpanTextView.kt @@ -0,0 +1,382 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.view + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Path +import android.graphics.Rect +import android.graphics.RectF +import android.text.Spanned +import android.text.style.ClickableSpan +import android.text.style.URLSpan +import android.util.AttributeSet +import android.util.Log +import android.view.MotionEvent +import android.view.MotionEvent.ACTION_CANCEL +import android.view.MotionEvent.ACTION_DOWN +import android.view.MotionEvent.ACTION_UP +import android.view.ViewConfiguration +import androidx.appcompat.widget.AppCompatTextView +import androidx.core.view.doOnLayout +import com.keylesspalace.tusky.BuildConfig +import com.keylesspalace.tusky.R +import java.lang.Float.max +import java.lang.Float.min +import kotlin.math.abs + +/** + * Displays text to the user with optional [ClickableSpan]s. Extends the touchable area of the spans + * to ensure they meet the minimum size of 48dp x 48dp for accessibility requirements. + * + * If the touchable area of multiple spans overlap the touch is dispatched to the closest span. + */ +class ClickableSpanTextView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = android.R.attr.textViewStyle +) : AppCompatTextView(context, attrs, defStyleAttr) { + /** + * Map of [RectF] that enclose the [ClickableSpan] without any additional touchable area. A span + * may extend over more than one line, so multiple entries in this map may point to the same + * span. + */ + private val spanRects = mutableMapOf() + + /** + * Map of [RectF] that enclose the [ClickableSpan] with the additional touchable area. A span + * may extend over more than one line, so multiple entries in this map may point to the same + * span. + */ + private val delegateRects = mutableMapOf() + + /** + * The [ClickableSpan] that is used for the point the user has touched. Null if the user is + * not tapping, or the point they have touched is not associated with a span. + */ + private var clickedSpan: ClickableSpan? = null + + /** The minimum size, in pixels, of a touchable area for accessibility purposes */ + private val minDimenPx = resources.getDimensionPixelSize(R.dimen.minimum_touch_target) + + /** + * Debugging helper. Normally false, set this to true to show a border around spans, and + * shade their touchable area. + */ + private val showSpanBoundaries = false + + /** + * Debugging helper. The paint to use to draw a span. + */ + private lateinit var spanDebugPaint: Paint + + /** + * Debugging helper. The paint to use to shade a span's touchable area. + */ + private lateinit var paddingDebugPaint: Paint + + init { + // Initialise debugging paints, if appropriate. Only ever present in debug builds, and + // is optimised out if showSpanBoundaries is false. + if (BuildConfig.DEBUG && showSpanBoundaries) { + spanDebugPaint = Paint() + spanDebugPaint.color = Color.BLACK + spanDebugPaint.style = Paint.Style.STROKE + + paddingDebugPaint = Paint() + paddingDebugPaint.color = Color.MAGENTA + paddingDebugPaint.alpha = 50 + } + } + + override fun onTextChanged( + text: CharSequence?, + start: Int, + lengthBefore: Int, + lengthAfter: Int + ) { + super.onTextChanged(text, start, lengthBefore, lengthAfter) + + // TextView tries to optimise the layout process, and will not perform a layout if the + // new text takes the same area as the old text (see TextView.checkForRelayout()). This + // can result in statuses using the wrong clickable areas as they are never remeasured. + // (https://github.com/tuskyapp/Tusky/issues/3596). Force a layout pass to ensure that + // the spans are measured correctly. + if (!isInLayout) requestLayout() + + doOnLayout { measureSpans() } + } + + /** + * Compute [Rect]s for each [ClickableSpan]. + * + * Each span is associated with at least two Rects. One for the span itself, and one for the + * touchable area around the span. + * + * If the span runs over multiple lines there will be two Rects per line. + */ + private fun measureSpans() { + spanRects.clear() + delegateRects.clear() + + val spannedText = text as? Spanned ?: return + + // The goal is to record all the [Rect]s associated with a span with the same fidelity + // that the user sees when they highlight text in the view to select it. + // + // There's no method in [TextView] or [Layout] that does exactly that. [Layout.getSelection] + // would be perfect, but it's not accessible. However, [Layout.getSelectionPath] is. That + // records the Rects between two characters in the string, and handles text that spans + // multiple lines, is bidirectional, etc. + // + // However, it records them in to a [Path], and a Path has no mechanism to extract the + // Rects saved in to it. + // + // So subclass Path with [RectRecordingPath], which records the data from calls to + // [addRect]. Pass that to `getSelectionPath` to extract all the Rects between start and + // end. + val rects = mutableListOf() + val rectRecorder = RectRecordingPath(rects) + + for (span in spannedText.getSpans(0, text.length - 1, ClickableSpan::class.java)) { + rects.clear() + val spanStart = spannedText.getSpanStart(span) + val spanEnd = spannedText.getSpanEnd(span) + + // Collect all the Rects for this span + layout.getSelectionPath(spanStart, spanEnd, rectRecorder) + + // Save them + for (rect in rects) { + // Adjust to account for the view's padding and gravity + rect.offset(totalPaddingLeft.toFloat(), totalPaddingTop.toFloat()) + rect.bottom += extendedPaddingBottom + + // The rect wraps just the span, with no additional touchable area. Save a copy. + spanRects[RectF(rect)] = span + + // Adjust the rect to meet the minimum dimensions + if (rect.height() < minDimenPx) { + val yOffset = (minDimenPx - rect.height()) / 2 + rect.top = max(0f, rect.top - yOffset) + rect.bottom = min(rect.bottom + yOffset, bottom.toFloat()) + } + + if (rect.width() < minDimenPx) { + val xOffset = (minDimenPx - rect.width()) / 2 + rect.left = max(0f, rect.left - xOffset) + rect.right = min(rect.right + xOffset, right.toFloat()) + } + + // Save it + delegateRects[rect] = span + } + } + } + + /** + * Handle some touch events. + * + * - [ACTION_DOWN]: Determine which, if any span, has been clicked, and save in clickedSpan + * - [ACTION_UP]: If a span was saved then dispatch the click to that span + * - [ACTION_CANCEL]: Clear the saved span + * + * Defer to the parent class for other touches. + */ + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent?): Boolean { + event ?: return super.onTouchEvent(null) + if (delegateRects.isEmpty()) return super.onTouchEvent(event) + + when (event.action) { + ACTION_DOWN -> { + clickedSpan = null + val x = event.x + val y = event.y + + // If the user has clicked directly on a span then use it, ignoring any overlap + for (entry in spanRects) { + if (!entry.key.contains(x, y)) continue + clickedSpan = entry.value + Log.v(TAG, "span click: ${(clickedSpan as URLSpan).url}") + return super.onTouchEvent(event) + } + + // Otherwise, check to see if it's in a touchable area + var activeEntry: MutableMap.MutableEntry? = null + + for (entry in delegateRects) { + if (entry == activeEntry) continue + if (!entry.key.contains(x, y)) continue + + if (activeEntry == null) { + activeEntry = entry + continue + } + Log.v(TAG, "Overlap: ${(entry.value as URLSpan).url} ${(activeEntry.value as URLSpan).url}") + if (isClickOnFirst(entry.key, activeEntry.key, x, y)) { + activeEntry = entry + } + } + clickedSpan = activeEntry?.value + clickedSpan?.let { Log.v(TAG, "padding click: ${(clickedSpan as URLSpan).url}") } + return super.onTouchEvent(event) + } + ACTION_UP -> { + clickedSpan?.let { + clickedSpan = null + val duration = event.eventTime - event.downTime + if (duration <= ViewConfiguration.getLongPressTimeout()) { + it.onClick(this) + return true + } + } + return super.onTouchEvent(event) + } + ACTION_CANCEL -> { + clickedSpan = null + return super.onTouchEvent(event) + } + else -> return super.onTouchEvent(event) + } + } + + /** + * Determine whether a click on overlapping rectangles should be attributed to the first or the + * second rectangle. + * + * When the user clicks on the overlap it has to be attributed to the "best" rectangle. The + * rectangles have equivalent z-order, so their "closeness" to the user in the Z-plane is not + * a consideration. + * + * The chosen rectangle depends on whether they overlap top/bottom (the top of one rect is + * not the same as the top of the other rect), or they overlap left/right (the tops of both + * rects are the same). + * + * In this example the rectangles overlap top/bottom because their top edges are not aligned. + * + * ``` + * +--------------+ + * |1 | + * | +--------------+ + * | |2 | + * | | | + * | | | + * +------| | + * | | + * +--------------+ + * ``` + * + * (Rectangle #1 being partially occluded by rectangle #2 is for clarity in the diagram, it + * does not affect the algorithm) + * + * Take the Y coordinate of the centre of each rectangle. + * + * ``` + * +--------------+ + * |1 | + * | +--------------+ + * |......|2 | <-- Rect #1 centre line + * | | | + * | |..............| <-- Rect #2 centre line + * +------| | + * | | + * +--------------+ + * ``` + * + * Take the Y position of the click, and determine which Y centre coordinate it is closest too. + * Whichever one is closest is the clicked rectangle. + * + * In these examples the left column of numbers is the Y coordinate, `*` marks the point where + * the user clicked. + * + * ``` + * 0 +--------------+ +--------------+ + * 1 |1 | |1 | + * 2 | +--------------+ | +--------------+ + * 3 |......|2 * | |......|2 | + * 4 | | | | | | + * 5 | |..............| | |*.............| + * 6 +------| | +------| | + * 7 | | | | + * 8 +--------------+ +--------------+ + * + * Rect #1 centre Y = 3 + * Rect #2 centre Y = 5 + * Click (*) Y = 3 Click (*) Y = 5 + * Result: Rect #1 is clicked Result: Rect #2 is clicked + * ``` + * + * The approach is the same if the rectangles overlap left/right, but the X coordinate of the + * centre of the rectangle is tested against the X coordinate of the click. + * + * @param first rectangle to test against + * @param second rectangle to test against + * @param x coordinate of user click + * @param y coordinate of user click + * @return true if the click was closer to the first rectangle than the second + */ + private fun isClickOnFirst(first: RectF, second: RectF, x: Float, y: Float): Boolean { + Log.v(TAG, "first: $first second: $second click: $x $y") + val (firstDiff, secondDiff) = if (first.top == second.top) { + Log.v(TAG, "left/right overlap") + Pair(abs(first.centerX() - x), abs(second.centerX() - x)) + } else { + Log.v(TAG, "top/bottom overlap") + Pair(abs(first.centerY() - y), abs(second.centerY() - y)) + } + Log.d(TAG, "firstDiff: $firstDiff secondDiff: $secondDiff") + return firstDiff < secondDiff + } + + override fun onDraw(canvas: Canvas?) { + super.onDraw(canvas) + + // Paint span boundaries. Optimised out on release builds, or debug builds where + // showSpanBoundaries is false. + if (BuildConfig.DEBUG && showSpanBoundaries) { + canvas?.save() + for (entry in delegateRects) { + canvas?.drawRect(entry.key, paddingDebugPaint) + } + + for (entry in spanRects) { + canvas?.drawRect(entry.key, spanDebugPaint) + } + canvas?.restore() + } + } + + companion object { + const val TAG = "ClickableSpanTextView" + } +} + +/** + * A [Path] that records the contents of all the [addRect] calls it receives. + * + * @param rects list to record the received [RectF] + */ +private class RectRecordingPath(private val rects: MutableList) : Path() { + override fun addRect(left: Float, top: Float, right: Float, bottom: Float, dir: Direction) { + rects.add(RectF(left, top, right, bottom)) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/view/FilterDialog.kt b/app/src/main/java/com/keylesspalace/tusky/view/FilterDialog.kt deleted file mode 100644 index c6cea1e2..00000000 --- a/app/src/main/java/com/keylesspalace/tusky/view/FilterDialog.kt +++ /dev/null @@ -1,73 +0,0 @@ -package com.keylesspalace.tusky.view - -import android.content.Context -import android.widget.ArrayAdapter -import androidx.appcompat.app.AlertDialog -import com.keylesspalace.tusky.FiltersActivity -import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.databinding.DialogFilterBinding -import com.keylesspalace.tusky.entity.Filter -import java.util.Date - -fun showAddFilterDialog(activity: FiltersActivity) { - val binding = DialogFilterBinding.inflate(activity.layoutInflater) - binding.phraseWholeWord.isChecked = true - binding.filterDurationSpinner.adapter = ArrayAdapter( - activity, - android.R.layout.simple_list_item_1, - activity.resources.getStringArray(R.array.filter_duration_names) - ) - AlertDialog.Builder(activity) - .setTitle(R.string.filter_addition_dialog_title) - .setView(binding.root) - .setPositiveButton(android.R.string.ok) { _, _ -> - activity.createFilter( - binding.phraseEditText.text.toString(), binding.phraseWholeWord.isChecked, - getSecondsForDurationIndex(binding.filterDurationSpinner.selectedItemPosition, activity) - ) - } - .setNeutralButton(android.R.string.cancel, null) - .show() -} - -fun setupEditDialogForFilter(activity: FiltersActivity, filter: Filter, itemIndex: Int) { - val binding = DialogFilterBinding.inflate(activity.layoutInflater) - binding.phraseEditText.setText(filter.phrase) - binding.phraseWholeWord.isChecked = filter.wholeWord - val filterNames = activity.resources.getStringArray(R.array.filter_duration_names).toMutableList() - if (filter.expiresAt != null) { - filterNames.add(0, activity.getString(R.string.duration_no_change)) - } - binding.filterDurationSpinner.adapter = ArrayAdapter(activity, android.R.layout.simple_list_item_1, filterNames) - - AlertDialog.Builder(activity) - .setTitle(R.string.filter_edit_dialog_title) - .setView(binding.root) - .setPositiveButton(R.string.filter_dialog_update_button) { _, _ -> - var index = binding.filterDurationSpinner.selectedItemPosition - if (filter.expiresAt != null) { - // We prepended "No changes", account for that here - --index - } - activity.updateFilter( - filter.id, binding.phraseEditText.text.toString(), filter.context, - filter.irreversible, binding.phraseWholeWord.isChecked, - getSecondsForDurationIndex(index, activity, filter.expiresAt), itemIndex - ) - } - .setNegativeButton(R.string.filter_dialog_remove_button) { _, _ -> - activity.deleteFilter(itemIndex) - } - .setNeutralButton(android.R.string.cancel, null) - .show() -} - -// 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) - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/view/GraphView.kt b/app/src/main/java/com/keylesspalace/tusky/view/GraphView.kt new file mode 100644 index 00000000..937db93a --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/view/GraphView.kt @@ -0,0 +1,332 @@ +/* Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.view + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Path +import android.graphics.PathMeasure +import android.graphics.Rect +import android.util.AttributeSet +import android.view.View +import androidx.annotation.ColorInt +import androidx.annotation.Dimension +import androidx.core.content.res.use +import com.keylesspalace.tusky.R +import kotlin.math.max + +class GraphView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : View(context, attrs, defStyleAttr) { + @get:ColorInt + @ColorInt + var primaryLineColor = 0 + + @get:ColorInt + @ColorInt + var secondaryLineColor = 0 + + @get:Dimension + var lineWidth = 0f + + @get:ColorInt + @ColorInt + var graphColor = 0 + + @get:ColorInt + @ColorInt + var metaColor = 0 + + private var proportionalTrending = false + + private lateinit var primaryLinePaint: Paint + private lateinit var secondaryLinePaint: Paint + private lateinit var primaryCirclePaint: Paint + private lateinit var secondaryCirclePaint: Paint + private lateinit var graphPaint: Paint + private lateinit var metaPaint: Paint + + private lateinit var sizeRect: Rect + private var primaryLinePath: Path = Path() + private var secondaryLinePath: Path = Path() + + var maxTrendingValue: Long = 300 + var primaryLineData: List = if (isInEditMode) { + listOf( + 30, + 60, + 70, + 80, + 130, + 190, + 80 + ) + } else { + listOf( + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ) + } + set(value) { + field = value.map { max(1, it) } + primaryLinePath.reset() + invalidate() + } + + var secondaryLineData: List = if (isInEditMode) { + listOf( + 10, + 20, + 40, + 60, + 100, + 132, + 20 + ) + } else { + listOf( + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ) + } + set(value) { + field = value.map { max(1, it) } + secondaryLinePath.reset() + invalidate() + } + + init { + initFromXML(attrs) + } + + private fun initFromXML(attr: AttributeSet?) { + context.obtainStyledAttributes(attr, R.styleable.GraphView).use { a -> + primaryLineColor = context.getColor( + a.getResourceId( + R.styleable.GraphView_primaryLineColor, + R.color.chinwag_green + ) + ) + + secondaryLineColor = context.getColor( + a.getResourceId( + R.styleable.GraphView_secondaryLineColor, + R.color.tusky_red + ) + ) + + lineWidth = a.getDimensionPixelSize( + R.styleable.GraphView_lineWidth, + R.dimen.graph_line_thickness + ).toFloat() + + graphColor = context.getColor( + a.getResourceId( + R.styleable.GraphView_graphColor, + R.color.colorBackground + ) + ) + + metaColor = context.getColor( + a.getResourceId( + R.styleable.GraphView_metaColor, + R.color.dividerColor + ) + ) + + proportionalTrending = a.getBoolean( + R.styleable.GraphView_proportionalTrending, + proportionalTrending + ) + } + + primaryLinePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = primaryLineColor + strokeWidth = lineWidth + style = Paint.Style.STROKE + } + + primaryCirclePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = primaryLineColor + style = Paint.Style.FILL + } + + secondaryLinePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = secondaryLineColor + strokeWidth = lineWidth + style = Paint.Style.STROKE + } + + secondaryCirclePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = secondaryLineColor + style = Paint.Style.FILL + } + + graphPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = graphColor + } + + metaPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = metaColor + strokeWidth = 0f + style = Paint.Style.STROKE + } + } + + private fun initializeVertices() { + sizeRect = Rect(0, 0, width, height) + + initLine(primaryLineData, primaryLinePath) + initLine(secondaryLineData, secondaryLinePath) + } + + private fun initLine(lineData: List, path: Path) { + val max = if (proportionalTrending) { + maxTrendingValue + } else { + max(primaryLineData.max(), 1) + } + val mainRatio = height.toFloat() / max.toFloat() + + val ratioedData = lineData.map { it.toFloat() * mainRatio } + + val pointDistance = dataSpacing(ratioedData) + + /** X coord of the start of this path segment */ + var startX = 0F + + /** Y coord of the start of this path segment */ + var startY = 0F + + /** X coord of the end of this path segment */ + var endX: Float + + /** Y coord of the end of this path segment */ + var endY: Float + + /** X coord of bezier control point #1 */ + var controlX1: Float + + /** X coord of bezier control point #2 */ + var controlX2: Float + + // Draw cubic bezier curves between each pair of points. + ratioedData.forEachIndexed { index, magnitude -> + val x = pointDistance * index.toFloat() + val y = height.toFloat() - magnitude + + if (index == 0) { + path.reset() + path.moveTo(x, y) + startX = x + startY = y + } else { + endX = x + endY = y + + // X-coord for a control point is placed one third of the distance between the + // two points. + val offsetX = (endX - startX) / 3 + controlX1 = startX + offsetX + controlX2 = endX - offsetX + path.cubicTo(controlX1, startY, controlX2, endY, x, y) + + startX = x + startY = y + } + } + } + + private fun dataSpacing(data: List) = width.toFloat() / max(data.size - 1, 1).toFloat() + + override fun onDraw(canvas: Canvas?) { + super.onDraw(canvas) + + if (primaryLinePath.isEmpty && width > 0) { + initializeVertices() + } + + canvas?.apply { + drawRect(sizeRect, graphPaint) + + val pointDistance = dataSpacing(primaryLineData) + + // Vertical tick marks + for (i in 0 until primaryLineData.size + 1) { + drawLine( + i * pointDistance, + height.toFloat(), + i * pointDistance, + height - (height.toFloat() / 20), + metaPaint + ) + } + + // X-axis + drawLine(0f, height.toFloat(), width.toFloat(), height.toFloat(), metaPaint) + + // Data lines + drawLine( + canvas = canvas, + linePath = secondaryLinePath, + linePaint = secondaryLinePaint, + circlePaint = secondaryCirclePaint, + lineThickness = lineWidth + ) + drawLine( + canvas = canvas, + linePath = primaryLinePath, + linePaint = primaryLinePaint, + circlePaint = primaryCirclePaint, + lineThickness = lineWidth + ) + } + } + + private fun drawLine( + canvas: Canvas, + linePath: Path, + linePaint: Paint, + circlePaint: Paint, + lineThickness: Float + ) { + canvas.apply { + drawPath( + linePath, + linePaint + ) + + val pm = PathMeasure(linePath, false) + val coord = floatArrayOf(0f, 0f) + pm.getPosTan(pm.length * 1f, coord, null) + + drawCircle(coord[0], coord[1], lineThickness * 2f, circlePaint) + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/view/LicenseCard.kt b/app/src/main/java/com/keylesspalace/tusky/view/LicenseCard.kt index 631b6e4e..394cd369 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/LicenseCard.kt +++ b/app/src/main/java/com/keylesspalace/tusky/view/LicenseCard.kt @@ -19,6 +19,7 @@ import android.content.Context import android.graphics.Color import android.util.AttributeSet import android.view.LayoutInflater +import androidx.core.content.res.use import com.google.android.material.card.MaterialCardView import com.google.android.material.color.MaterialColors import com.keylesspalace.tusky.R @@ -36,14 +37,20 @@ class LicenseCard init { val binding = CardLicenseBinding.inflate(LayoutInflater.from(context), this) - setCardBackgroundColor(MaterialColors.getColor(context, R.attr.colorSurface, Color.BLACK)) + setCardBackgroundColor(MaterialColors.getColor(context, com.google.android.material.R.attr.colorSurface, Color.BLACK)) - val a = context.theme.obtainStyledAttributes(attrs, R.styleable.LicenseCard, 0, 0) - - val name: String? = a.getString(R.styleable.LicenseCard_name) - val license: String? = a.getString(R.styleable.LicenseCard_license) - val link: String? = a.getString(R.styleable.LicenseCard_link) - a.recycle() + val (name, license, link) = context.theme.obtainStyledAttributes( + attrs, + R.styleable.LicenseCard, + 0, + 0 + ).use { a -> + Triple( + a.getString(R.styleable.LicenseCard_name), + a.getString(R.styleable.LicenseCard_license), + a.getString(R.styleable.LicenseCard_link) + ) + } binding.licenseCardName.text = name binding.licenseCardLicense.text = license diff --git a/app/src/main/java/com/keylesspalace/tusky/view/MediaPreviewImageView.kt b/app/src/main/java/com/keylesspalace/tusky/view/MediaPreviewImageView.kt index dc149e4b..717bd144 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/MediaPreviewImageView.kt +++ b/app/src/main/java/com/keylesspalace/tusky/view/MediaPreviewImageView.kt @@ -119,9 +119,12 @@ open class MediaPreviewImageView if (drawable != null && focus != null && focalMatrix != null) { scaleType = ScaleType.MATRIX FocalPointUtil.updateFocalPointMatrix( - width.toFloat(), height.toFloat(), - drawable.intrinsicWidth.toFloat(), drawable.intrinsicHeight.toFloat(), - focus as Attachment.Focus, focalMatrix as Matrix + width.toFloat(), + height.toFloat(), + drawable.intrinsicWidth.toFloat(), + drawable.intrinsicHeight.toFloat(), + focus as Attachment.Focus, + focalMatrix as Matrix ) imageMatrix = focalMatrix } diff --git a/app/src/main/java/com/keylesspalace/tusky/view/SliderPreference.kt b/app/src/main/java/com/keylesspalace/tusky/view/SliderPreference.kt new file mode 100644 index 00000000..742a2cd7 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/view/SliderPreference.kt @@ -0,0 +1,185 @@ +package com.keylesspalace.tusky.view + +import android.content.Context +import android.content.res.TypedArray +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.view.View.VISIBLE +import androidx.appcompat.content.res.AppCompatResources +import androidx.preference.Preference +import androidx.preference.PreferenceViewHolder +import com.google.android.material.slider.LabelFormatter.LABEL_GONE +import com.google.android.material.slider.Slider +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.databinding.PrefSliderBinding +import java.lang.Float.max +import java.lang.Float.min + +/** + * Slider preference + * + * Similar to [androidx.preference.SeekBarPreference], but better because: + * + * - Uses a [Slider] instead of a [android.widget.SeekBar]. Slider supports float values, and step sizes + * other than 1. + * - Displays the currently selected value in the Preference's summary, for consistency + * with platform norms. + * - Icon buttons can be displayed at the start/end of the slider. Pressing them will + * increment/decrement the slider by `stepSize`. + * - User can supply a custom formatter to format the summary value + */ +class SliderPreference @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = androidx.preference.R.attr.preferenceStyle, + defStyleRes: Int = 0 +) : Preference(context, attrs, defStyleAttr, defStyleRes), + Slider.OnChangeListener, + Slider.OnSliderTouchListener { + + /** Backing property for `value` */ + private var _value = 0F + + /** + * @see Slider.getValue + * @see Slider.setValue + */ + var value: Float = defaultValue + get() = _value + set(v) { + val clamped = max(max(v, valueFrom), min(v, valueTo)) + if (clamped == field) return + _value = clamped + persistFloat(v) + notifyChanged() + } + + /** @see Slider.setValueFrom */ + var valueFrom: Float + + /** @see Slider.setValueTo */ + var valueTo: Float + + /** @see Slider.setStepSize */ + var stepSize: Float + + /** + * Format string to be applied to values before setting the summary. For more control set + * [SliderPreference.formatter] + */ + var format: String = defaultFormat + + /** + * Function that will be used to format the summary. The default formatter formats using the + * value of the [SliderPreference.format] property. + */ + var formatter: (Float) -> String = { format.format(it) } + + /** + * Optional icon to show in a button at the start of the slide. If non-null the button is + * shown. Clicking the button decrements the value by one step. + */ + var decrementIcon: Drawable? = null + + /** + * Optional icon to show in a button at the end of the slider. If non-null the button is + * shown. Clicking the button increments the value by one step. + */ + var incrementIcon: Drawable? = null + + /** View binding */ + private lateinit var binding: PrefSliderBinding + + init { + // Using `widgetLayoutResource` here would be incorrect, as that tries to put the entire + // preference layout to the right of the title and summary. + layoutResource = R.layout.pref_slider + + val a = context.obtainStyledAttributes(attrs, R.styleable.SliderPreference, defStyleAttr, defStyleRes) + + value = a.getFloat(R.styleable.SliderPreference_android_value, defaultValue) + valueFrom = a.getFloat(R.styleable.SliderPreference_android_valueFrom, defaultValueFrom) + valueTo = a.getFloat(R.styleable.SliderPreference_android_valueTo, defaultValueTo) + stepSize = a.getFloat(R.styleable.SliderPreference_android_stepSize, defaultStepSize) + format = a.getString(R.styleable.SliderPreference_format) ?: defaultFormat + + val decrementIconResource = a.getResourceId(R.styleable.SliderPreference_iconStart, -1) + if (decrementIconResource != -1) { + decrementIcon = AppCompatResources.getDrawable(context, decrementIconResource) + } + + val incrementIconResource = a.getResourceId(R.styleable.SliderPreference_iconEnd, -1) + if (incrementIconResource != -1) { + incrementIcon = AppCompatResources.getDrawable(context, incrementIconResource) + } + + a.recycle() + } + + override fun onGetDefaultValue(a: TypedArray, i: Int): Any { + return a.getFloat(i, defaultValue) + } + + override fun onSetInitialValue(defaultValue: Any?) { + value = getPersistedFloat((defaultValue ?: Companion.defaultValue) as Float) + } + + override fun onBindViewHolder(holder: PreferenceViewHolder) { + super.onBindViewHolder(holder) + binding = PrefSliderBinding.bind(holder.itemView) + + binding.root.isClickable = false + + binding.slider.addOnChangeListener(this) + binding.slider.addOnSliderTouchListener(this) + binding.slider.value = value // sliderValue + binding.slider.valueTo = valueTo + binding.slider.valueFrom = valueFrom + binding.slider.stepSize = stepSize + + // Disable the label, the value is shown in the preference summary + binding.slider.labelBehavior = LABEL_GONE + binding.slider.isEnabled = isEnabled + + binding.summary.visibility = VISIBLE + binding.summary.text = formatter(value) + + decrementIcon?.let { icon -> + binding.decrement.icon = icon + binding.decrement.visibility = VISIBLE + binding.decrement.setOnClickListener { + value -= stepSize + } + } + + incrementIcon?.let { icon -> + binding.increment.icon = icon + binding.increment.visibility = VISIBLE + binding.increment.setOnClickListener { + value += stepSize + } + } + } + + override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) { + if (!fromUser) return + binding.summary.text = formatter(value) + } + + override fun onStartTrackingTouch(slider: Slider) { + // Deliberately empty + } + + override fun onStopTrackingTouch(slider: Slider) { + value = slider.value + } + + companion object { + private const val TAG = "SliderPreference" + private const val defaultValueFrom = 0F + private const val defaultValueTo = 1F + private const val defaultValue = 0.5F + private const val defaultStepSize = 0.1F + private const val defaultFormat = "%3.1f" + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java b/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java deleted file mode 100644 index c70e2fc7..00000000 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java +++ /dev/null @@ -1,138 +0,0 @@ -/* Copyright 2017 Andrew Dawson - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . */ - -package com.keylesspalace.tusky.viewdata; - -import androidx.annotation.Nullable; - -import com.keylesspalace.tusky.entity.Notification; -import com.keylesspalace.tusky.entity.Report; -import com.keylesspalace.tusky.entity.TimelineAccount; - -import java.util.Objects; - -/** - * Created by charlag on 12/07/2017. - *

- * Class to represent data required to display either a notification or a placeholder. - * It is either a {@link Placeholder} or a {@link Concrete}. - * It is modelled this way because close relationship between placeholder and concrete notification - * is fine in this case. Placeholder case is not modelled as a type of notification because - * invariants would be violated and because it would model domain incorrectly. It is preferable to - * {@link com.keylesspalace.tusky.util.Either} because class hierarchy is cheaper, faster and - * more native. - */ -public abstract class NotificationViewData { - private NotificationViewData() { - } - - public abstract long getViewDataId(); - - public abstract boolean deepEquals(NotificationViewData other); - - public static final class Concrete extends NotificationViewData { - private final Notification.Type type; - private final String id; - private final TimelineAccount account; - @Nullable - private final StatusViewData.Concrete statusViewData; - @Nullable - private final Report report; - - public Concrete(Notification.Type type, String id, TimelineAccount account, - @Nullable StatusViewData.Concrete statusViewData, @Nullable Report report) { - this.type = type; - this.id = id; - this.account = account; - this.statusViewData = statusViewData; - this.report = report; - } - - public Notification.Type getType() { - return type; - } - - public String getId() { - return id; - } - - public TimelineAccount getAccount() { - return account; - } - - @Nullable - public StatusViewData.Concrete getStatusViewData() { - return statusViewData; - } - - @Nullable - public Report getReport() { - return report; - } - - @Override - public long getViewDataId() { - return id.hashCode(); - } - - @Override - public boolean deepEquals(NotificationViewData o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - Concrete concrete = (Concrete) o; - return type == concrete.type && - Objects.equals(id, concrete.id) && - account.getId().equals(concrete.account.getId()) && - (Objects.equals(statusViewData, concrete.statusViewData)) && - (Objects.equals(report, concrete.report)); - } - - @Override - public int hashCode() { - - return Objects.hash(type, id, account, statusViewData); - } - - public Concrete copyWithStatus(@Nullable StatusViewData.Concrete statusViewData) { - return new Concrete(type, id, account, statusViewData, report); - } - } - - public static final class Placeholder extends NotificationViewData { - private final long id; - private final boolean isLoading; - - public Placeholder(long id, boolean isLoading) { - this.id = id; - this.isLoading = isLoading; - } - - public boolean isLoading() { - return isLoading; - } - - @Override - public long getViewDataId() { - return id; - } - - @Override - public boolean deepEquals(NotificationViewData other) { - if (!(other instanceof Placeholder)) return false; - Placeholder that = (Placeholder) other; - return isLoading == that.isLoading && id == that.id; - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.kt b/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.kt new file mode 100644 index 00000000..759d633e --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.kt @@ -0,0 +1,43 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . + */ + +/* + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ +package com.keylesspalace.tusky.viewdata + +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.entity.Report +import com.keylesspalace.tusky.entity.TimelineAccount + +data class NotificationViewData( + val type: Notification.Type, + val id: String, + val account: TimelineAccount, + var statusViewData: StatusViewData.Concrete?, + val report: Report? +) diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/PollViewData.kt b/app/src/main/java/com/keylesspalace/tusky/viewdata/PollViewData.kt index 3dc5ca10..7281bf06 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/PollViewData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/PollViewData.kt @@ -72,7 +72,7 @@ fun Poll?.toViewData(): PollViewData? { votesCount = votesCount, votersCount = votersCount, options = options.mapIndexed { index, option -> option.toViewData(ownVotes?.contains(index) == true) }, - voted = voted, + voted = voted ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt index f7125dc5..5ef3ef37 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt @@ -16,6 +16,7 @@ package com.keylesspalace.tusky.viewdata import android.os.Build import android.text.Spanned +import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.util.parseAsMastodonHtml import com.keylesspalace.tusky.util.replaceCrashingCharacters @@ -29,6 +30,7 @@ import com.keylesspalace.tusky.util.shouldTrimStatus */ sealed class StatusViewData { abstract val id: String + var filterAction: Filter.Action = Filter.Action.NONE data class Concrete( val status: Status, @@ -90,21 +92,6 @@ sealed class StatusViewData { this.isCollapsible = shouldTrimStatus(this.content) } - /** Helper for Java */ - fun copyWithStatus(status: Status): Concrete { - return copy(status = status) - } - - /** Helper for Java */ - fun copyWithExpanded(isExpanded: Boolean): Concrete { - return copy(isExpanded = isExpanded) - } - - /** Helper for Java */ - fun copyWithShowingContent(isShowingContent: Boolean): Concrete { - return copy(isShowingContent = isShowingContent) - } - /** Helper for Java */ fun copyWithCollapsed(isCollapsed: Boolean): Concrete { return copy(isCollapsed = isCollapsed) diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/TrendingViewData.kt b/app/src/main/java/com/keylesspalace/tusky/viewdata/TrendingViewData.kt new file mode 100644 index 00000000..6eae7331 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/TrendingViewData.kt @@ -0,0 +1,40 @@ +/* Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.viewdata + +import java.util.Date + +sealed class TrendingViewData { + abstract val id: String + + data class Header( + val start: Date, + val end: Date + ) : TrendingViewData() { + override val id: String + get() = start.toString() + end.toString() + } + + data class Tag( + val name: String, + val usage: List, + val accounts: List, + val maxTrendingValue: Long + ) : TrendingViewData() { + override val id: String + get() = name + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt index 88776606..fe511038 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt @@ -72,7 +72,6 @@ class EditProfileViewModel @Inject constructor( fun obtainProfile() = viewModelScope.launch { if (profileData.value == null || profileData.value is Error) { - profileData.postValue(Loading()) mastodonApi.accountVerifyCredentials().fold( @@ -100,7 +99,6 @@ class EditProfileViewModel @Inject constructor( } fun save(newDisplayName: String, newNote: String, newLocked: Boolean, newFields: List) { - if (saveData.value is Loading || profileData.value !is Success) { return } @@ -176,7 +174,8 @@ class EditProfileViewModel @Inject constructor( val newProfileSource = profileData.value?.data?.source?.copy(note = newNote, fields = newFields) val newProfile = profileData.value?.data?.copy( displayName = newDisplayName, - locked = newLocked, source = newProfileSource + locked = newLocked, + source = newProfileSource ) profileData.postValue(Success(newProfile)) diff --git a/app/src/main/java/com/keylesspalace/tusky/viewmodel/ListsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/viewmodel/ListsViewModel.kt index 4c755f86..f701847c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewmodel/ListsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewmodel/ListsViewModel.kt @@ -72,8 +72,11 @@ internal class ListsViewModel @Inject constructor(private val api: MastodonApi) { err -> updateState { copy( - loadingState = if (err is IOException || err is ConnectException) - LoadingState.ERROR_NETWORK else LoadingState.ERROR_OTHER + loadingState = if (err is IOException || err is ConnectException) { + LoadingState.ERROR_NETWORK + } else { + LoadingState.ERROR_OTHER + } ) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/worker/NotificationWorker.kt b/app/src/main/java/com/keylesspalace/tusky/worker/NotificationWorker.kt new file mode 100644 index 00000000..cc99b78b --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/worker/NotificationWorker.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.worker + +import android.app.Notification +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.ForegroundInfo +import androidx.work.WorkerParameters +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.components.notifications.NotificationFetcher +import com.keylesspalace.tusky.components.notifications.NotificationHelper +import com.keylesspalace.tusky.components.notifications.NotificationHelper.NOTIFICATION_ID_FETCH_NOTIFICATION +import javax.inject.Inject + +/** Fetch and show new notifications. */ +class NotificationWorker( + appContext: Context, + params: WorkerParameters, + private val notificationsFetcher: NotificationFetcher +) : CoroutineWorker(appContext, params) { + val notification: Notification = NotificationHelper.createWorkerNotification(applicationContext, R.string.notification_notification_worker) + + override suspend fun doWork(): Result { + notificationsFetcher.fetchAndShow() + return Result.success() + } + + override suspend fun getForegroundInfo() = ForegroundInfo(NOTIFICATION_ID_FETCH_NOTIFICATION, notification) + + class Factory @Inject constructor( + private val notificationsFetcher: NotificationFetcher + ) : ChildWorkerFactory { + override fun createWorker(appContext: Context, params: WorkerParameters): CoroutineWorker { + return NotificationWorker(appContext, params, notificationsFetcher) + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/worker/PruneCacheWorker.kt b/app/src/main/java/com/keylesspalace/tusky/worker/PruneCacheWorker.kt new file mode 100644 index 00000000..5a65a2ef --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/worker/PruneCacheWorker.kt @@ -0,0 +1,67 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.worker + +import android.app.Notification +import android.content.Context +import android.util.Log +import androidx.work.CoroutineWorker +import androidx.work.ForegroundInfo +import androidx.work.ListenableWorker +import androidx.work.WorkerParameters +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.components.notifications.NotificationHelper +import com.keylesspalace.tusky.components.notifications.NotificationHelper.NOTIFICATION_ID_PRUNE_CACHE +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.db.AppDatabase +import javax.inject.Inject + +/** Prune the database cache of old statuses. */ +class PruneCacheWorker( + appContext: Context, + workerParams: WorkerParameters, + private val appDatabase: AppDatabase, + private val accountManager: AccountManager +) : CoroutineWorker(appContext, workerParams) { + val notification: Notification = NotificationHelper.createWorkerNotification(applicationContext, R.string.notification_prune_cache) + + override suspend fun doWork(): Result { + for (account in accountManager.accounts) { + Log.d(TAG, "Pruning database using account ID: ${account.id}") + appDatabase.timelineDao().cleanup(account.id, MAX_STATUSES_IN_CACHE) + } + return Result.success() + } + + override suspend fun getForegroundInfo() = ForegroundInfo(NOTIFICATION_ID_PRUNE_CACHE, notification) + + companion object { + private const val TAG = "PruneCacheWorker" + private const val MAX_STATUSES_IN_CACHE = 1000 + const val PERIODIC_WORK_TAG = "PruneCacheWorker_periodic" + } + + class Factory @Inject constructor( + private val appDatabase: AppDatabase, + private val accountManager: AccountManager + ) : ChildWorkerFactory { + override fun createWorker(appContext: Context, params: WorkerParameters): ListenableWorker { + return PruneCacheWorker(appContext, params, appDatabase, accountManager) + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/worker/WorkerFactory.kt b/app/src/main/java/com/keylesspalace/tusky/worker/WorkerFactory.kt new file mode 100644 index 00000000..1d44b2ea --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/worker/WorkerFactory.kt @@ -0,0 +1,71 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.worker + +import android.content.Context +import android.util.Log +import androidx.work.ListenableWorker +import androidx.work.WorkerFactory +import androidx.work.WorkerParameters +import javax.inject.Inject +import javax.inject.Provider +import javax.inject.Singleton + +/** + * Workers implement this and are added to the map in [com.keylesspalace.tusky.di.WorkerModule] + * so they can be created by [WorkerFactory.createWorker]. + */ +interface ChildWorkerFactory { + /** Create a new instance of the given worker. */ + fun createWorker(appContext: Context, params: WorkerParameters): ListenableWorker +} + +/** + * Creates workers, delegating to each worker's [ChildWorkerFactory.createWorker] to do the + * creation. + * + * @see [com.keylesspalace.tusky.worker.NotificationWorker] + */ +@Singleton +class WorkerFactory @Inject constructor( + private val workerFactories: Map, @JvmSuppressWildcards Provider> +) : WorkerFactory() { + override fun createWorker( + appContext: Context, + workerClassName: String, + workerParameters: WorkerParameters + ): ListenableWorker? { + val key = try { + Class.forName(workerClassName) + } catch (e: ClassNotFoundException) { + // Class might be missing if it was renamed / moved to a different package, as + // periodic work requests from before the rename might still exist. Catch and + // return null, which should stop future requests. + Log.d(TAG, "Invalid class: $workerClassName", e) + null + } + workerFactories[key]?.let { + return it.get().createWorker(appContext, workerParameters) + } + return null + } + + companion object { + private const val TAG = "WorkerFactory" + } +} diff --git a/app/src/main/res/drawable/background_circle.xml b/app/src/main/res/drawable/background_circle.xml new file mode 100644 index 00000000..e10c9756 --- /dev/null +++ b/app/src/main/res/drawable/background_circle.xml @@ -0,0 +1,5 @@ + + + + diff --git a/app/src/main/res/drawable/help_message_background.xml b/app/src/main/res/drawable/help_message_background.xml new file mode 100644 index 00000000..cb28b798 --- /dev/null +++ b/app/src/main/res/drawable/help_message_background.xml @@ -0,0 +1,14 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_filter_24dp.xml b/app/src/main/res/drawable/ic_filter_24dp.xml new file mode 100644 index 00000000..ccb8fd22 --- /dev/null +++ b/app/src/main/res/drawable/ic_filter_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_trending_up_24px.xml b/app/src/main/res/drawable/ic_trending_up_24px.xml new file mode 100644 index 00000000..95e98c21 --- /dev/null +++ b/app/src/main/res/drawable/ic_trending_up_24px.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/layout-land/item_trending_cell.xml b/app/src/main/res/layout-land/item_trending_cell.xml new file mode 100644 index 00000000..81cf6c3f --- /dev/null +++ b/app/src/main/res/layout-land/item_trending_cell.xml @@ -0,0 +1,162 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout-sw640dp/fragment_timeline.xml b/app/src/main/res/layout-sw640dp/fragment_timeline.xml index e85798eb..6d014e70 100644 --- a/app/src/main/res/layout-sw640dp/fragment_timeline.xml +++ b/app/src/main/res/layout-sw640dp/fragment_timeline.xml @@ -12,18 +12,6 @@ android:layout_gravity="center_horizontal" android:background="?android:attr/colorBackground"> - - - - - - - + + + + + + + + + - \ No newline at end of file + diff --git a/app/src/main/res/layout-sw640dp/fragment_timeline_notifications.xml b/app/src/main/res/layout-sw640dp/fragment_timeline_notifications.xml index da5a204b..99b19573 100644 --- a/app/src/main/res/layout-sw640dp/fragment_timeline_notifications.xml +++ b/app/src/main/res/layout-sw640dp/fragment_timeline_notifications.xml @@ -1,4 +1,21 @@ + + - + android:layout_height="match_parent"> + + + + - - \ No newline at end of file + diff --git a/app/src/main/res/layout-sw640dp/fragment_view_thread.xml b/app/src/main/res/layout-sw640dp/fragment_view_thread.xml index 9248c542..c4053641 100644 --- a/app/src/main/res/layout-sw640dp/fragment_view_thread.xml +++ b/app/src/main/res/layout-sw640dp/fragment_view_thread.xml @@ -1,25 +1,10 @@ - - - - - - + + @@ -59,11 +52,4 @@ android:layout_gravity="center" android:contentDescription="@string/a11y_label_loading_thread" /> - - diff --git a/app/src/main/res/layout/activity_about.xml b/app/src/main/res/layout/activity_about.xml index c2d9bdfd..9048acce 100644 --- a/app/src/main/res/layout/activity_about.xml +++ b/app/src/main/res/layout/activity_about.xml @@ -55,7 +55,7 @@ android:textStyle="bold" /> - - - - \ No newline at end of file + diff --git a/app/src/main/res/layout/activity_account.xml b/app/src/main/res/layout/activity_account.xml index 41262647..b540b955 100644 --- a/app/src/main/res/layout/activity_account.xml +++ b/app/src/main/res/layout/activity_account.xml @@ -201,7 +201,8 @@ + android:hint="@string/account_note_hint" + android:textDirection="firstStrong" /> @@ -215,7 +216,7 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/accountNoteTextInputLayout" /> - @@ -441,8 +443,7 @@ android:layout_height="wrap_content" android:background="?attr/colorSurface" app:tabGravity="center" - app:tabMode="scrollable" - app:tabTextAppearance="@style/TuskyTabAppearance" /> + app:tabMode="scrollable" /> diff --git a/app/src/main/res/layout/activity_account_list.xml b/app/src/main/res/layout/activity_account_list.xml index c72ba66a..8023e1de 100644 --- a/app/src/main/res/layout/activity_account_list.xml +++ b/app/src/main/res/layout/activity_account_list.xml @@ -4,7 +4,7 @@ xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" - tools:context="com.keylesspalace.tusky.AccountListActivity"> + tools:context="com.keylesspalace.tusky.components.accountlist.AccountListActivity"> - \ No newline at end of file + + + diff --git a/app/src/main/res/layout/activity_announcements.xml b/app/src/main/res/layout/activity_announcements.xml index 6ae2da3d..4e75b689 100644 --- a/app/src/main/res/layout/activity_announcements.xml +++ b/app/src/main/res/layout/activity_announcements.xml @@ -21,23 +21,27 @@ android:layout_height="match_parent" app:layout_behavior="@string/appbar_scrolling_view_behavior"> - + android:layout_height="match_parent"> + + + + - - diff --git a/app/src/main/res/layout/activity_edit_filter.xml b/app/src/main/res/layout/activity_edit_filter.xml new file mode 100644 index 00000000..221a8b6a --- /dev/null +++ b/app/src/main/res/layout/activity_edit_filter.xml @@ -0,0 +1,173 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +