diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 84d088a9a..71ab930d1 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,11 +1,11 @@ # These are supported funding model platforms -github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +github: LucasGGamerM patreon: # mastodon open_collective: # Replace with a single Open Collective username e.g., user1 tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry -liberapay: # Replace with a single Liberapay username e.g., user1 +liberapay: LucasGGamerM # Replace with a single Liberapay username e.g., user1 issuehunt: # Replace with a single IssueHunt username e.g., user1 otechie: # Replace with a single Otechie username e.g., user1 custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 5aafe8adb..fba893604 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -22,12 +22,10 @@ Steps to reproduce the behavior: **Does this happen in the official app?** Does this issue also occur with the respective upstream release? -(Please test using the respective `upstream-xxxxxx.apk` provided in [Releases](https://github.com/sk22/megalodon/releases) or at least using the current Mastodon version from the Play Store) - > No / Yes > In case it does, please consider filing an [upstream bug report](https://github.com/mastodon/mastodon-android/issues) instead. -> If this bug is seriously impacting your usage or you think I might want to try to fix it for Megalodon, feel free to still create this issue! +> If this bug is seriously impacting your usage or you think I might want to try to fix it for Moshidon, feel free to still create this issue! **Screenshots and screen recordings** @@ -35,7 +33,7 @@ If applicable, add screenshots (and screen recordings, if possible) to help expl **Version** -Megalodon version: [e.g. v1.1.4+fork.#] +Moshidon version: [e.g. v1.1.4+fork.#] **Crash log** diff --git a/.github/workflows/nightly-builds.yml b/.github/workflows/nightly-builds.yml new file mode 100644 index 000000000..45e64bc97 --- /dev/null +++ b/.github/workflows/nightly-builds.yml @@ -0,0 +1,71 @@ +name: Nightly builds + +on: + push: + branches: [ "master" ] + workflow_dispatch: + +jobs: + build: + + runs-on: ubuntu-latest + + steps: +# - name: Checkout Appkit Repo +# uses: actions/checkout@v3 +# with: +# repository: grishka/appkit +# +# - name: set up JDK 17 +# uses: actions/setup-java@v3 +# with: +# java-version: '17' +# distribution: 'corretto' +# cache: gradle +# +# - name: Comment out signing config in appkits gradle file +# run: | +# sed -i 's/sign publishing\.publications\.release/\/\/ sign publishing.publications.release/' appkit/maven-push.gradle +# +# - name: Grant execute permission for gradlew for Appkit +# run: chmod +x gradlew +# +# - name: Compile appkit +# run: ./gradlew publishToMavenLocal + + - uses: actions/checkout@v3 + - name: set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'corretto' + cache: gradle + + - name: Get current date + id: date + run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Decode Keystore + id: decode_keystore + uses: timheuer/base64-to-file@v1 + with: + fileName: 'nightly_keystore.jks' + fileDir: './mastodon/keystore/' + encodedString: ${{ secrets.KEYSTORE }} + + - name: Build with Gradle + run: ./gradlew assembleNightly + env: + SIGNING_KEY_ALIAS: ${{ secrets.SIGNING_KEY_ALIAS }} + SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD }} + SIGNING_STORE_PASSWORD: ${{ secrets.SIGNING_STORE_PASSWORD }} + CURRENT_DATE: ${{ steps.date.outputs.date }} + + - name: Upload a Build Artifact + uses: actions/upload-artifact@v3.1.2 + with: + name: moshidon-nightly.apk + path: ./mastodon/build/outputs/apk/nightly/moshidon-nightly.apk diff --git a/.gitignore b/.gitignore index 593af090d..97a0829e2 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,5 @@ .cxx local.properties *.jks +*.keystore +/mastodon/keystore/nightly_keystore.keystore diff --git a/FAQ.md b/FAQ.md new file mode 100644 index 000000000..f18a7ca59 --- /dev/null +++ b/FAQ.md @@ -0,0 +1,9 @@ +## F.A.Q + +Q: What are the main differences between Moshidon and Megalodon? + +A: There are many, but the most outstanding differences are: the ability to have other server's local timeline inside the app. It can be acessed in the "Add community" option in the top right corner of the Edit timelines screen. Other outstanding features that Moshidon has are some quality of life improvements, such as notification actions and allowing for unlisted replies by default. Most other features are pretty minor, such as profile notes directly available in the person's profile. Other features are quite minor usability and visibility improvements. All of which can be found in the settings page. + +Q: Will there ever be a version of Moshidon for iOS? + +A: No. As android and iOS apps do not share code, it is incredibly hard to port. diff --git a/README.md b/README.md index e3f257b34..a66e44b84 100644 --- a/README.md +++ b/README.md @@ -1,128 +1,125 @@ -![Pink logo with pink shark](mastodon/src/main/res/mipmap-xhdpi/ic_launcher_round.png) +![MoshidonLogo](mastodon/src/main/res/mipmap-xhdpi/ic_launcher_round.png) -# Megalodon +# Moshidon, the material you mastodon client! -[![Translation status](https://translate.codeberg.org/widgets/megalodon/-/svg-badge.svg)](https://translate.codeberg.org/engage/megalodon/) +> A fork of [megalodon](https://github.com/sk22/megalodon) which is a fork of [official Mastodon Android app](https://github.com/mastodon/mastodon-android) adding important features that are missing in the official app and possibly won’t ever be implemented, such as the federated timeline, unlisted posting, bookmarks and an image description viewer. + + +[![Download latest release](https://img.shields.io/badge/dynamic/json?color=282C37&label=Download%20APK&query=%24.tag_name&url=https%3A%2F%2Fapi.github.com%2Frepos%2FLucasGGamerM%2Fmoshidon%2Freleases%2Flatest&style=for-the-badge)](https://github.com/LucasGGamerM/moshidon/releases/latest/download/moshidon.apk) + +[![Download nightly release](https://img.shields.io/badge/dynamic/json?color=282C37&label=Download%20Nightly%20APK&query=%24.tag_name&url=https%3A%2F%2Fapi.github.com%2Frepos%2FLucasGGamerM%2Fmoshidon%2Freleases%2Flatest&style=for-the-badge)](https://github.com/LucasGGamerM/moshidon-nightly/releases/latest/download/moshidon-nightly.apk) + + +[![Translation status](https://translate.codeberg.org/widgets/moshidon/-/svg-badge.svg)](https://translate.codeberg.org/engage/moshidon/)   -[![Download latest release](https://img.shields.io/badge/dynamic/json?color=d92aad&label=Download%20APK&query=%24.tag_name&url=https%3A%2F%2Fapi.github.com%2Frepos%2Fsk22%2Fmegalodon%2Freleases%2Flatest&style=flat)](https://github.com/sk22/megalodon/releases/latest/download/megalodon.apk) +[![Nightly build](https://github.com/LucasGGamerM/moshidon/actions/workflows/nightly-builds.yml/badge.svg)](https://github.com/LucasGGamerM/moshidon/actions/workflows/nightly-builds.yml) -Get it on Google Play +Get it on Google Play   -Get it on IzzyOnDroid +Get it on F-Droid +  +Get it on IzzyOnDroid -> A fork of the [Mastodon Android app](https://github.com/mastodon/mastodon-android) adding important features that are missing in the official app, focusing on [Glitch](https://glitch-soc.github.io/docs) compatibility, a pretty UI and adding new features that I feel make using the Fediverse a more pleasant experience. +## Help out the project by donating at: https://github.com/sponsors/LucasGGamerM! +### We also support LiberaPay at: https://liberapay.com/LucasGGamerM/donate +### You can also donate some Monero through this wallet address as well: +4886mdarcyB6Yf8Qc6vDJBK1fz6ibHFLZUmHb4GZZz9yLGNhcG3XC64e5UZ8dVQYTLZb82W6P9WhteowW4STJEec97Gf22j + +--- ## Key features +### **The ability to add other server's local timeline to your timelines** + +It can be accessed in the "Edit timelines" menu, where you can add a new "Community" to see other server's local posts! + +### **View remote profiles** + +You can now see all of a profile follows and followers, by directly loading them from the profile's home instance. In case of a failed lookup, the app will automatically fall back to the older method. + +### **Translate posts easily** + +Allows you to easily translate posts in another language with a translate button! Your instance must support translation, otherwise it will not work. + +### **Show posts filtered with a warning** + +Allows you to have filtered posts collapsed with a warning! As shown in the screenshots: + +Before | After +:-------------------------:|:-------------------------: +![Screenshot_20230205-100200edited](https://user-images.githubusercontent.com/71328265/216820539-20802dc5-e433-4511-b2d9-291d810e4ef2.png) | ![Screenshot_20230205-100203edited](https://user-images.githubusercontent.com/71328265/216820544-231b2966-f38f-4ec6-b555-d39c62433839.png) + + +### **Color themes** + +Allows you to change theme within the app. Supports Material You, purple, pink, green, blue, red, orange, yellow and Nord! + ### **Unlisted posting** -
-

Allows you to post publicly without having your post show up in trends, hashtags or public timelines (i.e., in the tabs “Community”, “Federated” and “Posts”).

- -When posting with Unlisted visibility, your posts will still be publicly accessible in your profile. They will also be shown in people’s Home timelines, but only if they follow you or someone they follow reblogged/replied to your post. +**Allows you to post publicly without having your post show up in trends, hashtags or public timelines (i.e., in the tabs “Local”, “Community” and “Posts”).** +When posting with Unlisted visibility, your posts will still be publicly accessible in your profile. They will also be shown in people’s Home timelines, but only if they follow you or someone they follow reposted/replied to your post. + The Mastodon documentation has some more information about [Unlisted posting](https://docs.joinmastodon.org/user/posting/#unlisted) and [Public timelines](https://docs.joinmastodon.org/user/network/#timelines). -
### **Federated timeline** -
-

This allows you to chronologically see all Public posts from people on all other Fediverse neighborhoods your home instance is connected to.

+**This allows you to chronologically see all Public posts from people on all other Fediverse neighborhoods your home instance is connected to.** Despite being one of the main features of federated social media, the Federated timeline wasn’t included in the official Mastodon app – supposedly, because this conflicts with Google’s safety requirements for apps on the Play Store. That’s one of the reasons why choosing a small, **well-moderated instance is important**. Instance admins and moderators should always make sure to ban abusive users and stop federating with instances who platform them. On well-moderated instances, the Federated timeline can be a welcoming place to meet new people! -
-### **Customizable timelines** +### **Image description viewer** -
-

You can customize Megalodon’s home tab and not only add local and federated timelines, but also pin lists and hashtags.

+**Allows you to quickly check whether an image or video has an alternative text attached to it.** -Even better: You can rename every timeline however you please and pick a distinct icon for each timeline. This way, you can pin the hashtag “#Caturday”, rename your timeline to “CUTENESS OVERLOAD” and set Cat icon from Microsoft Fluent UI icons as its icon. :3 You can find the timelines editor by opening your home tab, tapping the `⋮` button in the top right and going to “Edit timelines”. -
+This is important to **ensure the content you’re sharing is as accessible as possible** to people who can’t see the images and rely on software to read back the provided content descriptions. Thankfully, it’s quite common for people on the Fediverse to provide such alt texts, and hopefully things stay this way! -### **Draft and schedule posts** +### **Reminder to add alt text to attached media** -
-

-Allows to prepare a post and schedule it to send it automatically at a specific time.

+By default, Moshidon will show a warning to add alt text if your post has any attachments without any alt text. This is for better accessibility, and it can easily be bypassed and disabled in settings. -You can create drafts, edit them, send them manually later or set a scheduled date. Drafts are technically saved as scheduled posts, so you can view and edit them from other apps that support scheduled posts. Scheduled posts are handled by your home instance, so they'll work even if you uninstall Megalodon. -
+### **Pinning posts** + +**This lets you can highlight important posts on your profile. A dedicated “Pinned” tab in people’s profiles shows all the posts they pinned.** + +On the Fediverse, it’s quite common for people to pin posts they want others to read before following them. You can pin/unpin posts yourself by clicking the `⋯` button in the top right corner of your posts. + +### **Bookmarks** + +**They allow for quickly saving posts and viewing them through the Bookmarks button on the top right of your profile.** + +To bookmark a post, press the button between the Favorite and Share buttons on the bottom of the post. Bookmarks are saved privately, so the post authors won’t know you saved their post – the list of bookmarked posts is only visible to you. ## Installation -### Google Play Store +**Press the download button above to download the APK. Open the downloaded file on your Android device to install it. Moshidon will automatically notify you about new updates inside the app.** -[https://play.google.com/store/apps/details?id=org.joinmastodon.android.sk](https://play.google.com/store/apps/details?id=org.joinmastodon.android.sk) +To install this app on your Android device, download the [latest release from GitHub](https://github.com/LucasGGamerM/moshidon/releases/latest/download/moshidon.apk) and open it. You might have to accept installing APK files from your browser when trying to install it. You can also take a look at all releases on the [Releases](https://github.com/LucasGGamerM/moshidon/releases) page. -Get it on Google Play - -### F-Droid via IzzyOnDroid - -[https://apt.izzysoft.de/fdroid/index/apk/org.joinmastodon.android.sk](https://apt.izzysoft.de/fdroid/index/apk/org.joinmastodon.android.sk) - -Get it on IzzyOnDroid - -Note that you'll need to add Izzy's F-Droid repository to your F-Droid app first: - -[`https://apt.izzysoft.de/fdroid/repo`](https://apt.izzysoft.de/fdroid/repo) - -### F-Droid via saunarepo - -[https://repo.the-sauna.icu](https://repo.the-sauna.icu/) - -Get it on SaunaRepo - -### F-Droid - -**[F-Droid.org?](https://f-droid.org)** Not yet, sorry! - -If you want, you can help me figure out if something's missing in the [Issue #47: F-Droid.org](https://github.com/sk22/megalodon/issues/47) - -### Direct - -Press the download button to download the APK. Open the downloaded file on your Android device to install it. Megalodon will automatically notify you about new updates inside the app. - -[![Download latest release](https://img.shields.io/badge/dynamic/json?color=d92aad&label=Download%20APK&query=%24.tag_name&url=https%3A%2F%2Fapi.github.com%2Frepos%2Fsk22%2Fmegalodon%2Freleases%2Flatest&style=flat)](https://github.com/sk22/megalodon/releases/latest/download/megalodon.apk) - -You might have to accept installing APK files from your browser when trying to install it. You can also take a look at all releases on the [Releases](https://github.com/sk22/megalodon/releases) page. - -Megalodon makes use of [Mastodon for Android](https://github.com/mastodon/mastodon-android)’s automatic update checker. Megalodon will check for new updates available on GitHub and offer to download and install them. You can also manually press “Check for updates” at the bottom of the settings page! - ---- +Moshidon makes use of [Mastodon for Android](https://github.com/mastodon/mastodon-android)’s automatic update checker. Moshidon will check for new updates available on GitHub and offer to download and install them. You can also manually press “Check for updates” at the bottom of the settings page! +Moshidon is also available in [IzzyOnDroid repo](https://apt.izzysoft.de/fdroid/index/apk/org.joinmastodon.android.moshinda), compatible with all F-Droid clients. The APK provided here is the same as the one included in the Releases. ## Release variants -All downloads can be found on the [Releases](https://github.com/sk22/megalodon/releases) page. When downloading a pre-release, expect to see unfinished features and bugs. If you don’t want that, just download the [latest full release](https://github.com/sk22/megalodon/releases/latest/download/megalodon.apk). +### Stable variant -**`megalodon.apk`** +All stable version downloads can be found on the [Releases](https://github.com/LucasGGamerM/moshidon/releases) page. -Variant with an integrated updater. If you download Megalodon from here (and not from an app store), just download the regular `megalodon.apk`. +**`moshidon.apk`** -**`upstream-1234abc.apk`** +Variant with an integrated updater. If you download Moshidon from here (and not from an app store), just download the regular `moshidon.apk`. -This is an **unmodified version** of the official [Mastodon for Android](https://github.com/mastodon/mastodon-android) app the respective Megalodon release is based on. Should you find any bugs in Megalodon (which you will), try to see if it occurs with this variant, too. The last 7 digits of the file name are important to know which version of the official app you're using. +### Nightly variant - - ---- - -## Contribution - -### Translation - -The translation for the base of the app is sourced from the upstream **Mastodon for Android** project, which you can contribute to on its Crowdin project: [https://crowdin.com/project/mastodon-for-android](https://crowdin.com/project/mastodon-for-android) - -There's also a bunch of custom strings exclusive to this project that need to be translated. You can help translate **Megalodon** on Weblate: [https://translate.codeberg.org/projects/megalodon](https://translate.codeberg.org/projects/megalodon) - -[![Translation status](https://translate.codeberg.org/widgets/megalodon/-/horizontal-auto.svg)](https://translate.codeberg.org/engage/megalodon) +**`moshidon-nightly.apk`** +Unstable variant with an integrated updater. It's for development and testing purposes. If you find any bugs with it, please file a bug report at our [issues](https://github.com/LucasGGamerM/moshidon/issues) page. --- @@ -131,16 +128,25 @@ There's also a bunch of custom strings exclusive to this project that need to be ### Features +* [Adding the ability to view other server's local timelines](https://github.com/LucasGGamerM/moshidon/tree/feature/local-timelines) +* [Adding the ability to load followers and following from remote instance](https://github.com/LucasGGamerM/moshidon/tree/feature/remote-followers) +* [Adding the ability to have filtered posts show with a warning](https://github.com/LucasGGamerM/moshidon/tree/feature/filters_again) * [Add “Unlisted” as a post visibility option](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/enable-unlisted) ([Pull request](https://github.com/mastodon/mastodon-android/pull/103)) -* [Add “Federation” tab and change Discover tab order](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/add-federated-timeline) ([Closes issue](https://github.com/mastodon/mastodon-android/issues/8)) +* Adding a useful private profile note box +* Auto hiding the compose button on scroll +* Adding the ability to remind yourself to add alt text to images +* An indicator for if an image has alt text or not +* Adding the ability to have drafts +* Also adding the ability to view announcements from your instance +* Adding the ability to post for local timeline only (Only on instances that support it!) * [Add image description button and viewer](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/display-alt-text) ([Pull request](https://github.com/mastodon/mastodon-android/pull/129)) * [Implement pinning posts and displaying pinned posts](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/pin-posts) ([Pull request](https://github.com/mastodon/mastodon-android/pull/140)) * [Implement deleting and re-drafting](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/delete-redraft) ([Closes issue](https://github.com/mastodon/mastodon-android/issues/21)) * [Implement a bookmark button and list](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/bookmarks) ([Closes issue](https://github.com/mastodon/mastodon-android/issues/22)) * [Add “Check for update” button in addition to integrated update checker](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/check-for-update-button) * [Add “Mark media as sensitive” option](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/mark-media-as-sensitive) -* [Add settings to hide replies and reblogs from the timeline](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/filter-home-timeline) ([Pull request](https://github.com/mastodon/mastodon-android/pull/317)) +* [Add settings to hide replies and reposts from the timeline](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/filter-home-timeline) ([Pull request](https://github.com/mastodon/mastodon-android/pull/317)) * [Follow and unfollow hashtags](https://github.com/sk22/megalodon/commit/7d38f031f197aa6cefaf53e39d929538689c1e4e) ([Closes issue](https://github.com/mastodon/mastodon-android/issues/233)) * [Notification bell for posts](https://github.com/sk22/megalodon/commit/b166ca705eb9169025ef32bbe6315b42491b57ea) ([Closes issue](https://github.com/mastodon/mastodon-android/issues/81)) * [Viewing lists and adding/removing users from lists](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:list-timeline-views) based on [@obstsalatschuessel](https://github.com/obstsalatschuessel)'s [Pull request](https://github.com/mastodon/mastodon-android/pull/286) @@ -150,27 +156,13 @@ There's also a bunch of custom strings exclusive to this project that need to be * [Add notifications tab for posts](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/posts-notifications-tab) * [Show visibility of original post when replying](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/display-reply-visibility) * [Clickable reply/boost line above posts](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:clickable-boost-reply-line) -* [Add push notification setting for post notifications](https://github.com/sk22/megalodon/commit/b190480d7739be47f23543d9e7644660f9b4b4ee) -* [Add option to allow voting for multiple options on polls](https://github.com/sk22/megalodon/commit/5b28468efd49387b4f8b83f142f3adf3104ca60c) -* [Add translate function](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/translate-button) -* [Add language selector](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/language-selector) -* [Implement deleting notifications](https://github.com/sk22/megalodon/commit/b0f9ce081f69f29ad59658fc00ca41372cd2677d) (disabled by default) -* [Long-click boost button to "quote" a post](https://github.com/sk22/megalodon/commit/b25a237c20c6a924ed4d9b357999867c3a32b32b) -* [Draft and schedule posts](https://github.com/sk22/megalodon/pull/217) -* [Display original post when replying](https://github.com/sk22/megalodon/commit/375f8ceb2747705fedf43686681cc0e0b812f899) -* [Display server announcements](https://github.com/sk22/megalodon/commit/84179bc207d6b69cc2a770a3c28fa0a39b0b54e8) -* [Create](https://github.com/sk22/megalodon/commit/294595513a45037359b31377aafc25ae5b58d8e7), [edit](https://github.com/sk22/megalodon/commit/d47797bf7ac8cff3f9ba1cfee219a1bb2af21da6) and [delete](https://github.com/sk22/megalodon/commit/54c29fd787fc2cd0dfd2787ad796b8190f795973) lists -* [Soft-blocking (by blocking and immediately unblocking)](https://github.com/sk22/megalodon/commit/e75d350b7a2709259e9fc5138e0e1f361bdb0972) -* [Pinnable custom timelines](https://github.com/sk22/megalodon/pull/338/commits) -* Support for local-only posts -* Support for copying the URL to posts/accounts/… in Pixel launcher’s Recent apps view -* Compatibility for Akkoma Bubble timeline -* Listings of followers/following/favorites/boosts can be loaded from the origin instance (there’s an option to disable this in in the settings) -* Allow opening posts/accounts in-app by sharing a URL/handle to Megalodon (Originally implemented in [Moshidon](https://github.com/LucasGGamerM/moshidon), [PR](https://github.com/sk22/megalodon/pull/531)) +* [Clickable reply line while replying to open original post](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/clickable-reply-line-compose) ### Behavior +* Allow for confirmation before reblogging +* Adding a bottom option for the publish button, allowing for easier use on larger screens! * [Make back button return to the home tab before exiting the app](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/back-returns-home) ([Closes issue](https://github.com/mastodon/mastodon-android/issues/118)) * [Always preserve content warnings when replying](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/always-preserve-cw) ([Closes issue](https://github.com/mastodon/mastodon-android/issues/113)) * [Display full image when adding image description](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/compose-image-description-full-image) ([Pull request](https://github.com/mastodon/mastodon-android/pull/182)) @@ -178,22 +170,6 @@ There's also a bunch of custom strings exclusive to this project that need to be * [Option to hide interaction numbers](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:settings/hide-interaction-numbers) * [Option to always reveal content warnings](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/cw-above-text) * [Option to disable scrolling title bars](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:settings/disable-marquee) -* [No ellipsis for long poll answers](https://github.com/mastodon/mastodon-android/commit/c9aae828e2518adccdc092e41f8d1f0489636271) -* [Show poll vote button for multiple and single answer polls](https://github.com/mastodon/mastodon-android/commit/e14dfda2fdf32f0fa3043504ac5831683a87559a) -* [Show own vote after voting](https://github.com/mastodon/mastodon-android/commit/4ab9e25fec4fd9c10b7a8ddd1be522b3cc12cf28) ([Closes issue](https://github.com/mastodon/mastodon-android/commit/4ab9e25fec4fd9c10b7a8ddd1be522b3cc12cf28)) -* [Make inline emoji search case-insensitive and don't only search from start of emoji names](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:better-inline-emoji-search) ([Pull request](https://github.com/mastodon/mastodon-android/pull/445)) -* [Include subject line when sharing e.g. a website to Megalodon](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:external-share-include-subject) -* [Improve semantics for voting on polls (radio buttons and checkboxes)](https://github.com/sk22/megalodon/commit/6fd58c96827cb1d2da329cebdc170a1425dd18d7) -* [Copy post URL when long-pressing share button](https://github.com/sk22/megalodon/commit/ba36347f03278763ecec617b1ce57ba89db7be72) -* [Add option to disable swiping between tabs](https://github.com/sk22/megalodon/commit/1f20b21fc84bf006c1ec14bd2229cbfad5215ec8) -* [Resolve Fediverse links in the app](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/open-urls-in-app) -* [Preserve whitespaces in HTML](https://github.com/sk22/megalodon/commit/7d876bddc7a07d98f0fecbf62b13bdb9fcce3412) -* [Long-click to copy links](https://github.com/sk22/megalodon/commit/b32e32274923a94742a9926ef38785f746d41405) -* Improved filtering using Mastodon 4.0 API: [#202](https://github.com/sk22/megalodon/pull/202), [#212](https://github.com/sk22/megalodon/pull/212), [#255](https://github.com/sk22/megalodon/pull/255) by [@thiagojedi](https://github.com/thiagojedi) -* [Support admin notifications](https://github.com/sk22/megalodon/commit/c12a6eaee6b609bc53eb0a45d9199f37d5241801) and [notifications for edited reblogged posts](https://github.com/sk22/megalodon/commit/900e8fb2e9353002c16d15e06b78d2731e121601) -* [Android file opener added back in addition to image picker](https://github.com/sk22/megalodon/commit/3a6ace53d5ab01e28077c9c930cb6ed487b78031) -* [Replies are inserted below the replied-to post in thread view](https://github.com/sk22/megalodon/commit/87c37df370ec24aeea0d2dbaeb29468aa4fb5808) -* Option to auto-reveal equal content warnings in threads ### Visual @@ -201,17 +177,6 @@ There's also a bunch of custom strings exclusive to this project that need to be * [Custom extended footer redesign](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:compact-extended-footer) * [Improvements to the true black mode](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:true-black-improvements) * [Profile header tweaks](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:ui/profile-header-tweaks) -* [Custom color themes](https://github.com/sk22/megalodon/pull/124) by [@LucasGGamerM](https://github.com/LucasGGamerM) -* [Custom "megalodon" text logo](https://github.com/sk22/megalodon/commit/563afd487ca5c608cfbb00fa3909d3c27384acc0) by [@LucasGGamerM](https://github.com/LucasGGamerM) -* [Custom login screen](https://github.com/sk22/megalodon/commit/9bbf8c4618dbe13accaeb3b5482bf3fe88cac4c0) -* [More distinct filled boost icon](https://github.com/sk22/megalodon/commits/more-distinct-filled-boost-icon) -* Material You color theme by [@LucasGGamerM](https://github.com/LucasGGamerM) -* [Animations for interaction buttons](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/animate-buttons) -* [Dedicated icons for different notification types](https://github.com/sk22/megalodon/pull/178) by [@florian-obernberger](https://github.com/florian-obernberger) -* Scale text according to system settings -* Header in timeline for followed hashtags -* [Indicator for missing alt texts](https://github.com/sk22/megalodon/commit/c0c276f03e793b78c478c17dfdef24a66ef7cedb) -* Visually grouped (by removing divider lines and reducing padding) threaded replies in thread view ## Building @@ -222,12 +187,18 @@ As this app is using Java 17 features, you need JDK 17 or newer to build it. Oth ./gradlew assembleRelease ``` -Note that Megalodon might be depending on an in-development version of [AppKit](https://github.com/grishka/appkit) – a library by Mastodon for Android’s developer. In case the used AppKit version isn’t published to Maven Central yet, you might have to clone, build and publish it to your local Maven repository. For more information, see [this GitHub issue](https://github.com/mastodon/mastodon-android/issues/375#issuecomment-1507678585). - ## License This project is released under the [GPL-3 License](./LICENSE). ## Links -@megalodon@floss.social +[F.A.Q](FAQ.md) + +[Official matrix chatroom:](https://matrix.to/#/#moshidon:floss.social) https://matrix.to/#/#moshidon:floss.social + +[Moshidon roadmap](https://github.com/users/LucasGGamerM/projects/1) + +@moshidon@floss.social + +--- diff --git a/_config.yml b/_config.yml index 4e7d76b75..402335c59 100644 --- a/_config.yml +++ b/_config.yml @@ -1,2 +1,2 @@ -title: Megalodon +title: Moshidon layout: default diff --git a/_layouts/default.html b/_layouts/default.html index 490f89903..1007430d6 100644 --- a/_layouts/default.html +++ b/_layouts/default.html @@ -4,7 +4,7 @@ - Megalodon + Moshidon @@ -14,4 +14,4 @@ {{ content }} - \ No newline at end of file + diff --git a/build.gradle b/build.gradle index 5f07f7905..1bf260e0d 100644 --- a/build.gradle +++ b/build.gradle @@ -9,6 +9,7 @@ buildscript { includeModule 'com.github.UnifiedPush', 'android-connector' } } + mavenLocal() } dependencies { classpath 'com.android.tools.build:gradle:8.0.0' diff --git a/fix-metadata-markdown-lists.sh b/fix-metadata-markdown-lists.sh old mode 100755 new mode 100644 diff --git a/gradle.properties b/gradle.properties index 415e17f9f..cd211a89f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -19,4 +19,5 @@ android.useAndroidX=true android.enableJetifier=false android.defaults.buildfeatures.buildconfig=true android.nonTransitiveRClass=true -android.nonFinalResIds=false \ No newline at end of file +android.nonFinalResIds=false +org.gradle.configuration-cache=true \ No newline at end of file diff --git a/mastodon/build.gradle b/mastodon/build.gradle index cbc8b0219..03a4d68c9 100644 --- a/mastodon/build.gradle +++ b/mastodon/build.gradle @@ -11,16 +11,63 @@ java { android { compileSdk 33 defaultConfig { - archivesBaseName = "megalodon" - applicationId "org.joinmastodon.android.sk" + manifestPlaceholders = [oAuthScheme:"moshidon-android-auth"] + archivesBaseName = "moshidon" + applicationId "org.joinmastodon.android.moshinda" minSdk 23 - targetSdk 33 - versionCode 110 - versionName "2.1.6+fork.110" + targetSdk 34 + versionCode 107 + versionName "2.3.0+fork.107.moshinda" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" resourceConfigurations += ['ar-rSA', 'ar-rDZ', 'be-rBY', 'bn-rBD', 'bs-rBA', 'ca-rES', 'cs-rCZ', 'da-rDK', 'de-rDE', 'el-rGR', 'es-rES', 'eu-rES', 'fa-rIR', 'fi-rFI', 'fil-rPH', 'fr-rFR', 'ga-rIE', 'gd-rGB', 'gl-rES', 'hi-rIN', 'hr-rHR', 'hu-rHU', 'hy-rAM', 'ig-rNG', 'in-rID', 'is-rIS', 'it-rIT', 'iw-rIL', 'ja-rJP', 'kab', 'ko-rKR', 'my-rMM', 'nl-rNL', 'no-rNO', 'oc-rFR', 'pl-rPL', 'pt-rBR', 'pt-rPT', 'ro-rRO', 'ru-rRU', 'si-rLK', 'sl-rSI', 'sv-rSE', 'th-rTH', 'tr-rTR', 'uk-rUA', 'ur-rIN', 'vi-rVN', 'zh-rCN', 'zh-rTW'] } + signingConfigs { + nightly{ + storeFile = file("keystore/nightly_keystore.jks") + storePassword System.getenv("SIGNING_STORE_PASSWORD") + if (storePassword == null) { + Properties properties = new Properties() + properties.load(project.rootProject.file('local.properties').newDataInputStream()) + storePassword = properties.getProperty('SIGNING_STORE_PASSWORD') + } + keyAlias System.getenv("SIGNING_KEY_ALIAS") + if (keyAlias == null) { + Properties properties = new Properties() + properties.load(project.rootProject.file('local.properties').newDataInputStream()) + keyAlias = properties.getProperty('SIGNING_KEY_ALIAS') + } + keyPassword System.getenv("SIGNING_KEY_PASSWORD") + if (keyPassword == null) { + Properties properties = new Properties() + properties.load(project.rootProject.file('local.properties').newDataInputStream()) + keyPassword = properties.getProperty('SIGNING_KEY_PASSWORD') + } + } + +// release{ +// storeFile = file("keystore/release_keystore.jks") +// storePassword System.getenv("RELEASE_SIGNING_STORE_PASSWORD") +// if (storePassword == null) { +// Properties properties = new Properties() +// properties.load(project.rootProject.file('local.properties').newDataInputStream()) +// storePassword = properties.getProperty('RELEASE_SIGNING_STORE_PASSWORD') +// } +// keyAlias System.getenv("RELEASE_SIGNING_KEY_ALIAS") +// if (keyAlias == null) { +// Properties properties = new Properties() +// properties.load(project.rootProject.file('local.properties').newDataInputStream()) +// keyAlias = properties.getProperty('RELEASE_SIGNING_KEY_ALIAS') +// } +// keyPassword System.getenv("RELEASE_SIGNING_KEY_PASSWORD") +// if (keyPassword == null) { +// Properties properties = new Properties() +// properties.load(project.rootProject.file('local.properties').newDataInputStream()) +// keyPassword = properties.getProperty('RELEASE_SIGNING_KEY_PASSWORD') +// } +// } + } + buildTypes { release { minifyEnabled true @@ -31,9 +78,40 @@ android { debuggable true versionNameSuffix '-debug' applicationIdSuffix '.debug' + manifestPlaceholders = [oAuthScheme:"moshidon-android-debug-auth"] + } + githubRelease{ + initWith release + } + nightly{ + if(System.getenv("CURRENT_DATE") != null){ + versionNameSuffix '-nightly+@' + System.getenv("CURRENT_DATE") + } else { + Properties properties = new Properties() + properties.load(project.rootProject.file('local.properties').newDataInputStream()) + versionNameSuffix '-nightly+@' + properties.getProperty('CURRENT_DATE') + } + applicationIdSuffix '.nightly' + + signingConfig signingConfigs.nightly + manifestPlaceholders = [oAuthScheme:"moshidon-android-nightly-auth"] + } + playRelease{ + initWith release + minifyEnabled true + shrinkResources true + versionNameSuffix '-play' + } + githubRelease { + initWith release + versionNameSuffix '-github' + } + fdroidRelease { + initWith release + // The F-droid build system doesn't like this at all for some reason. +// versionNameSuffix '-fdroid' +// signingConfig signingConfigs.release } - githubRelease { initWith release } - fdroidRelease { initWith release } } compileOptions { sourceCompatibility JavaVersion.VERSION_17 @@ -45,7 +123,7 @@ android { setRoot "src/github" } debug { - setRoot "src/github" + setRoot "src/debug" } } namespace 'org.joinmastodon.android' @@ -69,7 +147,7 @@ dependencies { implementation 'me.grishka.litex:viewpager:1.0.0' implementation 'me.grishka.litex:viewpager2:1.0.0' implementation 'me.grishka.litex:palette:1.0.0' - implementation 'me.grishka.appkit:appkit:1.2.14' + implementation 'me.grishka.appkit:appkit:1.2.16' implementation 'com.google.code.gson:gson:2.9.0' implementation 'org.jsoup:jsoup:1.14.3' implementation 'com.squareup:otto:1.3.8' diff --git a/mastodon/proguard-rules.pro b/mastodon/proguard-rules.pro index f12799a6f..39538ac74 100644 --- a/mastodon/proguard-rules.pro +++ b/mastodon/proguard-rules.pro @@ -45,6 +45,13 @@ -keepattributes LineNumberTable +-keepattributes * +-keep,allowobfuscation,allowshrinking class com.google.gson.reflect.TypeToken +-keep,allowobfuscation,allowshrinking class * extends com.google.gson.reflect.TypeToken + +#-keep class javax.** { *; } +-keep class org.joinmastodon.android.** { *; } + # Parceler library -keep interface org.parceler.Parcel -keep @org.parceler.Parcel class * { *; } diff --git a/mastodon/src/androidTest/java/org/joinmastodon/android/utils/StatusFilterPredicateTest.java b/mastodon/src/androidTest/java/org/joinmastodon/android/utils/StatusFilterPredicateTest.java deleted file mode 100644 index 84f156706..000000000 --- a/mastodon/src/androidTest/java/org/joinmastodon/android/utils/StatusFilterPredicateTest.java +++ /dev/null @@ -1,81 +0,0 @@ -package org.joinmastodon.android.utils; - -import static org.joinmastodon.android.model.FilterAction.*; -import static org.joinmastodon.android.model.FilterContext.*; -import static org.junit.Assert.*; - -import org.joinmastodon.android.model.LegacyFilter; -import org.joinmastodon.android.model.Status; -import org.junit.Test; - -import java.time.Instant; -import java.util.EnumSet; -import java.util.List; - -public class StatusFilterPredicateTest { - - private static final LegacyFilter hideMeFilter = new LegacyFilter(), warnMeFilter = new LegacyFilter(); - private static final List allFilters = List.of(hideMeFilter, warnMeFilter); - - private static final Status - hideInHomePublic = Status.ofFake(null, "hide me, please", Instant.now()), - warnInHomePublic = Status.ofFake(null, "display me with a warning", Instant.now()); - - static { - hideMeFilter.phrase = "hide me"; - hideMeFilter.filterAction = HIDE; - hideMeFilter.context = EnumSet.of(PUBLIC, HOME); - - warnMeFilter.phrase = "warning"; - warnMeFilter.filterAction = WARN; - warnMeFilter.context = EnumSet.of(PUBLIC, HOME); - } - - @Test - public void testHide() { - assertFalse("should not pass because matching filter applies to given context", - new StatusFilterPredicate(allFilters, HOME).test(hideInHomePublic)); - } - - @Test - public void testHideRegardlessOfContext() { - assertTrue("filters without context should always pass", - new StatusFilterPredicate(allFilters, null).test(hideInHomePublic)); - } - - @Test - public void testHideInDifferentContext() { - assertTrue("should pass because matching filter does not apply to given context", - new StatusFilterPredicate(allFilters, THREAD).test(hideInHomePublic)); - } - - @Test - public void testHideWithWarningText() { - assertTrue("should pass because matching filter is for warnings", - new StatusFilterPredicate(allFilters, HOME).test(warnInHomePublic)); - } - - @Test - public void testWarn() { - assertFalse("should not pass because filter applies to given context", - new StatusFilterPredicate(allFilters, HOME, WARN).test(warnInHomePublic)); - } - - @Test - public void testWarnRegardlessOfContext() { - assertTrue("filters without context should always pass", - new StatusFilterPredicate(allFilters, null, WARN).test(warnInHomePublic)); - } - - @Test - public void testWarnInDifferentContext() { - assertTrue("should pass because filter does not apply to given context", - new StatusFilterPredicate(allFilters, THREAD, WARN).test(warnInHomePublic)); - } - - @Test - public void testWarnWithHideText() { - assertTrue("should pass because matching filter is for hiding", - new StatusFilterPredicate(allFilters, HOME, WARN).test(hideInHomePublic)); - } -} \ No newline at end of file diff --git a/mastodon/src/github/AndroidManifest.xml b/mastodon/src/debug/AndroidManifest.xml similarity index 81% rename from mastodon/src/github/AndroidManifest.xml rename to mastodon/src/debug/AndroidManifest.xml index 5838d1c43..aba7ef431 100644 --- a/mastodon/src/github/AndroidManifest.xml +++ b/mastodon/src/debug/AndroidManifest.xml @@ -1,9 +1,14 @@ - + - + + diff --git a/mastodon/src/debug/java/org/joinmastodon/android/updater/GithubSelfUpdaterImpl.java b/mastodon/src/debug/java/org/joinmastodon/android/updater/GithubSelfUpdaterImpl.java new file mode 100644 index 000000000..e4c5b321b --- /dev/null +++ b/mastodon/src/debug/java/org/joinmastodon/android/updater/GithubSelfUpdaterImpl.java @@ -0,0 +1,378 @@ +package org.joinmastodon.android.updater; + +import android.app.Activity; +import android.app.DownloadManager; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.content.pm.PackageInstaller; +import android.database.Cursor; +import android.net.Uri; +import android.os.Build; +import android.util.Log; +import android.widget.Toast; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import org.joinmastodon.android.BuildConfig; +import org.joinmastodon.android.E; +import org.joinmastodon.android.GlobalUserPreferences; +import org.joinmastodon.android.MastodonApp; +import org.joinmastodon.android.R; +import org.joinmastodon.android.api.MastodonAPIController; +import org.joinmastodon.android.events.SelfUpdateStateChangedEvent; + +import java.io.File; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import androidx.annotation.Keep; +import okhttp3.Call; +import okhttp3.Request; +import okhttp3.Response; + +@Keep +public class GithubSelfUpdaterImpl extends GithubSelfUpdater{ + private static final long CHECK_PERIOD=6*3600*1000L; + private static final String TAG="GithubSelfUpdater"; + + private UpdateState state=UpdateState.NO_UPDATE; + private UpdateInfo info; + private long downloadID; + private BroadcastReceiver downloadCompletionReceiver=new BroadcastReceiver(){ + @Override + public void onReceive(Context context, Intent intent){ + if(downloadID!=0 && intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0)==downloadID){ + MastodonApp.context.unregisterReceiver(this); + setState(UpdateState.DOWNLOADED); + } + } + }; + + public GithubSelfUpdaterImpl(){ + SharedPreferences prefs=getPrefs(); + int checkedByBuild=prefs.getInt("checkedByBuild", 0); + if(prefs.contains("version") && checkedByBuild==BuildConfig.VERSION_CODE){ + info=new UpdateInfo(); + info.version=prefs.getString("version", null); + info.size=prefs.getLong("apkSize", 0); + info.changelog=prefs.getString("changelog", null); + downloadID=prefs.getLong("downloadID", 0); + if(downloadID==0 || !getUpdateApkFile().exists()){ + state=UpdateState.UPDATE_AVAILABLE; + }else{ + DownloadManager dm=MastodonApp.context.getSystemService(DownloadManager.class); + state=dm.getUriForDownloadedFile(downloadID)==null ? UpdateState.DOWNLOADING : UpdateState.DOWNLOADED; + if(state==UpdateState.DOWNLOADING){ + MastodonApp.context.registerReceiver(downloadCompletionReceiver, new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)); + } + } + }else if(checkedByBuild!=BuildConfig.VERSION_CODE && checkedByBuild>0){ + // We are in a new version, running for the first time after update. Gotta clean things up. + long id=getPrefs().getLong("downloadID", 0); + if(id!=0){ + MastodonApp.context.getSystemService(DownloadManager.class).remove(id); + } + getUpdateApkFile().delete(); + getPrefs().edit() + .remove("apkSize") + .remove("version") + .remove("apkURL") + .remove("checkedByBuild") + .remove("downloadID") + .remove("changelog") + .apply(); + } + } + + private SharedPreferences getPrefs(){ + return MastodonApp.context.getSharedPreferences("githubUpdater", Context.MODE_PRIVATE); + } + + @Override + public void maybeCheckForUpdates(){ + if(state!=UpdateState.NO_UPDATE && state!=UpdateState.UPDATE_AVAILABLE) + return; + long timeSinceLastCheck=System.currentTimeMillis()-getPrefs().getLong("lastCheck", CHECK_PERIOD); + if(timeSinceLastCheck>=CHECK_PERIOD){ + setState(UpdateState.CHECKING); + MastodonAPIController.runInBackground(this::actuallyCheckForUpdates); + } + } + + @Override + public void checkForUpdates() { + setState(UpdateState.CHECKING); + MastodonAPIController.runInBackground(this::actuallyCheckForUpdates); + } + + private void actuallyCheckForUpdates(){ + Request req=new Request.Builder() + .url("https://api.github.com/repos/LucasGGamerM/moshidon/releases") + .build(); + Call call=MastodonAPIController.getHttpClient().newCall(req); + try(Response resp=call.execute()){ + JsonArray arr=JsonParser.parseReader(resp.body().charStream()).getAsJsonArray(); + for (JsonElement jsonElement : arr) { + JsonObject obj = jsonElement.getAsJsonObject(); + if (obj.get("prerelease").getAsBoolean() && !GlobalUserPreferences.enablePreReleases) continue; + + String tag=obj.get("tag_name").getAsString(); + String changelog=obj.get("body").getAsString(); + Pattern pattern=Pattern.compile("v?(\\d+)\\.(\\d+)\\.(\\d+)\\+fork\\.(\\d+)"); + Matcher matcher=pattern.matcher(tag); + if(!matcher.find()){ + Log.w(TAG, "actuallyCheckForUpdates: release tag has wrong format: "+tag); + return; + } + int newMajor=Integer.parseInt(matcher.group(1)), + newMinor=Integer.parseInt(matcher.group(2)), + newRevision=Integer.parseInt(matcher.group(3)), + newForkNumber=Integer.parseInt(matcher.group(4)); + matcher=pattern.matcher(BuildConfig.VERSION_NAME); + String[] currentParts=BuildConfig.VERSION_NAME.split("[.+]"); + if(!matcher.find()){ + Log.w(TAG, "actuallyCheckForUpdates: current version has wrong format: "+BuildConfig.VERSION_NAME); + return; + } + int curMajor=Integer.parseInt(matcher.group(1)), + curMinor=Integer.parseInt(matcher.group(2)), + curRevision=Integer.parseInt(matcher.group(3)), + curForkNumber=Integer.parseInt(matcher.group(4)); + long newVersion=((long)newMajor << 32) | ((long)newMinor << 16) | newRevision; + long curVersion=((long)curMajor << 32) | ((long)curMinor << 16) | curRevision; + if(newVersion>curVersion || newForkNumber>curForkNumber){ + String version=newMajor+"."+newMinor+"."+newRevision+"+fork."+newForkNumber; + Log.d(TAG, "actuallyCheckForUpdates: new version: "+version); + for(JsonElement el:obj.getAsJsonArray("assets")){ + JsonObject asset=el.getAsJsonObject(); + if("moshidon.apk".equals(asset.get("name").getAsString()) && "application/vnd.android.package-archive".equals(asset.get("content_type").getAsString()) && "uploaded".equals(asset.get("state").getAsString())){ + long size=asset.get("size").getAsLong(); + String url=asset.get("browser_download_url").getAsString(); + + UpdateInfo info=new UpdateInfo(); + info.size=size; + info.version=version; + info.changelog=changelog; + this.info=info; + + getPrefs().edit() + .putLong("apkSize", size) + .putString("version", version) + .putString("apkURL", url) + .putString("changelog", changelog) + .putInt("checkedByBuild", BuildConfig.VERSION_CODE) + .remove("downloadID") + .apply(); + + break; + } + } + } + getPrefs().edit().putLong("lastCheck", System.currentTimeMillis()).apply(); + break; + } + }catch(Exception x){ + Log.w(TAG, "actuallyCheckForUpdates", x); + }finally{ + setState(info==null ? UpdateState.NO_UPDATE : UpdateState.UPDATE_AVAILABLE); + } + } + + private void setState(UpdateState state){ + this.state=state; + E.post(new SelfUpdateStateChangedEvent(state)); + } + + @Override + public UpdateState getState(){ + return state; + } + + @Override + public UpdateInfo getUpdateInfo(){ + return info; + } + + public File getUpdateApkFile(){ + return new File(MastodonApp.context.getExternalCacheDir(), "update.apk"); + } + + @Override + public void downloadUpdate(){ + if(state==UpdateState.DOWNLOADING) + throw new IllegalStateException(); + DownloadManager dm=MastodonApp.context.getSystemService(DownloadManager.class); + MastodonApp.context.registerReceiver(downloadCompletionReceiver, new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)); + downloadID=dm.enqueue( + new DownloadManager.Request(Uri.parse(getPrefs().getString("apkURL", null))) + .setDestinationUri(Uri.fromFile(getUpdateApkFile())) + ); + getPrefs().edit().putLong("downloadID", downloadID).apply(); + setState(UpdateState.DOWNLOADING); + } + + @Override + public void installUpdate(Activity activity){ + if(state!=UpdateState.DOWNLOADED) + throw new IllegalStateException(); + Uri uri; + Intent intent=new Intent(Intent.ACTION_INSTALL_PACKAGE); + if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N){ + uri=new Uri.Builder().scheme("content").authority(activity.getPackageName()+".self_update_provider").path("update.apk").build(); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + }else{ + uri=Uri.fromFile(getUpdateApkFile()); + } + intent.setDataAndType(uri, "application/vnd.android.package-archive"); + activity.startActivity(intent); + + // TODO figure out how to restart the app when updating via this new API + /* + PackageInstaller installer=activity.getPackageManager().getPackageInstaller(); + try{ + final int sid=installer.createSession(new PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)); + installer.registerSessionCallback(new PackageInstaller.SessionCallback(){ + @Override + public void onCreated(int i){ + + } + + @Override + public void onBadgingChanged(int i){ + + } + + @Override + public void onActiveChanged(int i, boolean b){ + + } + + @Override + public void onProgressChanged(int id, float progress){ + + } + + @Override + public void onFinished(int id, boolean success){ + activity.getPackageManager().setComponentEnabledSetting(new ComponentName(activity, AfterUpdateRestartReceiver.class), PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP); + } + }); + activity.getPackageManager().setComponentEnabledSetting(new ComponentName(activity, AfterUpdateRestartReceiver.class), PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP); + PackageInstaller.Session session=installer.openSession(sid); + try(OutputStream out=session.openWrite("mastodon.apk", 0, info.size); InputStream in=new FileInputStream(getUpdateApkFile())){ + byte[] buffer=new byte[16384]; + int read; + while((read=in.read(buffer))>0){ + out.write(buffer, 0, read); + } + } +// PendingIntent intent=PendingIntent.getBroadcast(activity, 1, new Intent(activity, InstallerStatusReceiver.class), PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_MUTABLE); + PendingIntent intent=PendingIntent.getActivity(activity, 1, new Intent(activity, MainActivity.class), PendingIntent.FLAG_UPDATE_CURRENT); + session.commit(intent.getIntentSender()); + }catch(IOException x){ + Log.w(TAG, "installUpdate", x); + Toast.makeText(activity, x.getMessage(), Toast.LENGTH_SHORT).show(); + } + */ + } + + @Override + public float getDownloadProgress(){ + if(state!=UpdateState.DOWNLOADING) + throw new IllegalStateException(); + DownloadManager dm=MastodonApp.context.getSystemService(DownloadManager.class); + try(Cursor cursor=dm.query(new DownloadManager.Query().setFilterById(downloadID))){ + if(cursor.moveToFirst()){ + long loaded=cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)); + long total=cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)); +// Log.d(TAG, "getDownloadProgress: "+loaded+" of "+total); + return total>0 ? (float)loaded/total : 0f; + } + } + return 0; + } + + @Override + public void cancelDownload(){ + if(state!=UpdateState.DOWNLOADING) + throw new IllegalStateException(); + DownloadManager dm=MastodonApp.context.getSystemService(DownloadManager.class); + dm.remove(downloadID); + downloadID=0; + getPrefs().edit().remove("downloadID").apply(); + setState(UpdateState.UPDATE_AVAILABLE); + } + + @Override + public void handleIntentFromInstaller(Intent intent, Activity activity){ + int status=intent.getIntExtra(PackageInstaller.EXTRA_STATUS, 0); + if(status==PackageInstaller.STATUS_PENDING_USER_ACTION){ + Intent confirmIntent=intent.getParcelableExtra(Intent.EXTRA_INTENT); + activity.startActivity(confirmIntent); + }else if(status!=PackageInstaller.STATUS_SUCCESS){ + String msg=intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE); + Toast.makeText(activity, activity.getString(R.string.error)+":\n"+msg, Toast.LENGTH_LONG).show(); + } + } + + @Override + public void reset(){ + getPrefs().edit().clear().apply(); + File apk=getUpdateApkFile(); + if(apk.exists()) + apk.delete(); + state=UpdateState.NO_UPDATE; + } + + /*public static class InstallerStatusReceiver extends BroadcastReceiver{ + + @Override + public void onReceive(Context context, Intent intent){ + int status=intent.getIntExtra(PackageInstaller.EXTRA_STATUS, 0); + if(status==PackageInstaller.STATUS_PENDING_USER_ACTION){ + Intent confirmIntent=intent.getParcelableExtra(Intent.EXTRA_INTENT); + context.startActivity(confirmIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); + }else if(status!=PackageInstaller.STATUS_SUCCESS){ + String msg=intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE); + Toast.makeText(context, context.getString(R.string.error)+":\n"+msg, Toast.LENGTH_LONG).show(); + } + } + } + + public static class AfterUpdateRestartReceiver extends BroadcastReceiver{ + + @Override + public void onReceive(Context context, Intent intent){ + if(Intent.ACTION_MY_PACKAGE_REPLACED.equals(intent.getAction())){ + context.getPackageManager().setComponentEnabledSetting(new ComponentName(context, AfterUpdateRestartReceiver.class), PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP); + Toast.makeText(context, R.string.update_installed, Toast.LENGTH_SHORT).show(); + Intent restartIntent=new Intent(context, MainActivity.class) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + .setPackage(context.getPackageName()); + if(Build.VERSION.SDK_INT + + diff --git a/mastodon/src/debug/res/drawable/ic_fluent_mail_inbox_dismiss_24_regular.xml b/mastodon/src/debug/res/drawable/ic_fluent_mail_inbox_dismiss_24_regular.xml new file mode 100644 index 000000000..e32e4fab5 --- /dev/null +++ b/mastodon/src/debug/res/drawable/ic_fluent_mail_inbox_dismiss_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/debug/res/drawable/ic_fluent_mail_inbox_dismiss_28_regular.xml b/mastodon/src/debug/res/drawable/ic_fluent_mail_inbox_dismiss_28_regular.xml new file mode 100644 index 000000000..2ea945f3d --- /dev/null +++ b/mastodon/src/debug/res/drawable/ic_fluent_mail_inbox_dismiss_28_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/debug/res/drawable/ic_fluent_mention_20_regular.xml b/mastodon/src/debug/res/drawable/ic_fluent_mention_20_regular.xml new file mode 100644 index 000000000..77a55c495 --- /dev/null +++ b/mastodon/src/debug/res/drawable/ic_fluent_mention_20_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/debug/res/drawable/ic_fluent_open_24_regular.xml b/mastodon/src/debug/res/drawable/ic_fluent_open_24_regular.xml new file mode 100644 index 000000000..e18fe0aed --- /dev/null +++ b/mastodon/src/debug/res/drawable/ic_fluent_open_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/debug/res/drawable/ic_fluent_sign_out_24_regular.xml b/mastodon/src/debug/res/drawable/ic_fluent_sign_out_24_regular.xml new file mode 100644 index 000000000..d20ea1330 --- /dev/null +++ b/mastodon/src/debug/res/drawable/ic_fluent_sign_out_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/debug/res/drawable/ic_fluent_speaker_0_24_regular.xml b/mastodon/src/debug/res/drawable/ic_fluent_speaker_0_24_regular.xml new file mode 100644 index 000000000..8a35ff41e --- /dev/null +++ b/mastodon/src/debug/res/drawable/ic_fluent_speaker_0_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/debug/res/drawable/ic_fluent_speaker_0_28_regular.xml b/mastodon/src/debug/res/drawable/ic_fluent_speaker_0_28_regular.xml new file mode 100644 index 000000000..53c6f5b6a --- /dev/null +++ b/mastodon/src/debug/res/drawable/ic_fluent_speaker_0_28_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/debug/res/drawable/ic_fluent_speaker_off_24_regular.xml b/mastodon/src/debug/res/drawable/ic_fluent_speaker_off_24_regular.xml new file mode 100644 index 000000000..e1b6ba1c9 --- /dev/null +++ b/mastodon/src/debug/res/drawable/ic_fluent_speaker_off_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/debug/res/drawable/ic_fluent_speaker_off_28_regular.xml b/mastodon/src/debug/res/drawable/ic_fluent_speaker_off_28_regular.xml new file mode 100644 index 000000000..05defaa38 --- /dev/null +++ b/mastodon/src/debug/res/drawable/ic_fluent_speaker_off_28_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/debug/res/drawable/ic_launcher_foreground_debug.xml b/mastodon/src/debug/res/drawable/ic_launcher_foreground_debug.xml new file mode 100644 index 000000000..3faa6b997 --- /dev/null +++ b/mastodon/src/debug/res/drawable/ic_launcher_foreground_debug.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/mastodon/src/debug/res/drawable/ic_launcher_foreground_monochrome_debug.xml b/mastodon/src/debug/res/drawable/ic_launcher_foreground_monochrome_debug.xml new file mode 100644 index 000000000..3faa6b997 --- /dev/null +++ b/mastodon/src/debug/res/drawable/ic_launcher_foreground_monochrome_debug.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/mastodon/src/debug/res/mipmap-anydpi-v26/ic_launcher.xml b/mastodon/src/debug/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 000000000..16a3a2677 --- /dev/null +++ b/mastodon/src/debug/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/mastodon/src/debug/res/mipmap-anydpi-v26/ic_launcher_round.xml b/mastodon/src/debug/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 000000000..16a3a2677 --- /dev/null +++ b/mastodon/src/debug/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/mastodon/src/debug/res/mipmap-hdpi/ic_launcher.png b/mastodon/src/debug/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..e867bc877 Binary files /dev/null and b/mastodon/src/debug/res/mipmap-hdpi/ic_launcher.png differ diff --git a/mastodon/src/debug/res/mipmap-hdpi/ic_launcher_round.png b/mastodon/src/debug/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 000000000..4e20506d7 Binary files /dev/null and b/mastodon/src/debug/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/mastodon/src/debug/res/mipmap-mdpi/ic_launcher.png b/mastodon/src/debug/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..4bb62962b Binary files /dev/null and b/mastodon/src/debug/res/mipmap-mdpi/ic_launcher.png differ diff --git a/mastodon/src/debug/res/mipmap-mdpi/ic_launcher_round.png b/mastodon/src/debug/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 000000000..065ca7d71 Binary files /dev/null and b/mastodon/src/debug/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/mastodon/src/debug/res/mipmap-xhdpi/ic_launcher.png b/mastodon/src/debug/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..df1a45557 Binary files /dev/null and b/mastodon/src/debug/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/mastodon/src/debug/res/mipmap-xhdpi/ic_launcher_round.png b/mastodon/src/debug/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 000000000..d336b3dc9 Binary files /dev/null and b/mastodon/src/debug/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/mastodon/src/debug/res/mipmap-xxhdpi/ic_launcher.png b/mastodon/src/debug/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..165514c03 Binary files /dev/null and b/mastodon/src/debug/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/mastodon/src/debug/res/mipmap-xxhdpi/ic_launcher_round.png b/mastodon/src/debug/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 000000000..8d8aa6fa9 Binary files /dev/null and b/mastodon/src/debug/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/mastodon/src/debug/res/mipmap-xxxhdpi/ic_launcher.png b/mastodon/src/debug/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..351a649b1 Binary files /dev/null and b/mastodon/src/debug/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/mastodon/src/debug/res/mipmap-xxxhdpi/ic_launcher_round.png b/mastodon/src/debug/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 000000000..e0709f895 Binary files /dev/null and b/mastodon/src/debug/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/mastodon/src/debug/res/values/ic_launcher_background.xml b/mastodon/src/debug/res/values/ic_launcher_background.xml new file mode 100644 index 000000000..beab31f75 --- /dev/null +++ b/mastodon/src/debug/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #000000 + \ No newline at end of file diff --git a/mastodon/src/github/java/org/joinmastodon/android/updater/GithubSelfUpdaterImpl.java b/mastodon/src/github/java/org/joinmastodon/android/updater/GithubSelfUpdaterImpl.java index caced56a9..1cf492b5f 100644 --- a/mastodon/src/github/java/org/joinmastodon/android/updater/GithubSelfUpdaterImpl.java +++ b/mastodon/src/github/java/org/joinmastodon/android/updater/GithubSelfUpdaterImpl.java @@ -115,7 +115,7 @@ public class GithubSelfUpdaterImpl extends GithubSelfUpdater{ private void actuallyCheckForUpdates(){ Request req=new Request.Builder() - .url("https://api.github.com/repos/sk22/megalodon/releases") + .url("https://api.github.com/repos/LucasGGamerM/moshidon/releases") .build(); Call call=MastodonAPIController.getHttpClient().newCall(req); try(Response resp=call.execute()){ @@ -154,7 +154,7 @@ public class GithubSelfUpdaterImpl extends GithubSelfUpdater{ Log.d(TAG, "actuallyCheckForUpdates: new version: "+version); for(JsonElement el:obj.getAsJsonArray("assets")){ JsonObject asset=el.getAsJsonObject(); - if("megalodon.apk".equals(asset.get("name").getAsString()) && "application/vnd.android.package-archive".equals(asset.get("content_type").getAsString()) && "uploaded".equals(asset.get("state").getAsString())){ + if("moshidon.apk".equals(asset.get("name").getAsString()) && "application/vnd.android.package-archive".equals(asset.get("content_type").getAsString()) && "uploaded".equals(asset.get("state").getAsString())){ long size=asset.get("size").getAsLong(); String url=asset.get("browser_download_url").getAsString(); @@ -211,7 +211,13 @@ public class GithubSelfUpdaterImpl extends GithubSelfUpdater{ if(state==UpdateState.DOWNLOADING) throw new IllegalStateException(); DownloadManager dm=MastodonApp.context.getSystemService(DownloadManager.class); - MastodonApp.context.registerReceiver(downloadCompletionReceiver, new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)); + + if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.TIRAMISU){ + MastodonApp.context.registerReceiver(downloadCompletionReceiver, new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE), Context.RECEIVER_EXPORTED); + }else{ + MastodonApp.context.registerReceiver(downloadCompletionReceiver, new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)); + } + downloadID=dm.enqueue( new DownloadManager.Request(Uri.parse(getPrefs().getString("apkURL", null))) .setDestinationUri(Uri.fromFile(getUpdateApkFile())) diff --git a/mastodon/src/main/AndroidManifest.xml b/mastodon/src/main/AndroidManifest.xml index 3444c7818..6fed73c8c 100644 --- a/mastodon/src/main/AndroidManifest.xml +++ b/mastodon/src/main/AndroidManifest.xml @@ -1,16 +1,22 @@ + xmlns:android="http://schemas.android.com/apk/res/android" + package="org.joinmastodon.android"> - + + + + @@ -25,11 +31,13 @@ @@ -58,7 +66,7 @@ - + + + + + + + + + @@ -98,6 +115,14 @@ + + + + \ No newline at end of file diff --git a/mastodon/src/main/assets/blocks.txt b/mastodon/src/main/assets/blocks.txt index e7cf9991f..d2951e651 100644 --- a/mastodon/src/main/assets/blocks.txt +++ b/mastodon/src/main/assets/blocks.txt @@ -20,13 +20,16 @@ cachapa.xyz canary.fedinuke.example.com catgirl.life cawfee.club -childlove.space +childlove.su clew.lol clubcyberia.co contrapointsfan.club +cottoncandy.cafe +crlf.ninja crucible.world cum.camp cum.salon +cunnyborea.space decayable.ink dembased.xyz detroitriotcity.com @@ -34,10 +37,12 @@ djsumdog.com eientei.org eveningzoo.club fluf.club +foxgirl.lol freak.university freeatlantis.com freespeechextremist.com froth.zone +fsebugoutzone.org gameliberty.club gearlandia.haus genderheretics.xyz @@ -49,6 +54,7 @@ goyim.app h5q.net haeder.net handholding.io +harpy.faith hitchhiker.social iddqd.social kitsunemimi.club @@ -56,15 +62,14 @@ kiwifarms.cc kurosawa.moe kyaruc.moe leafposter.club -lewdieheaven.com liberdon.com ligma.pro +loli.church lolicon.rocks lolison.network lolison.top lovingexpressions.net makemysarcophagus.com -marsey.moe mastinator.com merovingian.club midwaytrades.com @@ -74,17 +79,21 @@ mouse.services mugicha.club narrativerry.xyz natehiggers.online +nationalist.social needs.vodka neenster.org nicecrew.digital +nightshift.social nnia.space noagendasocial.com noagendasocial.nl noagendatube.com +noauthority.social nobodyhasthe.biz norwoodzero.net nyanide.com onionfarms.org +parcero.bond pawlicker.com pawoo.net pedo.school @@ -129,9 +138,11 @@ sonichu.com spinster.xyz springbo.cc strelizia.net +taihou.website tastingtraffic.net teci.world theapex.social +theblab.org thechimp.zone thenobody.club thepostearthdestination.com @@ -139,9 +150,11 @@ tkammer.de trumpislovetrumpis.life truthsocial.co.in usualsuspects.lol +vampiremaid.cafe varishangout.net vtuberfan.social wolfgirl.bar xn--p1abe3d.xn--80asehdb yggdrasil.social youjo.love +zhub.link \ No newline at end of file diff --git a/mastodon/src/main/ic_launcher-playstore.png b/mastodon/src/main/ic_launcher-playstore.png index ffc0e5991..a3d056a93 100644 Binary files a/mastodon/src/main/ic_launcher-playstore.png and b/mastodon/src/main/ic_launcher-playstore.png differ diff --git a/mastodon/src/main/java/org/joinmastodon/android/AudioPlayerService.java b/mastodon/src/main/java/org/joinmastodon/android/AudioPlayerService.java index 067446d3e..94f06fc17 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/AudioPlayerService.java +++ b/mastodon/src/main/java/org/joinmastodon/android/AudioPlayerService.java @@ -88,8 +88,13 @@ public class AudioPlayerService extends Service{ nm=getSystemService(NotificationManager.class); // registerReceiver(receiver, new IntentFilter(Intent.ACTION_MEDIA_BUTTON)); registerReceiver(receiver, new IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY)); - registerReceiver(receiver, new IntentFilter(ACTION_PLAY_PAUSE)); - registerReceiver(receiver, new IntentFilter(ACTION_STOP)); + if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.TIRAMISU){ + registerReceiver(receiver, new IntentFilter(ACTION_PLAY_PAUSE), RECEIVER_EXPORTED); + registerReceiver(receiver, new IntentFilter(ACTION_STOP), RECEIVER_EXPORTED); + }else{ + registerReceiver(receiver, new IntentFilter(ACTION_PLAY_PAUSE)); + registerReceiver(receiver, new IntentFilter(ACTION_STOP)); + } instance=this; } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ChooseAccountForComposeActivity.java b/mastodon/src/main/java/org/joinmastodon/android/ChooseAccountForComposeActivity.java new file mode 100644 index 000000000..1b2241e07 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ChooseAccountForComposeActivity.java @@ -0,0 +1,52 @@ +package org.joinmastodon.android; + +import android.app.Fragment; +import android.content.Intent; +import android.os.Bundle; +import android.widget.Toast; + +import org.joinmastodon.android.api.session.AccountSession; +import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.fragments.ComposeFragment; +import org.joinmastodon.android.ui.sheets.AccountSwitcherSheet; +import org.joinmastodon.android.ui.utils.UiUtils; + +import java.util.List; +import java.util.Objects; + +import androidx.annotation.Nullable; +import me.grishka.appkit.FragmentStackActivity; + +public class ChooseAccountForComposeActivity extends FragmentStackActivity{ + @Override + protected void onCreate(@Nullable Bundle savedInstanceState){ + UiUtils.setUserPreferredTheme(this); + super.onCreate(savedInstanceState); + if (savedInstanceState == null && Objects.equals(getIntent().getAction(), Intent.ACTION_CHOOSER)) { + AccountSessionManager.getInstance().maybeUpdateLocalInfo(); + List sessions=AccountSessionManager.getInstance().getLoggedInAccounts(); + if (sessions.isEmpty()){ + Toast.makeText(this, R.string.err_not_logged_in, Toast.LENGTH_SHORT).show(); + finish(); + } else if (sessions.size() > 1) { + AccountSwitcherSheet sheet = new AccountSwitcherSheet(this, null, R.drawable.ic_fluent_compose_28_regular, + R.string.choose_account, null, false); + sheet.setOnClick((accountId, open) -> { + openComposeFragment(accountId); + }); + sheet.show(); + } else if (sessions.size() == 1) { + openComposeFragment(sessions.get(0).getID()); + } + } + } + + private void openComposeFragment(String accountID){ + getWindow().setBackgroundDrawable(null); + Bundle args=new Bundle(); + args.putString("account", accountID); + Fragment fragment=new ComposeFragment(); + fragment.setArguments(args); + showFragmentClearingBackStack(fragment); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ExternalShareActivity.java b/mastodon/src/main/java/org/joinmastodon/android/ExternalShareActivity.java index 382456256..588d26996 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ExternalShareActivity.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ExternalShareActivity.java @@ -13,7 +13,7 @@ import org.joinmastodon.android.api.MastodonAPIRequest; import org.joinmastodon.android.api.session.AccountSession; import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.fragments.ComposeFragment; -import org.joinmastodon.android.ui.AccountSwitcherSheet; +import org.joinmastodon.android.ui.sheets.AccountSwitcherSheet; import org.joinmastodon.android.ui.utils.UiUtils; import org.jsoup.internal.StringUtil; @@ -42,7 +42,11 @@ public class ExternalShareActivity extends FragmentStackActivity{ Toast.makeText(this, R.string.err_not_logged_in, Toast.LENGTH_SHORT).show(); finish(); } else if (isOpenable || sessions.size() > 1) { - AccountSwitcherSheet sheet = new AccountSwitcherSheet(this, null, true, isOpenable); + AccountSwitcherSheet sheet = new AccountSwitcherSheet(this, null, R.drawable.ic_fluent_share_28_regular, + isOpenable + ? R.string.sk_external_share_or_open_title + : R.string.sk_external_share_title, + null, isOpenable); sheet.setOnClick((accountId, open) -> { if (open && text.isPresent()) { BiConsumer, Bundle> callback = (clazz, args) -> { @@ -82,6 +86,8 @@ public class ExternalShareActivity extends FragmentStackActivity{ } private void openComposeFragment(String accountID){ + AccountSession session=AccountSessionManager.get(accountID); + UiUtils.setUserPreferredTheme(this, session); getWindow().setBackgroundDrawable(null); Intent intent=getIntent(); diff --git a/mastodon/src/main/java/org/joinmastodon/android/FileProvider.java b/mastodon/src/main/java/org/joinmastodon/android/FileProvider.java new file mode 100644 index 000000000..4c4965294 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/FileProvider.java @@ -0,0 +1,841 @@ +/* + * Copyright (C) 2013 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.joinmastodon.android; + +import static org.xmlpull.v1.XmlPullParser.END_DOCUMENT; +import static org.xmlpull.v1.XmlPullParser.START_TAG; + +import android.content.ClipData; +import android.content.ContentProvider; +import android.content.ContentValues; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.ProviderInfo; +import android.content.res.XmlResourceParser; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.Environment; +import android.os.ParcelFileDescriptor; +import android.provider.OpenableColumns; +import android.text.TextUtils; +import android.webkit.MimeTypeMap; + +import androidx.annotation.GuardedBy; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.xmlpull.v1.XmlPullParserException; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +/** + * FileProvider is a special subclass of {@link ContentProvider} that facilitates secure sharing + * of files associated with an app by creating a content:// {@link Uri} for a file + * instead of a file:/// {@link Uri}. + *

+ * A content URI allows you to grant read and write access using + * temporary access permissions. When you create an {@link Intent} containing + * a content URI, in order to send the content URI + * to a client app, you can also call {@link Intent#setFlags(int) Intent.setFlags()} to add + * permissions. These permissions are available to the client app for as long as the stack for + * a receiving {@link android.app.Activity} is active. For an {@link Intent} going to a + * {@link android.app.Service}, the permissions are available as long as the + * {@link android.app.Service} is running. + *

+ * In comparison, to control access to a file:/// {@link Uri} you have to modify the + * file system permissions of the underlying file. The permissions you provide become available to + * any app, and remain in effect until you change them. This level of access is + * fundamentally insecure. + *

+ * The increased level of file access security offered by a content URI + * makes FileProvider a key part of Android's security infrastructure. + *

+ * This overview of FileProvider includes the following topics: + *

+ *
    + *
  1. Defining a FileProvider
  2. + *
  3. Specifying Available Files
  4. + *
  5. Retrieving the Content URI for a File
  6. + *
  7. Granting Temporary Permissions to a URI
  8. + *
  9. Serving a Content URI to Another App
  10. + *
+ *

Defining a FileProvider

+ *

+ * Since the default functionality of FileProvider includes content URI generation for files, you + * don't need to define a subclass in code. Instead, you can include a FileProvider in your app + * by specifying it entirely in XML. To specify the FileProvider component itself, add a + * <provider> + * element to your app manifest. Set the android:name attribute to + * androidx.core.content.FileProvider. Set the android:authorities + * attribute to a URI authority based on a domain you control; for example, if you control the + * domain mydomain.com you should use the authority + * com.mydomain.fileprovider. Set the android:exported attribute to + * false; the FileProvider does not need to be public. Set the + * android:grantUriPermissions attribute to true, to allow you + * to grant temporary access to files. For example: + *

+ *<manifest>
+ *    ...
+ *    <application>
+ *        ...
+ *        <provider
+ *            android:name="androidx.core.content.FileProvider"
+ *            android:authorities="com.mydomain.fileprovider"
+ *            android:exported="false"
+ *            android:grantUriPermissions="true">
+ *            ...
+ *        </provider>
+ *        ...
+ *    </application>
+ *</manifest>
+ *

+ * If you want to override any of the default behavior of FileProvider methods, extend + * the FileProvider class and use the fully-qualified class name in the android:name + * attribute of the <provider> element. + *

Specifying Available Files

+ * A FileProvider can only generate a content URI for files in directories that you specify + * beforehand. To specify a directory, specify the its storage area and path in XML, using child + * elements of the <paths> element. + * For example, the following paths element tells FileProvider that you intend to + * request content URIs for the images/ subdirectory of your private file area. + *
+ *<paths xmlns:android="http://schemas.android.com/apk/res/android">
+ *    <files-path name="my_images" path="images/"/>
+ *    ...
+ *</paths>
+ *
+ *

+ * The <paths> element must contain one or more of the following child elements: + *

+ *
+ *
+ *
+ *<files-path name="name" path="path" />
+ *
+ *
+ *
+ * Represents files in the files/ subdirectory of your app's internal storage + * area. This subdirectory is the same as the value returned by {@link Context#getFilesDir() + * Context.getFilesDir()}. + *
+ *
+ *
+ *<cache-path name="name" path="path" />
+ *
+ *
+ *
+ * Represents files in the cache subdirectory of your app's internal storage area. The root path + * of this subdirectory is the same as the value returned by {@link Context#getCacheDir() + * getCacheDir()}. + *
+ *
+ *
+ *<external-path name="name" path="path" />
+ *
+ *
+ *
+ * Represents files in the root of the external storage area. The root path of this subdirectory + * is the same as the value returned by + * {@link Environment#getExternalStorageDirectory() Environment.getExternalStorageDirectory()}. + *
+ *
+ *
+ *<external-files-path name="name" path="path" />
+ *
+ *
+ *
+ * Represents files in the root of your app's external storage area. The root path of this + * subdirectory is the same as the value returned by + * {@code Context#getExternalFilesDir(String) Context.getExternalFilesDir(null)}. + *
+ *
+ *
+ *<external-cache-path name="name" path="path" />
+ *
+ *
+ *
+ * Represents files in the root of your app's external cache area. The root path of this + * subdirectory is the same as the value returned by + * {@link Context#getExternalCacheDir() Context.getExternalCacheDir()}. + *
+ *
+ *
+ *<external-media-path name="name" path="path" />
+ *
+ *
+ *
+ * Represents files in the root of your app's external media area. The root path of this + * subdirectory is the same as the value returned by the first result of + * {@link Context#getExternalMediaDirs() Context.getExternalMediaDirs()}. + *

Note: this directory is only available on API 21+ devices.

+ *
+ *
+ *

+ * These child elements all use the same attributes: + *

+ *
+ *
+ * name="name" + *
+ *
+ * A URI path segment. To enforce security, this value hides the name of the subdirectory + * you're sharing. The subdirectory name for this value is contained in the + * path attribute. + *
+ *
+ * path="path" + *
+ *
+ * The subdirectory you're sharing. While the name attribute is a URI path + * segment, the path value is an actual subdirectory name. Notice that the + * value refers to a subdirectory, not an individual file or files. You can't + * share a single file by its file name, nor can you specify a subset of files using + * wildcards. + *
+ *
+ *

+ * You must specify a child element of <paths> for each directory that contains + * files for which you want content URIs. For example, these XML elements specify two directories: + *

+ *<paths xmlns:android="http://schemas.android.com/apk/res/android">
+ *    <files-path name="my_images" path="images/"/>
+ *    <files-path name="my_docs" path="docs/"/>
+ *</paths>
+ *
+ *

+ * Put the <paths> element and its children in an XML file in your project. + * For example, you can add them to a new file called res/xml/file_paths.xml. + * To link this file to the FileProvider, add a + * <meta-data> element + * as a child of the <provider> element that defines the FileProvider. Set the + * <meta-data> element's "android:name" attribute to + * android.support.FILE_PROVIDER_PATHS. Set the element's "android:resource" attribute + * to @xml/file_paths (notice that you don't specify the .xml + * extension). For example: + *

+ *<provider
+ *    android:name="androidx.core.content.FileProvider"
+ *    android:authorities="com.mydomain.fileprovider"
+ *    android:exported="false"
+ *    android:grantUriPermissions="true">
+ *    <meta-data
+ *        android:name="android.support.FILE_PROVIDER_PATHS"
+ *        android:resource="@xml/file_paths" />
+ *</provider>
+ *
+ *

Generating the Content URI for a File

+ *

+ * To share a file with another app using a content URI, your app has to generate the content URI. + * To generate the content URI, create a new {@link File} for the file, then pass the {@link File} + * to {@link #getUriForFile(Context, String, File) getUriForFile()}. You can send the content URI + * returned by {@link #getUriForFile(Context, String, File) getUriForFile()} to another app in an + * {@link Intent}. The client app that receives the content URI can open the file + * and access its contents by calling + * {@link android.content.ContentResolver#openFileDescriptor(Uri, String) + * ContentResolver.openFileDescriptor} to get a {@link ParcelFileDescriptor}. + *

+ * For example, suppose your app is offering files to other apps with a FileProvider that has the + * authority com.mydomain.fileprovider. To get a content URI for the file + * default_image.jpg in the images/ subdirectory of your internal storage + * add the following code: + *

+ *File imagePath = new File(Context.getFilesDir(), "images");
+ *File newFile = new File(imagePath, "default_image.jpg");
+ *Uri contentUri = getUriForFile(getContext(), "com.mydomain.fileprovider", newFile);
+ *
+ * As a result of the previous snippet, + * {@link #getUriForFile(Context, String, File) getUriForFile()} returns the content URI + * content://com.mydomain.fileprovider/my_images/default_image.jpg. + *

Granting Temporary Permissions to a URI

+ * To grant an access permission to a content URI returned from + * {@link #getUriForFile(Context, String, File) getUriForFile()}, do one of the following: + *
    + *
  • + * Call the method + * {@link Context#grantUriPermission(String, Uri, int) + * Context.grantUriPermission(package, Uri, mode_flags)} for the content:// + * {@link Uri}, using the desired mode flags. This grants temporary access permission for the + * content URI to the specified package, according to the value of the + * the mode_flags parameter, which you can set to + * {@link Intent#FLAG_GRANT_READ_URI_PERMISSION}, {@link Intent#FLAG_GRANT_WRITE_URI_PERMISSION} + * or both. The permission remains in effect until you revoke it by calling + * {@link Context#revokeUriPermission(Uri, int) revokeUriPermission()} or until the device + * reboots. + *
  • + *
  • + * Put the content URI in an {@link Intent} by calling {@link Intent#setData(Uri) setData()}. + *
  • + *
  • + * Next, call the method {@link Intent#setFlags(int) Intent.setFlags()} with either + * {@link Intent#FLAG_GRANT_READ_URI_PERMISSION} or + * {@link Intent#FLAG_GRANT_WRITE_URI_PERMISSION} or both. + *
  • + *
  • + * Finally, send the {@link Intent} to + * another app. Most often, you do this by calling + * {@link android.app.Activity#setResult(int, Intent) setResult()}. + *

    + * Permissions granted in an {@link Intent} remain in effect while the stack of the receiving + * {@link android.app.Activity} is active. When the stack finishes, the permissions are + * automatically removed. Permissions granted to one {@link android.app.Activity} in a client + * app are automatically extended to other components of that app. + *

    + *
  • + *
+ *

Serving a Content URI to Another App

+ *

+ * There are a variety of ways to serve the content URI for a file to a client app. One common way + * is for the client app to start your app by calling + * {@link android.app.Activity#startActivityForResult(Intent, int, Bundle) startActivityResult()}, + * which sends an {@link Intent} to your app to start an {@link android.app.Activity} in your app. + * In response, your app can immediately return a content URI to the client app or present a user + * interface that allows the user to pick a file. In the latter case, once the user picks the file + * your app can return its content URI. In both cases, your app returns the content URI in an + * {@link Intent} sent via {@link android.app.Activity#setResult(int, Intent) setResult()}. + *

+ *

+ * You can also put the content URI in a {@link android.content.ClipData} object and then add the + * object to an {@link Intent} you send to a client app. To do this, call + * {@link Intent#setClipData(ClipData) Intent.setClipData()}. When you use this approach, you can + * add multiple {@link android.content.ClipData} objects to the {@link Intent}, each with its own + * content URI. When you call {@link Intent#setFlags(int) Intent.setFlags()} on the {@link Intent} + * to set temporary access permissions, the same permissions are applied to all of the content + * URIs. + *

+ *

+ * Note: The {@link Intent#setClipData(ClipData) Intent.setClipData()} method is + * only available in platform version 16 (Android 4.1) and later. If you want to maintain + * compatibility with previous versions, you should send one content URI at a time in the + * {@link Intent}. Set the action to {@link Intent#ACTION_SEND} and put the URI in data by calling + * {@link Intent#setData setData()}. + *

+ *

More Information

+ *

+ * To learn more about FileProvider, see the Android training class + * Sharing Files Securely with URIs. + *

+ */ +public class FileProvider extends ContentProvider { + private static final String[] COLUMNS = { + OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE }; + + private static final String + META_DATA_FILE_PROVIDER_PATHS = "android.support.FILE_PROVIDER_PATHS"; + + private static final String TAG_ROOT_PATH = "root-path"; + private static final String TAG_FILES_PATH = "files-path"; + private static final String TAG_CACHE_PATH = "cache-path"; + private static final String TAG_EXTERNAL = "external-path"; + private static final String TAG_EXTERNAL_FILES = "external-files-path"; + private static final String TAG_EXTERNAL_CACHE = "external-cache-path"; + private static final String TAG_EXTERNAL_MEDIA = "external-media-path"; + + private static final String ATTR_NAME = "name"; + private static final String ATTR_PATH = "path"; + + private static final File DEVICE_ROOT = new File("/"); + + @GuardedBy("sCache") + private static HashMap sCache = new HashMap(); + + private PathStrategy mStrategy; + + /** + * The default FileProvider implementation does not need to be initialized. If you want to + * override this method, you must provide your own subclass of FileProvider. + */ + @Override + public boolean onCreate() { + return true; + } + + /** + * After the FileProvider is instantiated, this method is called to provide the system with + * information about the provider. + * + * @param context A {@link Context} for the current component. + * @param info A {@link ProviderInfo} for the new provider. + */ + @Override + public void attachInfo(@NonNull Context context, @NonNull ProviderInfo info) { + super.attachInfo(context, info); + + // Sanity check our security + if (info.exported) { + throw new SecurityException("Provider must not be exported"); + } + if (!info.grantUriPermissions) { + throw new SecurityException("Provider must grant uri permissions"); + } + + mStrategy = getPathStrategy(context, info.authority); + } + + /** + * Return a content URI for a given {@link File}. Specific temporary + * permissions for the content URI can be set with + * {@link Context#grantUriPermission(String, Uri, int)}, or added + * to an {@link Intent} by calling {@link Intent#setData(Uri) setData()} and then + * {@link Intent#setFlags(int) setFlags()}; in both cases, the applicable flags are + * {@link Intent#FLAG_GRANT_READ_URI_PERMISSION} and + * {@link Intent#FLAG_GRANT_WRITE_URI_PERMISSION}. A FileProvider can only return a + * content {@link Uri} for file paths defined in their <paths> + * meta-data element. See the Class Overview for more information. + * + * @param context A {@link Context} for the current component. + * @param authority The authority of a {@link FileProvider} defined in a + * {@code } element in your app's manifest. + * @param file A {@link File} pointing to the filename for which you want a + * content {@link Uri}. + * @return A content URI for the file. + * @throws IllegalArgumentException When the given {@link File} is outside + * the paths supported by the provider. + */ + public static Uri getUriForFile(@NonNull Context context, @NonNull String authority, + @NonNull File file) { + final PathStrategy strategy = getPathStrategy(context, authority); + return strategy.getUriForFile(file); + } + + /** + * Use a content URI returned by + * {@link #getUriForFile(Context, String, File) getUriForFile()} to get information about a file + * managed by the FileProvider. + * FileProvider reports the column names defined in {@link OpenableColumns}: + *
    + *
  • {@link OpenableColumns#DISPLAY_NAME}
  • + *
  • {@link OpenableColumns#SIZE}
  • + *
+ * For more information, see + * {@link ContentProvider#query(Uri, String[], String, String[], String) + * ContentProvider.query()}. + * + * @param uri A content URI returned by {@link #getUriForFile}. + * @param projection The list of columns to put into the {@link Cursor}. If null all columns are + * included. + * @param selection Selection criteria to apply. If null then all data that matches the content + * URI is returned. + * @param selectionArgs An array of {@link String}, containing arguments to bind to + * the selection parameter. The query method scans selection from left to + * right and iterates through selectionArgs, replacing the current "?" character in + * selection with the value at the current position in selectionArgs. The + * values are bound to selection as {@link String} values. + * @param sortOrder A {@link String} containing the column name(s) on which to sort + * the resulting {@link Cursor}. + * @return A {@link Cursor} containing the results of the query. + * + */ + @Override + public Cursor query(@NonNull Uri uri, @Nullable String[] projection, @Nullable String selection, + @Nullable String[] selectionArgs, + @Nullable String sortOrder) { + // ContentProvider has already checked granted permissions + final File file = mStrategy.getFileForUri(uri); + + if (projection == null) { + projection = COLUMNS; + } + + String[] cols = new String[projection.length]; + Object[] values = new Object[projection.length]; + int i = 0; + for (String col : projection) { + if (OpenableColumns.DISPLAY_NAME.equals(col)) { + cols[i] = OpenableColumns.DISPLAY_NAME; + values[i++] = file.getName(); + } else if (OpenableColumns.SIZE.equals(col)) { + cols[i] = OpenableColumns.SIZE; + values[i++] = file.length(); + } + } + + cols = copyOf(cols, i); + values = copyOf(values, i); + + final MatrixCursor cursor = new MatrixCursor(cols, 1); + cursor.addRow(values); + return cursor; + } + + /** + * Returns the MIME type of a content URI returned by + * {@link #getUriForFile(Context, String, File) getUriForFile()}. + * + * @param uri A content URI returned by + * {@link #getUriForFile(Context, String, File) getUriForFile()}. + * @return If the associated file has an extension, the MIME type associated with that + * extension; otherwise application/octet-stream. + */ + @Override + public String getType(@NonNull Uri uri) { + // ContentProvider has already checked granted permissions + final File file = mStrategy.getFileForUri(uri); + + final int lastDot = file.getName().lastIndexOf('.'); + if (lastDot >= 0) { + final String extension = file.getName().substring(lastDot + 1); + final String mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension); + if (mime != null) { + return mime; + } + } + + return "application/octet-stream"; + } + + /** + * By default, this method throws an {@link UnsupportedOperationException}. You must + * subclass FileProvider if you want to provide different functionality. + */ + @Override + public Uri insert(@NonNull Uri uri, ContentValues values) { + throw new UnsupportedOperationException("No external inserts"); + } + + /** + * By default, this method throws an {@link UnsupportedOperationException}. You must + * subclass FileProvider if you want to provide different functionality. + */ + @Override + public int update(@NonNull Uri uri, ContentValues values, @Nullable String selection, + @Nullable String[] selectionArgs) { + throw new UnsupportedOperationException("No external updates"); + } + + /** + * Deletes the file associated with the specified content URI, as + * returned by {@link #getUriForFile(Context, String, File) getUriForFile()}. Notice that this + * method does not throw an {@link IOException}; you must check its return value. + * + * @param uri A content URI for a file, as returned by + * {@link #getUriForFile(Context, String, File) getUriForFile()}. + * @param selection Ignored. Set to {@code null}. + * @param selectionArgs Ignored. Set to {@code null}. + * @return 1 if the delete succeeds; otherwise, 0. + */ + @Override + public int delete(@NonNull Uri uri, @Nullable String selection, + @Nullable String[] selectionArgs) { + // ContentProvider has already checked granted permissions + final File file = mStrategy.getFileForUri(uri); + return file.delete() ? 1 : 0; + } + + /** + * By default, FileProvider automatically returns the + * {@link ParcelFileDescriptor} for a file associated with a content:// + * {@link Uri}. To get the {@link ParcelFileDescriptor}, call + * {@link android.content.ContentResolver#openFileDescriptor(Uri, String) + * ContentResolver.openFileDescriptor}. + * + * To override this method, you must provide your own subclass of FileProvider. + * + * @param uri A content URI associated with a file, as returned by + * {@link #getUriForFile(Context, String, File) getUriForFile()}. + * @param mode Access mode for the file. May be "r" for read-only access, "rw" for read and + * write access, or "rwt" for read and write access that truncates any existing file. + * @return A new {@link ParcelFileDescriptor} with which you can access the file. + */ + @Override + public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode) + throws FileNotFoundException { + // ContentProvider has already checked granted permissions + final File file = mStrategy.getFileForUri(uri); + final int fileMode = modeToMode(mode); + return ParcelFileDescriptor.open(file, fileMode); + } + + /** + * Return {@link PathStrategy} for given authority, either by parsing or + * returning from cache. + */ + private static PathStrategy getPathStrategy(Context context, String authority) { + PathStrategy strat; + synchronized (sCache) { + strat = sCache.get(authority); + if (strat == null) { + try { + strat = parsePathStrategy(context, authority); + } catch (IOException e) { + throw new IllegalArgumentException( + "Failed to parse " + META_DATA_FILE_PROVIDER_PATHS + " meta-data", e); + } catch (XmlPullParserException e) { + throw new IllegalArgumentException( + "Failed to parse " + META_DATA_FILE_PROVIDER_PATHS + " meta-data", e); + } + sCache.put(authority, strat); + } + } + return strat; + } + + /** + * Parse and return {@link PathStrategy} for given authority as defined in + * {@link #META_DATA_FILE_PROVIDER_PATHS} {@code }. + * + * @see #getPathStrategy(Context, String) + */ + private static PathStrategy parsePathStrategy(Context context, String authority) + throws IOException, XmlPullParserException { + final SimplePathStrategy strat = new SimplePathStrategy(authority); + + final ProviderInfo info = context.getPackageManager() + .resolveContentProvider(authority, PackageManager.GET_META_DATA); + if (info == null) { + throw new IllegalArgumentException( + "Couldn't find meta-data for provider with authority " + authority); + } + + final XmlResourceParser in = info.loadXmlMetaData( + context.getPackageManager(), META_DATA_FILE_PROVIDER_PATHS); + if (in == null) { + throw new IllegalArgumentException( + "Missing " + META_DATA_FILE_PROVIDER_PATHS + " meta-data"); + } + + int type; + while ((type = in.next()) != END_DOCUMENT) { + if (type == START_TAG) { + final String tag = in.getName(); + + final String name = in.getAttributeValue(null, ATTR_NAME); + String path = in.getAttributeValue(null, ATTR_PATH); + + File target = null; + if (TAG_ROOT_PATH.equals(tag)) { + target = DEVICE_ROOT; + } else if (TAG_FILES_PATH.equals(tag)) { + target = context.getFilesDir(); + } else if (TAG_CACHE_PATH.equals(tag)) { + target = context.getCacheDir(); + } else if (TAG_EXTERNAL.equals(tag)) { + target = Environment.getExternalStorageDirectory(); + } else if (TAG_EXTERNAL_FILES.equals(tag)) { + File[] externalFilesDirs = context.getExternalFilesDirs(null); + if (externalFilesDirs.length > 0) { + target = externalFilesDirs[0]; + } + } else if (TAG_EXTERNAL_CACHE.equals(tag)) { + File[] externalCacheDirs = context.getExternalCacheDirs(); + if (externalCacheDirs.length > 0) { + target = externalCacheDirs[0]; + } + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP + && TAG_EXTERNAL_MEDIA.equals(tag)) { + File[] externalMediaDirs = context.getExternalMediaDirs(); + if (externalMediaDirs.length > 0) { + target = externalMediaDirs[0]; + } + } + + if (target != null) { + strat.addRoot(name, buildPath(target, path)); + } + } + } + + return strat; + } + + /** + * Strategy for mapping between {@link File} and {@link Uri}. + *

+ * Strategies must be symmetric so that mapping a {@link File} to a + * {@link Uri} and then back to a {@link File} points at the original + * target. + *

+ * Strategies must remain consistent across app launches, and not rely on + * dynamic state. This ensures that any generated {@link Uri} can still be + * resolved if your process is killed and later restarted. + * + * @see SimplePathStrategy + */ + interface PathStrategy { + /** + * Return a {@link Uri} that represents the given {@link File}. + */ + Uri getUriForFile(File file); + + /** + * Return a {@link File} that represents the given {@link Uri}. + */ + File getFileForUri(Uri uri); + } + + /** + * Strategy that provides access to files living under a narrow whitelist of + * filesystem roots. It will throw {@link SecurityException} if callers try + * accessing files outside the configured roots. + *

+ * For example, if configured with + * {@code addRoot("myfiles", context.getFilesDir())}, then + * {@code context.getFileStreamPath("foo.txt")} would map to + * {@code content://myauthority/myfiles/foo.txt}. + */ + static class SimplePathStrategy implements PathStrategy { + private final String mAuthority; + private final HashMap mRoots = new HashMap(); + + SimplePathStrategy(String authority) { + mAuthority = authority; + } + + /** + * Add a mapping from a name to a filesystem root. The provider only offers + * access to files that live under configured roots. + */ + void addRoot(String name, File root) { + if (TextUtils.isEmpty(name)) { + throw new IllegalArgumentException("Name must not be empty"); + } + + try { + // Resolve to canonical path to keep path checking fast + root = root.getCanonicalFile(); + } catch (IOException e) { + throw new IllegalArgumentException( + "Failed to resolve canonical path for " + root, e); + } + + mRoots.put(name, root); + } + + @Override + public Uri getUriForFile(File file) { + String path; + try { + path = file.getCanonicalPath(); + } catch (IOException e) { + throw new IllegalArgumentException("Failed to resolve canonical path for " + file); + } + + // Find the most-specific root path + Map.Entry mostSpecific = null; + for (Map.Entry root : mRoots.entrySet()) { + final String rootPath = root.getValue().getPath(); + if (path.startsWith(rootPath) && (mostSpecific == null + || rootPath.length() > mostSpecific.getValue().getPath().length())) { + mostSpecific = root; + } + } + + if (mostSpecific == null) { + throw new IllegalArgumentException( + "Failed to find configured root that contains " + path); + } + + // Start at first char of path under root + final String rootPath = mostSpecific.getValue().getPath(); + if (rootPath.endsWith("/")) { + path = path.substring(rootPath.length()); + } else { + path = path.substring(rootPath.length() + 1); + } + + // Encode the tag and path separately + path = Uri.encode(mostSpecific.getKey()) + '/' + Uri.encode(path, "/"); + return new Uri.Builder().scheme("content") + .authority(mAuthority).encodedPath(path).build(); + } + + @Override + public File getFileForUri(Uri uri) { + String path = uri.getEncodedPath(); + + final int splitIndex = path.indexOf('/', 1); + final String tag = Uri.decode(path.substring(1, splitIndex)); + path = Uri.decode(path.substring(splitIndex + 1)); + + final File root = mRoots.get(tag); + if (root == null) { + throw new IllegalArgumentException("Unable to find configured root for " + uri); + } + + File file = new File(root, path); + try { + file = file.getCanonicalFile(); + } catch (IOException e) { + throw new IllegalArgumentException("Failed to resolve canonical path for " + file); + } + + if (!file.getPath().startsWith(root.getPath())) { + throw new SecurityException("Resolved path jumped beyond configured root"); + } + + return file; + } + } + + /** + * Copied from ContentResolver.java + */ + private static int modeToMode(String mode) { + int modeBits; + if ("r".equals(mode)) { + modeBits = ParcelFileDescriptor.MODE_READ_ONLY; + } else if ("w".equals(mode) || "wt".equals(mode)) { + modeBits = ParcelFileDescriptor.MODE_WRITE_ONLY + | ParcelFileDescriptor.MODE_CREATE + | ParcelFileDescriptor.MODE_TRUNCATE; + } else if ("wa".equals(mode)) { + modeBits = ParcelFileDescriptor.MODE_WRITE_ONLY + | ParcelFileDescriptor.MODE_CREATE + | ParcelFileDescriptor.MODE_APPEND; + } else if ("rw".equals(mode)) { + modeBits = ParcelFileDescriptor.MODE_READ_WRITE + | ParcelFileDescriptor.MODE_CREATE; + } else if ("rwt".equals(mode)) { + modeBits = ParcelFileDescriptor.MODE_READ_WRITE + | ParcelFileDescriptor.MODE_CREATE + | ParcelFileDescriptor.MODE_TRUNCATE; + } else { + throw new IllegalArgumentException("Invalid mode: " + mode); + } + return modeBits; + } + + private static File buildPath(File base, String... segments) { + File cur = base; + for (String segment : segments) { + if (segment != null) { + cur = new File(cur, segment); + } + } + return cur; + } + + private static String[] copyOf(String[] original, int newLength) { + final String[] result = new String[newLength]; + System.arraycopy(original, 0, result, 0, newLength); + return result; + } + + private static Object[] copyOf(Object[] original, int newLength) { + final Object[] result = new Object[newLength]; + System.arraycopy(original, 0, result, 0, newLength); + return result; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/GlobalUserPreferences.java b/mastodon/src/main/java/org/joinmastodon/android/GlobalUserPreferences.java index 9a0f93b52..228b02286 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/GlobalUserPreferences.java +++ b/mastodon/src/main/java/org/joinmastodon/android/GlobalUserPreferences.java @@ -7,6 +7,9 @@ import android.content.Context; import android.content.SharedPreferences; import android.util.Log; +import androidx.annotation.StringRes; +import android.os.Build; + import com.google.gson.JsonSyntaxException; import com.google.gson.reflect.TypeToken; @@ -25,6 +28,9 @@ import java.util.HashSet; import java.util.Map; import java.util.Set; +import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.model.Account; + public class GlobalUserPreferences{ private static final String TAG="GlobalUserPreferences"; @@ -51,7 +57,6 @@ public class GlobalUserPreferences{ public static boolean spectatorMode; public static boolean autoHideFab; public static boolean allowRemoteLoading; - public static boolean forwardReportDefault; public static AutoRevealMode autoRevealEqualSpoilers; public static boolean disableM3PillActiveIndicator; public static boolean showNavigationLabels; @@ -62,10 +67,30 @@ public class GlobalUserPreferences{ public static ColorPreference color; public static boolean likeIcon; - private static SharedPreferences getPrefs(){ + // MOSHIDON + public static boolean showDividers; + public static boolean relocatePublishButton; + public static boolean defaultToUnlistedReplies; + public static boolean doubleTapToSearch; + public static boolean doubleTapToSwipe; + public static boolean confirmBeforeReblog; + public static boolean hapticFeedback; + public static boolean replyLineAboveHeader; + public static boolean swapBookmarkWithBoostAction; + public static boolean mentionRebloggerAutomatically; + public static boolean showPostsWithoutAlt; + public static boolean showMediaPreview; + public static boolean removeTrackingParams; + + public static SharedPreferences getPrefs(){ return MastodonApp.context.getSharedPreferences("global", Context.MODE_PRIVATE); } + private static SharedPreferences getPreReplyPrefs(){ + return MastodonApp.context.getSharedPreferences("pre_reply_sheets", Context.MODE_PRIVATE); + } + + public static T fromJson(String json, Type type, T orElse){ if(json==null) return orElse; try{ @@ -111,7 +136,6 @@ public class GlobalUserPreferences{ autoHideFab=prefs.getBoolean("autoHideFab", true); allowRemoteLoading=prefs.getBoolean("allowRemoteLoading", true); autoRevealEqualSpoilers=AutoRevealMode.valueOf(prefs.getString("autoRevealEqualSpoilers", AutoRevealMode.THREADS.name())); - forwardReportDefault=prefs.getBoolean("forwardReportDefault", true); disableM3PillActiveIndicator=prefs.getBoolean("disableM3PillActiveIndicator", false); showNavigationLabels=prefs.getBoolean("showNavigationLabels", true); displayPronounsInTimelines=prefs.getBoolean("displayPronounsInTimelines", true); @@ -123,6 +147,25 @@ public class GlobalUserPreferences{ color=ColorPreference.valueOf(prefs.getString("color", MATERIAL3.name())); likeIcon=prefs.getBoolean("likeIcon", false); + // MOSHIDON + uniformNotificationIcon=prefs.getBoolean("uniformNotificationIcon", false); + showDividers =prefs.getBoolean("showDividers", false); + relocatePublishButton=prefs.getBoolean("relocatePublishButton", true); + defaultToUnlistedReplies=prefs.getBoolean("defaultToUnlistedReplies", false); + doubleTapToSearch =prefs.getBoolean("doubleTapToSearch", true); + doubleTapToSwipe =prefs.getBoolean("doubleTapToSwipe", true); + replyLineAboveHeader=prefs.getBoolean("replyLineAboveHeader", true); + confirmBeforeReblog=prefs.getBoolean("confirmBeforeReblog", false); + hapticFeedback=prefs.getBoolean("hapticFeedback", true); + swapBookmarkWithBoostAction=prefs.getBoolean("swapBookmarkWithBoostAction", false); + mentionRebloggerAutomatically=prefs.getBoolean("mentionRebloggerAutomatically", false); + showPostsWithoutAlt=prefs.getBoolean("showPostsWithoutAlt", true); + showMediaPreview=prefs.getBoolean("showMediaPreview", true); + removeTrackingParams=prefs.getBoolean("removeTrackingParams", true); + + theme=ThemePreference.values()[prefs.getInt("theme", 0)]; + + if (prefs.contains("prefixRepliesWithRe")) { prefixReplies = prefs.getBoolean("prefixRepliesWithRe", false) ? PrefixRepliesMode.TO_OTHERS : PrefixRepliesMode.NEVER; @@ -168,7 +211,6 @@ public class GlobalUserPreferences{ .putBoolean("autoHideFab", autoHideFab) .putBoolean("allowRemoteLoading", allowRemoteLoading) .putString("autoRevealEqualSpoilers", autoRevealEqualSpoilers.name()) - .putBoolean("forwardReportDefault", forwardReportDefault) .putBoolean("disableM3PillActiveIndicator", disableM3PillActiveIndicator) .putBoolean("showNavigationLabels", showNavigationLabels) .putBoolean("displayPronounsInTimelines", displayPronounsInTimelines) @@ -179,15 +221,61 @@ public class GlobalUserPreferences{ .putBoolean("underlinedLinks", underlinedLinks) .putString("color", color.name()) .putBoolean("likeIcon", likeIcon) + + // MOSHIDON + .putBoolean("defaultToUnlistedReplies", defaultToUnlistedReplies) + .putBoolean("doubleTapToSearch", doubleTapToSearch) + .putBoolean("doubleTapToSwipe", doubleTapToSwipe) + .putBoolean("replyLineAboveHeader", replyLineAboveHeader) + .putBoolean("confirmBeforeReblog", confirmBeforeReblog) + .putBoolean("swapBookmarkWithBoostAction", swapBookmarkWithBoostAction) + .putBoolean("hapticFeedback", hapticFeedback) + .putBoolean("mentionRebloggerAutomatically", mentionRebloggerAutomatically) + .putBoolean("showDividers", showDividers) + .putBoolean("relocatePublishButton", relocatePublishButton) + .putBoolean("enableDeleteNotifications", enableDeleteNotifications) + .putBoolean("showPostsWithoutAlt", showPostsWithoutAlt) + .putBoolean("showMediaPreview", showMediaPreview) + .putBoolean("removeTrackingParams", removeTrackingParams) + .apply(); } + public static boolean isOptedOutOfPreReplySheet(PreReplySheetType type, Account account, String accountID){ + if(getPreReplyPrefs().getBoolean("opt_out_"+type, false)) + return true; + if(account==null) + return false; + String accountKey=account.acct; + if(!accountKey.contains("@")) + accountKey+="@"+AccountSessionManager.get(accountID).domain; + return getPreReplyPrefs().getBoolean("opt_out_"+type+"_"+accountKey.toLowerCase(), false); + } + + public static void optOutOfPreReplySheet(PreReplySheetType type, Account account, String accountID){ + String key; + if(account==null){ + key="opt_out_"+type; + }else{ + String accountKey=account.acct; + if(!accountKey.contains("@")) + accountKey+="@"+AccountSessionManager.get(accountID).domain; + key="opt_out_"+type+"_"+accountKey.toLowerCase(); + } + getPreReplyPrefs().edit().putBoolean(key, true).apply(); + } + public enum ThemePreference{ AUTO, LIGHT, DARK } + public enum PreReplySheetType{ + OLD_POST, + NON_MUTUAL + } + public enum AutoRevealMode { NEVER, THREADS, @@ -252,5 +340,4 @@ public class GlobalUserPreferences{ } //endregion - } diff --git a/mastodon/src/main/java/org/joinmastodon/android/MainActivity.java b/mastodon/src/main/java/org/joinmastodon/android/MainActivity.java index 5050dcee1..977c8408b 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/MainActivity.java +++ b/mastodon/src/main/java/org/joinmastodon/android/MainActivity.java @@ -1,13 +1,21 @@ package org.joinmastodon.android; +import static org.joinmastodon.android.fragments.ComposeFragment.CAMERA_PERMISSION_CODE; +import static org.joinmastodon.android.fragments.ComposeFragment.CAMERA_PIC_REQUEST_CODE; + import android.Manifest; +import android.app.Activity; import android.app.Fragment; import android.app.assist.AssistContent; import android.content.Intent; import android.content.pm.PackageManager; +import android.graphics.Bitmap; import android.net.Uri; +import android.net.Uri; +import android.os.BadParcelableException; import android.os.Build; import android.os.Bundle; +import android.provider.MediaStore; import android.util.Log; import android.view.View; import android.widget.FrameLayout; @@ -17,6 +25,7 @@ import org.joinmastodon.android.api.ObjectValidationException; import org.joinmastodon.android.api.requests.search.GetSearchResults; import org.joinmastodon.android.api.session.AccountSession; import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.events.TakePictureRequestEvent; import org.joinmastodon.android.fragments.ComposeFragment; import org.joinmastodon.android.fragments.HomeFragment; import org.joinmastodon.android.fragments.ProfileFragment; @@ -102,8 +111,6 @@ public class MainActivity extends FragmentStackActivity implements ProvidesAssis fragment.setArguments(args); showFragmentClearingBackStack(fragment); } - }else if(intent.getBooleanExtra("compose", false)){ - showCompose(); }else if(Intent.ACTION_VIEW.equals(intent.getAction())){ handleURL(intent.getData(), null); }/*else if(intent.hasExtra(PackageInstaller.EXTRA_STATUS) && GithubSelfUpdater.needSelfUpdating()){ @@ -123,11 +130,11 @@ public class MainActivity extends FragmentStackActivity implements ProvidesAssis session=AccountSessionManager.get(accountID); if(session==null || !session.activated) return; - openSearchQuery(uri.toString(), session.getID(), R.string.opening_link, false); + openSearchQuery(uri.toString(), session.getID(), R.string.opening_link, false, null); } - public void openSearchQuery(String q, String accountID, int progressText, boolean fromSearch){ - new GetSearchResults(q, null, true, null, 0, 0) + public void openSearchQuery(String q, String accountID, int progressText, boolean fromSearch, GetSearchResults.Type type){ + new GetSearchResults(q, type, true, null, 0, 0) .setCallback(new Callback<>(){ @Override public void onSuccess(SearchResults result){ @@ -178,17 +185,6 @@ public class MainActivity extends FragmentStackActivity implements ProvidesAssis showFragment(fragment); } - private void showCompose(){ - AccountSession session=AccountSessionManager.getInstance().getLastActiveAccount(); - if(session==null || !session.activated) - return; - ComposeFragment compose=new ComposeFragment(); - Bundle composeArgs=new Bundle(); - composeArgs.putString("account", session.getID()); - compose.setArguments(composeArgs); - showFragment(compose); - } - private void maybeRequestNotificationsPermission(){ if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.TIRAMISU && checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)!=PackageManager.PERMISSION_GRANTED){ requestPermissions(new String[]{Manifest.permission.POST_NOTIFICATIONS}, 100); @@ -227,6 +223,24 @@ public class MainActivity extends FragmentStackActivity implements ProvidesAssis } } +// @Override +// public void onActivityResult(int requestCode, int resultCode, Intent data){ +// if(requestCode==CAMERA_PIC_REQUEST_CODE && resultCode== Activity.RESULT_OK){ +// E.post(new TakePictureRequestEvent()); +// } +// } + + @Override + public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + + if (requestCode == CAMERA_PERMISSION_CODE && (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED)) { + E.post(new TakePictureRequestEvent()); + } else { + Toast.makeText(this, R.string.permission_required, Toast.LENGTH_SHORT); + } + } + public Fragment getCurrentFragment() { for (int i = fragmentContainers.size() - 1; i >= 0; i--) { FrameLayout fl = fragmentContainers.get(i); @@ -308,10 +322,14 @@ public class MainActivity extends FragmentStackActivity implements ProvidesAssis Fragment fragment=session.activated ? new HomeFragment() : new AccountActivationFragment(); fragment.setArguments(args); if(fromNotification && hasNotification){ - Notification notification=Parcels.unwrap(intent.getParcelableExtra("notification")); - showFragmentForNotification(notification, session.getID()); - } else if (intent.getBooleanExtra("compose", false)){ - showCompose(); + // Parcelables might not be compatible across app versions so this protects against possible crashes + // when a notification was received, then the app was updated, and then the user opened the notification + try{ + Notification notification=Parcels.unwrap(intent.getParcelableExtra("notification")); + showFragmentForNotification(notification, session.getID()); + }catch(BadParcelableException x){ + Log.w(TAG, x); + } } else if (Intent.ACTION_VIEW.equals(intent.getAction())){ handleURL(intent.getData(), null); } else { diff --git a/mastodon/src/main/java/org/joinmastodon/android/MastodonApp.java b/mastodon/src/main/java/org/joinmastodon/android/MastodonApp.java index 61cd8deed..48cc95cb3 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/MastodonApp.java +++ b/mastodon/src/main/java/org/joinmastodon/android/MastodonApp.java @@ -25,7 +25,7 @@ public class MastodonApp extends Application{ params.diskCacheSize=100*1024*1024; params.maxMemoryCacheSize=Integer.MAX_VALUE; ImageCache.setParams(params); - NetworkUtils.setUserAgent("MastodonAndroid/"+BuildConfig.VERSION_NAME); + NetworkUtils.setUserAgent("MoshidonAndroid/"+BuildConfig.VERSION_NAME); PushSubscriptionManager.tryRegisterFCM(); GlobalUserPreferences.load(); diff --git a/mastodon/src/main/java/org/joinmastodon/android/PushNotificationReceiver.java b/mastodon/src/main/java/org/joinmastodon/android/PushNotificationReceiver.java index c6c549020..b26ba5fa7 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/PushNotificationReceiver.java +++ b/mastodon/src/main/java/org/joinmastodon/android/PushNotificationReceiver.java @@ -1,6 +1,8 @@ package org.joinmastodon.android; -import static org.joinmastodon.android.GlobalUserPreferences.PrefixRepliesMode.*; +import static org.joinmastodon.android.GlobalUserPreferences.PrefixRepliesMode.ALWAYS; +import static org.joinmastodon.android.GlobalUserPreferences.PrefixRepliesMode.TO_OTHERS; +import static org.joinmastodon.android.GlobalUserPreferences.getPrefs; import android.app.Notification; import android.app.NotificationChannel; @@ -12,12 +14,14 @@ import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.graphics.drawable.Drawable; +import android.opengl.Visibility; import android.os.Build; import android.os.Bundle; import android.text.TextUtils; import android.util.Log; import org.joinmastodon.android.api.MastodonAPIController; +import org.joinmastodon.android.api.requests.accounts.SetAccountFollowed; import org.joinmastodon.android.api.requests.notifications.GetNotificationByID; import org.joinmastodon.android.api.requests.statuses.CreateStatus; import org.joinmastodon.android.api.requests.statuses.SetStatusBookmarked; @@ -32,6 +36,7 @@ import org.joinmastodon.android.model.Preferences; import org.joinmastodon.android.model.PushNotification; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.model.StatusPrivacy; +import org.joinmastodon.android.model.StatusPrivacy; import org.joinmastodon.android.ui.utils.UiUtils; import org.parceler.Parcels; @@ -96,7 +101,7 @@ public class PushNotificationReceiver extends BroadcastReceiver{ } String accountID=account.getID(); PushNotification pn=AccountSessionManager.getInstance().getAccount(accountID).getPushSubscriptionManager().decryptNotification(k, p, s); - new GetNotificationByID(pn.notificationId+"") + new GetNotificationByID(pn.notificationId) .setCallback(new Callback<>(){ @Override public void onSuccess(org.joinmastodon.android.model.Notification result){ @@ -128,7 +133,11 @@ public class PushNotificationReceiver extends BroadcastReceiver{ if(intent.hasExtra("notification")){ org.joinmastodon.android.model.Notification notification=Parcels.unwrap(intent.getParcelableExtra("notification")); - String statusID=notification.status.id; + + String statusID = null; + if(notification != null && notification.status != null) + statusID=notification.status.id; + if (statusID != null) { AccountSessionManager accountSessionManager = AccountSessionManager.getInstance(); Preferences preferences = accountSessionManager.getAccount(accountID).preferences; @@ -136,9 +145,10 @@ public class PushNotificationReceiver extends BroadcastReceiver{ switch (NotificationAction.values()[intent.getIntExtra("notificationAction", 0)]) { case FAVORITE -> new SetStatusFavorited(statusID, true).exec(accountID); case BOOKMARK -> new SetStatusBookmarked(statusID, true).exec(accountID); - case REBLOG -> new SetStatusReblogged(notification.status.id, true, preferences.postingDefaultVisibility).exec(accountID); - case UNDO_REBLOG -> new SetStatusReblogged(notification.status.id, false, preferences.postingDefaultVisibility).exec(accountID); + case BOOST -> new SetStatusReblogged(notification.status.id, true, preferences.postingDefaultVisibility).exec(accountID); + case UNBOOST -> new SetStatusReblogged(notification.status.id, false, preferences.postingDefaultVisibility).exec(accountID); case REPLY -> handleReplyAction(context, accountID, intent, notification, notificationId, preferences); + case FOLLOW_BACK -> new SetAccountFollowed(notification.account.id, true, true, false).exec(accountID); default -> Log.w(TAG, "onReceive: Failed to get NotificationAction"); } } @@ -148,9 +158,9 @@ public class PushNotificationReceiver extends BroadcastReceiver{ } } - public void notifyUnifiedPush(Context context, String accountID, org.joinmastodon.android.model.Notification notification) { + public void notifyUnifiedPush(Context context, AccountSession account, org.joinmastodon.android.model.Notification notification) { // push notifications are only created from the official push notification, so we create a fake from by transforming the notification - PushNotificationReceiver.this.notify(context, PushNotification.fromNotification(context, notification), accountID, notification); + PushNotificationReceiver.this.notify(context, PushNotification.fromNotification(context, account, notification), account.getID(), notification); } private void notify(Context context, PushNotification pn, String accountID, org.joinmastodon.android.model.Notification notification){ @@ -205,8 +215,8 @@ public class PushNotificationReceiver extends BroadcastReceiver{ .setShowWhen(true) .setCategory(Notification.CATEGORY_SOCIAL) .setAutoCancel(true) - .setLights(UiUtils.getThemeColor(context, android.R.attr.colorAccent), 500, 1000) - .setColor(UiUtils.getThemeColor(context, android.R.attr.colorAccent)); + .setLights(context.getColor(R.color.primary_700), 500, 1000) + .setColor(context.getColor(R.color.shortcut_icon_background)); if (!GlobalUserPreferences.uniformNotificationIcon) { builder.setSmallIcon(switch (pn.notificationType) { @@ -252,14 +262,23 @@ public class PushNotificationReceiver extends BroadcastReceiver{ builder.addAction(buildReplyAction(context, id, accountID, notification)); } builder.addAction(buildNotificationAction(context, id, accountID, notification, context.getString(R.string.button_favorite), NotificationAction.FAVORITE)); - builder.addAction(buildNotificationAction(context, id, accountID, notification, context.getString(R.string.add_bookmark), NotificationAction.BOOKMARK)); - if(notification.status.visibility != StatusPrivacy.DIRECT) { - builder.addAction(buildNotificationAction(context, id, accountID, notification, context.getString(R.string.button_reblog), NotificationAction.REBLOG)); + if(GlobalUserPreferences.swapBookmarkWithBoostAction){ + if(notification.status.visibility != StatusPrivacy.DIRECT) { + builder.addAction(buildNotificationAction(context, id, accountID, notification, context.getString(R.string.button_reblog), NotificationAction.BOOST)); + }else{ + // This is just so there is a bookmark action if you cannot reblog the toot + builder.addAction(buildNotificationAction(context, id, accountID, notification, context.getString(R.string.add_bookmark), NotificationAction.BOOKMARK)); + } + } else { + builder.addAction(buildNotificationAction(context, id, accountID, notification, context.getString(R.string.add_bookmark), NotificationAction.BOOKMARK)); } } case UPDATE -> { if(notification.status.reblogged) - builder.addAction(buildNotificationAction(context, id, accountID, notification, context.getString(R.string.sk_undo_reblog), NotificationAction.UNDO_REBLOG)); + builder.addAction(buildNotificationAction(context, id, accountID, notification, context.getString(R.string.sk_undo_reblog), NotificationAction.UNBOOST)); + } + case FOLLOW -> { + builder.addAction(buildNotificationAction(context, id, accountID, notification, context.getString(R.string.follow_back), NotificationAction.FOLLOW_BACK)); } } } @@ -323,7 +342,7 @@ public class PushNotificationReceiver extends BroadcastReceiver{ CreateStatus.Request req=new CreateStatus.Request(); req.status = initialText + input.toString(); req.language = notification.status.language; - req.visibility = notification.status.visibility; + req.visibility = (notification.status.visibility == StatusPrivacy.PUBLIC && GlobalUserPreferences.defaultToUnlistedReplies ? StatusPrivacy.UNLISTED : notification.status.visibility); req.inReplyToId = notification.status.id; if (notification.status.hasSpoiler() && diff --git a/mastodon/src/main/java/org/joinmastodon/android/TweakedFileProvider.java b/mastodon/src/main/java/org/joinmastodon/android/TweakedFileProvider.java new file mode 100644 index 000000000..566db69b4 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/TweakedFileProvider.java @@ -0,0 +1,38 @@ +package org.joinmastodon.android; + +import android.database.Cursor; +import android.net.Uri; +import android.os.ParcelFileDescriptor; +import android.util.Log; + +import java.io.FileNotFoundException; +import java.util.Arrays; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +public class TweakedFileProvider extends FileProvider{ + private static final String TAG="TweakedFileProvider"; + + @Override + public String getType(@NonNull Uri uri){ + Log.d(TAG, "getType() called with: uri = ["+uri+"]"); + if(uri.getPathSegments().get(0).equals("image_cache")){ + Log.i(TAG, "getType: HERE!"); + return "image/jpeg"; // might as well be a png but image decoding APIs don't care, needs to be image/* though + } + return super.getType(uri); + } + + @Override + public Cursor query(@NonNull Uri uri, @Nullable String[] projection, @Nullable String selection, @Nullable String[] selectionArgs, @Nullable String sortOrder){ + Log.d(TAG, "query() called with: uri = ["+uri+"], projection = ["+Arrays.toString(projection)+"], selection = ["+selection+"], selectionArgs = ["+Arrays.toString(selectionArgs)+"], sortOrder = ["+sortOrder+"]"); + return super.query(uri, projection, selection, selectionArgs, sortOrder); + } + + @Override + public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode) throws FileNotFoundException{ + Log.d(TAG, "openFile() called with: uri = ["+uri+"], mode = ["+mode+"]"); + return super.openFile(uri, mode); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/UnifiedPushNotificationReceiver.java b/mastodon/src/main/java/org/joinmastodon/android/UnifiedPushNotificationReceiver.java index 2fc060c0f..4760fd111 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/UnifiedPushNotificationReceiver.java +++ b/mastodon/src/main/java/org/joinmastodon/android/UnifiedPushNotificationReceiver.java @@ -72,7 +72,7 @@ public class UnifiedPushNotificationReceiver extends MessagingReceiver{ result.items .stream() .findFirst() - .ifPresent(value->MastodonAPIController.runInBackground(()->new PushNotificationReceiver().notifyUnifiedPush(context, instance, value))); + .ifPresent(value->MastodonAPIController.runInBackground(()->new PushNotificationReceiver().notifyUnifiedPush(context, account, value))); } @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java b/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java index 47b55c4c1..a904ac0a0 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java @@ -9,21 +9,32 @@ import android.os.Handler; import android.os.Looper; import android.util.Log; +import com.google.gson.reflect.TypeToken; + import org.joinmastodon.android.BuildConfig; import org.joinmastodon.android.MastodonApp; +import org.joinmastodon.android.api.requests.lists.GetLists; import org.joinmastodon.android.api.requests.notifications.GetNotifications; import org.joinmastodon.android.api.requests.timelines.GetHomeTimeline; import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.model.CacheablePaginatedResponse; import org.joinmastodon.android.model.FilterContext; +import org.joinmastodon.android.model.FollowList; import org.joinmastodon.android.model.Instance; import org.joinmastodon.android.model.Notification; import org.joinmastodon.android.model.PaginatedResponse; import org.joinmastodon.android.model.SearchResult; import org.joinmastodon.android.model.Status; +import org.joinmastodon.android.ui.utils.UiUtils; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; import java.util.ArrayList; +import java.util.Comparator; import java.util.EnumSet; import java.util.List; import java.util.Objects; @@ -44,6 +55,7 @@ public class CacheController{ private final Runnable databaseCloseRunnable=this::closeDatabase; private boolean loadingNotifications; private final ArrayList>>> pendingNotificationsCallbacks=new ArrayList<>(); + private List lists; private static final int POST_FLAG_GAP_AFTER=1; @@ -348,6 +360,99 @@ public class CacheController{ }, 0); } + public void reloadLists(Callback> callback){ + new GetLists() + .setCallback(new Callback<>(){ + @Override + public void onSuccess(List result){ + result.sort(Comparator.comparing(l->l.title)); + lists=result; + if(callback!=null) + callback.onSuccess(result); + writeListsToFile(); + } + + @Override + public void onError(ErrorResponse error){ + if(callback!=null) + callback.onError(error); + } + }) + .exec(accountID); + } + + private List loadListsFromFile(){ + File file=getListsFile(); + if(!file.exists()) + return null; + try(InputStreamReader in=new InputStreamReader(new FileInputStream(file))){ + return MastodonAPIController.gson.fromJson(in, new TypeToken>(){}.getType()); + }catch(Exception x){ + Log.w(TAG, "failed to read lists from cache file", x); + return null; + } + } + + private void writeListsToFile(){ + databaseThread.postRunnable(()->{ + try(OutputStreamWriter out=new OutputStreamWriter(new FileOutputStream(getListsFile()))){ + MastodonAPIController.gson.toJson(lists, out); + }catch(IOException x){ + Log.w(TAG, "failed to write lists to cache file", x); + } + }, 0); + } + + public void getLists(Callback> callback){ + if(lists!=null){ + if(callback!=null) + callback.onSuccess(lists); + return; + } + databaseThread.postRunnable(()->{ + List lists=loadListsFromFile(); + if(lists!=null){ + this.lists=lists; + if(callback!=null) + uiHandler.post(()->callback.onSuccess(lists)); + return; + } + reloadLists(callback); + }, 0); + } + + public File getListsFile(){ + return new File(MastodonApp.context.getFilesDir(), "lists_"+accountID+".json"); + } + + public void addList(FollowList list){ + if(lists==null) + return; + lists.add(list); + lists.sort(Comparator.comparing(l->l.title)); + writeListsToFile(); + } + + public void deleteList(String id){ + if(lists==null) + return; + lists.removeIf(l->l.id.equals(id)); + writeListsToFile(); + } + + public void updateList(FollowList list){ + if(lists==null) + return; + for(int i=0;il.title)); + writeListsToFile(); + break; + } + } + } + private class DatabaseHelper extends SQLiteOpenHelper{ public DatabaseHelper(){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java index 297f8a949..9045842ac 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java @@ -54,7 +54,9 @@ public class MastodonAPIController{ .create(); private static WorkerThread thread=new WorkerThread("MastodonAPIController"); private static OkHttpClient httpClient=new OkHttpClient.Builder() - .readTimeout(30, TimeUnit.SECONDS) + .connectTimeout(60, TimeUnit.SECONDS) + .writeTimeout(60, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) .build(); private AccountSession session; @@ -89,13 +91,17 @@ public class MastodonAPIController{ final boolean isBad = host == null || badDomains.stream().anyMatch(h -> h.equalsIgnoreCase(host) || host.toLowerCase().endsWith("." + h)); thread.postRunnable(()->{ try{ - if (isBad) throw new IllegalArgumentException(); + if(isBad){ + Log.i(TAG, "submitRequest: refusing to connect to bad domain: " + host); + throw new IllegalArgumentException("Failed to connect to domain"); + } + if(req.canceled) return; Request.Builder builder=new Request.Builder() .url(req.getURL().toString()) .method(req.getMethod(), req.getRequestBody()) - .header("User-Agent", "MegalodonAndroid/"+BuildConfig.VERSION_NAME); + .header("User-Agent", "MoshidonAndroid/"+BuildConfig.VERSION_NAME); String token=null; if(session!=null) @@ -122,15 +128,15 @@ public class MastodonAPIController{ } if(BuildConfig.DEBUG) - Log.d(TAG, "["+(session==null ? "no-auth" : session.getID())+"] Sending request: "+hreq); + Log.d(TAG, logTag(session)+"Sending request: "+hreq); call.enqueue(new Callback(){ @Override public void onFailure(@NonNull Call call, @NonNull IOException e){ - if(call.isCanceled()) + if(req.canceled) return; if(BuildConfig.DEBUG) - Log.w(TAG, "["+(session==null ? "no-auth" : session.getID())+"] "+hreq+" failed", e); + Log.w(TAG, logTag(session)+""+hreq+" failed", e); synchronized(req){ req.okhttpCall=null; } @@ -139,10 +145,10 @@ public class MastodonAPIController{ @Override public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException{ - if(call.isCanceled()) + if(req.canceled) return; if(BuildConfig.DEBUG) - Log.d(TAG, "["+(session==null ? "no-auth" : session.getID())+"] "+hreq+" received response: "+response); + Log.d(TAG, logTag(session)+hreq+" received response: "+response); synchronized(req){ req.okhttpCall=null; } @@ -153,7 +159,7 @@ public class MastodonAPIController{ try{ if(BuildConfig.DEBUG){ JsonElement respJson=JsonParser.parseReader(reader); - Log.d(TAG, "["+(session==null ? "no-auth" : session.getID())+"] response body: "+respJson); + Log.d(TAG, logTag(session)+"response body: "+respJson); if(req.respTypeToken!=null) respObj=gson.fromJson(respJson, req.respTypeToken.getType()); else if(req.respClass!=null) @@ -175,7 +181,7 @@ public class MastodonAPIController{ return; } if(BuildConfig.DEBUG) - Log.w(TAG, "["+(session==null ? "no-auth" : session.getID())+"] "+response+" error parsing or reading body", x); + Log.w(TAG, logTag(session)+response+" error parsing or reading body", x); req.onError(x.getLocalizedMessage(), response.code(), x); return; } @@ -184,19 +190,19 @@ public class MastodonAPIController{ req.validateAndPostprocessResponse(respObj, response); }catch(IOException x){ if(BuildConfig.DEBUG) - Log.w(TAG, "["+(session==null ? "no-auth" : session.getID())+"] "+response+" error post-processing or validating response", x); + Log.w(TAG, logTag(session)+response+" error post-processing or validating response", x); req.onError(x.getLocalizedMessage(), response.code(), x); return; } if(BuildConfig.DEBUG) - Log.d(TAG, "["+(session==null ? "no-auth" : session.getID())+"] "+response+" parsed successfully: "+respObj); + Log.d(TAG, logTag(session)+response+" parsed successfully: "+respObj); req.onSuccess(respObj); }else{ try{ JsonObject error=JsonParser.parseReader(reader).getAsJsonObject(); - Log.w(TAG, "["+(session==null ? "no-auth" : session.getID())+"] "+response+" received error: "+error); + Log.w(TAG, logTag(session)+response+" received error: "+error); if(error.has("details")){ MastodonDetailedErrorResponse err=new MastodonDetailedErrorResponse(error.get("error").getAsString(), response.code(), null); HashMap> details=new HashMap<>(); @@ -231,7 +237,7 @@ public class MastodonAPIController{ }); }catch(Exception x){ if(BuildConfig.DEBUG) - Log.w(TAG, "["+(session==null ? "no-auth" : session.getID())+"] error creating and sending http request", x); + Log.w(TAG, logTag(session)+"error creating and sending http request", x); req.onError(x.getLocalizedMessage(), 0, x); } }, 0); @@ -244,4 +250,8 @@ public class MastodonAPIController{ public static OkHttpClient getHttpClient(){ return httpClient; } + + private static String logTag(AccountSession session){ + return "["+(session==null ? "no-auth" : session.getID())+"] "; + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java index abdd9b99e..f7610292b 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java @@ -182,6 +182,8 @@ public abstract class MastodonAPIRequest extends APIRequest{ } public RequestBody getRequestBody() throws IOException{ + if(requestBody instanceof RequestBody rb) + return rb; return requestBody==null ? null : new JsonObjectRequestBody(requestBody); } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/StatusInteractionController.java b/mastodon/src/main/java/org/joinmastodon/android/api/StatusInteractionController.java index 14765a481..e7f89590d 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/StatusInteractionController.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/StatusInteractionController.java @@ -6,6 +6,7 @@ import org.joinmastodon.android.E; import org.joinmastodon.android.MastodonApp; import org.joinmastodon.android.api.requests.statuses.SetStatusBookmarked; import org.joinmastodon.android.api.requests.statuses.SetStatusFavorited; +import org.joinmastodon.android.api.requests.statuses.SetStatusMuted; import org.joinmastodon.android.api.requests.statuses.SetStatusReblogged; import org.joinmastodon.android.api.session.AccountSession; import org.joinmastodon.android.api.session.AccountSessionManager; @@ -35,6 +36,7 @@ public class StatusInteractionController{ private final HashMap runningFavoriteRequests=new HashMap<>(); private final HashMap runningReblogRequests=new HashMap<>(); private final HashMap runningBookmarkRequests=new HashMap<>(); + private final HashMap runningMuteRequests=new HashMap<>(); public StatusInteractionController(String accountID, boolean updateCounters) { this.accountID=accountID; diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/CheckInviteLink.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/CheckInviteLink.java new file mode 100644 index 000000000..136200548 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/CheckInviteLink.java @@ -0,0 +1,22 @@ +package org.joinmastodon.android.api.requests.accounts; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.api.RequiredField; +import org.joinmastodon.android.model.BaseModel; + +public class CheckInviteLink extends MastodonAPIRequest{ + public CheckInviteLink(String path){ + super(HttpMethod.GET, path, Response.class); + addHeader("Accept", "application/json"); + } + + @Override + protected String getPathPrefix(){ + return ""; + } + + public static class Response extends BaseModel{ + @RequiredField + public String inviteCode; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetAccountLists.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetAccountLists.java new file mode 100644 index 000000000..55e03fb31 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetAccountLists.java @@ -0,0 +1,14 @@ +package org.joinmastodon.android.api.requests.accounts; + +import com.google.gson.reflect.TypeToken; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.FollowList; + +import java.util.List; + +public class GetAccountLists extends MastodonAPIRequest>{ + public GetAccountLists(String id){ + super(HttpMethod.GET, "/accounts/"+id+"/lists", new TypeToken<>(){}); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/RegisterAccount.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/RegisterAccount.java index 622145237..df7915bb5 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/RegisterAccount.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/RegisterAccount.java @@ -4,22 +4,23 @@ import org.joinmastodon.android.api.MastodonAPIRequest; import org.joinmastodon.android.model.Token; public class RegisterAccount extends MastodonAPIRequest{ - public RegisterAccount(String username, String email, String password, String locale, String reason, String timezone){ + public RegisterAccount(String username, String email, String password, String locale, String reason, String timezone, String inviteCode){ super(HttpMethod.POST, "/accounts", Token.class); - setRequestBody(new Body(username, email, password, locale, reason, timezone)); + setRequestBody(new Body(username, email, password, locale, reason, timezone, inviteCode)); } private static class Body{ - public String username, email, password, locale, reason, timeZone; + public String username, email, password, locale, reason, timeZone, inviteCode; public boolean agreement=true; - public Body(String username, String email, String password, String locale, String reason, String timeZone){ + public Body(String username, String email, String password, String locale, String reason, String timeZone, String inviteCode){ this.username=username; this.email=email; this.password=password; this.locale=locale; this.reason=reason; this.timeZone=timeZone; + this.inviteCode=inviteCode; } } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/SearchAccounts.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/SearchAccounts.java new file mode 100644 index 000000000..6bfb87e4c --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/SearchAccounts.java @@ -0,0 +1,23 @@ +package org.joinmastodon.android.api.requests.accounts; + +import com.google.gson.reflect.TypeToken; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.Account; + +import java.util.List; + +public class SearchAccounts extends MastodonAPIRequest>{ + public SearchAccounts(String q, int limit, int offset, boolean resolve, boolean following){ + super(HttpMethod.GET, "/accounts/search", new TypeToken<>(){}); + addQueryParameter("q", q); + if(limit>0) + addQueryParameter("limit", limit+""); + if(offset>0) + addQueryParameter("offset", offset+""); + if(resolve) + addQueryParameter("resolve", "true"); + if(following) + addQueryParameter("following", "true"); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/SetAccountMuted.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/SetAccountMuted.java index 7d6afadf6..982726fa9 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/SetAccountMuted.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/SetAccountMuted.java @@ -4,15 +4,21 @@ import org.joinmastodon.android.api.MastodonAPIRequest; import org.joinmastodon.android.model.Relationship; public class SetAccountMuted extends MastodonAPIRequest{ - public SetAccountMuted(String id, boolean muted, long duration){ + public SetAccountMuted(String id, boolean muted, long duration, boolean muteNotifications){ super(HttpMethod.POST, "/accounts/"+id+"/"+(muted ? "mute" : "unmute"), Relationship.class); - setRequestBody(new Request(duration)); + if(muted) + setRequestBody(new Request(duration, muteNotifications)); + else{ + setRequestBody(new Object()); + } } private static class Request{ public long duration; - public Request(long duration){ + public boolean muteNotifications; + public Request(long duration, boolean muteNotifications){ this.duration=duration; + this.muteNotifications=muteNotifications; } } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/SetPrivateNote.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/SetPrivateNote.java index a1b59a4c7..e9c9fe764 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/SetPrivateNote.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/SetPrivateNote.java @@ -4,16 +4,16 @@ import org.joinmastodon.android.api.MastodonAPIRequest; import org.joinmastodon.android.model.Relationship; public class SetPrivateNote extends MastodonAPIRequest{ - public SetPrivateNote(String id, String comment){ - super(MastodonAPIRequest.HttpMethod.POST, "/accounts/"+id+"/note", Relationship.class); - Request req = new Request(comment); - setRequestBody(req); - } + public SetPrivateNote(String id, String comment){ + super(MastodonAPIRequest.HttpMethod.POST, "/accounts/"+id+"/note", Relationship.class); + Request req = new Request(comment); + setRequestBody(req); + } - private static class Request{ - public String comment; - public Request(String comment){ - this.comment=comment; - } - } + private static class Request{ + public String comment; + public Request(String comment){ + this.comment=comment; + } + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/UpdateAccountCredentials.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/UpdateAccountCredentials.java index 5724a933b..37f52f83b 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/UpdateAccountCredentials.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/UpdateAccountCredentials.java @@ -22,6 +22,7 @@ public class UpdateAccountCredentials extends MastodonAPIRequest{ private Uri avatar, cover; private File avatarFile, coverFile; private List fields; + private Boolean discoverable, indexable; public UpdateAccountCredentials(String displayName, String bio, Uri avatar, Uri cover, List fields){ super(HttpMethod.PATCH, "/accounts/update_credentials", Account.class); @@ -41,6 +42,12 @@ public class UpdateAccountCredentials extends MastodonAPIRequest{ this.fields=fields; } + public UpdateAccountCredentials setDiscoverableIndexable(boolean discoverable, boolean indexable){ + this.discoverable=discoverable; + this.indexable=indexable; + return this; + } + @Override public RequestBody getRequestBody() throws IOException{ MultipartBody.Builder bldr=new MultipartBody.Builder() @@ -58,15 +65,21 @@ public class UpdateAccountCredentials extends MastodonAPIRequest{ }else if(coverFile!=null){ bldr.addFormDataPart("header", coverFile.getName(), new ResizedImageRequestBody(Uri.fromFile(coverFile), 1500*500, null)); } - if(fields.isEmpty()){ - bldr.addFormDataPart("fields_attributes[0][name]", "").addFormDataPart("fields_attributes[0][value]", ""); - }else{ - int i=0; - for(AccountField field:fields){ - bldr.addFormDataPart("fields_attributes["+i+"][name]", field.name).addFormDataPart("fields_attributes["+i+"][value]", field.value); - i++; + if(fields!=null){ + if(fields.isEmpty()){ + bldr.addFormDataPart("fields_attributes[0][name]", "").addFormDataPart("fields_attributes[0][value]", ""); + }else{ + int i=0; + for(AccountField field:fields){ + bldr.addFormDataPart("fields_attributes["+i+"][name]", field.name).addFormDataPart("fields_attributes["+i+"][value]", field.value); + i++; + } } } + if(discoverable!=null) + bldr.addFormDataPart("discoverable", discoverable.toString()); + if(indexable!=null) + bldr.addFormDataPart("indexable", indexable.toString()); return bldr.build(); } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/KeywordAttribute.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/KeywordAttribute.java index d35a0f0fa..b2ed7f18b 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/KeywordAttribute.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/KeywordAttribute.java @@ -2,6 +2,9 @@ package org.joinmastodon.android.api.requests.filters; import com.google.gson.annotations.SerializedName; +import androidx.annotation.Keep; + +@Keep class KeywordAttribute{ public String id; @SerializedName("_destroy") diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/instance/GetDomainBlocks.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/instance/GetDomainBlocks.java new file mode 100644 index 000000000..e2c8aa9cf --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/instance/GetDomainBlocks.java @@ -0,0 +1,16 @@ +package org.joinmastodon.android.api.requests.instance; + +import com.google.gson.reflect.TypeToken; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.DomainBlock; +import org.joinmastodon.android.model.ExtendedDescription; + +import java.util.List; + +public class GetDomainBlocks extends MastodonAPIRequest>{ + public GetDomainBlocks(){ + super(HttpMethod.GET, "/instance/domain_blocks", new TypeToken<>(){}); + } + +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/instance/GetExtendedDescription.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/instance/GetExtendedDescription.java new file mode 100644 index 000000000..5ce739a62 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/instance/GetExtendedDescription.java @@ -0,0 +1,12 @@ +package org.joinmastodon.android.api.requests.instance; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.ExtendedDescription; +import org.joinmastodon.android.model.Instance; + +public class GetExtendedDescription extends MastodonAPIRequest{ + public GetExtendedDescription(){ + super(HttpMethod.GET, "/instance/extended_description", ExtendedDescription.class); + } + +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/instance/GetWeeklyActivity.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/instance/GetWeeklyActivity.java new file mode 100644 index 000000000..87f74f9de --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/instance/GetWeeklyActivity.java @@ -0,0 +1,15 @@ +package org.joinmastodon.android.api.requests.instance; + +import com.google.gson.reflect.TypeToken; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.WeeklyActivity; + +import java.util.List; + +public class GetWeeklyActivity extends MastodonAPIRequest>{ + public GetWeeklyActivity(){ + super(HttpMethod.GET, "/instance/activity", new TypeToken<>(){}); + } + +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/AddAccountsToList.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/AddAccountsToList.java index f3db76322..29c1aacea 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/AddAccountsToList.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/AddAccountsToList.java @@ -1,17 +1,19 @@ package org.joinmastodon.android.api.requests.lists; -import org.joinmastodon.android.api.MastodonAPIRequest; -import java.util.List; +import org.joinmastodon.android.api.ResultlessMastodonAPIRequest; -public class AddAccountsToList extends MastodonAPIRequest { - public AddAccountsToList(String listId, List accountIds){ - super(HttpMethod.POST, "/lists/"+listId+"/accounts", Object.class); - Request req = new Request(); - req.accountIds = accountIds; - setRequestBody(req); - } +import java.nio.charset.StandardCharsets; +import java.util.Collection; - public static class Request{ - public List accountIds; - } +import okhttp3.FormBody; + +public class AddAccountsToList extends ResultlessMastodonAPIRequest{ + public AddAccountsToList(String listID, Collection accountIDs){ + super(HttpMethod.POST, "/lists/"+listID+"/accounts"); + FormBody.Builder builder=new FormBody.Builder(StandardCharsets.UTF_8); + for(String id:accountIDs){ + builder.add("account_ids[]", id); + } + setRequestBody(builder.build()); + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/AddList.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/AddList.java new file mode 100644 index 000000000..7c8e2e852 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/AddList.java @@ -0,0 +1,17 @@ +package org.joinmastodon.android.api.requests.lists; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import java.util.List; + +public class AddList extends MastodonAPIRequest { + public AddList(String listName){ + super(HttpMethod.POST, "/lists", Object.class); + Request req = new Request(); + req.title = listName; + setRequestBody(req); + } + + public static class Request{ + public String title; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/CreateList.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/CreateList.java index 1ec4204e5..2c217a627 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/CreateList.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/CreateList.java @@ -1,21 +1,23 @@ package org.joinmastodon.android.api.requests.lists; import org.joinmastodon.android.api.MastodonAPIRequest; -import org.joinmastodon.android.model.ListTimeline; +import org.joinmastodon.android.model.FollowList; -public class CreateList extends MastodonAPIRequest { - public CreateList(String title, boolean exclusive, ListTimeline.RepliesPolicy repliesPolicy) { - super(HttpMethod.POST, "/lists", ListTimeline.class); - Request req = new Request(); - req.title = title; - req.exclusive = exclusive; - req.repliesPolicy = repliesPolicy; - setRequestBody(req); +public class CreateList extends MastodonAPIRequest{ + public CreateList(String title, FollowList.RepliesPolicy repliesPolicy, boolean exclusive){ + super(HttpMethod.POST, "/lists", FollowList.class); + setRequestBody(new Request(title, repliesPolicy, exclusive)); } - public static class Request { + private static class Request{ public String title; + public FollowList.RepliesPolicy repliesPolicy; public boolean exclusive; - public ListTimeline.RepliesPolicy repliesPolicy; + + public Request(String title, FollowList.RepliesPolicy repliesPolicy, boolean exclusive){ + this.title=title; + this.repliesPolicy=repliesPolicy; + this.exclusive=exclusive; + } } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/DeleteList.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/DeleteList.java index 64620adac..716d9a5d8 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/DeleteList.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/DeleteList.java @@ -1,10 +1,9 @@ package org.joinmastodon.android.api.requests.lists; -import org.joinmastodon.android.api.MastodonAPIRequest; -import org.joinmastodon.android.model.ListTimeline; +import org.joinmastodon.android.api.ResultlessMastodonAPIRequest; -public class DeleteList extends MastodonAPIRequest { - public DeleteList(String id) { - super(HttpMethod.DELETE, "/lists/" + id, Object.class); +public class DeleteList extends ResultlessMastodonAPIRequest{ + public DeleteList(String id){ + super(HttpMethod.DELETE, "/lists/"+id); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/EditListName.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/EditListName.java new file mode 100644 index 000000000..7a5d52058 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/EditListName.java @@ -0,0 +1,17 @@ +package org.joinmastodon.android.api.requests.lists; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import java.util.List; + +public class EditListName extends MastodonAPIRequest { + public EditListName(String newListName, String listId){ + super(HttpMethod.PUT, "/lists/"+listId, Object.class); + Request req = new Request(); + req.title = newListName; + setRequestBody(req); + } + + public static class Request{ + public String title; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/GetList.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/GetList.java index 19bda79ca..340af5523 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/GetList.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/GetList.java @@ -1,10 +1,10 @@ package org.joinmastodon.android.api.requests.lists; import org.joinmastodon.android.api.MastodonAPIRequest; -import org.joinmastodon.android.model.ListTimeline; +import org.joinmastodon.android.model.FollowList; -public class GetList extends MastodonAPIRequest { +public class GetList extends MastodonAPIRequest { public GetList(String id) { - super(HttpMethod.GET, "/lists/" + id, ListTimeline.class); + super(HttpMethod.GET, "/lists/" + id, FollowList.class); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/GetListAccounts.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/GetListAccounts.java new file mode 100644 index 000000000..1d54dc2d9 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/GetListAccounts.java @@ -0,0 +1,17 @@ +package org.joinmastodon.android.api.requests.lists; + +import android.text.TextUtils; + +import com.google.gson.reflect.TypeToken; + +import org.joinmastodon.android.api.requests.HeaderPaginationRequest; +import org.joinmastodon.android.model.Account; + +public class GetListAccounts extends HeaderPaginationRequest{ + public GetListAccounts(String listID, String maxID, int limit){ + super(HttpMethod.GET, "/lists/"+listID+"/accounts", new TypeToken<>(){}); + if(!TextUtils.isEmpty(maxID)) + addQueryParameter("max_id", maxID); + addQueryParameter("limit", String.valueOf(limit)); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/GetLists.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/GetLists.java index 61abeb03a..ab1cdad48 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/GetLists.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/GetLists.java @@ -3,11 +3,11 @@ package org.joinmastodon.android.api.requests.lists; import com.google.gson.reflect.TypeToken; import org.joinmastodon.android.api.MastodonAPIRequest; -import org.joinmastodon.android.model.ListTimeline; +import org.joinmastodon.android.model.FollowList; import java.util.List; -public class GetLists extends MastodonAPIRequest>{ +public class GetLists extends MastodonAPIRequest>{ public GetLists() { super(HttpMethod.GET, "/lists", new TypeToken<>(){}); } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/RemoveAccountsFromList.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/RemoveAccountsFromList.java index f285d54f6..20f20463f 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/RemoveAccountsFromList.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/RemoveAccountsFromList.java @@ -1,17 +1,19 @@ package org.joinmastodon.android.api.requests.lists; -import org.joinmastodon.android.api.MastodonAPIRequest; -import java.util.List; +import org.joinmastodon.android.api.ResultlessMastodonAPIRequest; -public class RemoveAccountsFromList extends MastodonAPIRequest { - public RemoveAccountsFromList(String listId, List accountIds){ - super(HttpMethod.DELETE, "/lists/"+listId+"/accounts", Object.class); - Request req = new Request(); - req.accountIds = accountIds; - setRequestBody(req); - } +import java.nio.charset.StandardCharsets; +import java.util.Collection; - public static class Request{ - public List accountIds; - } +import okhttp3.FormBody; + +public class RemoveAccountsFromList extends ResultlessMastodonAPIRequest{ + public RemoveAccountsFromList(String listID, Collection accountIDs){ + super(HttpMethod.DELETE, "/lists/"+listID+"/accounts"); + FormBody.Builder builder=new FormBody.Builder(StandardCharsets.UTF_8); + for(String id:accountIDs){ + builder.add("account_ids[]", id); + } + setRequestBody(builder.build()); + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/RemoveList.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/RemoveList.java new file mode 100644 index 000000000..4a14962a2 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/RemoveList.java @@ -0,0 +1,10 @@ +package org.joinmastodon.android.api.requests.lists; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import java.util.List; + +public class RemoveList extends MastodonAPIRequest { + public RemoveList(String listId){ + super(HttpMethod.DELETE, "/lists/"+listId, Object.class); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/UpdateList.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/UpdateList.java index 64073fd3d..905ad0d26 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/UpdateList.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/UpdateList.java @@ -1,15 +1,23 @@ package org.joinmastodon.android.api.requests.lists; import org.joinmastodon.android.api.MastodonAPIRequest; -import org.joinmastodon.android.model.ListTimeline; +import org.joinmastodon.android.model.FollowList; -public class UpdateList extends MastodonAPIRequest { - public UpdateList(String id, String title, boolean exclusive, ListTimeline.RepliesPolicy repliesPolicy) { - super(HttpMethod.PUT, "/lists/" + id, ListTimeline.class); - CreateList.Request req = new CreateList.Request(); - req.title = title; - req.exclusive = exclusive; - req.repliesPolicy = repliesPolicy; - setRequestBody(req); +public class UpdateList extends MastodonAPIRequest{ + public UpdateList(String listID, String title, FollowList.RepliesPolicy repliesPolicy, boolean exclusive){ + super(HttpMethod.PUT, "/lists/"+listID, FollowList.class); + setRequestBody(new Request(title, repliesPolicy, exclusive)); + } + + private static class Request{ + public String title; + public FollowList.RepliesPolicy repliesPolicy; + public boolean exclusive; + + public Request(String title, FollowList.RepliesPolicy repliesPolicy, boolean exclusive){ + this.title=title; + this.repliesPolicy=repliesPolicy; + this.exclusive=exclusive; + } } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/DismissNotification.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/DismissNotification.java index ff83a55f0..5c2399774 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/DismissNotification.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/DismissNotification.java @@ -10,8 +10,8 @@ import java.util.EnumSet; import java.util.List; public class DismissNotification extends MastodonAPIRequest{ - public DismissNotification(String id){ - super(HttpMethod.POST, "/notifications/" + (id != null ? id + "/dismiss" : "clear"), Object.class); - setRequestBody(new Object()); - } + public DismissNotification(String id){ + super(HttpMethod.POST, "/notifications/" + (id != null ? id + "/dismiss" : "clear"), Object.class); + setRequestBody(new Object()); + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/oauth/CreateOAuthApp.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/oauth/CreateOAuthApp.java index 5360153ef..bb1e98713 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/oauth/CreateOAuthApp.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/oauth/CreateOAuthApp.java @@ -11,9 +11,9 @@ public class CreateOAuthApp extends MastodonAPIRequest{ } private static class Request{ - public String clientName="Megalodon"; + public String clientName="Moshidon"; public String redirectUris=AccountSessionManager.REDIRECT_URI; public String scopes=AccountSessionManager.SCOPE; - public String website="https://sk22.github.io/megalodon"; + public String website="https://github.com/LucasGGamerM/moshidon"; } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/GetStatusEditHistory.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/GetStatusEditHistory.java index 608c8211c..27432bfdc 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/GetStatusEditHistory.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/GetStatusEditHistory.java @@ -26,8 +26,11 @@ public class GetStatusEditHistory extends MastodonAPIRequest>{ s.visibility=StatusPrivacy.PUBLIC; s.mentions=Collections.emptyList(); s.tags=Collections.emptyList(); - if (s.poll != null) + if(s.poll!=null){ s.poll.id="fakeID"+i; + s.poll.emojis=Collections.emptyList(); + s.poll.ownVotes=Collections.emptyList(); + } i++; } super.validateAndPostprocessResponse(respObj, httpResponse); diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/SetStatusMuted.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/SetStatusMuted.java new file mode 100644 index 000000000..6164b2244 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/SetStatusMuted.java @@ -0,0 +1,11 @@ +package org.joinmastodon.android.api.requests.statuses; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.Status; + +public class SetStatusMuted extends MastodonAPIRequest{ + public SetStatusMuted(String id, boolean muted){ + super(HttpMethod.POST, "/statuses/"+id+"/"+(muted ? "mute" : "unmute"), Status.class); + setRequestBody(new Object()); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/tags/GetFollowedTags.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/tags/GetFollowedTags.java new file mode 100644 index 000000000..5c88a471a --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/tags/GetFollowedTags.java @@ -0,0 +1,16 @@ +package org.joinmastodon.android.api.requests.tags; + +import com.google.gson.reflect.TypeToken; + +import org.joinmastodon.android.api.requests.HeaderPaginationRequest; +import org.joinmastodon.android.model.Hashtag; + +public class GetFollowedTags extends HeaderPaginationRequest{ + public GetFollowedTags(String maxID, int limit){ + super(HttpMethod.GET, "/followed_tags", new TypeToken<>(){}); + if(maxID!=null) + addQueryParameter("max_id", maxID); + if(limit>0) + addQueryParameter("limit", limit+""); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetPublicTimeline.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetPublicTimeline.java index 328bf869a..08c03b1c4 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetPublicTimeline.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetPublicTimeline.java @@ -10,7 +10,7 @@ import org.joinmastodon.android.model.Status; import java.util.List; public class GetPublicTimeline extends MastodonAPIRequest>{ - public GetPublicTimeline(boolean local, boolean remote, String maxID, int limit, String replyVisibility){ + public GetPublicTimeline(boolean local, boolean remote, String maxID, String minID, int limit, String sinceID, String replyVisibility){ super(HttpMethod.GET, "/timelines/public", new TypeToken<>(){}); if(local) addQueryParameter("local", "true"); @@ -18,6 +18,10 @@ public class GetPublicTimeline extends MastodonAPIRequest>{ addQueryParameter("remote", "true"); if(!TextUtils.isEmpty(maxID)) addQueryParameter("max_id", maxID); + if(!TextUtils.isEmpty(minID)) + addQueryParameter("min_id", minID); + if(!TextUtils.isEmpty(sinceID)) + addQueryParameter("since_id", sinceID); if(limit>0) addQueryParameter("limit", limit+""); if(replyVisibility != null) diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetTrendingLinksTimeline.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetTrendingLinksTimeline.java new file mode 100644 index 000000000..428fdc3cf --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetTrendingLinksTimeline.java @@ -0,0 +1,23 @@ +package org.joinmastodon.android.api.requests.timelines; + +import androidx.annotation.NonNull; + +import com.google.gson.reflect.TypeToken; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.Status; + +import java.util.List; + +public class GetTrendingLinksTimeline extends MastodonAPIRequest>{ + public GetTrendingLinksTimeline(@NonNull String url, String maxID, String minID, int limit){ + super(HttpMethod.GET, "/timelines/link/", new TypeToken<>(){}); + addQueryParameter("url", url); + if(maxID!=null) + addQueryParameter("max_id", maxID); + if(minID!=null) + addQueryParameter("min_id", minID); + if(limit>0) + addQueryParameter("limit", ""+limit); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountLocalPreferences.java b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountLocalPreferences.java index e3acdc6fe..0db74f3d0 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountLocalPreferences.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountLocalPreferences.java @@ -14,11 +14,16 @@ import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.R; import org.joinmastodon.android.model.ContentType; import org.joinmastodon.android.model.Emoji; +import org.joinmastodon.android.model.Emoji; +import org.joinmastodon.android.model.PushSubscription; import org.joinmastodon.android.model.Instance; import org.joinmastodon.android.model.TimelineDefinition; import java.lang.reflect.Type; import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import java.util.Optional; public class AccountLocalPreferences{ @@ -47,11 +52,18 @@ public class AccountLocalPreferences{ public ShowEmojiReactions showEmojiReactions; public ColorPreference color; public ArrayList recentCustomEmoji; + public boolean preReplySheet; private final static Type recentLanguagesType=new TypeToken>() {}.getType(); private final static Type timelinesType=new TypeToken>() {}.getType(); private final static Type recentCustomEmojiType=new TypeToken>() {}.getType(); + // MOSHIDON +// private final static Type recentEmojisType = new TypeToken>() {}.getType(); +// public Map recentEmojis; + private final static Type notificationFiltersType = new TypeToken() {}.getType(); + public PushSubscription.Alerts notificationFilters; + public AccountLocalPreferences(SharedPreferences prefs, AccountSession session){ this.prefs=prefs; showInteractionCounts=prefs.getBoolean("interactionCounts", false); @@ -59,6 +71,7 @@ public class AccountLocalPreferences{ revealCWs=prefs.getBoolean("revealCWs", false); hideSensitiveMedia=prefs.getBoolean("hideSensitive", true); serverSideFiltersSupported=prefs.getBoolean("serverSideFilters", false); +// preReplySheet=prefs.getBoolean("preReplySheet", false); // MEGALODON Optional instance=session.getInstance(); @@ -78,6 +91,10 @@ public class AccountLocalPreferences{ showEmojiReactions=ShowEmojiReactions.valueOf(prefs.getString("showEmojiReactions", ShowEmojiReactions.HIDE_EMPTY.name())); color=prefs.contains("color") ? ColorPreference.valueOf(prefs.getString("color", null)) : null; recentCustomEmoji=fromJson(prefs.getString("recentCustomEmoji", null), recentCustomEmojiType, new ArrayList<>()); + + // MOSHIDON +// recentEmojis=fromJson(prefs.getString("recentEmojis", "{}"), recentEmojisType, new HashMap<>()); + notificationFilters=fromJson(prefs.getString("notificationFilters", gson.toJson(PushSubscription.Alerts.ofAll())), notificationFiltersType, PushSubscription.Alerts.ofAll()); } public long getNotificationsPauseEndTime(){ @@ -100,6 +117,9 @@ public class AccountLocalPreferences{ .putBoolean("hideSensitive", hideSensitiveMedia) .putBoolean("serverSideFilters", serverSideFiltersSupported) + //TODO figure this stuff out +// .putBoolean("preReplySheet", preReplySheet) + // MEGALODON .putBoolean("showReplies", showReplies) .putBoolean("showBoosts", showBoosts) @@ -117,18 +137,24 @@ public class AccountLocalPreferences{ .putString("showEmojiReactions", showEmojiReactions.name()) .putString("color", color!=null ? color.name() : null) .putString("recentCustomEmoji", gson.toJson(recentCustomEmoji)) + + // MOSHIDON +// .putString("recentEmojis", gson.toJson(recentEmojis)) + .putString("notificationFilters", gson.toJson(notificationFilters)) .apply(); } public enum ColorPreference{ MATERIAL3, - PINK, PURPLE, + PINK, GREEN, BLUE, BROWN, RED, - YELLOW; + YELLOW, + NORD, + WHITE; public @StringRes int getName() { return switch(this){ @@ -140,6 +166,8 @@ public class AccountLocalPreferences{ case BROWN -> R.string.sk_color_palette_brown; case RED -> R.string.sk_color_palette_red; case YELLOW -> R.string.sk_color_palette_yellow; + case NORD -> R.string.mo_color_palette_nord; + case WHITE -> R.string.mo_color_palette_black_and_white; }; } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSession.java b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSession.java index 5d70e0b23..3aebcd1ad 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSession.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSession.java @@ -21,11 +21,13 @@ import org.joinmastodon.android.api.requests.markers.SaveMarkers; import org.joinmastodon.android.api.requests.oauth.RevokeOauthToken; import org.joinmastodon.android.events.NotificationsMarkerUpdatedEvent; import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.model.AltTextFilter; import org.joinmastodon.android.model.Application; import org.joinmastodon.android.model.FilterAction; import org.joinmastodon.android.model.FilterContext; import org.joinmastodon.android.model.FilterResult; import org.joinmastodon.android.model.Instance; +import org.joinmastodon.android.model.FollowList; import org.joinmastodon.android.model.LegacyFilter; import org.joinmastodon.android.model.Preferences; import org.joinmastodon.android.model.PushSubscription; @@ -34,7 +36,9 @@ import org.joinmastodon.android.model.TimelineMarkers; import org.joinmastodon.android.model.Token; import org.joinmastodon.android.utils.ObjectIdComparator; +import java.io.File; import java.util.ArrayList; +import java.util.EnumSet; import java.util.List; import java.util.Objects; import java.util.Optional; @@ -70,6 +74,7 @@ public class AccountSession{ private transient SharedPreferences prefs; private transient boolean preferencesNeedSaving; private transient AccountLocalPreferences localPreferences; + private transient List lists; AccountSession(Token token, Account self, Application app, String domain, boolean activated, AccountActivationInfo activationInfo){ this.token=token; @@ -310,8 +315,11 @@ public class AccountSession{ return true; // Even with server-side filters, clients are expected to remove statuses that match a filter that hides them if(getLocalPreferences().serverSideFiltersSupported){ + // Moshidon: this code path in CustomLocalTimelines makes the app crash, so this check is here + if (s.filtered == null) + return false; for(FilterResult filter : s.filtered){ - if(filter.filter.isActive() && filter.filter.filterAction==FilterAction.HIDE) + if(filter.filter.isActive() && filter.filter.filterAction==FilterAction.HIDE && filter.filter.context.contains(context)) return true; } }else if(wordFilters!=null){ @@ -323,6 +331,21 @@ public class AccountSession{ return false; } + public List getClientSideFilters(Status status) { + List filters = new ArrayList<>(); + + // filter post that have no alt text + // it only applies when activated in the settings + AltTextFilter altTextFilter=new AltTextFilter(FilterAction.WARN, EnumSet.allOf(FilterContext.class)); + if(altTextFilter.matches(status)){ + FilterResult filterResult=new FilterResult(); + filterResult.filter=altTextFilter; + filterResult.keywordMatches=List.of(); + filters.add(filterResult); + } + return filters; + } + public void updateAccountInfo(){ AccountSessionManager.getInstance().updateSessionLocalInfo(this); } @@ -343,4 +366,12 @@ public class AccountSession{ .map(instance->"https://"+domain+(instance.isAkkoma() ? "/images/avi.png" : "/avatars/original/missing.png")) .orElse(""); } + + public boolean isNotificationsMentionsOnly(){ + return getRawLocalPreferences().getBoolean("notificationsMentionsOnly", false); + } + + public void setNotificationsMentionsOnly(boolean mentionsOnly){ + getRawLocalPreferences().edit().putBoolean("notificationsMentionsOnly", mentionsOnly).apply(); + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java index 0a3db0f1f..ea66479ea 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java @@ -1,7 +1,5 @@ package org.joinmastodon.android.api.session; -import static org.unifiedpush.android.connector.UnifiedPush.getDistributor; - import android.app.Activity; import android.app.NotificationManager; import android.content.ComponentName; @@ -17,7 +15,7 @@ import android.util.Log; import org.joinmastodon.android.BuildConfig; import org.joinmastodon.android.E; -import org.joinmastodon.android.GlobalUserPreferences; +import org.joinmastodon.android.ChooseAccountForComposeActivity; import org.joinmastodon.android.MainActivity; import org.joinmastodon.android.MastodonApp; import org.joinmastodon.android.R; @@ -64,7 +62,7 @@ import me.grishka.appkit.api.ErrorResponse; public class AccountSessionManager{ private static final String TAG="AccountSessionManager"; public static final String SCOPE="read write follow push"; - public static final String REDIRECT_URI="megalodon-android-auth://callback"; + public static final String REDIRECT_URI = getRedirectURI(); private static final AccountSessionManager instance=new AccountSessionManager(); @@ -82,8 +80,20 @@ public class AccountSessionManager{ return instance; } + public static String getRedirectURI() { + StringBuilder builder = new StringBuilder(); + builder.append("moshidon-android-"); + if (BuildConfig.BUILD_TYPE.equals("debug") || BuildConfig.BUILD_TYPE.equals("nightly")) { + builder.append(BuildConfig.BUILD_TYPE); + builder.append('-'); + } + builder.append("auth://callback"); + return builder.toString(); + } + private AccountSessionManager(){ prefs=MastodonApp.context.getSharedPreferences("account_manager", Context.MODE_PRIVATE); + // This file should not be backed up, otherwise the app may start with accounts already logged in. See res/xml/backup_rules.xml File file=new File(MastodonApp.context.getFilesDir(), "accounts.json"); if(!file.exists()) return; @@ -204,12 +214,17 @@ public class AccountSessionManager{ public void removeAccount(String id){ AccountSession session=getAccount(id); session.getCacheController().closeDatabase(); + session.getCacheController().getListsFile().delete(); MastodonApp.context.deleteDatabase(id+".db"); MastodonApp.context.getSharedPreferences(id, 0).edit().clear().commit(); if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N){ MastodonApp.context.deleteSharedPreferences(id); }else{ - new File(MastodonApp.context.getDir("shared_prefs", Context.MODE_PRIVATE), id+".xml").delete(); + String dataDir=MastodonApp.context.getApplicationInfo().dataDir; + if(dataDir!=null){ + File prefsDir=new File(dataDir, "shared_prefs"); + new File(prefsDir, id+".xml").delete(); + } } sessions.remove(id); if(lastActiveAccountID.equals(id)){ @@ -244,7 +259,7 @@ public class AccountSessionManager{ .path("/oauth/authorize") .appendQueryParameter("response_type", "code") .appendQueryParameter("client_id", result.clientId) - .appendQueryParameter("redirect_uri", "megalodon-android-auth://callback") + .appendQueryParameter("redirect_uri", REDIRECT_URI) .appendQueryParameter("scope", SCOPE) .build(); @@ -468,15 +483,19 @@ public class AccountSessionManager{ if(Build.VERSION.SDK_INT<26) return; ShortcutManager sm=MastodonApp.context.getSystemService(ShortcutManager.class); - if((sm.getDynamicShortcuts().isEmpty() || BuildConfig.DEBUG) && !sessions.isEmpty()){ + + Intent intent = new Intent(MastodonApp.context, ChooseAccountForComposeActivity.class) + .setAction(Intent.ACTION_CHOOSER) + .putExtra("compose", true); + + // This was done so that the old shortcuts get updated to the new implementation. + if((sm.getDynamicShortcuts().isEmpty() || sm.getDynamicShortcuts().get(0).getIntent() != intent || BuildConfig.DEBUG ) && !sessions.isEmpty()){ // There are no shortcuts, but there are accounts. Add a compose shortcut. ShortcutInfo info=new ShortcutInfo.Builder(MastodonApp.context, "compose") .setActivity(ComponentName.createRelative(MastodonApp.context, MainActivity.class.getName())) .setShortLabel(MastodonApp.context.getString(R.string.new_post)) .setIcon(Icon.createWithResource(MastodonApp.context, R.mipmap.ic_shortcut_compose)) - .setIntent(new Intent(MastodonApp.context, MainActivity.class) - .setAction(Intent.ACTION_MAIN) - .putExtra("compose", true)) + .setIntent(intent) .build(); sm.setDynamicShortcuts(Collections.singletonList(info)); }else if(sessions.isEmpty()){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/events/AccountAddedToListEvent.java b/mastodon/src/main/java/org/joinmastodon/android/events/AccountAddedToListEvent.java new file mode 100644 index 000000000..032485381 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/events/AccountAddedToListEvent.java @@ -0,0 +1,15 @@ +package org.joinmastodon.android.events; + +import org.joinmastodon.android.model.Account; + +public class AccountAddedToListEvent{ + public final String accountID; + public final String listID; + public final Account account; + + public AccountAddedToListEvent(String accountID, String listID, Account account){ + this.accountID=accountID; + this.listID=listID; + this.account=account; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/events/AccountRemovedFromListEvent.java b/mastodon/src/main/java/org/joinmastodon/android/events/AccountRemovedFromListEvent.java new file mode 100644 index 000000000..f7cce08e7 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/events/AccountRemovedFromListEvent.java @@ -0,0 +1,13 @@ +package org.joinmastodon.android.events; + +public class AccountRemovedFromListEvent{ + public final String accountID; + public final String listID; + public final String targetAccountID; + + public AccountRemovedFromListEvent(String accountID, String listID, String targetAccountID){ + this.accountID=accountID; + this.listID=listID; + this.targetAccountID=targetAccountID; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/events/FinishListCreationFragmentEvent.java b/mastodon/src/main/java/org/joinmastodon/android/events/FinishListCreationFragmentEvent.java new file mode 100644 index 000000000..ec7a33346 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/events/FinishListCreationFragmentEvent.java @@ -0,0 +1,11 @@ +package org.joinmastodon.android.events; + +public class FinishListCreationFragmentEvent{ + public final String accountID; + public final String listID; + + public FinishListCreationFragmentEvent(String accountID, String listID){ + this.accountID=accountID; + this.listID=listID; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/events/ListCreatedEvent.java b/mastodon/src/main/java/org/joinmastodon/android/events/ListCreatedEvent.java new file mode 100644 index 000000000..00de72dcc --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/events/ListCreatedEvent.java @@ -0,0 +1,13 @@ +package org.joinmastodon.android.events; + +import org.joinmastodon.android.model.FollowList; + +public class ListCreatedEvent{ + public final String accountID; + public final FollowList list; + + public ListCreatedEvent(String accountID, FollowList list){ + this.accountID=accountID; + this.list=list; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/events/ListDeletedEvent.java b/mastodon/src/main/java/org/joinmastodon/android/events/ListDeletedEvent.java index 9824bb233..b12eaa222 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/events/ListDeletedEvent.java +++ b/mastodon/src/main/java/org/joinmastodon/android/events/ListDeletedEvent.java @@ -1,9 +1,11 @@ package org.joinmastodon.android.events; -public class ListDeletedEvent { - public final String id; +public class ListDeletedEvent{ + public final String accountID; + public final String listID; - public ListDeletedEvent(String id) { - this.id = id; + public ListDeletedEvent(String accountID, String listID){ + this.accountID=accountID; + this.listID=listID; } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/events/ListUpdatedCreatedEvent.java b/mastodon/src/main/java/org/joinmastodon/android/events/ListUpdatedCreatedEvent.java index 26e0081e6..919a2950a 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/events/ListUpdatedCreatedEvent.java +++ b/mastodon/src/main/java/org/joinmastodon/android/events/ListUpdatedCreatedEvent.java @@ -1,14 +1,14 @@ package org.joinmastodon.android.events; -import org.joinmastodon.android.model.ListTimeline; +import org.joinmastodon.android.model.FollowList; public class ListUpdatedCreatedEvent { public final String id; public final String title; - public final ListTimeline.RepliesPolicy repliesPolicy; + public final FollowList.RepliesPolicy repliesPolicy; public final boolean exclusive; - public ListUpdatedCreatedEvent(String id, String title, boolean exclusive, ListTimeline.RepliesPolicy repliesPolicy) { + public ListUpdatedCreatedEvent(String id, String title, boolean exclusive, FollowList.RepliesPolicy repliesPolicy) { this.id = id; this.title = title; this.exclusive = exclusive; diff --git a/mastodon/src/main/java/org/joinmastodon/android/events/ListUpdatedEvent.java b/mastodon/src/main/java/org/joinmastodon/android/events/ListUpdatedEvent.java new file mode 100644 index 000000000..b27fe0142 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/events/ListUpdatedEvent.java @@ -0,0 +1,13 @@ +package org.joinmastodon.android.events; + +import org.joinmastodon.android.model.FollowList; + +public class ListUpdatedEvent{ + public final String accountID; + public final FollowList list; + + public ListUpdatedEvent(String accountID, FollowList list){ + this.accountID=accountID; + this.list=list; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/events/StatusMuteChangedEvent.java b/mastodon/src/main/java/org/joinmastodon/android/events/StatusMuteChangedEvent.java new file mode 100644 index 000000000..a0a1908e2 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/events/StatusMuteChangedEvent.java @@ -0,0 +1,15 @@ +package org.joinmastodon.android.events; + +import org.joinmastodon.android.model.Status; + +public class StatusMuteChangedEvent{ + public String id; + public boolean muted; + public Status status; + + public StatusMuteChangedEvent(Status s){ + id=s.id; + muted=s.muted; + status=s; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/events/TakePictureRequestEvent.java b/mastodon/src/main/java/org/joinmastodon/android/events/TakePictureRequestEvent.java new file mode 100644 index 000000000..4ce29381a --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/events/TakePictureRequestEvent.java @@ -0,0 +1,6 @@ +package org.joinmastodon.android.events; + +public class TakePictureRequestEvent { + public TakePictureRequestEvent(){ + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/AddAccountToListsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/AddAccountToListsFragment.java new file mode 100644 index 000000000..deb59fce3 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/AddAccountToListsFragment.java @@ -0,0 +1,114 @@ +package org.joinmastodon.android.fragments; + +import android.os.Bundle; +import android.widget.TextView; + +import org.joinmastodon.android.E; +import org.joinmastodon.android.R; +import org.joinmastodon.android.api.ResultlessMastodonAPIRequest; +import org.joinmastodon.android.api.requests.accounts.GetAccountLists; +import org.joinmastodon.android.api.requests.lists.AddAccountsToList; +import org.joinmastodon.android.api.requests.lists.RemoveAccountsFromList; +import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.events.AccountAddedToListEvent; +import org.joinmastodon.android.events.AccountRemovedFromListEvent; +import org.joinmastodon.android.fragments.settings.BaseSettingsFragment; +import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.model.FollowList; +import org.joinmastodon.android.model.viewmodel.CheckableListItem; +import org.joinmastodon.android.ui.utils.UiUtils; +import org.parceler.Parcels; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import androidx.recyclerview.widget.RecyclerView; +import me.grishka.appkit.api.Callback; +import me.grishka.appkit.api.ErrorResponse; +import me.grishka.appkit.api.SimpleCallback; +import me.grishka.appkit.utils.MergeRecyclerAdapter; +import me.grishka.appkit.utils.SingleViewRecyclerAdapter; +import me.grishka.appkit.utils.V; + +public class AddAccountToListsFragment extends BaseSettingsFragment{ + private Account account; + + @Override + public void onCreate(Bundle savedInstanceState){ + super.onCreate(savedInstanceState); + setTitle(R.string.add_user_to_list_title); + account=Parcels.unwrap(getArguments().getParcelable("targetAccount")); + loadData(); + } + + @Override + protected void doLoadData(int offset, int count){ + AccountSessionManager.get(accountID).getCacheController().getLists(new SimpleCallback<>(this){ + @Override + public void onSuccess(List allLists){ + if(getActivity()==null) + return; + loadAccountLists(allLists); + } + }); + } + + private void loadAccountLists(final List allLists){ + currentRequest=new GetAccountLists(account.id) + .setCallback(new SimpleCallback<>(this){ + @Override + public void onSuccess(List result){ + Set lists=result.stream().map(l->l.id).collect(Collectors.toSet()); + onDataLoaded(allLists.stream() + .map(l->new CheckableListItem<>(l.title, null, CheckableListItem.Style.CHECKBOX, lists.contains(l.id), + R.drawable.ic_list_alt_24px, AddAccountToListsFragment.this::onItemClick, l)) + .collect(Collectors.toList()), false); + } + }) + .exec(accountID); + } + + @Override + protected int indexOfItemsAdapter(){ + return 1; + } + + @Override + protected RecyclerView.Adapter getAdapter(){ + TextView topText=new TextView(getActivity()); + topText.setTextAppearance(R.style.m3_body_medium); + topText.setTextColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3OnSurface)); + topText.setPadding(V.dp(16), V.dp(8), V.dp(16), V.dp(8)); + topText.setText(getString(R.string.manage_user_lists, account.getDisplayUsername())); + + MergeRecyclerAdapter mergeAdapter=new MergeRecyclerAdapter(); + mergeAdapter.addAdapter(new SingleViewRecyclerAdapter(topText)); + mergeAdapter.addAdapter(super.getAdapter()); + return mergeAdapter; + } + + private void onItemClick(CheckableListItem item){ + boolean add=!item.checked; + ResultlessMastodonAPIRequest req=add ? new AddAccountsToList(item.parentObject.id, Set.of(account.id)) : new RemoveAccountsFromList(item.parentObject.id, Set.of(account.id)); + req.setCallback(new Callback<>(){ + @Override + public void onSuccess(Void result){ + item.checked=add; + rebindItem(item); + if(add){ + E.post(new AccountAddedToListEvent(accountID, item.parentObject.id, account)); + }else{ + E.post(new AccountRemovedFromListEvent(accountID, item.parentObject.id, account.id)); + } + } + + @Override + public void onError(ErrorResponse error){ + error.showToast(getActivity()); + } + }) + .wrapProgress(getActivity(), R.string.loading, false) + .exec(accountID); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseEditListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseEditListFragment.java new file mode 100644 index 000000000..5aa203f68 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseEditListFragment.java @@ -0,0 +1,176 @@ +package org.joinmastodon.android.fragments; + +import android.app.Activity; +import android.os.Bundle; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.Spinner; + +import org.joinmastodon.android.E; +import org.joinmastodon.android.R; +import org.joinmastodon.android.api.requests.lists.DeleteList; +import org.joinmastodon.android.api.requests.lists.GetListAccounts; +import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.events.ListDeletedEvent; +import org.joinmastodon.android.fragments.settings.BaseSettingsFragment; +import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.model.FollowList; +import org.joinmastodon.android.model.HeaderPaginationList; +import org.joinmastodon.android.model.viewmodel.AvatarPileListItem; +import org.joinmastodon.android.model.viewmodel.CheckableListItem; +import org.joinmastodon.android.model.viewmodel.ListItem; +import org.joinmastodon.android.ui.views.FloatingHintEditTextLayout; +import org.parceler.Parcels; + +import java.util.ArrayList; +import java.util.List; + +import androidx.recyclerview.widget.RecyclerView; +import me.grishka.appkit.Nav; +import me.grishka.appkit.api.APIRequest; +import me.grishka.appkit.api.Callback; +import me.grishka.appkit.api.ErrorResponse; +import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest; +import me.grishka.appkit.utils.MergeRecyclerAdapter; +import me.grishka.appkit.utils.SingleViewRecyclerAdapter; +import me.grishka.appkit.utils.V; + +public abstract class BaseEditListFragment extends BaseSettingsFragment{ + protected FollowList followList; + protected AvatarPileListItem membersItem; + protected CheckableListItem exclusiveItem; + protected FloatingHintEditTextLayout titleEditLayout; + protected EditText titleEdit; + protected Spinner showRepliesSpinner; + private APIRequest getMembersRequest; + + @Override + public void onCreate(Bundle savedInstanceState){ + super.onCreate(savedInstanceState); + followList=Parcels.unwrap(getArguments().getParcelable("list")); + + membersItem=new AvatarPileListItem<>(getString(R.string.list_members), null, List.of(), 0, i->onMembersClick(), null, false); + List> items=new ArrayList<>(); + if(followList!=null){ + items.add(membersItem); + } + exclusiveItem=new CheckableListItem<>(R.string.list_exclusive, R.string.list_exclusive_subtitle, CheckableListItem.Style.SWITCH, followList!=null && followList.exclusive, this::toggleCheckableItem); + items.add(exclusiveItem); + onDataLoaded(items); + } + + @Override + public void onDestroy(){ + super.onDestroy(); + if(getMembersRequest!=null) + getMembersRequest.cancel(); + } + + @Override + protected void doLoadData(int offset, int count){} + + @Override + protected RecyclerView.Adapter getAdapter(){ + LinearLayout topView=new LinearLayout(getActivity()); + topView.setOrientation(LinearLayout.VERTICAL); + + titleEditLayout=(FloatingHintEditTextLayout) getActivity().getLayoutInflater().inflate(R.layout.floating_hint_edit_text, topView, false); + titleEdit=titleEditLayout.findViewById(R.id.edit); + titleEdit.setHint(R.string.list_name); + titleEditLayout.updateHint(); + if(followList!=null) + titleEdit.setText(followList.title); + topView.addView(titleEditLayout); + + FloatingHintEditTextLayout showRepliesLayout=(FloatingHintEditTextLayout) getActivity().getLayoutInflater().inflate(R.layout.floating_hint_spinner, topView, false); + showRepliesSpinner=showRepliesLayout.findViewById(R.id.spinner); + showRepliesLayout.setHint(R.string.list_show_replies_to); + topView.addView(showRepliesLayout); + ArrayAdapter spinnerAdapter=new ArrayAdapter<>(getActivity(), R.layout.item_spinner, List.of( + getString(R.string.list_replies_no_one), + getString(R.string.list_replies_members), + getString(R.string.list_replies_anyone) + )); + showRepliesSpinner.setAdapter(spinnerAdapter); + showRepliesSpinner.setSelection(switch(followList!=null ? followList.repliesPolicy : FollowList.RepliesPolicy.LIST){ + case FOLLOWED -> 2; + case LIST -> 1; + case NONE -> 0; + }); + ViewGroup.MarginLayoutParams llp=(ViewGroup.MarginLayoutParams)showRepliesLayout.getLabel().getLayoutParams(); + llp.setMarginStart(llp.getMarginStart()+V.dp(16)); + + MergeRecyclerAdapter adapter=new MergeRecyclerAdapter(); + adapter.addAdapter(new SingleViewRecyclerAdapter(topView)); + adapter.addAdapter(super.getAdapter()); + return adapter; + } + + @Override + protected int indexOfItemsAdapter(){ + return 1; + } + + protected void doDeleteList(){ + new DeleteList(followList.id) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(Void result){ + AccountSessionManager.get(accountID).getCacheController().deleteList(followList.id); + E.post(new ListDeletedEvent(accountID, followList.id)); + Nav.finish(BaseEditListFragment.this); + } + + @Override + public void onError(ErrorResponse error){ + Activity activity=getActivity(); + if(activity==null) + return; + error.showToast(activity); + } + }) + .wrapProgress(getActivity(), R.string.loading, true) + .exec(accountID); + } + + private void onMembersClick(){ + Bundle args=new Bundle(); + args.putString("account", accountID); + args.putParcelable("list", Parcels.wrap(followList)); + Nav.go(getActivity(), ListMembersFragment.class, args); + } + + protected void loadMembers(){ + getMembersRequest=new GetListAccounts(followList.id, null, 3) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(HeaderPaginationList result){ + getMembersRequest=null; + membersItem.avatars=new ArrayList<>(); + for(int i=0;i FollowList.RepliesPolicy.NONE; + case 1 -> FollowList.RepliesPolicy.LIST; + case 2 -> FollowList.RepliesPolicy.FOLLOWED; + default -> throw new IllegalStateException("Unexpected value: "+showRepliesSpinner.getSelectedItemPosition()); + }; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseStatusListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseStatusListFragment.java index 6e7fc376d..e9f66df60 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseStatusListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseStatusListFragment.java @@ -9,6 +9,7 @@ import android.graphics.Rect; import android.graphics.drawable.Drawable; import android.os.Build; import android.os.Bundle; +import android.util.Pair; import android.view.View; import android.view.ViewGroup; import android.view.WindowInsets; @@ -16,9 +17,14 @@ import android.view.animation.TranslateAnimation; import android.widget.ImageButton; import android.widget.Toolbar; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.RecyclerView; + import org.joinmastodon.android.E; import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.R; +import org.joinmastodon.android.api.CacheController; import org.joinmastodon.android.api.MastodonAPIRequest; import org.joinmastodon.android.api.requests.accounts.GetAccountRelationships; import org.joinmastodon.android.api.requests.polls.SubmitPollVote; @@ -29,12 +35,15 @@ import org.joinmastodon.android.events.PollUpdatedEvent; import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.AkkomaTranslation; import org.joinmastodon.android.model.DisplayItemsParent; +import org.joinmastodon.android.model.Notification; import org.joinmastodon.android.model.Poll; import org.joinmastodon.android.model.Relationship; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.model.Translation; import org.joinmastodon.android.ui.BetterItemAnimator; import org.joinmastodon.android.ui.M3AlertDialogBuilder; +import org.joinmastodon.android.ui.NonMutualPreReplySheet; +import org.joinmastodon.android.ui.OldPostPreReplySheet; import org.joinmastodon.android.ui.displayitems.AccountStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.ExtendedFooterStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.FooterStatusDisplayItem; @@ -44,6 +53,7 @@ import org.joinmastodon.android.ui.displayitems.HeaderStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.MediaGridStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.PollFooterStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.PollOptionStatusDisplayItem; +import org.joinmastodon.android.ui.displayitems.PreviewlessMediaGridStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.SpoilerStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.StatusDisplayItem; import org.joinmastodon.android.ui.displayitems.TextStatusDisplayItem; @@ -52,12 +62,14 @@ import org.joinmastodon.android.ui.photoviewer.PhotoViewer; import org.joinmastodon.android.ui.photoviewer.PhotoViewerHost; import org.joinmastodon.android.ui.utils.InsetStatusItemDecoration; import org.joinmastodon.android.ui.utils.MediaAttachmentViewController; +import org.joinmastodon.android.ui.utils.PreviewlessMediaAttachmentViewController; import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.utils.ProvidesAssistContent; import org.joinmastodon.android.utils.TypedObjectPool; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; -import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Locale; @@ -66,10 +78,6 @@ import java.util.Set; import java.util.function.Consumer; import java.util.stream.Collectors; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.recyclerview.widget.RecyclerView; - import me.grishka.appkit.Nav; import me.grishka.appkit.api.Callback; import me.grishka.appkit.api.ErrorResponse; @@ -92,6 +100,8 @@ public abstract class BaseStatusListFragment exten protected HashMap relationships=new HashMap<>(); protected Rect tmpRect=new Rect(); protected TypedObjectPool attachmentViewsPool=new TypedObjectPool<>(this::makeNewMediaAttachmentView); + protected TypedObjectPool previewlessAttachmentViewsPool=new TypedObjectPool<>(this::makeNewPreviewlessMediaAttachmentView); + protected boolean currentlyScrolling; protected String maxID; @@ -135,6 +145,7 @@ public abstract class BaseStatusListFragment exten for(T s:items){ displayItems.addAll(buildDisplayItems(s)); } + loadRelationships(items.stream().map(DisplayItemsParent::getAccountID).filter(Objects::nonNull).collect(Collectors.toSet())); } @Override @@ -156,6 +167,7 @@ public abstract class BaseStatusListFragment exten } if(notify) adapter.notifyItemRangeInserted(0, offset); + loadRelationships(items.stream().map(DisplayItemsParent::getAccountID).filter(Objects::nonNull).collect(Collectors.toSet())); return offset; } @@ -212,7 +224,7 @@ public abstract class BaseStatusListFragment exten @Override public void openPhotoViewer(String parentID, Status _status, int attachmentIndex, MediaGridStatusDisplayItem.Holder gridHolder){ final Status status=_status.getContentStatus(); - currentPhotoViewer=new PhotoViewer(getActivity(), status.mediaAttachments, attachmentIndex, new PhotoViewer.Listener(){ + currentPhotoViewer=new PhotoViewer(getActivity(), status.mediaAttachments, attachmentIndex, status, accountID, new PhotoViewer.Listener(){ private MediaAttachmentViewController transitioningHolder; @Override @@ -278,6 +290,7 @@ public abstract class BaseStatusListFragment exten @Override public void photoViewerDismissed(){ currentPhotoViewer=null; + gridHolder.itemView.setHasTransientState(false); } @Override @@ -289,6 +302,80 @@ public abstract class BaseStatusListFragment exten return gridHolder.getViewController(index); } }); + gridHolder.itemView.setHasTransientState(true); + } + + + public void openPreviewlessMediaPhotoViewer(String parentID, Status _status, int attachmentIndex, PreviewlessMediaGridStatusDisplayItem.Holder gridHolder){ + final Status status=_status.getContentStatus(); + currentPhotoViewer=new PhotoViewer(getActivity(), status.mediaAttachments, attachmentIndex, status, accountID, new PhotoViewer.Listener(){ + private PreviewlessMediaAttachmentViewController transitioningHolder; + + @Override + public void setPhotoViewVisibility(int index, boolean visible){ + + } + + @Override + public boolean startPhotoViewTransition(int index, @NonNull Rect outRect, @NonNull int[] outCornerRadius){ + PreviewlessMediaAttachmentViewController holder=findPhotoViewHolder(index); + if(holder!=null && list!=null){ + transitioningHolder=holder; + View view=transitioningHolder.inner; + int[] pos={0, 0}; + view.getLocationOnScreen(pos); + outRect.set(pos[0], pos[1], pos[0]+view.getWidth(), pos[1]+view.getHeight()); + list.setClipChildren(false); + gridHolder.setClipChildren(false); + transitioningHolder.view.setElevation(1f); + return true; + } + return false; + } + + @Override + public void setTransitioningViewTransform(float translateX, float translateY, float scale){ + View view=transitioningHolder.inner; + view.setTranslationX(translateX); + view.setTranslationY(translateY); + view.setScaleX(scale); + view.setScaleY(scale); + } + + @Override + public void endPhotoViewTransition(){ + View view=transitioningHolder.inner; + view.setTranslationX(0f); + view.setTranslationY(0f); + view.setScaleX(1f); + view.setScaleY(1f); + transitioningHolder.view.setElevation(0f); + if(list!=null) + list.setClipChildren(true); + gridHolder.setClipChildren(true); + transitioningHolder=null; + } + + @Nullable + @Override + public Drawable getPhotoViewCurrentDrawable(int index){ + return null; + } + + @Override + public void photoViewerDismissed(){ + currentPhotoViewer=null; + } + + @Override + public void onRequestPermissions(String[] permissions){ + requestPermissions(permissions, PhotoViewer.PERMISSION_REQUEST); + } + + private PreviewlessMediaAttachmentViewController findPhotoViewHolder(int index){ + return gridHolder.getViewController(index); + } + }); } @Override @@ -491,6 +578,25 @@ public abstract class BaseStatusListFragment exten } i++; } + + // This is a temporary measure to deal with the app crashing when the poll isn't updated. + // This is needed because of a possible id mismatch that screws with things + if(firstOptionIndex==-1 || footerIndex==-1){ + for(StatusDisplayItem item:displayItems){ + if(status.id.equals(itemID)){ + if(item instanceof SpoilerStatusDisplayItem){ + spoilerItem=(SpoilerStatusDisplayItem) item; + }else if(item instanceof PollOptionStatusDisplayItem && firstOptionIndex==-1){ + firstOptionIndex=i; + }else if(item instanceof PollFooterStatusDisplayItem){ + footerIndex=i; + break; + } + } + i++; + } + } + if(firstOptionIndex==-1 || footerIndex==-1) throw new IllegalStateException("Can't find all poll items in displayItems"); List pollItems=displayItems.subList(firstOptionIndex, footerIndex+1); @@ -552,11 +658,30 @@ public abstract class BaseStatusListFragment exten } public void onPollViewResultsButtonClick(PollFooterStatusDisplayItem.Holder holder, boolean shown){ - for(int i=0;i pollItems=displayItems.subList(firstOptionIndex, footerIndex+1); + + for(StatusDisplayItem item:pollItems){ + if (item instanceof PollOptionStatusDisplayItem) { + ((PollOptionStatusDisplayItem) item).isAnimating=true; + ((PollOptionStatusDisplayItem) item).showResults=shown; + adapter.notifyItemRangeChanged(firstOptionIndex, pollItems.size()); + } + } } protected void submitPollVote(String parentID, String pollID, List choices){ @@ -584,6 +709,42 @@ public abstract class BaseStatusListFragment exten toggleSpoiler(status, isForQuote, holder.getItemID()); } + public void updateStatusWithQuote(DisplayItemsParent parent) { + Pair items=findAllItemsOfParent(parent); + if (items==null) + return; + + // Only StatusListFragments/NotificationsListFragments can display status with quotes + assert (this instanceof StatusListFragment) || (this instanceof NotificationsListFragment); + List oldItems = displayItems.subList(items.first, items.second+1); + List newItems=this.buildDisplayItems((T) parent); + int prevSize=oldItems.size(); + oldItems.clear(); + displayItems.addAll(items.first, newItems); + + // Update the cache + final CacheController cache=AccountSessionManager.get(accountID).getCacheController(); + if (parent instanceof Status) { + cache.updateStatus((Status) parent); + } else if (parent instanceof Notification) { + cache.updateNotification((Notification) parent); + } + + adapter.notifyItemRangeRemoved(items.first, prevSize); + adapter.notifyItemRangeInserted(items.first, newItems.size()); + } + + public void removeStatus(DisplayItemsParent parent) { + Pair items=findAllItemsOfParent(parent); + if (items==null) + return; + + List statusDisplayItems = displayItems.subList(items.first, items.second+1); + int prevSize=statusDisplayItems.size(); + statusDisplayItems.clear(); + adapter.notifyItemRangeRemoved(items.first, prevSize); + } + public void onVisibilityIconClick(HeaderStatusDisplayItem.Holder holder) { Status status = holder.getItem().status; if(holder.getItem().hasVisibilityToggle) holder.animateVisibilityToggle(false); @@ -619,6 +780,8 @@ public abstract class BaseStatusListFragment exten displayItems.addAll(index+1, spoilerItem.contentItems); adapter.notifyItemRangeInserted(index+1, spoilerItem.contentItems.size()); }else{ + if(spoilers.size()>1 && !isForQuote && status.quote.spoilerRevealed) + toggleSpoiler(status.quote, true, itemID); displayItems.subList(index+1, index+1+spoilerItem.contentItems.size()).clear(); adapter.notifyItemRangeRemoved(index+1, spoilerItem.contentItems.size()); } @@ -631,19 +794,33 @@ public abstract class BaseStatusListFragment exten list.invalidateItemDecorations(); } - public void onEnableExpandable(TextStatusDisplayItem.Holder holder, boolean expandable) { + public void onEnableExpandable(TextStatusDisplayItem.Holder holder, boolean expandable, boolean isForQuote) { Status s=holder.getItem().status; if(s.textExpandable!=expandable && list!=null) { s.textExpandable=expandable; - HeaderStatusDisplayItem.Holder header=findHolderOfType(holder.getItemID(), HeaderStatusDisplayItem.Holder.class); - if(header!=null) header.bindCollapseButton(); + List headers=findAllHoldersOfType(holder.getItemID(), HeaderStatusDisplayItem.Holder.class); + if(headers!=null && !headers.isEmpty()){ + HeaderStatusDisplayItem.Holder header=headers.size() > 1 && isForQuote ? headers.get(1) : headers.get(0); + if(header!=null) header.bindCollapseButton(); + } } } - public void onToggleExpanded(Status status, String itemID) { + public void onToggleExpanded(Status status, boolean isForQuote, String itemID) { status.textExpanded = !status.textExpanded; - notifyItemChanged(itemID, TextStatusDisplayItem.class); - HeaderStatusDisplayItem.Holder header=findHolderOfType(itemID, HeaderStatusDisplayItem.Holder.class); + // TODO: simplify this to a single case + if(!isForQuote) + // using the adapter directly to update the item does not work for non-quoted texts + notifyItemChanged(itemID, TextStatusDisplayItem.class); + else{ + List textItems=findAllHoldersOfType(itemID, TextStatusDisplayItem.Holder.class); + TextStatusDisplayItem.Holder text=textItems.size()>1 ? textItems.get(1) : textItems.get(0); + adapter.notifyItemChanged(text.getAbsoluteAdapterPosition()); + } + List headers=findAllHoldersOfType(itemID, HeaderStatusDisplayItem.Holder.class); + if (headers.isEmpty()) + return; + HeaderStatusDisplayItem.Holder header=headers.size() > 1 && isForQuote ? headers.get(1) : headers.get(0); if(header!=null) header.animateExpandToggle(); else notifyItemChanged(itemID, HeaderStatusDisplayItem.class); } @@ -651,12 +828,14 @@ public abstract class BaseStatusListFragment exten public void onGapClick(GapStatusDisplayItem.Holder item, boolean downwards){} public void onWarningClick(WarningFilteredStatusDisplayItem.Holder warning){ - int startPos = warning.getAbsoluteAdapterPosition(); + WarningFilteredStatusDisplayItem filterItem=findItemOfType(warning.getItemID(), WarningFilteredStatusDisplayItem.class); + int startPos=displayItems.indexOf(filterItem); displayItems.remove(startPos); displayItems.addAll(startPos, warning.filteredItems); adapter.notifyItemRangeInserted(startPos, warning.filteredItems.size() - 1); if (startPos == 0) scrollToTop(); warning.getItem().status.filterRevealed = true; + list.invalidateItemDecorations(); } public void onFavoriteChanged(Status status, String itemID) { @@ -681,6 +860,9 @@ public abstract class BaseStatusListFragment exten } protected void loadRelationships(Set ids){ + if(ids.isEmpty()) + return; + ids=ids.stream().filter(id->!relationships.containsKey(id)).collect(Collectors.toSet()); if(ids.isEmpty()) return; // TODO somehow manage these and cancel outstanding requests on refresh @@ -774,6 +956,23 @@ public abstract class BaseStatusListFragment exten return null; } + @Nullable + protected Pair findAllItemsOfParent(DisplayItemsParent parent){ + int startIndex=-1; + int endIndex=-1; + for(int i=0; i> List findAllHoldersOfType(String id, Class type){ ArrayList holders=new ArrayList<>(); for(int i=0;i exten return new MediaAttachmentViewController(getActivity(), type); } + private PreviewlessMediaAttachmentViewController makeNewPreviewlessMediaAttachmentView(MediaGridStatusDisplayItem.GridItemType type){ + return new PreviewlessMediaAttachmentViewController(getActivity(), type); + } + public TypedObjectPool getAttachmentViewsPool(){ return attachmentViewsPool; } + public TypedObjectPool getPreviewlessAttachmentViewsPool(){ + return previewlessAttachmentViewsPool; + } + @Override public void onProvideAssistContent(AssistContent assistContent) { assistContent.setWebUri(getWebUri(getSession().getInstanceUri().buildUpon())); @@ -944,6 +1151,11 @@ public abstract class BaseStatusListFragment exten media.rebind(); } + PreviewlessMediaGridStatusDisplayItem.Holder previewLessMedia=findHolderOfType(itemID, PreviewlessMediaGridStatusDisplayItem.Holder.class); + if (previewLessMedia!=null) { + previewLessMedia.rebind(); + } + for(int i=0;i exten adapter.notifyDataSetChanged(); } + public void maybeShowPreReplySheet(Status status, Runnable proceed){ + Relationship rel=getRelationship(status.account.id); + if(!GlobalUserPreferences.isOptedOutOfPreReplySheet(GlobalUserPreferences.PreReplySheetType.NON_MUTUAL, status.account, accountID) && + !status.account.id.equals(AccountSessionManager.get(accountID).self.id) && rel!=null && !rel.followedBy && status.account.followingCount>=1){ + new NonMutualPreReplySheet(getActivity(), notAgain->{ + GlobalUserPreferences.optOutOfPreReplySheet(GlobalUserPreferences.PreReplySheetType.NON_MUTUAL, notAgain ? null : status.account, accountID); + proceed.run(); + }, status.account, accountID).show(); + }else if(!GlobalUserPreferences.isOptedOutOfPreReplySheet(GlobalUserPreferences.PreReplySheetType.OLD_POST, null, null) && + status.createdAt.isBefore(Instant.now().minus(90, ChronoUnit.DAYS))){ + new OldPostPreReplySheet(getActivity(), notAgain->{ + if(notAgain) + GlobalUserPreferences.optOutOfPreReplySheet(GlobalUserPreferences.PreReplySheetType.OLD_POST, null, null); + proceed.run(); + }, status).show(); + }else{ + proceed.run(); + } + } + protected void onModifyItemViewHolder(BindableViewHolder holder){} @Override @@ -1020,9 +1252,9 @@ public abstract class BaseStatusListFragment exten private Paint dividerPaint=new Paint(); { - dividerPaint.setColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3OutlineVariant)); + dividerPaint.setColor(UiUtils.getThemeColor(getActivity(), GlobalUserPreferences.showDividers ? R.attr.colorM3OutlineVariant : R.attr.colorM3Surface)); dividerPaint.setStyle(Paint.Style.STROKE); - dividerPaint.setStrokeWidth(V.dp(0.5f)); + dividerPaint.setStrokeWidth(V.dp(1f)); } @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java index 71527154c..d224f9217 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java @@ -5,12 +5,18 @@ import static org.joinmastodon.android.GlobalUserPreferences.PrefixRepliesMode.T import static org.joinmastodon.android.api.requests.statuses.CreateStatus.DRAFTS_AFTER_INSTANT; import static org.joinmastodon.android.api.requests.statuses.CreateStatus.getDraftInstant; +import android.Manifest; +import android.animation.ObjectAnimator; +import android.animation.Animator; +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; import android.annotation.SuppressLint; import android.app.Activity; import android.app.DatePickerDialog; import android.app.TimePickerDialog; import android.content.ClipData; import android.content.Intent; +import android.content.pm.PackageManager; import android.content.res.Configuration; import android.graphics.Outline; import android.graphics.PixelFormat; @@ -53,11 +59,13 @@ import android.widget.TextView; import android.widget.Toast; import com.github.bottomSoftwareFoundation.bottom.Bottom; +import com.squareup.otto.Subscribe; import com.twitter.twittertext.TwitterTextEmojiRegex; import org.joinmastodon.android.E; import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.R; +import org.joinmastodon.android.TweakedFileProvider; import org.joinmastodon.android.api.MastodonErrorResponse; import org.joinmastodon.android.api.requests.statuses.CreateStatus; import org.joinmastodon.android.api.requests.statuses.DeleteStatus; @@ -65,12 +73,13 @@ import org.joinmastodon.android.api.requests.statuses.EditStatus; import org.joinmastodon.android.api.session.AccountLocalPreferences; import org.joinmastodon.android.api.session.AccountSession; import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.events.TakePictureRequestEvent; import org.joinmastodon.android.events.ScheduledStatusCreatedEvent; import org.joinmastodon.android.events.ScheduledStatusDeletedEvent; import org.joinmastodon.android.events.StatusCountersUpdatedEvent; import org.joinmastodon.android.events.StatusCreatedEvent; import org.joinmastodon.android.events.StatusUpdatedEvent; -import org.joinmastodon.android.fragments.account_list.ComposeAccountSearchFragment; +import org.joinmastodon.android.fragments.account_list.AccountSearchFragment; import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.ContentType; import org.joinmastodon.android.model.Emoji; @@ -90,6 +99,8 @@ import org.joinmastodon.android.ui.text.ComposeAutocompleteSpan; import org.joinmastodon.android.ui.text.ComposeHashtagOrMentionSpan; import org.joinmastodon.android.ui.text.HtmlParser; import org.joinmastodon.android.ui.utils.SimpleTextWatcher; +import org.joinmastodon.android.utils.Tracking; +import org.joinmastodon.android.utils.TransferSpeedTracker; import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.ui.viewcontrollers.ComposeAutocompleteViewController; import org.joinmastodon.android.ui.viewcontrollers.ComposeLanguageAlertViewController; @@ -102,6 +113,12 @@ import org.joinmastodon.android.utils.MastodonLanguage; import org.joinmastodon.android.utils.StatusTextEncoder; import org.parceler.Parcels; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InterruptedIOException; +import java.net.SocketException; +import java.net.UnknownHostException; import java.time.Instant; import java.time.LocalDateTime; import java.time.OffsetDateTime; @@ -122,12 +139,14 @@ import java.util.stream.Collectors; import me.grishka.appkit.Nav; import me.grishka.appkit.api.Callback; import me.grishka.appkit.api.ErrorResponse; +import me.grishka.appkit.fragments.CustomTransitionsFragment; import me.grishka.appkit.fragments.OnBackPressedListener; import me.grishka.appkit.imageloader.ViewImageLoader; import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest; +import me.grishka.appkit.utils.CubicBezierInterpolator; import me.grishka.appkit.utils.V; -public class ComposeFragment extends MastodonToolbarFragment implements OnBackPressedListener, ComposeEditText.SelectionListener, HasAccountID { +public class ComposeFragment extends MastodonToolbarFragment implements OnBackPressedListener, ComposeEditText.SelectionListener, HasAccountID, CustomTransitionsFragment { private static final int MEDIA_RESULT=717; public static final int IMAGE_DESCRIPTION_RESULT=363; @@ -138,6 +157,8 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr private static final Pattern GLITCH_LOCAL_ONLY_PATTERN = Pattern.compile("[\\s\\S]*" + GLITCH_LOCAL_ONLY_SUFFIX + "[\uFE00-\uFE0F]*"); private static final String TAG="ComposeFragment"; + public static final int CAMERA_PERMISSION_CODE = 626938; + public static final int CAMERA_PIC_REQUEST_CODE = 6242069; private static final Pattern MENTION_PATTERN=Pattern.compile("(^|[^\\/\\w])@(([a-z0-9_]+)@[a-z0-9\\.\\-]+[a-z0-9]+)", Pattern.CASE_INSENSITIVE); @@ -162,7 +183,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr private Button publishButton, languageButton, scheduleTimeBtn; private PopupMenu contentTypePopup, visibilityPopup, draftOptionsPopup; - private ImageButton mediaBtn, pollBtn, emojiBtn, spoilerBtn, draftsBtn, scheduleDraftDismiss, contentTypeBtn; + private ImageButton publishButtonRelocated, mediaBtn, pollBtn, emojiBtn, spoilerBtn, draftsBtn, scheduleDraftDismiss, contentTypeBtn; private View sensitiveBtn; private TextView replyText; private LinearLayout scheduleDraftView; @@ -203,6 +224,8 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr public ScheduledStatus scheduledStatus; private boolean redraftStatus; + private Uri photoUri; + private ContentType contentType; private MastodonLanguage.LanguageResolver languageResolver; @@ -214,7 +237,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr private BackgroundColorSpan overLimitBG; private ForegroundColorSpan overLimitFG; - + public ComposeFragment(){ super(R.layout.toolbar_fragment_with_progressbar); } @@ -222,6 +245,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr @Override public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); + E.register(this); setRetainInstance(true); accountID=getArguments().getString("account"); @@ -270,6 +294,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr @Override public void onDestroy(){ super.onDestroy(); + E.unregister(this); mediaViewController.cancelAllUploads(); } @@ -315,11 +340,30 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr }); View view=inflater.inflate(R.layout.fragment_compose, container, false); + + if(GlobalUserPreferences.relocatePublishButton){ + publishButtonRelocated=view.findViewById(R.id.publish); +// publishButton.setText(editingStatus==null || redraftStatus ? R.string.publish : R.string.save); +// publishButton.setEllipsize(TextUtils.TruncateAt.END); + publishButtonRelocated.setOnClickListener(v -> { + if(GlobalUserPreferences.altTextReminders && editingStatus==null) + checkAltTextsAndPublish(); + else + publish(); + }); + publishButtonRelocated.setVisibility(View.VISIBLE); + + draftsBtn=view.findViewById(R.id.drafts_btn); + draftsBtn.setVisibility(View.VISIBLE); + } else { + charCounter=view.findViewById(R.id.char_counter); + charCounter.setVisibility(View.VISIBLE); + charCounter.setText(String.valueOf(charLimit)); + } + mainLayout=view.findViewById(R.id.compose_main_ll); mainEditText=view.findViewById(R.id.toot_text); mainEditTextWrap=view.findViewById(R.id.toot_text_wrap); - charCounter=view.findViewById(R.id.char_counter); - charCounter.setText(String.valueOf(charLimit)); scrollView=view.findViewById(R.id.scroll_view); selfName=view.findViewById(R.id.self_name); @@ -353,19 +397,27 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr sensitiveBtn=view.findViewById(R.id.sensitive_item); replyText=view.findViewById(R.id.reply_text); - if (UiUtils.isPhotoPickerAvailable()) { - PopupMenu attachPopup = new PopupMenu(getContext(), mediaBtn); - attachPopup.inflate(R.menu.attach); - attachPopup.setOnMenuItemClickListener(i -> { + PopupMenu attachPopup = new PopupMenu(getContext(), mediaBtn); + attachPopup.inflate(R.menu.attach); + if(UiUtils.isPhotoPickerAvailable()) + attachPopup.getMenu().findItem(R.id.media).setVisible(true); + + attachPopup.setOnMenuItemClickListener(i -> { + if (i.getItemId() == R.id.camera){ + try { + openCamera(); + } catch (IOException e){ + Toast.makeText(getContext(), e.getMessage(), Toast.LENGTH_SHORT); + } + + } else { openFilePicker(i.getItemId() == R.id.media); - return true; - }); - UiUtils.enablePopupMenuIcons(getContext(), attachPopup); - mediaBtn.setOnClickListener(v->attachPopup.show()); - mediaBtn.setOnTouchListener(attachPopup.getDragToOpenListener()); - } else { - mediaBtn.setOnClickListener(v -> openFilePicker(false)); - } + } + return true; + }); + UiUtils.enablePopupMenuIcons(getContext(), attachPopup); + mediaBtn.setOnClickListener(v->attachPopup.show()); + mediaBtn.setOnTouchListener(attachPopup.getDragToOpenListener()); if (isInstancePixelfed()) pollBtn.setVisibility(View.GONE); pollBtn.setOnClickListener(v->togglePoll()); emojiBtn.setOnClickListener(v->emojiKeyboard.toggleKeyboardPopup(mainEditText)); @@ -461,7 +513,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr } int typeIndex=contentType.ordinal(); - if(contentTypePopup.getMenu().findItem(typeIndex)!=null) + if (contentTypePopup.getMenu().findItem(typeIndex) != null) contentTypePopup.getMenu().findItem(typeIndex).setChecked(true); contentTypeBtn.setSelected(typeIndex != ContentType.UNSPECIFIED.ordinal() && typeIndex != ContentType.PLAIN.ordinal()); @@ -482,7 +534,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr public void onLaunchAccountSearch(){ Bundle args=new Bundle(); args.putString("account", accountID); - Nav.goForResult(getActivity(), ComposeAccountSearchFragment.class, args, AUTOCOMPLETE_ACCOUNT_RESULT, ComposeFragment.this); + Nav.goForResult(getActivity(), AccountSearchFragment.class, args, AUTOCOMPLETE_ACCOUNT_RESULT, ComposeFragment.this); } }); View autocompleteView=autocompleteViewController.getView(); @@ -513,6 +565,19 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr } } + + @Override + public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + + if (requestCode == CAMERA_PERMISSION_CODE && (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED)) { + Intent cameraIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE); + startActivityForResult(cameraIntent, CAMERA_PIC_REQUEST_CODE); + } else { + Toast.makeText(getContext(), R.string.permission_required, Toast.LENGTH_SHORT); + } + } + @Override public void onResume(){ super.onResume(); @@ -713,11 +778,17 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr replyText.setOnClickListener(v->{ scrollView.smoothScrollTo(0, 0); }); + replyText.setOnClickListener(v->{ + scrollView.smoothScrollTo(0, 0); + }); + ArrayList mentions=new ArrayList<>(); String ownID=AccountSessionManager.getInstance().getAccount(accountID).self.id; if(!status.account.id.equals(ownID)) mentions.add('@'+status.account.acct); + if(status.rebloggedBy != null && GlobalUserPreferences.mentionRebloggerAutomatically) + mentions.add('@'+status.rebloggedBy.acct); for(Mention mention:status.mentions){ if(mention.id.equals(ownID)) continue; @@ -808,7 +879,25 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr actionItem.setActionView(wrap); actionItem.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS); - draftsBtn=wrap.findViewById(R.id.drafts_btn); + if(!GlobalUserPreferences.relocatePublishButton){ + publishButton = wrap.findViewById(R.id.publish_btn); + publishButton.setOnClickListener(v -> { + if(GlobalUserPreferences.altTextReminders && editingStatus==null) + checkAltTextsAndPublish(); + else + publish(); + }); + publishButton.setVisibility(View.VISIBLE); + + draftsBtn = wrap.findViewById(R.id.drafts_btn); + draftsBtn.setVisibility(View.VISIBLE); + }else{ + charCounter = wrap.findViewById(R.id.char_counter); + charCounter.setVisibility(View.VISIBLE); + charCounter.setText(String.valueOf(charLimit)); + } + +// draftsBtn=wrap.findViewById(R.id.drafts_btn); draftOptionsPopup=new PopupMenu(getContext(), draftsBtn); draftOptionsPopup.inflate(R.menu.compose_more); Menu draftOptionsMenu=draftOptionsPopup.getMenu(); @@ -828,8 +917,8 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr }); UiUtils.enablePopupMenuIcons(getContext(), draftOptionsPopup); - publishButton=wrap.findViewById(R.id.publish_btn); - languageButton=wrap.findViewById(R.id.language_btn); + + languageButton = wrap.findViewById(R.id.language_btn); if(instance.isIceshrimpJs()) languageButton.setVisibility(View.GONE); else { @@ -842,9 +931,10 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr return false; }); } - publishButton.post(()->publishButton.setMinimumWidth(publishButton.getWidth())); + if (!GlobalUserPreferences.relocatePublishButton) + publishButton.post(()->publishButton.setMinimumWidth(publishButton.getWidth())); - publishButton.setOnClickListener(v->{ + (GlobalUserPreferences.relocatePublishButton ? publishButtonRelocated : publishButton).setOnClickListener(v->{ Consumer draftCheckComplete=(isDraft)->{ if(GlobalUserPreferences.altTextReminders && !isDraft) checkAltTextsAndPublish(); else publish(); @@ -952,6 +1042,9 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr private void resetPublishButtonText() { int publishText = editingStatus==null || redraftStatus ? R.string.publish : R.string.save; + if(GlobalUserPreferences.relocatePublishButton){ + return; + } AccountLocalPreferences prefs=AccountSessionManager.get(accountID).getLocalPreferences(); if (publishText == R.string.publish && !TextUtils.isEmpty(prefs.publishButtonText)) { publishButton.setText(prefs.publishButtonText); @@ -962,6 +1055,10 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr public void updatePublishButtonState(){ uuid=null; + if(GlobalUserPreferences.relocatePublishButton && publishButtonRelocated != null){ + publishButtonRelocated.setEnabled((!isInstancePixelfed() || !mediaViewController.isEmpty()) && (trimmedCharCount>0 || !mediaViewController.isEmpty()) && charCount<=charLimit && mediaViewController.getNonDoneAttachmentCount()==0 && (pollViewController.isEmpty() || pollViewController.getNonEmptyOptionsCount()>1)); + } + if(publishButton==null) return; publishButton.setEnabled((!isInstancePixelfed() || !mediaViewController.isEmpty()) && (trimmedCharCount>0 || !mediaViewController.isEmpty()) && charCount<=charLimit && mediaViewController.getNonDoneAttachmentCount()==0 && (pollViewController.isEmpty() || pollViewController.getNonEmptyOptionsCount()>1)); @@ -1073,7 +1170,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr overlayParams.token=mainEditText.getWindowToken(); wm.addView(sendingOverlay, overlayParams); - publishButton.setEnabled(false); + (GlobalUserPreferences.relocatePublishButton ? publishButtonRelocated : publishButton).setEnabled(false); V.setVisibilityAnimated(sendProgress, View.VISIBLE); mediaViewController.saveAltTextsBeforePublishing( @@ -1083,6 +1180,8 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr private void actuallyPublish(boolean preview){ String text=mainEditText.getText().toString(); + if(GlobalUserPreferences.removeTrackingParams) + text=Tracking.cleanUrlsInText(text); CreateStatus.Request req=new CreateStatus.Request(); if("bottom".equals(postLang.encoding)){ text=new StatusTextEncoder(Bottom::encode).encode(text); @@ -1207,7 +1306,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr .setPositiveButton(R.string.ok, (a, b)->{}) .show(); handlePublishError(null); - publishButton.setEnabled(false); + (GlobalUserPreferences.relocatePublishButton ? publishButtonRelocated : publishButton).setEnabled(false); } if (replyTo == null) updateRecentLanguages(); @@ -1217,7 +1316,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr wm.removeView(sendingOverlay); sendingOverlay=null; V.setVisibilityAnimated(sendProgress, View.GONE); - publishButton.setEnabled(true); + (GlobalUserPreferences.relocatePublishButton ? publishButtonRelocated : publishButton).setEnabled(true); if(error instanceof MastodonErrorResponse me){ new M3AlertDialogBuilder(getActivity()) .setTitle(R.string.post_failed) @@ -1234,7 +1333,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr result.preview=true; wm.removeView(sendingOverlay); sendingOverlay=null; - publishButton.setEnabled(true); + (GlobalUserPreferences.relocatePublishButton ? publishButtonRelocated : publishButton).setEnabled(true); V.setVisibilityAnimated(sendProgress, View.GONE); InputMethodManager imm=getActivity().getSystemService(InputMethodManager.class); imm.hideSoftInputFromWindow(contentView.getWindowToken(), 0); @@ -1392,6 +1491,39 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr } } } + + if(requestCode==CAMERA_PIC_REQUEST_CODE && resultCode==Activity.RESULT_OK){ + onAddMediaAttachmentFromEditText(photoUri, null); + } + } + + @Subscribe + public void onTakePictureRequest(TakePictureRequestEvent ev) { + if(isVisible()) { + try { + openCamera(); + } catch (IOException e) { + Toast.makeText(getContext(), e.getMessage(), Toast.LENGTH_SHORT); + } + + } + } + + private void openCamera() throws IOException { + if (getContext().checkSelfPermission(Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { + File photoFile = File.createTempFile("img", ".jpg"); + photoUri = UiUtils.getFileProviderUri(getContext(), photoFile); + + Intent cameraIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE); + cameraIntent.putExtra(MediaStore.EXTRA_OUTPUT, photoUri); + if(getContext().getPackageManager().hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY)){ + startActivityForResult(cameraIntent, CAMERA_PIC_REQUEST_CODE); + } else { + Toast.makeText(getContext(), R.string.mo_camera_not_available, Toast.LENGTH_SHORT); + } + } else { + getActivity().requestPermissions(new String[]{Manifest.permission.CAMERA}, CAMERA_PERMISSION_CODE); + } } @@ -1431,7 +1563,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr public void updateSensitive() { sensitiveBtn.setVisibility(View.GONE); - if (!mediaViewController.isEmpty() && !hasSpoiler) sensitiveBtn.setVisibility(View.VISIBLE); + if (!mediaViewController.isEmpty()) sensitiveBtn.setVisibility(View.VISIBLE); if (mediaViewController.isEmpty()) sensitive = false; } @@ -1468,9 +1600,15 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr scheduleDraftDismiss.setTooltipText(getString(R.string.sk_compose_no_draft)); } scheduleDraftDismiss.setContentDescription(getString(R.string.sk_compose_no_draft)); - draftsBtn.setImageResource(R.drawable.ic_fluent_drafts_20_filled); - publishButton.setText(scheduledStatus != null && scheduledStatus.scheduledAt.isAfter(DRAFTS_AFTER_INSTANT) - ? R.string.save : R.string.sk_draft); + draftsBtn.setImageDrawable(getContext().getDrawable(GlobalUserPreferences.relocatePublishButton ? R.drawable.ic_fluent_drafts_24_regular : R.drawable.ic_fluent_drafts_20_filled)); + + if(GlobalUserPreferences.relocatePublishButton){ + publishButtonRelocated.setImageResource(scheduledStatus != null && scheduledStatus.scheduledAt.isAfter(DRAFTS_AFTER_INSTANT) + ? R.drawable.ic_fluent_save_24_selector : R.drawable.ic_fluent_drafts_24_selector); + }else{ + publishButton.setText(scheduledStatus != null && scheduledStatus.scheduledAt.isAfter(DRAFTS_AFTER_INSTANT) + ? R.string.save : R.string.sk_draft); + } } else { scheduleMenuItem.setVisible(false); unscheduleMenuItem.setVisible(true); @@ -1483,12 +1621,21 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr scheduleDraftDismiss.setTooltipText(getString(R.string.sk_compose_no_schedule)); } scheduleDraftDismiss.setContentDescription(getString(R.string.sk_compose_no_schedule)); - draftsBtn.setImageResource(R.drawable.ic_fluent_clock_20_filled); - publishButton.setText(scheduledStatus != null && scheduledStatus.scheduledAt.equals(scheduledAt) - ? R.string.save : R.string.sk_schedule); + draftsBtn.setImageDrawable(getContext().getDrawable(GlobalUserPreferences.relocatePublishButton ? R.drawable.ic_fluent_clock_24_filled : R.drawable.ic_fluent_clock_20_filled)); + if(GlobalUserPreferences.relocatePublishButton) + { + publishButtonRelocated.setImageResource(scheduledStatus != null && scheduledStatus.scheduledAt.isAfter(DRAFTS_AFTER_INSTANT) + ? R.drawable.ic_fluent_save_24_selector : R.drawable.ic_fluent_clock_24_selector); + }else{ + publishButton.setText(scheduledStatus != null && scheduledStatus.scheduledAt.equals(scheduledAt) + ? R.string.save : R.string.sk_schedule); + } } } else { - draftsBtn.setImageResource(R.drawable.ic_fluent_clock_20_regular); + draftsBtn.setImageDrawable(getContext().getDrawable(GlobalUserPreferences.relocatePublishButton ? R.drawable.ic_fluent_clock_24_regular : R.drawable.ic_fluent_clock_20_regular)); + if(GlobalUserPreferences.relocatePublishButton){ + publishButtonRelocated.setImageResource(R.drawable.ic_fluent_send_24_regular); + } resetPublishButtonText(); } } @@ -1523,7 +1670,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr } } UiUtils.enablePopupMenuIcons(getActivity(), visibilityPopup); - if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.P) m.setGroupDividerEnabled(true); + if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.P && !UiUtils.isEMUI() && !UiUtils.isMagic()) m.setGroupDividerEnabled(true); visibilityPopup.setOnMenuItemClickListener(new PopupMenu.OnMenuItemClickListener(){ @Override public boolean onMenuItemClick(MenuItem item){ @@ -1599,8 +1746,10 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr menu.show(); } - private void loadDefaultStatusVisibility(Bundle savedInstanceState){ - if(replyTo != null) statusVisibility = replyTo.visibility; + private void loadDefaultStatusVisibility(Bundle savedInstanceState) { + if(replyTo != null) { + statusVisibility = (replyTo.visibility == StatusPrivacy.PUBLIC && GlobalUserPreferences.defaultToUnlistedReplies ? StatusPrivacy.UNLISTED : replyTo.visibility); + } AccountSessionManager asm = AccountSessionManager.getInstance(); Preferences prefs=asm.getAccount(accountID).preferences; @@ -1670,8 +1819,26 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr return new String[]{"image/jpeg", "image/gif", "image/png", "video/mp4"}; } + private String sanitizeMediaDescription(String description){ + if(description == null){ + return null; + } + + // The Gboard android keyboard attaches this text whenever the user + // pastes something from the keyboard's suggestion bar. + // Due to different end user locales, the exact text may vary, but at + // least in version 13.4.08, all of the translations contained the + // string "Gboard". + if (description.contains("Gboard")){ + return null; + } + + return description; + } + @Override public boolean onAddMediaAttachmentFromEditText(Uri uri, String description){ + description = sanitizeMediaDescription(description); return mediaViewController.addMediaAttachment(uri, description); } @@ -1714,6 +1881,8 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr Editable e=mainEditText.getText(); int start=e.getSpanStart(currentAutocompleteSpan); int end=e.getSpanEnd(currentAutocompleteSpan); + if(start==-1 || end==-1) + return; e.replace(start, end, text+" "); finishAutocomplete(); InputConnection conn=mainEditText.getCurrentInputConnection(); @@ -1782,4 +1951,35 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr languageButton.setText(opt.language.getLanguageName()); languageButton.setContentDescription(getActivity().getString(R.string.sk_post_language, opt.language.getDefaultName())); } + + @Override + public Animator onCreateEnterTransition(View prev, View container){ + AnimatorSet anim=new AnimatorSet(); + if(getArguments().getBoolean("fromThreadFragment")){ + anim.playTogether( + ObjectAnimator.ofFloat(container, View.ALPHA, 0f, 1f), + ObjectAnimator.ofFloat(container, View.TRANSLATION_Y, V.dp(200), 0) + ); + }else{ + anim.playTogether( + ObjectAnimator.ofFloat(container, View.ALPHA, 0f, 1f), + ObjectAnimator.ofFloat(container, View.TRANSLATION_X, V.dp(100), 0) + ); + } + anim.setDuration(300); + anim.setInterpolator(CubicBezierInterpolator.DEFAULT); + return anim; + } + + @Override + public Animator onCreateExitTransition(View prev, View container){ + AnimatorSet anim=new AnimatorSet(); + anim.playTogether( + ObjectAnimator.ofFloat(container, View.TRANSLATION_X, V.dp(100)), + ObjectAnimator.ofFloat(container, View.ALPHA, 0) + ); + anim.setDuration(200); + anim.setInterpolator(CubicBezierInterpolator.DEFAULT); + return anim; + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeImageDescriptionFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeImageDescriptionFragment.java index 733bc0815..a82f95592 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeImageDescriptionFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeImageDescriptionFragment.java @@ -7,10 +7,7 @@ import android.graphics.Rect; import android.graphics.drawable.Drawable; import android.media.MediaMetadataRetriever; import android.net.Uri; -import android.os.Build; import android.os.Bundle; -import android.text.SpannableStringBuilder; -import android.text.style.BulletSpan; import android.util.Log; import android.view.ContextThemeWrapper; import android.view.LayoutInflater; @@ -135,20 +132,9 @@ public class ComposeImageDescriptionFragment extends MastodonToolbarFragment imp @Override public boolean onOptionsItemSelected(MenuItem item){ if(item.getItemId()==R.id.help){ - SpannableStringBuilder msg=new SpannableStringBuilder(getText(R.string.alt_text_help)); - BulletSpan[] spans=msg.getSpans(0, msg.length(), BulletSpan.class); - for(BulletSpan span:spans){ - BulletSpan betterSpan; - if(Build.VERSION.SDK_INT accountIDsInList=new HashSet<>(); + + @Override + public void onCreate(Bundle savedInstanceState){ + super.onCreate(savedInstanceState); + setTitle(R.string.manage_list_members); + setSubtitle(getString(R.string.step_x_of_y, 2, 2)); + setLayout(R.layout.fragment_login); + setEmptyText(R.string.list_no_members); + setHasOptionsMenu(true); + + followList=Parcels.unwrap(getArguments().getParcelable("list")); + if(savedInstanceState!=null || getArguments().getBoolean("needLoadMembers", false)){ + loadData(); + }else{ + onDataLoaded(List.of()); + } + } + + @Override + protected void doLoadData(int offset, int count){ + currentRequest=new GetListAccounts(followList.id, null, 0) + .setCallback(new SimpleCallback<>(this){ + @Override + public void onSuccess(HeaderPaginationList result){ + for(Account acc:result) + accountIDsInList.add(acc.id); + onDataLoaded(result.stream().map(a->new AccountViewModel(a, accountID)).collect(Collectors.toList())); + } + }) + .exec(accountID); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState){ + View view=super.onCreateView(inflater, container, savedInstanceState); + FrameLayout wrapper=new FrameLayout(getActivity()); + wrapper.addView(view); + rootView=(FragmentRootLinearLayout) view; + fragmentContentWrap=wrapper; + return wrapper; + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState){ + nextButton=view.findViewById(R.id.btn_next); + nextButton.setOnClickListener(this::onNextClick); + nextButton.setText(R.string.done); + buttonBar=view.findViewById(R.id.button_bar); + + super.onViewCreated(view, savedInstanceState); + } + + @Override + public void onApplyWindowInsets(WindowInsets insets){ + lastInsets=insets; + if(searchFragment!=null) + searchFragment.onApplyWindowInsets(insets); + insets=UiUtils.applyBottomInsetToFixedView(buttonBar, insets); + rootView.dispatchApplyWindowInsets(insets); + } + + @Override + protected List getViewsForElevationEffect(){ + return List.of(getToolbar(), buttonBar); + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){ + MenuItem item=menu.add(R.string.add_list_member); + item.setIcon(R.drawable.ic_fluent_add_24_regular); + item.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item){ + if(searchFragmentContainer!=null) + return true; + + searchFragmentContainer=new FrameLayout(getActivity()); + searchFragmentContainer.setId(R.id.search_fragment); + fragmentContentWrap.addView(searchFragmentContainer); + + Bundle args=new Bundle(); + args.putString("account", accountID); + args.putParcelable("list", Parcels.wrap(followList)); + args.putBoolean("_can_go_back", true); + searchFragment=new AddNewListMembersFragment(this); + searchFragment.setArguments(args); + getChildFragmentManager().beginTransaction().add(R.id.search_fragment, searchFragment).commit(); + getChildFragmentManager().executePendingTransactions(); + if(lastInsets!=null) + searchFragment.onApplyWindowInsets(lastInsets); + searchFragmentContainer.setTranslationX(V.dp(100)); + searchFragmentContainer.setAlpha(0f); + searchFragmentContainer.animate().translationX(0).alpha(1).setDuration(300).withLayer().setInterpolator(CubicBezierInterpolator.DEFAULT).withEndAction(()->{ + rootView.setVisibility(View.GONE); + }).start(); + return true; + } + + @Override + protected void initializeEmptyView(View contentView){ + ViewStub emptyStub=contentView.findViewById(R.id.empty); + emptyStub.setLayoutResource(R.layout.empty_with_arrow); + super.initializeEmptyView(contentView); + TextView emptySecondary=contentView.findViewById(R.id.empty_text_secondary); + emptySecondary.setText(R.string.list_find_users); + CurlyArrowEmptyView arrowView=(CurlyArrowEmptyView) emptyView; + arrowView.setGravityAndOffsets(Gravity.TOP | Gravity.END, 24, 2); + } + + @Override + protected void setStatusBarColor(int color){ + rootView.setStatusBarColor(color); + } + + @Override + protected void setNavigationBarColor(int color){ + rootView.setNavigationBarColor(color); + } + + private void dismissSearchFragment(){ + if(searchFragment==null || dismissingSearchFragment) + return; + dismissingSearchFragment=true; + rootView.setVisibility(View.VISIBLE); + searchFragmentContainer.animate().translationX(V.dp(100)).alpha(0).setDuration(200).withLayer().setInterpolator(CubicBezierInterpolator.DEFAULT).withEndAction(()->{ + getChildFragmentManager().beginTransaction().remove(searchFragment).commit(); + getChildFragmentManager().executePendingTransactions(); + fragmentContentWrap.removeView(searchFragmentContainer); + searchFragmentContainer=null; + searchFragment=null; + dismissingSearchFragment=false; + }).start(); + getActivity().getSystemService(InputMethodManager.class).hideSoftInputFromWindow(contentView.getWindowToken(), 0); + } + + private void onNextClick(View v){ + E.post(new FinishListCreationFragmentEvent(accountID, followList.id)); + Nav.finish(this); + } + + @Override + public boolean onBackPressed(){ + if(searchFragment!=null){ + dismissSearchFragment(); + return true; + } + return false; + } + + @Override + public boolean isAccountInList(AccountViewModel account){ + return accountIDsInList.contains(account.account.id); + } + + @Override + public void addAccountToList(AccountViewModel account, Runnable onDone){ + new AddAccountsToList(followList.id, Set.of(account.account.id)) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(Void result){ + accountIDsInList.add(account.account.id); + if(onDone!=null) + onDone.run(); + int i=0; + for(AccountViewModel acc:data){ + if(acc.account.id.equals(account.account.id)){ + list.getAdapter().notifyItemChanged(i); + return; + } + i++; + } + int pos=data.size(); + data.add(account); + list.getAdapter().notifyItemInserted(pos); + } + + @Override + public void onError(ErrorResponse error){ + error.showToast(getActivity()); + } + }) + .exec(accountID); + } + + @Override + public void removeAccountAccountFromList(AccountViewModel account, Runnable onDone){ + new RemoveAccountsFromList(followList.id, Set.of(account.account.id)) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(Void result){ + accountIDsInList.remove(account.account.id); + if(onDone!=null) + onDone.run(); + int i=0; + for(AccountViewModel acc:data){ + if(acc.account.id.equals(account.account.id)){ + list.getAdapter().notifyItemChanged(i); + return; + } + i++; + } + } + + @Override + public void onError(ErrorResponse error){ + error.showToast(getActivity()); + } + }) + .exec(accountID); + } + + @Override + protected void onConfigureViewHolder(AccountViewHolder holder){ + holder.setStyle(AccountViewHolder.AccessoryType.CUSTOM_BUTTON, false); + holder.setOnLongClickListener(vh->false); + Button button=holder.getButton(); + button.setPadding(V.dp(24), 0, V.dp(24), 0); + button.setMinimumWidth(0); + button.setMinWidth(0); + button.setOnClickListener(v->{ + holder.setActionProgressVisible(true); + holder.itemView.setHasTransientState(true); + Runnable onDone=()->{ + holder.setActionProgressVisible(false); + holder.itemView.setHasTransientState(false); + }; + AccountViewModel account=holder.getItem(); + if(isAccountInList(account)){ + removeAccountAccountFromList(account, onDone); + }else{ + addAccountToList(account, onDone); + } + }); + } + + @Override + protected void onBindViewHolder(AccountViewHolder holder){ + Button button=holder.getButton(); + int textRes, styleRes; + if(isAccountInList(holder.getItem())){ + textRes=R.string.remove; + styleRes=R.style.Widget_Mastodon_M3_Button_Tonal_Error; + }else{ + textRes=R.string.add; + styleRes=R.style.Widget_Mastodon_M3_Button_Filled; + } + button.setText(textRes); + TypedArray ta=button.getContext().obtainStyledAttributes(styleRes, new int[]{android.R.attr.background}); + button.setBackground(ta.getDrawable(0)); + ta.recycle(); + ta=button.getContext().obtainStyledAttributes(styleRes, new int[]{android.R.attr.textColor}); + button.setTextColor(ta.getColorStateList(0)); + ta.recycle(); + } + + @Override + protected void loadRelationships(List accounts){ + // no-op + } + + @Override + public Uri getWebUri(Uri.Builder base){ + // TODO this + return null; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/CreateListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/CreateListFragment.java new file mode 100644 index 000000000..9ae409045 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/CreateListFragment.java @@ -0,0 +1,149 @@ +package org.joinmastodon.android.fragments; + +import android.os.Bundle; +import android.text.TextUtils; +import android.view.View; +import android.view.WindowInsets; +import android.view.inputmethod.InputMethodManager; +import android.widget.Button; + +import com.squareup.otto.Subscribe; + +import org.joinmastodon.android.E; +import org.joinmastodon.android.R; +import org.joinmastodon.android.api.requests.lists.CreateList; +import org.joinmastodon.android.api.requests.lists.UpdateList; +import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.events.FinishListCreationFragmentEvent; +import org.joinmastodon.android.events.ListCreatedEvent; +import org.joinmastodon.android.events.ListUpdatedEvent; +import org.joinmastodon.android.model.FollowList; +import org.joinmastodon.android.ui.utils.UiUtils; +import org.parceler.Parcels; + +import java.util.List; + +import me.grishka.appkit.Nav; +import me.grishka.appkit.api.Callback; +import me.grishka.appkit.api.ErrorResponse; + +public class CreateListFragment extends BaseEditListFragment{ + private Button nextButton; + private View buttonBar; + private FollowList followList; + + @Override + public void onCreate(Bundle savedInstanceState){ + super.onCreate(savedInstanceState); + setTitle(R.string.create_list); + setSubtitle(getString(R.string.step_x_of_y, 1, 2)); + setLayout(R.layout.fragment_login); + if(savedInstanceState!=null) + followList=Parcels.unwrap(savedInstanceState.getParcelable("list")); + E.register(this); + } + + @Override + public void onDestroy(){ + super.onDestroy(); + E.unregister(this); + } + + @Override + protected int getNavigationIconDrawableResource(){ + return R.drawable.ic_baseline_arrow_drop_down_18; + } + + @Override + public boolean wantsCustomNavigationIcon(){ + return true; + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState){ + nextButton=view.findViewById(R.id.btn_next); + nextButton.setOnClickListener(this::onNextClick); + nextButton.setText(R.string.create); + buttonBar=view.findViewById(R.id.button_bar); + super.onViewCreated(view, savedInstanceState); + } + + @Override + public void onApplyWindowInsets(WindowInsets insets){ + super.onApplyWindowInsets(UiUtils.applyBottomInsetToFixedView(buttonBar, insets)); + } + + @Override + protected List getViewsForElevationEffect(){ + return List.of(getToolbar(), buttonBar); + } + + @Override + public void onSaveInstanceState(Bundle outState){ + super.onSaveInstanceState(outState); + outState.putParcelable("list", Parcels.wrap(followList)); + } + + private void onNextClick(View v){ + String title=titleEdit.getText().toString().trim(); + if(TextUtils.isEmpty(title)){ + titleEditLayout.setErrorState(getString(R.string.required_form_field_blank)); + return; + } + if(followList==null){ + new CreateList(title, getSelectedRepliesPolicy(), exclusiveItem.checked) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(FollowList result){ + followList=result; + proceed(false); + E.post(new ListCreatedEvent(accountID, result)); + AccountSessionManager.get(accountID).getCacheController().addList(result); + } + + @Override + public void onError(ErrorResponse error){ + error.showToast(getActivity()); + } + }) + .wrapProgress(getActivity(), R.string.loading, true) + .exec(accountID); + }else if(!title.equals(followList.title) || getSelectedRepliesPolicy()!=followList.repliesPolicy || exclusiveItem.checked!=followList.exclusive){ + new UpdateList(followList.id, title, getSelectedRepliesPolicy(), exclusiveItem.checked) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(FollowList result){ + followList=result; + proceed(true); + E.post(new ListUpdatedEvent(accountID, result)); + AccountSessionManager.get(accountID).getCacheController().updateList(result); + } + + @Override + public void onError(ErrorResponse error){ + error.showToast(getActivity()); + } + }) + .wrapProgress(getActivity(), R.string.loading, true) + .exec(accountID); + }else{ + proceed(true); + } + } + + private void proceed(boolean needLoadMembers){ + Bundle args=new Bundle(); + args.putString("account", accountID); + args.putParcelable("list", Parcels.wrap(followList)); + args.putBoolean("needLoadMembers", needLoadMembers); + Nav.go(getActivity(), CreateListAddMembersFragment.class, args); + getActivity().getSystemService(InputMethodManager.class).hideSoftInputFromWindow(contentView.getWindowToken(), 0); + } + + @Subscribe + public void onFinishListCreationFragment(FinishListCreationFragmentEvent ev){ + if(ev.accountID.equals(accountID) && followList!=null && ev.listID.equals(followList.id)){ + Nav.finish(this); + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/CustomLocalTimelineFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/CustomLocalTimelineFragment.java new file mode 100644 index 000000000..a2510a337 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/CustomLocalTimelineFragment.java @@ -0,0 +1,98 @@ +package org.joinmastodon.android.fragments; + +import android.app.Activity; +import android.net.Uri; +import android.view.Menu; +import android.view.MenuInflater; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.api.requests.timelines.GetPublicTimeline; +import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.model.FilterContext; +import org.joinmastodon.android.model.Status; +import org.joinmastodon.android.model.TimelineDefinition; +import org.joinmastodon.android.ui.utils.UiUtils; +import org.joinmastodon.android.utils.ProvidesAssistContent; + +import java.util.List; + +import me.grishka.appkit.api.SimpleCallback; + +public class CustomLocalTimelineFragment extends PinnableStatusListFragment implements ProvidesAssistContent.ProvidesWebUri{ + // private String name; + private String domain; + + private String maxID; + @Override + protected boolean wantsComposeButton() { + return false; + } + + @Override + public void onAttach(Activity activity){ + super.onAttach(activity); + domain=getArguments().getString("domain"); + updateTitle(domain); + + setHasOptionsMenu(true); + } + + private void updateTitle(String domain) { + this.domain = domain; + setTitle(this.domain); + } + + @Override + protected void doLoadData(int offset, int count){ + currentRequest=new GetPublicTimeline(true, false, refreshing ? null : maxID, null, count, null, getLocalPrefs().timelineReplyVisibility) + .setCallback(new SimpleCallback<>(this){ + @Override + public void onSuccess(List result){ + if(!result.isEmpty()) + maxID=result.get(result.size()-1).id; + if (getActivity() == null) return; + AccountSessionManager.get(accountID).filterStatuses(result, FilterContext.PUBLIC); + result.stream().forEach(status -> { + status.account.acct += "@"+domain; + status.mentions.forEach(mention -> mention.id = null); + status.isRemote = true; + }); + + onDataLoaded(result, !result.isEmpty()); + } + }) + .execNoAuth(domain); + } + + @Override + protected void onShown(){ + super.onShown(); + if(!getArguments().getBoolean("noAutoLoad") && !loaded && !dataLoading) + loadData(); + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + inflater.inflate(R.menu.custom_local_timelines, menu); + super.onCreateOptionsMenu(menu, inflater); + UiUtils.enableOptionsMenuIcons(getContext(), menu, R.id.pin); + } + + @Override + protected FilterContext getFilterContext() { + return FilterContext.PUBLIC; + } + + @Override + public Uri getWebUri(Uri.Builder base) { + return new Uri.Builder() + .scheme("https") + .authority(domain) + .build(); + } + + @Override + protected TimelineDefinition makeTimelineDefinition() { + return TimelineDefinition.ofCustomLocalTimeline(domain); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/EditListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/EditListFragment.java new file mode 100644 index 000000000..0d8244159 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/EditListFragment.java @@ -0,0 +1,67 @@ +package org.joinmastodon.android.fragments; + +import android.os.Bundle; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; + +import org.joinmastodon.android.E; +import org.joinmastodon.android.R; +import org.joinmastodon.android.api.requests.lists.UpdateList; +import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.events.ListUpdatedEvent; +import org.joinmastodon.android.model.FollowList; +import org.joinmastodon.android.ui.M3AlertDialogBuilder; + +import me.grishka.appkit.api.Callback; +import me.grishka.appkit.api.ErrorResponse; + +public class EditListFragment extends BaseEditListFragment{ + @Override + public void onCreate(Bundle savedInstanceState){ + super.onCreate(savedInstanceState); + setTitle(R.string.edit_list); + loadMembers(); + setHasOptionsMenu(true); + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){ + menu.add(R.string.delete_list); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item){ + new M3AlertDialogBuilder(getActivity()) + .setTitle(R.string.delete_list) + .setMessage(getString(R.string.delete_list_confirm, followList.title)) + .setPositiveButton(R.string.delete, (dlg, which)->doDeleteList()) + .setNegativeButton(R.string.cancel, null) + .show(); + return true; + } + + @Override + public void onDestroy(){ + super.onDestroy(); + String newTitle=titleEdit.getText().toString(); + FollowList.RepliesPolicy newRepliesPolicy=getSelectedRepliesPolicy(); + boolean newExclusive=exclusiveItem.checked; + if(!newTitle.equals(followList.title) || newRepliesPolicy!=followList.repliesPolicy || newExclusive!=followList.exclusive){ + new UpdateList(followList.id, newTitle, newRepliesPolicy, newExclusive) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(FollowList result){ + AccountSessionManager.get(accountID).getCacheController().updateList(result); + E.post(new ListUpdatedEvent(accountID, result)); + } + + @Override + public void onError(ErrorResponse error){ + // TODO handle errors somehow + } + }) + .exec(accountID); + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/EditTimelinesFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/EditTimelinesFragment.java index 53894974b..b9f58c826 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/EditTimelinesFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/EditTimelinesFragment.java @@ -8,6 +8,7 @@ import android.annotation.SuppressLint; import android.app.AlertDialog; import android.content.Context; import android.os.Bundle; +import android.text.InputType; import android.text.TextUtils; import android.view.Menu; import android.view.MenuInflater; @@ -18,6 +19,7 @@ import android.view.View; import android.view.ViewGroup; import android.widget.Button; import android.widget.EditText; +import android.widget.FrameLayout; import android.widget.ImageButton; import android.widget.ImageView; import android.widget.LinearLayout; @@ -39,9 +41,10 @@ import org.joinmastodon.android.api.requests.lists.GetLists; import org.joinmastodon.android.api.requests.tags.GetFollowedHashtags; import org.joinmastodon.android.api.session.AccountLocalPreferences; import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.model.CustomLocalTimeline; +import org.joinmastodon.android.model.FollowList; import org.joinmastodon.android.model.Hashtag; import org.joinmastodon.android.model.HeaderPaginationList; -import org.joinmastodon.android.model.ListTimeline; import org.joinmastodon.android.model.TimelineDefinition; import org.joinmastodon.android.ui.DividerItemDecoration; import org.joinmastodon.android.ui.M3AlertDialogBuilder; @@ -58,6 +61,7 @@ import java.util.function.Consumer; import me.grishka.appkit.api.Callback; import me.grishka.appkit.api.ErrorResponse; import me.grishka.appkit.utils.BindableViewHolder; +import me.grishka.appkit.utils.V; import me.grishka.appkit.views.UsableRecyclerView; public class EditTimelinesFragment extends MastodonRecyclerFragment implements ScrollableToTop{ @@ -67,9 +71,10 @@ public class EditTimelinesFragment extends MastodonRecyclerFragment timelineByMenuItem=new HashMap<>(); - private final List listTimelines=new ArrayList<>(); + private final List followLists =new ArrayList<>(); private final List hashtags=new ArrayList<>(); private MenuItem addHashtagItem; + private final List localTimelines = new ArrayList<>(); public EditTimelinesFragment(){ super(10); @@ -86,8 +91,8 @@ public class EditTimelinesFragment extends MastodonRecyclerFragment(){ @Override - public void onSuccess(List result){ - listTimelines.addAll(result); + public void onSuccess(List result){ + followLists.addAll(result); updateOptionsMenu(); } @@ -138,16 +143,20 @@ public class EditTimelinesFragment extends MastodonRecyclerFragment{ - if(hashtag!=null) addTimeline(hashtag); - }, null); - } - return true; - } + if (item.getItemId() == R.id.menu_add_local_timelines) { + addNewLocalTimeline(); + return true; + } + TimelineDefinition tl = timelineByMenuItem.get(item); + if (tl != null) { + addTimeline(tl); + } else if (item == addHashtagItem) { + makeTimelineEditor(null, (hashtag) -> { + if (hashtag != null) addTimeline(hashtag); + }, null); + } + return true; + } private void addTimeline(TimelineDefinition tl){ data.add(tl.copy()); @@ -156,11 +165,31 @@ public class EditTimelinesFragment extends MastodonRecyclerFragment { + TimelineDefinition tl = TimelineDefinition.ofCustomLocalTimeline(input.getText().toString().trim()); + data.add(tl); + saveTimelines(); + }) + .setNegativeButton(R.string.cancel, (d, which) -> { + }) + .show(); + } + + private void addTimelineToOptions(TimelineDefinition tl, Menu menu) { + if (data.contains(tl)) return; + MenuItem item = addOptionsItem(menu, tl.getTitle(getContext()), tl.getIcon().iconRes); + timelineByMenuItem.put(item, tl); + } private MenuItem addOptionsItem(Menu menu, String name, @DrawableRes int icon){ MenuItem item=menu.add(0, View.generateViewId(), Menu.NONE, name); @@ -184,12 +213,15 @@ public class EditTimelinesFragment extends MastodonRecyclerFragmentaddTimelineToOptions(tl, timelinesMenu)); - listTimelines.stream().map(TimelineDefinition::ofList).forEach(tl->addTimelineToOptions(tl, listsMenu)); + followLists.stream().map(TimelineDefinition::ofList).forEach(tl->addTimelineToOptions(tl, listsMenu)); addHashtagItem=addOptionsItem(hashtagsMenu, getContext().getString(R.string.sk_timelines_add), R.drawable.ic_fluent_add_24_regular); hashtags.stream().map(TimelineDefinition::ofHashtag).forEach(tl->addTimelineToOptions(tl, hashtagsMenu)); @@ -343,7 +375,7 @@ public class EditTimelinesFragment extends MastodonRecyclerFragment { + UiUtils.handleFollowRequest((Activity) v.getContext(), item.account, accountID, null, v == acceptButton, relationship, (Boolean visible) -> { + if(v==acceptButton){ + acceptButton.setTextVisible(!visible); + acceptProgress.setVisibility(visible ? View.VISIBLE : View.GONE); + acceptButton.setClickable(!visible); + }else{ + rejectButton.setTextVisible(!visible); + rejectProgress.setVisibility(visible ? View.VISIBLE : View.GONE); + rejectButton.setClickable(!visible); + } + itemView.setHasTransientState(false); + }, rel -> { if(getContext()==null) return; itemView.setHasTransientState(false); relationships.put(item.account.id, rel); diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/FollowedHashtagsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/FollowedHashtagsFragment.java index ccb86fdc5..22739fc6d 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/FollowedHashtagsFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/FollowedHashtagsFragment.java @@ -72,6 +72,7 @@ public class FollowedHashtagsFragment extends MastodonRecyclerFragment return new HashtagsAdapter(); } + @Override public void scrollToTop() { smoothScrollRecyclerViewToTop(list); diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/HashtagTimelineFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/HashtagTimelineFragment.java index 5a23e2a44..a982737dc 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/HashtagTimelineFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HashtagTimelineFragment.java @@ -16,11 +16,22 @@ import android.widget.ProgressBar; import android.widget.TextView; import android.widget.Toast; +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + import org.joinmastodon.android.R; import org.joinmastodon.android.api.MastodonErrorResponse; +import org.joinmastodon.android.api.requests.filters.CreateFilter; +import org.joinmastodon.android.api.requests.filters.DeleteFilter; +import org.joinmastodon.android.api.requests.filters.GetFilters; import org.joinmastodon.android.api.requests.tags.GetTag; import org.joinmastodon.android.api.requests.tags.SetTagFollowed; import org.joinmastodon.android.api.requests.timelines.GetHashtagTimeline; +import org.joinmastodon.android.model.Filter; +import org.joinmastodon.android.model.FilterAction; +import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.model.FilterContext; +import org.joinmastodon.android.model.FilterKeyword; import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.model.FilterContext; import org.joinmastodon.android.model.Hashtag; @@ -31,10 +42,12 @@ import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.ui.views.ProgressBarButton; import org.parceler.Parcels; +import java.util.ArrayList; +import java.util.EnumSet; import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; -import androidx.annotation.NonNull; -import androidx.recyclerview.widget.RecyclerView; import me.grishka.appkit.Nav; import me.grishka.appkit.api.Callback; import me.grishka.appkit.api.ErrorResponse; @@ -49,9 +62,10 @@ public class HashtagTimelineFragment extends PinnableStatusListFragment{ private TextView headerTitle, headerSubtitle; private ProgressBarButton followButton; private ProgressBar followProgress; - private MenuItem followMenuItem, pinMenuItem; + private MenuItem followMenuItem, pinMenuItem, muteMenuItem; private boolean followRequestRunning; private boolean toolbarContentVisible; + private String maxID; private List any; private List all; @@ -61,6 +75,8 @@ public class HashtagTimelineFragment extends PinnableStatusListFragment{ private Menu optionsMenu; private MenuInflater optionsMenuInflater; + private Optional filter = Optional.empty(); + @Override protected boolean wantsComposeButton() { return true; @@ -84,6 +100,56 @@ public class HashtagTimelineFragment extends PinnableStatusListFragment{ setHasOptionsMenu(true); } + private void updateMuteState(boolean newMute) { + muteMenuItem.setTitle(getString(newMute ? R.string.unmute_user : R.string.mute_user, "#" + hashtagName)); + muteMenuItem.setIcon(newMute ? R.drawable.ic_fluent_speaker_2_24_regular : R.drawable.ic_fluent_speaker_off_24_regular); + } + + private void showMuteDialog(boolean mute) { + UiUtils.showConfirmationAlert(getContext(), + mute ? R.string.mo_unmute_hashtag : R.string.mo_mute_hashtag, + mute ? R.string.mo_confirm_to_unmute_hashtag : R.string.mo_confirm_to_mute_hashtag, + mute ? R.string.do_unmute : R.string.do_mute, + mute ? R.drawable.ic_fluent_speaker_2_28_regular : R.drawable.ic_fluent_speaker_off_28_regular, + mute ? this::unmuteHashtag : this::muteHashtag + ); + } + private void unmuteHashtag() { + //safe to get, this only called if filter is present + new DeleteFilter(filter.get().id).setCallback(new Callback<>(){ + @Override + public void onSuccess(Void result){ + filter=Optional.empty(); + updateMuteState(false); + } + + @Override + public void onError(ErrorResponse error){ + error.showToast(getContext()); + } + }).exec(accountID); + } + + private void muteHashtag() { + FilterKeyword hashtagFilter=new FilterKeyword(); + hashtagFilter.wholeWord=true; + hashtagFilter.keyword="#"+hashtagName; + new CreateFilter("#"+hashtagName, EnumSet.of(FilterContext.HOME), FilterAction.HIDE, 0 , List.of(hashtagFilter)).setCallback(new Callback<>(){ + @Override + public void onSuccess(Filter result){ + filter=Optional.of(result); + updateMuteState(true); + } + + @Override + public void onError(ErrorResponse error){ + error.showToast(getContext()); + } + }).exec(accountID); + } + + + @Override protected TimelineDefinition makeTimelineDefinition() { return TimelineDefinition.ofHashtag(hashtagName); @@ -141,6 +207,11 @@ public class HashtagTimelineFragment extends PinnableStatusListFragment{ }); } + @Override + public boolean onFabLongClick(View v) { + return UiUtils.pickAccountForCompose(getActivity(), accountID, '#'+hashtagName+' '); + } + @Override public void onFabClick(View v){ Bundle args=new Bundle(); @@ -161,7 +232,7 @@ public class HashtagTimelineFragment extends PinnableStatusListFragment{ @Override public Uri getWebUri(Uri.Builder base) { - return base.path((isInstanceAkkoma() ? "/tag/" : "/tags/") + hashtag).build(); + return base.path((isInstanceAkkoma() ? "/tag/" : "/tags/") + hashtagName).build(); } @Override @@ -221,13 +292,24 @@ public class HashtagTimelineFragment extends PinnableStatusListFragment{ followMenuItem=optionsMenu.findItem(R.id.follow_hashtag); pinMenuItem=optionsMenu.findItem(R.id.pin); followMenuItem.setVisible(toolbarContentVisible); - pinMenuItem.setShowAsAction(toolbarContentVisible ? MenuItem.SHOW_AS_ACTION_NEVER : MenuItem.SHOW_AS_ACTION_ALWAYS); +// pinMenuItem.setShowAsAction(toolbarContentVisible ? MenuItem.SHOW_AS_ACTION_NEVER : MenuItem.SHOW_AS_ACTION_ALWAYS); super.updatePinButton(pinMenuItem); - if(toolbarContentVisible){ - UiUtils.enableOptionsMenuIcons(getContext(), optionsMenu); - }else{ - UiUtils.enableOptionsMenuIcons(getContext(), optionsMenu, R.id.pin); - } + + muteMenuItem = optionsMenu.findItem(R.id.mute_hashtag); + updateMuteState(filter.isPresent()); + new GetFilters().setCallback(new Callback<>() { + @Override + public void onSuccess(List filters) { + if (getActivity() == null) return; + filter=filters.stream().filter(filter->filter.title.equals("#"+hashtagName)).findAny(); + updateMuteState(filter.isPresent()); + } + + @Override + public void onError(ErrorResponse error) { + error.showToast(getActivity()); + } + }).exec(accountID); } @Override @@ -250,6 +332,9 @@ public class HashtagTimelineFragment extends PinnableStatusListFragment{ if (super.onOptionsItemSelected(item)) return true; if (item.getItemId() == R.id.follow_hashtag && hashtag!=null) { setFollowed(!hashtag.following); + } else if (item.getItemId() == R.id.mute_hashtag) { + showMuteDialog(filter.isPresent()); + return true; } return true; } @@ -305,7 +390,10 @@ public class HashtagTimelineFragment extends PinnableStatusListFragment{ if(followMenuItem!=null){ followMenuItem.setTitle(getString(hashtag.following ? R.string.unfollow_user : R.string.follow_user, "#"+hashtagName)); followMenuItem.setIcon(hashtag.following ? R.drawable.ic_fluent_person_delete_24_filled : R.drawable.ic_fluent_person_add_24_regular); - UiUtils.insetPopupMenuIcon(getContext(), followMenuItem); + } + if(muteMenuItem!=null){ + muteMenuItem.setTitle(getString(filter.isPresent() ? R.string.unmute_user : R.string.mute_user, "#" + hashtag)); + muteMenuItem.setIcon(filter.isPresent() ? R.drawable.ic_fluent_speaker_2_24_regular : R.drawable.ic_fluent_speaker_off_24_regular); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeFragment.java index 1ddf727ae..7c91307f9 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeFragment.java @@ -31,10 +31,11 @@ import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.events.NotificationsMarkerUpdatedEvent; import org.joinmastodon.android.events.StatusDisplaySettingsChangedEvent; import org.joinmastodon.android.fragments.discover.DiscoverFragment; +import org.joinmastodon.android.fragments.onboarding.OnboardingFollowSuggestionsFragment; import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.Notification; import org.joinmastodon.android.model.PaginatedResponse; -import org.joinmastodon.android.ui.AccountSwitcherSheet; +import org.joinmastodon.android.ui.sheets.AccountSwitcherSheet; import org.joinmastodon.android.ui.OutlineProviders; import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.ui.views.TabBar; @@ -46,6 +47,7 @@ import java.util.ArrayList; import java.util.List; import me.grishka.appkit.FragmentStackActivity; +import me.grishka.appkit.Nav; import me.grishka.appkit.api.Callback; import me.grishka.appkit.api.ErrorResponse; import me.grishka.appkit.fragments.AppKitFragment; @@ -75,7 +77,7 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); accountID=getArguments().getString("account"); - setTitle(R.string.sk_app_name); + setTitle(R.string.mo_app_name); if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N) setRetainInstance(true); @@ -159,6 +161,8 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene notificationsBadge=tabBar.findViewById(R.id.notifications_badge); notificationsBadge.setVisibility(View.GONE); + tabBar.selectTab(currentTab); + if(savedInstanceState==null){ getChildFragmentManager().beginTransaction() .add(me.grishka.appkit.R.id.fragment_wrap, homeTabFragment) @@ -180,7 +184,6 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene }); } } - tabBar.selectTab(currentTab); return content; } @@ -260,8 +263,11 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene private void onTabSelected(@IdRes int tab){ Fragment newFragment=fragmentForTab(tab); - if(tab==currentTab && newFragment instanceof ScrollableToTop scrollable) { - scrollable.scrollToTop(); + if(tab==currentTab){ + if (tab == R.id.tab_search && GlobalUserPreferences.doubleTapToSearch) + discoverFragment.openSearch(); + else if(newFragment instanceof ScrollableToTop scrollable) + scrollable.scrollToTop(); return; } getChildFragmentManager().beginTransaction().hide(fragmentForTab(currentTab)).show(newFragment).commit(); @@ -296,9 +302,12 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene } new AccountSwitcherSheet(getActivity(), this).show(); return true; - } else if(tab==R.id.tab_search){ - tabBar.selectTab(R.id.tab_search); - onTabSelected(R.id.tab_search); + } + if(tab==R.id.tab_search){ + if(currentTab!=R.id.tab_search){ + onTabSelected(R.id.tab_search); + tabBar.selectTab(R.id.tab_search); + } discoverFragment.openSearch(); return true; } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTabFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTabFragment.java index 85a79b7ed..2af83c5a2 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTabFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTabFragment.java @@ -53,7 +53,7 @@ import org.joinmastodon.android.fragments.settings.SettingsMainFragment; import org.joinmastodon.android.model.Announcement; import org.joinmastodon.android.model.Hashtag; import org.joinmastodon.android.model.HeaderPaginationList; -import org.joinmastodon.android.model.ListTimeline; +import org.joinmastodon.android.model.FollowList; import org.joinmastodon.android.model.TimelineDefinition; import org.joinmastodon.android.ui.SimpleViewHolder; import org.joinmastodon.android.ui.utils.UiUtils; @@ -95,7 +95,7 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab private ImageView collapsedChevron; private TextView timelineTitle; private PopupMenu switcherPopup; - private final Map listItems = new HashMap<>(); + private final Map listItems = new HashMap<>(); private final Map hashtagsItems = new HashMap<>(); private List timelinesList; private int count; @@ -122,6 +122,10 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab fragments=new Fragment[count]; tabViews=new FrameLayout[count]; timelines=new TimelineDefinition[count]; + if(GlobalUserPreferences.toolbarMarquee){ + setTitleMarqueeEnabled(false); + setSubtitleMarqueeEnabled(false); + } } @Override @@ -266,7 +270,7 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab new GetLists().setCallback(new Callback<>() { @Override - public void onSuccess(List lists) { + public void onSuccess(List lists) { updateList(lists, listItems); } @@ -404,7 +408,7 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab addListsToOverflowMenu(); addHashtagsToOverflowMenu(); - if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.P && !UiUtils.isEMUI()) + if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.P && !UiUtils.isEMUI() && !UiUtils.isMagic()) m.setGroupDividerEnabled(true); } @@ -508,7 +512,7 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab Bundle args=new Bundle(); args.putString("account", accountID); int id = item.getItemId(); - ListTimeline list; + FollowList list; Hashtag hashtag; if (item.getItemId() == R.id.menu_back) { @@ -534,6 +538,12 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab @Override public void scrollToTop(){ + if (((IsOnTop) fragments[pager.getCurrentItem()]).isOnTop() && + GlobalUserPreferences.doubleTapToSwipe && !newPostsBtnShown) { + int nextPage = (pager.getCurrentItem() + 1) % count; + navigateTo(nextPage); + return; + } ((ScrollableToTop) fragments[pager.getCurrentItem()]).scrollToTop(); } @@ -607,8 +617,8 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab private void onNewPostsBtnClick(View view) { if(newPostsBtnShown){ - hideNewPostsButton(); scrollToTop(); + hideNewPostsButton(); } } @@ -691,13 +701,13 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab @Subscribe public void onListDeletedEvent(ListDeletedEvent event) { - handleListEvent(listItems, l -> l.id.equals(event.id), false, null); + handleListEvent(listItems, l -> l.id.equals(event.listID), false, null); } @Subscribe public void onListUpdatedCreatedEvent(ListUpdatedCreatedEvent event) { handleListEvent(listItems, l -> l.id.equals(event.id), true, () -> { - ListTimeline list = new ListTimeline(); + FollowList list = new FollowList(); list.id = event.id; list.title = event.title; list.repliesPolicy = event.repliesPolicy; diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ListMembersFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ListMembersFragment.java new file mode 100644 index 000000000..83259e155 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ListMembersFragment.java @@ -0,0 +1,324 @@ +package org.joinmastodon.android.fragments; + +import android.net.Uri; +import android.os.Bundle; +import android.view.ActionMode; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.WindowInsets; +import android.widget.ImageButton; + +import com.squareup.otto.Subscribe; + +import org.joinmastodon.android.E; +import org.joinmastodon.android.R; +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.api.requests.HeaderPaginationRequest; +import org.joinmastodon.android.api.requests.lists.AddAccountsToList; +import org.joinmastodon.android.api.requests.lists.GetListAccounts; +import org.joinmastodon.android.api.requests.lists.RemoveAccountsFromList; +import org.joinmastodon.android.events.AccountAddedToListEvent; +import org.joinmastodon.android.events.AccountRemovedFromListEvent; +import org.joinmastodon.android.fragments.account_list.AddListMembersFragment; +import org.joinmastodon.android.fragments.account_list.PaginatedAccountListFragment; +import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.model.FollowList; +import org.joinmastodon.android.model.viewmodel.AccountViewModel; +import org.joinmastodon.android.ui.M3AlertDialogBuilder; +import org.joinmastodon.android.ui.utils.ActionModeHelper; +import org.joinmastodon.android.ui.utils.UiUtils; +import org.joinmastodon.android.ui.viewholders.AccountViewHolder; +import org.parceler.Parcels; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +import me.grishka.appkit.Nav; +import me.grishka.appkit.api.Callback; +import me.grishka.appkit.api.ErrorResponse; +import me.grishka.appkit.utils.V; + +public class ListMembersFragment extends PaginatedAccountListFragment{ + private static final int ADD_MEMBER_RESULT=600; + + private ImageButton fab; + private FollowList followList; + private boolean inSelectionMode; + private Set selectedAccounts=new HashSet<>(); + private ActionMode actionMode; + private MenuItem deleteItem; + + public ListMembersFragment(){ + setListLayoutId(R.layout.recycler_fragment_with_fab); + } + + @Override + public void onCreate(Bundle savedInstanceState){ + super.onCreate(savedInstanceState); + followList=Parcels.unwrap(getArguments().getParcelable("list")); + setTitle(R.string.list_members); + setHasOptionsMenu(true); + E.register(this); + } + + @Override + public void onDestroy(){ + super.onDestroy(); + E.unregister(this); + } + + @Override + public HeaderPaginationRequest onCreateRequest(String maxID, int count){ + return new GetListAccounts(followList.id, maxID, count); + } + + @Override + protected MastodonAPIRequest loadRemoteInfo(){ + return null; + } + + @Override + public Object getCurrentInfo(){ + return null; + } + + @Override + public String getRemoteDomain(){ + return null; + } + + @Override + protected void onConfigureViewHolder(AccountViewHolder holder){ + super.onConfigureViewHolder(holder); + holder.setStyle(inSelectionMode ? AccountViewHolder.AccessoryType.CHECKBOX : AccountViewHolder.AccessoryType.MENU, false); + holder.setOnClickListener(this::onItemClick); + holder.setOnLongClickListener(this::onItemLongClick); + holder.getContextMenu().getMenu().add(0, R.id.remove_from_list, 0, R.string.remove_from_list); + holder.setOnCustomMenuItemSelectedListener(item->onItemMenuItemSelected(holder, item)); + } + + @Override + protected void onBindViewHolder(AccountViewHolder holder){ + super.onBindViewHolder(holder); + holder.setStyle(inSelectionMode ? AccountViewHolder.AccessoryType.CHECKBOX : AccountViewHolder.AccessoryType.MENU, false); + if(inSelectionMode){ + holder.setChecked(selectedAccounts.contains(holder.getItem().account.id)); + } + } + + @Override + public boolean wantsLightStatusBar(){ + if(actionMode!=null) + return UiUtils.isDarkTheme(); + return super.wantsLightStatusBar(); + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){ + inflater.inflate(R.menu.selectable_list, menu); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item){ + int id=item.getItemId(); + if(id==R.id.select){ + enterSelectionMode(); + }else if(id==R.id.select_all){ + for(AccountViewModel a:(ArrayList)data){ + selectedAccounts.add(a.account.id); + } + enterSelectionMode(); + } + return true; + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState){ + super.onViewCreated(view, savedInstanceState); + fab=view.findViewById(R.id.fab); + fab.setImageResource(R.drawable.ic_fluent_add_24_regular); + fab.setContentDescription(getString(R.string.add_list_member)); + fab.setOnClickListener(v->onFabClick()); + } + + @Override + public void onApplyWindowInsets(WindowInsets insets){ + super.onApplyWindowInsets(insets); + UiUtils.applyBottomInsetToFAB(fab, insets); + } + + @Override + public void onFragmentResult(int reqCode, boolean success, Bundle result){ + if(reqCode==ADD_MEMBER_RESULT && success){ + Account acc=Objects.requireNonNull(Parcels.unwrap(result.getParcelable("selectedAccount"))); + addAccounts(List.of(acc)); + } + } + + @Subscribe + public void onAccountRemovedFromList(AccountRemovedFromListEvent ev){ + if(ev.accountID.equals(accountID) && ev.listID.equals(followList.id)){ + removeAccountRows(Set.of(ev.targetAccountID)); + } + } + + @Subscribe + public void onAccountAddedToList(AccountAddedToListEvent ev){ + if(ev.accountID.equals(accountID) && ev.listID.equals(followList.id)){ + data.add(new AccountViewModel(ev.account, accountID)); + list.getAdapter().notifyItemInserted(data.size()-1); + } + } + + private void onFabClick(){ + Bundle args=new Bundle(); + args.putString("account", accountID); + Nav.goForResult(getActivity(), AddListMembersFragment.class, args, ADD_MEMBER_RESULT, this); + } + + private void onItemClick(AccountViewHolder holder){ + if(inSelectionMode){ + String id=holder.getItem().account.id; + if(selectedAccounts.contains(id)){ + selectedAccounts.remove(id); + holder.setChecked(false); + }else{ + selectedAccounts.add(id); + holder.setChecked(true); + } + updateActionModeTitle(); + deleteItem.setEnabled(!selectedAccounts.isEmpty()); + return; + } + Bundle args=new Bundle(); + args.putString("account", accountID); + args.putParcelable("profileAccount", Parcels.wrap(holder.getItem().account)); + Nav.go(getActivity(), ProfileFragment.class, args); + } + + private boolean onItemLongClick(AccountViewHolder holder){ + if(inSelectionMode) + return false; + selectedAccounts.add(holder.getItem().account.id); + enterSelectionMode(); + return true; + } + + private void onItemMenuItemSelected(AccountViewHolder holder, MenuItem item){ + int id=item.getItemId(); + if(id==R.id.remove_from_list){ + new M3AlertDialogBuilder(getActivity()) + .setTitle(R.string.confirm_remove_list_member) + .setPositiveButton(R.string.remove, (dlg, which)->removeAccounts(Set.of(holder.getItem().account.id))) + .setNegativeButton(R.string.cancel, null) + .show(); + } + } + + private void updateItemsForSelectionModeTransition(){ + list.getAdapter().notifyItemRangeChanged(0, data.size()); + } + + private void enterSelectionMode(){ + inSelectionMode=true; + updateItemsForSelectionModeTransition(); + V.setVisibilityAnimated(fab, View.INVISIBLE); + actionMode=ActionModeHelper.startActionMode(this, ()->elevationOnScrollListener.getCurrentStatusBarColor(), new ActionMode.Callback(){ + @Override + public boolean onCreateActionMode(ActionMode mode, Menu menu){ + return true; + } + + @Override + public boolean onPrepareActionMode(ActionMode mode, Menu menu){ + mode.getMenuInflater().inflate(R.menu.settings_filter_words_action_mode, menu); + deleteItem=menu.findItem(R.id.delete); + return true; + } + + @Override + public boolean onActionItemClicked(ActionMode mode, MenuItem item){ + new M3AlertDialogBuilder(getActivity()) + .setTitle(R.string.confirm_remove_list_members) + .setPositiveButton(R.string.remove, (dlg, which)->removeAccounts(new HashSet<>(selectedAccounts))) + .setNegativeButton(R.string.cancel, null) + .show(); + return true; + } + + @Override + public void onDestroyActionMode(ActionMode mode){ + actionMode=null; + inSelectionMode=false; + selectedAccounts.clear(); + updateItemsForSelectionModeTransition(); + V.setVisibilityAnimated(fab, View.VISIBLE); + } + }); + updateActionModeTitle(); + } + + private void updateActionModeTitle(){ + actionMode.setTitle(getResources().getQuantityString(R.plurals.x_items_selected, selectedAccounts.size(), selectedAccounts.size())); + } + + private void removeAccounts(Set ids){ + new RemoveAccountsFromList(followList.id, ids) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(Void result){ + if(inSelectionMode) + actionMode.finish(); + removeAccountRows(ids); + } + + @Override + public void onError(ErrorResponse error){ + error.showToast(getActivity()); + } + }) + .wrapProgress(getActivity(), R.string.loading, true) + .exec(accountID); + } + + private void addAccounts(Collection accounts){ + new AddAccountsToList(followList.id, accounts.stream().map(a->a.id).collect(Collectors.toSet())) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(Void result){ + for(Account acc:accounts){ + data.add(new AccountViewModel(acc, accountID)); + } + list.getAdapter().notifyItemRangeInserted(data.size()-accounts.size(), accounts.size()); + } + + @Override + public void onError(ErrorResponse error){ + error.showToast(getActivity()); + } + }) + .wrapProgress(getActivity(), R.string.loading, true) + .exec(accountID); + } + + private void removeAccountRows(Set ids){ + for(int i=data.size()-1;i>=0;i--){ + if(ids.contains(((ArrayList)data).get(i).account.id)){ + data.remove(i); + list.getAdapter().notifyItemRemoved(i); + } + } + } + + @Override + public Uri getWebUri(Uri.Builder base){ + return null; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ListTimelineFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ListTimelineFragment.java index fb357d991..514566874 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ListTimelineFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ListTimelineFragment.java @@ -20,7 +20,7 @@ import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.events.ListDeletedEvent; import org.joinmastodon.android.events.ListUpdatedCreatedEvent; import org.joinmastodon.android.model.FilterContext; -import org.joinmastodon.android.model.ListTimeline; +import org.joinmastodon.android.model.FollowList; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.model.TimelineDefinition; import org.joinmastodon.android.ui.M3AlertDialogBuilder; @@ -39,7 +39,7 @@ public class ListTimelineFragment extends PinnableStatusListFragment { private String listID; private String listTitle; @Nullable - private ListTimeline.RepliesPolicy repliesPolicy; + private FollowList.RepliesPolicy repliesPolicy; private boolean exclusive; @Override @@ -54,19 +54,19 @@ public class ListTimelineFragment extends PinnableStatusListFragment { listID = args.getString("listID"); listTitle = args.getString("listTitle"); exclusive = args.getBoolean("listIsExclusive"); - repliesPolicy = ListTimeline.RepliesPolicy.values()[args.getInt("repliesPolicy", 0)]; + repliesPolicy = FollowList.RepliesPolicy.values()[args.getInt("repliesPolicy", 0)]; setTitle(listTitle); setHasOptionsMenu(true); new GetList(listID).setCallback(new Callback<>() { @Override - public void onSuccess(ListTimeline listTimeline) { + public void onSuccess(FollowList followList) { if(getActivity()==null) return; // TODO: save updated info - if (!listTimeline.title.equals(listTitle)) setTitle(listTimeline.title); - if (listTimeline.repliesPolicy != null && !listTimeline.repliesPolicy.equals(repliesPolicy)) { - repliesPolicy = listTimeline.repliesPolicy; + if (!followList.title.equals(listTitle)) setTitle(followList.title); + if (followList.repliesPolicy != null && !followList.repliesPolicy.equals(repliesPolicy)) { + repliesPolicy = followList.repliesPolicy; } } @@ -97,9 +97,9 @@ public class ListTimelineFragment extends PinnableStatusListFragment { .setPositiveButton(R.string.save, (d, which) -> { String newTitle = editor.getTitle().trim(); setTitle(newTitle); - new UpdateList(listID, newTitle, editor.isExclusive(), editor.getRepliesPolicy()).setCallback(new Callback<>() { + new UpdateList(listID, newTitle, editor.getRepliesPolicy(), editor.isExclusive()).setCallback(new Callback<>() { @Override - public void onSuccess(ListTimeline list) { + public void onSuccess(FollowList list) { if(getActivity()==null) return; setTitle(list.title); listTitle = list.title; @@ -119,7 +119,7 @@ public class ListTimelineFragment extends PinnableStatusListFragment { .show(); } else if (item.getItemId() == R.id.delete) { UiUtils.confirmDeleteList(getActivity(), accountID, listID, listTitle, () -> { - E.post(new ListDeletedEvent(listID)); + E.post(new ListDeletedEvent(accountID, listID)); Nav.finish(this); }); } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ListsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ListsFragment.java index 8e92bb5ea..1edabac66 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ListsFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ListsFragment.java @@ -18,13 +18,14 @@ import com.squareup.otto.Subscribe; import org.joinmastodon.android.E; import org.joinmastodon.android.R; import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.api.ResultlessMastodonAPIRequest; import org.joinmastodon.android.api.requests.lists.AddAccountsToList; import org.joinmastodon.android.api.requests.lists.CreateList; import org.joinmastodon.android.api.requests.lists.GetLists; import org.joinmastodon.android.api.requests.lists.RemoveAccountsFromList; import org.joinmastodon.android.events.ListDeletedEvent; import org.joinmastodon.android.events.ListUpdatedCreatedEvent; -import org.joinmastodon.android.model.ListTimeline; +import org.joinmastodon.android.model.FollowList; import org.joinmastodon.android.ui.DividerItemDecoration; import org.joinmastodon.android.ui.M3AlertDialogBuilder; import org.joinmastodon.android.ui.views.ListEditor; @@ -42,7 +43,7 @@ import me.grishka.appkit.api.SimpleCallback; import me.grishka.appkit.utils.BindableViewHolder; import me.grishka.appkit.views.UsableRecyclerView; -public class ListsFragment extends MastodonRecyclerFragment implements ScrollableToTop, ProvidesAssistContent.ProvidesWebUri { +public class ListsFragment extends MastodonRecyclerFragment implements ScrollableToTop, ProvidesAssistContent.ProvidesWebUri { private String accountID; private String profileAccountId; private final HashMap userInListBefore = new HashMap<>(); @@ -97,9 +98,9 @@ public class ListsFragment extends MastodonRecyclerFragment implem .setIcon(R.drawable.ic_fluent_people_add_28_regular) .setView(editor) .setPositiveButton(R.string.sk_create, (d, which) -> - new CreateList(editor.getTitle(), editor.isExclusive(), editor.getRepliesPolicy()).setCallback(new Callback<>() { + new CreateList(editor.getTitle(), editor.getRepliesPolicy(), editor.isExclusive()).setCallback(new Callback<>() { @Override - public void onSuccess(ListTimeline list) { + public void onSuccess(FollowList list) { data.add(0, list); adapter.notifyItemRangeInserted(0, 1); E.post(new ListUpdatedCreatedEvent(list.id, list.title, list.exclusive, list.repliesPolicy)); @@ -120,10 +121,10 @@ public class ListsFragment extends MastodonRecyclerFragment implem private void saveListMembership(String listId, boolean isMember) { userInList.put(listId, isMember); List accountIdList = Collections.singletonList(profileAccountId); - MastodonAPIRequest req = isMember ? new AddAccountsToList(listId, accountIdList) : new RemoveAccountsFromList(listId, accountIdList); + ResultlessMastodonAPIRequest req = isMember ? new AddAccountsToList(listId, accountIdList) : new RemoveAccountsFromList(listId, accountIdList); req.setCallback(new Callback<>() { @Override - public void onSuccess(Object o) {} + public void onSuccess(Void o) {} @Override public void onError(ErrorResponse error) { @@ -139,19 +140,19 @@ public class ListsFragment extends MastodonRecyclerFragment implem currentRequest=(profileAccountId != null ? new GetLists(profileAccountId) : new GetLists()) .setCallback(new SimpleCallback<>(this) { @Override - public void onSuccess(List lists) { + public void onSuccess(List lists) { if(getActivity()==null) return; - for (ListTimeline l : lists) userInListBefore.put(l.id, true); + for (FollowList l : lists) userInListBefore.put(l.id, true); userInList.putAll(userInListBefore); if (profileAccountId == null || !lists.isEmpty()) onDataLoaded(lists, false); if (profileAccountId == null) return; currentRequest=new GetLists().setCallback(new SimpleCallback<>(ListsFragment.this) { @Override - public void onSuccess(List allLists) { + public void onSuccess(List allLists) { if(getActivity()==null) return; - List newLists = new ArrayList<>(); - for (ListTimeline l : allLists) { + List newLists = new ArrayList<>(); + for (FollowList l : allLists) { if (lists.stream().noneMatch(e -> e.id.equals(l.id))) newLists.add(l); if (!userInListBefore.containsKey(l.id)) { userInListBefore.put(l.id, false); @@ -169,8 +170,8 @@ public class ListsFragment extends MastodonRecyclerFragment implem @Subscribe public void onListDeletedEvent(ListDeletedEvent event) { for (int i = 0; i < data.size(); i++) { - ListTimeline item = data.get(i); - if (item.id.equals(event.id)) { + FollowList item = data.get(i); + if (item.id.equals(event.listID)) { data.remove(i); adapter.notifyItemRemoved(i); break; @@ -181,7 +182,7 @@ public class ListsFragment extends MastodonRecyclerFragment implem @Subscribe public void onListUpdatedCreatedEvent(ListUpdatedCreatedEvent event) { for (int i = 0; i < data.size(); i++) { - ListTimeline item = data.get(i); + FollowList item = data.get(i); if (item.id.equals(event.id)) { item.title = event.title; item.repliesPolicy = event.repliesPolicy; @@ -230,7 +231,7 @@ public class ListsFragment extends MastodonRecyclerFragment implem } } - private class ListViewHolder extends BindableViewHolder implements UsableRecyclerView.Clickable{ + private class ListViewHolder extends BindableViewHolder implements UsableRecyclerView.Clickable{ private final TextView title; private final CheckBox listToggle; @@ -241,7 +242,7 @@ public class ListsFragment extends MastodonRecyclerFragment implem } @Override - public void onBind(ListTimeline item) { + public void onBind(FollowList item) { title.setText(item.title); title.setCompoundDrawablesRelativeWithIntrinsicBounds(itemView.getContext().getDrawable( item.exclusive ? R.drawable.ic_fluent_rss_24_regular : R.drawable.ic_fluent_people_24_regular diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ManageFollowedHashtagsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ManageFollowedHashtagsFragment.java new file mode 100644 index 000000000..e83aa5c5b --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ManageFollowedHashtagsFragment.java @@ -0,0 +1,95 @@ +package org.joinmastodon.android.fragments; + +import android.os.Bundle; +import android.view.Menu; +import android.view.MenuItem; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.api.requests.tags.GetFollowedTags; +import org.joinmastodon.android.api.requests.tags.SetTagFollowed; +import org.joinmastodon.android.fragments.settings.BaseSettingsFragment; +import org.joinmastodon.android.model.Hashtag; +import org.joinmastodon.android.model.HeaderPaginationList; +import org.joinmastodon.android.model.viewmodel.ListItemWithOptionsMenu; +import org.joinmastodon.android.ui.M3AlertDialogBuilder; +import org.joinmastodon.android.ui.utils.UiUtils; + +import java.util.stream.Collectors; + +import me.grishka.appkit.api.Callback; +import me.grishka.appkit.api.ErrorResponse; +import me.grishka.appkit.api.SimpleCallback; + +public class ManageFollowedHashtagsFragment extends BaseSettingsFragment implements ListItemWithOptionsMenu.OptionsMenuListener{ + private String maxID; + + public ManageFollowedHashtagsFragment(){ + super(100); + } + + @Override + public void onCreate(Bundle savedInstanceState){ + super.onCreate(savedInstanceState); + setTitle(R.string.manage_hashtags); + loadData(); + } + + @Override + protected void doLoadData(int offset, int count){ + currentRequest=new GetFollowedTags(offset>0 ? maxID : null, count) + .setCallback(new SimpleCallback<>(this){ + @Override + public void onSuccess(HeaderPaginationList result){ + maxID=null; + if(result.nextPageUri!=null) + maxID=result.nextPageUri.getQueryParameter("max_id"); + onDataLoaded(result.stream().map(t->{ + int posts=t.getWeekPosts(); + return new ListItemWithOptionsMenu<>(t.name, getResources().getQuantityString(R.plurals.x_posts_recently, posts, posts), ManageFollowedHashtagsFragment.this, + R.drawable.ic_fluent_tag_24_regular, ManageFollowedHashtagsFragment.this::onItemClick, t, false); + }).collect(Collectors.toList()), maxID!=null); + } + }) + .exec(accountID); + } + + @Override + public void onConfigureListItemOptionsMenu(ListItemWithOptionsMenu item, Menu menu){ + menu.clear(); + menu.add(getString(R.string.unfollow_user, "#"+item.parentObject.name)); + } + + @Override + public void onListItemOptionSelected(ListItemWithOptionsMenu item, MenuItem menuItem){ + new M3AlertDialogBuilder(getActivity()) + .setTitle(getString(R.string.unfollow_confirmation, "#"+item.parentObject.name)) + .setPositiveButton(R.string.unfollow, (dlg, which)->doUnfollow(item)) + .setNegativeButton(R.string.cancel, null) + .show(); + } + + private void onItemClick(ListItemWithOptionsMenu item){ + UiUtils.openHashtagTimeline(getActivity(), accountID, item.parentObject); + } + + private void doUnfollow(ListItemWithOptionsMenu item){ + new SetTagFollowed(item.parentObject.name, false) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(Hashtag result){ + int index=data.indexOf(item); + if(index==-1) + return; + data.remove(index); + list.getAdapter().notifyItemRemoved(index); + } + + @Override + public void onError(ErrorResponse error){ + error.showToast(getActivity()); + } + }) + .wrapProgress(getActivity(), R.string.loading, true) + .exec(accountID); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ManageListsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ManageListsFragment.java new file mode 100644 index 000000000..796064d98 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ManageListsFragment.java @@ -0,0 +1,199 @@ +package org.joinmastodon.android.fragments; + +import android.app.Activity; +import android.os.Bundle; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.WindowInsets; +import android.widget.ImageButton; + +import com.squareup.otto.Subscribe; + +import org.joinmastodon.android.E; +import org.joinmastodon.android.R; +import org.joinmastodon.android.api.requests.lists.DeleteList; +import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.events.ListCreatedEvent; +import org.joinmastodon.android.events.ListDeletedEvent; +import org.joinmastodon.android.events.ListUpdatedEvent; +import org.joinmastodon.android.fragments.settings.BaseSettingsFragment; +import org.joinmastodon.android.model.FollowList; +import org.joinmastodon.android.model.viewmodel.ListItem; +import org.joinmastodon.android.model.viewmodel.ListItemWithOptionsMenu; +import org.joinmastodon.android.ui.M3AlertDialogBuilder; +import org.joinmastodon.android.ui.utils.UiUtils; +import org.parceler.Parcels; + +import java.util.Comparator; +import java.util.List; +import java.util.stream.Collectors; + +import me.grishka.appkit.Nav; +import me.grishka.appkit.api.Callback; +import me.grishka.appkit.api.ErrorResponse; +import me.grishka.appkit.api.SimpleCallback; + +public class ManageListsFragment extends BaseSettingsFragment implements ListItemWithOptionsMenu.OptionsMenuListener{ + private ImageButton fab; + + public ManageListsFragment(){ + setListLayoutId(R.layout.recycler_fragment_with_fab); + } + + @Override + public void onCreate(Bundle savedInstanceState){ + super.onCreate(savedInstanceState); + setTitle(R.string.manage_lists); + loadData(); + setRefreshEnabled(true); + E.register(this); + } + + @Override + public void onDestroy(){ + super.onDestroy(); + E.unregister(this); + } + + @Override + protected void doLoadData(int offset, int count){ + Callback> callback=new SimpleCallback<>(this){ + @Override + public void onSuccess(List result){ + onDataLoaded(result.stream().map(ManageListsFragment.this::makeItem).collect(Collectors.toList()), false); + } + }; + if(refreshing){ + AccountSessionManager.get(accountID) + .getCacheController() + .reloadLists(callback); + }else{ + AccountSessionManager.get(accountID) + .getCacheController() + .getLists(callback); + } + } + + private ListItem makeItem(FollowList l){ + return new ListItemWithOptionsMenu<>(l.title, null, ManageListsFragment.this, R.drawable.ic_list_alt_24px, ManageListsFragment.this::onListClick, l, false); + } + + private void onListClick(ListItemWithOptionsMenu item){ + Bundle args=new Bundle(); + args.putString("account", accountID); + args.putParcelable("list", Parcels.wrap(item.parentObject)); + Nav.go(getActivity(), ListTimelineFragment.class, args); + } + + @Override + public void onConfigureListItemOptionsMenu(ListItemWithOptionsMenu item, Menu menu){ + menu.add(0, R.id.edit, 0, R.string.edit_list); + menu.add(0, R.id.delete, 1, R.string.delete_list); + } + + @Override + public void onListItemOptionSelected(ListItemWithOptionsMenu item, MenuItem menuItem){ + int id=menuItem.getItemId(); + if(id==R.id.edit){ + Bundle args=new Bundle(); + args.putString("account", accountID); + args.putParcelable("list", Parcels.wrap(item.parentObject)); + Nav.go(getActivity(), EditListFragment.class, args); + }else if(id==R.id.delete){ + new M3AlertDialogBuilder(getActivity()) + .setTitle(R.string.delete_list) + .setMessage(getString(R.string.delete_list_confirm, item.parentObject.title)) + .setPositiveButton(R.string.delete, (dlg, which)->doDeleteList(item.parentObject)) + .setNegativeButton(R.string.cancel, null) + .show(); + } + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState){ + super.onViewCreated(view, savedInstanceState); + fab=view.findViewById(R.id.fab); + fab.setImageResource(R.drawable.ic_fluent_add_24_regular); + fab.setContentDescription(getString(R.string.create_list)); + fab.setOnClickListener(v->onFabClick()); + } + + @Override + public void onApplyWindowInsets(WindowInsets insets){ + super.onApplyWindowInsets(insets); + UiUtils.applyBottomInsetToFAB(fab, insets); + } + + private void doDeleteList(FollowList list){ + new DeleteList(list.id) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(Void result){ + for(int i=0;i item:data){ + if(item.parentObject.id.equals(ev.list.id)){ + item.parentObject=ev.list; + item.title=ev.list.title; + rebindItem(item); + break; + } + } + } + + @Subscribe + public void onListDeleted(ListDeletedEvent ev){ + if(!ev.accountID.equals(accountID)) + return; + int i=0; + for(ListItem item:data){ + if(item.parentObject.id.equals(ev.listID)){ + data.remove(i); + itemsAdapter.notifyItemRemoved(i); + break; + } + i++; + } + } + + @Subscribe + public void onListCreated(ListCreatedEvent ev){ + if(!ev.accountID.equals(accountID)) + return; + ListItem item=makeItem(ev.list); + data.add(item); + ((List>)data).sort(Comparator.comparing(l->l.parentObject.title)); + itemsAdapter.notifyItemInserted(data.indexOf(item)); + } + + private void onFabClick(){ + Bundle args=new Bundle(); + args.putString("account", accountID); + Nav.go(getActivity(), CreateListFragment.class, args); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/MastodonRecyclerFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/MastodonRecyclerFragment.java index 3d3781f36..746bb77b0 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/MastodonRecyclerFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/MastodonRecyclerFragment.java @@ -2,9 +2,12 @@ package org.joinmastodon.android.fragments; import android.os.Bundle; import android.view.View; +import android.widget.LinearLayout; +import android.widget.TextView; import android.widget.Toolbar; import org.joinmastodon.android.R; +import org.joinmastodon.android.ui.BetterItemAnimator; import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.utils.ElevationOnScrollListener; @@ -44,6 +47,7 @@ public abstract class MastodonRecyclerFragment extends BaseRecyclerFragmentcheckedItems[which] = isChecked); + + dialogBuilder.setPositiveButton(R.string.save, (d, which) -> { + saveFilters(checkedItems); + this.allNotificationsFragment.reload(); + }).setNeutralButton(R.string.mo_notification_filter_reset, (d, which) -> { + Arrays.fill(checkedItems, true); + saveFilters(checkedItems); + this.allNotificationsFragment.reload(); + }).setNegativeButton(R.string.cancel, (d, which) -> {}); + + dialogBuilder.create().show(); + return true; } return false; } + private void saveFilters(boolean[] checkedItems) { + PushSubscription.Alerts filter = getLocalPrefs().notificationFilters; + filter.mention = checkedItems[0]; + filter.reblog = checkedItems[1]; + filter.favourite = checkedItems[2]; + filter.follow = checkedItems[3]; + filter.poll = checkedItems[4]; + filter.update = checkedItems[5]; + filter.status = checkedItems[6]; + getLocalPrefs().save(); + } + void markAsRead(){ if(allNotificationsFragment.getData().isEmpty()) return; String id=allNotificationsFragment.getData().get(0).id; @@ -184,6 +239,7 @@ public class NotificationsFragment extends MastodonToolbarFragment implements Sc public void onPageSelected(int position){ if (elevationOnScrollListener != null && getCurrentFragment() instanceof IsOnTop f) elevationOnScrollListener.handleScroll(getContext(), f.isOnTop()); + filterItem.setVisible(position==0); if(position==0) return; Fragment _page=getFragmentForPage(position); @@ -270,9 +326,15 @@ public class NotificationsFragment extends MastodonToolbarFragment implements Sc @Override public void scrollToTop(){ + if (getFragmentForPage(pager.getCurrentItem()).isOnTop() && GlobalUserPreferences.doubleTapToSwipe) { + int nextPage = (pager.getCurrentItem() + 1) % tabViews.length; + pager.setCurrentItem(nextPage, true); + return; + } getFragmentForPage(pager.getCurrentItem()).scrollToTop(); } + public void loadData(){ refreshFollowRequestsBadge(); if(allNotificationsFragment!=null && !allNotificationsFragment.loaded && !allNotificationsFragment.dataLoading) @@ -303,6 +365,11 @@ public class NotificationsFragment extends MastodonToolbarFragment implements Sc callFragmentToProvideAssistContent(getFragmentForPage(pager.getCurrentItem()), assistContent); } + @Override + public String getAccountID(){ + return accountID; + } + private class DiscoverPagerAdapter extends RecyclerView.Adapter{ @NonNull @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsListFragment.java index e58e5c98d..92d2f7143 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsListFragment.java @@ -7,6 +7,10 @@ import android.graphics.Rect; import android.net.Uri; import android.os.Bundle; import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; import android.view.View; import com.squareup.otto.Subscribe; @@ -14,6 +18,7 @@ import com.squareup.otto.Subscribe; import org.joinmastodon.android.E; import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.R; +import org.joinmastodon.android.api.requests.markers.SaveMarkers; import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.events.EmojiReactionsUpdatedEvent; import org.joinmastodon.android.events.PollUpdatedEvent; @@ -39,6 +44,7 @@ import org.parceler.Parcels; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -47,12 +53,13 @@ import androidx.recyclerview.widget.RecyclerView; import me.grishka.appkit.api.SimpleCallback; import me.grishka.appkit.utils.MergeRecyclerAdapter; -public class NotificationsListFragment extends BaseStatusListFragment { +public class NotificationsListFragment extends BaseStatusListFragment{ private boolean onlyMentions; - private boolean onlyPosts; private String maxID; private boolean reloadingFromCache; private DiscoverInfoBannerHelper bannerHelper; + private String unreadMarker, realUnreadMarker; + private MenuItem markAllReadItem; @Override protected boolean wantsComposeButton() { @@ -63,13 +70,8 @@ public class NotificationsListFragment extends BaseStatusListFragment buildDisplayItems(Notification n){ + if(!onlyMentions){ + switch(n.type){ + case MENTION -> { + if(!getLocalPrefs().notificationFilters.mention) + return new ArrayList<>(); + } + case REBLOG -> { + if(!getLocalPrefs().notificationFilters.reblog) + return new ArrayList<>(); + } + case FAVORITE, REACTION -> { + if(!getLocalPrefs().notificationFilters.favourite) + return new ArrayList<>(); + } + case FOLLOW, FOLLOW_REQUEST -> { + if(!getLocalPrefs().notificationFilters.follow) + return new ArrayList<>(); + } + case POLL -> { + if(!getLocalPrefs().notificationFilters.poll) + return new ArrayList<>(); + } + case UPDATE -> { + if(!getLocalPrefs().notificationFilters.update) + return new ArrayList<>(); + } + case STATUS -> { + if(!getLocalPrefs().notificationFilters.status) + return new ArrayList<>(); + } + default -> {} + } + } + NotificationHeaderStatusDisplayItem titleItem; Account self=AccountSessionManager.get(accountID).self; if(n.type==Notification.Type.MENTION || n.type==Notification.Type.STATUS @@ -96,7 +130,7 @@ public class NotificationsListFragment extends BaseStatusListFragment items = new ArrayList<>(); items.add(titleItem); items.add(new AccountCardStatusDisplayItem(n.id, this, accountID, n.account, n)); @@ -106,6 +140,8 @@ public class NotificationsListFragment extends BaseStatusListFragment items=StatusDisplayItem.buildItems(this, n.status, accountID, n, knownAccounts, null, flags); if(titleItem!=null) items.add(0, titleItem); @@ -130,11 +166,18 @@ public class NotificationsListFragment extends BaseStatusListFragment0 ? maxID : null, count, onlyMentions, onlyPosts, refreshing && !reloadingFromCache, new SimpleCallback<>(this){ + .getNotifications(offset>0 ? maxID : null, count, onlyMentions, false, refreshing && !reloadingFromCache, new SimpleCallback<>(this){ @Override public void onSuccess(PaginatedResponse> result){ if(getActivity()==null) return; + + Set needRelationships=result.items.stream() + .filter(ntf->ntf.status==null && !relationships.containsKey(ntf.account.id)) + .map(ntf->ntf.account.id) + .collect(Collectors.toSet()); + loadRelationships(needRelationships); + maxID=result.maxID; onDataLoaded(result.items.stream().filter(n->n.type!=null).collect(Collectors.toList()), !result.items.isEmpty()); if(bannerHelper!=null) bannerHelper.onBannerBecameVisible(); @@ -146,16 +189,24 @@ public class NotificationsListFragment extends BaseStatusListFragment=adapter.getItemCount()); } + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){ + inflater.inflate(R.menu.notifications, menu); + markAllReadItem=menu.findItem(R.id.mark_all_read); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item){ + if(item.getItemId()==R.id.mark_all_read){ + markAsRead(); + resetUnreadBackground(); + } + return true; + } + + private void markAsRead(){ + if(data.isEmpty()) + return; + String id=data.get(0).id; + if(ObjectIdComparator.INSTANCE.compare(id, realUnreadMarker)>0){ + new SaveMarkers(null, id).exec(accountID); + AccountSessionManager.get(accountID).setNotificationsMarker(id, true); + realUnreadMarker=id; + } + } + void resetUnreadBackground(){ if (getParentFragment() instanceof NotificationsFragment nf) { nf.unreadMarker=nf.realUnreadMarker; @@ -347,13 +418,20 @@ public class NotificationsListFragment extends BaseStatusListFragment{ nf.unreadMarker=nf.realUnreadMarker=m; nf.updateMarkAllReadButton(); }); } resetUnreadBackground(); + AccountSessionManager.get(accountID).reloadNotificationsMarker(m->{ + unreadMarker=realUnreadMarker=m; + }); + } + + private void updateMarkAllReadButton(){ + markAllReadItem.setEnabled(!data.isEmpty() && realUnreadMarker!=null && !realUnreadMarker.equals(data.get(0).id)); } @Override @@ -371,4 +449,20 @@ public class NotificationsListFragment extends BaseStatusListFragment=0;i--){ + if(list.getChildViewHolder(list.getChildAt(i)) instanceof StatusDisplayItem.Holder itemHolder){ + String id=itemHolder.getItemID(); + for(int j=0;j fields=Collections.emptyList(); diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFragment.java index 1cb01244c..95cebf6a6 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFragment.java @@ -8,6 +8,8 @@ import android.annotation.SuppressLint; import android.app.Activity; import android.app.Fragment; import android.app.assist.AssistContent; +import android.content.ClipData; +import android.content.ClipboardManager; import android.content.Intent; import android.content.res.ColorStateList; import android.content.res.Configuration; @@ -21,11 +23,9 @@ import android.graphics.drawable.LayerDrawable; import android.net.Uri; import android.os.Build; import android.os.Bundle; -import android.text.Editable; import android.text.InputType; import android.text.SpannableStringBuilder; import android.text.TextUtils; -import android.text.TextWatcher; import android.transition.ChangeBounds; import android.transition.Fade; import android.transition.TransitionManager; @@ -52,7 +52,13 @@ import android.widget.TextView; import android.widget.Toast; import android.widget.Toolbar; +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; +import androidx.viewpager2.widget.ViewPager2; + import org.joinmastodon.android.GlobalUserPreferences; +import org.joinmastodon.android.MastodonApp; import org.joinmastodon.android.R; import org.joinmastodon.android.api.requests.accounts.GetAccountByID; import org.joinmastodon.android.api.requests.accounts.GetAccountRelationships; @@ -80,6 +86,7 @@ import org.joinmastodon.android.ui.OutlineProviders; import org.joinmastodon.android.ui.SimpleViewHolder; import org.joinmastodon.android.ui.SingleImagePhotoViewerListener; import org.joinmastodon.android.ui.photoviewer.PhotoViewer; +import org.joinmastodon.android.ui.sheets.DecentralizationExplainerSheet; import org.joinmastodon.android.ui.tabs.TabLayout; import org.joinmastodon.android.ui.tabs.TabLayoutMediator; import org.joinmastodon.android.ui.text.CustomEmojiSpan; @@ -103,13 +110,9 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; -import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; -import androidx.viewpager2.widget.ViewPager2; import me.grishka.appkit.Nav; import me.grishka.appkit.api.Callback; import me.grishka.appkit.api.ErrorResponse; @@ -137,8 +140,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList private ImageView avatar; private CoverImageView cover; private View avatarBorder; - private View usernameWrap; - private TextView name, username, bio, followersCount, followersLabel, followingCount, followingLabel; + private TextView name, username, usernameDomain, bio, followersCount, followersLabel, followingCount, followingLabel; private ImageView lockIcon, botIcon; private ProgressBarButton actionButton, notifyButton; private ViewPager2 pager; @@ -149,13 +151,13 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList private SwipeRefreshLayout refreshLayout; private View followersBtn, followingBtn; private EditText nameEdit, bioEdit; - private ProgressBar actionProgress, notifyProgress, noteSaveProgress; + private ProgressBar actionProgress, notifyProgress; private FrameLayout[] tabViews; private TabLayoutMediator tabLayoutMediator; private TextView followsYouView; private ViewGroup rolesView; private LinearLayout countersLayout; - private View nameEditWrap, bioEditWrap; + private View nameEditWrap, bioEditWrap, usernameWrap; private View tabsDivider; private View actionButtonWrap; private CustomDrawingOrderLinearLayout scrollableContent; @@ -192,7 +194,6 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList // profile note private FrameLayout noteWrap; - private ImageButton noteSaveBtn; private EditText noteEdit; @Override @@ -215,7 +216,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList if(!isOwnProfile) loadRelationship(); else if (isInstanceAkkoma()) { - maxFields = getInstance().get().pleroma.metadata.fieldsLimits.maxFields; + maxFields = (int) getInstance().get().pleroma.metadata.fieldsLimits.maxFields; } }else{ profileAccountID=getArguments().getString("profileAccountID"); @@ -245,6 +246,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList name=content.findViewById(R.id.name); usernameWrap=content.findViewById(R.id.username_wrap); username=content.findViewById(R.id.username); + usernameDomain=content.findViewById(R.id.username_domain); lockIcon=content.findViewById(R.id.lock_icon); botIcon=content.findViewById(R.id.bot_icon); bio=content.findViewById(R.id.bio); @@ -266,7 +268,6 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList bioEditWrap=content.findViewById(R.id.bio_edit_wrap); actionProgress=content.findViewById(R.id.action_progress); notifyProgress=content.findViewById(R.id.notify_progress); - noteSaveProgress=content.findViewById(R.id.note_save_progress); fab=content.findViewById(R.id.fab); followsYouView=content.findViewById(R.id.follows_you); countersLayout=content.findViewById(R.id.profile_counters); @@ -283,47 +284,14 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList noteEdit=content.findViewById(R.id.note_edit); noteWrap=content.findViewById(R.id.note_edit_wrap); - noteSaveBtn=content.findViewById(R.id.note_save_btn); - - noteSaveBtn.setOnClickListener((v->{ - savePrivateNote(noteEdit.getText().toString()); - InputMethodManager imm=(InputMethodManager) getContext().getSystemService(Activity.INPUT_METHOD_SERVICE); - imm.hideSoftInputFromWindow(this.getView().getRootView().getWindowToken(), 0); - noteEdit.clearFocus(); - noteSaveBtn.clearFocus(); - })); - noteEdit.setOnFocusChangeListener((v, hasFocus)->{ if(hasFocus){ hideFab(); - V.setVisibilityAnimated(noteSaveBtn, View.VISIBLE); - noteEdit.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_MULTI_LINE | InputType.TYPE_TEXT_FLAG_CAP_SENTENCES); - }else if(!noteSaveBtn.hasFocus()){ - showFab(); - hideNoteSaveBtnIfNotDirty(); - } - }); - - noteEdit.addTextChangedListener(new TextWatcher(){ - @Override - public void beforeTextChanged(CharSequence s, int start, int count, int after){} - - @Override - public void onTextChanged(CharSequence s, int start, int before, int count){ - if(relationship!=null && noteSaveBtn.getVisibility()!=View.VISIBLE && !s.toString().equals(relationship.note)) - V.setVisibilityAnimated(noteSaveBtn, View.VISIBLE); - } - - @Override - public void afterTextChanged(Editable s){} - }); - - noteSaveBtn.setOnFocusChangeListener((v, hasFocus)->{ - if(!hasFocus && !noteEdit.hasFocus()){ - showFab(); - hideNoteSaveBtnIfNotDirty(); + return; } + showFab(); + savePrivateNote(noteEdit.getText().toString()); }); FrameLayout sizeWrapper=new FrameLayout(getActivity()){ @@ -430,28 +398,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList followingBtn.setOnClickListener(this::onFollowersOrFollowingClick); content.findViewById(R.id.username_wrap).setOnClickListener(v->{ - try { - new GetInstance() - .setCallback(new Callback<>(){ - @Override - public void onSuccess(Instance result){ - Bundle args = new Bundle(); - args.putParcelable("instance", Parcels.wrap(result)); - args.putString("account", accountID); - Nav.go(getActivity(), SettingsServerFragment.class, args); - } - - @Override - public void onError(ErrorResponse error){ - error.showToast(getContext()); - } - }) - .wrapProgress((Activity) getContext(), R.string.loading, true) - .execRemote(Uri.parse(account.url).getHost()); - } catch (NullPointerException ignored) { - // maybe the url was malformed? - Toast.makeText(getContext(), R.string.error, Toast.LENGTH_SHORT).show(); - } + new DecentralizationExplainerSheet(getActivity(), accountID, account).show(); }); content.findViewById(R.id.username_wrap).setOnLongClickListener(v->{ @@ -459,7 +406,8 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList if(!usernameString.contains("@")){ usernameString+="@"+domain; } - UiUtils.copyText(username, '@'+usernameString); + getActivity().getSystemService(ClipboardManager.class).setPrimaryClip(ClipData.newPlainText(null, "@"+usernameString)); + UiUtils.maybeShowTextCopiedToast(getActivity()); return true; }); @@ -487,13 +435,17 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList nameEdit.addTextChangedListener(new SimpleTextWatcher(e->editDirty=true)); bioEdit.addTextChangedListener(new SimpleTextWatcher(e->editDirty=true)); - return sizeWrapper; - } - private void hideNoteSaveBtnIfNotDirty(){ - if(noteEdit.getText().toString().equals(relationship.note)){ - V.setVisibilityAnimated(noteSaveBtn, View.INVISIBLE); - } +// qrCodeButton.setOnClickListener(v->{ +// Bundle args=new Bundle(); +// args.putString("account", accountID); +// args.putParcelable("targetAccount", Parcels.wrap(account)); +// ProfileQrCodeFragment qf=new ProfileQrCodeFragment(); +// qf.setArguments(args); +// qf.show(getChildFragmentManager(), "qrDialog"); +// }); + + return sizeWrapper; } private void showPrivateNote(){ @@ -502,8 +454,8 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList } private void hidePrivateNote(){ - noteWrap.setVisibility(View.GONE); noteEdit.setText(null); + noteWrap.setVisibility(View.GONE); } private void savePrivateNote(String note){ @@ -512,20 +464,18 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList invalidateOptionsMenu(); return; } - V.setVisibilityAnimated(noteSaveProgress, View.VISIBLE); - V.setVisibilityAnimated(noteSaveBtn, View.INVISIBLE); new SetPrivateNote(profileAccountID, note).setCallback(new Callback<>() { @Override public void onSuccess(Relationship result) { updateRelationship(result); invalidateOptionsMenu(); + if(!TextUtils.isEmpty(result.note)) + Toast.makeText(MastodonApp.context, R.string.mo_personal_note_saved, Toast.LENGTH_SHORT).show(); } @Override public void onError(ErrorResponse error) { error.showToast(getContext()); - V.setVisibilityAnimated(noteSaveProgress, View.GONE); - V.setVisibilityAnimated(noteSaveBtn, View.VISIBLE); } }).exec(accountID); } @@ -586,6 +536,11 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList @Override public void onRefresh(){ + if(isInEditMode){ + refreshing=false; + refreshLayout.setRefreshing(false); + return; + } if(refreshing) return; refreshing=true; @@ -683,6 +638,13 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList getChildFragmentManager().putFragment(outState, "pinnedPosts", pinnedPostsFragment); } + @Override + public void onHidden(){ + if (relationship != null && !noteEdit.getText().toString().equals(relationship.note)){ + savePrivateNote(noteEdit.getText().toString()); + } + } + @Override public void onConfigurationChanged(Configuration newConfig){ super.onConfigurationChanged(newConfig); @@ -745,13 +707,18 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList } } - boolean isSelf=AccountSessionManager.getInstance().isSelf(accountID, account); +// boolean isSelf=AccountSessionManager.getInstance().isSelf(accountID, account); - String acct = ((isSelf || account.isRemote) - ? account.getFullyQualifiedName() - : account.acct); +// String acct = ((isSelf || account.isRemote) +// ? account.getFullyQualifiedName() +// : account.acct); - username.setText('@'+acct); + username.setText("@"+account.username); + + String domain=account.getDomain(); + if(TextUtils.isEmpty(domain)) + domain=AccountSessionManager.get(accountID).domain; + usernameDomain.setText(domain); lockIcon.setVisibility(account.locked ? View.VISIBLE : View.GONE); lockIcon.setImageTintList(ColorStateList.valueOf(username.getCurrentTextColor())); @@ -770,7 +737,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList followingCount.setText(UiUtils.abbreviateNumber(account.followingCount)); followersLabel.setText(getResources().getQuantityString(R.plurals.followers, (int)Math.min(999, account.followersCount))); followingLabel.setText(getResources().getQuantityString(R.plurals.following, (int)Math.min(999, account.followingCount))); - + if (account.followersCount < 0) followersBtn.setVisibility(View.GONE); if (account.followingCount < 0) followingBtn.setVisibility(View.GONE); if (account.followersCount < 0 || account.followingCount < 0) @@ -802,7 +769,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList } for(AccountField field:account.fields){ - field.parsedValue=ssb=HtmlParser.parse(field.value, account.emojis, Collections.emptyList(), Collections.emptyList(), accountID); + field.parsedValue=ssb=HtmlParser.parse(field.value, account.emojis, Collections.emptyList(), Collections.emptyList(), accountID, account); field.valueEmojis=ssb.getSpans(0, ssb.length(), CustomEmojiSpan.class); ssb=new SpannableStringBuilder(field.name); HtmlParser.parseCustomEmoji(ssb, account.emojis); @@ -846,27 +813,25 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList if(relationship==null && !isOwnProfile) return; inflater.inflate(isOwnProfile ? R.menu.profile_own : R.menu.profile, menu); - UiUtils.enableOptionsMenuIcons(getActivity(), menu, R.id.bookmarks, R.id.followed_hashtags); - boolean hasMultipleAccounts = AccountSessionManager.getInstance().getLoggedInAccounts().size() > 1; - MenuItem openWithAccounts = menu.findItem(R.id.open_with_account); - openWithAccounts.setVisible(hasMultipleAccounts); - SubMenu accountsMenu=openWithAccounts.getSubMenu(); - if(hasMultipleAccounts){ - accountsMenu.clear(); - UiUtils.populateAccountsMenu(accountID, accountsMenu, s-> UiUtils.openURL( - getActivity(), s.getID(), account.url, false - )); + if(isOwnProfile){ + UiUtils.enableOptionsMenuIcons(getActivity(), menu, R.id.scheduled, R.id.bookmarks); + }else{ + UiUtils.enableOptionsMenuIcons(getActivity(), menu, R.id.edit_note); } + boolean hasMultipleAccounts = AccountSessionManager.getInstance().getLoggedInAccounts().size() > 1; + menu.findItem(R.id.open_with_account).setVisible(hasMultipleAccounts); if(isOwnProfile) { if (isInstancePixelfed()) menu.findItem(R.id.scheduled).setVisible(false); + menu.findItem(R.id.favorites).setIcon(GlobalUserPreferences.likeIcon ? R.drawable.ic_fluent_heart_20_regular : R.drawable.ic_fluent_star_20_regular); + UiUtils.insetPopupMenuIcon(getContext(), menu.findItem(R.id.favorites)); return; } menu.findItem(R.id.manage_user_lists).setTitle(getString(R.string.sk_lists_with_user, account.getShortUsername())); MenuItem mute=menu.findItem(R.id.mute); mute.setTitle(getString(relationship.muting ? R.string.unmute_user : R.string.mute_user, account.getShortUsername())); - mute.setIcon(relationship.muting ? R.drawable.ic_fluent_speaker_0_24_regular : R.drawable.ic_fluent_speaker_off_24_regular); + mute.setIcon(relationship.muting ? R.drawable.ic_fluent_speaker_2_24_regular : R.drawable.ic_fluent_speaker_off_24_regular); UiUtils.insetPopupMenuIcon(getContext(), mute); menu.findItem(R.id.block).setTitle(getString(relationship.blocking ? R.string.unblock_user : R.string.block_user, account.getShortUsername())); menu.findItem(R.id.report).setTitle(getString(R.string.report_user, account.getShortUsername())); @@ -888,18 +853,15 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList }else{ blockDomain.setVisible(false); } - menu.findItem(R.id.edit_note).setTitle(noteWrap.getVisibility()==View.GONE && (relationship.note==null || relationship.note.isEmpty()) - ? R.string.sk_add_note : R.string.sk_delete_note); + boolean canAddNote = noteWrap.getVisibility()==View.GONE && (relationship.note==null || relationship.note.isEmpty()); + menu.findItem(R.id.edit_note).setTitle(canAddNote ? R.string.sk_add_note : R.string.sk_delete_note); } @Override public boolean onOptionsItemSelected(MenuItem item){ int id=item.getItemId(); if(id==R.id.share){ - Intent intent=new Intent(Intent.ACTION_SEND); - intent.setType("text/plain"); - intent.putExtra(Intent.EXTRA_TEXT, account.url); - startActivity(Intent.createChooser(intent, item.getTitle())); + UiUtils.openSystemShareSheet(getActivity(), account); }else if(id==R.id.mute){ UiUtils.confirmToggleMuteUser(getActivity(), accountID, account, relationship.muting, this::updateRelationship); }else if(id==R.id.block){ @@ -980,8 +942,13 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList InputMethodManager imm=getActivity().getSystemService(InputMethodManager.class); imm.showSoftInput(noteEdit, 0); }, 100); - }else if(relationship.note.isEmpty()){ + }else if(relationship.note.isEmpty() && noteEdit.getText().toString().isEmpty()){ hidePrivateNote(); + noteEdit.clearFocus(); + noteEdit.postDelayed(()->{ + InputMethodManager imm=getActivity().getSystemService(InputMethodManager.class); + imm.hideSoftInputFromWindow(noteEdit.getWindowToken(), 0); + }, 100); UiUtils.beginLayoutTransition(scrollableContent); }else{ new M3AlertDialogBuilder(getActivity()) @@ -991,6 +958,10 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList .show(); } invalidateOptionsMenu(); + }else if(id==R.id.open_with_account){ + UiUtils.pickAccount(getActivity(), accountID, R.string.sk_open_with_account, R.drawable.ic_fluent_person_swap_24_regular, session ->UiUtils.openURL( + getActivity(), session.getID(), account.url, false + ), null); } return true; } @@ -1024,13 +995,10 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList UiUtils.setRelationshipToActionButtonM3(relationship, actionButton); actionProgress.setIndeterminateTintList(actionButton.getTextColors()); notifyProgress.setIndeterminateTintList(notifyButton.getTextColors()); - noteSaveProgress.setIndeterminateTintList(noteEdit.getTextColors()); followsYouView.setVisibility(relationship.followedBy ? View.VISIBLE : View.GONE); notifyButton.setSelected(relationship.notifying); notifyButton.setContentDescription(getString(relationship.notifying ? R.string.sk_user_post_notifications_on : R.string.sk_user_post_notifications_off, '@'+account.username)); noteEdit.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_MULTI_LINE | InputType.TYPE_TEXT_FLAG_CAP_SENTENCES | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS); - V.setVisibilityAnimated(noteSaveProgress, View.GONE); - V.setVisibilityAnimated(noteSaveBtn, View.INVISIBLE); UiUtils.beginLayoutTransition(scrollableContent); } @@ -1342,6 +1310,9 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList @Override public boolean onBackPressed(){ + if(noteEdit.hasFocus()) { + savePrivateNote(noteEdit.getText().toString()); + } if(isInEditMode){ if(savingEdits) return true; @@ -1382,7 +1353,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList return; int radius=V.dp(25); currentPhotoViewer=new PhotoViewer(getActivity(), createFakeAttachments(TextUtils.isEmpty(account.avatar) ? getSession().getDefaultAvatarUrl() : account.avatar, ava), 0, - new SingleImagePhotoViewerListener(avatar, avatarBorder, new int[]{radius, radius, radius, radius}, this, ()->currentPhotoViewer=null, ()->ava, null, null)); + null, accountID, new SingleImagePhotoViewerListener(avatar, avatarBorder, new int[]{radius, radius, radius, radius}, this, ()->currentPhotoViewer=null, ()->ava, null, null)); } } @@ -1394,7 +1365,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList if(drawable==null || drawable instanceof ColorDrawable) return; currentPhotoViewer=new PhotoViewer(getActivity(), createFakeAttachments(account.header, drawable), 0, - new SingleImagePhotoViewerListener(cover, cover, null, this, ()->currentPhotoViewer=null, ()->drawable, ()->avatarBorder.setTranslationZ(2), ()->avatarBorder.setTranslationZ(0))); + null, accountID, new SingleImagePhotoViewerListener(cover, cover, null, this, ()->currentPhotoViewer=null, ()->drawable, ()->avatarBorder.setTranslationZ(2), ()->avatarBorder.setTranslationZ(0))); } } @@ -1618,8 +1589,9 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList public void setImage(int index, Drawable image){ CustomEmojiSpan span=index>=item.nameEmojis.length ? item.valueEmojis[index-item.nameEmojis.length] : item.nameEmojis[index]; span.setDrawable(image); - title.invalidate(); - value.invalidate(); + title.setText(title.getText()); + value.setText(value.getText()); + toolbarTitleView.setText(toolbarTitleView.getText()); } @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ScheduledStatusListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ScheduledStatusListFragment.java index 9140f57d2..e254297e1 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ScheduledStatusListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ScheduledStatusListFragment.java @@ -17,6 +17,7 @@ import org.joinmastodon.android.api.requests.statuses.CreateStatus; import org.joinmastodon.android.api.requests.statuses.GetScheduledStatuses; import org.joinmastodon.android.events.ScheduledStatusCreatedEvent; import org.joinmastodon.android.events.ScheduledStatusDeletedEvent; +import org.joinmastodon.android.model.Filter; import org.joinmastodon.android.model.HeaderPaginationList; import org.joinmastodon.android.model.ScheduledStatus; import org.joinmastodon.android.model.Status; @@ -84,7 +85,7 @@ public class ScheduledStatusListFragment extends BaseStatusListFragment buildDisplayItems(ScheduledStatus s) { - return StatusDisplayItem.buildItems(this, s.toStatus(), accountID, s, knownAccounts, null, + return StatusDisplayItem.buildItems(this, s.toFormattedStatus(accountID), accountID, s, knownAccounts, null, StatusDisplayItem.FLAG_NO_EMOJI_REACTIONS | StatusDisplayItem.FLAG_NO_FOOTER | StatusDisplayItem.FLAG_NO_TRANSLATE); @@ -203,10 +204,7 @@ public class ScheduledStatusListFragment extends BaseStatusListFragment=29 && insets.getTappableElementInsets().bottom==0){ - int insetBottom=insets.getSystemWindowInsetBottom(); - ((ViewGroup.MarginLayoutParams) list.getLayoutParams()).bottomMargin=insetBottom; - ((ViewGroup.MarginLayoutParams) fab.getLayoutParams()).bottomMargin=V.dp(16)+insetBottom; - insets=insets.inset(0, 0, 0, insetBottom); + ((ViewGroup.MarginLayoutParams) fab.getLayoutParams()).bottomMargin=V.dp(16)+insets.getSystemWindowInsetBottom(); }else{ ((ViewGroup.MarginLayoutParams) fab.getLayoutParams()).bottomMargin=V.dp(16); } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ScrollableToTop.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ScrollableToTop.java index af675748a..fb10263dc 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ScrollableToTop.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ScrollableToTop.java @@ -7,6 +7,8 @@ import androidx.recyclerview.widget.RecyclerView; import org.joinmastodon.android.ui.utils.UiUtils; public interface ScrollableToTop{ +// boolean isScrolledToTop(); + void scrollToTop(); /** diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/SplashFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/SplashFragment.java index 05622a216..d1326c589 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/SplashFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/SplashFragment.java @@ -1,7 +1,12 @@ package org.joinmastodon.android.fragments; +import android.app.ProgressDialog; +import android.content.ClipData; +import android.content.ClipboardManager; import android.graphics.drawable.ColorDrawable; +import android.net.Uri; import android.os.Bundle; +import android.text.TextUtils; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -11,6 +16,8 @@ import android.widget.ProgressBar; import org.joinmastodon.android.MastodonApp; import org.joinmastodon.android.R; +import org.joinmastodon.android.api.MastodonErrorResponse; +import org.joinmastodon.android.api.requests.accounts.CheckInviteLink; import org.joinmastodon.android.api.requests.catalog.GetCatalogDefaultInstances; import org.joinmastodon.android.api.requests.instance.GetInstance; import org.joinmastodon.android.fragments.onboarding.InstanceCatalogSignupFragment; @@ -20,6 +27,7 @@ import org.joinmastodon.android.model.Instance; import org.joinmastodon.android.model.catalog.CatalogDefaultInstance; import org.joinmastodon.android.ui.InterpolatingMotionEffect; import org.joinmastodon.android.ui.M3AlertDialogBuilder; +import org.joinmastodon.android.ui.text.HtmlParser; import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.ui.views.ProgressBarButton; import org.joinmastodon.android.ui.views.SizeListenerFrameLayout; @@ -48,6 +56,9 @@ public class SplashFragment extends AppKitFragment{ private ProgressBar defaultServerProgress; private String chosenDefaultServer=DEFAULT_SERVER; private boolean loadingDefaultServer, loadedDefaultServer; + private Uri currentInviteLink; + private ProgressDialog instanceLoadingProgress; + private String inviteCode; @Override public void onCreate(Bundle savedInstanceState){ @@ -110,19 +121,65 @@ public class SplashFragment extends AppKitFragment{ Bundle extras=new Bundle(); boolean isSignup=v.getId()==R.id.btn_get_started; extras.putBoolean("signup", isSignup); + extras.putString("defaultServer", chosenDefaultServer); Nav.go(getActivity(), isSignup ? InstanceCatalogSignupFragment.class : InstanceChooserLoginFragment.class, extras); } private void onJoinDefaultServerClick(View v){ if(loadingDefaultServer) return; + instanceLoadingProgress=new ProgressDialog(getActivity()); + instanceLoadingProgress.setCancelable(false); + instanceLoadingProgress.setMessage(getString(R.string.loading_instance)); + instanceLoadingProgress.show(); + if(currentInviteLink!=null){ + new CheckInviteLink(currentInviteLink.getPath()) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(CheckInviteLink.Response result){ + inviteCode=result.inviteCode; + proceedWithServerDomain(currentInviteLink.getHost()); + } + + @Override + public void onError(ErrorResponse error){ + if(getActivity()==null) + return; + instanceLoadingProgress.dismiss(); + instanceLoadingProgress=null; + if(error instanceof MastodonErrorResponse mer){ + switch(mer.httpStatus){ + case 401 -> new M3AlertDialogBuilder(getActivity()) + .setTitle(R.string.expired_invite_link) + .setMessage(getString(R.string.expired_clipboard_invite_link_alert, currentInviteLink.getHost(), chosenDefaultServer)) + .setPositiveButton(R.string.ok, null) + .show(); + case 404 -> new M3AlertDialogBuilder(getActivity()) + .setTitle(R.string.invalid_invite_link) + .setMessage(getString(R.string.invalid_clipboard_invite_link_alert, currentInviteLink.getHost(), chosenDefaultServer)) + .setPositiveButton(R.string.ok, null) + .show(); + default -> error.showToast(getActivity()); + } + } + } + }) + .execNoAuth(currentInviteLink.getHost()); + return; + } + proceedWithServerDomain(chosenDefaultServer); + } + + private void proceedWithServerDomain(String domain){ new GetInstance() .setCallback(new Callback<>(){ @Override public void onSuccess(Instance result){ if(getActivity()==null) return; - if(!result.registrations){ + instanceLoadingProgress.dismiss(); + instanceLoadingProgress=null; + if(!result.registrations && TextUtils.isEmpty(inviteCode)){ new M3AlertDialogBuilder(getActivity()) .setTitle(R.string.error) .setMessage(R.string.instance_signup_closed) @@ -132,6 +189,8 @@ public class SplashFragment extends AppKitFragment{ } Bundle args=new Bundle(); args.putParcelable("instance", Parcels.wrap(result)); + if(inviteCode!=null) + args.putString("inviteCode", inviteCode); Nav.go(getActivity(), InstanceRulesFragment.class, args); } @@ -139,11 +198,12 @@ public class SplashFragment extends AppKitFragment{ public void onError(ErrorResponse error){ if(getActivity()==null) return; + instanceLoadingProgress.dismiss(); + instanceLoadingProgress=null; error.showToast(getActivity()); } }) - .wrapProgress(getActivity(), R.string.loading_instance, true) - .execNoAuth(chosenDefaultServer); + .execNoAuth(domain); } private void onLearnMoreClick(View v){ @@ -198,7 +258,18 @@ public class SplashFragment extends AppKitFragment{ } private void loadAndChooseDefaultServer(){ - loadingDefaultServer=true; + ClipData clipData=getActivity().getSystemService(ClipboardManager.class).getPrimaryClip(); + if(clipData!=null && clipData.getItemCount()>0){ + CharSequence clipText=clipData.getItemAt(0).coerceToText(getActivity()); + if(HtmlParser.INVITE_LINK_PATTERN.matcher(clipText).find()){ + currentInviteLink=Uri.parse(clipText.toString()); + defaultServerButton.setText(getString(R.string.join_server_x_with_invite, currentInviteLink.getHost())); + } + }else{ + loadingDefaultServer=true; + defaultServerButton.setTextVisible(false); + defaultServerProgress.setVisibility(View.VISIBLE); + } new GetCatalogDefaultInstances() .setCallback(new Callback<>(){ @Override @@ -241,7 +312,7 @@ public class SplashFragment extends AppKitFragment{ chosenDefaultServer=domain; loadingDefaultServer=false; loadedDefaultServer=true; - if(defaultServerButton!=null && getActivity()!=null){ + if(defaultServerButton!=null && getActivity()!=null && currentInviteLink==null){ defaultServerButton.setTextVisible(true); defaultServerProgress.setVisibility(View.GONE); defaultServerButton.setText(getString(R.string.join_default_server, chosenDefaultServer)); diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/StatusEditHistoryFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/StatusEditHistoryFragment.java index 819f58a77..0e6bf1ac9 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/StatusEditHistoryFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/StatusEditHistoryFragment.java @@ -3,8 +3,8 @@ package org.joinmastodon.android.fragments; import android.app.Activity; import android.net.Uri; import android.os.Bundle; -import android.view.View; +import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.R; import org.joinmastodon.android.api.requests.statuses.GetStatusEditHistory; import org.joinmastodon.android.model.FilterContext; @@ -13,7 +13,6 @@ import org.joinmastodon.android.ui.displayitems.DummyStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.ReblogOrReplyLineStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.StatusDisplayItem; import org.joinmastodon.android.ui.text.HtmlParser; -import org.joinmastodon.android.ui.utils.InsetStatusItemDecoration; import org.joinmastodon.android.ui.utils.UiUtils; import java.time.ZoneId; @@ -26,6 +25,8 @@ import java.util.List; import java.util.Objects; import java.util.stream.Collectors; +import me.grishka.appkit.api.Callback; +import me.grishka.appkit.api.ErrorResponse; import me.grishka.appkit.api.SimpleCallback; import name.fraser.neil.plaintext.diff_match_patch; @@ -54,12 +55,47 @@ public class StatusEditHistoryFragment extends StatusListFragment{ public void onSuccess(List result){ if(getActivity()==null) return; Collections.sort(result, Comparator.comparing((Status s)->s.createdAt).reversed()); + if(result.size()<=1&& GlobalUserPreferences.allowRemoteLoading) { + //server send only a single edit, which is always the original status + //try to get the complete history from the remote server + loadRemoteData(result); + return; + } onDataLoaded(result, false); } }) .exec(accountID); } + void loadRemoteData(List prevData){ + String remoteURL = Uri.parse(url).getHost(); + String[] parts=url.split("/"); + + if(parts.length==0||remoteURL==null) { + onDataLoaded(prevData, false); + setSubtitle(getContext().getString(R.string.sk_no_remote_info_hint)); + return; + } + + new GetStatusEditHistory(parts[parts.length-1]) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(List result){ + if(getActivity()==null) return; + Collections.sort(result, Comparator.comparing((Status s)->s.createdAt).reversed()); + onDataLoaded(result, false); + } + + @Override + public void onError(ErrorResponse errorResponse){ + //fallback to previously loaded data + onDataLoaded(prevData, false); + setSubtitle(getContext().getString(R.string.sk_no_remote_info_hint)); + } + }) + .execNoAuth(remoteURL); + } + @Override protected List buildDisplayItems(Status s){ List items=new ArrayList<>(); diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/StatusListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/StatusListFragment.java index a5aa4a90c..40cc4ffeb 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/StatusListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/StatusListFragment.java @@ -12,6 +12,7 @@ import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.api.CacheController; import org.joinmastodon.android.api.session.AccountLocalPreferences; import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.events.StatusMuteChangedEvent; import org.joinmastodon.android.events.EmojiReactionsUpdatedEvent; import org.joinmastodon.android.events.PollUpdatedEvent; import org.joinmastodon.android.events.ReblogDeletedEvent; @@ -25,9 +26,11 @@ import org.joinmastodon.android.model.Status; import org.joinmastodon.android.ui.displayitems.EmojiReactionsStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.ExtendedFooterStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.FooterStatusDisplayItem; +import org.joinmastodon.android.ui.displayitems.HeaderStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.GapStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.StatusDisplayItem; import org.joinmastodon.android.ui.displayitems.TextStatusDisplayItem; +import org.joinmastodon.android.ui.utils.UiUtils; import org.parceler.Parcels; import java.util.ArrayList; @@ -56,7 +59,11 @@ public abstract class StatusListFragment extends BaseStatusListFragment flags |= StatusDisplayItem.FLAG_NO_EMOJI_REACTIONS; if(GlobalUserPreferences.translateButtonOpenedOnly) flags |= StatusDisplayItem.FLAG_NO_TRANSLATE; - return StatusDisplayItem.buildItems(this, s, accountID, s, knownAccounts, getFilterContext(), isMainThreadStatus ? 0 : flags); + if(!GlobalUserPreferences.showMediaPreview) + flags |= StatusDisplayItem.FLAG_NO_MEDIA_PREVIEW; + /* MOSHIDON: we make the filterContext null in the main status in the thread fragment, so that the main status is never filtered (because you just clicked on it). + This also restores old behavior that got lost to time and changes in the filter system */ + return StatusDisplayItem.buildItems(this, s, accountID, s, knownAccounts, isMainThreadStatus ? null : getFilterContext(), isMainThreadStatus ? 0 : flags); } protected abstract FilterContext getFilterContext(); @@ -85,6 +92,18 @@ public abstract class StatusListFragment extends BaseStatusListFragment public void onItemClick(String id){ Status status=getContentStatusByID(id); if(status==null || status.preview) return; + if(status.isRemote){ + UiUtils.lookupStatus(getContext(), status, accountID, null, status1 -> { + status1.filterRevealed = true; + Bundle args=new Bundle(); + args.putString("account", accountID); + args.putParcelable("status", Parcels.wrap(status1)); + if(status1.inReplyToAccountId!=null && knownAccounts.containsKey(status1.inReplyToAccountId)) + args.putParcelable("inReplyToAccount", Parcels.wrap(knownAccounts.get(status1.inReplyToAccountId))); + Nav.go(getActivity(), ThreadFragment.class, args); + }); + return; + } status.filterRevealed=true; Bundle args=new Bundle(); args.putString("account", accountID); @@ -282,6 +301,28 @@ public abstract class StatusListFragment extends BaseStatusListFragment } } + @Subscribe + public void onStatusMuteChaged(StatusMuteChangedEvent ev){ + for(Status s:data){ + if(s.getContentStatus().id.equals(ev.id)){ + s.getContentStatus().update(ev); + AccountSessionManager.get(accountID).getCacheController().updateStatus(s); + for(int i=0;i ancestryMap = new HashMap<>(); private StatusContext result; protected boolean contextInitiallyRendered, transitionFinished, preview; + private FrameLayout replyContainer; + private LinearLayout replyButton; + private ImageView replyButtonAva; + private TextView replyButtonText; + private int lastBottomInset; @Override public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); + setLayout(R.layout.fragment_thread); mainStatus=Parcels.unwrap(getArguments().getParcelable("status")); replyTo=Parcels.unwrap(getArguments().getParcelable("inReplyTo")); Account inReplyToAccount=Parcels.unwrap(getArguments().getParcelable("inReplyToAccount")); @@ -70,6 +95,28 @@ public class ThreadFragment extends StatusListFragment implements ProvidesAssist if(preview) setRefreshEnabled(false); setTitle(preview ? getString(R.string.sk_post_preview) : HtmlParser.parseCustomEmoji(getString(R.string.post_from_user, mainStatus.account.getDisplayName()), mainStatus.account.emojis)); transitionFinished = getArguments().getBoolean("noTransition", false); + + E.register(this); + } + + @Override + public void onDestroy(){ + super.onDestroy(); + E.unregister(this); + } + + @Subscribe + public void onStatusMuteChanged(StatusMuteChangedEvent ev){ + for(Status s:data){ + s.getContentStatus().update(ev); + AccountSessionManager.get(accountID).getCacheController().updateStatus(s); + for(int i=0;i index + 1 ? statuses.get(index + 1) : null) - .filter(s -> s.inReplyToId!=null && s.inReplyToId.equals(current.id)) // inReplyToId is null for quote posts on Iceshrimp + .filter(s -> current.id.equals(s.inReplyToId)) .orElse(null), // ancestoring neighbor Optional.ofNullable(index > 0 ? ancestry.get(index - 1) : null) .filter(ancestor -> Optional.ofNullable(ancestor.descendantNeighbor) - .map(ancestorsDescendant -> ancestorsDescendant.id.equals(current.id)) + .map(ancestorsDescendant -> current.id.equals(ancestorsDescendant.id)) .orElse(false)) .map(a -> a.status) .orElse(null) @@ -341,7 +399,7 @@ public class ThreadFragment extends StatusListFragment implements ProvidesAssist private static List getDirectDescendants(String id, List statuses){ return statuses.stream() - .filter(s -> s.inReplyToId.equals(id)) + .filter(s -> id.equals(s.inReplyToId)) .collect(Collectors.toList()); } @@ -361,6 +419,22 @@ public class ThreadFragment extends StatusListFragment implements ProvidesAssist @Override public void onViewCreated(View view, Bundle savedInstanceState){ super.onViewCreated(view, savedInstanceState); + replyContainer=view.findViewById(R.id.reply_button_wrapper); + replyButton=replyContainer.findViewById(R.id.reply_button); + replyButtonText=replyButton.findViewById(R.id.reply_btn_text); + replyButtonAva=replyButton.findViewById(R.id.avatar); + replyButton.setOutlineProvider(OutlineProviders.roundedRect(20)); + replyButton.setClipToOutline(true); + replyButtonText.setText(HtmlParser.parseCustomEmoji(getString(R.string.reply_to_user, mainStatus.account.displayName), mainStatus.account.emojis)); + UiUtils.loadCustomEmojiInTextView(replyButtonText); + replyButtonAva.setOutlineProvider(OutlineProviders.OVAL); + replyButtonAva.setClipToOutline(true); + replyButton.setOnClickListener(v->openReply(mainStatus, accountID)); + replyButton.setOnLongClickListener(this::onReplyLongClick); + Account self=AccountSessionManager.get(accountID).self; + if(!TextUtils.isEmpty(self.avatar)){ + ViewImageLoader.loadWithoutAnimation(replyButtonAva, getResources().getDrawable(R.drawable.image_placeholder), new UrlImageLoaderRequest(self.avatar, V.dp(24), V.dp(24))); + } UiUtils.loadCustomEmojiInTextView(toolbarTitleView); showContent(); if(!loaded) @@ -500,4 +574,36 @@ public class ThreadFragment extends StatusListFragment implements ProvidesAssist } super.onErrorRetryClick(); } + + @Override + public void onApplyWindowInsets(WindowInsets insets){ + lastBottomInset=insets.getSystemWindowInsetBottom(); + super.onApplyWindowInsets(UiUtils.applyBottomInsetToFixedView(replyContainer, insets)); + } + + private void openReply(Status status, String accountID){ + maybeShowPreReplySheet(status, ()->{ + Bundle args=new Bundle(); + args.putString("account", accountID); + args.putParcelable("replyTo", Parcels.wrap(status)); + args.putBoolean("fromThreadFragment", true); + Nav.go(getActivity(), ComposeFragment.class, args); + }); + } + private boolean onReplyLongClick(View v) { + if(mainStatus.preview) return false; + if (AccountSessionManager.getInstance().getLoggedInAccounts().size() < 2) return false; + UiUtils.pickAccount(v.getContext(), accountID, R.string.sk_reply_as, R.drawable.ic_fluent_arrow_reply_28_regular, session -> { + String pickedAccountID = session.getID(); + UiUtils.lookupStatus(v.getContext(), mainStatus, pickedAccountID, accountID, status -> { + if (status == null) return; + openReply(status, pickedAccountID); + }); + }, null); + return true; + } + + public int getSnackbarOffset(){ + return replyContainer.getHeight()-lastBottomInset; + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/ComposeAccountSearchFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/AccountSearchFragment.java similarity index 81% rename from mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/ComposeAccountSearchFragment.java rename to mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/AccountSearchFragment.java index 965c2d49c..ce3579a8b 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/ComposeAccountSearchFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/AccountSearchFragment.java @@ -6,7 +6,9 @@ import android.text.TextUtils; import android.view.View; import org.joinmastodon.android.R; +import org.joinmastodon.android.api.MastodonAPIRequest; import org.joinmastodon.android.api.requests.search.GetSearchResults; +import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.SearchResults; import org.joinmastodon.android.model.viewmodel.AccountViewModel; import org.joinmastodon.android.ui.SearchViewHelper; @@ -14,13 +16,14 @@ import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.ui.viewholders.AccountViewHolder; import org.parceler.Parcels; +import java.util.List; import java.util.stream.Collectors; import me.grishka.appkit.Nav; import me.grishka.appkit.api.SimpleCallback; -public class ComposeAccountSearchFragment extends BaseAccountListFragment{ - private String currentQuery; +public class AccountSearchFragment extends BaseAccountListFragment{ + protected String currentQuery; private boolean resultDelivered; private SearchViewHelper searchViewHelper; @@ -29,12 +32,11 @@ public class ComposeAccountSearchFragment extends BaseAccountListFragment{ super.onCreate(savedInstanceState); setRefreshEnabled(false); setEmptyText(""); - dataLoaded(); } @Override public void onViewCreated(View view, Bundle savedInstanceState){ - searchViewHelper=new SearchViewHelper(getActivity(), getToolbarContext(), getString(R.string.search_hint)); + searchViewHelper=new SearchViewHelper(getActivity(), getToolbarContext(), getSearchViewPlaceholder()); searchViewHelper.setListeners(this::onQueryChanged, null); searchViewHelper.addDivider(contentView); super.onViewCreated(view, savedInstanceState); @@ -52,13 +54,21 @@ public class ComposeAccountSearchFragment extends BaseAccountListFragment{ .setCallback(new SimpleCallback<>(this){ @Override public void onSuccess(SearchResults result){ - setEmptyText(R.string.no_search_results); - onDataLoaded(result.accounts.stream().map(a->new AccountViewModel(a, accountID)).collect(Collectors.toList()), false); + AccountSearchFragment.this.onSuccess(result.accounts); } }) .exec(accountID); } + protected void onSuccess(List result){ + setEmptyText(R.string.no_search_results); + onDataLoaded(result.stream().map(a->new AccountViewModel(a, accountID)).collect(Collectors.toList()), false); + } + + protected String getSearchViewPlaceholder(){ + return getString(R.string.search_hint); + } + @Override protected void onUpdateToolbar(){ super.onUpdateToolbar(); diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/AddListMembersFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/AddListMembersFragment.java new file mode 100644 index 000000000..28afce3e2 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/AddListMembersFragment.java @@ -0,0 +1,37 @@ +package org.joinmastodon.android.fragments.account_list; + +import android.os.Bundle; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.api.requests.accounts.SearchAccounts; +import org.joinmastodon.android.model.Account; + +import java.util.List; + +import me.grishka.appkit.api.SimpleCallback; + +public class AddListMembersFragment extends AccountSearchFragment{ + @Override + public void onCreate(Bundle savedInstanceState){ + super.onCreate(savedInstanceState); + dataLoaded(); + } + + @Override + protected void doLoadData(int offset, int count){ + refreshing=true; + currentRequest=new SearchAccounts(currentQuery, 0, 0, false, true) + .setCallback(new SimpleCallback<>(this){ + @Override + public void onSuccess(List result){ + AddListMembersFragment.this.onSuccess(result); + } + }) + .exec(accountID); + } + + @Override + protected String getSearchViewPlaceholder(){ + return getString(R.string.search_among_people_you_follow); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/AddNewListMembersFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/AddNewListMembersFragment.java new file mode 100644 index 000000000..802318b9d --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/AddNewListMembersFragment.java @@ -0,0 +1,125 @@ +package org.joinmastodon.android.fragments.account_list; + +import android.annotation.SuppressLint; +import android.content.res.TypedArray; +import android.os.Bundle; +import android.text.TextUtils; +import android.widget.Button; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.api.requests.accounts.GetAccountFollowing; +import org.joinmastodon.android.api.requests.accounts.SearchAccounts; +import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.model.HeaderPaginationList; +import org.joinmastodon.android.model.viewmodel.AccountViewModel; +import org.joinmastodon.android.ui.viewholders.AccountViewHolder; + +import java.util.List; +import java.util.stream.Collectors; + +import me.grishka.appkit.api.SimpleCallback; +import me.grishka.appkit.utils.V; + +@SuppressLint("ValidFragment") // This shouldn't be part of any saved states anyway +public class AddNewListMembersFragment extends AccountSearchFragment{ + private Listener listener; + private String maxID; + + public AddNewListMembersFragment(Listener listener){ + this.listener=listener; + } + + @Override + public void onCreate(Bundle savedInstanceState){ + super.onCreate(savedInstanceState); + loadData(); + } + + @Override + protected void doLoadData(int offset, int count){ + if(TextUtils.isEmpty(currentQuery)){ + currentRequest=new GetAccountFollowing(AccountSessionManager.get(accountID).self.id, offset>0 ? maxID : null, count) + .setCallback(new SimpleCallback<>(this){ + @Override + public void onSuccess(HeaderPaginationList result){ + setEmptyText(""); + onDataLoaded(result.stream().map(a->new AccountViewModel(a, accountID)).collect(Collectors.toList()), result.nextPageUri!=null); + maxID=result.getNextPageMaxID(); + } + }) + .exec(accountID); + }else{ + refreshing=true; + currentRequest=new SearchAccounts(currentQuery, 0, 0, false, true) + .setCallback(new SimpleCallback<>(this){ + @Override + public void onSuccess(List result){ + AddNewListMembersFragment.this.onSuccess(result); + } + }) + .exec(accountID); + } + } + + @Override + protected String getSearchViewPlaceholder(){ + return getString(R.string.search_among_people_you_follow); + } + + @Override + protected void onConfigureViewHolder(AccountViewHolder holder){ + holder.setStyle(AccountViewHolder.AccessoryType.CUSTOM_BUTTON, false); + holder.setOnLongClickListener(vh->false); + Button button=holder.getButton(); + button.setPadding(V.dp(24), 0, V.dp(24), 0); + button.setMinimumWidth(0); + button.setMinWidth(0); + button.setOnClickListener(v->{ + holder.setActionProgressVisible(true); + holder.itemView.setHasTransientState(true); + Runnable onDone=()->{ + holder.setActionProgressVisible(false); + holder.itemView.setHasTransientState(false); + onBindViewHolder(holder); + }; + AccountViewModel account=holder.getItem(); + if(listener.isAccountInList(account)){ + listener.removeAccountAccountFromList(account, onDone); + }else{ + listener.addAccountToList(account, onDone); + } + }); + } + + @Override + protected void onBindViewHolder(AccountViewHolder holder){ + Button button=holder.getButton(); + int textRes, styleRes; + if(listener.isAccountInList(holder.getItem())){ + textRes=R.string.remove; + styleRes=R.style.Widget_Mastodon_M3_Button_Tonal_Error; + }else{ + textRes=R.string.add; + styleRes=R.style.Widget_Mastodon_M3_Button_Filled; + } + button.setText(textRes); + TypedArray ta=button.getContext().obtainStyledAttributes(styleRes, new int[]{android.R.attr.background}); + button.setBackground(ta.getDrawable(0)); + ta.recycle(); + ta=button.getContext().obtainStyledAttributes(styleRes, new int[]{android.R.attr.textColor}); + button.setTextColor(ta.getColorStateList(0)); + ta.recycle(); + } + + @Override + protected void loadRelationships(List accounts){ + // no-op + } + + public interface Listener{ + boolean isAccountInList(AccountViewModel account); + void addAccountToList(AccountViewModel account, Runnable onDone); + void removeAccountAccountFromList(AccountViewModel account, Runnable onDone); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/BaseAccountListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/BaseAccountListFragment.java index ca95f4697..f758bb927 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/BaseAccountListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/BaseAccountListFragment.java @@ -39,6 +39,7 @@ public abstract class BaseAccountListFragment extends MastodonRecyclerFragment relationships=new HashMap<>(); protected String accountID; protected ArrayList> relationshipsRequests=new ArrayList<>(); + protected int itemLayoutRes=R.layout.item_account_list; public BaseAccountListFragment(){ super(40); @@ -74,6 +75,8 @@ public abstract class BaseAccountListFragment extends MastodonRecyclerFragment accounts){ Set ids=accounts.stream().map(ai->ai.account.id).collect(Collectors.toSet()); + if(ids.isEmpty()) + return; GetAccountRelationships req=new GetAccountRelationships(ids); relationshipsRequests.add(req); req.setCallback(new Callback<>(){ @@ -124,13 +127,6 @@ public abstract class BaseAccountListFragment extends MastodonRecyclerFragment implements ImageLoaderRecyclerAdapter{ public AccountsAdapter(){ @@ -171,7 +168,7 @@ public abstract class BaseAccountListFragment extends MastodonRecyclerFragment onCreateRequest(String maxID, int count){ + return new GetAccountBlocks(maxID, count); + } + + @Override + protected void onConfigureViewHolder(AccountViewHolder holder){ + super.onConfigureViewHolder(holder); + holder.setStyle(AccountViewHolder.AccessoryType.NONE, false); + } + + @Override + public Uri getWebUri(Uri.Builder base) { + return super.getWebUri(base).buildUpon() + .appendPath("/blocks").build(); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/MutesListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/MutesListFragment.java new file mode 100644 index 000000000..64acdaef2 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/MutesListFragment.java @@ -0,0 +1,36 @@ +package org.joinmastodon.android.fragments.account_list; + +import android.net.Uri; +import android.os.Bundle; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.api.requests.HeaderPaginationRequest; +import org.joinmastodon.android.api.requests.accounts.GetAccountMutes; +import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.ui.viewholders.AccountViewHolder; + +public class MutesListFragment extends AccountRelatedAccountListFragment{ + + @Override + public void onCreate(Bundle savedInstanceState){ + super.onCreate(savedInstanceState); + setTitle(R.string.mo_muted_accounts); + } + + @Override + public HeaderPaginationRequest onCreateRequest(String maxID, int count){ + return new GetAccountMutes(maxID, count); + } + + @Override + protected void onConfigureViewHolder(AccountViewHolder holder){ + super.onConfigureViewHolder(holder); + holder.setStyle(AccountViewHolder.AccessoryType.NONE, false); + } + + @Override + public Uri getWebUri(Uri.Builder base) { + return super.getWebUri(base).buildUpon() + .appendPath("/mutes").build(); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverAccountsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverAccountsFragment.java index d7194bbf3..51a9e320c 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverAccountsFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverAccountsFragment.java @@ -279,8 +279,8 @@ public class DiscoverAccountsFragment extends MastodonRecyclerFragment R.id.discover_hashtags; - case 1 -> R.id.discover_posts; + case 0 -> R.id.discover_posts; + case 1 -> R.id.discover_hashtags; case 2 -> isIceshrimp ? R.id.discover_users : R.id.discover_news; case 3 -> R.id.discover_users; default -> throw new IllegalStateException("Unexpected value: "+i); @@ -134,8 +137,8 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop, FragmentTransaction transaction = getChildFragmentManager().beginTransaction(); transaction - .add(R.id.discover_hashtags, hashtagsFragment) - .add(R.id.discover_posts, postsFragment); + .add(R.id.discover_posts, postsFragment) + .add(R.id.discover_hashtags, hashtagsFragment); if(!isIceshrimp) transaction.add(R.id.discover_news, newsFragment); transaction @@ -147,8 +150,8 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop, @Override public void onConfigureTab(@NonNull TabLayout.Tab tab, int position){ tab.setText(switch(position){ - case 0 -> R.string.hashtags; - case 1 -> R.string.posts; + case 0 -> R.string.posts; + case 1 -> R.string.hashtags; case 2 -> isIceshrimp ? R.string.for_you : R.string.news; case 3 -> R.string.for_you; default -> throw new IllegalStateException("Unexpected value: "+position); @@ -219,6 +222,11 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop, @Override public void scrollToTop(){ if(!searchActive){ + if (((IsOnTop)getFragmentForPage(pager.getCurrentItem())).isOnTop() && GlobalUserPreferences.doubleTapToSwipe){ + int nextPage=(pager.getCurrentItem()+1)%tabViews.length; + pager.setCurrentItem(nextPage, true); + return; + } ((ScrollableToTop)getFragmentForPage(pager.getCurrentItem())).scrollToTop(); }else{ searchFragment.scrollToTop(); @@ -263,8 +271,8 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop, private Fragment getFragmentForPage(int page){ return switch(page){ - case 0 -> hashtagsFragment; - case 1 -> postsFragment; + case 0 -> postsFragment; + case 1 -> hashtagsFragment; case 2 -> isIceshrimp ? accountsFragment : newsFragment; case 3 -> accountsFragment; default -> throw new IllegalStateException("Unexpected value: "+page); @@ -296,6 +304,13 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop, } } + @Override + public void onProvideAssistContent(AssistContent assistContent) { + callFragmentToProvideAssistContent(searchActive + ? searchFragment + : getFragmentForPage(pager.getCurrentItem()), assistContent); + } + private class DiscoverPagerAdapter extends RecyclerView.Adapter{ @NonNull @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverNewsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverNewsFragment.java index e0814fa45..a63d94ead 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverNewsFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverNewsFragment.java @@ -2,6 +2,7 @@ package org.joinmastodon.android.fragments.discover; import android.graphics.Rect; import android.graphics.drawable.Drawable; +import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.view.View; @@ -11,6 +12,7 @@ import android.widget.TextView; import org.joinmastodon.android.R; import org.joinmastodon.android.api.requests.trends.GetTrendingLinks; +import org.joinmastodon.android.fragments.IsOnTop; import org.joinmastodon.android.fragments.ScrollableToTop; import org.joinmastodon.android.model.Card; import org.joinmastodon.android.model.viewmodel.CardViewModel; @@ -18,6 +20,8 @@ import org.joinmastodon.android.ui.OutlineProviders; import org.joinmastodon.android.ui.drawables.BlurhashCrossfadeDrawable; import org.joinmastodon.android.ui.utils.DiscoverInfoBannerHelper; import org.joinmastodon.android.ui.utils.UiUtils; +import org.joinmastodon.android.utils.ProvidesAssistContent; +import org.parceler.Parcels; import java.util.ArrayList; import java.util.List; @@ -26,6 +30,7 @@ import java.util.stream.Collectors; import androidx.annotation.NonNull; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; +import me.grishka.appkit.Nav; import me.grishka.appkit.api.SimpleCallback; import me.grishka.appkit.fragments.BaseRecyclerFragment; import me.grishka.appkit.imageloader.ImageLoaderRecyclerAdapter; @@ -39,7 +44,7 @@ import me.grishka.appkit.utils.SingleViewRecyclerAdapter; import me.grishka.appkit.utils.V; import me.grishka.appkit.views.UsableRecyclerView; -public class DiscoverNewsFragment extends BaseRecyclerFragment implements ScrollableToTop{ +public class DiscoverNewsFragment extends BaseRecyclerFragment implements ScrollableToTop, IsOnTop, ProvidesAssistContent.ProvidesWebUri{ private String accountID; private DiscoverInfoBannerHelper bannerHelper; private MergeRecyclerAdapter mergeAdapter; @@ -109,6 +114,21 @@ public class DiscoverNewsFragment extends BaseRecyclerFragment im smoothScrollRecyclerViewToTop(list); } + @Override + public boolean isOnTop(){ + return isRecyclerViewOnTop(list); + } + + @Override + public String getAccountID() { + return accountID; + } + + @Override + public Uri getWebUri(Uri.Builder base) { + return isInstanceAkkoma() ? null : base.path("/explore/links").build(); + } + private class LinksAdapter extends UsableRecyclerView.Adapter implements ImageLoaderRecyclerAdapter{ private final List data; @@ -197,7 +217,16 @@ public class DiscoverNewsFragment extends BaseRecyclerFragment im @Override public void onClick(){ - UiUtils.launchWebBrowser(getActivity(), item.url); + //TODO: enable timeline for all servers once 4.3.0 is released + if(getInstance().isEmpty() || + !getInstance().get().checkVersion(4,3,0)){ + UiUtils.launchWebBrowser(getActivity(), item.url); + return; + } + Bundle args=new Bundle(); + args.putString("account", accountID); + args.putParcelable("trendingLink", Parcels.wrap(item)); + Nav.go(getActivity(), DiscoverTrendingLinkTimelineFragment.class, args); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverPostsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverPostsFragment.java index 999515878..28f34be35 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverPostsFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverPostsFragment.java @@ -18,7 +18,7 @@ import me.grishka.appkit.utils.MergeRecyclerAdapter; public class DiscoverPostsFragment extends StatusListFragment{ private DiscoverInfoBannerHelper bannerHelper; - private int offset; + private int realOffset=0; @Override public void onCreate(Bundle savedInstanceState){ @@ -27,24 +27,22 @@ public class DiscoverPostsFragment extends StatusListFragment{ } @Override - protected void doLoadData(int o, int count){ - if(refreshing) offset=0; - currentRequest=new GetTrendingStatuses(offset, count) + protected void doLoadData(int offset, int count){ + currentRequest=new GetTrendingStatuses(offset==0 ? 0 : realOffset, count) .setCallback(new SimpleCallback<>(this){ @Override public void onSuccess(List result){ if(getActivity()==null) return; - boolean empty=result.isEmpty(); - offset+=result.size(); + realOffset+=result.size(); AccountSessionManager.get(accountID).filterStatuses(result, getFilterContext()); - onDataLoaded(result, !empty); + onDataLoaded(result, !result.isEmpty()); bannerHelper.onBannerBecameVisible(); } }).exec(accountID); } @Override - protected RecyclerView.Adapter getAdapter(){ + protected RecyclerView.Adapter getAdapter(){ MergeRecyclerAdapter adapter=new MergeRecyclerAdapter(); bannerHelper.maybeAddBanner(list, adapter); adapter.addAdapter(super.getAdapter()); diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverTrendingLinkTimelineFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverTrendingLinkTimelineFragment.java new file mode 100644 index 000000000..20fe727fa --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverTrendingLinkTimelineFragment.java @@ -0,0 +1,209 @@ +package org.joinmastodon.android.fragments.discover; + +import android.app.Activity; +import android.net.Uri; +import android.os.Bundle; +import android.text.TextUtils; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.api.requests.timelines.GetTrendingLinksTimeline; +import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.fragments.ComposeFragment; +import org.joinmastodon.android.fragments.HomeTabFragment; +import org.joinmastodon.android.fragments.StatusListFragment; +import org.joinmastodon.android.model.Card; +import org.joinmastodon.android.model.FilterContext; +import org.joinmastodon.android.model.Status; +import org.joinmastodon.android.ui.utils.UiUtils; +import org.parceler.Parcels; + +import java.util.List; + +import me.grishka.appkit.Nav; +import me.grishka.appkit.api.SimpleCallback; +import me.grishka.appkit.utils.MergeRecyclerAdapter; +import me.grishka.appkit.utils.SingleViewRecyclerAdapter; +import me.grishka.appkit.utils.V; + +//TODO: replace this implementation when upstream implements their own design +public class DiscoverTrendingLinkTimelineFragment extends StatusListFragment{ + private Card trendingLink; + private TextView headerTitle, headerSubtitle; + private Button openLinkButton; + private boolean toolbarContentVisible; + + private Menu optionsMenu; + private MenuInflater optionsMenuInflater; + + @Override + protected boolean wantsComposeButton() { + return true; + } + + @Override + public void onAttach(Activity activity){ + super.onAttach(activity); + trendingLink=Parcels.unwrap(getArguments().getParcelable("trendingLink")); + setTitle(trendingLink.title); + setHasOptionsMenu(true); + } + + + @Override + protected void doLoadData(int offset, int count){ + currentRequest=new GetTrendingLinksTimeline(trendingLink.url, getMaxID(), null, count) + .setCallback(new SimpleCallback<>(this){ + @Override + public void onSuccess(List result){ + if(getActivity()==null) return; + boolean more=applyMaxID(result); + AccountSessionManager.get(accountID).filterStatuses(result, getFilterContext()); + onDataLoaded(result, more); + } + }) + .exec(accountID); + } + + @Override + protected void onShown(){ + super.onShown(); + if(!getArguments().getBoolean("noAutoLoad") && !loaded && !dataLoading) + loadData(); + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState){ + super.onViewCreated(view, savedInstanceState); + fab=view.findViewById(R.id.fab); + fab.setOnClickListener(this::onFabClick); + + if(getParentFragment() instanceof HomeTabFragment) return; + + list.addOnScrollListener(new RecyclerView.OnScrollListener(){ + @Override + public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy){ + View topChild=recyclerView.getChildAt(0); + int firstChildPos=recyclerView.getChildAdapterPosition(topChild); + float newAlpha=firstChildPos>0 ? 1f : Math.min(1f, -topChild.getTop()/(float)headerTitle.getHeight()); + toolbarTitleView.setAlpha(newAlpha); + boolean newToolbarVisibility=newAlpha>0.5f; + if(newToolbarVisibility!=toolbarContentVisible){ + toolbarContentVisible=newToolbarVisibility; + createOptionsMenu(); + } + } + }); + } + + + @Override + public boolean onFabLongClick(View v) { + return UiUtils.pickAccountForCompose(getActivity(), accountID, trendingLink.url); + } + + @Override + public void onFabClick(View v){ + Bundle args=new Bundle(); + args.putString("account", accountID); + args.putString("prefilledText", trendingLink.url); + Nav.go(getActivity(), ComposeFragment.class, args); + } + + @Override + protected void onSetFabBottomInset(int inset){ + ((ViewGroup.MarginLayoutParams) fab.getLayoutParams()).bottomMargin=V.dp(16)+inset; + } + + @Override + protected FilterContext getFilterContext() { + return FilterContext.PUBLIC; + } + + @Override + public Uri getWebUri(Uri.Builder base) { + return base.path("/links").appendPath(trendingLink.url).build(); + } + + @Override + protected RecyclerView.Adapter getAdapter(){ + View header=getActivity().getLayoutInflater().inflate(R.layout.header_trending_link_timeline, list, false); + headerTitle=header.findViewById(R.id.title); + headerSubtitle=header.findViewById(R.id.subtitle); + openLinkButton=header.findViewById(R.id.profile_action_btn); + + headerTitle.setText(trendingLink.title); + openLinkButton.setVisibility(View.GONE); + openLinkButton.setOnClickListener(v->{ + if(trendingLink==null) + return; + openLink(); + }); + updateHeader(); + + MergeRecyclerAdapter mergeAdapter=new MergeRecyclerAdapter(); + if(!(getParentFragment() instanceof HomeTabFragment)){ + mergeAdapter.addAdapter(new SingleViewRecyclerAdapter(header)); + } + mergeAdapter.addAdapter(super.getAdapter()); + return mergeAdapter; + } + + @Override + protected int getMainAdapterOffset(){ + return 1; + } + + private void createOptionsMenu(){ + optionsMenu.clear(); + optionsMenuInflater.inflate(R.menu.trending_links_timeline, optionsMenu); + MenuItem openLinkMenuItem=optionsMenu.findItem(R.id.open_link); + openLinkMenuItem.setVisible(toolbarContentVisible); + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){ + inflater.inflate(R.menu.trending_links_timeline, menu); + super.onCreateOptionsMenu(menu, inflater); + optionsMenu=menu; + optionsMenuInflater=inflater; + createOptionsMenu(); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item){ + if (super.onOptionsItemSelected(item)) return true; + if (item.getItemId() == R.id.open_link && trendingLink!=null) { + openLink(); + } + return true; + } + + @Override + protected void onUpdateToolbar(){ + super.onUpdateToolbar(); + toolbarTitleView.setAlpha(toolbarContentVisible ? 1f : 0f); + createOptionsMenu(); + } + + private void updateHeader(){ + if(trendingLink==null || getActivity()==null) + return; + //TODO: update to show mastodon account once fully implemented upstream + headerSubtitle.setText(getContext().getString(R.string.article_by_author, TextUtils.isEmpty(trendingLink.authorName)? trendingLink.providerName : trendingLink.authorName)); + openLinkButton.setVisibility(View.VISIBLE); + } + + private void openLink() { + UiUtils.launchWebBrowser(getActivity(), trendingLink.url); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/FederatedTimelineFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/FederatedTimelineFragment.java index 5ff063738..eb74bd278 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/FederatedTimelineFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/FederatedTimelineFragment.java @@ -29,7 +29,7 @@ public class FederatedTimelineFragment extends StatusListFragment{ @Override protected void doLoadData(int offset, int count){ - currentRequest=new GetPublicTimeline(false, false, getMaxID(), count, getLocalPrefs().timelineReplyVisibility) + currentRequest=new GetPublicTimeline(false, false, getMaxID(), null, count, null, getLocalPrefs().timelineReplyVisibility) .setCallback(new SimpleCallback<>(this){ @Override public void onSuccess(List result){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/LocalTimelineFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/LocalTimelineFragment.java index aae59908c..d6975f9af 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/LocalTimelineFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/LocalTimelineFragment.java @@ -29,7 +29,7 @@ public class LocalTimelineFragment extends StatusListFragment{ @Override protected void doLoadData(int offset, int count){ - currentRequest=new GetPublicTimeline(true, false, getMaxID(), count, getLocalPrefs().timelineReplyVisibility) + currentRequest=new GetPublicTimeline(true, false, getMaxID(), null, count, null, getLocalPrefs().timelineReplyVisibility) .setCallback(new SimpleCallback<>(this){ @Override public void onSuccess(List result){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/SearchFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/SearchFragment.java index 599d82077..f5c605a94 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/SearchFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/SearchFragment.java @@ -143,7 +143,7 @@ public class SearchFragment extends BaseStatusListFragment{ }*/ int offset=_offset; currentRequest=new GetSearchResults(currentQuery, type, type==null, maxID, offset, type==null ? 0 : count) - .setCallback(new SimpleCallback<>(this){ + .setCallback(new SimpleCallback(this){ @Override public void onSuccess(SearchResults result){ ArrayList results=new ArrayList<>(); @@ -164,7 +164,10 @@ public class SearchFragment extends BaseStatusListFragment{ } prevDisplayItems=new ArrayList<>(displayItems); unfilteredResults=results; + boolean wasRefreshing=refreshing; onDataLoaded(filterSearchResults(results), type!=null && !results.isEmpty()); + if(wasRefreshing) + list.scrollToPosition(0); } }) .setTimeout(180000) // 3 minutes (searches can take a long time) diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/SearchQueryFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/SearchQueryFragment.java index 84bceb46f..859e07363 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/SearchQueryFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/SearchQueryFragment.java @@ -10,6 +10,7 @@ import android.graphics.Outline; import android.graphics.Rect; import android.graphics.drawable.Drawable; import android.graphics.drawable.LayerDrawable; +import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.text.TextUtils; @@ -26,6 +27,7 @@ import android.widget.Toast; import android.widget.Toolbar; import org.joinmastodon.android.GlobalUserPreferences; +import org.joinmastodon.android.MainActivity; import org.joinmastodon.android.R; import org.joinmastodon.android.api.requests.search.GetSearchResults; import org.joinmastodon.android.api.session.AccountSessionManager; @@ -127,7 +129,11 @@ public class SearchQueryFragment extends MastodonRecyclerFragment(this){ @Override public void onSuccess(SearchResults result){ - onDataLoaded(Stream.of(result.hashtags.stream().map(SearchResult::new), result.accounts.stream().map(SearchResult::new)) + onDataLoaded(Stream + .of( + result.hashtags.stream().filter(hashtag -> !hashtag.name.isEmpty()).map(SearchResult::new), + result.accounts.stream().map(SearchResult::new) + ) .flatMap(Function.identity()) .map(sr->{ SearchResultViewModel vm=new SearchResultViewModel(sr, accountID, false); @@ -430,7 +436,7 @@ public class SearchQueryFragment extends MastodonRecyclerFragment item_){ - UiUtils.openURL(getContext(), accountID, searchViewHelper.getQuery(), false); + ((MainActivity)getActivity()).handleURL(Uri.parse(searchViewHelper.getQuery()), accountID); } private void onGoToHashtagClick(ListItem item_){ @@ -477,6 +483,8 @@ public class SearchQueryFragment extends MastodonRecyclerFragment implements ScrollableToTop{ +public class TrendingHashtagsFragment extends BaseRecyclerFragment implements ScrollableToTop, IsOnTop, ProvidesAssistContent.ProvidesWebUri{ private String accountID; public TrendingHashtagsFragment(){ @@ -59,6 +62,21 @@ public class TrendingHashtagsFragment extends BaseRecyclerFragment impl smoothScrollRecyclerViewToTop(list); } + @Override + public boolean isOnTop(){ + return isRecyclerViewOnTop(list); + } + + @Override + public String getAccountID() { + return accountID; + } + + @Override + public Uri getWebUri(Uri.Builder base) { + return isInstanceAkkoma() ? null : base.path("/explore/tags").build(); + } + private class HashtagsAdapter extends RecyclerView.Adapter{ @NonNull @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/AccountActivationFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/AccountActivationFragment.java index 75325ad4a..c3d36b9f2 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/AccountActivationFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/AccountActivationFragment.java @@ -24,7 +24,7 @@ import org.joinmastodon.android.api.session.AccountSession; import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.fragments.settings.SettingsMainFragment; import org.joinmastodon.android.model.Account; -import org.joinmastodon.android.ui.AccountSwitcherSheet; +import org.joinmastodon.android.ui.sheets.AccountSwitcherSheet; import org.joinmastodon.android.ui.utils.UiUtils; import java.io.File; diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/CustomWelcomeFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/CustomWelcomeFragment.java index 6f2065adc..54f35591f 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/CustomWelcomeFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/CustomWelcomeFragment.java @@ -142,8 +142,8 @@ public class CustomWelcomeFragment extends InstanceCatalogFragment { headerView.findViewById(R.id.unread_indicator).setVisibility(View.GONE); headerView.findViewById(R.id.separator).setVisibility(View.GONE); headerView.findViewById(R.id.time).setVisibility(View.GONE); - ((TextView) headerView.findViewById(R.id.username)).setText(R.string.sk_app_username); - ((TextView) headerView.findViewById(R.id.name)).setText(R.string.sk_app_name); + ((TextView) headerView.findViewById(R.id.username)).setText(R.string.mo_app_username); + ((TextView) headerView.findViewById(R.id.name)).setText(R.string.mo_app_name); ((ImageView) headerView.findViewById(R.id.avatar)).setImageDrawable(getActivity().getDrawable(R.mipmap.ic_launcher)); ((FragmentStackActivity) getActivity()).invalidateSystemBarColors(this); diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/GoogleMadeMeAddThisFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/GoogleMadeMeAddThisFragment.java index 05746a5a4..03d0c3557 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/GoogleMadeMeAddThisFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/GoogleMadeMeAddThisFragment.java @@ -137,6 +137,9 @@ public class GoogleMadeMeAddThisFragment extends ToolbarFragment{ protected void onButtonClick(){ Bundle args=new Bundle(); args.putParcelable("instance", Parcels.wrap(instance)); + if(getArguments().containsKey("inviteCode")){ + args.putString("inviteCode", getArguments().getString("inviteCode")); + } Nav.goForResult(getActivity(), SignupFragment.class, args, SIGNUP_REQUEST, this); } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceCatalogFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceCatalogFragment.java index ed9de50e4..0980f6673 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceCatalogFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceCatalogFragment.java @@ -3,7 +3,6 @@ package org.joinmastodon.android.fragments.onboarding; import android.app.Activity; import android.app.ProgressDialog; import android.net.Uri; -import android.os.Build; import android.os.Bundle; import android.text.TextUtils; import android.view.KeyEvent; @@ -38,6 +37,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.function.Consumer; import java.util.stream.Collectors; import javax.xml.parsers.DocumentBuilderFactory; @@ -48,7 +48,6 @@ import me.grishka.appkit.api.Callback; import me.grishka.appkit.api.ErrorResponse; import me.grishka.appkit.utils.BindableViewHolder; import me.grishka.appkit.utils.MergeRecyclerAdapter; -import me.grishka.appkit.utils.V; import okhttp3.Call; import okhttp3.Request; import okhttp3.Response; @@ -61,6 +60,7 @@ abstract class InstanceCatalogFragment extends MastodonRecyclerFragment instancesCache=new HashMap<>(); protected View buttonBar; @@ -91,6 +91,7 @@ abstract class InstanceCatalogFragment extends MastodonRecyclerFragment onError){ if(TextUtils.isEmpty(_domain)) return; String domain=normalizeInstanceDomain(_domain); @@ -180,7 +186,10 @@ abstract class InstanceCatalogFragment extends MastodonRecyclerFragment0 && filteredData.get(0)==fakeInstance){ @@ -200,10 +209,11 @@ abstract class InstanceCatalogFragment extends MastodonRecyclerFragment0 && filteredData.get(0)==fakeInstance){ @@ -283,7 +296,7 @@ abstract class InstanceCatalogFragment extends MastodonRecyclerFragment onError){ String url="https://"+domain+"/.well-known/host-meta"; Request req=new Request.Builder() .url(url) @@ -297,7 +310,12 @@ abstract class InstanceCatalogFragment extends MastodonRecyclerFragmentshowInstanceInfoLoadError(domain, e)); + a.runOnUiThread(()->{ + if(onError!=null) + onError.accept(e); + else + showInstanceInfoLoadError(domain, e); + }); } @Override @@ -309,7 +327,13 @@ abstract class InstanceCatalogFragment extends MastodonRecyclerFragmentshowInstanceInfoLoadError(domain, response.code()+" "+response.message())); + a.runOnUiThread(()->{ + String err=response.code()+" "+response.message(); + if(onError!=null) + onError.accept(err); + else + showInstanceInfoLoadError(domain, err); + }); return; } InputSource source=new InputSource(response.body().charStream()); @@ -328,9 +352,19 @@ abstract class InstanceCatalogFragment extends MastodonRecyclerFragmentshowInstanceInfoLoadError(domain, origError)); + a.runOnUiThread(()->{ + if(onError!=null) + onError.accept(origError); + else + showInstanceInfoLoadError(domain, origError); + }); }catch(Exception x){ - a.runOnUiThread(()->showInstanceInfoLoadError(domain, x)); + a.runOnUiThread(()->{ + if(onError!=null) + onError.accept(x); + else + showInstanceInfoLoadError(domain, x); + }); } } }); diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceCatalogSignupFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceCatalogSignupFragment.java index 798b18ac5..68decec3e 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceCatalogSignupFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceCatalogSignupFragment.java @@ -1,8 +1,13 @@ package org.joinmastodon.android.fragments.onboarding; import android.annotation.SuppressLint; +import android.app.AlertDialog; +import android.content.ClipData; +import android.content.ClipboardManager; import android.content.Context; +import android.content.DialogInterface; import android.content.res.ColorStateList; +import android.net.Uri; import android.os.Bundle; import android.text.Editable; import android.text.TextUtils; @@ -12,6 +17,8 @@ import android.view.View; import android.view.ViewGroup; import android.view.WindowInsets; import android.view.inputmethod.InputMethodManager; +import android.widget.Button; +import android.widget.EditText; import android.widget.HorizontalScrollView; import android.widget.ImageButton; import android.widget.LinearLayout; @@ -19,9 +26,12 @@ import android.widget.PopupMenu; import android.widget.RadioButton; import android.widget.RelativeLayout; import android.widget.TextView; +import android.widget.Toast; import org.joinmastodon.android.R; import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.api.MastodonErrorResponse; +import org.joinmastodon.android.api.requests.accounts.CheckInviteLink; import org.joinmastodon.android.api.requests.catalog.GetCatalogCategories; import org.joinmastodon.android.api.requests.catalog.GetCatalogInstances; import org.joinmastodon.android.model.Instance; @@ -29,6 +39,8 @@ import org.joinmastodon.android.model.catalog.CatalogCategory; import org.joinmastodon.android.model.catalog.CatalogInstance; import org.joinmastodon.android.ui.BetterItemAnimator; import org.joinmastodon.android.ui.M3AlertDialogBuilder; +import org.joinmastodon.android.ui.text.HtmlParser; +import org.joinmastodon.android.ui.utils.SimpleTextWatcher; import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.ui.views.FilterChipView; import org.joinmastodon.android.utils.ElevationOnScrollListener; @@ -40,7 +52,9 @@ import java.util.Collections; import java.util.Comparator; import java.util.List; import java.util.Locale; +import java.util.Objects; import java.util.Random; +import java.util.function.Consumer; import java.util.stream.Collectors; import androidx.annotation.NonNull; @@ -77,6 +91,9 @@ public class InstanceCatalogSignupFragment extends InstanceCatalogFragment imple private CatalogInstance.Region chosenRegion; private CategoryChoice categoryChoice=CategoryChoice.GENERAL; + private String inviteCode, inviteCodeHost; + private AlertDialog currentInviteLinkAlert; + public InstanceCatalogSignupFragment(){ super(R.layout.fragment_onboarding_common, 10); } @@ -317,7 +334,7 @@ public class InstanceCatalogSignupFragment extends InstanceCatalogFragment imple focusThing=view.findViewById(R.id.focus_thing); focusThing.requestFocus(); - view.findViewById(R.id.btn_random_instance).setOnClickListener(this::onPickRandomInstanceClick); + view.findViewById(R.id.btn_use_invite).setOnClickListener(this::onUseInviteClick); nextButton.setEnabled(chosenInstance!=null); } @@ -351,91 +368,191 @@ public class InstanceCatalogSignupFragment extends InstanceCatalogFragment imple @Override protected void proceedWithAuthOrSignup(Instance instance){ + if(currentInviteLinkAlert!=null){ + currentInviteLinkAlert.dismiss(); + }else if(!TextUtils.isEmpty(currentSearchQuery) && HtmlParser.INVITE_LINK_PATTERN.matcher(currentSearchQueryButWithCasePreserved).find()){ + if(TextUtils.isEmpty(inviteCode) || !Objects.equals(instance.uri, inviteCodeHost)){ + Uri inviteLink=Uri.parse(currentSearchQueryButWithCasePreserved); + new CheckInviteLink(inviteLink.getPath()) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(CheckInviteLink.Response result){ + inviteCodeHost=inviteLink.getHost(); + inviteCode=result.inviteCode; + proceedWithAuthOrSignup(instance); + } + + @Override + public void onError(ErrorResponse error){ + if(getActivity()==null) + return; + if(error instanceof MastodonErrorResponse mer){ + switch(mer.httpStatus){ + case 401 -> new M3AlertDialogBuilder(getActivity()) + .setTitle(R.string.expired_invite_link) + .setMessage(getString(R.string.expired_clipboard_invite_link_alert, inviteLink.getHost(), getArguments().getString("defaultServer"))) + .setPositiveButton(R.string.ok, null) + .show(); + case 404 -> new M3AlertDialogBuilder(getActivity()) + .setTitle(R.string.invalid_invite_link) + .setMessage(getString(R.string.invalid_clipboard_invite_link_alert, inviteLink.getHost(), getArguments().getString("defaultServer"))) + .setPositiveButton(R.string.ok, null) + .show(); + default -> error.showToast(getActivity()); + } + } + } + }) + .wrapProgress(getActivity(), R.string.loading_instance, true) + .execNoAuth(inviteLink.getHost()); + return; + } + } getActivity().getSystemService(InputMethodManager.class).hideSoftInputFromWindow(contentView.getWindowToken(), 0); - if(!instance.registrations){ - new M3AlertDialogBuilder(getActivity()) - .setTitle(R.string.error) - .setMessage(R.string.instance_signup_closed) - .setPositiveButton(R.string.ok, null) - .show(); + if(!instance.registrations && (TextUtils.isEmpty(inviteCode) || !Objects.equals(instance.uri, inviteCodeHost))){ + if(instance.invitesEnabled){ + showInviteLinkAlert(instance.uri); + }else{ + new M3AlertDialogBuilder(getActivity()) + .setTitle(R.string.error) + .setMessage(R.string.instance_signup_closed) + .setPositiveButton(R.string.ok, null) + .show(); + } return; } Bundle args=new Bundle(); args.putParcelable("instance", Parcels.wrap(instance)); + if(!TextUtils.isEmpty(inviteCode) && Objects.equals(instance.uri, inviteCodeHost)) + args.putString("inviteCode", inviteCode); Nav.go(getActivity(), InstanceRulesFragment.class, args); } - private void onPickRandomInstanceClick(View v){ - String lang=Locale.getDefault().getLanguage(); - List instances=data.stream().filter(ci->!ci.approvalRequired && ("general".equals(ci.category) || (ci.categories!=null && ci.categories.contains("general"))) && (lang.equals(ci.language) || (ci.languages!=null && ci.languages.contains(lang)))).collect(Collectors.toList()); - if(instances.isEmpty()){ - instances=data.stream().filter(ci->!ci.approvalRequired && ("general".equals(ci.category) || (ci.categories!=null && ci.categories.contains("general")))).collect(Collectors.toList()); - } - if(instances.isEmpty()){ - instances=data.stream().filter(ci->("general".equals(ci.category) || (ci.categories!=null && ci.categories.contains("general")))).collect(Collectors.toList()); - } - if(instances.isEmpty()){ - return; - } - chosenInstance=instances.get(new Random().nextInt(instances.size())); - onNextClick(v); + private void onUseInviteClick(View v){ + showInviteLinkAlert(null); } -// private String getEmojiForCategory(String category){ -// return switch(category){ -// case "all" -> "💬"; -// case "academia" -> "📚"; -// case "activism" -> "✊"; -// case "food" -> "🍕"; -// case "furry" -> "🦁"; -// case "games" -> "🕹"; -// case "general" -> "🐘"; -// case "journalism" -> "📰"; -// case "lgbt" -> "🏳️‍🌈"; -// case "regional" -> "📍"; -// case "art" -> "🎨"; -// case "music" -> "🎼"; -// case "tech" -> "📱"; -// default -> "❓"; -// }; -// } + private void showInviteLinkAlert(String domain){ + AlertDialog alert=new M3AlertDialogBuilder(getActivity()) + .setView(R.layout.alert_invite_link) + .setPositiveButton(R.string.next, null) + .setNegativeButton(R.string.cancel, null) + .create(); - private int getEmojiForCategory(String category){ - return switch(category){ - case "all" -> R.drawable.ic_category_all; - case "academia" -> R.drawable.ic_category_academia; - case "activism" -> R.drawable.ic_category_activism; - case "food" -> R.drawable.ic_category_food; - case "furry" -> R.drawable.ic_category_furry; - case "games" -> R.drawable.ic_category_games; - case "general" -> R.drawable.ic_category_general; - case "journalism" -> R.drawable.ic_category_journalism; - case "lgbt" -> R.drawable.ic_category_lgbt; - case "regional" -> R.drawable.ic_category_regional; - case "art" -> R.drawable.ic_category_art; - case "music" -> R.drawable.ic_category_music; - case "tech" -> R.drawable.ic_category_tech; - default -> R.drawable.ic_category_unknown; - }; - } + Button next=alert.getButton(AlertDialog.BUTTON_POSITIVE); + EditText edit=alert.findViewById(R.id.edit); + TextView supportingText=alert.findViewById(R.id.supporting_text); + TextView label=alert.findViewById(R.id.label); + TextView subtitle=alert.findViewById(R.id.subtitle); + ImageButton clear=alert.findViewById(R.id.clear); + clear.setVisibility(View.GONE); - private int getTitleForCategory(String category){ - return switch(category){ - case "all" -> R.string.category_all; - case "academia" -> R.string.category_academia; - case "activism" -> R.string.category_activism; - case "food" -> R.string.category_food; - case "furry" -> R.string.category_furry; - case "games" -> R.string.category_games; - case "general" -> R.string.category_general; - case "journalism" -> R.string.category_journalism; - case "lgbt" -> R.string.category_lgbt; - case "regional" -> R.string.category_regional; - case "art" -> R.string.category_art; - case "music" -> R.string.category_music; - case "tech" -> R.string.category_tech; - default -> 0; + if(TextUtils.isEmpty(domain)){ + subtitle.setVisibility(View.GONE); + }else{ + subtitle.setText(getString(R.string.need_invite_to_join_server, domain)); + } + + Consumer errorSetter=err->{ + supportingText.setText(err); + int errorColor=UiUtils.getThemeColor(getActivity(), R.attr.colorM3Error); + supportingText.setTextColor(errorColor); + label.setTextColor(errorColor); + edit.setBackgroundResource(R.drawable.bg_m3_filled_text_field_error); }; + + next.setOnClickListener(_v->{ + Uri inviteLink=Uri.parse(edit.getText().toString()); + if(TextUtils.isEmpty(inviteLink.getHost()) || TextUtils.isEmpty(inviteLink.getPath())){ + errorSetter.accept(getString(R.string.this_invite_is_invalid)); + return; + } + UiUtils.showProgressForAlertButton(next, true); + new CheckInviteLink(inviteLink.getPath()) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(CheckInviteLink.Response result){ + if(getActivity()==null || !alert.isShowing()) + return; + + String host=inviteLink.getHost(); + inviteCode=result.inviteCode; + inviteCodeHost=host; + + Instance instance=instancesCache.get(normalizeInstanceDomain(host)); + if(instance==null){ + loadInstanceInfo(host, false, err->{ + String errorStr; + if(err instanceof String str){ + errorStr=str; + }else if(err instanceof Throwable x){ + errorStr=x.getMessage(); + }else if(err instanceof MastodonErrorResponse mer){ + errorStr=mer.error; + }else{ + errorStr=getString(R.string.error); + } + errorSetter.accept(errorStr); + UiUtils.showProgressForAlertButton(next, false); + }); + }else{ + proceedWithAuthOrSignup(instance); + } + } + + @Override + public void onError(ErrorResponse error){ + if(getActivity()==null || !alert.isShowing()) + return; + UiUtils.showProgressForAlertButton(next, false); + if(error instanceof MastodonErrorResponse mer){ + errorSetter.accept(switch(mer.httpStatus){ + case 404 -> getString(R.string.this_invite_is_invalid); + case 401 -> getString(R.string.this_invite_has_expired); + default -> mer.error; + }); + } + } + }) + .execNoAuth(inviteLink.getHost()); + }); + next.setEnabled(false); + edit.addTextChangedListener(new SimpleTextWatcher(e->{ + boolean wasEmpty=!next.isEnabled(); + next.setEnabled(e.length()>0); + if(supportingText.length()>0){ + supportingText.setText(""); + int regularColor=UiUtils.getThemeColor(getActivity(), R.attr.colorM3OnSurfaceVariant); + supportingText.setTextColor(regularColor); + label.setTextColor(regularColor); + edit.setBackgroundResource(R.drawable.bg_m3_filled_text_field); + } + if(wasEmpty!=(e.length()==0)){ + int padEnd; + if(e.length()==0){ + clear.setVisibility(View.GONE); + padEnd=V.dp(16); + }else{ + clear.setVisibility(View.VISIBLE); + padEnd=V.dp(48); + } + edit.setPaddingRelative(edit.getPaddingStart(), edit.getPaddingTop(), padEnd, edit.getPaddingBottom()); + } + })); + clear.setOnClickListener(_v->edit.setText("")); + + ClipData clipData=getActivity().getSystemService(ClipboardManager.class).getPrimaryClip(); + if(clipData!=null && clipData.getItemCount()>0){ + CharSequence clipText=clipData.getItemAt(0).coerceToText(getActivity()); + if(HtmlParser.INVITE_LINK_PATTERN.matcher(clipText).find()){ + edit.setText(clipText); + supportingText.setText(R.string.invite_link_pasted); + } + } + + currentInviteLinkAlert=alert; + alert.setOnDismissListener(dialog->currentInviteLinkAlert=null); + alert.show(); } @Override @@ -444,8 +561,14 @@ public class InstanceCatalogSignupFragment extends InstanceCatalogFragment imple filteredData.clear(); if(searchQueryMode){ if(!TextUtils.isEmpty(currentSearchQuery)){ + String actualQuery; + if(currentSearchQuery.startsWith("https:")){ + actualQuery=Uri.parse(currentSearchQuery).getHost(); + }else{ + actualQuery=currentSearchQuery; + } for(CatalogInstance instance:data){ - if(instance.domain.contains(currentSearchQuery)){ + if(instance.domain.contains(actualQuery)){ filteredData.add(instance); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceChooserLoginFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceChooserLoginFragment.java index e4ce64016..f71f3cfd3 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceChooserLoginFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceChooserLoginFragment.java @@ -106,13 +106,13 @@ public class InstanceChooserLoginFragment extends InstanceCatalogFragment{ .execNoAuth(""); } - @Override - protected void onUpdateToolbar(){ - super.onUpdateToolbar(); - Toolbar toolbar=getToolbar(); - toolbar.setElevation(0); - toolbar.setBackground(null); - } +// @Override +// protected void onUpdateToolbar(){ +// super.onUpdateToolbar(); +// Toolbar toolbar=getToolbar(); +// toolbar.setElevation(0); +// toolbar.setBackground(null); +// } @Override protected RecyclerView.Adapter getAdapter(){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceRulesFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceRulesFragment.java index d9ee9c773..7432db287 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceRulesFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceRulesFragment.java @@ -111,6 +111,9 @@ public class InstanceRulesFragment extends ToolbarFragment implements ProvidesAs protected void onButtonClick(){ Bundle args=new Bundle(); args.putParcelable("instance", Parcels.wrap(instance)); + if(getArguments().containsKey("inviteCode")){ + args.putString("inviteCode", getArguments().getString("inviteCode")); + } Nav.goForResult(getActivity(), GoogleMadeMeAddThisFragment.class, args, RULES_REQUEST, this); } @@ -127,7 +130,7 @@ public class InstanceRulesFragment extends ToolbarFragment implements ProvidesAs if(Build.VERSION.SDK_INT>=27){ int inset=insets.getSystemWindowInsetBottom(); buttonBar.setPadding(0, 0, 0, inset>0 ? Math.max(inset, V.dp(36)) : 0); - super.onApplyWindowInsets(insets.replaceSystemWindowInsets(insets.getSystemWindowInsetLeft(), insets.getSystemWindowInsetTop(), insets.getSystemWindowInsetRight(), 0)); + super.onApplyWindowInsets(insets.replaceSystemWindowInsets(insets.getSystemWindowInsetLeft(), insets.getSystemWindowInsetTop(), insets.getSystemWindowInsetRight(), insets.getSystemWindowInsetBottom())); }else{ super.onApplyWindowInsets(insets.replaceSystemWindowInsets(insets.getSystemWindowInsetLeft(), insets.getSystemWindowInsetTop(), insets.getSystemWindowInsetRight(), insets.getSystemWindowInsetBottom())); } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/OnboardingFollowSuggestionsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/OnboardingFollowSuggestionsFragment.java index 374aeef9f..e6e4d46af 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/OnboardingFollowSuggestionsFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/OnboardingFollowSuggestionsFragment.java @@ -5,6 +5,7 @@ import android.net.Uri; import android.os.Bundle; import android.view.View; import android.view.WindowInsets; +import android.widget.TextView; import org.joinmastodon.android.R; import org.joinmastodon.android.api.requests.accounts.GetFollowSuggestions; @@ -13,35 +14,38 @@ import org.joinmastodon.android.fragments.account_list.BaseAccountListFragment; import org.joinmastodon.android.model.FollowSuggestion; import org.joinmastodon.android.model.Relationship; import org.joinmastodon.android.model.viewmodel.AccountViewModel; +import org.joinmastodon.android.ui.OutlineProviders; import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.ui.viewholders.AccountViewHolder; -import org.joinmastodon.android.utils.ElevationOnScrollListener; import java.util.ArrayList; import java.util.List; import java.util.stream.Collectors; +import androidx.recyclerview.widget.RecyclerView; import me.grishka.appkit.Nav; import me.grishka.appkit.api.Callback; import me.grishka.appkit.api.ErrorResponse; import me.grishka.appkit.api.SimpleCallback; -import me.grishka.appkit.views.FragmentRootLinearLayout; +import me.grishka.appkit.utils.MergeRecyclerAdapter; +import me.grishka.appkit.utils.SingleViewRecyclerAdapter; +import me.grishka.appkit.utils.V; public class OnboardingFollowSuggestionsFragment extends BaseAccountListFragment{ private String accountID; private View buttonBar; - private ElevationOnScrollListener onScrollListener; private int numRunningFollowRequests=0; public OnboardingFollowSuggestionsFragment(){ super(R.layout.fragment_onboarding_follow_suggestions, 40); + itemLayoutRes=R.layout.item_account_list; } @Override public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); setRetainInstance(true); - setTitle(R.string.popular_on_mastodon); + setTitle(R.string.onboarding_recommendations_title); accountID=getArguments().getString("account"); loadData(); } @@ -50,18 +54,15 @@ public class OnboardingFollowSuggestionsFragment extends BaseAccountListFragment public void onViewCreated(View view, Bundle savedInstanceState){ super.onViewCreated(view, savedInstanceState); buttonBar=view.findViewById(R.id.button_bar); - list.addOnScrollListener(onScrollListener=new ElevationOnScrollListener((FragmentRootLinearLayout) view, buttonBar, getToolbar())); view.findViewById(R.id.btn_next).setOnClickListener(UiUtils.rateLimitedClickListener(this::onFollowAllClick)); - view.findViewById(R.id.btn_skip).setOnClickListener(UiUtils.rateLimitedClickListener(v->proceed())); +// view.findViewById(R.id.btn_skip).setOnClickListener(UiUtils.rateLimitedClickListener(v->proceed())); } @Override protected void onUpdateToolbar(){ super.onUpdateToolbar(); - if(onScrollListener!=null){ - onScrollListener.setViews(buttonBar, getToolbar()); - } + getToolbar().setContentInsetsRelative(V.dp(56), 0); } @Override @@ -70,7 +71,7 @@ public class OnboardingFollowSuggestionsFragment extends BaseAccountListFragment .setCallback(new SimpleCallback<>(this){ @Override public void onSuccess(List result){ - onDataLoaded(result.stream().map(fs->new AccountViewModel(fs.account, accountID)).collect(Collectors.toList()), false); + onDataLoaded(result.stream().map(fs->new AccountViewModel(fs.account, accountID).stripLinksFromBio()).collect(Collectors.toList()), false); } }) .exec(accountID); @@ -81,6 +82,20 @@ public class OnboardingFollowSuggestionsFragment extends BaseAccountListFragment super.onApplyWindowInsets(UiUtils.applyBottomInsetToFixedView(buttonBar, insets)); } + @Override + protected RecyclerView.Adapter getAdapter(){ +// Unused in Moshidon +// TextView introText=new TextView(getActivity()); +// introText.setTextAppearance(R.style.m3_body_large); +// introText.setTextColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3OnSurface)); +// introText.setPaddingRelative(V.dp(56), 0, V.dp(24), V.dp(8)); +// introText.setText(R.string.onboarding_recommendations_intro); + MergeRecyclerAdapter mergeAdapter=new MergeRecyclerAdapter(); +// mergeAdapter.addAdapter(new SingleViewRecyclerAdapter(introText)); + mergeAdapter.addAdapter(super.getAdapter()); + return mergeAdapter; + } + private void onFollowAllClick(View v){ if(!loaded || relationships.isEmpty()) return; @@ -147,15 +162,16 @@ public class OnboardingFollowSuggestionsFragment extends BaseAccountListFragment } private void proceed(){ - Bundle args=new Bundle(); - args.putString("account", accountID); - Nav.go(getActivity(), OnboardingProfileSetupFragment.class, args); +// Bundle args=new Bundle(); +// args.putString("account", accountID); +// Nav.go(getActivity(), OnboardingProfileSetupFragment.class, args); } @Override protected void onConfigureViewHolder(AccountViewHolder holder){ super.onConfigureViewHolder(holder); holder.setStyle(AccountViewHolder.AccessoryType.BUTTON, true); + holder.avatar.setOutlineProvider(OutlineProviders.roundedRect(8)); } @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/OnboardingProfileSetupFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/OnboardingProfileSetupFragment.java index 086bef065..892261e4d 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/OnboardingProfileSetupFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/OnboardingProfileSetupFragment.java @@ -11,6 +11,7 @@ import android.view.WindowInsets; import android.widget.Button; import android.widget.EditText; import android.widget.ImageView; +import android.widget.LinearLayout; import android.widget.ScrollView; import org.joinmastodon.android.R; @@ -19,12 +20,17 @@ import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.fragments.HomeFragment; import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.AccountField; +import org.joinmastodon.android.model.viewmodel.CheckableListItem; +import org.joinmastodon.android.ui.M3AlertDialogBuilder; import org.joinmastodon.android.ui.OutlineProviders; +import org.joinmastodon.android.ui.adapters.GenericListItemsAdapter; import org.joinmastodon.android.ui.utils.UiUtils; +import org.joinmastodon.android.ui.viewholders.ListItemViewHolder; import org.joinmastodon.android.ui.views.ReorderableLinearLayout; import org.joinmastodon.android.utils.ElevationOnScrollListener; import java.util.ArrayList; +import java.util.List; import me.grishka.appkit.Nav; import me.grishka.appkit.api.Callback; @@ -35,7 +41,7 @@ import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest; import me.grishka.appkit.utils.V; import me.grishka.appkit.views.FragmentRootLinearLayout; -public class OnboardingProfileSetupFragment extends ToolbarFragment implements ReorderableLinearLayout.OnDragListener{ +public class OnboardingProfileSetupFragment extends ToolbarFragment{ private Button btn; private View buttonBar; private String accountID; @@ -43,9 +49,9 @@ public class OnboardingProfileSetupFragment extends ToolbarFragment implements R private ScrollView scroller; private EditText nameEdit, bioEdit; private ImageView avaImage, coverImage; - private Button addRow; - private ReorderableLinearLayout profileFieldsLayout; private Uri avatarUri, coverUri; + private LinearLayout scrollContent; + private CheckableListItem discoverableItem; private static final int AVATAR_RESULT=348; private static final int COVER_RESULT=183; @@ -73,8 +79,6 @@ public class OnboardingProfileSetupFragment extends ToolbarFragment implements R bioEdit=view.findViewById(R.id.bio); avaImage=view.findViewById(R.id.avatar); coverImage=view.findViewById(R.id.header); - addRow=view.findViewById(R.id.add_row); - profileFieldsLayout=view.findViewById(R.id.profile_fields); btn=view.findViewById(R.id.btn_next); btn.setOnClickListener(v->onButtonClick()); @@ -86,31 +90,20 @@ public class OnboardingProfileSetupFragment extends ToolbarFragment implements R Account account=AccountSessionManager.getInstance().getAccount(accountID).self; if(savedInstanceState==null){ nameEdit.setText(account.displayName); - makeFieldsRow(); - }else{ - ArrayList fieldTitles=savedInstanceState.getStringArrayList("fieldTitles"); - ArrayList fieldValues=savedInstanceState.getStringArrayList("fieldValues"); - for(int i=0;i{ - makeFieldsRow(); - if(profileFieldsLayout.getChildCount()==4){ - addRow.setVisibility(View.GONE); - } - }); - profileFieldsLayout.setDragListener(this); avaImage.setOnClickListener(v->startActivityForResult(UiUtils.getMediaPickerIntent(new String[]{"image/*"}, 1), AVATAR_RESULT)); coverImage.setOnClickListener(v->startActivityForResult(UiUtils.getMediaPickerIntent(new String[]{"image/*"}, 1), COVER_RESULT)); + scrollContent=view.findViewById(R.id.scrollable_content); + discoverableItem=new CheckableListItem<>(R.string.make_profile_discoverable, 0, CheckableListItem.Style.SWITCH_SEPARATED, true, R.drawable.ic_campaign_24px, item->showDiscoverabilityAlert()); + GenericListItemsAdapter fakeAdapter=new GenericListItemsAdapter<>(List.of(discoverableItem)); + ListItemViewHolder holder=fakeAdapter.onCreateViewHolder(scrollContent, fakeAdapter.getItemViewType(0)); + fakeAdapter.bindViewHolder(holder, 0); + holder.itemView.setBackground(UiUtils.getThemeDrawable(getActivity(), android.R.attr.selectableItemBackground)); + holder.itemView.setOnClickListener(v->holder.onClick()); + scrollContent.addView(holder.itemView); + return view; } @@ -129,17 +122,8 @@ public class OnboardingProfileSetupFragment extends ToolbarFragment implements R } protected void onButtonClick(){ - ArrayList fields=new ArrayList<>(); - for(int i=0;i(){ @Override public void onSuccess(Account result){ @@ -163,39 +147,6 @@ public class OnboardingProfileSetupFragment extends ToolbarFragment implements R super.onApplyWindowInsets(UiUtils.applyBottomInsetToFixedView(buttonBar, insets)); } - private View makeFieldsRow(){ - View view=LayoutInflater.from(getActivity()).inflate(R.layout.onboarding_profile_field, profileFieldsLayout, false); - profileFieldsLayout.addView(view); - view.findViewById(R.id.dragger_thingy).setOnLongClickListener(v->{ - profileFieldsLayout.startDragging(view); - return true; - }); - view.findViewById(R.id.delete).setOnClickListener(v->{ - profileFieldsLayout.removeView(view); - if(addRow.getVisibility()==View.GONE) - addRow.setVisibility(View.VISIBLE); - }); - return view; - } - - @Override - public void onSwapItems(int oldIndex, int newIndex){} - - @Override - public void onSaveInstanceState(Bundle outState){ - super.onSaveInstanceState(outState); - ArrayList fieldTitles=new ArrayList<>(), fieldValues=new ArrayList<>(); - for(int i=0;i errorFields=new HashSet<>(); private ElevationOnScrollListener onScrollListener; + private Set serverSupportedTimezones, serverSupportedLocales; @Override public void onCreate(Bundle savedInstanceState){ @@ -87,6 +87,8 @@ public class SignupFragment extends ToolbarFragment{ instance=Parcels.unwrap(getArguments().getParcelable("instance")); createAppAndGetToken(); setTitle(R.string.signup_title); + serverSupportedTimezones=Arrays.stream(getResources().getStringArray(R.array.server_supported_timezones)).collect(Collectors.toSet()); + serverSupportedLocales=Arrays.stream(getResources().getStringArray(R.array.server_supported_locales)).collect(Collectors.toSet()); } @Nullable @@ -190,7 +192,36 @@ public class SignupFragment extends ToolbarFragment{ edit.setError(null); } errorFields.clear(); - new RegisterAccount(username, email, password.getText().toString(), getResources().getConfiguration().locale.getLanguage(), reason.getText().toString(), ZoneId.systemDefault().getId()) + String locale=null; + String timezone=ZoneId.systemDefault().getId(); + + if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N){ + LocaleList localeList=getResources().getConfiguration().getLocales(); + for(int i=0;i(){ @Override public void onSuccess(Token result){ @@ -271,7 +302,7 @@ public class SignupFragment extends ToolbarFragment{ @Override public void tail(Node node, int depth){ if(node instanceof Element){ - ssb.setSpan(new LinkSpan("", SignupFragment.this::onGoBackLinkClick, LinkSpan.Type.CUSTOM, null, null, null), spanStart, ssb.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + ssb.setSpan(new LinkSpan("", SignupFragment.this::onGoBackLinkClick, LinkSpan.Type.CUSTOM, null, null, null, null), spanStart, ssb.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); ssb.setSpan(new TypefaceSpan("sans-serif-medium"), spanStart, ssb.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/report/ReportCommentFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/report/ReportCommentFragment.java index 9966253f7..260dbab7f 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/report/ReportCommentFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/report/ReportCommentFragment.java @@ -100,7 +100,6 @@ public class ReportCommentFragment extends MastodonToolbarFragment{ ProgressBar topProgress=view.findViewById(R.id.top_progress); topProgress.setProgress(getArguments().containsKey("ruleIDs") ? 75 : 66); - forwardSwitch.setChecked(GlobalUserPreferences.forwardReportDefault); } @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/BaseSettingsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/BaseSettingsFragment.java index e0ee967be..6d925fd6e 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/BaseSettingsFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/BaseSettingsFragment.java @@ -1,25 +1,26 @@ package org.joinmastodon.android.fragments.settings; +import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.view.View; import android.view.WindowInsets; import org.joinmastodon.android.R; +import org.joinmastodon.android.fragments.HasAccountID; import org.joinmastodon.android.fragments.MastodonRecyclerFragment; import org.joinmastodon.android.model.viewmodel.CheckableListItem; import org.joinmastodon.android.model.viewmodel.ListItem; import org.joinmastodon.android.ui.BetterItemAnimator; import org.joinmastodon.android.ui.DividerItemDecoration; import org.joinmastodon.android.ui.adapters.GenericListItemsAdapter; -import org.joinmastodon.android.ui.viewholders.CheckableListItemViewHolder; import org.joinmastodon.android.ui.viewholders.ListItemViewHolder; import org.joinmastodon.android.ui.viewholders.SimpleListItemViewHolder; +import org.joinmastodon.android.utils.ProvidesAssistContent; import androidx.recyclerview.widget.RecyclerView; -import me.grishka.appkit.utils.V; -public abstract class BaseSettingsFragment extends MastodonRecyclerFragment>{ +public abstract class BaseSettingsFragment extends MastodonRecyclerFragment> implements HasAccountID, ProvidesAssistContent.ProvidesWebUri{ protected GenericListItemsAdapter itemsAdapter; protected String accountID; @@ -45,7 +46,7 @@ public abstract class BaseSettingsFragment extends MastodonRecyclerFragment getAdapter(){ - return itemsAdapter=new GenericListItemsAdapter(data); + return itemsAdapter=new GenericListItemsAdapter(imgLoader, data); } @Override @@ -59,12 +60,13 @@ public abstract class BaseSettingsFragment extends MastodonRecyclerFragment item){ - item.toggle(); + protected void toggleCheckableItem(ListItem item){ + if(item instanceof CheckableListItem checkable) + checkable.toggle(); rebindItem(item); } - protected void rebindItem(ListItem item){ + protected void rebindItem(ListItem item){ if(list==null) return; if(list.findViewHolderForAdapterPosition(indexOfItemsAdapter()+data.indexOf(item)) instanceof ListItemViewHolder holder){ @@ -84,4 +86,14 @@ public abstract class BaseSettingsFragment extends MastodonRecyclerFragment implements On public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); filter=Parcels.unwrap(getArguments().getParcelable("filter")); + ArrayList words=getArguments().getParcelableArrayList("words"); + if (words != null) { + words.stream().map(p->(FilterKeyword)Parcels.unwrap(p)).forEach(keywords::add); + } setTitle(filter==null ? R.string.settings_add_filter : R.string.settings_edit_filter); onDataLoaded(List.of( durationItem=new ListItem<>(R.string.settings_filter_duration, 0, this::onDurationClick), @@ -325,4 +330,8 @@ public class EditFilterFragment extends BaseSettingsFragment implements On } return false; } + @Override + public Uri getWebUri(Uri.Builder base) { + return base.path(filter == null ? "/filters/new" : "/filters/"+ filter.id + "/edit").build(); + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsAboutAppFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsAboutAppFragment.java index 509ef7cbd..87078c110 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsAboutAppFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsAboutAppFragment.java @@ -1,6 +1,14 @@ package org.joinmastodon.android.fragments.settings; import android.app.Activity; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.ComponentName; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.net.Uri; +import android.os.Build; import android.os.Bundle; import android.util.Log; import android.view.Gravity; @@ -8,6 +16,13 @@ import android.view.ViewGroup; import android.widget.TextView; import android.widget.Toast; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.ToNumberPolicy; + import org.joinmastodon.android.BuildConfig; import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.MastodonApp; @@ -15,26 +30,29 @@ import org.joinmastodon.android.R; import org.joinmastodon.android.api.MastodonAPIController; import org.joinmastodon.android.api.session.AccountSession; import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.fragments.HasAccountID; import org.joinmastodon.android.model.viewmodel.CheckableListItem; import org.joinmastodon.android.model.viewmodel.ListItem; +import org.joinmastodon.android.ui.M3AlertDialogBuilder; +import org.joinmastodon.android.ui.Snackbar; import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.updater.GithubSelfUpdater; import java.io.BufferedReader; import java.io.File; import java.io.FileInputStream; +import java.io.FileWriter; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; -import java.io.StringReader; -import java.nio.charset.Charset; -import java.nio.file.Files; +import java.io.OutputStream; import java.time.Instant; import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.time.format.FormatStyle; import java.util.ArrayList; import java.util.List; +import java.util.Map; import androidx.recyclerview.widget.RecyclerView; import me.grishka.appkit.imageloader.ImageCache; @@ -42,34 +60,41 @@ import me.grishka.appkit.utils.MergeRecyclerAdapter; import me.grishka.appkit.utils.SingleViewRecyclerAdapter; import me.grishka.appkit.utils.V; -public class SettingsAboutAppFragment extends BaseSettingsFragment{ +public class SettingsAboutAppFragment extends BaseSettingsFragment implements HasAccountID{ private static final String TAG="SettingsAboutAppFragment"; + private static final int IMPORT_RESULT=314; + private static final int EXPORT_RESULT=271; private ListItem mediaCacheItem, copyCrashLogItem; private CheckableListItem enablePreReleasesItem; private AccountSession session; private boolean timelineCacheCleared=false; private File crashLogFile=new File(MastodonApp.context.getFilesDir(), "crash.log"); + // MOSHIDON + private ListItem clearRecentEmojisItem, exportItem, importItem; @Override public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); - setTitle(getString(R.string.about_app, getString(R.string.sk_app_name))); + setTitle(getString(R.string.about_app, getString(R.string.mo_app_name))); session=AccountSessionManager.get(accountID); String lastModified=crashLogFile.exists() ? DateTimeFormatter.ofLocalizedDateTime(FormatStyle.LONG, FormatStyle.SHORT).withZone(ZoneId.systemDefault()).format(Instant.ofEpochMilli(crashLogFile.lastModified())) : getString(R.string.sk_settings_crash_log_unavailable); List> items=new ArrayList<>(List.of( - new ListItem<>(R.string.sk_settings_donate, 0, R.drawable.ic_fluent_heart_24_regular, i->UiUtils.openHashtagTimeline(getActivity(), accountID, getString(R.string.donate_hashtag))), - new ListItem<>(R.string.sk_settings_contribute, 0, R.drawable.ic_fluent_open_24_regular, i->UiUtils.launchWebBrowser(getActivity(), getString(R.string.repo_url))), + new ListItem<>(R.string.sk_settings_donate, 0, R.drawable.ic_fluent_heart_24_regular, i->UiUtils.launchWebBrowser(getActivity(), getString(R.string.mo_donate_url))), + new ListItem<>(R.string.mo_settings_contribute, 0, R.drawable.ic_fluent_open_24_regular, i->UiUtils.launchWebBrowser(getActivity(), getString(R.string.mo_repo_url))), new ListItem<>(R.string.settings_tos, 0, R.drawable.ic_fluent_open_24_regular, i->UiUtils.launchWebBrowser(getActivity(), "https://"+session.domain+"/terms")), new ListItem<>(R.string.settings_privacy_policy, 0, R.drawable.ic_fluent_open_24_regular, i->UiUtils.launchWebBrowser(getActivity(), getString(R.string.privacy_policy_url)), 0, true), + exportItem=new ListItem<>(R.string.export_settings_title, R.string.export_settings_summary, R.drawable.ic_fluent_arrow_export_24_filled, this::onExportClick), + importItem=new ListItem<>(R.string.import_settings_title, R.string.import_settings_summary, R.drawable.ic_fluent_arrow_import_24_filled, this::onImportClick, 0, true), + clearRecentEmojisItem=new ListItem<>(R.string.mo_clear_recent_emoji, 0, this::onClearRecentEmojisClick), mediaCacheItem=new ListItem<>(R.string.settings_clear_cache, 0, this::onClearMediaCacheClick), new ListItem<>(getString(R.string.sk_settings_clear_timeline_cache), session.domain, this::onClearTimelineCacheClick), copyCrashLogItem=new ListItem<>(getString(R.string.sk_settings_copy_crash_log), lastModified, 0, this::onCopyCrashLog) )); - if(GithubSelfUpdater.needSelfUpdating()){ + if(GithubSelfUpdater.needSelfUpdating() && !BuildConfig.BUILD_TYPE.equals("nightly") ){ items.add(enablePreReleasesItem=new CheckableListItem<>(R.string.sk_updater_enable_pre_releases, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.enablePreReleases, i->toggleCheckableItem(enablePreReleasesItem))); } @@ -95,12 +120,19 @@ public class SettingsAboutAppFragment extends BaseSettingsFragment{ adapter.addAdapter(super.getAdapter()); TextView versionInfo=new TextView(getActivity()); - versionInfo.setSingleLine(); versionInfo.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, V.dp(32))); versionInfo.setTextAppearance(R.style.m3_label_medium); versionInfo.setTextColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3Outline)); versionInfo.setGravity(Gravity.CENTER); - versionInfo.setText(getString(R.string.sk_settings_app_version, BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE)); + versionInfo.setText(getString(R.string.mo_settings_app_version, BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE)); + versionInfo.setOnClickListener(v->{ + getActivity().getSystemService(ClipboardManager.class).setPrimaryClip(ClipData.newPlainText("", BuildConfig.VERSION_NAME+" ("+BuildConfig.VERSION_CODE+")")); + if(Build.VERSION.SDK_INT<=Build.VERSION_CODES.S_V2){ + new Snackbar.Builder(getActivity()) + .setText(R.string.app_version_copied) + .show(); + } + }); adapter.addAdapter(new SingleViewRecyclerAdapter(versionInfo)); return adapter; @@ -123,6 +155,172 @@ public class SettingsAboutAppFragment extends BaseSettingsFragment{ timelineCacheCleared=true; } + private void onClearRecentEmojisClick(ListItem item){ + getLocalPrefs().recentCustomEmoji=new ArrayList<>(); + getLocalPrefs().save(); + Toast.makeText(getContext(), R.string.mo_recent_emoji_cleared, Toast.LENGTH_SHORT).show(); + } + + private void onExportClick(ListItem item){ + // The magic will happen on the onActivityResult Method + Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); + intent.setType("application/json"); + intent.putExtra(Intent.EXTRA_TITLE,"moshidon-exported-settings.json"); + startActivityForResult(intent, EXPORT_RESULT); + } + + private void onImportClick(ListItem item){ + new M3AlertDialogBuilder(getContext()) + .setTitle(R.string.import_settings_confirm) + .setIcon(R.drawable.ic_fluent_warning_24_regular) + .setMessage(R.string.import_settings_confirm_body) + .setPositiveButton(R.string.ok, (dialogInterface, i) -> { + Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.setType("application/json"); + startActivityForResult(intent, IMPORT_RESULT); + }) + .setNegativeButton(R.string.cancel, null) + .show(); + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data){ + if(requestCode==IMPORT_RESULT && resultCode==Activity.RESULT_OK){ + Uri uri=data.getData(); + if(uri==null){ + return; + } + try{ + InputStream inputStream=getContext().getContentResolver().openInputStream(uri); + if(inputStream==null) + return; + BufferedReader reader=new BufferedReader(new InputStreamReader(inputStream)); + StringBuilder stringBuilder=new StringBuilder(); + String line; + while((line=reader.readLine())!=null){ + stringBuilder.append(line); + } + inputStream.close(); + String jsonString=stringBuilder.toString(); + + Gson gson=new GsonBuilder().setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE).create(); + + //check if json is not null + if(jsonString.isEmpty()) { + throw new IOException(); + } + + JsonObject jsonObject=JsonParser.parseString(jsonString).getAsJsonObject(); + + //check if json has required attributes + if(!(jsonObject.has("versionName") && jsonObject.has("versionCode") && jsonObject.has("GlobalUserPreferences"))){ + Toast.makeText(getContext(), getContext().getString(R.string.import_settings_failed), Toast.LENGTH_SHORT).show(); + return; + } + String versionName=jsonObject.get("versionName").getAsString(); + int versionCode=jsonObject.get("versionCode").getAsInt(); + Log.i(TAG, "onActivityResult: Reading exported settings ("+versionName+" "+versionCode+")"); + + // retrieve GlobalUserPreferences + Map jsonGlobalPrefs=gson.fromJson(jsonObject.getAsJsonObject("GlobalUserPreferences"), Map.class); + SharedPreferences.Editor globalPrefsEditor=GlobalUserPreferences.getPrefs().edit(); + for(String key : jsonGlobalPrefs.keySet()){ + Object value=jsonGlobalPrefs.get(key); + if(value==null) + continue; + savePrefValue(globalPrefsEditor, key, value); + } + + // retrieve LocalPreferences for all logged in accounts + //TODO: maybe show a dialog for which accounts to import? + for(AccountSession accountSession : AccountSessionManager.getInstance().getLoggedInAccounts()){ + if(!jsonObject.has(accountSession.self.id)) + continue; + Map prefs=gson.fromJson(jsonObject.getAsJsonObject(accountSession.self.id), Map.class); + + SharedPreferences.Editor prefEditor=accountSession.getRawLocalPreferences().edit(); + for(String key : prefs.keySet()){ + Object value=prefs.get(key); + if(value==null) + continue; + savePrefValue(prefEditor, key, value); + } + } + + // restart app to apply new preferences + // https://stackoverflow.com/a/46848226 + PackageManager packageManager=getContext().getPackageManager(); + Intent intent=packageManager.getLaunchIntentForPackage(getContext().getPackageName()); + ComponentName componentName=intent.getComponent(); + Intent mainIntent=Intent.makeRestartActivityTask(componentName); + // Required for API 34 and later + // Ref: https://developer.android.com/about/versions/14/behavior-changes-14#safer-intents + mainIntent.setPackage(getContext().getPackageName()); + getContext().startActivity(mainIntent); + Runtime.getRuntime().exit(0); + }catch(IOException e){ + Log.w(TAG, e); + Toast.makeText(getContext(), getContext().getString(R.string.import_settings_failed), Toast.LENGTH_SHORT).show(); + } + } + + if(requestCode == EXPORT_RESULT && resultCode==Activity.RESULT_OK) { + try{ + Gson gson = new Gson(); + JsonObject jsonObject = new JsonObject(); + jsonObject.addProperty("versionName", BuildConfig.VERSION_NAME); + jsonObject.addProperty("versionCode", BuildConfig.VERSION_CODE); + + // GlobalUserPreferences + //TODO: remove prefs that should not be exported + JsonElement je = gson.toJsonTree(GlobalUserPreferences.getPrefs().getAll()); + jsonObject.add("GlobalUserPreferences", je); + + // add account local prefs + for(AccountSession accountSession: AccountSessionManager.getInstance().getLoggedInAccounts()) { + Map prefs = accountSession.getRawLocalPreferences().getAll(); + //TODO: remove prefs that should not be exported + JsonElement accountPrefs = gson.toJsonTree(prefs); + jsonObject.add(accountSession.self.id, accountPrefs); + } + + File file = new File(getContext().getCacheDir(), "moshidon-exported-settings.json"); + FileWriter writer = new FileWriter(file); + writer.write(jsonObject.toString()); + writer.flush(); + writer.close(); + + // Got this from stackoverflow at https://stackoverflow.com/a/67046741 + InputStream is = new FileInputStream(file); + OutputStream os = getContext().getContentResolver().openOutputStream(data.getData()); + + byte[] buffer = new byte[1024]; + int length; + while ((length = is.read(buffer)) > 0) { + os.write(buffer, 0, length); + } + }catch(IOException e){ + Toast.makeText(getContext(), e.getMessage(), Toast.LENGTH_SHORT); + } + } + } + + private void savePrefValue(SharedPreferences.Editor editor, String key, Object value) { + if(value.getClass().equals(Boolean.class)) + editor.putBoolean(key, (Boolean) value); + // gson parses all numbers either long (for int) or double (the rest) + else if(value.getClass().equals(Long.class)) + editor.putInt(key, ((Long) value).intValue()); + else if(value.getClass().equals(Double.class)) + editor.putFloat(key, ((Double) value).floatValue()); + else + editor.putString(key, String.valueOf(value)); + //explicitly immediately since the app will restarted soon after + // and it may not have the time to write the values in the background + editor.commit(); + } + private void updateMediaCacheItem(){ long size=ImageCache.getInstance(getActivity()).getDiskCache().size(); mediaCacheItem.subtitle=UiUtils.formatFileSize(getActivity(), size, false); @@ -130,6 +328,11 @@ public class SettingsAboutAppFragment extends BaseSettingsFragment{ rebindItem(mediaCacheItem); } + @Override + public String getAccountID(){ + return accountID; + } + private void onCopyCrashLog(ListItem item){ if(!crashLogFile.exists()) return; try(InputStream is=new FileInputStream(crashLogFile)){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsBehaviorFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsBehaviorFragment.java index 52993acf1..a1c217e4e 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsBehaviorFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsBehaviorFragment.java @@ -1,8 +1,11 @@ package org.joinmastodon.android.fragments.settings; +import android.os.Build; import android.os.Bundle; - -import androidx.annotation.StringRes; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.TextView; import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.R; @@ -20,18 +23,24 @@ import org.joinmastodon.android.utils.MastodonLanguage; import java.util.ArrayList; import java.util.List; import java.util.stream.IntStream; -import java.util.stream.Stream; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; public class SettingsBehaviorFragment extends BaseSettingsFragment implements HasAccountID{ private ListItem languageItem; - private CheckableListItem altTextItem, playGifsItem, customTabsItem, confirmUnfollowItem, confirmBoostItem, confirmDeleteItem; + private CheckableListItem altTextItem, playGifsItem, confirmUnfollowItem, confirmBoostItem, confirmDeleteItem; private MastodonLanguage postLanguage; private ComposeLanguageAlertViewController.SelectedOption newPostLanguage; // MEGALODON private MastodonLanguage.LanguageResolver languageResolver; - private ListItem prefixRepliesItem, replyVisibilityItem; - private CheckableListItem forwardReportsItem, remoteLoadingItem, showBoostsItem, showRepliesItem, loadNewPostsItem, seeNewPostsBtnItem, overlayMediaItem; + private ListItem prefixRepliesItem, replyVisibilityItem, customTabsItem; + private CheckableListItem remoteLoadingItem, showBoostsItem, showRepliesItem, loadNewPostsItem, seeNewPostsBtnItem, overlayMediaItem; + + // MOSHIDON + private CheckableListItem mentionRebloggerAutomaticallyItem, hapticFeedbackItem, showPostsWithoutAltItem; @Override public void onCreate(Bundle savedInstanceState){ @@ -45,18 +54,20 @@ public class SettingsBehaviorFragment extends BaseSettingsFragment impleme languageResolver.from(s.preferences.postingDefaultLanguage).orElse(null); List> items = new ArrayList<>(List.of( + customTabsItem=new ListItem<>(getString(R.string.settings_custom_tabs), getString(GlobalUserPreferences.useCustomTabs ? R.string.in_app_browser : R.string.system_browser), R.drawable.ic_fluent_open_24_regular, this::onCustomTabsClick), altTextItem=new CheckableListItem<>(R.string.settings_alt_text_reminders, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.altTextReminders, R.drawable.ic_fluent_image_alt_text_24_regular, i->toggleCheckableItem(altTextItem)), - playGifsItem=new CheckableListItem<>(R.string.settings_gif, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.playGifs, R.drawable.ic_fluent_gif_24_regular, i->toggleCheckableItem(playGifsItem)), + showPostsWithoutAltItem=new CheckableListItem<>(R.string.mo_settings_show_posts_without_alt, R.string.mo_settings_show_posts_without_alt_summary, CheckableListItem.Style.SWITCH, GlobalUserPreferences.showPostsWithoutAlt, R.drawable.ic_fluent_eye_tracking_on_24_regular, i->toggleCheckableItem(showPostsWithoutAltItem)), + playGifsItem=new CheckableListItem<>(R.string.settings_gif, R.string.mo_setting_play_gif_summary, CheckableListItem.Style.SWITCH, GlobalUserPreferences.playGifs, R.drawable.ic_fluent_gif_24_regular, i->toggleCheckableItem(playGifsItem)), overlayMediaItem=new CheckableListItem<>(R.string.sk_settings_continues_playback, R.string.sk_settings_continues_playback_summary, CheckableListItem.Style.SWITCH, GlobalUserPreferences.overlayMedia, R.drawable.ic_fluent_play_circle_hint_24_regular, i->toggleCheckableItem(overlayMediaItem)), - customTabsItem=new CheckableListItem<>(R.string.settings_custom_tabs, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.useCustomTabs, R.drawable.ic_fluent_link_24_regular, i->toggleCheckableItem(customTabsItem)), confirmUnfollowItem=new CheckableListItem<>(R.string.settings_confirm_unfollow, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.confirmUnfollow, R.drawable.ic_fluent_person_delete_24_regular, i->toggleCheckableItem(confirmUnfollowItem)), confirmBoostItem=new CheckableListItem<>(R.string.settings_confirm_boost, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.confirmBoost, R.drawable.ic_fluent_arrow_repeat_all_24_regular, i->toggleCheckableItem(confirmBoostItem)), confirmDeleteItem=new CheckableListItem<>(R.string.settings_confirm_delete_post, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.confirmDeletePost, R.drawable.ic_fluent_delete_24_regular, i->toggleCheckableItem(confirmDeleteItem)), prefixRepliesItem=new ListItem<>(R.string.sk_settings_prefix_reply_cw_with_re, getPrefixWithRepliesString(), R.drawable.ic_fluent_arrow_reply_24_regular, this::onPrefixRepliesClick), - forwardReportsItem=new CheckableListItem<>(R.string.sk_settings_forward_report_default, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.forwardReportDefault, R.drawable.ic_fluent_arrow_forward_24_regular, i->toggleCheckableItem(forwardReportsItem)), loadNewPostsItem=new CheckableListItem<>(R.string.sk_settings_load_new_posts, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.loadNewPosts, R.drawable.ic_fluent_arrow_sync_24_regular, i->onLoadNewPostsClick()), seeNewPostsBtnItem=new CheckableListItem<>(R.string.sk_settings_see_new_posts_button, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.showNewPostsButton, R.drawable.ic_fluent_arrow_up_24_regular, i->toggleCheckableItem(seeNewPostsBtnItem)), - remoteLoadingItem=new CheckableListItem<>(R.string.sk_settings_allow_remote_loading, R.string.sk_settings_allow_remote_loading_explanation, CheckableListItem.Style.SWITCH, GlobalUserPreferences.allowRemoteLoading, R.drawable.ic_fluent_communication_24_regular, i->toggleCheckableItem(remoteLoadingItem), true), + hapticFeedbackItem=new CheckableListItem<>(R.string.mo_haptic_feedback, R.string.mo_setting_haptic_feedback_summary, CheckableListItem.Style.SWITCH, GlobalUserPreferences.hapticFeedback, R.drawable.ic_fluent_phone_vibrate_24_regular, i->toggleCheckableItem(hapticFeedbackItem)), + remoteLoadingItem=new CheckableListItem<>(R.string.sk_settings_allow_remote_loading, R.string.sk_settings_allow_remote_loading_explanation, CheckableListItem.Style.SWITCH, GlobalUserPreferences.allowRemoteLoading, R.drawable.ic_fluent_communication_24_regular, i->toggleCheckableItem(remoteLoadingItem)), + mentionRebloggerAutomaticallyItem=new CheckableListItem<>(R.string.mo_mention_reblogger_automatically, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.mentionRebloggerAutomatically, R.drawable.ic_fluent_comment_mention_24_regular, i->toggleCheckableItem(mentionRebloggerAutomaticallyItem), true), showBoostsItem=new CheckableListItem<>(R.string.sk_settings_show_boosts, 0, CheckableListItem.Style.SWITCH, lp.showBoosts, R.drawable.ic_fluent_arrow_repeat_all_24_regular, i->toggleCheckableItem(showBoostsItem)), showRepliesItem=new CheckableListItem<>(R.string.sk_settings_show_replies, 0, CheckableListItem.Style.SWITCH, lp.showReplies, R.drawable.ic_fluent_arrow_reply_24_regular, i->toggleCheckableItem(showRepliesItem)) )); @@ -164,20 +175,50 @@ public class SettingsBehaviorFragment extends BaseSettingsFragment impleme rebindItem(seeNewPostsBtnItem); } + private void onCustomTabsClick(ListItem item){ +// GlobalUserPreferences.useCustomTabs=customTabsItem.checked; + ArrayAdapter adapter=new ArrayAdapter<>(getActivity(), R.layout.item_alert_single_choice_2lines_but_different, R.id.text, + new String[]{getString(R.string.in_app_browser), getString(R.string.system_browser)}){ + @Override + public boolean hasStableIds(){ + return true; + } + + @NonNull + @Override + public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent){ + View view=super.getView(position, convertView, parent); + TextView subtitle=view.findViewById(R.id.subtitle); + subtitle.setVisibility(View.GONE); + return view; + } + }; + new M3AlertDialogBuilder(getActivity()) + .setTitle(R.string.settings_custom_tabs) + .setSingleChoiceItems(adapter, GlobalUserPreferences.useCustomTabs ? 0 : 1, (dlg, which)->{ + GlobalUserPreferences.useCustomTabs=which==0; + customTabsItem.subtitleRes=GlobalUserPreferences.useCustomTabs ? R.string.in_app_browser : R.string.system_browser; + rebindItem(customTabsItem); + dlg.dismiss(); + }) + .show(); + } + @Override protected void onHidden(){ super.onHidden(); GlobalUserPreferences.playGifs=playGifsItem.checked; GlobalUserPreferences.overlayMedia=overlayMediaItem.checked; - GlobalUserPreferences.useCustomTabs=customTabsItem.checked; GlobalUserPreferences.altTextReminders=altTextItem.checked; GlobalUserPreferences.confirmUnfollow=confirmUnfollowItem.checked; GlobalUserPreferences.confirmBoost=confirmBoostItem.checked; GlobalUserPreferences.confirmDeletePost=confirmDeleteItem.checked; - GlobalUserPreferences.forwardReportDefault=forwardReportsItem.checked; GlobalUserPreferences.loadNewPosts=loadNewPostsItem.checked; GlobalUserPreferences.showNewPostsButton=seeNewPostsBtnItem.checked; GlobalUserPreferences.allowRemoteLoading=remoteLoadingItem.checked; + GlobalUserPreferences.mentionRebloggerAutomatically=mentionRebloggerAutomaticallyItem.checked; + GlobalUserPreferences.hapticFeedback=hapticFeedbackItem.checked; + GlobalUserPreferences.showPostsWithoutAlt=showPostsWithoutAltItem.checked; GlobalUserPreferences.save(); AccountLocalPreferences lp=getLocalPrefs(); boolean restartPlease=lp.showBoosts!=showBoostsItem.checked diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsDebugFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsDebugFragment.java index bc3b59496..f67af8a72 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsDebugFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsDebugFragment.java @@ -1,10 +1,9 @@ package org.joinmastodon.android.fragments.settings; -import android.content.Context; -import android.content.SharedPreferences; import android.os.Bundle; +import android.widget.Toast; -import org.joinmastodon.android.MastodonApp; +import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.api.session.AccountActivationInfo; import org.joinmastodon.android.api.session.AccountSession; import org.joinmastodon.android.api.session.AccountSessionManager; @@ -28,7 +27,8 @@ public class SettingsDebugFragment extends BaseSettingsFragment{ new ListItem<>("Test email confirmation flow", null, this::onTestEmailConfirmClick), selfUpdateItem=new ListItem<>("Force self-update", null, this::onForceSelfUpdateClick), resetUpdateItem=new ListItem<>("Reset self-updater", null, this::onResetUpdaterClick), - new ListItem<>("Reset search info banners", null, this::onResetDiscoverBannersClick) + new ListItem<>("Reset search info banners", null, this::onResetDiscoverBannersClick), + new ListItem<>("Reset pre-reply sheets", null, this::onResetPreReplySheetsClick) )); if(!GithubSelfUpdater.needSelfUpdating()){ resetUpdateItem.isEnabled=selfUpdateItem.isEnabled=false; @@ -65,6 +65,12 @@ public class SettingsDebugFragment extends BaseSettingsFragment{ restartUI(); } + private void onResetPreReplySheetsClick(ListItem item){ + // TODO fix this +// GlobalUserPreferences.resetPreReplySheets(); + Toast.makeText(getActivity(), "Pre-reply sheets were reset", Toast.LENGTH_SHORT).show(); + } + private void restartUI(){ Bundle args=new Bundle(); args.putString("account", accountID); diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsDisplayFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsDisplayFragment.java index dcfde4ae5..9e675ee8c 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsDisplayFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsDisplayFragment.java @@ -6,6 +6,7 @@ import android.graphics.Bitmap; import android.graphics.Canvas; import android.os.Build; import android.os.Bundle; +import android.provider.Settings; import android.text.TextUtils; import android.view.View; import android.view.WindowManager; @@ -45,6 +46,9 @@ public class SettingsDisplayFragment extends BaseSettingsFragment{ private ListItem colorItem, publishTextItem, autoRevealCWsItem; private CheckableListItem pronounsInUserListingsItem, pronounsInTimelinesItem, pronounsInThreadsItem; + // MOSHIDON + private CheckableListItem enableDoubleTapToSwipeItem, relocatePublishButtonItem, showPostDividersItem, enableDoubleTapToSearchItem, showMediaPreviewItem; + private AccountLocalPreferences lp; @Override @@ -56,16 +60,20 @@ public class SettingsDisplayFragment extends BaseSettingsFragment{ onDataLoaded(List.of( themeItem=new ListItem<>(R.string.settings_theme, getAppearanceValue(), R.drawable.ic_fluent_weather_moon_24_regular, this::onAppearanceClick), colorItem=new ListItem<>(getString(R.string.sk_settings_color_palette), getColorPaletteValue(), R.drawable.ic_fluent_color_24_regular, this::onColorClick), - trueBlackModeItem=new CheckableListItem<>(R.string.sk_settings_true_black, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.trueBlackTheme, R.drawable.ic_fluent_dark_theme_24_regular, i->onTrueBlackModeClick(), true), + trueBlackModeItem=new CheckableListItem<>(R.string.sk_settings_true_black, R.string.mo_setting_true_black_summary, CheckableListItem.Style.SWITCH, GlobalUserPreferences.trueBlackTheme, R.drawable.ic_fluent_dark_theme_24_regular, i->onTrueBlackModeClick(), true), publishTextItem=new ListItem<>(getString(R.string.sk_settings_publish_button_text), getPublishButtonText(), R.drawable.ic_fluent_send_24_regular, this::onPublishTextClick), autoRevealCWsItem=new ListItem<>(R.string.sk_settings_auto_reveal_equal_spoilers, getAutoRevealSpoilersText(), R.drawable.ic_fluent_eye_24_regular, this::onAutoRevealSpoilersClick), + relocatePublishButtonItem=new CheckableListItem<>(R.string.mo_relocate_publish_button, R.string.mo_setting_relocate_publish_summary, CheckableListItem.Style.SWITCH, GlobalUserPreferences.relocatePublishButton, R.drawable.ic_fluent_arrow_autofit_down_24_regular, i->toggleCheckableItem(relocatePublishButtonItem)), revealCWsItem=new CheckableListItem<>(R.string.sk_settings_always_reveal_content_warnings, 0, CheckableListItem.Style.SWITCH, lp.revealCWs, R.drawable.ic_fluent_chat_warning_24_regular, i->toggleCheckableItem(revealCWsItem)), hideSensitiveMediaItem=new CheckableListItem<>(R.string.settings_hide_sensitive_media, 0, CheckableListItem.Style.SWITCH, lp.hideSensitiveMedia, R.drawable.ic_fluent_flag_24_regular, i->toggleCheckableItem(hideSensitiveMediaItem)), - interactionCountsItem=new CheckableListItem<>(R.string.settings_show_interaction_counts, 0, CheckableListItem.Style.SWITCH, lp.showInteractionCounts, R.drawable.ic_fluent_number_row_24_regular, i->toggleCheckableItem(interactionCountsItem)), + showMediaPreviewItem=new CheckableListItem<>(R.string.mo_show_media_preview, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.showMediaPreview, R.drawable.ic_fluent_image_24_regular, i->toggleCheckableItem(showMediaPreviewItem)), + interactionCountsItem=new CheckableListItem<>(R.string.settings_show_interaction_counts, R.string.mo_setting_interaction_count_summary, CheckableListItem.Style.SWITCH, lp.showInteractionCounts, R.drawable.ic_fluent_number_row_24_regular, i->toggleCheckableItem(interactionCountsItem)), emojiInNamesItem=new CheckableListItem<>(R.string.settings_show_emoji_in_names, 0, CheckableListItem.Style.SWITCH, lp.customEmojiInNames, R.drawable.ic_fluent_emoji_24_regular, i->toggleCheckableItem(emojiInNamesItem)), - marqueeItem=new CheckableListItem<>(R.string.sk_settings_enable_marquee, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.toolbarMarquee, R.drawable.ic_fluent_text_more_24_regular, i->toggleCheckableItem(marqueeItem)), - reduceMotionItem=new CheckableListItem<>(R.string.sk_settings_reduce_motion, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.reduceMotion, R.drawable.ic_fluent_star_emphasis_24_regular, i->toggleCheckableItem(reduceMotionItem)), - disableSwipeItem=new CheckableListItem<>(R.string.sk_settings_tabs_disable_swipe, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.disableSwipe, R.drawable.ic_fluent_swipe_right_24_regular, i->toggleCheckableItem(disableSwipeItem)), + marqueeItem=new CheckableListItem<>(R.string.sk_settings_enable_marquee, R.string.mo_setting_marquee_summary, CheckableListItem.Style.SWITCH, GlobalUserPreferences.toolbarMarquee, R.drawable.ic_fluent_text_more_24_regular, i->toggleCheckableItem(marqueeItem)), + reduceMotionItem=new CheckableListItem<>(R.string.sk_settings_reduce_motion, R.string.mo_setting_reduced_motion_summary, CheckableListItem.Style.SWITCH, GlobalUserPreferences.reduceMotion, R.drawable.ic_fluent_star_emphasis_24_regular, i->toggleCheckableItem(reduceMotionItem)), + enableDoubleTapToSearchItem=new CheckableListItem<>(R.string.mo_double_tap_to_search, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.doubleTapToSearch, R.drawable.ic_fluent_search_24_regular, i->toggleCheckableItem(enableDoubleTapToSearchItem)), + disableSwipeItem=new CheckableListItem<>(R.string.sk_settings_tabs_disable_swipe, R.string.mo_setting_disable_swipe_summary, CheckableListItem.Style.SWITCH, GlobalUserPreferences.disableSwipe, R.drawable.ic_fluent_swipe_right_24_regular, i->toggleCheckableItem(disableSwipeItem)), + enableDoubleTapToSwipeItem=new CheckableListItem<>(R.string.mo_double_tap_to_swipe_between_tabs, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.doubleTapToSwipe, R.drawable.ic_fluent_double_tap_swipe_right_24_regular, i->toggleCheckableItem(enableDoubleTapToSwipeItem)), altIndicatorItem=new CheckableListItem<>(R.string.sk_settings_show_alt_indicator, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.showAltIndicator, R.drawable.ic_fluent_scan_text_24_regular, i->toggleCheckableItem(altIndicatorItem)), noAltIndicatorItem=new CheckableListItem<>(R.string.sk_settings_show_no_alt_indicator, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.showNoAltIndicator, R.drawable.ic_fluent_important_24_regular, i->toggleCheckableItem(noAltIndicatorItem)), collapsePostsItem=new CheckableListItem<>(R.string.sk_settings_collapse_long_posts, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.collapseLongPosts, R.drawable.ic_fluent_chevron_down_24_regular, i->toggleCheckableItem(collapsePostsItem)), @@ -74,6 +82,7 @@ public class SettingsDisplayFragment extends BaseSettingsFragment{ translateOpenedItem=new CheckableListItem<>(R.string.sk_settings_translate_only_opened, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.translateButtonOpenedOnly, R.drawable.ic_fluent_translate_24_regular, i->toggleCheckableItem(translateOpenedItem)), likeIconItem=new CheckableListItem<>(R.string.sk_settings_like_icon, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.likeIcon, R.drawable.ic_fluent_heart_24_regular, i->toggleCheckableItem(likeIconItem)), underlinedLinksItem=new CheckableListItem<>(R.string.sk_settings_underlined_links, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.underlinedLinks, R.drawable.ic_fluent_text_underline_24_regular, i->toggleCheckableItem(underlinedLinksItem)), + showPostDividersItem=new CheckableListItem<>(R.string.mo_enable_dividers, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.showDividers, R.drawable.ic_fluent_timeline_24_regular, i->toggleCheckableItem(showPostDividersItem)), disablePillItem=new CheckableListItem<>(R.string.sk_disable_pill_shaped_active_indicator, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.disableM3PillActiveIndicator, R.drawable.ic_fluent_pill_24_regular, i->toggleCheckableItem(disablePillItem)), showNavigationLabelsItem=new CheckableListItem<>(R.string.sk_settings_show_labels_in_navigation_bar, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.showNavigationLabels, R.drawable.ic_fluent_tag_24_regular, i->toggleCheckableItem(showNavigationLabelsItem), true), pronounsInTimelinesItem=new CheckableListItem<>(R.string.sk_settings_display_pronouns_in_timelines, 0, CheckableListItem.Style.CHECKBOX, GlobalUserPreferences.displayPronounsInTimelines, 0, i->toggleCheckableItem(pronounsInTimelinesItem)), @@ -102,6 +111,8 @@ public class SettingsDisplayFragment extends BaseSettingsFragment{ boolean restartPlease=GlobalUserPreferences.disableM3PillActiveIndicator!=disablePillItem.checked || GlobalUserPreferences.showNavigationLabels!=showNavigationLabelsItem.checked + || GlobalUserPreferences.showMediaPreview!=showMediaPreviewItem.checked + || GlobalUserPreferences.showDividers!=showPostDividersItem.checked || GlobalUserPreferences.likeIcon!=likeIconItem.checked; lp.revealCWs=revealCWsItem.checked; @@ -110,8 +121,11 @@ public class SettingsDisplayFragment extends BaseSettingsFragment{ lp.customEmojiInNames=emojiInNamesItem.checked; lp.save(); GlobalUserPreferences.toolbarMarquee=marqueeItem.checked; + GlobalUserPreferences.relocatePublishButton=relocatePublishButtonItem.checked; GlobalUserPreferences.reduceMotion=reduceMotionItem.checked; GlobalUserPreferences.disableSwipe=disableSwipeItem.checked; + GlobalUserPreferences.doubleTapToSearch=enableDoubleTapToSearchItem.checked; + GlobalUserPreferences.doubleTapToSwipe=enableDoubleTapToSwipeItem.checked; GlobalUserPreferences.showAltIndicator=altIndicatorItem.checked; GlobalUserPreferences.showNoAltIndicator=noAltIndicatorItem.checked; GlobalUserPreferences.collapseLongPosts=collapsePostsItem.checked; @@ -120,11 +134,13 @@ public class SettingsDisplayFragment extends BaseSettingsFragment{ GlobalUserPreferences.translateButtonOpenedOnly=translateOpenedItem.checked; GlobalUserPreferences.likeIcon=likeIconItem.checked; GlobalUserPreferences.underlinedLinks=underlinedLinksItem.checked; + GlobalUserPreferences.showDividers=showPostDividersItem.checked; GlobalUserPreferences.disableM3PillActiveIndicator=disablePillItem.checked; GlobalUserPreferences.showNavigationLabels=showNavigationLabelsItem.checked; GlobalUserPreferences.displayPronounsInTimelines=pronounsInTimelinesItem.checked; GlobalUserPreferences.displayPronounsInThreads=pronounsInThreadsItem.checked; GlobalUserPreferences.displayPronounsInUserListings=pronounsInUserListingsItem.checked; + GlobalUserPreferences.showMediaPreview=showMediaPreviewItem.checked; GlobalUserPreferences.save(); if(restartPlease) restartActivityToApplyNewTheme(); else E.post(new StatusDisplaySettingsChangedEvent(accountID)); diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsFiltersFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsFiltersFragment.java index b2a40c953..d0cdb6f78 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsFiltersFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsFiltersFragment.java @@ -1,5 +1,6 @@ package org.joinmastodon.android.fragments.settings; +import android.net.Uri; import android.os.Bundle; import com.squareup.otto.Subscribe; @@ -107,4 +108,9 @@ public class SettingsFiltersFragment extends BaseSettingsFragment{ data.add(makeListItem(ev.filter)); itemsAdapter.notifyItemInserted(data.size()-1); } + + @Override + public Uri getWebUri(Uri.Builder base) { + return base.path("/filters").build(); + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsMainFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsMainFragment.java index 03bdc8e19..a9357f9c7 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsMainFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsMainFragment.java @@ -1,6 +1,5 @@ package org.joinmastodon.android.fragments.settings; -import android.content.Intent; import android.os.Bundle; import android.view.View; import android.widget.Button; @@ -18,7 +17,7 @@ import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.events.SelfUpdateStateChangedEvent; import org.joinmastodon.android.model.Instance; import org.joinmastodon.android.model.viewmodel.ListItem; -import org.joinmastodon.android.ui.AccountSwitcherSheet; +import org.joinmastodon.android.ui.sheets.AccountSwitcherSheet; import org.joinmastodon.android.ui.M3AlertDialogBuilder; import org.joinmastodon.android.ui.utils.HideableSingleViewRecyclerAdapter; import org.joinmastodon.android.ui.utils.UiUtils; @@ -59,7 +58,7 @@ public class SettingsMainFragment extends BaseSettingsFragment{ new ListItem<>(R.string.settings_privacy, 0, R.drawable.ic_fluent_shield_24_regular, this::onPrivacyClick), new ListItem<>(R.string.settings_notifications, 0, R.drawable.ic_fluent_alert_24_regular, this::onNotificationsClick), new ListItem<>(R.string.sk_settings_instance, 0, R.drawable.ic_fluent_server_24_regular, this::onInstanceClick), - new ListItem<>(getString(R.string.about_app, getString(R.string.sk_app_name)), null, R.drawable.ic_fluent_info_24_regular, this::onAboutClick, null, 0, true), + new ListItem<>(getString(R.string.about_app, getString(R.string.mo_app_name)), null, R.drawable.ic_fluent_info_24_regular, this::onAboutClick, null, 0, true), new ListItem<>(R.string.manage_accounts, 0, R.drawable.ic_fluent_person_swap_24_regular, this::onManageAccountsClick), new ListItem<>(R.string.log_out, 0, R.drawable.ic_fluent_sign_out_24_regular, this::onLogOutClick, R.attr.colorM3Error, false) )); @@ -166,6 +165,7 @@ public class SettingsMainFragment extends BaseSettingsFragment{ private void onLogOutClick(ListItem item_){ AccountSession session=AccountSessionManager.getInstance().getAccount(accountID); new M3AlertDialogBuilder(getActivity()) + .setTitle(R.string.log_out) .setMessage(getString(R.string.confirm_log_out, session.getFullUsername())) .setPositiveButton(R.string.log_out, (dialog, which)->account.logOut(getActivity(), ()->{ loggedOut=true; diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsNotificationsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsNotificationsFragment.java index 9c6d09632..60804eccc 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsNotificationsFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsNotificationsFragment.java @@ -1,7 +1,5 @@ package org.joinmastodon.android.fragments.settings; -import static org.unifiedpush.android.connector.UnifiedPush.getDistributor; - import android.app.AlertDialog; import android.app.NotificationManager; import android.content.Intent; @@ -15,6 +13,7 @@ import android.widget.ImageView; import android.widget.RelativeLayout; import android.widget.TextView; +import org.joinmastodon.android.BuildConfig; import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.R; import org.joinmastodon.android.api.PushSubscriptionManager; @@ -48,6 +47,7 @@ public class SettingsNotificationsFragment extends BaseSettingsFragment{ private HideableSingleViewRecyclerAdapter bannerAdapter; private ImageView bannerIcon; private TextView bannerText; + private TextView bannerTitle; private Button bannerButton; private CheckableListItem mentionsItem, boostsItem, favoritesItem, followersItem, pollsItem; @@ -60,6 +60,9 @@ public class SettingsNotificationsFragment extends BaseSettingsFragment{ private CheckableListItem uniformIconItem, deleteItem, onlyLatestItem, unifiedPushItem; private CheckableListItem postsItem, updateItem; + // MOSHIDON + private CheckableListItem swapBookmarkWithReblogItem; + private AccountLocalPreferences lp; @Override @@ -69,7 +72,7 @@ public class SettingsNotificationsFragment extends BaseSettingsFragment{ lp=AccountSessionManager.get(accountID).getLocalPreferences(); getPushSubscription(); - useUnifiedPush=!getDistributor(getContext()).isEmpty(); + useUnifiedPush=!UnifiedPush.getDistributor(getContext()).isEmpty(); onDataLoaded(List.of( pauseItem=new CheckableListItem<>(getString(R.string.pause_all_notifications), getPauseItemSubtitle(), CheckableListItem.Style.SWITCH, false, R.drawable.ic_fluent_alert_snooze_24_regular, i->onPauseNotificationsClick(false)), @@ -83,7 +86,8 @@ public class SettingsNotificationsFragment extends BaseSettingsFragment{ updateItem=new CheckableListItem<>(R.string.sk_notification_type_update, 0, CheckableListItem.Style.CHECKBOX, pushSubscription.alerts.update, R.drawable.ic_fluent_history_24_regular, i->toggleCheckableItem(updateItem)), postsItem=new CheckableListItem<>(R.string.sk_notification_type_posts, 0, CheckableListItem.Style.CHECKBOX, pushSubscription.alerts.status, R.drawable.ic_fluent_chat_24_regular, i->toggleCheckableItem(postsItem), true), - uniformIconItem=new CheckableListItem<>(R.string.sk_settings_uniform_icon_for_notifications, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.uniformNotificationIcon, R.drawable.ic_ntf_logo, i->toggleCheckableItem(uniformIconItem)), + uniformIconItem=new CheckableListItem<>(R.string.sk_settings_uniform_icon_for_notifications, R.string.mo_setting_uniform_summary, CheckableListItem.Style.SWITCH, GlobalUserPreferences.uniformNotificationIcon, R.drawable.ic_ntf_logo, i->toggleCheckableItem(uniformIconItem)), + swapBookmarkWithReblogItem=new CheckableListItem<>(R.string.mo_swap_bookmark_with_reblog, R.string.mo_swap_bookmark_with_reblog_summary, CheckableListItem.Style.SWITCH, GlobalUserPreferences.swapBookmarkWithBoostAction, R.drawable.ic_boost, i->toggleCheckableItem(swapBookmarkWithReblogItem)), deleteItem=new CheckableListItem<>(R.string.sk_settings_enable_delete_notifications, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.enableDeleteNotifications, R.drawable.ic_fluent_mail_inbox_dismiss_24_regular, i->toggleCheckableItem(deleteItem)), onlyLatestItem=new CheckableListItem<>(R.string.sk_settings_single_notification, 0, CheckableListItem.Style.SWITCH, lp.keepOnlyLatestNotification, R.drawable.ic_fluent_convert_range_24_regular, i->toggleCheckableItem(onlyLatestItem), true), unifiedPushItem=new CheckableListItem<>(R.string.sk_settings_unifiedpush, 0, CheckableListItem.Style.SWITCH, useUnifiedPush, R.drawable.ic_fluent_alert_arrow_up_24_regular, i->onUnifiedPushClick(), true) @@ -116,6 +120,7 @@ public class SettingsNotificationsFragment extends BaseSettingsFragment{ || pollsItem.checked!=ps.alerts.poll; GlobalUserPreferences.uniformNotificationIcon=uniformIconItem.checked; GlobalUserPreferences.enableDeleteNotifications=deleteItem.checked; + GlobalUserPreferences.swapBookmarkWithBoostAction=swapBookmarkWithReblogItem.checked; GlobalUserPreferences.save(); lp.keepOnlyLatestNotification=onlyLatestItem.checked; lp.save(); @@ -153,6 +158,7 @@ public class SettingsNotificationsFragment extends BaseSettingsFragment{ @Override protected RecyclerView.Adapter getAdapter(){ View banner=getActivity().getLayoutInflater().inflate(R.layout.item_settings_banner, list, false); + bannerTitle=banner.findViewById(R.id.title); bannerText=banner.findViewById(R.id.text); bannerIcon=banner.findViewById(R.id.icon); bannerButton=banner.findViewById(R.id.button); @@ -160,12 +166,6 @@ public class SettingsNotificationsFragment extends BaseSettingsFragment{ bannerAdapter.setVisible(false); banner.findViewById(R.id.button2).setVisibility(View.GONE); banner.findViewById(R.id.title).setVisibility(View.GONE); - ((RelativeLayout.LayoutParams) bannerText.getLayoutParams()) - .setMargins(0, V.dp(4), 0, 0); - ((RelativeLayout.LayoutParams) bannerIcon.getLayoutParams()) - .addRule(RelativeLayout.CENTER_VERTICAL); - RelativeLayout.LayoutParams buttonParams = (RelativeLayout.LayoutParams) bannerButton.getLayoutParams(); - buttonParams.setMargins(buttonParams.leftMargin, V.dp(-8), buttonParams.rightMargin, V.dp(-12)); mergeAdapter=new MergeRecyclerAdapter(); mergeAdapter.addAdapter(bannerAdapter); @@ -316,6 +316,20 @@ public class SettingsNotificationsFragment extends BaseSettingsFragment{ bannerText.setText(R.string.notifications_disabled_in_system); bannerButton.setText(R.string.open_system_notification_settings); bannerButton.setOnClickListener(v->openSystemNotificationSettings()); + }else if(BuildConfig.BUILD_TYPE.equals("fdroidRelease") && UnifiedPush.getDistributor(getContext()).isEmpty()){ + bannerAdapter.setVisible(true); + bannerIcon.setImageResource(R.drawable.ic_fluent_warning_24_filled); + bannerTitle.setVisibility(View.VISIBLE); + bannerTitle.setText(R.string.mo_settings_unifiedpush_warning); + if(UnifiedPush.getDistributors(getContext(), new ArrayList<>()).isEmpty()) { + bannerText.setText(R.string.mo_settings_unifiedpush_warning_no_distributors); + bannerButton.setText(R.string.info); + bannerButton.setOnClickListener(v->UiUtils.launchWebBrowser(getContext(), "https://unifiedpush.org/")); + } else { + bannerText.setText(R.string.mo_settings_unifiedpush_warning_disabled); + bannerButton.setText(R.string.mo_settings_unifiedpush_enable); + bannerButton.setOnClickListener(v->onUnifiedPushClick()); + } }else if(pauseTime>System.currentTimeMillis()){ bannerAdapter.setVisible(true); bannerIcon.setImageResource(R.drawable.ic_fluent_alert_snooze_24_regular); @@ -328,7 +342,7 @@ public class SettingsNotificationsFragment extends BaseSettingsFragment{ } private void onUnifiedPushClick(){ - if(getDistributor(getContext()).isEmpty()){ + if(UnifiedPush.getDistributor(getContext()).isEmpty()){ List distributors = UnifiedPush.getDistributors(getContext(), new ArrayList<>()); showUnifiedPushRegisterDialog(distributors); return; @@ -364,4 +378,9 @@ public class SettingsNotificationsFragment extends BaseSettingsFragment{ rebindItem(unifiedPushItem); }).setOnCancelListener(d->rebindItem(unifiedPushItem)).show(); } + + @Override + public Uri getWebUri(Uri.Builder base) { + return base.path("/settings/preferences/notifications").build(); + } } \ No newline at end of file diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsPrivacyFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsPrivacyFragment.java index 6f8e76830..fab0e373c 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsPrivacyFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsPrivacyFragment.java @@ -4,6 +4,7 @@ import android.os.Bundle; import androidx.annotation.StringRes; +import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.R; import org.joinmastodon.android.api.session.AccountSession; import org.joinmastodon.android.api.session.AccountSessionManager; @@ -23,6 +24,10 @@ public class SettingsPrivacyFragment extends BaseSettingsFragment{ private StatusPrivacy privacy=null; private Instance instance; + //MOSHIDON + private CheckableListItem unlistedRepliesItem, removeTrackingParams; + + @Override public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); @@ -32,7 +37,9 @@ public class SettingsPrivacyFragment extends BaseSettingsFragment{ instance=AccountSessionManager.getInstance().getInstanceInfo(session.domain); privacy=self.source.privacy; onDataLoaded(List.of( - privacyItem=new ListItem<>(R.string.sk_settings_default_visibility, getPrivacyString(privacy), R.drawable.ic_fluent_eye_24_regular, this::onPrivacyClick, 0, true), + privacyItem=new ListItem<>(R.string.sk_settings_default_visibility, getPrivacyString(privacy), R.drawable.ic_fluent_eye_24_regular, this::onPrivacyClick, 0, false), + unlistedRepliesItem=new CheckableListItem<>(R.string.mo_change_default_reply_visibility_to_unlisted, R.string.mo_setting_default_reply_privacy_summary, CheckableListItem.Style.SWITCH, GlobalUserPreferences.defaultToUnlistedReplies, R.drawable.ic_fluent_lock_open_24_regular, i->toggleCheckableItem(unlistedRepliesItem)), + removeTrackingParams=new CheckableListItem<>(R.string.mo_settings_remove_tracking_params, R.string.mo_settings_remove_tracking_params_summary, CheckableListItem.Style.SWITCH, GlobalUserPreferences.removeTrackingParams, R.drawable.ic_fluent_eye_tracking_off_24_filled, i->toggleCheckableItem(removeTrackingParams), true), lockedItem=new CheckableListItem<>(R.string.sk_settings_lock_account, 0, CheckableListItem.Style.SWITCH, self.locked, R.drawable.ic_fluent_person_available_24_regular, i->toggleCheckableItem(lockedItem)) )); @@ -82,6 +89,9 @@ public class SettingsPrivacyFragment extends BaseSettingsFragment{ @Override public void onPause(){ super.onPause(); + GlobalUserPreferences.defaultToUnlistedReplies=unlistedRepliesItem.checked; + GlobalUserPreferences.removeTrackingParams=removeTrackingParams.checked; + GlobalUserPreferences.save(); AccountSession s=AccountSessionManager.get(accountID); Account self=s.self; boolean savePlease=self.locked!=lockedItem.checked diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsServerAboutFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsServerAboutFragment.java index a655368e1..9f519770d 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsServerAboutFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsServerAboutFragment.java @@ -83,7 +83,7 @@ public class SettingsServerAboutFragment extends LoaderFragment{ public boolean shouldOverrideUrlLoading(WebView view, String url){ Uri uri=Uri.parse(url); if(uri.getScheme().equals("http") || uri.getScheme().equals("https")){ - UiUtils.launchWebBrowser(getActivity(), url); + UiUtils.openURL(getActivity(),accountID, url); }else{ Intent intent=new Intent(Intent.ACTION_VIEW, uri); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsServerFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsServerFragment.java index 3fd882faf..fb2ac2fa6 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsServerFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsServerFragment.java @@ -1,18 +1,25 @@ package org.joinmastodon.android.fragments.settings; +import android.app.Activity; import android.app.Fragment; +import android.content.Intent; +import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.view.LayoutInflater; +import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import android.view.ViewTreeObserver; import android.view.WindowInsets; import android.widget.FrameLayout; import android.widget.TextView; +import android.view.Menu; +import android.view.MenuInflater; import org.joinmastodon.android.R; import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.fragments.CustomLocalTimelineFragment; import org.joinmastodon.android.model.Instance; import org.joinmastodon.android.ui.SimpleViewHolder; import org.joinmastodon.android.ui.tabs.TabLayout; @@ -25,6 +32,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.recyclerview.widget.RecyclerView; import androidx.viewpager2.widget.ViewPager2; +import me.grishka.appkit.Nav; import me.grishka.appkit.fragments.AppKitFragment; import me.grishka.appkit.utils.V; @@ -129,6 +137,41 @@ public class SettingsServerFragment extends AppKitFragment{ }; } + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){ + if (instance != null) { + inflater.inflate(R.menu.instance_info, menu); + UiUtils.enableOptionsMenuIcons(getActivity(), menu); + menu.findItem(R.id.share).setTitle(R.string.button_share); + + } + } + + @Override + public boolean onOptionsItemSelected(MenuItem item){ + int id=item.getItemId(); + if(id==R.id.share){ + Intent intent = new Intent(Intent.ACTION_SEND); + intent.setType("text/plain"); + intent.putExtra(Intent.EXTRA_TEXT, instance.normalizedUri); + startActivity(Intent.createChooser(intent, item.getTitle())); + } else if (id==R.id.open_timeline) { + Bundle args=new Bundle(); + args.putString("account", accountID); + args.putString("domain", instance.normalizedUri); + Nav.go(getActivity(), CustomLocalTimelineFragment.class, args); + } else if (id==R.id.open_in_browser){ + UiUtils.launchWebBrowser(getActivity(), new Uri.Builder().scheme("https").authority(instance.uri).appendPath("about").build().toString()); + } + return true; + } + + @Override + public void onAttach(Activity activity){ + super.onAttach(activity); + setHasOptionsMenu(true); + } + @Override public void onApplyWindowInsets(WindowInsets insets){ if(contentView!=null){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/AltTextFilter.java b/mastodon/src/main/java/org/joinmastodon/android/model/AltTextFilter.java new file mode 100644 index 000000000..c0d2f99cd --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/AltTextFilter.java @@ -0,0 +1,28 @@ +package org.joinmastodon.android.model; + +import org.joinmastodon.android.GlobalUserPreferences; +import org.joinmastodon.android.MastodonApp; +import org.joinmastodon.android.R; +import org.jsoup.internal.StringUtil; + +import java.util.EnumSet; + +public class AltTextFilter extends LegacyFilter { + + public AltTextFilter(FilterAction filterAction, EnumSet filterContexts) { + this.filterAction=filterAction; + this.title=MastodonApp.context.getString(R.string.sk_no_alt_text); + this.isRemote=false; + this.context=filterContexts; + } + + @Override + public boolean matches(Status status) { + return status.getContentStatus().mediaAttachments.stream().map(attachment -> attachment.description).anyMatch(StringUtil::isBlank); + } + + @Override + public boolean isActive(){ + return !GlobalUserPreferences.showPostsWithoutAlt; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/Card.java b/mastodon/src/main/java/org/joinmastodon/android/model/Card.java index de4554ca9..0f197ab33 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/Card.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Card.java @@ -12,6 +12,7 @@ import org.joinmastodon.android.ui.utils.BlurHashDecoder; import org.joinmastodon.android.ui.utils.BlurHashDrawable; import org.parceler.Parcel; +import java.time.Instant; import java.util.List; @Parcel @@ -35,11 +36,14 @@ public class Card extends BaseModel{ public String embedUrl; public String blurhash; public List history; + public Instant publishedAt; public transient Drawable blurhashPlaceholder; @Override public void postprocess() throws ObjectValidationException{ + if(type==null) + type=Type.LINK; super.postprocess(); if(blurhash!=null){ Bitmap placeholder=BlurHashDecoder.decode(blurhash, 16, 16); @@ -72,6 +76,7 @@ public class Card extends BaseModel{ ", embedUrl='"+embedUrl+'\''+ ", blurhash='"+blurhash+'\''+ ", history="+history+ + ", publishedAt="+publishedAt+ '}'; } diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/CustomLocalTimeline.java b/mastodon/src/main/java/org/joinmastodon/android/model/CustomLocalTimeline.java new file mode 100644 index 000000000..85b71c39b --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/CustomLocalTimeline.java @@ -0,0 +1,19 @@ +package org.joinmastodon.android.model; + +import org.joinmastodon.android.api.RequiredField; +import org.parceler.Parcel; + +import java.util.List; + +@Parcel +public class CustomLocalTimeline extends BaseModel{ + @RequiredField + public String domain; + + @Override + public String toString(){ + return "Hashtag{"+ + ", url='"+domain+'\''+ + '}'; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/DisplayItemsParent.java b/mastodon/src/main/java/org/joinmastodon/android/model/DisplayItemsParent.java index 911ba1ddc..25e6c408b 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/DisplayItemsParent.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/DisplayItemsParent.java @@ -5,4 +5,8 @@ package org.joinmastodon.android.model; */ public interface DisplayItemsParent{ String getID(); + + default String getAccountID(){ + return null; + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/DomainBlock.java b/mastodon/src/main/java/org/joinmastodon/android/model/DomainBlock.java new file mode 100644 index 000000000..00f974c43 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/DomainBlock.java @@ -0,0 +1,27 @@ +package org.joinmastodon.android.model; + +import org.joinmastodon.android.api.RequiredField; +import org.parceler.Parcel; + +@Parcel +public class DomainBlock extends BaseModel { + @RequiredField + public String domain; + @RequiredField + public String digest; + @RequiredField + public Severity severity; + public String comment; + + @Override + public String toString() { + return "DomainBlock{" + + "domain='" + domain + '\'' + + ", digest='" + digest + '\'' + + ", severity='" + severity + '\'' + + ", comment='" + comment + '\'' + + '}'; + } + + +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/EmojiReaction.java b/mastodon/src/main/java/org/joinmastodon/android/model/EmojiReaction.java index 343e6a30a..12ec162c8 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/EmojiReaction.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/EmojiReaction.java @@ -39,7 +39,7 @@ public class EmojiReaction { reaction.staticUrl=info.staticUrl; reaction.accounts=new ArrayList<>(Collections.singleton(me)); reaction.accountIds=new ArrayList<>(Collections.singleton(me.id)); - reaction.request=new UrlImageLoaderRequest(info.url, V.sp(24), V.sp(24)); + reaction.request=new UrlImageLoaderRequest(info.url, 0, V.sp(24)); return reaction; } diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/ExtendedDescription.java b/mastodon/src/main/java/org/joinmastodon/android/model/ExtendedDescription.java new file mode 100644 index 000000000..c82f4ad6d --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/ExtendedDescription.java @@ -0,0 +1,21 @@ +package org.joinmastodon.android.model; + +import org.joinmastodon.android.api.RequiredField; +import org.parceler.Parcel; + +import java.util.List; + +@Parcel +public class ExtendedDescription extends BaseModel{ + @RequiredField + public String content; + public String updatedAt; + + @Override + public String toString() { + return "ExtendedDescription{" + + "content='" + content + '\'' + + ", updatedAt='" + updatedAt + '\'' + + '}'; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/FollowList.java b/mastodon/src/main/java/org/joinmastodon/android/model/FollowList.java new file mode 100644 index 000000000..cb6b03c31 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/FollowList.java @@ -0,0 +1,38 @@ +package org.joinmastodon.android.model; + +import androidx.annotation.NonNull; + +import com.google.gson.annotations.SerializedName; + +import org.joinmastodon.android.api.RequiredField; +import org.parceler.Parcel; + +@Parcel +public class FollowList extends BaseModel { + @RequiredField + public String id; + @RequiredField + public String title; + public RepliesPolicy repliesPolicy; + public boolean exclusive; + + @NonNull + @Override + public String toString() { + return "List{" + + "id='" + id + '\'' + + ", title='" + title + '\'' + + ", repliesPolicy=" + repliesPolicy + + ", exclusive=" + exclusive + + '}'; + } + + public enum RepliesPolicy{ + @SerializedName("followed") + FOLLOWED, + @SerializedName("list") + LIST, + @SerializedName("none") + NONE + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/Hashtag.java b/mastodon/src/main/java/org/joinmastodon/android/model/Hashtag.java index 0566ea85f..d5f923533 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/Hashtag.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Hashtag.java @@ -46,4 +46,8 @@ public class Hashtag extends BaseModel implements DisplayItemsParent{ public int hashCode(){ return name.hashCode(); } + + public int getWeekPosts(){ + return history.stream().mapToInt(h->h.uses).sum(); + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/HeaderPaginationList.java b/mastodon/src/main/java/org/joinmastodon/android/model/HeaderPaginationList.java index 10390e316..37c56e931 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/HeaderPaginationList.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/HeaderPaginationList.java @@ -21,4 +21,10 @@ public class HeaderPaginationList extends ArrayList{ public HeaderPaginationList(@NonNull Collection c){ super(c); } + + public String getNextPageMaxID(){ + if(nextPageUri==null) + return null; + return nextPageUri.getQueryParameter("max_id"); + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/Instance.java b/mastodon/src/main/java/org/joinmastodon/android/model/Instance.java index be0e2aa15..5bd03db18 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/Instance.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Instance.java @@ -1,6 +1,7 @@ package org.joinmastodon.android.model; import android.text.Html; +import android.util.Log; import org.joinmastodon.android.api.ObjectValidationException; import org.joinmastodon.android.api.RequiredField; @@ -8,6 +9,7 @@ import org.joinmastodon.android.model.catalog.CatalogInstance; import org.parceler.Parcel; import java.net.IDN; +import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Map; @@ -174,6 +176,31 @@ public class Instance extends BaseModel{ .orElse(false); }; } + /** + * Returns true if the instance version is the same as or newer than the passed in version. + * @param major: The major version to check for. + * @param minor: the minor version to check for. + * @param patch: The patch version to check for. + */ + public boolean checkVersion(int major, int minor, int patch) { + try{ + String[] parts=version.split("-", 2); + String[] numbers=parts[0].split("\\.", 3); + if(numbers.length < 3) + throw new IllegalArgumentException("Invalid version format. Expected format: major.minor.micro"); + + int majorVersion=Integer.parseInt(numbers[0]); + int minorVersion=Integer.parseInt(numbers[1]); + int patchVersion=Integer.parseInt(numbers[2]); + return (majorVersion > major || + (majorVersion == major && minorVersion > minor) || + (majorVersion == major && minorVersion == minor && + patchVersion>= patch)); + } catch(Exception e) { + Log.w("Instance", "checkVersion: failed to parse " + version + ", " + e); + return false; + } + } public enum Feature { BUBBLE_TIMELINE, @@ -260,19 +287,19 @@ public class Instance extends BaseModel{ @Parcel public static class FieldsLimits { - public int maxFields; - public int maxRemoteFields; - public int nameLength; - public int valueLength; + public long maxFields; + public long maxRemoteFields; + public long nameLength; + public long valueLength; } } } @Parcel public static class PleromaPollLimits { - public int maxExpiration; - public int maxOptionChars; - public int maxOptions; - public int minExpiration; + public long maxExpiration; + public long maxOptionChars; + public long maxOptions; + public long minExpiration; } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/Notification.java b/mastodon/src/main/java/org/joinmastodon/android/model/Notification.java index b4340fd09..3353eea9c 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/Notification.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Notification.java @@ -36,6 +36,11 @@ public class Notification extends BaseModel implements DisplayItemsParent{ return id; } + @Override + public String getAccountID(){ + return status!=null ? account.id : null; + } + public enum Type{ @SerializedName("follow") FOLLOW, diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/NotificationAction.java b/mastodon/src/main/java/org/joinmastodon/android/model/NotificationAction.java index 4f43a51a8..52b936243 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/NotificationAction.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/NotificationAction.java @@ -2,8 +2,9 @@ package org.joinmastodon.android.model; public enum NotificationAction { FAVORITE, - REBLOG, - UNDO_REBLOG, + BOOST, + UNBOOST, BOOKMARK, REPLY, + FOLLOW_BACK } diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/PushNotification.java b/mastodon/src/main/java/org/joinmastodon/android/model/PushNotification.java index 1e7ce594e..d440db2bc 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/PushNotification.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/PushNotification.java @@ -6,6 +6,7 @@ import com.google.gson.annotations.SerializedName; import org.joinmastodon.android.R; import org.joinmastodon.android.api.RequiredField; +import org.joinmastodon.android.api.session.AccountSession; import org.joinmastodon.android.ui.utils.UiUtils; import androidx.annotation.StringRes; @@ -13,7 +14,7 @@ import androidx.annotation.StringRes; public class PushNotification extends BaseModel{ public String accessToken; public String preferredLocale; - public long notificationId; + public String notificationId; @RequiredField public Type notificationType; @RequiredField @@ -23,7 +24,7 @@ public class PushNotification extends BaseModel{ @RequiredField public String body; - public static PushNotification fromNotification(Context context, Notification notification){ + public static PushNotification fromNotification(Context context, AccountSession account, Notification notification){ PushNotification pushNotification = new PushNotification(); pushNotification.notificationType = switch(notification.type) { case FOLLOW -> PushNotification.Type.FOLLOW; @@ -45,6 +46,7 @@ public class PushNotification extends BaseModel{ case REBLOG -> R.string.notification_boosted; case FAVORITE -> R.string.user_favorited; case POLL -> R.string.poll_ended; + case STATUS -> R.string.sk_posted; case UPDATE -> R.string.sk_post_edited; case SIGN_UP -> R.string.sk_signed_up; case REPORT -> R.string.sk_reported; @@ -52,8 +54,12 @@ public class PushNotification extends BaseModel{ }); pushNotification.title = UiUtils.generateFormattedString(notificationTitle, notification.account.displayName).toString(); - pushNotification.icon = notification.status.account.avatarStatic; - pushNotification.body = notification.status.getStrippedText(); + if (notification.status != null) { + pushNotification.icon = notification.status.account.avatarStatic; + pushNotification.body = notification.status.getStrippedText(); + } else { + pushNotification.icon = account.getDefaultAvatarUrl(); + } return pushNotification; } diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/ScheduledStatus.java b/mastodon/src/main/java/org/joinmastodon/android/model/ScheduledStatus.java index 0f2af6cb6..b16691376 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/ScheduledStatus.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/ScheduledStatus.java @@ -1,17 +1,27 @@ package org.joinmastodon.android.model; +import android.util.Patterns; + +import androidx.annotation.NonNull; + import org.joinmastodon.android.api.ObjectValidationException; import org.joinmastodon.android.api.RequiredField; +import org.joinmastodon.android.api.session.AccountSession; +import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.model.Poll.Option; import org.parceler.Parcel; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import java.util.stream.Collectors; @Parcel public class ScheduledStatus extends BaseModel implements DisplayItemsParent{ + private static final Pattern HIGHLIGHT_PATTER=Pattern.compile("(?"); + if(!s.content.contains("@") && !s.content.contains("#") && !s.content.contains(":")) + return s; + + StringBuffer sb=new StringBuffer(); + Matcher matcher=HIGHLIGHT_PATTER.matcher(s.content); + + // I'm sure this will cause problems at some point... + while(matcher.find()){ + String content=matcher.group(); + String href=""; + // add relevant links, so on-click actions work + // hashtags are done by the parser + if(content.startsWith("@")) + href=" href=\""+formatMention(content, self.domain)+"\" class=\"u-url mention\""; + else if(content.startsWith("https://")) + href=" href=\""+content+"\""; + + matcher.appendReplacement(sb, ""+content+""); + } + matcher.appendTail(sb); + s.content=sb.toString(); + return s; + } + + /** + * Converts a string mention into a URL of the account. + * @param mention Mention in the form a of user name with an optional instance URL + * @param instanceURL URL of the home instance of the user + * @return Formatted HTML or the mention + */ + @NonNull + private static String formatMention(@NonNull String mention, @NonNull String instanceURL){ + String[] parts=mention.split("@"); + if(parts.length>1){ + String username=parts[1]; + String domain=parts.length==3 ? parts[2] : instanceURL; + return "https://"+domain+"/@"+username; + } + return mention; + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/SearchResult.java b/mastodon/src/main/java/org/joinmastodon/android/model/SearchResult.java index 6e4f4a2ed..f56b757ea 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/SearchResult.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/SearchResult.java @@ -34,10 +34,18 @@ public class SearchResult extends BaseModel implements DisplayItemsParent{ generateID(); } + @Override public String getID(){ return id; } + @Override + public String getAccountID(){ + if(type==Type.STATUS) + return status.getAccountID(); + return null; + } + @Override public void postprocess() throws ObjectValidationException{ super.postprocess(); diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/Severity.java b/mastodon/src/main/java/org/joinmastodon/android/model/Severity.java new file mode 100644 index 000000000..96ffb8d67 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Severity.java @@ -0,0 +1,12 @@ +package org.joinmastodon.android.model; + +import com.google.gson.annotations.SerializedName; + +import org.parceler.Parcel; + +public enum Severity { + @SerializedName("silence") + SILENCE, + @SerializedName("suspend") + SUSPEND +} \ No newline at end of file diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/Status.java b/mastodon/src/main/java/org/joinmastodon/android/model/Status.java index 6f94eaf5f..83ebe6ff6 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/Status.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Status.java @@ -3,12 +3,15 @@ package org.joinmastodon.android.model; import static org.joinmastodon.android.api.MastodonAPIController.gson; import static org.joinmastodon.android.api.MastodonAPIController.gsonWithoutDeserializer; +import androidx.annotation.Nullable; + import android.text.TextUtils; import android.util.Pair; import org.joinmastodon.android.api.ObjectValidationException; import org.joinmastodon.android.api.RequiredField; import org.joinmastodon.android.events.StatusCountersUpdatedEvent; +import org.joinmastodon.android.events.StatusMuteChangedEvent; import org.joinmastodon.android.ui.text.HtmlParser; import org.parceler.Parcel; @@ -52,7 +55,7 @@ public class Status extends BaseModel implements DisplayItemsParent, Searchable{ public StatusPrivacy visibility; public boolean sensitive; @RequiredField - public String spoilerText; + public String spoilerText=""; public List mediaAttachments; public Application application; @RequiredField @@ -75,6 +78,8 @@ public class Status extends BaseModel implements DisplayItemsParent, Searchable{ public Card card; public String language; public String text; + @Nullable + public Account rebloggedBy; public boolean localOnly; public boolean favourited; @@ -181,6 +186,11 @@ public class Status extends BaseModel implements DisplayItemsParent, Searchable{ return id; } + @Override + public String getAccountID(){ + return getContentStatus().account.id; + } + public void update(StatusCountersUpdatedEvent ev){ favouritesCount=ev.favorites; reblogsCount=ev.reblogs; @@ -191,6 +201,10 @@ public class Status extends BaseModel implements DisplayItemsParent, Searchable{ pinned=ev.pinned; } + public void update(StatusMuteChangedEvent ev) { + muted=ev.muted; + } + public void update(EmojiReactionsUpdatedEvent ev){ reactions=ev.reactions; } @@ -216,7 +230,10 @@ public class Status extends BaseModel implements DisplayItemsParent, Searchable{ @NonNull @Override public Status clone(){ - return (Status) super.clone(); + Status copy=(Status) super.clone(); + copy.spoilerRevealed=false; + copy.translationState=TranslationState.HIDDEN; + return copy; } public static final Pattern BOTTOM_TEXT_PATTERN = Pattern.compile("(?:[\uD83E\uDEC2\uD83D\uDC96✨\uD83E\uDD7A,]+|❤️)(?:\uD83D\uDC49\uD83D\uDC48(?:[\uD83E\uDEC2\uD83D\uDC96✨\uD83E\uDD7A,]+|❤️))*\uD83D\uDC49\uD83D\uDC48"); diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/TimelineDefinition.java b/mastodon/src/main/java/org/joinmastodon/android/model/TimelineDefinition.java index 71ad02d1e..adca34420 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/TimelineDefinition.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/TimelineDefinition.java @@ -10,6 +10,7 @@ import androidx.annotation.Nullable; import androidx.annotation.StringRes; import org.joinmastodon.android.R; +import org.joinmastodon.android.fragments.CustomLocalTimelineFragment; import org.joinmastodon.android.api.session.AccountSession; import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.fragments.BookmarkedStatusListFragment; @@ -36,6 +37,7 @@ public class TimelineDefinition { private @Nullable String listTitle; private boolean listIsExclusive; + private @Nullable String domain; private @Nullable String hashtagName; private @Nullable List hashtagAny; private @Nullable List hashtagAll; @@ -50,7 +52,7 @@ public class TimelineDefinition { return def; } - public static TimelineDefinition ofList(ListTimeline list) { + public static TimelineDefinition ofList(FollowList list) { return ofList(list.id, list.title, list.exclusive); } @@ -60,6 +62,12 @@ public class TimelineDefinition { return def; } + public static TimelineDefinition ofCustomLocalTimeline(String domain) { + TimelineDefinition def = new TimelineDefinition(TimelineType.CUSTOM_LOCAL_TIMELINE); + def.domain = domain; + return def; + } + public static TimelineDefinition ofHashtag(Hashtag hashtag) { return ofHashtag(hashtag.name); } @@ -142,6 +150,7 @@ public class TimelineDefinition { case BUBBLE -> ctx.getString(R.string.sk_timeline_bubble); case BOOKMARKS -> ctx.getString(R.string.bookmarks); case FAVORITES -> ctx.getString(R.string.your_favorites); + case CUSTOM_LOCAL_TIMELINE -> domain; }; } @@ -153,6 +162,7 @@ public class TimelineDefinition { case POST_NOTIFICATIONS -> Icon.POST_NOTIFICATIONS; case LIST -> listIsExclusive ? Icon.EXCLUSIVE_LIST : Icon.LIST; case HASHTAG -> Icon.HASHTAG; + case CUSTOM_LOCAL_TIMELINE -> Icon.CUSTOM_LOCAL_TIMELINE; case BUBBLE -> Icon.BUBBLE; case BOOKMARKS -> Icon.BOOKMARKS; case FAVORITES -> Icon.FAVORITES; @@ -168,6 +178,7 @@ public class TimelineDefinition { case HASHTAG -> new HashtagTimelineFragment(); case POST_NOTIFICATIONS -> new NotificationsListFragment(); case BUBBLE -> new BubbleTimelineFragment(); + case CUSTOM_LOCAL_TIMELINE -> new CustomLocalTimelineFragment(); case BOOKMARKS -> new BookmarkedStatusListFragment(); case FAVORITES -> new FavoritedStatusListFragment(); }; @@ -194,6 +205,7 @@ public class TimelineDefinition { TimelineDefinition that = (TimelineDefinition) o; if (type != that.type) return false; if (type == TimelineType.LIST) return Objects.equals(listId, that.listId); + if (type == TimelineType.CUSTOM_LOCAL_TIMELINE) return Objects.equals(domain.toLowerCase(), that.domain.toLowerCase()); if (type == TimelineType.HASHTAG) { if (hashtagName == null && that.hashtagName == null) return true; if (hashtagName == null || that.hashtagName == null) return false; @@ -214,6 +226,7 @@ public class TimelineDefinition { def.listTitle = listTitle; def.listIsExclusive = listIsExclusive; def.hashtagName = hashtagName; + def.domain = domain; def.hashtagAny = hashtagAny; def.hashtagAll = hashtagAll; def.hashtagNone = hashtagNone; @@ -232,6 +245,8 @@ public class TimelineDefinition { args.putStringArrayList("any", hashtagAny == null ? new ArrayList<>() : new ArrayList<>(hashtagAny)); args.putStringArrayList("all", hashtagAll == null ? new ArrayList<>() : new ArrayList<>(hashtagAll)); args.putStringArrayList("none", hashtagNone == null ? new ArrayList<>() : new ArrayList<>(hashtagNone)); + } else if (type == TimelineType.CUSTOM_LOCAL_TIMELINE) { + args.putString("domain", domain); } return args; } @@ -244,6 +259,7 @@ public class TimelineDefinition { LIST, HASHTAG, BUBBLE, + CUSTOM_LOCAL_TIMELINE, // not really timelines, but some people want it, so,, BOOKMARKS, @@ -320,6 +336,7 @@ public class TimelineDefinition { THUNDERSTORM(R.drawable.ic_fluent_weather_thunderstorm_24_regular, R.string.sk_icon_thunderstorm), RAIN(R.drawable.ic_fluent_weather_rain_24_regular, R.string.sk_icon_rain), SNOWFLAKE(R.drawable.ic_fluent_weather_snowflake_24_regular, R.string.sk_icon_snowflake), + GNOME(R.drawable.ic_gnome_logo, R.string.mo_icon_gnome), HOME(R.drawable.ic_fluent_home_24_regular, R.string.sk_timeline_home, true), LOCAL(R.drawable.ic_fluent_people_community_24_regular, R.string.sk_timeline_local, true), @@ -328,6 +345,7 @@ public class TimelineDefinition { LIST(R.drawable.ic_fluent_people_24_regular, R.string.sk_list, true), EXCLUSIVE_LIST(R.drawable.ic_fluent_rss_24_regular, R.string.sk_exclusive_list, true), HASHTAG(R.drawable.ic_fluent_number_symbol_24_regular, R.string.sk_hashtag, true), + CUSTOM_LOCAL_TIMELINE(R.drawable.ic_fluent_people_community_24_regular, R.string.sk_timeline_local, true), BUBBLE(R.drawable.ic_fluent_circle_24_regular, R.string.sk_timeline_bubble, true), BOOKMARKS(R.drawable.ic_fluent_bookmark_multiple_24_regular, R.string.bookmarks, true), FAVORITES(R.drawable.ic_fluent_star_24_regular, R.string.your_favorites, true); diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/Token.java b/mastodon/src/main/java/org/joinmastodon/android/model/Token.java index 5c6ffddcc..4c060ffb9 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/Token.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Token.java @@ -1,15 +1,15 @@ package org.joinmastodon.android.model; -import org.joinmastodon.android.api.AllFieldsAreRequired; +import org.joinmastodon.android.api.RequiredField; /** * Represents an OAuth token used for authenticating with the API and performing actions. */ -@AllFieldsAreRequired public class Token extends BaseModel{ /** * An OAuth token to be used for authorization. */ + @RequiredField public String accessToken; /** * The OAuth token type. Mastodon uses Bearer tokens. @@ -23,5 +23,6 @@ public class Token extends BaseModel{ * When the token was generated. * (unixtime) */ + @RequiredField public long createdAt; } diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/WeeklyActivity.java b/mastodon/src/main/java/org/joinmastodon/android/model/WeeklyActivity.java new file mode 100644 index 000000000..58e794acc --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/WeeklyActivity.java @@ -0,0 +1,26 @@ +package org.joinmastodon.android.model; + +import org.joinmastodon.android.api.RequiredField; +import org.parceler.Parcel; + +@Parcel +public class WeeklyActivity extends BaseModel { + @RequiredField + public String week; + @RequiredField + public int statuses; + @RequiredField + public int logins; + @RequiredField + public int registrations; + + @Override + public String toString() { + return "WeeklyActivity{" + + "week=" + week + + ", statuses=" + statuses + + ", logins=" + logins + + ", registrations=" + registrations + + '}'; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/viewmodel/AccountViewModel.java b/mastodon/src/main/java/org/joinmastodon/android/model/viewmodel/AccountViewModel.java index 1614f728f..2df24df8c 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/viewmodel/AccountViewModel.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/viewmodel/AccountViewModel.java @@ -1,5 +1,6 @@ package org.joinmastodon.android.model.viewmodel; +import android.text.Spannable; import android.text.SpannableStringBuilder; import android.text.TextUtils; @@ -9,6 +10,7 @@ import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.AccountField; import org.joinmastodon.android.ui.text.HtmlParser; +import org.joinmastodon.android.ui.text.LinkSpan; import org.joinmastodon.android.ui.utils.CustomEmojiHelper; import java.util.Collections; @@ -36,7 +38,7 @@ public class AccountViewModel{ parsedName=HtmlParser.parseCustomEmoji(account.getDisplayName(), account.emojis); else parsedName=account.getDisplayName(); - parsedBio=HtmlParser.parse(account.note, account.emojis, Collections.emptyList(), Collections.emptyList(), accountID); + parsedBio=HtmlParser.parse(account.note, account.emojis, Collections.emptyList(), Collections.emptyList(), accountID, account); SpannableStringBuilder ssb=new SpannableStringBuilder(parsedName); ssb.append(parsedBio); emojiHelper.setText(ssb); @@ -49,4 +51,13 @@ public class AccountViewModel{ } this.verifiedLink=verifiedLink; } + + public AccountViewModel stripLinksFromBio(){ + if(parsedBio instanceof Spannable spannable){ + for(LinkSpan span:spannable.getSpans(0, spannable.length(), LinkSpan.class)){ + spannable.removeSpan(span); + } + } + return this; + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/viewmodel/AvatarPileListItem.java b/mastodon/src/main/java/org/joinmastodon/android/model/viewmodel/AvatarPileListItem.java new file mode 100644 index 000000000..6839de1d0 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/viewmodel/AvatarPileListItem.java @@ -0,0 +1,22 @@ +package org.joinmastodon.android.model.viewmodel; + +import org.joinmastodon.android.R; + +import java.util.List; +import java.util.function.Consumer; + +import me.grishka.appkit.imageloader.requests.ImageLoaderRequest; + +public class AvatarPileListItem extends ListItem{ + public List avatars; + + public AvatarPileListItem(String title, String subtitle, List avatars, int iconRes, Consumer> onClick, T parentObject, boolean dividerAfter){ + super(title, subtitle, iconRes, (Consumer>)(Object)onClick, parentObject, 0, dividerAfter); + this.avatars=avatars; + } + + @Override + public int getItemViewType(){ + return R.id.list_item_avatar_pile; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/viewmodel/CheckableListItem.java b/mastodon/src/main/java/org/joinmastodon/android/model/viewmodel/CheckableListItem.java index 0363f81f5..b8dde665e 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/viewmodel/CheckableListItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/viewmodel/CheckableListItem.java @@ -55,6 +55,7 @@ public class CheckableListItem extends ListItem{ case CHECKBOX -> R.id.list_item_checkbox; case RADIO -> R.id.list_item_radio; case SWITCH -> R.id.list_item_switch; + case SWITCH_SEPARATED -> R.id.list_item_switch_separated; }; } @@ -69,6 +70,7 @@ public class CheckableListItem extends ListItem{ public enum Style{ CHECKBOX, RADIO, - SWITCH + SWITCH, + SWITCH_SEPARATED } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/viewmodel/ListItemWithOptionsMenu.java b/mastodon/src/main/java/org/joinmastodon/android/model/viewmodel/ListItemWithOptionsMenu.java new file mode 100644 index 000000000..7f3f60803 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/viewmodel/ListItemWithOptionsMenu.java @@ -0,0 +1,35 @@ +package org.joinmastodon.android.model.viewmodel; + +import android.view.Menu; +import android.view.MenuItem; + +import org.joinmastodon.android.R; + +import java.util.function.Consumer; + +public class ListItemWithOptionsMenu extends ListItem{ + public OptionsMenuListener listener; + + public ListItemWithOptionsMenu(String title, String subtitle, OptionsMenuListener listener, int iconRes, Consumer> onClick, T parentObject, boolean dividerAfter){ + super(title, subtitle, iconRes, (Consumer>)(Object)onClick, parentObject, 0, dividerAfter); + this.listener=listener; + } + + @Override + public int getItemViewType(){ + return R.id.list_item_options; + } + + public void performConfigureMenu(Menu menu){ + listener.onConfigureListItemOptionsMenu(this, menu); + } + + public void performItemSelected(MenuItem item){ + listener.onListItemOptionSelected(this, item); + } + + public interface OptionsMenuListener{ + void onConfigureListItemOptionsMenu(ListItemWithOptionsMenu item, Menu menu); + void onListItemOptionSelected(ListItemWithOptionsMenu item, MenuItem menuItem); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/CustomEmojiPopupKeyboard.java b/mastodon/src/main/java/org/joinmastodon/android/ui/CustomEmojiPopupKeyboard.java index 0b98ff9f6..16a1ae52e 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/CustomEmojiPopupKeyboard.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/CustomEmojiPopupKeyboard.java @@ -113,7 +113,7 @@ public class CustomEmojiPopupKeyboard extends PopupKeyboard{ List recentEmoji=new ArrayList<>(lp.recentCustomEmoji); if(!recentEmoji.isEmpty()) - adapter.addAdapter(new SingleCategoryAdapter(recentEmojiCategory=new EmojiCategory(activity.getString(R.string.sk_recently_used), recentEmoji))); + adapter.addAdapter(new SingleCategoryAdapter(recentEmojiCategory=new EmojiCategory(activity.getString(R.string.mo_emoji_recent), recentEmoji))); for(EmojiCategory category:emojis) adapter.addAdapter(new SingleCategoryAdapter(category)); @@ -168,7 +168,7 @@ public class CustomEmojiPopupKeyboard extends PopupKeyboard{ if(start == 0){ if(emojiRegex.matcher(s.toString()).find()){ imm.hideSoftInputFromWindow(input.getWindowToken(), 0); - listener.onEmojiSelected(s.toString().substring(before)); + listener.onEmojiSelected(s.toString()); input.getText().clear(); } } @@ -349,7 +349,7 @@ public class CustomEmojiPopupKeyboard extends PopupKeyboard{ ImageView img=(ImageView) itemView; img.setLayoutParams(new RecyclerView.LayoutParams(V.dp(48), V.dp(48))); img.setScaleType(ImageView.ScaleType.FIT_CENTER); - int pad=V.dp(12); + int pad=V.dp(6); img.setPadding(pad, pad, pad, pad); img.setBackgroundResource(R.drawable.bg_custom_emoji); this.isRecentEmojiCategory=isRecentEmojiCategory; @@ -375,7 +375,7 @@ public class CustomEmojiPopupKeyboard extends PopupKeyboard{ @Override public boolean onLongClick(){ - if(!isRecentEmojiCategory) return false; + if(!isRecentEmojiCategory || requests.size() < getAbsoluteAdapterPosition()-1 || 0 > getAbsoluteAdapterPosition()-1) return false; requests.remove(getAbsoluteAdapterPosition()-1); getBindingAdapter().notifyItemRemoved(getAbsoluteAdapterPosition()); getBindingAdapter().notifyItemChanged(0); @@ -402,7 +402,7 @@ public class CustomEmojiPopupKeyboard extends PopupKeyboard{ public interface Listener{ void onEmojiSelected(Emoji customEmoji); - void onEmojiSelected(String emoji); + void onEmojiSelected(String emoji); void onBackspace(); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/ImageDescriptionSheet.java b/mastodon/src/main/java/org/joinmastodon/android/ui/ImageDescriptionSheet.java deleted file mode 100644 index 5abd991d6..000000000 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/ImageDescriptionSheet.java +++ /dev/null @@ -1,87 +0,0 @@ -package org.joinmastodon.android.ui; - -import android.app.Activity; -import android.graphics.Typeface; -import android.graphics.drawable.ColorDrawable; -import android.os.Build; -import android.view.View; -import android.view.ViewGroup; -import android.view.WindowInsets; -import android.widget.FrameLayout; -import android.widget.LinearLayout; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.recyclerview.widget.LinearLayoutManager; - -import org.joinmastodon.android.R; -import org.joinmastodon.android.model.Attachment; -import org.joinmastodon.android.ui.utils.UiUtils; - -import me.grishka.appkit.utils.SingleViewRecyclerAdapter; -import me.grishka.appkit.utils.V; -import me.grishka.appkit.views.BottomSheet; -import me.grishka.appkit.views.UsableRecyclerView; - -public class ImageDescriptionSheet extends BottomSheet{ - private UsableRecyclerView list; - - public ImageDescriptionSheet(@NonNull Activity activity, Attachment attachment){ - super(activity); - - View handleView=new View(activity); - handleView.setBackgroundResource(R.drawable.bg_bottom_sheet_handle); - ViewGroup handle=new FrameLayout(activity); - handle.addView(handleView); - handle.setLayoutParams(new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, V.dp(24))); - - TextView textView = new TextView(activity); - if (attachment.description == null || attachment.description.isEmpty()) { - textView.setText(R.string.media_no_description); - textView.setTypeface(null, Typeface.ITALIC); - } else { - textView.setText(attachment.description); - textView.setTextIsSelectable(true); - } - - TextView heading=new TextView(activity); - heading.setText(R.string.sk_image_description); - heading.setAllCaps(true); - heading.setTypeface(null, Typeface.BOLD); - heading.setPadding(0, V.dp(24), 0, V.dp(8)); - - LinearLayout linearLayout = new LinearLayout(activity); - linearLayout.setOrientation(LinearLayout.VERTICAL); - linearLayout.setPadding(V.dp(24), 0, V.dp(24), 0); - linearLayout.addView(heading); - linearLayout.addView(textView); - - FrameLayout layout=new FrameLayout(activity); - layout.addView(handle); - layout.addView(linearLayout); - - list=new UsableRecyclerView(activity); - list.setLayoutManager(new LinearLayoutManager(activity)); - list.setBackgroundResource(R.drawable.bg_bottom_sheet); - list.setAdapter(new SingleViewRecyclerAdapter(layout)); - list.setClipToPadding(false); - - setContentView(list); - setNavigationBarBackground(new ColorDrawable(UiUtils.getThemeColor(activity, R.attr.colorM3Surface)), !UiUtils.isDarkTheme()); - } - - @Override - protected void onWindowInsetsUpdated(WindowInsets insets){ - if(Build.VERSION.SDK_INT>=29){ - int tappableBottom=insets.getTappableElementInsets().bottom; - int insetBottom=insets.getSystemWindowInsetBottom(); - if(tappableBottom==0 && insetBottom>0){ - list.setPadding(0, 0, 0, V.dp(48)-insetBottom); - }else{ - list.setPadding(0, 0, 0, V.dp(24)); - } - }else{ - list.setPadding(0, 0, 0, V.dp(24)); - } - } -} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/M3AlertDialogBuilder.java b/mastodon/src/main/java/org/joinmastodon/android/ui/M3AlertDialogBuilder.java index 0f386d505..0e176c6e8 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/M3AlertDialogBuilder.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/M3AlertDialogBuilder.java @@ -65,7 +65,7 @@ public class M3AlertDialogBuilder extends AlertDialog.Builder{ View title=alert.findViewById(titleID); if(title!=null){ int pad=V.dp(24); - title.setPadding(pad, pad, pad, pad); + title.setPadding(pad, pad, pad, V.dp(18)); } } int titleDividerID=getContext().getResources().getIdentifier("titleDividerNoCustom", "id", "android"); diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/NonMutualPreReplySheet.java b/mastodon/src/main/java/org/joinmastodon/android/ui/NonMutualPreReplySheet.java new file mode 100644 index 000000000..a86ba1649 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/NonMutualPreReplySheet.java @@ -0,0 +1,130 @@ +package org.joinmastodon.android.ui; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.text.TextUtils; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.ui.text.HtmlParser; +import org.joinmastodon.android.ui.utils.UiUtils; + +import androidx.annotation.NonNull; +import me.grishka.appkit.imageloader.ViewImageLoader; +import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest; +import me.grishka.appkit.utils.V; + +public class NonMutualPreReplySheet extends PreReplySheet{ + private boolean fullBioShown=false; + + @SuppressLint("DefaultLocale") + public NonMutualPreReplySheet(@NonNull Context context, ResultListener resultListener, Account account, String accountID){ + super(context, resultListener); + icon.setImageResource(R.drawable.ic_waving_hand_24px); + title.setText(R.string.non_mutual_sheet_title); + text.setText(R.string.non_mutual_sheet_text); + + LinearLayout userInfo=new LinearLayout(context); + userInfo.setOrientation(LinearLayout.HORIZONTAL); + userInfo.setBackgroundResource(R.drawable.bg_user_info); + UiUtils.setAllPaddings(userInfo, 12); + + ImageView ava=new ImageView(context); + ava.setScaleType(ImageView.ScaleType.CENTER_CROP); + ava.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO); + ava.setOutlineProvider(OutlineProviders.roundedRect(12)); + ava.setClipToOutline(true); + ava.setForeground(context.getResources().getDrawable(R.drawable.fg_user_info_ava, context.getTheme())); + userInfo.addView(ava, UiUtils.makeLayoutParams(56, 56, 0, 0, 12, 0)); + ViewImageLoader.loadWithoutAnimation(ava, context.getResources().getDrawable(R.drawable.image_placeholder), new UrlImageLoaderRequest(account.avatarStatic, V.dp(56), V.dp(56))); + + LinearLayout nameAndFields=new LinearLayout(context); + nameAndFields.setOrientation(LinearLayout.VERTICAL); + nameAndFields.setMinimumHeight(V.dp(56)); + nameAndFields.setGravity(Gravity.CENTER_VERTICAL); + userInfo.addView(nameAndFields, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + TextView name=new TextView(context); + name.setSingleLine(); + name.setEllipsize(TextUtils.TruncateAt.END); + name.setTextAppearance(R.style.m3_title_medium); + name.setTextColor(UiUtils.getThemeColor(context, R.attr.colorM3OnSurface)); + if(AccountSessionManager.get(accountID).getLocalPreferences().customEmojiInNames){ + name.setText(HtmlParser.parseCustomEmoji(account.displayName, account.emojis)); + UiUtils.loadCustomEmojiInTextView(name); + }else{ + name.setText(account.displayName); + } + name.setGravity(Gravity.CENTER_VERTICAL | Gravity.START); + nameAndFields.addView(name, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, V.dp(24))); + if(!TextUtils.isEmpty(account.note)){ + CharSequence strippedBio=HtmlParser.parseCustomEmoji(HtmlParser.stripAndRemoveInvisibleSpans(account.note), account.emojis); + TextView bioShort=new TextView(context); + bioShort.setTextAppearance(R.style.m3_body_medium); + bioShort.setTextColor(UiUtils.getThemeColor(context, R.attr.colorM3Secondary)); + bioShort.setMaxLines(2); + bioShort.setEllipsize(TextUtils.TruncateAt.END); + bioShort.setText(strippedBio); + nameAndFields.addView(bioShort, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + + TextView bioFull=new TextView(context); + bioFull.setTextAppearance(R.style.m3_body_medium); + bioFull.setTextColor(UiUtils.getThemeColor(context, R.attr.colorM3Secondary)); + bioFull.setText(strippedBio); + bioFull.setVisibility(View.GONE); + nameAndFields.addView(bioFull, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + nameAndFields.setOnClickListener(v->{ + UiUtils.beginLayoutTransition((ViewGroup) getWindow().getDecorView()); + fullBioShown=!fullBioShown; + if(fullBioShown){ + bioFull.setVisibility(View.VISIBLE); + bioShort.setVisibility(View.GONE); + }else{ + bioFull.setVisibility(View.GONE); + bioShort.setVisibility(View.VISIBLE); + } + }); + UiUtils.loadCustomEmojiInTextView(bioShort); + UiUtils.loadCustomEmojiInTextView(bioFull); + }else{ + TextView username=new TextView(context); + username.setTextAppearance(R.style.m3_body_medium); + username.setTextColor(UiUtils.getThemeColor(context, R.attr.colorM3Secondary)); + username.setSingleLine(); + username.setEllipsize(TextUtils.TruncateAt.END); + username.setText(account.getDisplayUsername()); + username.setGravity(Gravity.CENTER_VERTICAL | Gravity.START); + nameAndFields.addView(username, new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, V.dp(20))); + } + + contentWrap.addView(userInfo, UiUtils.makeLayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT, 0, 0, 0, 8)); + + for(int i=0;i<3;i++){ + View item=context.getSystemService(LayoutInflater.class).inflate(R.layout.item_other_numbered_rule, contentWrap, false); + TextView number=item.findViewById(R.id.number); + number.setText(String.format("%d", i+1)); + TextView title=item.findViewById(R.id.title); + TextView text=item.findViewById(R.id.text); + title.setText(switch(i){ + case 0 -> R.string.non_mutual_title1; + case 1 -> R.string.non_mutual_title2; + case 2 -> R.string.non_mutual_title3; + default -> throw new IllegalStateException("Unexpected value: "+i); + }); + text.setText(switch(i){ + case 0 -> R.string.non_mutual_text1; + case 1 -> R.string.non_mutual_text2; + case 2 -> R.string.non_mutual_text3; + default -> throw new IllegalStateException("Unexpected value: "+i); + }); + contentWrap.addView(item); + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/OldPostPreReplySheet.java b/mastodon/src/main/java/org/joinmastodon/android/ui/OldPostPreReplySheet.java new file mode 100644 index 000000000..6897859cc --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/OldPostPreReplySheet.java @@ -0,0 +1,23 @@ +package org.joinmastodon.android.ui; + +import android.content.Context; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.model.Status; + +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; + +import androidx.annotation.NonNull; + +public class OldPostPreReplySheet extends PreReplySheet{ + public OldPostPreReplySheet(@NonNull Context context, ResultListener resultListener, Status status){ + super(context, resultListener); + int months=(int)status.createdAt.atZone(ZoneId.systemDefault()).until(ZonedDateTime.now(), ChronoUnit.MONTHS); + String monthsStr=months>24 ? context.getString(R.string.more_than_two_years) : context.getResources().getQuantityString(R.plurals.x_months, months, months); + title.setText(context.getString(R.string.old_post_sheet_title, monthsStr)); + text.setText(R.string.old_post_sheet_text); + icon.setImageResource(R.drawable.ic_fluent_clock_24_regular); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/PreReplySheet.java b/mastodon/src/main/java/org/joinmastodon/android/ui/PreReplySheet.java new file mode 100644 index 000000000..2fb3c9a7a --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/PreReplySheet.java @@ -0,0 +1,54 @@ +package org.joinmastodon.android.ui; + +import android.content.Context; +import android.graphics.drawable.ColorDrawable; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.Button; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.ui.utils.UiUtils; + +import androidx.annotation.NonNull; +import me.grishka.appkit.views.BottomSheet; + +public abstract class PreReplySheet extends BottomSheet{ + protected ImageView icon; + protected TextView title, text; + protected Button gotItButton, dontRemindButton; + protected LinearLayout contentWrap; + + public PreReplySheet(@NonNull Context context, ResultListener resultListener){ + super(context); + + View content=context.getSystemService(LayoutInflater.class).inflate(R.layout.sheet_pre_reply, null); + setContentView(content); + + setNavigationBarBackground(new ColorDrawable(UiUtils.alphaBlendColors(UiUtils.getThemeColor(context, R.attr.colorM3Surface), + UiUtils.getThemeColor(context, R.attr.colorM3Primary), 0.05f)), !UiUtils.isDarkTheme()); + + icon=findViewById(R.id.icon); + title=findViewById(R.id.title); + text=findViewById(R.id.text); + gotItButton=findViewById(R.id.btn_got_it); + dontRemindButton=findViewById(R.id.btn_dont_remind_again); + contentWrap=findViewById(R.id.content_wrap); + + gotItButton.setOnClickListener(v->{ + dismiss(); + resultListener.onButtonClicked(false); + }); + dontRemindButton.setOnClickListener(v->{ + dismiss(); + resultListener.onButtonClicked(true); + }); + } + + @FunctionalInterface + public interface ResultListener{ + void onButtonClicked(boolean notAgain); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/SearchViewHelper.java b/mastodon/src/main/java/org/joinmastodon/android/ui/SearchViewHelper.java index 96d4c5480..53e4fd75c 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/SearchViewHelper.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/SearchViewHelper.java @@ -48,7 +48,7 @@ public class SearchViewHelper{ searchEdit.setPadding(0, 0, 0, 0); searchEdit.addTextChangedListener(new SimpleTextWatcher(e->{ searchEdit.removeCallbacks(debouncer); - searchEdit.postDelayed(debouncer, 300); + searchEdit.postDelayed(debouncer, 500); boolean newIsEmpty=e.length()==0; if(isEmpty!=newIsEmpty){ isEmpty=newIsEmpty; diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/Snackbar.java b/mastodon/src/main/java/org/joinmastodon/android/ui/Snackbar.java new file mode 100644 index 000000000..7ee499756 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/Snackbar.java @@ -0,0 +1,217 @@ +package org.joinmastodon.android.ui; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; +import android.content.Context; +import android.content.res.ColorStateList; +import android.graphics.Outline; +import android.graphics.PixelFormat; +import android.text.TextUtils; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewOutlineProvider; +import android.view.ViewTreeObserver; +import android.view.WindowManager; +import android.view.animation.AnimationUtils; +import android.widget.Button; +import android.widget.FrameLayout; +import android.widget.LinearLayout; +import android.widget.TextView; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.ui.utils.UiUtils; + +import androidx.annotation.Keep; +import androidx.annotation.StringRes; +import me.grishka.appkit.utils.CubicBezierInterpolator; +import me.grishka.appkit.utils.V; + +public class Snackbar{ + private static Snackbar current; + + private final Context context; + private int bottomOffset; + private FrameLayout windowView; + private LinearLayout contentView; + private boolean hasAction; + private AnimatableOutlineProvider outlineProvider; + private Animator currentAnim; + private Runnable dismissRunnable=this::dismiss; + + private Snackbar(Context context, String text, String action, Runnable onActionClick, int bottomOffset){ + this.context=context; + this.bottomOffset=bottomOffset; + hasAction=onActionClick!=null; + + windowView=new FrameLayout(context); + windowView.setClipToPadding(false); + contentView=new LinearLayout(context); + contentView.setOrientation(LinearLayout.HORIZONTAL); + contentView.setBaselineAligned(false); + contentView.setBackgroundColor(UiUtils.getThemeColor(context, R.attr.colorM3SurfaceInverse)); + contentView.setOutlineProvider(outlineProvider=new AnimatableOutlineProvider(contentView)); + contentView.setClipToOutline(true); + contentView.setElevation(V.dp(6)); + contentView.setPaddingRelative(V.dp(16), 0, V.dp(8), 0); + FrameLayout.LayoutParams lp=new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + lp.leftMargin=lp.topMargin=lp.rightMargin=lp.bottomMargin=V.dp(16); + windowView.addView(contentView, lp); + + TextView textView=new TextView(context); + textView.setTextAppearance(R.style.m3_body_medium); + textView.setTextColor(UiUtils.getThemeColor(context, R.attr.colorM3OnSurfaceInverse)); + textView.setMaxLines(2); + textView.setEllipsize(TextUtils.TruncateAt.END); + textView.setText(text); + textView.setMinHeight(V.dp(48)); + textView.setGravity(Gravity.CENTER_VERTICAL | Gravity.START); + textView.setPadding(0, V.dp(14), 0, V.dp(14)); + contentView.addView(textView, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)); + + if(action!=null){ + Button button=new Button(context); + int primaryInverse=UiUtils.getThemeColor(context, R.attr.colorM3PrimaryInverse); + button.setTextColor(primaryInverse); + button.setBackgroundResource(R.drawable.bg_rect_4dp_ripple); + button.setBackgroundTintList(ColorStateList.valueOf(primaryInverse)); + button.setText(action); + button.setPadding(V.dp(8), 0, V.dp(8), 0); + button.setOnClickListener(v->onActionClick.run()); + LinearLayout.LayoutParams blp=new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, V.dp(40)); + blp.leftMargin=blp.topMargin=blp.rightMargin=blp.bottomMargin=V.dp(4); + contentView.addView(button, blp); + } + } + + public void show(){ + if(current!=null) + current.dismiss(); + current=this; + WindowManager.LayoutParams lp=new WindowManager.LayoutParams(WindowManager.LayoutParams.TYPE_APPLICATION_PANEL, WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN, PixelFormat.TRANSLUCENT); + lp.width=ViewGroup.LayoutParams.MATCH_PARENT; + lp.height=ViewGroup.LayoutParams.WRAP_CONTENT; + lp.gravity=Gravity.BOTTOM; + lp.y=bottomOffset; + WindowManager wm=context.getSystemService(WindowManager.class); + wm.addView(windowView, lp); + windowView.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener(){ + @Override + public boolean onPreDraw(){ + windowView.getViewTreeObserver().removeOnPreDrawListener(this); + + AnimatorSet set=new AnimatorSet(); + set.playTogether( + ObjectAnimator.ofFloat(outlineProvider, "fraction", 0, 1), + ObjectAnimator.ofFloat(contentView, View.ALPHA, 0, 1) + ); + set.setInterpolator(AnimationUtils.loadInterpolator(context, R.interpolator.m3_sys_motion_easing_standard_decelerate)); + set.setDuration(350); + set.addListener(new AnimatorListenerAdapter(){ + @Override + public void onAnimationEnd(Animator animation){ + currentAnim=null; + } + }); + currentAnim=set; + set.start(); + + return true; + } + }); + windowView.postDelayed(dismissRunnable, 4000); + } + + public void dismiss(){ + current=null; + if(currentAnim!=null){ + currentAnim.cancel(); + } + windowView.removeCallbacks(dismissRunnable); + ObjectAnimator anim=ObjectAnimator.ofFloat(contentView, View.ALPHA, 0); + anim.setInterpolator(CubicBezierInterpolator.DEFAULT); + anim.setDuration(200); + anim.addListener(new AnimatorListenerAdapter(){ + @Override + public void onAnimationEnd(Animator animation){ + WindowManager wm=context.getSystemService(WindowManager.class); + wm.removeView(windowView); + } + }); + anim.start(); + } + + private static class AnimatableOutlineProvider extends ViewOutlineProvider{ + private float fraction=1f; + private final View view; + + private AnimatableOutlineProvider(View view){ + this.view=view; + } + + @Override + public void getOutline(View view, Outline outline){ + outline.setRoundRect(0, Math.round(view.getHeight()*(1f-fraction)), view.getWidth(), view.getHeight(), V.dp(4)); + } + + @Keep + public float getFraction(){ + return fraction; + } + + @Keep + public void setFraction(float fraction){ + this.fraction=fraction; + view.invalidateOutline(); + } + } + + public static class Builder{ + private final Context context; + private String text; + private String action; + private Runnable onActionClick; + private int bottomOffset; + + public Builder(Context context){ + this.context=context; + } + + public Builder setText(String text){ + this.text=text; + return this; + } + + public Builder setText(@StringRes int res){ + text=context.getString(res); + return this; + } + + public Builder setAction(String action, Runnable onActionClick){ + this.action=action; + this.onActionClick=onActionClick; + return this; + } + + public Builder setAction(@StringRes int action, Runnable onActionClick){ + this.action=context.getString(action); + this.onActionClick=onActionClick; + return this; + } + + public Builder setBottomOffset(int offset){ + bottomOffset=offset; + return this; + } + + public Snackbar create(){ + return new Snackbar(context, text, action, onActionClick, bottomOffset); + } + + public void show(){ + create().show(); + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/adapters/GenericListItemsAdapter.java b/mastodon/src/main/java/org/joinmastodon/android/ui/adapters/GenericListItemsAdapter.java index d5b5655b8..bd057a428 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/adapters/GenericListItemsAdapter.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/adapters/GenericListItemsAdapter.java @@ -3,9 +3,12 @@ package org.joinmastodon.android.ui.adapters; import android.view.ViewGroup; import org.joinmastodon.android.R; +import org.joinmastodon.android.model.viewmodel.AvatarPileListItem; import org.joinmastodon.android.model.viewmodel.ListItem; +import org.joinmastodon.android.ui.viewholders.AvatarPileListItemViewHolder; import org.joinmastodon.android.ui.viewholders.CheckboxOrRadioListItemViewHolder; import org.joinmastodon.android.ui.viewholders.ListItemViewHolder; +import org.joinmastodon.android.ui.viewholders.OptionsListItemViewHolder; import org.joinmastodon.android.ui.viewholders.SimpleListItemViewHolder; import org.joinmastodon.android.ui.viewholders.SwitchListItemViewHolder; @@ -13,11 +16,21 @@ import java.util.List; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; +import me.grishka.appkit.imageloader.ImageLoaderRecyclerAdapter; +import me.grishka.appkit.imageloader.ListImageLoaderWrapper; +import me.grishka.appkit.imageloader.requests.ImageLoaderRequest; +import me.grishka.appkit.views.UsableRecyclerView; -public class GenericListItemsAdapter extends RecyclerView.Adapter>{ +public class GenericListItemsAdapter extends UsableRecyclerView.Adapter> implements ImageLoaderRecyclerAdapter{ private List> items; public GenericListItemsAdapter(List> items){ + super(null); + this.items=items; + } + + public GenericListItemsAdapter(ListImageLoaderWrapper imgLoader, List> items){ + super(imgLoader); this.items=items; } @@ -26,12 +39,16 @@ public class GenericListItemsAdapter extends RecyclerView.Adapter onCreateViewHolder(@NonNull ViewGroup parent, int viewType){ if(viewType==R.id.list_item_simple || viewType==R.id.list_item_simple_tinted) return new SimpleListItemViewHolder(parent.getContext(), parent); - if(viewType==R.id.list_item_switch) - return new SwitchListItemViewHolder(parent.getContext(), parent); + if(viewType==R.id.list_item_switch || viewType==R.id.list_item_switch_separated) + return new SwitchListItemViewHolder(parent.getContext(), parent, viewType==R.id.list_item_switch_separated); if(viewType==R.id.list_item_checkbox) return new CheckboxOrRadioListItemViewHolder(parent.getContext(), parent, false); if(viewType==R.id.list_item_radio) return new CheckboxOrRadioListItemViewHolder(parent.getContext(), parent, true); + if(viewType==R.id.list_item_options) + return new OptionsListItemViewHolder(parent.getContext(), parent); + if(viewType==R.id.list_item_avatar_pile) + return new AvatarPileListItemViewHolder(parent.getContext(), parent); throw new IllegalArgumentException("Unexpected view type "+viewType); } @@ -51,4 +68,20 @@ public class GenericListItemsAdapter extends RecyclerView.Adapter item=items.get(position); + if(item instanceof AvatarPileListItem avatarPileListItem) + return avatarPileListItem.avatars.size(); + return 0; + } + + @Override + public ImageLoaderRequest getImageRequest(int position, int image){ + ListItem item=items.get(position); + if(item instanceof AvatarPileListItem avatarPileListItem) + return avatarPileListItem.avatars.get(image); + return null; + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/AccountCardStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/AccountCardStatusDisplayItem.java index fd753ef77..c6c2288aa 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/AccountCardStatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/AccountCardStatusDisplayItem.java @@ -172,7 +172,18 @@ public class AccountCardStatusDisplayItem extends StatusDisplayItem{ private void onFollowRequestButtonClick(View v) { itemView.setHasTransientState(true); - UiUtils.handleFollowRequest((Activity) v.getContext(), item.account, item.parentFragment.getAccountID(), null, v == acceptButton, relationship, rel -> { + UiUtils.handleFollowRequest((Activity) v.getContext(), item.account, item.parentFragment.getAccountID(), null, v == acceptButton, relationship, (Boolean visible) -> { + if(v==acceptButton){ + acceptButton.setTextVisible(!visible); + acceptProgress.setVisibility(visible ? View.VISIBLE : View.GONE); + acceptButton.setClickable(!visible); + }else{ + rejectButton.setTextVisible(!visible); + rejectProgress.setVisibility(visible ? View.VISIBLE : View.GONE); + rejectButton.setClickable(!visible); + } + itemView.setHasTransientState(false); + }, rel -> { if(v.getContext()==null || rel==null) return; itemView.setHasTransientState(false); item.parentFragment.putRelationship(item.account.id, rel); @@ -201,6 +212,8 @@ public class AccountCardStatusDisplayItem extends StatusDisplayItem{ private void setActionProgressVisible(boolean visible){ actionButton.setTextVisible(!visible); actionProgress.setVisibility(visible ? View.VISIBLE : View.GONE); + if(visible) + actionProgress.setIndeterminateTintList(actionButton.getTextColors()); actionButton.setClickable(!visible); } @@ -212,8 +225,8 @@ public class AccountCardStatusDisplayItem extends StatusDisplayItem{ cover.setImageDrawable(image); }else{ item.emojiHelper.setImageDrawable(index-2, image); - name.invalidate(); - bio.invalidate(); + name.setText(name.getText()); + bio.setText(bio.getText()); } if(image instanceof Animatable && !((Animatable) image).isRunning()) ((Animatable) image).start(); diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/AudioStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/AudioStatusDisplayItem.java index 1333d76d0..767c28e1f 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/AudioStatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/AudioStatusDisplayItem.java @@ -39,7 +39,7 @@ public class AudioStatusDisplayItem extends StatusDisplayItem{ super(parentID, parentFragment); this.status=status; this.attachment=attachment; - imageRequest=new UrlImageLoaderRequest(TextUtils.isEmpty(attachment.previewUrl) ? status.account.avatarStatic : attachment.previewUrl, V.dp(100), V.dp(100)); + imageRequest=new UrlImageLoaderRequest(TextUtils.isEmpty(attachment.previewUrl) ? (status.account != null ? status.account.avatarStatic : "") : attachment.previewUrl, V.dp(100), V.dp(100)); } @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/EmojiReactionsStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/EmojiReactionsStatusDisplayItem.java index b93fa638a..42124bd34 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/EmojiReactionsStatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/EmojiReactionsStatusDisplayItem.java @@ -195,7 +195,7 @@ public class EmojiReactionsStatusDisplayItem extends StatusDisplayItem { addButton.setAlpha(canReact ? 1 : ALPHA_DISABLED); } item.status.reactions.forEach(r->r.request=r.getUrl(item.playGifs)!=null - ? new UrlImageLoaderRequest(r.getUrl(item.playGifs), V.sp(24), V.sp(24)) + ? new UrlImageLoaderRequest(r.getUrl(item.playGifs), 0, V.sp(24)) : null); emojiKeyboard=new CustomEmojiPopupKeyboard( (Activity) item.parentFragment.getContext(), @@ -477,7 +477,9 @@ public class EmojiReactionsStatusDisplayItem extends StatusDisplayItem { @Override public void setImage(int index, Drawable drawable){ - drawable.setBounds(0, 0, V.sp(24), V.sp(24)); + int height=V.sp(24); + int width=drawable.getIntrinsicWidth()*height/drawable.getIntrinsicHeight(); + drawable.setBounds(0, 0, width, height); btn.setCompoundDrawablesRelative(drawable, null, null, null); if(drawable instanceof Animatable) ((Animatable) drawable).start(); } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/ErrorStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/ErrorStatusDisplayItem.java new file mode 100644 index 000000000..20ca27d6e --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/ErrorStatusDisplayItem.java @@ -0,0 +1,64 @@ +package org.joinmastodon.android.ui.displayitems; + +import android.content.Context; +import android.os.Build; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; + +import org.joinmastodon.android.BuildConfig; +import org.joinmastodon.android.R; +import org.joinmastodon.android.fragments.BaseStatusListFragment; +import org.joinmastodon.android.model.Status; +import org.joinmastodon.android.ui.utils.UiUtils; + +import java.io.PrintWriter; +import java.io.StringWriter; + +public class ErrorStatusDisplayItem extends StatusDisplayItem{ + private final Exception exception; + + public ErrorStatusDisplayItem(String parentID, Status status, BaseStatusListFragment parentFragment, Exception exception) { + super(parentID, parentFragment); + this.exception=exception; + this.status=status; + } + + @Override + public Type getType() { + return Type.ERROR_ITEM; + } + + public static class Holder extends StatusDisplayItem.Holder { + private final Button openInBrowserButton; + + public Holder(Context context, ViewGroup parent) { + super(context, R.layout.display_item_error, parent); + openInBrowserButton=findViewById(R.id.button_open_browser); + openInBrowserButton.setOnClickListener(v -> UiUtils.launchWebBrowser(v.getContext(), item.status.url)); + findViewById(R.id.button_copy_error_details).setOnClickListener(this::copyErrorDetails); + } + + @Override + public void onBind(ErrorStatusDisplayItem item) { + openInBrowserButton.setEnabled(item.status!=null && item.status.url!=null); + } + + private void copyErrorDetails(View v) { + StringWriter stringWriter=new StringWriter(); + PrintWriter printWriter=new PrintWriter(stringWriter); + item.exception.printStackTrace(printWriter); + String stackTrace=stringWriter.toString(); + + String errorDetails=String.format( + "App Version: %s\nOS Version: %s\nStatus URL: %s\nException: %s", + v.getContext().getString(R.string.mo_settings_app_version, BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE), + "Android " + Build.VERSION.RELEASE, + item.status.url, + stackTrace + ); + UiUtils.copyText(v, errorDetails); + } + } +} + diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/ExtendedFooterStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/ExtendedFooterStatusDisplayItem.java index 3e08f6101..616edd0d2 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/ExtendedFooterStatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/ExtendedFooterStatusDisplayItem.java @@ -2,43 +2,57 @@ package org.joinmastodon.android.ui.displayitems; import android.annotation.SuppressLint; import android.content.Context; +import android.graphics.Typeface; +import android.graphics.drawable.Drawable; +import android.os.Build; import android.os.Bundle; import android.text.SpannableStringBuilder; import android.text.TextUtils; import android.text.style.ForegroundColorSpan; +import android.text.style.StyleSpan; import android.text.style.TypefaceSpan; import android.view.View; import android.view.ViewGroup; import android.widget.Button; import android.widget.ImageView; import android.widget.TextView; +import android.widget.Toast; import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.R; import org.joinmastodon.android.api.session.AccountSession; import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.fragments.BaseStatusListFragment; import org.joinmastodon.android.fragments.StatusEditHistoryFragment; +import org.joinmastodon.android.fragments.ThreadFragment; import org.joinmastodon.android.fragments.account_list.StatusFavoritesListFragment; import org.joinmastodon.android.fragments.account_list.StatusReblogsListFragment; import org.joinmastodon.android.fragments.account_list.StatusRelatedAccountListFragment; import org.joinmastodon.android.model.Status; +import org.joinmastodon.android.ui.Snackbar; import org.joinmastodon.android.model.StatusPrivacy; import org.joinmastodon.android.ui.utils.UiUtils; import org.parceler.Parcels; +import java.time.LocalDate; import java.time.ZoneId; +import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.time.format.FormatStyle; import java.util.Locale; import androidx.annotation.PluralsRes; +import androidx.annotation.StringRes; import me.grishka.appkit.Nav; +import me.grishka.appkit.utils.V; public class ExtendedFooterStatusDisplayItem extends StatusDisplayItem{ public final String accountID; - private static final DateTimeFormatter TIME_FORMATTER=DateTimeFormatter.ofLocalizedDateTime(FormatStyle.LONG, FormatStyle.SHORT); + private static final DateTimeFormatter TIME_FORMATTER=DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT); + private static final DateTimeFormatter TIME_FORMATTER_LONG=DateTimeFormatter.ofLocalizedTime(FormatStyle.MEDIUM); + private static final DateTimeFormatter DATE_FORMATTER=DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM); public ExtendedFooterStatusDisplayItem(String parentID, BaseStatusListFragment parentFragment, String accountID, Status status){ super(parentID, parentFragment); @@ -52,8 +66,8 @@ public class ExtendedFooterStatusDisplayItem extends StatusDisplayItem{ } public static class Holder extends StatusDisplayItem.Holder{ - private final TextView time; - private final Button favorites, reblogs, editHistory, applicationName; + private final TextView time, date, app, dateAppSeparator; + private final TextView favorites, reblogs, editHistory; private final ImageView visibility; private final Context context; @@ -63,45 +77,51 @@ public class ExtendedFooterStatusDisplayItem extends StatusDisplayItem{ reblogs=findViewById(R.id.reblogs); favorites=findViewById(R.id.favorites); editHistory=findViewById(R.id.edit_history); - applicationName=findViewById(R.id.application_name); + time=findViewById(R.id.time); + date=findViewById(R.id.date); + app=findViewById(R.id.app_name); visibility=findViewById(R.id.visibility); - time=findViewById(R.id.timestamp); + dateAppSeparator=findViewById(R.id.date_app_separator); reblogs.setOnClickListener(v->startAccountListFragment(StatusReblogsListFragment.class)); favorites.setOnClickListener(v->startAccountListFragment(StatusFavoritesListFragment.class)); editHistory.setOnClickListener(v->startEditHistoryFragment()); + time.setOnClickListener(v->showTimeSnackbar()); + app.setOnClickListener(v->UiUtils.launchWebBrowser(context, item.status.application.website)); } @SuppressLint("DefaultLocale") @Override public void onBind(ExtendedFooterStatusDisplayItem item){ Status s=item.status; + favorites.setText(getFormattedPlural(R.plurals.x_favorites, item.status.favouritesCount)); favorites.setCompoundDrawablesRelativeWithIntrinsicBounds(GlobalUserPreferences.likeIcon ? R.drawable.ic_fluent_heart_20_regular : R.drawable.ic_fluent_star_20_regular, 0, 0, 0); - favorites.setText(context.getResources().getQuantityString(R.plurals.x_favorites, (int)(s.favouritesCount%1000), s.favouritesCount)); - reblogs.setText(context.getResources().getQuantityString(R.plurals.x_reblogs, (int) (s.reblogsCount % 1000), s.reblogsCount)); - reblogs.setVisibility(s.visibility != StatusPrivacy.DIRECT ? View.VISIBLE : View.GONE); - + reblogs.setText(getFormattedPlural(R.plurals.x_reblogs, item.status.reblogsCount)); if(s.editedAt!=null){ editHistory.setVisibility(View.VISIBLE); - editHistory.setText(UiUtils.formatRelativeTimestampAsMinutesAgo(itemView.getContext(), s.editedAt, false)); + ZonedDateTime dt=s.editedAt.atZone(ZoneId.systemDefault()); + String time=TIME_FORMATTER.format(dt); + if(!dt.toLocalDate().equals(LocalDate.now())){ + time+=" · "+DATE_FORMATTER.format(dt); + } + editHistory.setText(getFormattedSubstitutedString(R.string.last_edit_at_x, time)); }else{ editHistory.setVisibility(View.GONE); } - String timeStr=item.status.createdAt != null ? TIME_FORMATTER.format(item.status.createdAt.atZone(ZoneId.systemDefault())) : null; - - if (item.status.application!=null && !TextUtils.isEmpty(item.status.application.name)) { - time.setText(timeStr != null ? item.parentFragment.getString(R.string.timestamp_via_app, timeStr, "") : ""); - applicationName.setText(item.status.application.name); - if (item.status.application.website != null && item.status.application.website.toLowerCase().startsWith("https://")) { - applicationName.setOnClickListener(e -> UiUtils.openURL(context, null, item.status.application.website)); - } else { - applicationName.setEnabled(false); - } - } else { - time.setText(timeStr); - applicationName.setVisibility(View.GONE); + ZonedDateTime dt=item.status.createdAt.atZone(ZoneId.systemDefault()); + time.setText(TIME_FORMATTER.format(dt)); + date.setText(DATE_FORMATTER.format(dt)); + if(item.status.application!=null && !TextUtils.isEmpty(item.status.application.name)){ + app.setVisibility(View.VISIBLE); + dateAppSeparator.setVisibility(View.VISIBLE); + app.setText(item.status.application.name); + app.setEnabled(!TextUtils.isEmpty(item.status.application.website)); + }else{ + app.setVisibility(View.GONE); + dateAppSeparator.setVisibility(View.GONE); } + //TODO: make a snackbar pop up on hold of this visibility.setImageResource(switch (s.visibility) { case PUBLIC -> R.drawable.ic_fluent_earth_20_regular; case UNLISTED -> R.drawable.ic_fluent_lock_open_20_regular; @@ -116,14 +136,39 @@ public class ExtendedFooterStatusDisplayItem extends StatusDisplayItem{ return false; } - private SpannableStringBuilder getFormattedPlural(@PluralsRes int res, int quantity){ - String str=item.parentFragment.getResources().getQuantityString(res, quantity, quantity); + private SpannableStringBuilder getFormattedPlural(@PluralsRes int res, long quantity){ + String str=item.parentFragment.getResources().getQuantityString(res, (int)quantity, quantity); String formattedNumber=String.format(Locale.getDefault(), "%,d", quantity); int index=str.indexOf(formattedNumber); SpannableStringBuilder ssb=new SpannableStringBuilder(str); if(index>=0){ - ssb.setSpan(new TypefaceSpan("sans-serif-medium"), index, index+formattedNumber.length(), 0); - ssb.setSpan(new ForegroundColorSpan(UiUtils.getThemeColor(item.parentFragment.getActivity(), android.R.attr.textColorPrimary)), index, index+formattedNumber.length(), 0); + ForegroundColorSpan colorSpan=new ForegroundColorSpan(UiUtils.getThemeColor(itemView.getContext(), R.attr.colorM3OnSurfaceVariant)); + ssb.setSpan(colorSpan, index, index+formattedNumber.length(), 0); + Object typefaceSpan; + if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.S){ + typefaceSpan=new TypefaceSpan(Typeface.create(Typeface.DEFAULT, 600, false)); + }else{ + typefaceSpan=new StyleSpan(Typeface.BOLD); + } + ssb.setSpan(typefaceSpan, index, index+formattedNumber.length(), 0); + } + return ssb; + } + + private SpannableStringBuilder getFormattedSubstitutedString(@StringRes int res, String substitution){ + String str=item.parentFragment.getString(res, substitution); + int index=item.parentFragment.getString(res).indexOf("%s"); + SpannableStringBuilder ssb=new SpannableStringBuilder(str); + if(index>=0){ + ForegroundColorSpan colorSpan=new ForegroundColorSpan(UiUtils.getThemeColor(itemView.getContext(), R.attr.colorM3OnSurfaceVariant)); + ssb.setSpan(colorSpan, index, index+substitution.length(), 0); + Object typefaceSpan; + if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.S){ + typefaceSpan=new TypefaceSpan(Typeface.create(Typeface.DEFAULT, 600, false)); + }else{ + typefaceSpan=new StyleSpan(Typeface.BOLD); + } + ssb.setSpan(typefaceSpan, index, index+substitution.length(), 0); } return ssb; } @@ -144,5 +189,16 @@ public class ExtendedFooterStatusDisplayItem extends StatusDisplayItem{ args.putString("url", item.status.url); Nav.go(item.parentFragment.getActivity(), StatusEditHistoryFragment.class, args); } + + private void showTimeSnackbar(){ + int bottomOffset=0; + if(item.parentFragment instanceof ThreadFragment tf){ + bottomOffset=tf.getSnackbarOffset(); + } + new Snackbar.Builder(itemView.getContext()) + .setText(itemView.getContext().getString(R.string.posted_at, TIME_FORMATTER_LONG.format(item.status.createdAt.atZone(ZoneId.systemDefault())))) + .setBottomOffset(bottomOffset) + .show(); + } } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/FooterStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/FooterStatusDisplayItem.java index bd4bc8bff..26e995d9b 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/FooterStatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/FooterStatusDisplayItem.java @@ -6,13 +6,21 @@ import android.content.Context; import android.content.Intent; import android.content.res.ColorStateList; import android.graphics.drawable.Drawable; +import android.os.Build; import android.os.Bundle; +import android.os.VibrationEffect; +import android.os.Vibrator; +import android.view.HapticFeedbackConstants; import android.view.LayoutInflater; import android.view.MotionEvent; import android.view.View; import android.view.ViewConfiguration; import android.view.ViewGroup; import android.view.accessibility.AccessibilityNodeInfo; +import android.view.animation.AlphaAnimation; +import android.view.animation.Animation; +import android.view.animation.AnimationSet; +import android.view.animation.RotateAnimation; import android.widget.Button; import android.widget.FrameLayout; import android.widget.ImageView; @@ -56,6 +64,8 @@ public class FooterStatusDisplayItem extends StatusDisplayItem{ private final TextView replies, boosts, favorites; private final View reply, boost, favorite, share, bookmark; private final ImageView favIcon; + private static Animation spin; + private View touchingView = null; private boolean longClickPerformed = false; @@ -76,6 +86,13 @@ public class FooterStatusDisplayItem extends StatusDisplayItem{ } }; + static { + spin = new RotateAnimation(0, 360, + Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF, + 0.5f); + spin.setDuration(400); + } + public Holder(Activity activity, ViewGroup parent){ super(activity, R.layout.display_item_footer, parent); @@ -182,29 +199,34 @@ public class FooterStatusDisplayItem extends StatusDisplayItem{ private void onReplyClick(View v){ if(item.status.preview) return; - UiUtils.opacityIn(v); - Bundle args=new Bundle(); - args.putString("account", item.accountID); - args.putParcelable("replyTo", Parcels.wrap(item.status)); - Nav.go(item.parentFragment.getActivity(), ComposeFragment.class, args); + applyInteraction(v, status -> { + UiUtils.opacityIn(v); + openComposeView(status, item.accountID); + }); } private boolean onReplyLongClick(View v) { if(item.status.preview) return false; if (AccountSessionManager.getInstance().getLoggedInAccounts().size() < 2) return false; UiUtils.pickAccount(v.getContext(), item.accountID, R.string.sk_reply_as, R.drawable.ic_fluent_arrow_reply_28_regular, session -> { - Bundle args=new Bundle(); String accountID = session.getID(); - args.putString("account", accountID); UiUtils.lookupStatus(v.getContext(), item.status, accountID, item.accountID, status -> { if (status == null) return; - args.putParcelable("replyTo", Parcels.wrap(status)); - Nav.go(item.parentFragment.getActivity(), ComposeFragment.class, args); + openComposeView(status, accountID); }); }, null); return true; } + private void openComposeView(Status status, String accountID) { + item.parentFragment.maybeShowPreReplySheet(status, () ->{ + Bundle args=new Bundle(); + args.putString("account", accountID); + args.putParcelable("replyTo", Parcels.wrap(status)); + Nav.go(item.parentFragment.getActivity(), ComposeFragment.class, args); + }); + } + private void onBoostClick(View v){ if(item.status.preview) return; if (GlobalUserPreferences.confirmBoost) { @@ -212,8 +234,13 @@ public class FooterStatusDisplayItem extends StatusDisplayItem{ onBoostLongClick(v); return; } - boost.setSelected(!item.status.reblogged); - AccountSessionManager.getInstance().getAccount(item.accountID).getStatusInteractionController().setReblogged(item.status, !item.status.reblogged, null, r->boostConsumer(v, r)); + applyInteraction(v, status -> { + if(status == null) + return; + boost.setSelected(!status.reblogged); + vibrateForAction(boost, !status.reblogged); + AccountSessionManager.getInstance().getAccount(item.accountID).getStatusInteractionController().setReblogged(status, !status.reblogged, null, r->boostConsumer(v, r)); + }); } private void boostConsumer(View v, Status r) { @@ -230,9 +257,12 @@ public class FooterStatusDisplayItem extends StatusDisplayItem{ Consumer doReblog = (visibility) -> { UiUtils.opacityOut(v); - session.getStatusInteractionController() - .setReblogged(item.status, !item.status.reblogged, visibility, r->boostConsumer(v, r)); - dialog.dismiss(); + applyInteraction(v,status -> { + session.getStatusInteractionController() + .setReblogged(status, !status.reblogged, visibility, r->boostConsumer(v, r)); + boost.setSelected(status.reblogged); + dialog.dismiss(); + }); }; View separator = menu.findViewById(R.id.separator); @@ -310,16 +340,27 @@ public class FooterStatusDisplayItem extends StatusDisplayItem{ favorite.postDelayed(() -> { favorite.animate().scaleX(1).scaleY(1).setInterpolator(CubicBezierInterpolator.DEFAULT).setDuration(150).start(); UiUtils.opacityIn(favorite); + if(item.status.favourited && !GlobalUserPreferences.reduceMotion && !GlobalUserPreferences.likeIcon) { + favorite.startAnimation(spin); + } }, 300); bindText(favorites, item.status.favouritesCount); } private void onFavoriteClick(View v){ if(item.status.preview) return; - favorite.setSelected(!item.status.favourited); - AccountSessionManager.getInstance().getAccount(item.accountID).getStatusInteractionController().setFavorited(item.status, !item.status.favourited, r->{ - UiUtils.opacityIn(v); - bindText(favorites, r.favouritesCount); + applyInteraction(v, status -> { + if(status == null) + return; + favorite.setSelected(!status.favourited); + vibrateForAction(favorite, !status.favourited); + AccountSessionManager.getInstance().getAccount(item.accountID).getStatusInteractionController().setFavorited(status, !status.favourited, r->{ + if (status.favourited && !GlobalUserPreferences.reduceMotion && !GlobalUserPreferences.likeIcon) { + v.startAnimation(spin); + } + UiUtils.opacityIn(v); + bindText(favorites, r.favouritesCount); + }); }); } @@ -340,10 +381,16 @@ public class FooterStatusDisplayItem extends StatusDisplayItem{ private void onBookmarkClick(View v){ if(item.status.preview) return; - bookmark.setSelected(!item.status.bookmarked); - AccountSessionManager.getInstance().getAccount(item.accountID).getStatusInteractionController().setBookmarked(item.status, !item.status.bookmarked, r->{ - UiUtils.opacityIn(v); - }); + applyInteraction(v, + status -> { + if(status == null) + return; + bookmark.setSelected(!status.bookmarked); + vibrateForAction(bookmark, !status.bookmarked); + AccountSessionManager.getInstance().getAccount(item.accountID).getStatusInteractionController().setBookmarked(status, !status.bookmarked, r->{ + UiUtils.opacityIn(v); + }); + }); } private boolean onBookmarkLongClick(View v) { @@ -364,10 +411,7 @@ public class FooterStatusDisplayItem extends StatusDisplayItem{ private void onShareClick(View v){ if(item.status.preview) return; UiUtils.opacityIn(v); - Intent intent=new Intent(Intent.ACTION_SEND); - intent.setType("text/plain"); - intent.putExtra(Intent.EXTRA_TEXT, item.status.url); - v.getContext().startActivity(Intent.createChooser(intent, v.getContext().getString(R.string.share_toot_title))); + UiUtils.openSystemShareSheet(v.getContext(), item.status); } private boolean onShareLongClick(View v){ @@ -389,5 +433,39 @@ public class FooterStatusDisplayItem extends StatusDisplayItem{ return R.string.button_share; return 0; } + + private void applyInteraction(View v, Consumer interactionConsumer) { + if(!item.status.isRemote){ + interactionConsumer.accept(item.status); + return; + } + UiUtils.lookupStatus(v.getContext(), + item.status, item.accountID, null, + interactionConsumer + ); + } + + private static void vibrateForAction(View view, boolean isPositive) { + if (!GlobalUserPreferences.hapticFeedback) return; + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + view.performHapticFeedback(isPositive ? HapticFeedbackConstants.CONFIRM : HapticFeedbackConstants.REJECT); + return; + } + + Vibrator vibrator = view.getContext().getSystemService(Vibrator.class); + + if (Build.VERSION.SDK_INT == Build.VERSION_CODES.Q) { + vibrator.vibrate(VibrationEffect.createPredefined(isPositive ? VibrationEffect.EFFECT_CLICK : VibrationEffect.EFFECT_DOUBLE_CLICK)); + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + VibrationEffect effect = isPositive + ? VibrationEffect.createOneShot(75L, 128) + : VibrationEffect.createWaveform(new long[]{0L, 75L, 75L, 75L}, new int[]{0, 128, 0, 128}, -1); + vibrator.vibrate(effect); + } else { + if (isPositive) vibrator.vibrate(75L); + else vibrator.vibrate(new long[]{0L, 75L, 75L, 75L}, -1); + } + } } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/HeaderStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/HeaderStatusDisplayItem.java index a6fce3772..b17283927 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/HeaderStatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/HeaderStatusDisplayItem.java @@ -38,6 +38,7 @@ import org.joinmastodon.android.fragments.ThreadFragment; import org.joinmastodon.android.fragments.report.ReportReasonChoiceFragment; import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.Announcement; +import org.joinmastodon.android.model.Mention; import org.joinmastodon.android.model.Notification; import org.joinmastodon.android.model.Relationship; import org.joinmastodon.android.model.ScheduledStatus; @@ -56,6 +57,7 @@ import java.time.format.FormatStyle; import java.util.Collections; import java.util.List; import java.util.Locale; +import java.util.Objects; import java.util.function.Consumer; import androidx.annotation.LayoutRes; @@ -173,11 +175,11 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{ fragment.removeNotification(item.notification); } })); - collapseBtn.setOnClickListener(l -> item.parentFragment.onToggleExpanded(item.status, getItemID())); + collapseBtn.setOnClickListener(l -> item.parentFragment.onToggleExpanded(item.status, item.isForQuote, getItemID())); optionsMenu=new PopupMenu(activity, more); optionsMenu.inflate(R.menu.post); - if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.P && !UiUtils.isEMUI()) + if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.P && !UiUtils.isEMUI() && !UiUtils.isMagic()) optionsMenu.getMenu().setGroupDividerEnabled(true); optionsMenu.setOnMenuItemClickListener(menuItem->{ Account account=item.user; @@ -246,6 +248,8 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{ UiUtils.confirmPinPost(item.parentFragment.getActivity(), item.parentFragment.getAccountID(), item.status, !item.status.pinned, s->{}); }else if(id==R.id.mute){ UiUtils.confirmToggleMuteUser(item.parentFragment.getActivity(), item.parentFragment.getAccountID(), account, relationship!=null && relationship.muting, r->{}); + }else if (id==R.id.mute_conversation || id==R.id.unmute_conversation) { + UiUtils.confirmToggleMuteConversation(item.parentFragment.getActivity(), item.parentFragment.getAccountID(), item.status, ()->{}); }else if(id==R.id.block){ UiUtils.confirmToggleBlockUser(item.parentFragment.getActivity(), item.parentFragment.getAccountID(), account, relationship!=null && relationship.blocking, r->{}); }else if(id==R.id.report){ @@ -285,7 +289,11 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{ args.putString("profileDisplayUsername", account.getDisplayUsername()); Nav.go(item.parentFragment.getActivity(), ListsFragment.class, args); }else if(id==R.id.share){ - UiUtils.openSystemShareSheet(activity, item.status.url); + UiUtils.openSystemShareSheet(activity, item.status); + }else if(id==R.id.open_with_account){ + UiUtils.pickAccount(item.parentFragment.getActivity(), item.accountID, R.string.sk_open_with_account, R.drawable.ic_fluent_person_swap_24_regular, session ->UiUtils.openURL( + item.parentFragment.getActivity(), session.getID(), item.status.url, false + ), null); } return true; }); @@ -414,7 +422,7 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{ public void setImage(int index, Drawable drawable){ if(index>0){ item.emojiHelper.setImageDrawable(index-1, drawable); - name.invalidate(); + name.setText(name.getText()); }else{ avatar.setImageDrawable(drawable); } @@ -439,6 +447,14 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{ return; } Bundle args=new Bundle(); + if(item.status != null && item.status.isRemote){ + UiUtils.lookupAccount(v.getContext(), item.status.account, item.accountID, null, account -> { + args.putString("account", item.accountID); + args.putParcelable("profileAccount", Parcels.wrap(account)); + Nav.go(item.parentFragment.getActivity(), ProfileFragment.class, args); + }); + return; + } args.putString("account", item.accountID); args.putParcelable("profileAccount", Parcels.wrap(item.user)); Nav.go(item.parentFragment.getActivity(), ProfileFragment.class, args); @@ -477,17 +493,6 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{ Account account=item.user; Menu menu=optionsMenu.getMenu(); - MenuItem openWithAccounts = menu.findItem(R.id.open_with_account); - SubMenu accountsMenu = openWithAccounts != null ? openWithAccounts.getSubMenu() : null; - if (hasMultipleAccounts && accountsMenu != null) { - openWithAccounts.setVisible(true); - accountsMenu.clear(); - UiUtils.populateAccountsMenu(item.accountID, accountsMenu, s-> UiUtils.openURL( - item.parentFragment.getActivity(), s.getID(), item.status.url, false - )); - } else if (openWithAccounts != null) { - openWithAccounts.setVisible(false); - } String username = account.getShortUsername(); boolean isOwnPost=AccountSessionManager.getInstance().isSelf(item.parentFragment.getAccountID(), account); @@ -498,6 +503,14 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{ menu.findItem(R.id.delete_and_redraft).setVisible(!isPostScheduled && item.status!=null && isOwnPost); menu.findItem(R.id.pin).setVisible(!isPostScheduled && item.status!=null && isOwnPost && !item.status.pinned); menu.findItem(R.id.unpin).setVisible(!isPostScheduled && item.status!=null && isOwnPost && item.status.pinned); + menu.findItem(R.id.mute_conversation).setVisible((item.status!=null && !item.status.muted && !isPostScheduled) && (isOwnPost || item.status.mentions.stream().anyMatch(m->{ + if(m==null) + return false; + return AccountSessionManager.get(item.parentFragment.getAccountID()).self.id.equals(m.id) || + AccountSessionManager.get(item.parentFragment.getAccountID()).self.getFullyQualifiedName().equals(m.username) || + AccountSessionManager.get(item.parentFragment.getAccountID()).self.acct.equals(m.acct); + }))); + menu.findItem(R.id.unmute_conversation).setVisible(item.status!=null && item.status.muted); menu.findItem(R.id.open_in_browser).setVisible(!isPostScheduled && item.status!=null); menu.findItem(R.id.copy_link).setVisible(!isPostScheduled && item.status!=null); MenuItem blockDomain=menu.findItem(R.id.block_domain); diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/LinkCardStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/LinkCardStatusDisplayItem.java index b1ec54bb4..831e10dd4 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/LinkCardStatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/LinkCardStatusDisplayItem.java @@ -1,12 +1,13 @@ package org.joinmastodon.android.ui.displayitems; +import android.annotation.SuppressLint; import android.content.Context; +import android.content.res.ColorStateList; import android.graphics.drawable.Drawable; import android.net.Uri; import android.text.TextUtils; import android.view.View; import android.view.ViewGroup; -import android.widget.FrameLayout; import android.widget.ImageView; import android.widget.TextView; @@ -16,20 +17,23 @@ import org.joinmastodon.android.model.Card; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.ui.OutlineProviders; import org.joinmastodon.android.ui.drawables.BlurhashCrossfadeDrawable; +import org.joinmastodon.android.ui.text.HtmlParser; import org.joinmastodon.android.ui.utils.UiUtils; +import java.util.Optional; +import java.util.regex.Matcher; + import me.grishka.appkit.imageloader.ImageLoaderViewHolder; import me.grishka.appkit.imageloader.requests.ImageLoaderRequest; import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest; -import me.grishka.appkit.utils.V; public class LinkCardStatusDisplayItem extends StatusDisplayItem{ private final UrlImageLoaderRequest imgRequest; - public LinkCardStatusDisplayItem(String parentID, BaseStatusListFragment parentFragment, Status status){ + public LinkCardStatusDisplayItem(String parentID, BaseStatusListFragment parentFragment, Status status, boolean showImagePreview){ super(parentID, parentFragment); this.status=status; - if(status.card.image!=null) + if(status.card.image!=null && showImagePreview) imgRequest=new UrlImageLoaderRequest(status.card.image, 1000, 1000); else imgRequest=null; @@ -37,7 +41,7 @@ public class LinkCardStatusDisplayItem extends StatusDisplayItem{ @Override public Type getType(){ - return Type.CARD; + return status.card.type==Card.Type.VIDEO || (status.card.image!=null && status.card.width>status.card.height) ? Type.CARD_LARGE : Type.CARD_COMPACT; } @Override @@ -51,32 +55,55 @@ public class LinkCardStatusDisplayItem extends StatusDisplayItem{ } public static class Holder extends StatusDisplayItem.Holder implements ImageLoaderViewHolder{ - private final TextView title, description, domain; + private final TextView title, description, domain, timestamp; private final ImageView photo; - private final View inner; private BlurhashCrossfadeDrawable crossfadeDrawable=new BlurhashCrossfadeDrawable(); private boolean didClear; + private final View inner; + private final boolean isLarge; - public Holder(Context context, ViewGroup parent){ - super(context, R.layout.display_item_link_card, parent); + public Holder(Context context, ViewGroup parent, boolean isLarge){ + super(context, isLarge ? R.layout.display_item_link_card : R.layout.display_item_link_card_compact, parent); + this.isLarge=isLarge; title=findViewById(R.id.title); description=findViewById(R.id.description); domain=findViewById(R.id.domain); + timestamp=findViewById(R.id.timestamp); photo=findViewById(R.id.photo); inner=findViewById(R.id.inner); inner.setOnClickListener(this::onClick); + inner.setOutlineProvider(OutlineProviders.roundedRect(12)); + inner.setClipToOutline(true); } + @SuppressLint("SetTextI18n") @Override public void onBind(LinkCardStatusDisplayItem item){ Card card=item.status.card; title.setText(card.title); - description.setText(card.description); - description.setVisibility(TextUtils.isEmpty(card.description) ? View.GONE : View.VISIBLE); - domain.setText(Uri.parse(card.url).getHost()); + if(description!=null){ + description.setText(card.description); + description.setVisibility(TextUtils.isEmpty(card.description) ? View.GONE : View.VISIBLE); + } + String cardDomain=Uri.parse(card.url).getHost(); + if(isLarge && !TextUtils.isEmpty(card.authorName)){ + domain.setText(itemView.getContext().getString(R.string.article_by_author, card.authorName)+" · "+cardDomain); + }else{ + domain.setText(cardDomain); + } + if(card.publishedAt!=null){ + timestamp.setVisibility(View.VISIBLE); + timestamp.setText(" · "+UiUtils.formatRelativeTimestamp(itemView.getContext(), card.publishedAt)); + }else{ + timestamp.setVisibility(View.GONE); + } photo.setImageDrawable(null); if(item.imgRequest!=null){ + photo.setScaleType(ImageView.ScaleType.CENTER_CROP); + photo.setBackground(null); + photo.setImageTintList(null); + crossfadeDrawable.setSize(card.width, card.height); if (card.width > 0) { // akkoma servers don't provide width and height crossfadeDrawable.setSize(card.width, card.height); @@ -84,19 +111,17 @@ public class LinkCardStatusDisplayItem extends StatusDisplayItem{ crossfadeDrawable.setSize(itemView.getWidth(), itemView.getHeight()); } crossfadeDrawable.setBlurhashDrawable(card.blurhashPlaceholder); - crossfadeDrawable.setCrossfadeAlpha(item.status.spoilerRevealed ? 0f : 1f); + crossfadeDrawable.setCrossfadeAlpha(0f); + photo.setImageDrawable(null); photo.setImageDrawable(crossfadeDrawable); + photo.setVisibility(View.VISIBLE); didClear=false; + } else { + photo.setBackgroundColor(UiUtils.getThemeColor(itemView.getContext(), R.attr.colorM3SurfaceVariant)); + photo.setImageTintList(ColorStateList.valueOf(UiUtils.getThemeColor(itemView.getContext(), R.attr.colorM3Outline))); + photo.setScaleType(ImageView.ScaleType.CENTER); + photo.setImageResource(R.drawable.ic_feed_48px); } - - // if there's no image, we don't want to cover the inset borders - FrameLayout.LayoutParams params=(FrameLayout.LayoutParams) inner.getLayoutParams(); - int margin=item.inset && item.imgRequest == null ? V.dp(1) : 0; - params.setMargins(margin, 0, margin, margin); - - boolean insetAndLast=item.inset && isLastDisplayItemForStatus(); - inner.setClipToOutline(insetAndLast); - inner.setOutlineProvider(insetAndLast ? OutlineProviders.bottomRoundedRect(12) : null); } @Override @@ -104,6 +129,12 @@ public class LinkCardStatusDisplayItem extends StatusDisplayItem{ crossfadeDrawable.setImageDrawable(drawable); if(didClear && item.status.spoilerRevealed) crossfadeDrawable.animateAlpha(0f); + Card card=item.status.card; + // Make sure the image is not stretched if the server returned wrong dimensions + if(drawable!=null && (drawable.getIntrinsicWidth()!=card.width || drawable.getIntrinsicHeight()!=card.height)){ + photo.setImageDrawable(null); + photo.setImageDrawable(crossfadeDrawable); + } } @Override @@ -113,7 +144,35 @@ public class LinkCardStatusDisplayItem extends StatusDisplayItem{ } private void onClick(View v){ - UiUtils.openURL(itemView.getContext(), item.parentFragment.getAccountID(), item.status.card.url); + String url=item.status.card.url; + // Mastodon.social sometimes adds an additional redirect page + // this is really disruptive on mobile, especially since it breaks the loopUp/openURL functionality + Uri parsedURL=Uri.parse(url); + if(parsedURL.getPath()!=null && parsedURL.getPath().startsWith("/redirect/")){ + url=findRedirectedURL(parsedURL).orElse(url); + } + UiUtils.openURL(itemView.getContext(), item.parentFragment.getAccountID(), url); + } + + private Optional findRedirectedURL(Uri url){ + // find actually linked url in status content + Matcher matcher=HtmlParser.URL_PATTERN.matcher(item.status.content); + boolean isAccountRedirect=url.getPath().startsWith("/redirect/accounts"); + String foundURL; + while(matcher.find()){ + foundURL=matcher.group(3); + if(TextUtils.isEmpty(matcher.group(4))) + foundURL="http://"+foundURL; + // SAFETY: Cannot be null, as otherwise the matcher wouldn't find it + // also, group is marked as non-null + assert foundURL!=null && url.getLastPathSegment()!=null; + if(foundURL.endsWith(url.getLastPathSegment()) || + (isAccountRedirect && foundURL.matches("https://"+url.getHost()+"/@[a-zA-Z0-9_]+@[a-zA-Z0-9._]+$"))){ + // found correct URL + return Optional.of(foundURL); + } + } + return Optional.empty(); } } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/MediaGridStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/MediaGridStatusDisplayItem.java index 87a697086..fbd8a580f 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/MediaGridStatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/MediaGridStatusDisplayItem.java @@ -31,6 +31,7 @@ import org.joinmastodon.android.model.Translation; import org.joinmastodon.android.ui.OutlineProviders; import org.joinmastodon.android.ui.PhotoLayoutHelper; import org.joinmastodon.android.ui.drawables.SpoilerStripesDrawable; +import org.joinmastodon.android.ui.photoviewer.AltTextSheet; import org.joinmastodon.android.ui.photoviewer.PhotoViewerHost; import org.joinmastodon.android.ui.utils.MediaAttachmentViewController; import org.joinmastodon.android.ui.utils.UiUtils; @@ -72,8 +73,8 @@ public class MediaGridStatusDisplayItem extends StatusDisplayItem{ for(Attachment att:attachments){ requests.add(new UrlImageLoaderRequest(switch(att.type){ case IMAGE -> att.url; - case VIDEO, GIFV -> att.previewUrl != null ? att.previewUrl : att.url; - default -> throw new IllegalStateException("Unexpected value: "+att.type); + case VIDEO, GIFV -> att.previewUrl == null ? att.url : att.previewUrl; + default -> throw new IllegalStateException("Unexpected value: "+att.url); }, 1000, 1000)); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/NotificationHeaderStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/NotificationHeaderStatusDisplayItem.java index df87dae50..bb90911fd 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/NotificationHeaderStatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/NotificationHeaderStatusDisplayItem.java @@ -115,7 +115,6 @@ public class NotificationHeaderStatusDisplayItem extends StatusDisplayItem{ public static class Holder extends StatusDisplayItem.Holder implements ImageLoaderViewHolder{ private final ImageView icon, avatar, deleteNotification; private final TextView text, timestamp; - private final int selectableItemBackground; public Holder(Activity activity, ViewGroup parent){ super(activity, R.layout.display_item_notification_header, parent); @@ -134,9 +133,6 @@ public class NotificationHeaderStatusDisplayItem extends StatusDisplayItem{ })); itemView.setOnClickListener(this::onItemClick); - TypedValue outValue = new TypedValue(); - context.getTheme().resolveAttribute(android.R.attr.selectableItemBackground, outValue, true); - selectableItemBackground = outValue.resourceId; } @Override @@ -145,7 +141,7 @@ public class NotificationHeaderStatusDisplayItem extends StatusDisplayItem{ avatar.setImageDrawable(image); }else{ item.emojiHelper.setImageDrawable(index-1, image); - text.invalidate(); + text.setText(text.getText()); } if(image instanceof Animatable) ((Animatable) image).start(); @@ -183,12 +179,7 @@ public class NotificationHeaderStatusDisplayItem extends StatusDisplayItem{ default -> android.R.attr.colorAccent; }))); deleteNotification.setVisibility(GlobalUserPreferences.enableDeleteNotifications && item.notification != null ? View.VISIBLE : View.GONE); - itemView.setBackgroundResource(item.notification.type != Notification.Type.POLL - && item.notification.type != Notification.Type.REPORT ? - selectableItemBackground : 0); - itemView.setClickable(item.notification.type != Notification.Type.POLL); - itemView.setPaddingRelative(itemView.getPaddingStart(), itemView.getPaddingTop(), - GlobalUserPreferences.enableDeleteNotifications ? V.dp(4) : V.dp(16), itemView.getPaddingBottom()); + itemView.setBackgroundResource(0); } public void onItemClick(View v) { diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/PollFooterStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/PollFooterStatusDisplayItem.java index fbfba530d..2443e997d 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/PollFooterStatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/PollFooterStatusDisplayItem.java @@ -9,15 +9,18 @@ import android.widget.TextView; import org.joinmastodon.android.R; import org.joinmastodon.android.fragments.BaseStatusListFragment; import org.joinmastodon.android.model.Poll; +import org.joinmastodon.android.model.Status; import org.joinmastodon.android.ui.utils.UiUtils; public class PollFooterStatusDisplayItem extends StatusDisplayItem{ public final Poll poll; public boolean resultsVisible=false; + public final Status status; - public PollFooterStatusDisplayItem(String parentID, BaseStatusListFragment parentFragment, Poll poll){ + public PollFooterStatusDisplayItem(String parentID, BaseStatusListFragment parentFragment, Poll poll, Status status){ super(parentID, parentFragment); this.poll=poll; + this.status=status; } @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/PollOptionStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/PollOptionStatusDisplayItem.java index 434709e1c..1966c0fed 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/PollOptionStatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/PollOptionStatusDisplayItem.java @@ -1,10 +1,15 @@ package org.joinmastodon.android.ui.displayitems; +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.ObjectAnimator; +import android.animation.ValueAnimator; import android.app.Activity; import android.graphics.drawable.Animatable; import android.graphics.drawable.Drawable; import android.view.View; import android.view.ViewGroup; +import android.view.animation.DecelerateInterpolator; import android.widget.ImageView; import android.widget.TextView; @@ -17,7 +22,6 @@ import org.joinmastodon.android.ui.text.HtmlParser; import org.joinmastodon.android.ui.utils.CustomEmojiHelper; import org.joinmastodon.android.ui.utils.UiUtils; -import java.util.Collections; import java.util.Locale; import me.grishka.appkit.imageloader.ImageLoaderViewHolder; @@ -28,7 +32,8 @@ public class PollOptionStatusDisplayItem extends StatusDisplayItem{ private CharSequence translatedText; public final Poll.Option option; private CustomEmojiHelper emojiHelper=new CustomEmojiHelper(); - private boolean showResults; + public boolean showResults; + public boolean isAnimating; private float votesFraction; // 0..1 private boolean isMostVoted; private final int optionIndex; @@ -79,6 +84,7 @@ public class PollOptionStatusDisplayItem extends StatusDisplayItem{ private final View button; private final ImageView icon; private final Drawable progressBg; + private static final int ANIMATION_DURATION=500; public Holder(Activity activity, ViewGroup parent){ super(activity, R.layout.display_item_poll_option, parent); @@ -120,12 +126,17 @@ public class PollOptionStatusDisplayItem extends StatusDisplayItem{ } text.setTextColor(UiUtils.getThemeColor(itemView.getContext(), android.R.attr.textColorPrimary)); percent.setTextColor(UiUtils.getThemeColor(itemView.getContext(), R.attr.colorM3OnSecondaryContainer)); + + if (item.isAnimating) { + showResults(item.showResults); + item.isAnimating= false; + } } @Override public void setImage(int index, Drawable image){ item.emojiHelper.setImageDrawable(index, image); - text.invalidate(); + text.setText(text.getText()); if(image instanceof Animatable){ ((Animatable) image).start(); } @@ -134,7 +145,7 @@ public class PollOptionStatusDisplayItem extends StatusDisplayItem{ @Override public void clearImage(int index){ item.emojiHelper.setImageDrawable(index, null); - text.invalidate(); + text.setText(text.getText()); } private void onButtonClick(View v){ @@ -144,7 +155,34 @@ public class PollOptionStatusDisplayItem extends StatusDisplayItem{ public void showResults(boolean shown) { item.showResults = shown; item.calculateResults(); - rebind(); + Drawable bg=progressBg; + long animationDuration = (long) (ANIMATION_DURATION*item.votesFraction); + int startLevel=shown ? 0 : progressBg.getLevel(); + int targetLevel=shown ? Math.round(10000f*item.votesFraction) : 0; + ObjectAnimator animator=ObjectAnimator.ofInt(bg, "level", startLevel, targetLevel); + animator.setDuration(animationDuration); + animator.setInterpolator(new DecelerateInterpolator()); + button.setBackground(bg); + if(shown){ + itemView.setSelected(item.poll.ownVotes!=null && item.poll.ownVotes.contains(item.optionIndex)); + // animate percent + percent.setVisibility(View.VISIBLE); + ValueAnimator percentAnimation=ValueAnimator.ofInt(0, Math.round(100f*item.votesFraction)); + percentAnimation.setDuration(animationDuration); + percentAnimation.setInterpolator(new DecelerateInterpolator()); + percentAnimation.addUpdateListener(animation -> percent.setText(String.format(Locale.getDefault(), "%d%%", (int) animation.getAnimatedValue()))); + percentAnimation.start(); + }else{ + animator.addListener(new AnimatorListenerAdapter(){ + @Override + public void onAnimationEnd(Animator animation){ + button.setBackgroundResource(R.drawable.bg_poll_option_clickable); + } + }); + itemView.setSelected(item.poll.selectedOptions!=null && item.poll.selectedOptions.contains(item.option)); + percent.setVisibility(View.GONE); + } + animator.start(); } } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/PreviewlessMediaGridStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/PreviewlessMediaGridStatusDisplayItem.java new file mode 100644 index 000000000..ba5e3357b --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/PreviewlessMediaGridStatusDisplayItem.java @@ -0,0 +1,173 @@ +package org.joinmastodon.android.ui.displayitems; + +import android.app.Activity; +import android.util.Pair; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.LinearLayout; + +import org.joinmastodon.android.fragments.BaseStatusListFragment; +import org.joinmastodon.android.model.Attachment; +import org.joinmastodon.android.model.Status; +import org.joinmastodon.android.model.Translation; +import org.joinmastodon.android.ui.OutlineProviders; +import org.joinmastodon.android.ui.PhotoLayoutHelper; +import org.joinmastodon.android.ui.utils.PreviewlessMediaAttachmentViewController; +import org.joinmastodon.android.ui.views.FrameLayoutThatOnlyMeasuresFirstChild; +import org.joinmastodon.android.utils.TypedObjectPool; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +import me.grishka.appkit.imageloader.requests.ImageLoaderRequest; +import me.grishka.appkit.utils.V; + +public class PreviewlessMediaGridStatusDisplayItem extends StatusDisplayItem{ + private static final String TAG="PreviewlessMediaGridDisplayItem"; + + private PhotoLayoutHelper.TiledLayoutResult tiledLayout; + private final TypedObjectPool viewPool; + private final List attachments; + private final Map> translatedAttachments = new HashMap<>(); + private final ArrayList requests=new ArrayList<>(); + public final Status status; + public String sensitiveTitle; + + public PreviewlessMediaGridStatusDisplayItem(String parentID, BaseStatusListFragment parentFragment, PhotoLayoutHelper.TiledLayoutResult tiledLayout, List attachments, Status status){ + super(parentID, parentFragment); + this.tiledLayout=tiledLayout; + this.viewPool=parentFragment.getPreviewlessAttachmentViewsPool(); + this.attachments=attachments; + this.status=status; +// for(Attachment att:attachments){ +// requests.add(new UrlImageLoaderRequest(switch(att.type){ +// case IMAGE -> att.url; +// case VIDEO, GIFV -> att.previewUrl == null ? att.url : att.previewUrl; +// default -> throw new IllegalStateException("Unexpected value: "+att.url); +// }, 1000, 1000)); +// } + } + + @Override + public Type getType(){ + return Type.PREVIEWLESS_MEDIA_GRID; + } + + @Override + public int getImageCount(){ + return attachments.size(); + } + + @Override + public ImageLoaderRequest getImageRequest(int index){ + return requests.get(index); + } + + public static class Holder extends StatusDisplayItem.Holder { + private final FrameLayout wrapper; + private final LinearLayout layout; + private final View.OnClickListener clickListener=this::onViewClick; + private final ArrayList controllers=new ArrayList<>(); + + // private final FrameLayout hideSensitiveButton; + + public Holder(Activity activity, ViewGroup parent){ + super(new FrameLayoutThatOnlyMeasuresFirstChild(activity)); + wrapper=(FrameLayout)itemView; + layout= new LinearLayout(activity); + layout.setOrientation(LinearLayout.VERTICAL); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + layout.setLayoutParams(params); + wrapper.addView(layout); + wrapper.setClipToPadding(false); + + // megalodon: no sensitive hide button because the visibility toggle looks prettier imo +// hideSensitiveButton=(FrameLayout) activity.getLayoutInflater().inflate(R.layout.alt_text_badge, overlays, false); +// ((TextView) hideSensitiveButton.findViewById(R.id.alt_button)).setText(R.string.hide); +// overlays.addView(hideSensitiveButton, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.END | Gravity.TOP)); + +// hideSensitiveButton.setOnClickListener(v->hideSensitive()); + } + + @Override + public void onBind(PreviewlessMediaGridStatusDisplayItem item){ + wrapper.setPadding(0, 0, 0, 0); // item.inset ? 0 : V.dp(8)); + +// if(altTextAnimator!=null) +// altTextAnimator.cancel(); + + for(PreviewlessMediaAttachmentViewController c:controllers){ + item.viewPool.reuse(c.type, c); + } + layout.removeAllViews(); + controllers.clear(); + + int i=0; +// if (!item.attachments.isEmpty()) updateBlurhashInSensitiveOverlay(); + for(Attachment att:item.attachments){ + PreviewlessMediaAttachmentViewController c=item.viewPool.obtain(switch(att.type){ + case IMAGE -> MediaGridStatusDisplayItem.GridItemType.PHOTO; + case VIDEO -> MediaGridStatusDisplayItem.GridItemType.VIDEO; + case GIFV -> MediaGridStatusDisplayItem.GridItemType.GIFV; + default -> throw new IllegalStateException("Unexpected value: "+att.type); + }); + if(c.view.getLayoutParams()==null) + c.view.setLayoutParams(new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + layout.addView(c.view); + c.view.setOnClickListener(clickListener); + c.view.setTag(i); + controllers.add(c); + + if (item.status.translation != null){ + if(item.status.translationState==Status.TranslationState.SHOWN){ + if(!item.translatedAttachments.containsKey(att.id)){ + Optional translatedAttachment=Arrays.stream(item.status.translation.mediaAttachments).filter(mediaAttachment->mediaAttachment.id.equals(att.id)).findFirst(); + translatedAttachment.ifPresent(mediaAttachment->{ + item.translatedAttachments.put(mediaAttachment.id, new Pair<>(att.description, mediaAttachment.description)); + att.description=mediaAttachment.description; + }); + }else{ + //SAFETY: must be non-null, as we check if the map contains the attachment before + att.description=Objects.requireNonNull(item.translatedAttachments.get(att.id)).second; + } + }else{ + if (item.translatedAttachments.containsKey(att.id)) { + att.description=Objects.requireNonNull(item.translatedAttachments.get(att.id)).first; + } + } + } + c.bind(att, item.status); + i++; + } + + boolean insetAndLast=item.inset && isLastDisplayItemForStatus(); + wrapper.setClipToOutline(insetAndLast); + wrapper.setOutlineProvider(insetAndLast ? OutlineProviders.bottomRoundedRect(12) : null); + } + + private void onViewClick(View v){ + int index=(Integer)v.getTag(); + item.parentFragment.openPreviewlessMediaPhotoViewer(item.parentID, item.status, index, this); + } + + + public PreviewlessMediaAttachmentViewController getViewController(int index){ + return controllers.get(index); + } + + public void setClipChildren(boolean clip){ + layout.setClipChildren(clip); + wrapper.setClipChildren(clip); + } + + public LinearLayout getLayout(){ + return layout; + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/ReblogOrReplyLineStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/ReblogOrReplyLineStatusDisplayItem.java index 84df641a1..d6d4eefb3 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/ReblogOrReplyLineStatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/ReblogOrReplyLineStatusDisplayItem.java @@ -7,6 +7,7 @@ import android.content.Context; import android.graphics.drawable.Drawable; import android.os.Build; import android.text.SpannableStringBuilder; +import android.text.Spanned; import android.util.TypedValue; import android.view.View; import android.view.ViewGroup; @@ -15,10 +16,13 @@ import android.widget.TextView; import org.joinmastodon.android.R; import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.fragments.BaseStatusListFragment; +import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.Emoji; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.model.StatusPrivacy; +import org.joinmastodon.android.ui.text.AvatarSpan; import org.joinmastodon.android.ui.text.HtmlParser; +import org.joinmastodon.android.ui.text.SpacerSpan; import org.joinmastodon.android.ui.utils.CustomEmojiHelper; import org.joinmastodon.android.ui.utils.UiUtils; @@ -45,14 +49,23 @@ public class ReblogOrReplyLineStatusDisplayItem extends StatusDisplayItem{ CharSequence fullText; public ReblogOrReplyLineStatusDisplayItem(String parentID, BaseStatusListFragment parentFragment, CharSequence text, List emojis, @DrawableRes int icon, StatusPrivacy visibility, @Nullable View.OnClickListener handleClick, Status status) { - this(parentID, parentFragment, text, emojis, icon, visibility, handleClick, text, status); + this(parentID, parentFragment, text, emojis, icon, visibility, handleClick, text, status, null); } - public ReblogOrReplyLineStatusDisplayItem(String parentID, BaseStatusListFragment parentFragment, CharSequence text, List emojis, @DrawableRes int icon, StatusPrivacy visibility, @Nullable View.OnClickListener handleClick, CharSequence fullText, Status status) { + public ReblogOrReplyLineStatusDisplayItem(String parentID, BaseStatusListFragment parentFragment, CharSequence text, List emojis, @DrawableRes int icon, StatusPrivacy visibility, @Nullable View.OnClickListener handleClick, CharSequence fullText, Status status, Account account) { super(parentID, parentFragment); SpannableStringBuilder ssb=new SpannableStringBuilder(text); if(AccountSessionManager.get(parentFragment.getAccountID()).getLocalPreferences().customEmojiInNames) HtmlParser.parseCustomEmoji(ssb, emojis); + //this is fine, since the display name is surround by '\u2068' and '\u2069' + int nameLoc=account!=null ? text.toString().indexOf(account.getDisplayName()) : -1; + if(nameLoc!=-1&&ssb.length()>=nameLoc){ + //add temp chars for span replacement, length should be the same as the amount of spans replacing below + ssb.insert(nameLoc, " "); + ssb.setSpan(new SpacerSpan(15, 20), nameLoc+1, nameLoc+2, Spanned.SPAN_INCLUSIVE_EXCLUSIVE); + ssb.setSpan(new AvatarSpan(account), nameLoc+1, nameLoc+2, Spanned.SPAN_INCLUSIVE_EXCLUSIVE); + ssb.setSpan(new SpacerSpan(15, 20), nameLoc+2, nameLoc+3, Spanned.SPAN_INCLUSIVE_EXCLUSIVE); + } this.text=ssb; emojiHelper.setText(ssb); this.fullText=fullText; @@ -139,8 +152,8 @@ public class ReblogOrReplyLineStatusDisplayItem extends StatusDisplayItem{ int firstHelperCount=item.emojiHelper.getImageCount(); CustomEmojiHelper helper=index0 ? index%firstHelperCount : index, image); - text.invalidate(); - extraText.invalidate(); + text.setText(text.getText()); + extraText.setText(extraText.getText()); } @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/SpoilerStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/SpoilerStatusDisplayItem.java index 050988508..cbe013779 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/SpoilerStatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/SpoilerStatusDisplayItem.java @@ -114,7 +114,7 @@ public class SpoilerStatusDisplayItem extends StatusDisplayItem{ @Override public void setImage(int index, Drawable image){ item.emojiHelper.setImageDrawable(index, image); - title.invalidate(); + title.setText(title.getText()); } @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/StatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/StatusDisplayItem.java index 9ea9d9b75..84a920211 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/StatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/StatusDisplayItem.java @@ -10,13 +10,17 @@ import android.graphics.drawable.ColorDrawable; import android.os.Bundle; import android.text.SpannableStringBuilder; import android.text.TextUtils; +import android.util.Log; import android.view.View; import android.view.ViewGroup; import androidx.annotation.NonNull; import org.joinmastodon.android.R; +import org.joinmastodon.android.api.requests.accounts.GetAccountRelationships; +import org.joinmastodon.android.api.requests.search.GetSearchResults; import org.joinmastodon.android.api.session.AccountLocalPreferences; +import org.joinmastodon.android.api.session.AccountSession; import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.fragments.BaseStatusListFragment; import org.joinmastodon.android.fragments.HashtagTimelineFragment; @@ -28,12 +32,14 @@ import org.joinmastodon.android.fragments.ThreadFragment; import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.Attachment; import org.joinmastodon.android.model.DisplayItemsParent; -import org.joinmastodon.android.model.LegacyFilter; import org.joinmastodon.android.model.FilterContext; import org.joinmastodon.android.model.FilterResult; +import org.joinmastodon.android.model.LegacyFilter; import org.joinmastodon.android.model.Notification; import org.joinmastodon.android.model.Poll; +import org.joinmastodon.android.model.Relationship; import org.joinmastodon.android.model.ScheduledStatus; +import org.joinmastodon.android.model.SearchResults; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.ui.PhotoLayoutHelper; import org.joinmastodon.android.ui.text.HtmlParser; @@ -45,11 +51,16 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.function.Predicate; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import java.util.stream.Collectors; import me.grishka.appkit.Nav; +import me.grishka.appkit.api.Callback; +import me.grishka.appkit.api.ErrorResponse; import me.grishka.appkit.imageloader.requests.ImageLoaderRequest; import me.grishka.appkit.utils.BindableViewHolder; import me.grishka.appkit.views.UsableRecyclerView; @@ -75,7 +86,12 @@ public abstract class StatusDisplayItem{ public static final int FLAG_NO_TRANSLATE=1 << 5; public static final int FLAG_NO_EMOJI_REACTIONS=1 << 6; public static final int FLAG_IS_FOR_QUOTE=1 << 7; - + public static final int FLAG_NO_MEDIA_PREVIEW=1 << 8; + + + private final static Pattern QUOTE_MENTION_PATTERN=Pattern.compile("(?:

)?\\s?(?:RE:\\s?()?)?]*>https:\\/\\/<\\/span>[^<]+<\\/span>[^<]+<\\/span><\\/a>(?:<\\/p>)?$"); + private final static Pattern QUOTE_PATTERN=Pattern.compile("https://[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,8}\\b([-a-zA-Z0-9()@:%_\\+.~#?&//=]*)$"); + public void setAncestryInfo( boolean hasDescendantNeighbor, boolean hasAncestoringNeighbor, @@ -122,7 +138,8 @@ public abstract class StatusDisplayItem{ case AUDIO -> new AudioStatusDisplayItem.Holder(activity, parent); case POLL_OPTION -> new PollOptionStatusDisplayItem.Holder(activity, parent); case POLL_FOOTER -> new PollFooterStatusDisplayItem.Holder(activity, parent); - case CARD -> new LinkCardStatusDisplayItem.Holder(activity, parent); + case CARD_LARGE -> new LinkCardStatusDisplayItem.Holder(activity, parent, true); + case CARD_COMPACT -> new LinkCardStatusDisplayItem.Holder(activity, parent, false); case EMOJI_REACTIONS -> new EmojiReactionsStatusDisplayItem.Holder(activity, parent); case FOOTER -> new FooterStatusDisplayItem.Holder(activity, parent); case ACCOUNT_CARD -> new AccountCardStatusDisplayItem.Holder(activity, parent); @@ -131,11 +148,13 @@ public abstract class StatusDisplayItem{ case GAP -> new GapStatusDisplayItem.Holder(activity, parent); case EXTENDED_FOOTER -> new ExtendedFooterStatusDisplayItem.Holder(activity, parent); case MEDIA_GRID -> new MediaGridStatusDisplayItem.Holder(activity, parent); + case PREVIEWLESS_MEDIA_GRID -> new PreviewlessMediaGridStatusDisplayItem.Holder(activity, parent); case WARNING -> new WarningFilteredStatusDisplayItem.Holder(activity, parent); case FILE -> new FileStatusDisplayItem.Holder(activity, parent); case SPOILER, FILTER_SPOILER -> new SpoilerStatusDisplayItem.Holder(activity, parent, type); case SECTION_HEADER -> null; // new SectionHeaderStatusDisplayItem.Holder(activity, parent); case NOTIFICATION_HEADER -> new NotificationHeaderStatusDisplayItem.Holder(activity, parent); + case ERROR_ITEM -> new ErrorStatusDisplayItem.Holder(activity, parent); case DUMMY -> new DummyStatusDisplayItem.Holder(activity); }; } @@ -151,7 +170,7 @@ public abstract class StatusDisplayItem{ : fragment.getString(R.string.in_reply_to, account.getDisplayName()); return new ReblogOrReplyLineStatusDisplayItem( parentID, fragment, text, account == null ? List.of() : account.emojis, - R.drawable.ic_fluent_arrow_reply_20sp_filled, null, null, fullText, status + R.drawable.ic_fluent_arrow_reply_20sp_filled, null, null, fullText, status, account ); } @@ -161,89 +180,99 @@ public abstract class StatusDisplayItem{ Status statusForContent=status.getContentStatus(); Bundle args=new Bundle(); args.putString("account", accountID); - ScheduledStatus scheduledStatus = parentObject instanceof ScheduledStatus s ? s : null; + try{ + ScheduledStatus scheduledStatus=parentObject instanceof ScheduledStatus s ? s : null; - HeaderStatusDisplayItem header=null; - boolean hideCounts=!AccountSessionManager.get(accountID).getLocalPreferences().showInteractionCounts; - - if((flags & FLAG_NO_HEADER)==0){ - ReblogOrReplyLineStatusDisplayItem replyLine = null; - boolean threadReply = statusForContent.inReplyToAccountId != null && - statusForContent.inReplyToAccountId.equals(statusForContent.account.id); + HeaderStatusDisplayItem header=null; + boolean hideCounts=!AccountSessionManager.get(accountID).getLocalPreferences().showInteractionCounts; - if(statusForContent.inReplyToAccountId!=null && !(threadReply && fragment instanceof ThreadFragment)){ - Account account = knownAccounts.get(statusForContent.inReplyToAccountId); - replyLine = buildReplyLine(fragment, status, accountID, parentObject, account, threadReply); + if((flags&FLAG_NO_HEADER)==0){ + ReblogOrReplyLineStatusDisplayItem replyLine=null; + boolean threadReply=statusForContent.inReplyToAccountId!=null && + statusForContent.inReplyToAccountId.equals(statusForContent.account.id); + + if(statusForContent.inReplyToAccountId!=null && !(threadReply && fragment instanceof ThreadFragment)){ + Account account=knownAccounts.get(statusForContent.inReplyToAccountId); + replyLine=buildReplyLine(fragment, status, accountID, parentObject, account, threadReply); + } + + if(status.reblog!=null){ + boolean isOwnPost=AccountSessionManager.getInstance().isSelf(fragment.getAccountID(), status.account); + + statusForContent.rebloggedBy=status.account; + + String text=fragment.getString(R.string.user_boosted, status.account.getDisplayName()); + items.add(new ReblogOrReplyLineStatusDisplayItem(parentID, fragment, text, status.account.emojis, R.drawable.ic_fluent_arrow_repeat_all_20sp_filled, isOwnPost ? status.visibility : null, i->{ + args.putParcelable("profileAccount", Parcels.wrap(status.account)); + Nav.go(fragment.getActivity(), ProfileFragment.class, args); + }, null, status, status.account)); + }else if(!(status.tags.isEmpty() || + fragment instanceof HashtagTimelineFragment || + fragment instanceof ListTimelineFragment + ) && fragment.getParentFragment() instanceof HomeTabFragment home){ + home.getHashtags().stream() + .filter(followed->status.tags.stream() + .anyMatch(hashtag->followed.name.equalsIgnoreCase(hashtag.name))) + .findAny() + // post contains a hashtag the user is following + .ifPresent(hashtag->items.add(new ReblogOrReplyLineStatusDisplayItem( + parentID, fragment, hashtag.name, List.of(), + R.drawable.ic_fluent_number_symbol_20sp_filled, null, + i->UiUtils.openHashtagTimeline(fragment.getActivity(), accountID, hashtag), + status + ))); + } + + if(replyLine!=null){ + Optional primaryLine=items.stream() + .filter(i->i instanceof ReblogOrReplyLineStatusDisplayItem) + .map(ReblogOrReplyLineStatusDisplayItem.class::cast) + .findFirst(); + + if(primaryLine.isPresent()){ + primaryLine.get().extra=replyLine; + }else{ + items.add(replyLine); + } + } + + if((flags&FLAG_CHECKABLE)!=0) + items.add(header=new CheckableHeaderStatusDisplayItem(parentID, statusForContent.account, statusForContent.createdAt, fragment, accountID, statusForContent, null)); + else + items.add(header=new HeaderStatusDisplayItem(parentID, statusForContent.account, statusForContent.createdAt, fragment, accountID, statusForContent, null, parentObject instanceof Notification n ? n : null, scheduledStatus)); } - if(status.reblog!=null){ - boolean isOwnPost = AccountSessionManager.getInstance().isSelf(fragment.getAccountID(), status.account); - String text=fragment.getString(R.string.user_boosted, status.account.getDisplayName()); - items.add(new ReblogOrReplyLineStatusDisplayItem(parentID, fragment, text, status.account.emojis, R.drawable.ic_fluent_arrow_repeat_all_20sp_filled, isOwnPost ? status.visibility : null, i->{ - args.putParcelable("profileAccount", Parcels.wrap(status.account)); - Nav.go(fragment.getActivity(), ProfileFragment.class, args); - }, null, status)); - } else if (!(status.tags.isEmpty() || - fragment instanceof HashtagTimelineFragment || - fragment instanceof ListTimelineFragment - ) && fragment.getParentFragment() instanceof HomeTabFragment home) { - home.getHashtags().stream() - .filter(followed -> status.tags.stream() - .anyMatch(hashtag -> followed.name.equalsIgnoreCase(hashtag.name))) - .findAny() - // post contains a hashtag the user is following - .ifPresent(hashtag -> items.add(new ReblogOrReplyLineStatusDisplayItem( - parentID, fragment, hashtag.name, List.of(), - R.drawable.ic_fluent_number_symbol_20sp_filled, null, - i->UiUtils.openHashtagTimeline(fragment.getActivity(), accountID, hashtag), - status - ))); - } + LegacyFilter applyingFilter=null; + if(status.filtered!=null){ + ArrayList filters= new ArrayList<>(status.filtered); - if (replyLine != null) { - Optional primaryLine = items.stream() - .filter(i -> i instanceof ReblogOrReplyLineStatusDisplayItem) - .map(ReblogOrReplyLineStatusDisplayItem.class::cast) - .findFirst(); + // Only add client filters if there are no pre-existing status filter + if(filters.isEmpty()) + filters.addAll(AccountSessionManager.get(accountID).getClientSideFilters(status)); - if (primaryLine.isPresent()) { - primaryLine.get().extra = replyLine; - } else { - items.add(replyLine); + for(FilterResult filter : filters){ + LegacyFilter f=filter.filter; + if(f.isActive() && filterContext!=null && f.context.contains(filterContext)){ + applyingFilter=f; + break; + } } } - if((flags & FLAG_CHECKABLE)!=0) - items.add(header=new CheckableHeaderStatusDisplayItem(parentID, statusForContent.account, statusForContent.createdAt, fragment, accountID, statusForContent, null)); - else - items.add(header=new HeaderStatusDisplayItem(parentID, statusForContent.account, statusForContent.createdAt, fragment, accountID, statusForContent, null, parentObject instanceof Notification n ? n : null, scheduledStatus)); - } - - LegacyFilter applyingFilter=null; - if(status.filtered!=null){ - for(FilterResult filter:status.filtered){ - LegacyFilter f=filter.filter; - if(f.isActive() && filterContext != null && f.context.contains(filterContext)){ - applyingFilter=f; - break; + ArrayList contentItems; + if(statusForContent.hasSpoiler()){ + if(AccountSessionManager.get(accountID).getLocalPreferences().revealCWs) statusForContent.spoilerRevealed=true; + SpoilerStatusDisplayItem spoilerItem=new SpoilerStatusDisplayItem(parentID, fragment, null, statusForContent, Type.SPOILER); + if((flags&FLAG_IS_FOR_QUOTE)!=0){ + for(StatusDisplayItem item : spoilerItem.contentItems){ + item.isForQuote=true; + } } + items.add(spoilerItem); + contentItems=spoilerItem.contentItems; + }else{ + contentItems=items; } - } - - ArrayList contentItems; - if(statusForContent.hasSpoiler()){ - if (AccountSessionManager.get(accountID).getLocalPreferences().revealCWs) statusForContent.spoilerRevealed = true; - SpoilerStatusDisplayItem spoilerItem=new SpoilerStatusDisplayItem(parentID, fragment, null, statusForContent, Type.SPOILER); - if((flags & FLAG_IS_FOR_QUOTE)!=0){ - for(StatusDisplayItem item:spoilerItem.contentItems){ - item.isForQuote=true; - } - } - items.add(spoilerItem); - contentItems=spoilerItem.contentItems; - }else{ - contentItems=items; - } if(statusForContent.quote!=null) { int quoteInlineIndex=statusForContent.content.lastIndexOf("

RE:"); @@ -251,99 +280,104 @@ public abstract class StatusDisplayItem{ quoteInlineIndex=statusForContent.content.lastIndexOf("

RE:"); if(quoteInlineIndex!=-1) statusForContent.content=statusForContent.content.substring(0, quoteInlineIndex); - } - - boolean hasSpoiler=!TextUtils.isEmpty(statusForContent.spoilerText); - if(!TextUtils.isEmpty(statusForContent.content)){ - SpannableStringBuilder parsedText=HtmlParser.parse(statusForContent.content, statusForContent.emojis, statusForContent.mentions, statusForContent.tags, accountID, fragment.getContext()); - HtmlParser.applyFilterHighlights(fragment.getActivity(), parsedText, status.filtered); - TextStatusDisplayItem text=new TextStatusDisplayItem(parentID, HtmlParser.parse(statusForContent.content, statusForContent.emojis, statusForContent.mentions, statusForContent.tags, accountID, fragment.getContext()), fragment, statusForContent, (flags & FLAG_NO_TRANSLATE) != 0); - contentItems.add(text); - }else if(!hasSpoiler && header!=null){ - header.needBottomPadding=true; - }else if(hasSpoiler){ - contentItems.add(new DummyStatusDisplayItem(parentID, fragment)); - } - - List imageAttachments=statusForContent.mediaAttachments.stream().filter(att->att.type.isImage()).collect(Collectors.toList()); - if(!imageAttachments.isEmpty()){ - int color = UiUtils.getThemeColor(fragment.getContext(), R.attr.colorM3SurfaceVariant); - for (Attachment att : imageAttachments) { - if (att.blurhashPlaceholder == null) { - att.blurhashPlaceholder = new ColorDrawable(color); + else { + // hide non-official quote patters + Matcher matcher=QUOTE_MENTION_PATTERN.matcher(status.content); + if(matcher.find()){ + String quoteMention=matcher.group(); + statusForContent.content=statusForContent.content.replace(quoteMention, ""); } } - PhotoLayoutHelper.TiledLayoutResult layout=PhotoLayoutHelper.processThumbs(imageAttachments); - MediaGridStatusDisplayItem mediaGrid=new MediaGridStatusDisplayItem(parentID, fragment, layout, imageAttachments, statusForContent); - if((flags & FLAG_MEDIA_FORCE_HIDDEN)!=0){ - mediaGrid.sensitiveTitle=fragment.getString(R.string.media_hidden); - statusForContent.sensitiveRevealed=false; - statusForContent.sensitive=true; - } else if(statusForContent.sensitive && AccountSessionManager.get(accountID).getLocalPreferences().revealCWs && !AccountSessionManager.get(accountID).getLocalPreferences().hideSensitiveMedia) - statusForContent.sensitiveRevealed=true; - contentItems.add(mediaGrid); } - for(Attachment att:statusForContent.mediaAttachments){ - if(att.type==Attachment.Type.AUDIO){ - contentItems.add(new AudioStatusDisplayItem(parentID, fragment, statusForContent, att)); - } - if(att.type==Attachment.Type.UNKNOWN){ - contentItems.add(new FileStatusDisplayItem(parentID, fragment, att)); - } - } - if(statusForContent.poll!=null){ - buildPollItems(parentID, fragment, statusForContent.poll, status, contentItems); - } - if(statusForContent.card!=null && statusForContent.mediaAttachments.isEmpty() && statusForContent.quote==null && !statusForContent.card.isHashtagUrl(statusForContent.url)){ - contentItems.add(new LinkCardStatusDisplayItem(parentID, fragment, statusForContent)); - } - if(statusForContent.quote!=null && !(parentObject instanceof Notification)){ - if(!statusForContent.mediaAttachments.isEmpty() && statusForContent.poll==null) // add spacing if immediately preceded by attachment + + boolean hasSpoiler=!TextUtils.isEmpty(statusForContent.spoilerText); + if(!TextUtils.isEmpty(statusForContent.content)){ + SpannableStringBuilder parsedText=HtmlParser.parse(statusForContent.content, statusForContent.emojis, statusForContent.mentions, statusForContent.tags, accountID, fragment.getContext()); + if(applyingFilter!=null) + HtmlParser.applyFilterHighlights(fragment.getActivity(), parsedText, status.filtered); + TextStatusDisplayItem text=new TextStatusDisplayItem(parentID, parsedText, fragment, statusForContent, (flags&FLAG_NO_TRANSLATE)!=0); + contentItems.add(text); + }else if(!hasSpoiler && header!=null){ + header.needBottomPadding=true; + }else if(hasSpoiler){ contentItems.add(new DummyStatusDisplayItem(parentID, fragment)); - contentItems.addAll(buildItems(fragment, statusForContent.quote, accountID, parentObject, knownAccounts, filterContext, FLAG_NO_FOOTER | FLAG_INSET | FLAG_NO_EMOJI_REACTIONS | FLAG_IS_FOR_QUOTE)); - } - if(contentItems!=items && statusForContent.spoilerRevealed){ - items.addAll(contentItems); - } - AccountLocalPreferences lp=fragment.getLocalPrefs(); - if((flags & FLAG_NO_EMOJI_REACTIONS)==0 && !status.preview && lp.emojiReactionsEnabled && - (lp.showEmojiReactions!=ONLY_OPENED || fragment instanceof ThreadFragment) && - statusForContent.reactions!=null){ - boolean isMainStatus=fragment instanceof ThreadFragment t && t.getMainStatus().id.equals(statusForContent.id); - boolean showAddButton=lp.showEmojiReactions==ALWAYS || isMainStatus; - items.add(new EmojiReactionsStatusDisplayItem(parentID, fragment, statusForContent, accountID, !showAddButton, false)); - } - FooterStatusDisplayItem footer=null; - if((flags & FLAG_NO_FOOTER)==0){ - footer=new FooterStatusDisplayItem(parentID, fragment, statusForContent, accountID); - footer.hideCounts=hideCounts; - items.add(footer); - } - boolean inset=(flags & FLAG_INSET)!=0; - boolean isForQuote=(flags & FLAG_IS_FOR_QUOTE)!=0; - // add inset dummy so last content item doesn't clip out of inset bounds - if((inset || footer==null) && (flags & FLAG_CHECKABLE)==0 && !isForQuote){ - items.add(new DummyStatusDisplayItem(parentID, fragment)); - // in case we ever need the dummy to display a margin for the media grid again: - // (i forgot why we apparently don't need this anymore) - // !contentItems.isEmpty() && contentItems - // .get(contentItems.size() - 1) instanceof MediaGridStatusDisplayItem)); - } - GapStatusDisplayItem gap=null; - if((flags & FLAG_NO_FOOTER)==0 && status.hasGapAfter!=null && !(fragment instanceof ThreadFragment)) - items.add(gap=new GapStatusDisplayItem(parentID, fragment, status)); - int i=1; - for(StatusDisplayItem item:items){ - if(inset) - item.inset=true; - if(isForQuote){ - item.status=statusForContent; - item.isForQuote=true; } - item.index=i++; - } - if(items!=contentItems && !statusForContent.spoilerRevealed){ - for(StatusDisplayItem item:contentItems){ + + List imageAttachments=statusForContent.mediaAttachments.stream().filter(att->att.type.isImage()).collect(Collectors.toList()); + if(!imageAttachments.isEmpty() && (flags&FLAG_NO_MEDIA_PREVIEW)==0){ + int color=UiUtils.getThemeColor(fragment.getContext(), R.attr.colorM3SurfaceVariant); + for(Attachment att : imageAttachments){ + if(att.blurhashPlaceholder==null){ + att.blurhashPlaceholder=new ColorDrawable(color); + } + } + PhotoLayoutHelper.TiledLayoutResult layout=PhotoLayoutHelper.processThumbs(imageAttachments); + MediaGridStatusDisplayItem mediaGrid=new MediaGridStatusDisplayItem(parentID, fragment, layout, imageAttachments, statusForContent); + if((flags&FLAG_MEDIA_FORCE_HIDDEN)!=0){ + mediaGrid.sensitiveTitle=fragment.getString(R.string.media_hidden); + statusForContent.sensitiveRevealed=false; + statusForContent.sensitive=true; + }else if(statusForContent.sensitive && AccountSessionManager.get(accountID).getLocalPreferences().revealCWs && !AccountSessionManager.get(accountID).getLocalPreferences().hideSensitiveMedia) + statusForContent.sensitiveRevealed=true; + contentItems.add(mediaGrid); + } + if((flags&FLAG_NO_MEDIA_PREVIEW)!=0){ + contentItems.add(new PreviewlessMediaGridStatusDisplayItem(parentID, fragment, null, imageAttachments, statusForContent)); + + } + for(Attachment att : statusForContent.mediaAttachments){ + if(att.type==Attachment.Type.AUDIO){ + contentItems.add(new AudioStatusDisplayItem(parentID, fragment, statusForContent, att)); + } + if(att.type==Attachment.Type.UNKNOWN){ + contentItems.add(new FileStatusDisplayItem(parentID, fragment, att)); + } + } + if(statusForContent.poll!=null){ + buildPollItems(parentID, fragment, statusForContent.poll, status, contentItems); + } + if(statusForContent.card!=null && statusForContent.mediaAttachments.isEmpty() && statusForContent.quote==null && !statusForContent.card.isHashtagUrl(statusForContent.url)){ + contentItems.add(new LinkCardStatusDisplayItem(parentID, fragment, statusForContent, (flags&FLAG_NO_MEDIA_PREVIEW)==0)); + } + if(statusForContent.quote!=null && (flags & FLAG_INSET)==0){ + if(!statusForContent.mediaAttachments.isEmpty() && statusForContent.poll==null) // add spacing if immediately preceded by attachment + contentItems.add(new DummyStatusDisplayItem(parentID, fragment)); + contentItems.addAll(buildItems(fragment, statusForContent.quote, accountID, parentObject, knownAccounts, filterContext, FLAG_NO_FOOTER|FLAG_INSET|FLAG_NO_EMOJI_REACTIONS|FLAG_IS_FOR_QUOTE)); + } else if((flags & FLAG_INSET)==0 && statusForContent.mediaAttachments.isEmpty() && statusForContent.account!=null){ + tryAddNonOfficialQuote(statusForContent, fragment, accountID, filterContext); + } + if(contentItems!=items && statusForContent.spoilerRevealed){ + items.addAll(contentItems); + } + AccountLocalPreferences lp=fragment.getLocalPrefs(); + if((flags&FLAG_NO_EMOJI_REACTIONS)==0 && !status.preview && lp.emojiReactionsEnabled && + (lp.showEmojiReactions!=ONLY_OPENED || fragment instanceof ThreadFragment) && + statusForContent.reactions!=null){ + boolean isMainStatus=fragment instanceof ThreadFragment t && t.getMainStatus().id.equals(statusForContent.id); + boolean showAddButton=lp.showEmojiReactions==ALWAYS || isMainStatus; + items.add(new EmojiReactionsStatusDisplayItem(parentID, fragment, statusForContent, accountID, !showAddButton, false)); + } + FooterStatusDisplayItem footer=null; + if((flags&FLAG_NO_FOOTER)==0){ + footer=new FooterStatusDisplayItem(parentID, fragment, statusForContent, accountID); + footer.hideCounts=hideCounts; + items.add(footer); + } + boolean inset=(flags&FLAG_INSET)!=0; + boolean isForQuote=(flags&FLAG_IS_FOR_QUOTE)!=0; + // add inset dummy so last content item doesn't clip out of inset bounds + if((inset || footer==null) && (flags&FLAG_CHECKABLE)==0 && !isForQuote){ + items.add(new DummyStatusDisplayItem(parentID, fragment)); + // in case we ever need the dummy to display a margin for the media grid again: + // (i forgot why we apparently don't need this anymore) + // !contentItems.isEmpty() && contentItems + // .get(contentItems.size() - 1) instanceof MediaGridStatusDisplayItem)); + } + GapStatusDisplayItem gap=null; + if((flags&FLAG_NO_FOOTER)==0 && status.hasGapAfter!=null && !(fragment instanceof ThreadFragment)) + items.add(gap=new GapStatusDisplayItem(parentID, fragment, status)); + int i=1; + for(StatusDisplayItem item : items){ if(inset) item.inset=true; if(isForQuote){ @@ -352,15 +386,31 @@ public abstract class StatusDisplayItem{ } item.index=i++; } - } + if(items!=contentItems && !statusForContent.spoilerRevealed){ + for(StatusDisplayItem item : contentItems){ + if(inset) + item.inset=true; + if(isForQuote){ + item.status=statusForContent; + item.isForQuote=true; + } + item.index=i++; + } + } - List nonGapItems=gap!=null ? items.subList(0, items.size()-1) : items; - WarningFilteredStatusDisplayItem warning=applyingFilter==null ? null : - new WarningFilteredStatusDisplayItem(parentID, fragment, statusForContent, nonGapItems, applyingFilter); - return applyingFilter==null ? items : new ArrayList<>(gap!=null - ? List.of(warning, gap) - : Collections.singletonList(warning) - ); + List nonGapItems=gap!=null ? items.subList(0, items.size()-1) : items; + WarningFilteredStatusDisplayItem warning=applyingFilter==null ? null : + new WarningFilteredStatusDisplayItem(parentID, fragment, statusForContent, nonGapItems, applyingFilter); + if(warning!=null) + warning.inset=inset; + return applyingFilter==null ? items : new ArrayList<>(gap!=null + ? List.of(warning, gap) + : Collections.singletonList(warning) + ); + } catch(Exception e) { + Log.e("StatusDisplayItem", "buildItems: failed to build StatusDisplayItem " + e); + return new ArrayList<>(Collections.singletonList(new ErrorStatusDisplayItem(parentID, statusForContent, fragment, e))); + } } public static void buildPollItems(String parentID, BaseStatusListFragment fragment, Poll poll, Status status, List items){ @@ -369,7 +419,62 @@ public abstract class StatusDisplayItem{ items.add(new PollOptionStatusDisplayItem(parentID, poll, i, fragment, status)); i++; } - items.add(new PollFooterStatusDisplayItem(parentID, fragment, poll)); + items.add(new PollFooterStatusDisplayItem(parentID, fragment, poll, status)); + } + + /** + * Tries to adds a non-official quote to a status. + * A non-official quote is a quote on an instance that does not support quotes officially. + */ + private static void tryAddNonOfficialQuote(Status status, BaseStatusListFragment fragment, String accountID, FilterContext filterContext) { + Matcher matcher=QUOTE_PATTERN.matcher(status.getStrippedText()); + + if(!matcher.find()) + return; + String quoteURL=matcher.group(); + + // account may be null for scheduled posts + if (!UiUtils.looksLikeFediverseUrl(quoteURL)) + return; + + new GetSearchResults(quoteURL, GetSearchResults.Type.STATUSES, true, null, 0, 0).setCallback(new Callback<>(){ + @Override + public void onSuccess(SearchResults results){ + AccountSessionManager.get(accountID).filterStatuses(results.statuses, filterContext); + if (results.statuses == null || results.statuses.isEmpty()) + return; + + Status quote=results.statuses.get(0); + new GetAccountRelationships(Collections.singletonList(quote.account.id)) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(List relationships){ + if(relationships.isEmpty()) + return; + + Relationship relationship=relationships.get(0); + String selfId=AccountSessionManager.get(accountID).self.id; + if(!status.account.id.equals(selfId) && (relationship.domainBlocking || relationship.muting || relationship.blocking)) { + // do not show posts that are quoting a muted/blocked user + fragment.removeStatus(status); + return; + } + + status.quote=results.statuses.get(0); + fragment.updateStatusWithQuote(status); + } + + @Override + public void onError(ErrorResponse error){} + }) + .exec(accountID); + } + + @Override + public void onError(ErrorResponse error){ + Log.w("StatusDisplayItem", "onError: failed to find quote status with URL: " + quoteURL + " " + error); + } + }).exec(accountID); } public enum Type{ @@ -379,7 +484,8 @@ public abstract class StatusDisplayItem{ AUDIO, POLL_OPTION, POLL_FOOTER, - CARD, + CARD_LARGE, + CARD_COMPACT, EMOJI_REACTIONS, FOOTER, ACCOUNT_CARD, @@ -388,12 +494,14 @@ public abstract class StatusDisplayItem{ GAP, EXTENDED_FOOTER, MEDIA_GRID, + PREVIEWLESS_MEDIA_GRID, WARNING, FILE, SPOILER, SECTION_HEADER, HEADER_CHECKABLE, NOTIFICATION_HEADER, + ERROR_ITEM, FILTER_SPOILER, DUMMY } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/TextStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/TextStatusDisplayItem.java index ecbf2f3d5..28dcc692d 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/TextStatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/TextStatusDisplayItem.java @@ -24,7 +24,10 @@ import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.ui.views.LinkedTextView; import java.util.Locale; +import java.util.regex.Pattern; +import me.grishka.appkit.api.Callback; +import me.grishka.appkit.api.ErrorResponse; import me.grishka.appkit.imageloader.ImageLoaderViewHolder; import me.grishka.appkit.imageloader.MovieDrawable; import me.grishka.appkit.imageloader.requests.ImageLoaderRequest; @@ -65,7 +68,7 @@ public class TextStatusDisplayItem extends StatusDisplayItem{ public void setTranslatedText(String text){ Status statusForContent=status.getContentStatus(); - translatedText=HtmlParser.parse(text, statusForContent.emojis, statusForContent.mentions, statusForContent.tags, parentFragment.getAccountID()); + translatedText=HtmlParser.parse(text, statusForContent.emojis, statusForContent.mentions, statusForContent.tags, parentFragment.getAccountID(), statusForContent); translationEmojiHelper.setText(translatedText); } @@ -98,7 +101,7 @@ public class TextStatusDisplayItem extends StatusDisplayItem{ float textCollapsedHeight=activity.getResources().getDimension(R.dimen.text_collapsed_height); collapseParams=new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, (int) textCollapsedHeight); wrapParams=new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); - readMore.setOnClickListener(v -> item.parentFragment.onToggleExpanded(item.status, getItemID())); + readMore.setOnClickListener(v -> item.parentFragment.onToggleExpanded(item.status, item.isForQuote, getItemID())); } @Override @@ -152,7 +155,7 @@ public class TextStatusDisplayItem extends StatusDisplayItem{ if (GlobalUserPreferences.collapseLongPosts && !item.status.textExpandable) { boolean tooBig = text.getMeasuredHeight() > textMaxHeight; boolean expandable = tooBig && !item.status.hasSpoiler(); - item.parentFragment.onEnableExpandable(Holder.this, expandable); + item.parentFragment.onEnableExpandable(Holder.this, expandable, item.isForQuote); } boolean expandButtonShown=item.status.textExpandable && !item.status.textExpanded; @@ -170,7 +173,7 @@ public class TextStatusDisplayItem extends StatusDisplayItem{ @Override public void setImage(int index, Drawable image){ getEmojiHelper().setImageDrawable(index, image); - text.invalidate(); + text.setText(text.getText()); if(image instanceof Animatable){ ((Animatable) image).start(); if(image instanceof MovieDrawable) @@ -181,7 +184,7 @@ public class TextStatusDisplayItem extends StatusDisplayItem{ @Override public void clearImage(int index){ getEmojiHelper().setImageDrawable(index, null); - text.invalidate(); + text.setText(text.getText()); } private CustomEmojiHelper getEmojiHelper(){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/WarningFilteredStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/WarningFilteredStatusDisplayItem.java index 2d67feb67..7318a7232 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/WarningFilteredStatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/WarningFilteredStatusDisplayItem.java @@ -3,12 +3,14 @@ package org.joinmastodon.android.ui.displayitems; import android.content.Context; import android.view.View; import android.view.ViewGroup; +import android.widget.Button; import android.widget.TextView; import org.joinmastodon.android.R; import org.joinmastodon.android.fragments.BaseStatusListFragment; import org.joinmastodon.android.model.LegacyFilter; import org.joinmastodon.android.model.Status; +import org.joinmastodon.android.ui.OutlineProviders; import java.util.List; @@ -20,8 +22,8 @@ public class WarningFilteredStatusDisplayItem extends StatusDisplayItem{ public WarningFilteredStatusDisplayItem(String parentID, BaseStatusListFragment parentFragment, Status status, List filteredItems, LegacyFilter applyingFilter){ super(parentID, parentFragment); this.status=status; - this.filteredItems = filteredItems; - this.applyingFilter = applyingFilter; + this.filteredItems=filteredItems; + this.applyingFilter=applyingFilter; } @Override @@ -30,19 +32,33 @@ public class WarningFilteredStatusDisplayItem extends StatusDisplayItem{ } public static class Holder extends StatusDisplayItem.Holder{ + public final View warningWrap; + public final Button showBtn; public final TextView text; public List filteredItems; - public Holder(Context context, ViewGroup parent) { - super(context, R.layout.display_item_filter_warning, parent); + public Holder(Context context, ViewGroup parent){ + super(context, R.layout.display_item_warning, parent); + warningWrap=findViewById(R.id.warning_wrap); + showBtn=findViewById(R.id.reveal_btn); + showBtn.setOnClickListener(i->item.parentFragment.onWarningClick(this)); + itemView.setOnClickListener(v->item.parentFragment.onWarningClick(this)); text=findViewById(R.id.text); } @Override - public void onBind(WarningFilteredStatusDisplayItem item) { - filteredItems = item.filteredItems; - text.setText(item.parentFragment.getString(R.string.sk_filtered, item.applyingFilter.title)); - itemView.setOnClickListener(v->item.parentFragment.onWarningClick(this)); + public void onBind(WarningFilteredStatusDisplayItem item){ + filteredItems=item.filteredItems; + String title=item.applyingFilter.title; + text.setText(item.parentFragment.getString(R.string.sk_filtered, title)); + + if(item.inset){ + itemView.setClipToOutline(true); + itemView.setOutlineProvider(OutlineProviders.roundedRect(8)); + } } + + @Override + public void onClick(){} } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/photoviewer/AltTextSheet.java b/mastodon/src/main/java/org/joinmastodon/android/ui/photoviewer/AltTextSheet.java new file mode 100644 index 000000000..b4affaf3c --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/photoviewer/AltTextSheet.java @@ -0,0 +1,37 @@ +package org.joinmastodon.android.ui.photoviewer; + +import android.content.Context; +import android.graphics.drawable.ColorDrawable; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.TextView; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.model.Attachment; +import org.joinmastodon.android.ui.M3AlertDialogBuilder; +import org.joinmastodon.android.ui.utils.UiUtils; + +import androidx.annotation.NonNull; +import me.grishka.appkit.views.BottomSheet; + +public class AltTextSheet extends BottomSheet{ + public AltTextSheet(@NonNull Context context, Attachment attachment){ + super(context); + + View content=context.getSystemService(LayoutInflater.class).inflate(R.layout.sheet_alt_text, null); + setContentView(content); + TextView altText=findViewById(R.id.alt_text); + altText.setText(attachment.description); + findViewById(R.id.alt_text_help).setOnClickListener(v->showAltTextHelp()); + setNavigationBarBackground(new ColorDrawable(UiUtils.alphaBlendColors(UiUtils.getThemeColor(context, R.attr.colorM3Surface), + UiUtils.getThemeColor(context, R.attr.colorM3Primary), 0.05f)), !UiUtils.isDarkTheme()); + } + + private void showAltTextHelp(){ + new M3AlertDialogBuilder(getContext()) + .setTitle(R.string.what_is_alt_text) + .setMessage(UiUtils.fixBulletListInString(getContext(), R.string.alt_text_help)) + .setPositiveButton(R.string.ok, null) + .show(); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/photoviewer/PhotoViewer.java b/mastodon/src/main/java/org/joinmastodon/android/ui/photoviewer/PhotoViewer.java index 053b55758..5eda26005 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/photoviewer/PhotoViewer.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/photoviewer/PhotoViewer.java @@ -1,6 +1,10 @@ package org.joinmastodon.android.ui.photoviewer; import android.Manifest; +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; import android.annotation.SuppressLint; import android.app.Activity; import android.app.DownloadManager; @@ -13,6 +17,7 @@ import android.graphics.Insets; import android.graphics.PixelFormat; import android.graphics.Rect; import android.graphics.SurfaceTexture; +import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.Drawable; import android.media.AudioManager; @@ -25,6 +30,8 @@ import android.os.SystemClock; import android.provider.MediaStore; import android.provider.Settings; import android.util.Log; +import android.util.Property; +import android.view.ContextThemeWrapper; import android.view.DisplayCutout; import android.view.Gravity; import android.view.KeyEvent; @@ -48,13 +55,16 @@ import android.widget.Toolbar; import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.R; import org.joinmastodon.android.api.MastodonAPIController; +import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.model.Attachment; -import org.joinmastodon.android.ui.ImageDescriptionSheet; +import org.joinmastodon.android.model.Status; import org.joinmastodon.android.ui.M3AlertDialogBuilder; +import org.joinmastodon.android.ui.utils.UiUtils; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; +import java.io.InputStream; import java.io.OutputStream; import java.util.ArrayList; import java.util.List; @@ -72,6 +82,9 @@ import me.grishka.appkit.utils.BindableViewHolder; import me.grishka.appkit.utils.CubicBezierInterpolator; import me.grishka.appkit.utils.V; import me.grishka.appkit.views.FragmentRootLinearLayout; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; import okio.BufferedSink; import okio.Okio; import okio.Sink; @@ -86,6 +99,8 @@ public class PhotoViewer implements ZoomPanView.Listener{ private int currentIndex; private WindowManager wm; private Listener listener; + private Status status; + private String accountID; private FrameLayout windowView; private FragmentRootLinearLayout uiOverlay; @@ -99,24 +114,38 @@ public class PhotoViewer implements ZoomPanView.Listener{ private TextView videoTimeView; private ImageButton videoPlayPauseButton; private View videoControls; - private MenuItem imageDescriptionButton; private boolean uiVisible=true; private AudioManager.OnAudioFocusChangeListener audioFocusListener=this::onAudioFocusChanged; private Runnable uiAutoHider=()->{ if(uiVisible) toggleUI(); }; + private Animator currentSheetRelatedToolbarAnimation; private boolean videoPositionNeedsUpdating; private Runnable videoPositionUpdater=this::updateVideoPosition; private int videoDuration, videoInitialPosition, videoLastTimeUpdatePosition; private long videoInitialPositionTime; - public PhotoViewer(Activity activity, List attachments, int index, Listener listener){ + private static final Property STATUS_BAR_COLOR_PROPERTY=new Property<>(Integer.class, "Fdsafdsa"){ + @Override + public Integer get(FragmentRootLinearLayout object){ + return object.getStatusBarColor(); + } + + @Override + public void set(FragmentRootLinearLayout object, Integer value){ + object.setStatusBarColor(value); + } + }; + + public PhotoViewer(Activity activity, List attachments, int index, Status status, String accountID, Listener listener){ this.activity=activity; this.attachments=attachments.stream().filter(a->a.type==Attachment.Type.IMAGE || a.type==Attachment.Type.GIFV || a.type==Attachment.Type.VIDEO).collect(Collectors.toList()); currentIndex=index; this.listener=listener; + this.status=status; + this.accountID=accountID; wm=activity.getWindowManager(); @@ -147,7 +176,7 @@ public class PhotoViewer implements ZoomPanView.Listener{ toolbarWrap.setPadding(0, 0, 0, 0); videoControls.setPadding(0, 0, 0, 0); } - insets=insets.replaceSystemWindowInsets(tappable.left, tappable.top, tappable.right, tappable.bottom); + insets=insets.replaceSystemWindowInsets(tappable.left, tappable.top, tappable.right, insets.getSystemWindowInsetBottom()); } uiOverlay.dispatchApplyWindowInsets(insets); int bottomInset=insets.getSystemWindowInsetBottom(); @@ -177,16 +206,7 @@ public class PhotoViewer implements ZoomPanView.Listener{ toolbarWrap=uiOverlay.findViewById(R.id.toolbar_wrap); toolbar=uiOverlay.findViewById(R.id.toolbar); toolbar.setNavigationOnClickListener(v->onStartSwipeToDismissTransition(0)); - imageDescriptionButton = toolbar.getMenu() - .add(R.string.sk_image_description) - .setIcon(R.drawable.ic_fluent_image_alt_text_24_regular) - .setVisible(attachments.get(pager.getCurrentItem()).description != null - && !attachments.get(pager.getCurrentItem()).description.isEmpty()) - .setOnMenuItemClickListener(item -> { - new ImageDescriptionSheet(activity,attachments.get(pager.getCurrentItem())).show(); - return true; - }); - imageDescriptionButton.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS); + toolbar.getMenu() .add(R.string.download) .setIcon(R.drawable.ic_fluent_arrow_download_24_regular) @@ -195,12 +215,32 @@ public class PhotoViewer implements ZoomPanView.Listener{ return true; }) .setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS); + toolbar.getMenu() + .add(R.string.button_share) + .setIcon(R.drawable.ic_fluent_share_24_regular) + .setOnMenuItemClickListener(item -> { + shareCurrentFile(); + return true; + }) + .setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS); + + if(status!=null){ + toolbar.getMenu() + .add(R.string.info) + .setIcon(R.drawable.ic_fluent_info_24_regular) + .setOnMenuItemClickListener(item->{ + showInfoSheet(); + return true; + }) + .setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS); + } + uiOverlay.setAlpha(0f); videoControls=uiOverlay.findViewById(R.id.video_player_controls); videoSeekBar=uiOverlay.findViewById(R.id.seekbar); videoTimeView=uiOverlay.findViewById(R.id.time); videoPlayPauseButton=uiOverlay.findViewById(R.id.play_pause_btn); - if(attachments.get(index).type==Attachment.Type.IMAGE){ + if(attachments.get(index).type!=Attachment.Type.VIDEO){ videoControls.setVisibility(View.GONE); }else{ videoDuration=(int)Math.round(attachments.get(index).getDuration()*1000); @@ -337,7 +377,7 @@ public class PhotoViewer implements ZoomPanView.Listener{ listener.setPhotoViewVisibility(pager.getCurrentItem(), true); if(!uiVisible){ windowView.setSystemUiVisibility(windowView.getSystemUiVisibility() | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY | View.SYSTEM_UI_FLAG_FULLSCREEN); - }else if(attachments.get(currentIndex).type!=Attachment.Type.IMAGE){ + }else if(attachments.get(currentIndex).type==Attachment.Type.VIDEO){ hideUiDelayed(); } } @@ -376,7 +416,7 @@ public class PhotoViewer implements ZoomPanView.Listener{ .setInterpolator(CubicBezierInterpolator.DEFAULT) .start(); windowView.setSystemUiVisibility(windowView.getSystemUiVisibility() & ~(View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY | View.SYSTEM_UI_FLAG_FULLSCREEN)); - if(attachments.get(currentIndex).type!=Attachment.Type.IMAGE) + if(attachments.get(currentIndex).type==Attachment.Type.VIDEO) hideUiDelayed(5000); } uiVisible=!uiVisible; @@ -394,8 +434,7 @@ public class PhotoViewer implements ZoomPanView.Listener{ private void onPageChanged(int index){ currentIndex=index; Attachment att=attachments.get(index); - imageDescriptionButton.setVisible(att.description != null && !att.description.isEmpty()); - V.setVisibilityAnimated(videoControls, att.type!=Attachment.Type.IMAGE ? View.VISIBLE : View.GONE); + V.setVisibilityAnimated(videoControls, att.type==Attachment.Type.VIDEO ? View.VISIBLE : View.GONE); if(att.type==Attachment.Type.VIDEO){ videoSeekBar.setSecondaryProgress(0); videoDuration=(int)Math.round(att.getDuration()*1000); @@ -441,6 +480,33 @@ public class PhotoViewer implements ZoomPanView.Listener{ pauseVideo(); } + private void shareCurrentFile(){ + Attachment att=attachments.get(pager.getCurrentItem()); + + if(att.type!=Attachment.Type.IMAGE){ + shareAfterDownloading(att); + return; + } + + UrlImageLoaderRequest req=new UrlImageLoaderRequest(att.url); + try{ + File file=ImageCache.getInstance(activity).getFile(req); + if(file==null){ + shareAfterDownloading(att); + return; + } + MastodonAPIController.runInBackground(()->{ + File imageDir=new File(activity.getCacheDir(), "."); + File renamedFile; + file.renameTo(renamedFile=new File(imageDir, Uri.parse(att.url).getLastPathSegment())); + shareFile(renamedFile); + }); + }catch(IOException x){ + Log.w(TAG, "shareCurrentFile: ", x); + Toast.makeText(activity, R.string.error, Toast.LENGTH_SHORT).show(); + } + } + private void saveCurrentFile(){ if(Build.VERSION.SDK_INT>=29){ doSaveCurrentFile(); @@ -545,29 +611,82 @@ public class PhotoViewer implements ZoomPanView.Listener{ Toast.makeText(activity, R.string.downloading, Toast.LENGTH_SHORT).show(); } + private void shareAfterDownloading(Attachment att){ + Uri uri=Uri.parse(att.url); + + Toast.makeText(activity, R.string.downloading, Toast.LENGTH_SHORT).show(); + + MastodonAPIController.runInBackground(()->{ + try { + OkHttpClient client = new OkHttpClient(); + Request request = new Request.Builder().url(att.url).build(); + + Response response = client.newCall(request).execute(); + Toast.makeText(activity, R.string.downloading, Toast.LENGTH_SHORT); + if (!response.isSuccessful()) { + throw new IOException("" + response); + } + + File imageDir = new File(activity.getCacheDir(), "."); + InputStream inputStream = response.body().byteStream(); + File file = new File(imageDir, uri.getLastPathSegment()); + FileOutputStream outputStream = new FileOutputStream(file); + + byte[] buffer = new byte[4096]; + int bytesRead; + while ((bytesRead = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, bytesRead); + } + + outputStream.close(); + inputStream.close(); + shareFile(file); + } catch(IOException e){ + Toast.makeText(activity, R.string.error, Toast.LENGTH_SHORT).show(); + } + }); + } + + private void shareFile(@NonNull File file) { + Intent intent = new Intent(Intent.ACTION_SEND); + Uri outputUri = UiUtils.getFileProviderUri(activity, file); + intent.setDataAndType(outputUri, mimeTypeForFileName(outputUri.getLastPathSegment())); + intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + intent.putExtra(Intent.EXTRA_STREAM, outputUri); + activity.startActivity(Intent.createChooser(intent, activity.getString(R.string.button_share))); + } + private void onAudioFocusChanged(int change){ if(change==AudioManager.AUDIOFOCUS_LOSS || change==AudioManager.AUDIOFOCUS_LOSS_TRANSIENT || change==AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK){ pauseVideo(); } } - private MediaPlayer findCurrentVideoPlayer(){ + private GifVViewHolder findCurrentVideoPlayerHolder(){ RecyclerView rv=(RecyclerView) pager.getChildAt(0); if(rv.findViewHolderForAdapterPosition(pager.getCurrentItem()) instanceof GifVViewHolder vvh && vvh.playerReady){ - return vvh.player; + return vvh; } return null; } + private MediaPlayer findCurrentVideoPlayer(){ + GifVViewHolder holder=findCurrentVideoPlayerHolder(); + return holder!=null ? holder.player : null; + } + private void pauseVideo(){ - MediaPlayer player=findCurrentVideoPlayer(); - if(player==null || !player.isPlaying()) + GifVViewHolder holder=findCurrentVideoPlayerHolder(); + if(holder==null || !holder.player.isPlaying()) return; - player.pause(); + holder.player.pause(); videoPlayPauseButton.setImageResource(R.drawable.ic_fluent_play_24_filled); videoPlayPauseButton.setContentDescription(activity.getString(R.string.play)); stopUpdatingVideoPosition(); windowView.removeCallbacks(uiAutoHider); + // Some MediaPlayer implementations clear the texture when the app goes into background. + // This makes sure the frame on which the video was paused is retained on the screen. + holder.wrap.setBackground(new BitmapDrawable(holder.textureView.getBitmap())); } private void resumeVideo(){ @@ -619,6 +738,94 @@ public class PhotoViewer implements ZoomPanView.Listener{ } } + private void showInfoSheet(){ + pauseVideo(); + PhotoViewerInfoSheet sheet=new PhotoViewerInfoSheet(new ContextThemeWrapper(activity, R.style.Theme_Mastodon_Dark), attachments.get(currentIndex), toolbar.getHeight(), new PhotoViewerInfoSheet.Listener(){ + private boolean ignoreBeforeDismiss; + + @Override + public void onBeforeDismiss(int duration){ + if(ignoreBeforeDismiss) + return; + if(currentSheetRelatedToolbarAnimation!=null) + currentSheetRelatedToolbarAnimation.cancel(); + AnimatorSet set=new AnimatorSet(); + set.playTogether( + ObjectAnimator.ofFloat(pager, View.TRANSLATION_Y, 0), + ObjectAnimator.ofFloat(toolbarWrap, View.ALPHA, 1f), + ObjectAnimator.ofArgb(uiOverlay, STATUS_BAR_COLOR_PROPERTY, 0x80000000) + ); + set.setDuration(duration); + set.setInterpolator(CubicBezierInterpolator.EASE_OUT); + currentSheetRelatedToolbarAnimation=set; + set.addListener(new AnimatorListenerAdapter(){ + @Override + public void onAnimationEnd(Animator animation){ + currentSheetRelatedToolbarAnimation=null; + } + }); + set.start(); + } + + @Override + public void onDismissEntireViewer(){ + ignoreBeforeDismiss=true; + onStartSwipeToDismissTransition(0); + } + + @Override + public void onButtonClick(int id){ + if(id==R.id.btn_boost){ + if(status!=null){ + AccountSessionManager.get(accountID).getStatusInteractionController().setReblogged(status, !status.reblogged, null, r->{}); + } + }else if(id==R.id.btn_favorite){ + if(status!=null){ + AccountSessionManager.get(accountID).getStatusInteractionController().setFavorited(status, !status.favourited, r->{}); + } +// }else if(id==R.id.btn_share){ +// if(status!=null){ +// shareCurrentFile(); +// } + }else if(id==R.id.btn_bookmark){ + if(status!=null){ + AccountSessionManager.get(accountID).getStatusInteractionController().setBookmarked(status, !status.bookmarked); + } + } +// else if(id==R.id.btn_download){ +// saveCurrentFile(); +// } + } + }); + sheet.setStatus(status); + sheet.show(); + if(currentSheetRelatedToolbarAnimation!=null) + currentSheetRelatedToolbarAnimation.cancel(); + sheet.getWindow().getDecorView().getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener(){ + @Override + public boolean onPreDraw(){ + sheet.getWindow().getDecorView().getViewTreeObserver().removeOnPreDrawListener(this); + AnimatorSet set=new AnimatorSet(); + set.playTogether( + ObjectAnimator.ofFloat(pager, View.TRANSLATION_Y, -pager.getHeight()*0.2f), + ObjectAnimator.ofFloat(toolbarWrap, View.ALPHA, 0f), + ObjectAnimator.ofArgb(uiOverlay, STATUS_BAR_COLOR_PROPERTY, 0) + ); + set.setDuration(300); + set.setInterpolator(CubicBezierInterpolator.DEFAULT); + currentSheetRelatedToolbarAnimation=set; + set.addListener(new AnimatorListenerAdapter(){ + @Override + public void onAnimationEnd(Animator animation){ + currentSheetRelatedToolbarAnimation=null; + } + }); + set.start(); + return true; + } + }); + } + public interface Listener{ void setPhotoViewVisibility(int index, boolean visible); @@ -835,7 +1042,10 @@ public class PhotoViewer implements ZoomPanView.Listener{ @Override public void onSurfaceTextureUpdated(@NonNull SurfaceTexture surface){ - + // A new frame of video was rendered. Clear the thumbnail or paused frame, if any, to avoid overdraw and free up some memory. + if(player.isPlaying() && wrap.getBackground()!=null){ + wrap.setBackground(null); + } } private void startPlayer(){ @@ -843,13 +1053,12 @@ public class PhotoViewer implements ZoomPanView.Listener{ if(item.type==Attachment.Type.VIDEO){ incKeepScreenOn(); keepingScreenOn=true; - } if(getAbsoluteAdapterPosition()==currentIndex){ player.start(); startUpdatingVideoPosition(player); hideUiDelayed(); } - if (item.type == Attachment.Type.GIFV) { + }else{ keepingScreenOn=false; player.setLooping(true); player.start(); @@ -871,7 +1080,7 @@ public class PhotoViewer implements ZoomPanView.Listener{ player.setOnPreparedListener(this); player.setOnErrorListener(this); player.setOnVideoSizeChangedListener(this); - if(item.type!=Attachment.Type.IMAGE){ + if(item.type==Attachment.Type.VIDEO){ player.setOnBufferingUpdateListener(this); player.setOnInfoListener(this); player.setOnSeekCompleteListener(this); diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/photoviewer/PhotoViewerInfoSheet.java b/mastodon/src/main/java/org/joinmastodon/android/ui/photoviewer/PhotoViewerInfoSheet.java new file mode 100644 index 000000000..70904bbeb --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/photoviewer/PhotoViewerInfoSheet.java @@ -0,0 +1,180 @@ +package org.joinmastodon.android.ui.photoviewer; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.graphics.drawable.ColorDrawable; +import android.text.TextUtils; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewOutlineProvider; +import android.view.ViewTreeObserver; +import android.widget.Button; +import android.widget.FrameLayout; +import android.widget.ImageButton; +import android.widget.TextView; + +import com.squareup.otto.Subscribe; + +import org.joinmastodon.android.E; +import org.joinmastodon.android.R; +import org.joinmastodon.android.events.StatusCountersUpdatedEvent; +import org.joinmastodon.android.model.Attachment; +import org.joinmastodon.android.model.Status; +import org.joinmastodon.android.model.StatusPrivacy; +import org.joinmastodon.android.ui.M3AlertDialogBuilder; +import org.joinmastodon.android.ui.utils.UiUtils; + +import androidx.annotation.NonNull; +import me.grishka.appkit.utils.CubicBezierInterpolator; +import me.grishka.appkit.utils.V; +import me.grishka.appkit.views.BottomSheet; + +public class PhotoViewerInfoSheet extends BottomSheet{ + private final Attachment attachment; + private final View buttonsContainer; + private final TextView altText; + private final ImageButton backButton, infoButton; + private final Button boostBtn, favoriteBtn, bookmarkBtn; + private final Listener listener; + private String statusID; + + public PhotoViewerInfoSheet(@NonNull Context context, Attachment attachment, int toolbarHeight, Listener listener){ + super(context); + this.attachment=attachment; + this.listener=listener; + + dimAmount=0; + View content=context.getSystemService(LayoutInflater.class).inflate(R.layout.sheet_photo_viewer_info, null); + setContentView(content); + setNavigationBarBackground(new ColorDrawable(UiUtils.alphaBlendColors(UiUtils.getThemeColor(context, R.attr.colorM3Surface), + UiUtils.getThemeColor(context, R.attr.colorM3Primary), 0.05f)), !UiUtils.isDarkTheme()); + + buttonsContainer=findViewById(R.id.buttons_container); + altText=findViewById(R.id.alt_text); + + if(TextUtils.isEmpty(attachment.description)){ + findViewById(R.id.alt_text).setVisibility(View.GONE); + findViewById(R.id.alt_text_title).setVisibility(View.GONE); + findViewById(R.id.divider).setVisibility(View.GONE); + }else{ + altText.setText(attachment.description); + findViewById(R.id.alt_text_help).setOnClickListener(v->showAltTextHelp()); + } + + backButton=new ImageButton(context); + backButton.setImageResource(R.drawable.ic_fluent_arrow_left_24_regular); + backButton.setImageTintList(ColorStateList.valueOf(UiUtils.getThemeColor(context, R.attr.colorM3OnSurfaceVariant))); + backButton.setBackgroundResource(R.drawable.bg_button_m3_tonal_icon); + backButton.setOutlineProvider(ViewOutlineProvider.BACKGROUND); + backButton.setElevation(V.dp(2)); + backButton.setAlpha(0f); + backButton.setOnClickListener(v->{ + listener.onDismissEntireViewer(); + dismiss(); + }); + + infoButton=new ImageButton(context); + infoButton.setImageResource(R.drawable.ic_info_fill1_24px); + infoButton.setImageTintList(ColorStateList.valueOf(UiUtils.getThemeColor(context, R.attr.colorM3OnPrimary))); + infoButton.setBackgroundResource(R.drawable.bg_button_m3_filled_icon); + infoButton.setOutlineProvider(ViewOutlineProvider.BACKGROUND); + infoButton.setElevation(V.dp(2)); + infoButton.setAlpha(0f); + infoButton.setSelected(true); + infoButton.setOnClickListener(v->dismiss()); + + FrameLayout.LayoutParams lp=new FrameLayout.LayoutParams(V.dp(48), V.dp(48)); + lp.topMargin=toolbarHeight/2-V.dp(24); + lp.leftMargin=lp.rightMargin=V.dp(4); + lp.gravity=Gravity.START | Gravity.TOP; + container.addView(backButton, lp); + + lp=new FrameLayout.LayoutParams(lp); + lp.leftMargin=lp.rightMargin=0; + lp.gravity=Gravity.END | Gravity.TOP; + container.addView(infoButton, lp); + + boostBtn=findViewById(R.id.btn_boost); + favoriteBtn=findViewById(R.id.btn_favorite); + bookmarkBtn=findViewById(R.id.btn_bookmark); + View.OnClickListener clickListener=v->listener.onButtonClick(v.getId()); + + boostBtn.setOnClickListener(clickListener); + favoriteBtn.setOnClickListener(clickListener); +// findViewById(R.id.btn_share).setOnClickListener(clickListener); + bookmarkBtn.setOnClickListener(clickListener); +// findViewById(R.id.btn_download).setOnClickListener(clickListener); + } + + private void showAltTextHelp(){ + new M3AlertDialogBuilder(getContext()) + .setTitle(R.string.what_is_alt_text) + .setMessage(UiUtils.fixBulletListInString(getContext(), R.string.alt_text_help)) + .setPositiveButton(R.string.ok, null) + .show(); + } + + @Override + public void dismiss(){ + if(dismissed) + return; + int height=content.getHeight(); + int duration=Math.max(60, (int) (180 * (height - content.getTranslationY()) / (float) height)); + listener.onBeforeDismiss(duration); + backButton.animate().alpha(0).setDuration(duration).setInterpolator(CubicBezierInterpolator.EASE_OUT).start(); + infoButton.animate().alpha(0).setDuration(duration).setInterpolator(CubicBezierInterpolator.EASE_OUT).start(); + super.dismiss(); + E.unregister(this); + } + + @Override + public void show(){ + super.show(); + E.register(this); + content.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener(){ + @Override + public boolean onPreDraw(){ + content.getViewTreeObserver().removeOnPreDrawListener(this); + backButton.animate().alpha(1).setDuration(300).setInterpolator(CubicBezierInterpolator.DEFAULT).start(); + infoButton.animate().alpha(1).setDuration(300).setInterpolator(CubicBezierInterpolator.DEFAULT).start(); + return true; + } + }); + } + + public void setStatus(Status status){ + statusID=status.id; + boostBtn.setCompoundDrawablesWithIntrinsicBounds(0, switch(status.visibility){ + case DIRECT -> R.drawable.ic_boost_disabled_24px; + case PUBLIC, UNLISTED, LOCAL -> R.drawable.ic_boost; + case PRIVATE -> R.drawable.ic_boost_private; + }, 0, 0); + boostBtn.setEnabled(status.visibility!=StatusPrivacy.DIRECT); + setButtonStates(status.reblogged, status.favourited, status.bookmarked); + } + + @Subscribe + public void onCountersUpdated(StatusCountersUpdatedEvent ev){ + if(ev.id.equals(statusID)){ + setButtonStates(ev.reblogged, ev.favorited, ev.bookmarked); + } + } + + private void setButtonStates(boolean reblogged, boolean favorited, boolean bookmarked){ + boostBtn.setText(reblogged ? R.string.button_reblogged : R.string.button_reblog); + boostBtn.setSelected(reblogged); + + favoriteBtn.setText(favorited ? R.string.button_favorited : R.string.button_favorite); + favoriteBtn.setSelected(favorited); + + bookmarkBtn.setText(bookmarked ? R.string.bookmarked : R.string.add_bookmark); + bookmarkBtn.setSelected(bookmarked); + } + + public interface Listener{ + void onBeforeDismiss(int duration); + void onDismissEntireViewer(); + void onButtonClick(int id); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/sheets/AccountRestrictionConfirmationSheet.java b/mastodon/src/main/java/org/joinmastodon/android/ui/sheets/AccountRestrictionConfirmationSheet.java new file mode 100644 index 000000000..7f293f734 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/sheets/AccountRestrictionConfirmationSheet.java @@ -0,0 +1,114 @@ +package org.joinmastodon.android.ui.sheets; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.InsetDrawable; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.ui.drawables.EmptyDrawable; +import org.joinmastodon.android.ui.utils.UiUtils; +import org.joinmastodon.android.ui.views.AutoOrientationLinearLayout; +import org.joinmastodon.android.ui.views.ProgressBarButton; + +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.StringRes; +import me.grishka.appkit.utils.V; +import me.grishka.appkit.views.BottomSheet; + +public abstract class AccountRestrictionConfirmationSheet extends BottomSheet{ + private LinearLayout contentWrap; + protected Button cancelBtn; + protected ProgressBarButton confirmBtn, secondaryBtn; + protected TextView titleView, subtitleView; + protected ImageView icon; + protected boolean loading; + + public AccountRestrictionConfirmationSheet(@NonNull Context context, Account user, ConfirmCallback confirmCallback){ + super(context); + View content=context.getSystemService(LayoutInflater.class).inflate(R.layout.sheet_restrict_account, null); + setContentView(content); + setNavigationBarBackground(new ColorDrawable(UiUtils.alphaBlendColors(UiUtils.getThemeColor(context, R.attr.colorM3Surface), + UiUtils.getThemeColor(context, R.attr.colorM3Primary), 0.05f)), !UiUtils.isDarkTheme()); + + contentWrap=findViewById(R.id.content_wrap); + titleView=findViewById(R.id.title); + subtitleView=findViewById(R.id.text); + cancelBtn=findViewById(R.id.btn_cancel); + confirmBtn=findViewById(R.id.btn_confirm); + secondaryBtn=findViewById(R.id.btn_secondary); + icon=findViewById(R.id.icon); + + contentWrap.setDividerDrawable(new EmptyDrawable(1, V.dp(8))); + contentWrap.setShowDividers(LinearLayout.SHOW_DIVIDER_MIDDLE); + confirmBtn.setOnClickListener(v->{ + if(loading) + return; + loading=true; + confirmBtn.setProgressBarVisible(true); + confirmCallback.onConfirmed(this::dismiss, ()->{ + confirmBtn.setProgressBarVisible(false); + loading=false; + }); + }); + cancelBtn.setOnClickListener(v->{ + if(!loading) + dismiss(); + }); + } + + protected void addRow(@DrawableRes int icon, CharSequence text, View view) { + TextView tv=new TextView(getContext()); + tv.setTextAppearance(R.style.m3_body_large); + tv.setTextColor(UiUtils.getThemeColor(getContext(), R.attr.colorM3OnSurfaceVariant)); + tv.setCompoundDrawableTintList(ColorStateList.valueOf(UiUtils.getThemeColor(getContext(), R.attr.colorM3Primary))); + tv.setGravity(Gravity.START | Gravity.CENTER_VERTICAL); + tv.setText(text); + InsetDrawable drawable=new InsetDrawable(getContext().getResources().getDrawable(icon, getContext().getTheme()), V.dp(8)); + drawable.setBounds(0, 0, V.dp(40), V.dp(40)); + tv.setCompoundDrawablesRelative(drawable, null, null, null); + tv.setCompoundDrawablePadding(V.dp(16)); + + if(view==null){ + contentWrap.addView(tv, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + return; + } + + AutoOrientationLinearLayout layout = new AutoOrientationLinearLayout(getContext()); + // allow complete row to trigger child click listener + if(view.hasOnClickListeners()) + layout.setOnClickListener(v -> view.performClick()); + LinearLayout.LayoutParams lp=new LinearLayout.LayoutParams(0,ViewGroup.LayoutParams.WRAP_CONTENT); + lp.gravity=Gravity.CENTER; + lp.weight=1f; + layout.addView(tv, lp); + layout.addView(view, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + contentWrap.addView(layout, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + } + + protected void addRow(@DrawableRes int icon, @StringRes int text, View view){ + addRow(icon, getContext().getString(text), view); + } + + protected void addRow(@DrawableRes int icon, CharSequence text){ + addRow(icon, text, null); + } + + protected void addRow(@DrawableRes int icon, @StringRes int text){ + addRow(icon, getContext().getString(text)); + } + + public interface ConfirmCallback{ + void onConfirmed(Runnable onSuccess, Runnable onError); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/AccountSwitcherSheet.java b/mastodon/src/main/java/org/joinmastodon/android/ui/sheets/AccountSwitcherSheet.java similarity index 83% rename from mastodon/src/main/java/org/joinmastodon/android/ui/AccountSwitcherSheet.java rename to mastodon/src/main/java/org/joinmastodon/android/ui/sheets/AccountSwitcherSheet.java index 889932c75..94731c8a1 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/AccountSwitcherSheet.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/sheets/AccountSwitcherSheet.java @@ -1,4 +1,4 @@ -package org.joinmastodon.android.ui; +package org.joinmastodon.android.ui.sheets; import android.annotation.SuppressLint; import android.app.Activity; @@ -8,7 +8,6 @@ import android.graphics.drawable.Animatable; import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.Drawable; import android.os.Build; -import android.text.SpannableStringBuilder; import android.view.View; import android.view.ViewGroup; import android.view.WindowInsets; @@ -27,8 +26,10 @@ import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.fragments.HomeFragment; import org.joinmastodon.android.fragments.SplashFragment; import org.joinmastodon.android.fragments.onboarding.CustomWelcomeFragment; +import org.joinmastodon.android.ui.ClickableSingleViewRecyclerAdapter; +import org.joinmastodon.android.ui.M3AlertDialogBuilder; +import org.joinmastodon.android.ui.OutlineProviders; import org.joinmastodon.android.ui.text.HtmlParser; -import org.joinmastodon.android.ui.utils.CustomEmojiHelper; import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.ui.views.CheckableRelativeLayout; @@ -61,25 +62,31 @@ import me.grishka.appkit.views.UsableRecyclerView; public class AccountSwitcherSheet extends BottomSheet{ private final Activity activity; private final HomeFragment fragment; - private final boolean externalShare, openInApp; + private final boolean accountChooser, openInApp; private BiConsumer onClick; private UsableRecyclerView list; private List accounts; private ListImageLoaderWrapper imgLoader; private AccountsAdapter accountsAdapter; + private Runnable onLoggedOutCallback; public AccountSwitcherSheet(@NonNull Activity activity, @Nullable HomeFragment fragment){ - this(activity, fragment, false, false); + this(activity, fragment, 0, 0, null, false); } - public AccountSwitcherSheet(@NonNull Activity activity, @Nullable HomeFragment fragment, boolean externalShare, boolean openInApp){ + + public AccountSwitcherSheet(@NonNull Activity activity, @Nullable HomeFragment fragment, @DrawableRes int headerIcon, @StringRes int headerTitle, String exceptFor, boolean openInApp){ super(activity); this.activity=activity; this.fragment=fragment; - this.externalShare = externalShare; - this.openInApp = openInApp; + this.accountChooser=headerTitle!=0; + // currently there is only one use case for a end row button (openInApp) + // if more are needed ti should be generified + this.openInApp=openInApp; - accounts=AccountSessionManager.getInstance().getLoggedInAccounts().stream().map(WrappedAccount::new).collect(Collectors.toList()); + accounts=AccountSessionManager.getInstance().getLoggedInAccounts().stream() + .filter(accountSession -> !accountSession.getID().equals(exceptFor)) + .map(WrappedAccount::new).collect(Collectors.toList()); list=new UsableRecyclerView(activity); imgLoader=new ListImageLoaderWrapper(activity, list, new RecyclerViewDelegate(list), null); @@ -93,20 +100,21 @@ public class AccountSwitcherSheet extends BottomSheet{ adapter.addAdapter(new SingleViewRecyclerAdapter(handle)); - if (externalShare) { + if (accountChooser) { FrameLayout shareHeading = new FrameLayout(activity); activity.getLayoutInflater().inflate(R.layout.item_external_share_heading, shareHeading); - ((TextView) shareHeading.findViewById(R.id.title)).setText(openInApp - ? R.string.sk_external_share_or_open_title - : R.string.sk_external_share_title); + ((ImageView) shareHeading.findViewById(R.id.icon)).setImageDrawable(getContext().getDrawable(headerIcon)); + ((TextView) shareHeading.findViewById(R.id.title)).setText(getContext().getString(headerTitle)); + adapter.addAdapter(new SingleViewRecyclerAdapter(shareHeading)); - setOnDismissListener((d) -> activity.finish()); + // we're using the sheet for interactAs picking, so the activity should not be closed + setOnDismissListener(exceptFor!=null ? null : (d) -> activity.finish()); } adapter.addAdapter(accountsAdapter = new AccountsAdapter()); - if (!externalShare) { + if (!accountChooser) { adapter.addAdapter(new ClickableSingleViewRecyclerAdapter(makeSimpleListItem(R.string.add_account, R.drawable.ic_fluent_add_24_regular), () -> { Nav.go(activity, CustomWelcomeFragment.class, null); dismiss(); @@ -125,6 +133,11 @@ public class AccountSwitcherSheet extends BottomSheet{ UiUtils.getThemeColor(activity, R.attr.colorM3Primary), 0.05f)), !UiUtils.isDarkTheme()); } + public AccountSwitcherSheet setOnLoggedOutCallback(Runnable onLoggedOutCallback){ + this.onLoggedOutCallback=onLoggedOutCallback; + return this; + } + public void setOnClick(BiConsumer onClick) { this.onClick = onClick; } @@ -132,6 +145,7 @@ public class AccountSwitcherSheet extends BottomSheet{ private void confirmLogOut(String accountID){ AccountSession session=AccountSessionManager.getInstance().getAccount(accountID); new M3AlertDialogBuilder(activity) + .setTitle(R.string.log_out) .setMessage(activity.getString(R.string.confirm_log_out, session.getFullUsername())) .setPositiveButton(R.string.log_out, (dialog, which) -> logOut(accountID)) .setNegativeButton(R.string.cancel, null) @@ -147,8 +161,12 @@ public class AccountSwitcherSheet extends BottomSheet{ } private void logOut(String accountID){ + String activeAccount=AccountSessionManager.getInstance().getLastActiveAccountID(); AccountSessionManager.get(accountID).logOut(activity, ()->{ - ((MainActivity)activity).restartActivity(); + if(accountID.equals(activeAccount) && onLoggedOutCallback!=null) + onLoggedOutCallback.run(); + dismiss(); + ((MainActivity)activity).restartHomeFragment(); }); } @@ -166,6 +184,8 @@ public class AccountSwitcherSheet extends BottomSheet{ AccountSessionManager.getInstance().removeAccount(session.getID()); sessions.remove(session); if(sessions.isEmpty()){ + if(onLoggedOutCallback!=null) + onLoggedOutCallback.run(); progress.dismiss(); Nav.goClearingStack(activity, SplashFragment.class, null); dismiss(); @@ -177,6 +197,8 @@ public class AccountSwitcherSheet extends BottomSheet{ AccountSessionManager.getInstance().removeAccount(session.getID()); sessions.remove(session); if(sessions.isEmpty()){ + if(onLoggedOutCallback!=null) + onLoggedOutCallback.run(); progress.dismiss(); Nav.goClearingStack(activity, SplashFragment.class, null); dismiss(); @@ -285,9 +307,9 @@ public class AccountSwitcherSheet extends BottomSheet{ public void onBind(AccountSession item){ HtmlParser.setTextWithCustomEmoji(name, item.self.getDisplayName(), item.self.emojis); username.setText(item.getFullUsername()); - radioButton.setVisibility(externalShare ? View.GONE : View.VISIBLE); - extraBtnWrap.setVisibility(externalShare && openInApp ? View.VISIBLE : View.GONE); - if (externalShare) view.setCheckable(false); + radioButton.setVisibility(accountChooser ? View.GONE : View.VISIBLE); + extraBtnWrap.setVisibility(accountChooser && openInApp ? View.VISIBLE : View.GONE); + if (accountChooser) view.setCheckable(false); else { String accountId = fragment != null ? fragment.getAccountID() @@ -322,7 +344,8 @@ public class AccountSwitcherSheet extends BottomSheet{ onClick.accept(item.getID(), false); return; } - if(AccountSessionManager.getInstance().tryGetAccount(item.getID())!=null){ + AccountSessionManager accountSessionManager=AccountSessionManager.getInstance(); + if(accountSessionManager.tryGetAccount(item.getID())!=null && !view.isChecked()){ AccountSessionManager.getInstance().setLastActiveAccountID(item.getID()); ((MainActivity)activity).restartActivity(); } @@ -330,7 +353,7 @@ public class AccountSwitcherSheet extends BottomSheet{ @Override public boolean onLongClick(){ - if (externalShare) return false; + if (accountChooser) return false; confirmLogOut(item.getID()); return true; } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/sheets/BlockAccountConfirmationSheet.java b/mastodon/src/main/java/org/joinmastodon/android/ui/sheets/BlockAccountConfirmationSheet.java new file mode 100644 index 000000000..3097cf3f5 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/sheets/BlockAccountConfirmationSheet.java @@ -0,0 +1,24 @@ +package org.joinmastodon.android.ui.sheets; + +import android.content.Context; +import android.view.View; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.model.Account; + +import androidx.annotation.NonNull; + +public class BlockAccountConfirmationSheet extends AccountRestrictionConfirmationSheet{ + public BlockAccountConfirmationSheet(@NonNull Context context, Account user, ConfirmCallback confirmCallback){ + super(context, user, confirmCallback); + titleView.setText(R.string.block_user_confirm_title); + confirmBtn.setText(R.string.do_block); + secondaryBtn.setVisibility(View.GONE); + icon.setImageResource(R.drawable.ic_fluent_shield_24_regular); + subtitleView.setText(user.getDisplayUsername()); + addRow(R.drawable.ic_campaign_24px, R.string.user_can_see_blocked); + addRow(R.drawable.ic_fluent_eye_off_24_regular, R.string.user_cant_see_each_other_posts); + addRow(R.drawable.ic_fluent_mention_24_regular, R.string.you_wont_see_user_mentions); + addRow(R.drawable.ic_fluent_arrow_reply_24_regular, R.string.user_cant_mention_or_follow_you); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/sheets/DecentralizationExplainerSheet.java b/mastodon/src/main/java/org/joinmastodon/android/ui/sheets/DecentralizationExplainerSheet.java new file mode 100644 index 000000000..cfc0a2e52 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/sheets/DecentralizationExplainerSheet.java @@ -0,0 +1,107 @@ +package org.joinmastodon.android.ui.sheets; + +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.graphics.drawable.ColorDrawable; +import android.net.Uri; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.TextView; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.ui.M3AlertDialogBuilder; +import org.joinmastodon.android.ui.Snackbar; +import org.joinmastodon.android.ui.text.LinkSpan; +import org.joinmastodon.android.ui.utils.UiUtils; +import org.joinmastodon.android.ui.views.RippleAnimationTextView; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Element; +import org.jsoup.nodes.Node; +import org.jsoup.nodes.TextNode; +import org.jsoup.select.NodeVisitor; + +import androidx.annotation.NonNull; +import me.grishka.appkit.views.BottomSheet; + +public class DecentralizationExplainerSheet extends BottomSheet{ + private final String handleStr; + + public DecentralizationExplainerSheet(@NonNull Context context, String accountID, Account account){ + super(context); + View content=context.getSystemService(LayoutInflater.class).inflate(R.layout.sheet_decentralization_info, null); + setContentView(content); + setNavigationBarBackground(new ColorDrawable(UiUtils.alphaBlendColors(UiUtils.getThemeColor(context, R.attr.colorM3Surface), + UiUtils.getThemeColor(context, R.attr.colorM3Primary), 0.05f)), !UiUtils.isDarkTheme()); + + TextView handleTitle=findViewById(R.id.handle_title); + RippleAnimationTextView handle=findViewById(R.id.handle); + TextView usernameExplanation=findViewById(R.id.username_text); + TextView serverExplanation=findViewById(R.id.server_text); + TextView handleExplanation=findViewById(R.id.handle_explanation); + findViewById(R.id.btn_cancel).setOnClickListener(v->dismiss()); + + findViewById(R.id.btn_view_info).setOnClickListener(v->{ + UiUtils.goToInstanceAboutFragment(Uri.parse(account.url).getHost(), accountID, context); + dismiss(); + }); + + String domain=account.getDomain(); + if(TextUtils.isEmpty(domain)) + domain=AccountSessionManager.get(accountID).domain; + handleStr="@"+account.username+"@"+domain; + boolean isSelf=AccountSessionManager.getInstance().isSelf(accountID, account); + + handleTitle.setText(isSelf ? R.string.handle_title_own : R.string.handle_title); + handle.setText(handleStr); + usernameExplanation.setText(isSelf ? R.string.handle_username_explanation_own : R.string.handle_username_explanation); + serverExplanation.setText(isSelf ? R.string.handle_server_explanation_own : R.string.handle_server_explanation); + + String explanation=context.getString(isSelf ? R.string.handle_explanation_own : R.string.handle_explanation); + SpannableStringBuilder ssb=new SpannableStringBuilder(); + Jsoup.parseBodyFragment(explanation).body().traverse(new NodeVisitor(){ + private int spanStart; + @Override + public void head(Node node, int depth){ + if(node instanceof TextNode tn){ + ssb.append(tn.text()); + }else if(node instanceof Element){ + spanStart=ssb.length(); + } + } + + @Override + public void tail(Node node, int depth){ + if(node instanceof Element){ + ssb.setSpan(new LinkSpan("", DecentralizationExplainerSheet.this::showActivityPubAlert, LinkSpan.Type.CUSTOM, null, null, null, null), spanStart, ssb.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + } + }); + handleExplanation.setText(ssb); + + findViewById(R.id.handle_wrap).setOnClickListener(v->{ + context.getSystemService(ClipboardManager.class).setPrimaryClip(ClipData.newPlainText(null, handleStr)); + if(UiUtils.needShowClipboardToast()){ + new Snackbar.Builder(context) + .setText(R.string.handle_copied) + .show(); + } + }); + String _domain=domain; + findViewById(R.id.username_row).setOnClickListener(v->handle.animate(1, account.username.length()+1)); + findViewById(R.id.server_row).setOnClickListener(v->handle.animate(handleStr.length()-_domain.length(), handleStr.length())); + } + + private void showActivityPubAlert(LinkSpan s){ + new M3AlertDialogBuilder(getContext()) + .setTitle(R.string.what_is_activitypub_title) + .setMessage(R.string.what_is_activitypub) + .setPositiveButton(R.string.ok, null) + .show(); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/sheets/MuteAccountConfirmationSheet.java b/mastodon/src/main/java/org/joinmastodon/android/ui/sheets/MuteAccountConfirmationSheet.java new file mode 100644 index 000000000..6a1172e13 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/sheets/MuteAccountConfirmationSheet.java @@ -0,0 +1,85 @@ +package org.joinmastodon.android.ui.sheets; + +import android.app.AlertDialog; +import android.content.Context; +import android.view.View; +import android.widget.Button; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.ui.M3AlertDialogBuilder; +import org.joinmastodon.android.ui.views.M3Switch; + +import java.time.Duration; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import androidx.annotation.NonNull; + +public class MuteAccountConfirmationSheet extends AccountRestrictionConfirmationSheet{ + public MuteAccountConfirmationSheet(@NonNull Context context, Account user, AtomicReference muteDuration, AtomicBoolean muteNotifications, ConfirmCallback confirmCallback){ + super(context, user, confirmCallback); + titleView.setText(R.string.mute_user_confirm_title); + confirmBtn.setText(R.string.do_mute); + secondaryBtn.setVisibility(View.GONE); + icon.setImageResource(R.drawable.ic_fluent_speaker_off_24_regular); + subtitleView.setText(user.getDisplayUsername()); + addRow(R.drawable.ic_campaign_24px, R.string.user_wont_know_muted); + addRow(R.drawable.ic_fluent_eye_off_24_regular, R.string.user_can_still_see_your_posts); + addRow(R.drawable.ic_fluent_mention_24_regular, R.string.you_wont_see_user_mentions); + addRow(R.drawable.ic_fluent_arrow_reply_24_regular, R.string.user_can_mention_and_follow_you); + + // add mute notifications toggle (Moshidon) + M3Switch m3Switch=new M3Switch(getContext()); + m3Switch.setClickable(true); + m3Switch.setChecked(muteNotifications.get()); + m3Switch.setOnCheckedChangeListener((compoundButton, b) -> muteNotifications.set(b)); + m3Switch.setOnClickListener(view -> muteNotifications.set(m3Switch.isSelected())); + addRow(R.drawable.ic_fluent_alert_off_24_regular, R.string.mo_mute_notifications, m3Switch); + + // add mute duration (Moshidon) + Button muteDurationBtn=new Button(getContext()); + muteDurationBtn.setOnClickListener(v->getMuteDurationDialog(context, muteDuration, muteDurationBtn).show()); + muteDurationBtn.setText(R.string.sk_duration_indefinite); + addRow(R.drawable.ic_fluent_clock_20_regular, R.string.sk_mute_label, muteDurationBtn); + } + + @NonNull + private M3AlertDialogBuilder getMuteDurationDialog(@NonNull Context context, AtomicReference muteDuration, Button button){ + M3AlertDialogBuilder builder=new M3AlertDialogBuilder(context); + builder.setTitle(R.string.sk_mute_label); + builder.setIcon(R.drawable.ic_fluent_clock_20_regular); + List durations =List.of(Duration.ZERO, + Duration.ofMinutes(5), + Duration.ofMinutes(30), + Duration.ofHours(1), + Duration.ofHours(6), + Duration.ofDays(1), + Duration.ofDays(3), + Duration.ofDays(7), + Duration.ofDays(7)); + + String[] choices = {context.getString(R.string.sk_duration_indefinite), + context.getString(R.string.sk_duration_minutes_5), + context.getString(R.string.sk_duration_minutes_30), + context.getString(R.string.sk_duration_hours_1), + context.getString(R.string.sk_duration_hours_6), + context.getString(R.string.sk_duration_days_1), + context.getString(R.string.sk_duration_days_3), + context.getString(R.string.sk_duration_days_7)}; + + builder.setSingleChoiceItems(choices, durations.indexOf(muteDuration.get()), (dialog, which) -> {}); + + builder.setPositiveButton(R.string.ok, (dialog, which)->{ + int selected = ((AlertDialog) dialog).getListView().getCheckedItemPosition(); + muteDuration.set(durations.get(selected)); + button.setText(choices[selected]); + }); + builder.setNegativeButton(R.string.cancel, null); + + return builder; + } + + +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/sheets/NonMutualPreReplySheet.java b/mastodon/src/main/java/org/joinmastodon/android/ui/sheets/NonMutualPreReplySheet.java new file mode 100644 index 000000000..9e358be17 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/sheets/NonMutualPreReplySheet.java @@ -0,0 +1,131 @@ +package org.joinmastodon.android.ui.sheets; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.text.TextUtils; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.ui.OutlineProviders; +import org.joinmastodon.android.ui.text.HtmlParser; +import org.joinmastodon.android.ui.utils.UiUtils; + +import androidx.annotation.NonNull; +import me.grishka.appkit.imageloader.ViewImageLoader; +import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest; +import me.grishka.appkit.utils.V; + +public class NonMutualPreReplySheet extends PreReplySheet{ + private boolean fullBioShown=false; + + @SuppressLint("DefaultLocale") + public NonMutualPreReplySheet(@NonNull Context context, ResultListener resultListener, Account account, String accountID){ + super(context, resultListener); + icon.setImageResource(R.drawable.ic_waving_hand_24px); + title.setText(R.string.non_mutual_sheet_title); + text.setText(R.string.non_mutual_sheet_text); + + LinearLayout userInfo=new LinearLayout(context); + userInfo.setOrientation(LinearLayout.HORIZONTAL); + userInfo.setBackgroundResource(R.drawable.bg_user_info); + UiUtils.setAllPaddings(userInfo, 12); + + ImageView ava=new ImageView(context); + ava.setScaleType(ImageView.ScaleType.CENTER_CROP); + ava.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO); + ava.setOutlineProvider(OutlineProviders.roundedRect(12)); + ava.setClipToOutline(true); + ava.setForeground(context.getResources().getDrawable(R.drawable.fg_user_info_ava, context.getTheme())); + userInfo.addView(ava, UiUtils.makeLayoutParams(56, 56, 0, 0, 12, 0)); + ViewImageLoader.loadWithoutAnimation(ava, context.getResources().getDrawable(R.drawable.image_placeholder), new UrlImageLoaderRequest(account.avatarStatic, V.dp(56), V.dp(56))); + + LinearLayout nameAndFields=new LinearLayout(context); + nameAndFields.setOrientation(LinearLayout.VERTICAL); + nameAndFields.setMinimumHeight(V.dp(56)); + nameAndFields.setGravity(Gravity.CENTER_VERTICAL); + userInfo.addView(nameAndFields, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + TextView name=new TextView(context); + name.setSingleLine(); + name.setEllipsize(TextUtils.TruncateAt.END); + name.setTextAppearance(R.style.m3_title_medium); + name.setTextColor(UiUtils.getThemeColor(context, R.attr.colorM3OnSurface)); + if(AccountSessionManager.get(accountID).getLocalPreferences().customEmojiInNames){ + name.setText(HtmlParser.parseCustomEmoji(account.displayName, account.emojis)); + UiUtils.loadCustomEmojiInTextView(name); + }else{ + name.setText(account.displayName); + } + name.setGravity(Gravity.CENTER_VERTICAL | Gravity.START); + nameAndFields.addView(name, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, V.dp(24))); + if(!TextUtils.isEmpty(account.note)){ + CharSequence strippedBio=HtmlParser.parseCustomEmoji(HtmlParser.stripAndRemoveInvisibleSpans(account.note), account.emojis); + TextView bioShort=new TextView(context); + bioShort.setTextAppearance(R.style.m3_body_medium); + bioShort.setTextColor(UiUtils.getThemeColor(context, R.attr.colorM3Secondary)); + bioShort.setMaxLines(2); + bioShort.setEllipsize(TextUtils.TruncateAt.END); + bioShort.setText(strippedBio); + nameAndFields.addView(bioShort, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + + TextView bioFull=new TextView(context); + bioFull.setTextAppearance(R.style.m3_body_medium); + bioFull.setTextColor(UiUtils.getThemeColor(context, R.attr.colorM3Secondary)); + bioFull.setText(strippedBio); + bioFull.setVisibility(View.GONE); + nameAndFields.addView(bioFull, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + nameAndFields.setOnClickListener(v->{ + UiUtils.beginLayoutTransition((ViewGroup) getWindow().getDecorView()); + fullBioShown=!fullBioShown; + if(fullBioShown){ + bioFull.setVisibility(View.VISIBLE); + bioShort.setVisibility(View.GONE); + }else{ + bioFull.setVisibility(View.GONE); + bioShort.setVisibility(View.VISIBLE); + } + }); + UiUtils.loadCustomEmojiInTextView(bioShort); + UiUtils.loadCustomEmojiInTextView(bioFull); + }else{ + TextView username=new TextView(context); + username.setTextAppearance(R.style.m3_body_medium); + username.setTextColor(UiUtils.getThemeColor(context, R.attr.colorM3Secondary)); + username.setSingleLine(); + username.setEllipsize(TextUtils.TruncateAt.END); + username.setText(account.getDisplayUsername()); + username.setGravity(Gravity.CENTER_VERTICAL | Gravity.START); + nameAndFields.addView(username, new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, V.dp(20))); + } + + contentWrap.addView(userInfo, UiUtils.makeLayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT, 0, 0, 0, 8)); + + for(int i=0;i<3;i++){ + View item=context.getSystemService(LayoutInflater.class).inflate(R.layout.item_other_numbered_rule, contentWrap, false); + TextView number=item.findViewById(R.id.number); + number.setText(String.format("%d", i+1)); + TextView title=item.findViewById(R.id.title); + TextView text=item.findViewById(R.id.text); + title.setText(switch(i){ + case 0 -> R.string.non_mutual_title1; + case 1 -> R.string.non_mutual_title2; + case 2 -> R.string.non_mutual_title3; + default -> throw new IllegalStateException("Unexpected value: "+i); + }); + text.setText(switch(i){ + case 0 -> R.string.non_mutual_text1; + case 1 -> R.string.non_mutual_text2; + case 2 -> R.string.non_mutual_text3; + default -> throw new IllegalStateException("Unexpected value: "+i); + }); + contentWrap.addView(item); + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/sheets/OldPostPreReplySheet.java b/mastodon/src/main/java/org/joinmastodon/android/ui/sheets/OldPostPreReplySheet.java new file mode 100644 index 000000000..644ae1865 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/sheets/OldPostPreReplySheet.java @@ -0,0 +1,23 @@ +package org.joinmastodon.android.ui.sheets; + +import android.content.Context; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.model.Status; + +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; + +import androidx.annotation.NonNull; + +public class OldPostPreReplySheet extends PreReplySheet{ + public OldPostPreReplySheet(@NonNull Context context, ResultListener resultListener, Status status){ + super(context, resultListener); + int months=(int)status.createdAt.atZone(ZoneId.systemDefault()).until(ZonedDateTime.now(), ChronoUnit.MONTHS); + String monthsStr=months>24 ? context.getString(R.string.more_than_two_years) : context.getResources().getQuantityString(R.plurals.x_months, months, months); + title.setText(context.getString(R.string.old_post_sheet_title, monthsStr)); + text.setText(R.string.old_post_sheet_text); + icon.setImageResource(R.drawable.ic_fluent_history_24_regular); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/sheets/PreReplySheet.java b/mastodon/src/main/java/org/joinmastodon/android/ui/sheets/PreReplySheet.java new file mode 100644 index 000000000..cae173a5e --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/sheets/PreReplySheet.java @@ -0,0 +1,54 @@ +package org.joinmastodon.android.ui.sheets; + +import android.content.Context; +import android.graphics.drawable.ColorDrawable; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.Button; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.ui.utils.UiUtils; + +import androidx.annotation.NonNull; +import me.grishka.appkit.views.BottomSheet; + +public abstract class PreReplySheet extends BottomSheet{ + protected ImageView icon; + protected TextView title, text; + protected Button gotItButton, dontRemindButton; + protected LinearLayout contentWrap; + + public PreReplySheet(@NonNull Context context, ResultListener resultListener){ + super(context); + + View content=context.getSystemService(LayoutInflater.class).inflate(R.layout.sheet_pre_reply, null); + setContentView(content); + + setNavigationBarBackground(new ColorDrawable(UiUtils.alphaBlendColors(UiUtils.getThemeColor(context, R.attr.colorM3Surface), + UiUtils.getThemeColor(context, R.attr.colorM3Primary), 0.05f)), !UiUtils.isDarkTheme()); + + icon=findViewById(R.id.icon); + title=findViewById(R.id.title); + text=findViewById(R.id.text); + gotItButton=findViewById(R.id.btn_got_it); + dontRemindButton=findViewById(R.id.btn_dont_remind_again); + contentWrap=findViewById(R.id.content_wrap); + + gotItButton.setOnClickListener(v->{ + dismiss(); + resultListener.onButtonClicked(false); + }); + dontRemindButton.setOnClickListener(v->{ + dismiss(); + resultListener.onButtonClicked(true); + }); + } + + @FunctionalInterface + public interface ResultListener{ + void onButtonClicked(boolean notAgain); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/text/AvatarSpan.java b/mastodon/src/main/java/org/joinmastodon/android/ui/text/AvatarSpan.java new file mode 100644 index 000000000..bb766ef73 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/text/AvatarSpan.java @@ -0,0 +1,51 @@ +package org.joinmastodon.android.ui.text; + +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.text.style.ReplacementSpan; + +import org.joinmastodon.android.GlobalUserPreferences; +import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.model.Emoji; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest; +import me.grishka.appkit.utils.V; + +public class AvatarSpan extends CustomEmojiSpan{ + + public AvatarSpan(Account account){ + //this is a hacky solution to allow loading of avatars in the middle of strings, + //using already existing code for loading emojis + super(new Emoji(account.avatarStatic, account.avatar, account.avatarStatic)); + } + + @Override + public void draw(@NonNull Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, @NonNull Paint paint){ + //modified draw of a CustomEmojiSpan, drawing a circular image instead. + if(drawable==null) + return; + top += 4; + int size=Math.round(paint.descent()-paint.ascent()); + Rect bounds=drawable.getBounds(); + int dw=drawable.getIntrinsicWidth(); + int dh=drawable.getIntrinsicHeight(); + if(bounds.left!=0 || bounds.top!=0 || bounds.right!=dw || bounds.left!=dh){ + drawable.setBounds(0, 0, dw, dh); + } + canvas.save(); + float radius = size / 2f; + Path clipPath = new Path(); + clipPath.addCircle(x + radius, top + radius, radius, Path.Direction.CW); + canvas.clipPath(clipPath); + canvas.translate(x, top); + canvas.scale(size/(float)dw, size/(float)dh, 0f, 0f); + drawable.draw(canvas); + canvas.restore(); + } + +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/text/CustomEmojiSpan.java b/mastodon/src/main/java/org/joinmastodon/android/ui/text/CustomEmojiSpan.java index b6732b31c..71b4e9cf2 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/text/CustomEmojiSpan.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/text/CustomEmojiSpan.java @@ -16,7 +16,7 @@ import me.grishka.appkit.utils.V; public class CustomEmojiSpan extends ReplacementSpan{ public final Emoji emoji; - private Drawable drawable; + protected Drawable drawable; public CustomEmojiSpan(Emoji emoji){ this.emoji=emoji; @@ -24,7 +24,8 @@ public class CustomEmojiSpan extends ReplacementSpan{ @Override public int getSize(@NonNull Paint paint, CharSequence text, int start, int end, @Nullable Paint.FontMetricsInt fm){ - return Math.round(paint.descent()-paint.ascent()); + int size = Math.round(paint.descent()-paint.ascent()); + return drawable!=null ? (int) (drawable.getIntrinsicWidth()*(size/(float) drawable.getIntrinsicHeight())) : size; } @Override @@ -45,7 +46,8 @@ public class CustomEmojiSpan extends ReplacementSpan{ } canvas.save(); canvas.translate(x, top); - canvas.scale(size/(float)dw, size/(float)dh, 0f, 0f); + float scale = size/(float)dh; + canvas.scale(scale, scale, 0f, 0f); drawable.draw(canvas); canvas.restore(); } @@ -56,7 +58,6 @@ public class CustomEmojiSpan extends ReplacementSpan{ } public UrlImageLoaderRequest createImageLoaderRequest(){ - int size=V.dp(20); - return new UrlImageLoaderRequest(GlobalUserPreferences.playGifs ? emoji.url : emoji.staticUrl, size, size); + return new UrlImageLoaderRequest(GlobalUserPreferences.playGifs ? emoji.url : emoji.staticUrl, 0, V.dp(20)); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/text/HtmlParser.java b/mastodon/src/main/java/org/joinmastodon/android/ui/text/HtmlParser.java index 7cc2741b6..9d679db33 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/text/HtmlParser.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/text/HtmlParser.java @@ -66,12 +66,21 @@ public class HtmlParser{ ")" + ")"; public static final Pattern URL_PATTERN=Pattern.compile(VALID_URL_PATTERN_STRING, Pattern.CASE_INSENSITIVE); + public static final Pattern INVITE_LINK_PATTERN=Pattern.compile("^https://"+Regex.URL_VALID_DOMAIN+"/invite/[a-z\\d]+$", Pattern.CASE_INSENSITIVE); private static Pattern EMOJI_CODE_PATTERN=Pattern.compile(":([\\w]+):"); private HtmlParser(){} public static SpannableStringBuilder parse(String source, List emojis, List mentions, List tags, String accountID){ - return parse(source, emojis, mentions, tags, accountID, null); + return parse(source, emojis, mentions, tags, accountID, null, null); + } + + public static SpannableStringBuilder parse(String source, List emojis, List mentions, List tags, String accountID, Context context){ + return parse(source, emojis, mentions, tags, accountID, null, context); + } + + public static SpannableStringBuilder parse(String source, List emojis, List mentions, List tags, String accountID, Object parentObject){ + return parse(source, emojis, mentions, tags, accountID, parentObject, null); } /** @@ -86,7 +95,7 @@ public class HtmlParser{ * @param emojis Custom emojis that are present in source as :code: * @return a spanned string */ - public static SpannableStringBuilder parse(String source, List emojis, List mentions, List tags, String accountID, Context context){ + public static SpannableStringBuilder parse(String source, List emojis, List mentions, List tags, String accountID, Object parentObject, Context context){ class SpanInfo{ public Object span; public int start; @@ -105,7 +114,7 @@ public class HtmlParser{ } } - Map idsByUrl=mentions.stream().distinct().collect(Collectors.toMap(m->m.url, m->m.id)); + Map idsByUrl=mentions.stream().filter(mention -> mention.id != null).collect(Collectors.toMap(m->m.url, m->m.id)); // Hashtags in remote posts have remote URLs, these have local URLs so they don't match. // Map tagsByUrl=tags.stream().collect(Collectors.toMap(t->t.url, t->t.name)); Map tagsByTag=tags.stream().distinct().collect(Collectors.toMap(t->t.name.toLowerCase(), Function.identity())); @@ -114,13 +123,16 @@ public class HtmlParser{ int colorInsert=UiUtils.getThemeColor(context, R.attr.colorM3Success); int colorDelete=UiUtils.getThemeColor(context, R.attr.colorM3Error); + if(source.endsWith("\n")) + source=source.stripTrailing(); + Jsoup.parseBodyFragment(source).body().traverse(new NodeVisitor(){ private final ArrayList openSpans=new ArrayList<>(); @Override public void head(@NonNull Node node, int depth){ if(node instanceof TextNode textNode){ - ssb.append(textNode.getWholeText()); + ssb.append(textNode.text()); }else if(node instanceof Element el){ switch(el.nodeName()){ case "a" -> { @@ -128,14 +140,12 @@ public class HtmlParser{ String href=el.attr("href"); LinkSpan.Type linkType; String text=el.text(); - if(el.hasClass("hashtag")){ - if(text.startsWith("#")){ - linkType=LinkSpan.Type.HASHTAG; - href=text.substring(1); - linkObject=tagsByTag.get(text.substring(1).toLowerCase()); - }else{ - linkType=LinkSpan.Type.URL; - } + if(el.hasClass("hashtag") || text.startsWith("#")){ + // MOSHIDON: we have slightly refactored this so that the hashtags properly work in akkoma + // TODO: upstream this + linkType=LinkSpan.Type.HASHTAG; + href=text.substring(1); + linkObject=tagsByTag.get(text.substring(1).toLowerCase()); }else if(el.hasClass("mention")){ String id=idsByUrl.get(href); if(id!=null){ @@ -147,7 +157,7 @@ public class HtmlParser{ }else{ linkType=LinkSpan.Type.URL; } - openSpans.add(new SpanInfo(new LinkSpan(href, null, linkType, accountID, linkObject, text), ssb.length(), el)); + openSpans.add(new SpanInfo(new LinkSpan(href, null, linkType, accountID, linkObject, parentObject, text), ssb.length(), el)); } case "br" -> ssb.append('\n'); case "span" -> { @@ -269,8 +279,28 @@ public class HtmlParser{ public static String stripAndRemoveInvisibleSpans(String html){ Document doc=Jsoup.parseBodyFragment(html); doc.body().select("span.invisible").remove(); - Cleaner cleaner=new Cleaner(Safelist.none()); - return cleaner.clean(doc).body().html(); + Cleaner cleaner=new Cleaner(Safelist.none().addTags("br", "p")); + StringBuilder sb=new StringBuilder(); + cleaner.clean(doc).body().traverse(new NodeVisitor(){ + @Override + public void head(Node node, int depth){ + if(node instanceof TextNode tn){ + sb.append(tn.text()); + }else if(node instanceof Element el){ + if("br".equals(el.tagName())){ + sb.append('\n'); + } + } + } + + @Override + public void tail(Node node, int depth){ + if(node instanceof Element el && "p".equals(el.tagName()) && el.nextSibling()!=null){ + sb.append("\n\n"); + } + } + }); + return sb.toString(); } public static String text(String html) { @@ -286,18 +316,17 @@ public class HtmlParser{ String url=matcher.group(3); if(TextUtils.isEmpty(matcher.group(4))) url="http://"+url; - ssb.setSpan(new LinkSpan(url, null, LinkSpan.Type.URL, null, null, null), matcher.start(3), matcher.end(3), 0); + ssb.setSpan(new LinkSpan(url, null, LinkSpan.Type.URL, null, null, null, url), matcher.start(3), matcher.end(3), 0); }while(matcher.find()); // Find more URLs return ssb; } public static void applyFilterHighlights(Context context, SpannableStringBuilder text, List filters){ - if (filters == null) return; int fgColor=UiUtils.getThemeColor(context, R.attr.colorM3Error); int bgColor=UiUtils.getThemeColor(context, R.attr.colorM3ErrorContainer); for(FilterResult filter:filters){ if(!filter.filter.isActive()) - continue;; + continue; for(String word:filter.keywordMatches){ Matcher matcher=Pattern.compile("\\b"+Pattern.quote(word)+"\\b", Pattern.CASE_INSENSITIVE).matcher(text); while(matcher.find()){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/text/LinkSpan.java b/mastodon/src/main/java/org/joinmastodon/android/ui/text/LinkSpan.java index 66281119b..2500f947a 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/text/LinkSpan.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/text/LinkSpan.java @@ -17,14 +17,16 @@ public class LinkSpan extends CharacterStyle { private Type type; private String accountID; private Object linkObject; + private Object parentObject; private String text; - public LinkSpan(String link, OnLinkClickListener listener, Type type, String accountID, Object linkObject, String text){ + public LinkSpan(String link, OnLinkClickListener listener, Type type, String accountID, Object linkObject, Object parentObject, String text){ this.listener=listener; this.link=link; this.type=type; this.accountID=accountID; this.linkObject=linkObject; + this.parentObject=parentObject; this.text=text; } @@ -52,6 +54,13 @@ public class LinkSpan extends CharacterStyle { } } + public void onLongClick(View view) { + if(linkObject instanceof Hashtag ht) + UiUtils.copyText(view, ht.name); + else + UiUtils.copyText(view, link); + } + public String getLink(){ return link; } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/ActionModeHelper.java b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/ActionModeHelper.java index 23f7fbf45..7e8bb310e 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/ActionModeHelper.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/ActionModeHelper.java @@ -9,6 +9,7 @@ import android.view.ActionMode; import android.view.Menu; import android.view.MenuItem; import android.view.View; +import android.view.ViewGroup; import org.joinmastodon.android.R; diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/ColorPalette.java b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/ColorPalette.java index f7e21590b..dca5ad8ed 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/ColorPalette.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/ColorPalette.java @@ -25,8 +25,11 @@ public class ColorPalette { BLUE, new ColorPalette(R.style.ColorPalette_Blue), BROWN, new ColorPalette(R.style.ColorPalette_Brown), RED, new ColorPalette(R.style.ColorPalette_Red), - YELLOW, new ColorPalette(R.style.ColorPalette_Yellow) - ); + YELLOW, new ColorPalette(R.style.ColorPalette_Yellow), + NORD, new ColorPalette(R.style.ColorPalette_Nord), + WHITE, new ColorPalette(R.style.ColorPalette_White) + + ); private @StyleRes int base; private @StyleRes int autoDark; diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/CustomEmojiHelper.java b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/CustomEmojiHelper.java index faafc39d4..aaac45298 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/CustomEmojiHelper.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/CustomEmojiHelper.java @@ -48,6 +48,8 @@ public class CustomEmojiHelper{ } public void setImageDrawable(int image, Drawable drawable){ + if(spans.isEmpty()) + return; for(CustomEmojiSpan span:spans.get(image)){ span.setDrawable(drawable); } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/DiscoverInfoBannerHelper.java b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/DiscoverInfoBannerHelper.java index fbee5dc6a..c2330b5d9 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/DiscoverInfoBannerHelper.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/DiscoverInfoBannerHelper.java @@ -23,6 +23,8 @@ public class DiscoverInfoBannerHelper{ private final BannerType type; private final String accountID; private static EnumSet bannerTypesToShow=EnumSet.noneOf(BannerType.class); + private SingleViewRecyclerAdapter bannerAdapter; + private boolean added; static{ for(BannerType t:BannerType.values()){ @@ -41,6 +43,8 @@ public class DiscoverInfoBannerHelper{ } public void maybeAddBanner(RecyclerView list, MergeRecyclerAdapter adapter){ + if(added) + return; if(bannerTypesToShow.contains(type)){ banner=((Activity)list.getContext()).getLayoutInflater().inflate(R.layout.discover_info_banner, list, false); TextView text=banner.findViewById(R.id.banner_text); @@ -63,7 +67,8 @@ public class DiscoverInfoBannerHelper{ case BUBBLE_TIMELINE -> TimelineDefinition.BUBBLE_TIMELINE.getDefaultIcon().iconRes; case POST_NOTIFICATIONS -> TimelineDefinition.POSTS_TIMELINE.getDefaultIcon().iconRes; }); - adapter.addAdapter(new SingleViewRecyclerAdapter(banner)); + adapter.addAdapter(0, bannerAdapter=new SingleViewRecyclerAdapter(banner)); + added=true; } } @@ -72,6 +77,13 @@ public class DiscoverInfoBannerHelper{ // bannerTypesToShow is not updated here on purpose so the banner keeps showing until the app is relaunched } + public void removeBanner(MergeRecyclerAdapter adapter){ + if(bannerAdapter!=null){ + adapter.removeAdapter(bannerAdapter); + added=false; + } + } + public static void reset(){ SharedPreferences prefs=getPrefs(); SharedPreferences.Editor e=prefs.edit(); @@ -89,4 +101,4 @@ public class DiscoverInfoBannerHelper{ ACCOUNTS, BUBBLE_TIMELINE } -} +} \ No newline at end of file diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/InsetStatusItemDecoration.java b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/InsetStatusItemDecoration.java index d64b1d0f3..b6e893d43 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/InsetStatusItemDecoration.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/InsetStatusItemDecoration.java @@ -9,6 +9,9 @@ import android.view.View; import org.joinmastodon.android.R; import org.joinmastodon.android.fragments.BaseStatusListFragment; import org.joinmastodon.android.ui.displayitems.StatusDisplayItem; +import org.joinmastodon.android.ui.displayitems.LinkCardStatusDisplayItem; +import org.joinmastodon.android.ui.displayitems.MediaGridStatusDisplayItem; +import org.joinmastodon.android.ui.displayitems.WarningFilteredStatusDisplayItem; import java.util.List; @@ -40,9 +43,20 @@ public class InsetStatusItemDecoration extends RecyclerView.ItemDecoration{ boolean inset=(holder instanceof StatusDisplayItem.Holder sdi) && sdi.getItem().inset; if(inset){ if(rect.isEmpty()){ - rect.set(child.getX(), i==0 && pos>0 && displayItems.get(pos-1).inset ? V.dp(-10) : child.getY(), child.getX()+child.getWidth(), child.getY()+child.getHeight()); + if(holder instanceof MediaGridStatusDisplayItem.Holder || holder instanceof LinkCardStatusDisplayItem.Holder || holder instanceof WarningFilteredStatusDisplayItem.Holder){ + float topInset=i == 0 && pos > 0 && displayItems.get(pos - 1).inset ? V.dp(-10) : child.getY(); + if(holder instanceof WarningFilteredStatusDisplayItem.Holder) + topInset-=V.dp(4); + rect.set(child.getX(), topInset, child.getX() + child.getWidth(), child.getY() + child.getHeight() + V.dp(4)); + }else { + rect.set(child.getX(), i == 0 && pos > 0 && displayItems.get(pos - 1).inset ? V.dp(-10) : child.getY(), child.getX() + child.getWidth(), child.getY() + child.getHeight()); + } }else{ - rect.bottom=Math.max(rect.bottom, child.getY()+child.getHeight()); + if(holder instanceof MediaGridStatusDisplayItem.Holder || holder instanceof LinkCardStatusDisplayItem.Holder || holder instanceof WarningFilteredStatusDisplayItem.Holder){ + rect.bottom=Math.max(rect.bottom, child.getY()+child.getHeight()) + V.dp(4); + }else { + rect.bottom=Math.max(rect.bottom, child.getY()+child.getHeight()); + } } }else if(!rect.isEmpty()){ drawInsetBackground(parent, c); @@ -63,7 +77,7 @@ public class InsetStatusItemDecoration extends RecyclerView.ItemDecoration{ paint.setColor(bgColor); rect.left=V.dp(12); rect.right=list.getWidth()-V.dp(12); - rect.inset(V.dp(4), V.dp(0)); + rect.intersect(V.dp(4), V.dp(4), V.dp(4), V.dp(-4)); c.drawRoundRect(rect, V.dp(12), V.dp(12), paint); paint.setStyle(Paint.Style.STROKE); paint.setStrokeWidth(V.dp(1)); diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/PreviewlessMediaAttachmentViewController.java b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/PreviewlessMediaAttachmentViewController.java new file mode 100644 index 000000000..3d27e30a0 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/PreviewlessMediaAttachmentViewController.java @@ -0,0 +1,56 @@ +package org.joinmastodon.android.ui.utils; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.os.Build; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.ImageView; +import android.widget.TextView; + +import org.joinmastodon.android.GlobalUserPreferences; +import org.joinmastodon.android.R; +import org.joinmastodon.android.model.Attachment; +import org.joinmastodon.android.model.Status; +import org.joinmastodon.android.ui.displayitems.MediaGridStatusDisplayItem; +import org.joinmastodon.android.ui.drawables.BlurhashCrossfadeDrawable; +import org.joinmastodon.android.ui.drawables.PlayIconDrawable; + +public class PreviewlessMediaAttachmentViewController{ + public final View view; + public final MediaGridStatusDisplayItem.GridItemType type; + private final TextView title, domain; + public final View inner; + private final ImageView icon; + private final Context context; + private Status status; + + public PreviewlessMediaAttachmentViewController(Context context, MediaGridStatusDisplayItem.GridItemType type){ + view=context.getSystemService(LayoutInflater.class).inflate(R.layout.display_item_file, null); + title=view.findViewById(R.id.title); + domain=view.findViewById(R.id.domain); + icon=view.findViewById(R.id.imageView); + inner=view.findViewById(R.id.inner); + this.context=context; + this.type=type; + } + + public void bind(Attachment attachment, Status status){ + this.status=status; + title.setText(attachment.description != null + ? attachment.description + : context.getString(R.string.sk_no_alt_text)); + title.setSingleLine(false); + + domain.setText(status.sensitive ? context.getString(R.string.sensitive_content_explain) : null); + domain.setVisibility(status.sensitive ? View.VISIBLE : View.GONE); + + if(attachment.type == Attachment.Type.IMAGE) + icon.setImageDrawable(context.getDrawable(R.drawable.ic_fluent_image_24_regular)); + if(attachment.type == Attachment.Type.VIDEO) + icon.setImageDrawable(context.getDrawable(R.drawable.ic_fluent_video_clip_24_regular)); + if(attachment.type == Attachment.Type.GIFV) + icon.setImageDrawable(context.getDrawable(R.drawable.ic_fluent_gif_24_regular)); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java index d5f3281ad..69b20f7c7 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java @@ -23,9 +23,11 @@ import android.content.res.TypedArray; import android.database.Cursor; import android.graphics.Bitmap; import android.graphics.Canvas; +import android.graphics.drawable.Animatable; import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.Drawable; import android.graphics.drawable.InsetDrawable; +import android.graphics.drawable.LayerDrawable; import android.net.Uri; import android.os.Build; import android.os.Bundle; @@ -38,6 +40,7 @@ import android.provider.OpenableColumns; import android.text.SpannableStringBuilder; import android.text.Spanned; import android.text.TextUtils; +import android.text.style.BulletSpan; import android.text.style.TypefaceSpan; import android.transition.ChangeBounds; import android.transition.ChangeScroll; @@ -61,14 +64,15 @@ import android.widget.Button; import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.PopupMenu; +import android.widget.ProgressBar; import android.widget.TextView; import android.widget.Toast; import org.joinmastodon.android.E; +import org.joinmastodon.android.FileProvider; import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.MastodonApp; import org.joinmastodon.android.R; -import org.joinmastodon.android.api.CacheController; import org.joinmastodon.android.api.MastodonAPIRequest; import org.joinmastodon.android.api.MastodonErrorResponse; import org.joinmastodon.android.api.StatusInteractionController; @@ -76,19 +80,21 @@ import org.joinmastodon.android.api.requests.accounts.SetAccountBlocked; import org.joinmastodon.android.api.requests.accounts.SetAccountFollowed; import org.joinmastodon.android.api.requests.accounts.SetAccountMuted; import org.joinmastodon.android.api.requests.accounts.SetDomainBlocked; +import org.joinmastodon.android.api.requests.search.GetSearchResults; import org.joinmastodon.android.api.requests.accounts.AuthorizeFollowRequest; import org.joinmastodon.android.api.requests.accounts.RejectFollowRequest; import org.joinmastodon.android.api.requests.instance.GetInstance; import org.joinmastodon.android.api.requests.lists.DeleteList; import org.joinmastodon.android.api.requests.notifications.DismissNotification; -import org.joinmastodon.android.api.requests.search.GetSearchResults; import org.joinmastodon.android.api.requests.statuses.CreateStatus; import org.joinmastodon.android.api.requests.statuses.DeleteStatus; import org.joinmastodon.android.api.requests.statuses.GetStatusByID; +import org.joinmastodon.android.api.requests.statuses.SetStatusMuted; import org.joinmastodon.android.api.requests.statuses.SetStatusPinned; import org.joinmastodon.android.api.session.AccountLocalPreferences; import org.joinmastodon.android.api.session.AccountSession; import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.events.StatusMuteChangedEvent; import org.joinmastodon.android.events.ScheduledStatusDeletedEvent; import org.joinmastodon.android.events.StatusCountersUpdatedEvent; import org.joinmastodon.android.events.FollowRequestHandledEvent; @@ -108,17 +114,23 @@ import org.joinmastodon.android.model.Instance; import org.joinmastodon.android.model.Notification; import org.joinmastodon.android.model.Hashtag; import org.joinmastodon.android.model.Relationship; -import org.joinmastodon.android.model.ScheduledStatus; import org.joinmastodon.android.model.SearchResults; +import org.joinmastodon.android.model.ScheduledStatus; import org.joinmastodon.android.model.Searchable; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.ui.M3AlertDialogBuilder; +import org.joinmastodon.android.ui.Snackbar; +import org.joinmastodon.android.ui.sheets.AccountSwitcherSheet; +import org.joinmastodon.android.ui.sheets.BlockAccountConfirmationSheet; +import org.joinmastodon.android.ui.sheets.MuteAccountConfirmationSheet; import org.joinmastodon.android.ui.text.CustomEmojiSpan; import org.joinmastodon.android.ui.text.HtmlParser; +import org.joinmastodon.android.utils.Tracking; import org.parceler.Parcels; import java.io.File; import java.lang.reflect.Field; +import java.io.IOException; import java.lang.reflect.Method; import java.net.IDN; import java.net.URI; @@ -140,8 +152,9 @@ import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.Optional; -import java.util.function.BiConsumer; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BiConsumer; import java.util.function.BiPredicate; import java.util.function.Consumer; import java.util.function.Function; @@ -164,6 +177,7 @@ import androidx.viewpager2.widget.ViewPager2; import me.grishka.appkit.Nav; import me.grishka.appkit.api.Callback; import me.grishka.appkit.api.ErrorResponse; +import me.grishka.appkit.imageloader.ImageCache; import me.grishka.appkit.imageloader.ViewImageLoader; import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest; import me.grishka.appkit.utils.CubicBezierInterpolator; @@ -183,6 +197,8 @@ public class UiUtils { } public static void launchWebBrowser(Context context, String url) { + if(GlobalUserPreferences.removeTrackingParams) + url=Tracking.removeTrackingParameters(url); try { if (GlobalUserPreferences.useCustomTabs) { new CustomTabsIntent.Builder() @@ -401,7 +417,6 @@ public class UiUtils { CustomEmojiSpan[] spans = text.getSpans(0, text.length(), CustomEmojiSpan.class); if (spans.length == 0) return; - int emojiSize = V.dp(20); Map> spansByEmoji = Arrays.stream(spans).collect(Collectors.groupingBy(s -> s.emoji)); for (Map.Entry> emoji : spansByEmoji.entrySet()) { ViewImageLoader.load(new ViewImageLoader.Target() { @@ -412,14 +427,14 @@ public class UiUtils { for (CustomEmojiSpan span : emoji.getValue()) { span.setDrawable(d); } - view.invalidate(); + view.setText(view.getText()); } @Override public View getView() { return view; } - }, null, new UrlImageLoaderRequest(emoji.getKey().url, emojiSize, emojiSize), null, false, true); + }, null, new UrlImageLoaderRequest(emoji.getKey().url, 0, V.dp(20)), null, false, true); } } @@ -469,40 +484,61 @@ public class UiUtils { } public static void showConfirmationAlert(Context context, CharSequence title, CharSequence message, CharSequence confirmButton, int icon, Runnable onConfirmed) { + showConfirmationAlert(context, title, message, confirmButton, icon, onConfirmed, null); + } + + public static void showConfirmationAlert(Context context, CharSequence title, CharSequence message, CharSequence confirmButton, int icon, Runnable onConfirmed, Runnable onDenied){ new M3AlertDialogBuilder(context) .setTitle(title) .setMessage(message) - .setPositiveButton(confirmButton, (dlg, i) -> onConfirmed.run()) - .setNegativeButton(R.string.cancel, null) + .setPositiveButton(confirmButton, (dlg, i)->onConfirmed.run()) + .setNegativeButton(R.string.cancel, (dialog, which) -> { + if (onDenied != null) + onDenied.run(); + }) .setIcon(icon) .show(); } public static void confirmToggleBlockUser(Activity activity, String accountID, Account account, boolean currentlyBlocked, Consumer resultCallback) { - showConfirmationAlert(activity, activity.getString(currentlyBlocked ? R.string.confirm_unblock_title : R.string.confirm_block_title), - activity.getString(currentlyBlocked ? R.string.confirm_unblock : R.string.confirm_block, account.getDisplayName()), - activity.getString(currentlyBlocked ? R.string.do_unblock : R.string.do_block), - R.drawable.ic_fluent_person_prohibited_28_regular, - () -> { - new SetAccountBlocked(account.id, !currentlyBlocked) - .setCallback(new Callback<>() { - @Override - public void onSuccess(Relationship result) { - if (activity == null) return; - resultCallback.accept(result); - if (!currentlyBlocked) { - E.post(new RemoveAccountPostsEvent(accountID, account.id, false)); - } - } + if(!currentlyBlocked){ + new BlockAccountConfirmationSheet(activity, account, (onSuccess, onError)->{ + new SetAccountBlocked(account.id, true) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(Relationship result){ + resultCallback.accept(result); + onSuccess.run(); + E.post(new RemoveAccountPostsEvent(accountID, account.id, false)); + } - @Override - public void onError(ErrorResponse error) { - error.showToast(activity); - } - }) - .wrapProgress(activity, R.string.loading, false) - .exec(accountID); - }); + @Override + public void onError(ErrorResponse error){ + error.showToast(activity); + onError.run(); + } + }) + .exec(accountID); + }).show(); + }else{ + new SetAccountBlocked(account.id, false) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(Relationship result){ + resultCallback.accept(result); + new Snackbar.Builder(activity) + .setText(activity.getString(R.string.unblocked_user_x, account.getDisplayUsername())) + .show(); + } + + @Override + public void onError(ErrorResponse error){ + error.showToast(activity); + } + }) + .wrapProgress(activity, R.string.loading, false) + .exec(accountID); + } } public static void confirmSoftBlockUser(Activity activity, String accountID, Account account, Consumer resultCallback) { @@ -561,69 +597,112 @@ public class UiUtils { }); } public static void confirmToggleMuteUser(Context context, String accountID, Account account, boolean currentlyMuted, Consumer resultCallback){ - View durationView=LayoutInflater.from(context).inflate(R.layout.mute_user_dialog, null); - LinearLayout.LayoutParams params=new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); - params.setMargins(0, V.dp(-12), 0, 0); - durationView.setLayoutParams(params); - Button button=durationView.findViewById(R.id.button); - ((TextView) durationView.findViewById(R.id.message)).setText(context.getString(R.string.confirm_mute, account.getDisplayName())); + if(!currentlyMuted){ + //pass a references, so they can be changed inside the confirmation sheet + AtomicReference muteDuration=new AtomicReference<>(Duration.ZERO); + AtomicBoolean muteNotifications=new AtomicBoolean(true); + new MuteAccountConfirmationSheet(context, account, muteDuration, muteNotifications, (onSuccess, onError)->{ + new SetAccountMuted(account.id, true, muteDuration.get().getSeconds(), muteNotifications.get()) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(Relationship result){ + resultCallback.accept(result); + onSuccess.run(); + E.post(new RemoveAccountPostsEvent(accountID, account.id, false)); + } - AtomicReference muteDuration=new AtomicReference<>(Duration.ZERO); + @Override + public void onError(ErrorResponse error){ + error.showToast(context); + onError.run(); + } + }) + .exec(accountID); + }).show(); + }else{ + new SetAccountMuted(account.id, false, 0, false) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(Relationship result){ + resultCallback.accept(result); + new Snackbar.Builder(context) + .setText(context.getString(R.string.unmuted_user_x, account.getDisplayUsername())) + .show(); + } - PopupMenu popupMenu=new PopupMenu(context, button, Gravity.CENTER_HORIZONTAL); - popupMenu.inflate(R.menu.mute_duration); - popupMenu.setOnMenuItemClickListener(item->{ - int id=item.getItemId(); - if(id==R.id.duration_indefinite) - muteDuration.set(Duration.ZERO); - else if(id==R.id.duration_minutes_5){ - muteDuration.set(Duration.ofMinutes(5)); - }else if(id==R.id.duration_minutes_30){ - muteDuration.set(Duration.ofMinutes(30)); - }else if(id==R.id.duration_hours_1){ - muteDuration.set(Duration.ofHours(1)); - }else if(id==R.id.duration_hours_6){ - muteDuration.set(Duration.ofHours(6)); - }else if(id==R.id.duration_days_1){ - muteDuration.set(Duration.ofDays(1)); - }else if(id==R.id.duration_days_3){ - muteDuration.set(Duration.ofDays(3)); - }else if(id==R.id.duration_days_7){ - muteDuration.set(Duration.ofDays(7)); - } - button.setText(item.getTitle()); - return true; - }); - button.setOnTouchListener(popupMenu.getDragToOpenListener()); - button.setOnClickListener(v->popupMenu.show()); - button.setText(popupMenu.getMenu().getItem(0).getTitle()); + @Override + public void onError(ErrorResponse error){ + error.showToast(context); + } + }) + .wrapProgress(context, R.string.loading, false) + .exec(accountID); + } - new M3AlertDialogBuilder(context) - .setTitle(context.getString(currentlyMuted ? R.string.confirm_unmute_title : R.string.confirm_mute_title)) - .setMessage(currentlyMuted ? context.getString(R.string.confirm_unmute, account.getDisplayName()) : null) - .setView(currentlyMuted ? null : durationView) - .setPositiveButton(context.getString(currentlyMuted ? R.string.do_unmute : R.string.do_mute), (dlg, i)->{ - new SetAccountMuted(account.id, !currentlyMuted, muteDuration.get().getSeconds()) - .setCallback(new Callback<>(){ - @Override - public void onSuccess(Relationship result){ - resultCallback.accept(result); - if(!currentlyMuted){ - E.post(new RemoveAccountPostsEvent(accountID, account.id, false)); - } - } - - @Override - public void onError(ErrorResponse error){ - error.showToast(context); - } - }) - .wrapProgress(context, R.string.loading, false) - .exec(accountID); - }) - .setNegativeButton(R.string.cancel, null) - .setIcon(currentlyMuted ? R.drawable.ic_fluent_speaker_0_28_regular : R.drawable.ic_fluent_speaker_off_28_regular) - .show(); + // I need to readd the mute thing, so this is gonna stay as a comment for now +// View durationView=LayoutInflater.from(context).inflate(R.layout.mute_user_dialog, null); +// LinearLayout.LayoutParams params=new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); +// params.setMargins(0, V.dp(-12), 0, 0); +// durationView.setLayoutParams(params); +// Button button=durationView.findViewById(R.id.button); +// ((TextView) durationView.findViewById(R.id.message)).setText(context.getString(R.string.confirm_mute, account.getDisplayName())); +// +// AtomicReference muteDuration=new AtomicReference<>(Duration.ZERO); +// +// PopupMenu popupMenu=new PopupMenu(context, button, Gravity.CENTER_HORIZONTAL); +// popupMenu.inflate(R.menu.mute_duration); +// popupMenu.setOnMenuItemClickListener(item->{ +// int id=item.getItemId(); +// if(id==R.id.duration_indefinite) +// muteDuration.set(Duration.ZERO); +// else if(id==R.id.duration_minutes_5){ +// muteDuration.set(Duration.ofMinutes(5)); +// }else if(id==R.id.duration_minutes_30){ +// muteDuration.set(Duration.ofMinutes(30)); +// }else if(id==R.id.duration_hours_1){ +// muteDuration.set(Duration.ofHours(1)); +// }else if(id==R.id.duration_hours_6){ +// muteDuration.set(Duration.ofHours(6)); +// }else if(id==R.id.duration_days_1){ +// muteDuration.set(Duration.ofDays(1)); +// }else if(id==R.id.duration_days_3){ +// muteDuration.set(Duration.ofDays(3)); +// }else if(id==R.id.duration_days_7){ +// muteDuration.set(Duration.ofDays(7)); +// } +// button.setText(item.getTitle()); +// return true; +// }); +// button.setOnTouchListener(popupMenu.getDragToOpenListener()); +// button.setOnClickListener(v->popupMenu.show()); +// button.setText(popupMenu.getMenu().getItem(0).getTitle()); +// +// new M3AlertDialogBuilder(context) +// .setTitle(context.getString(currentlyMuted ? R.string.confirm_unmute_title : R.string.confirm_mute_title)) +// .setMessage(currentlyMuted ? context.getString(R.string.confirm_unmute, account.getDisplayName()) : null) +// .setView(currentlyMuted ? null : durationView) +// .setPositiveButton(context.getString(currentlyMuted ? R.string.do_unmute : R.string.do_mute), (dlg, i)->{ +// new SetAccountMuted(account.id, !currentlyMuted, muteDuration.get().getSeconds()) +// .setCallback(new Callback<>(){ +// @Override +// public void onSuccess(Relationship result){ +// resultCallback.accept(result); +// if(!currentlyMuted){ +// E.post(new RemoveAccountPostsEvent(accountID, account.id, false)); +// } +// } +// +// @Override +// public void onError(ErrorResponse error){ +// error.showToast(context); +// } +// }) +// .wrapProgress(context, R.string.loading, false) +// .exec(accountID); +// }) +// .setNegativeButton(R.string.cancel, null) +// .setIcon(currentlyMuted ? R.drawable.ic_fluent_speaker_2_28_regular : R.drawable.ic_fluent_speaker_off_28_regular) +// .show(); } public static void confirmDeletePost(Activity activity, String accountID, Status status, Consumer resultCallback, boolean forRedraft) { @@ -654,6 +733,32 @@ public class UiUtils { ); } + public static void confirmToggleMuteConversation(Activity activity, String accountID, Status status, Runnable resultCallback) { + showConfirmationAlert(activity, + status.muted ? R.string.mo_unmute_conversation : R.string.mo_mute_conversation, + status.muted ? R.string.mo_confirm_to_unmute_conversation : R.string.mo_confirm_to_mute_conversation, + status.muted ? R.string.do_unmute : R.string.do_mute, + status.muted ? R.drawable.ic_fluent_alert_28_regular : R.drawable.ic_fluent_alert_off_28_regular, + () -> new SetStatusMuted(status.id, !status.muted) + .setCallback(new Callback(){ + @Override + public void onSuccess(Status result){ + resultCallback.run(); + Toast.makeText(activity, result.muted ? R.string.mo_muted_conversation_successfully : R.string.mo_unmuted_conversation_successfully, Toast.LENGTH_SHORT).show(); + E.post(new StatusMuteChangedEvent(result)); + } + + @Override + public void onError(ErrorResponse error){ + error.showToast(activity); + } + }) + .wrapProgress(activity, status.muted ? R.string.mo_unmuting : R.string.mo_muting, false) + .exec(accountID) + + ); + } + public static void confirmDeleteScheduledPost(Activity activity, String accountID, ScheduledStatus status, Runnable resultCallback) { boolean isDraft = status.scheduledAt.isAfter(CreateStatus.DRAFTS_AFTER_INSTANT); showConfirmationAlert(activity, @@ -734,8 +839,9 @@ public class UiUtils { activity.getString(R.string.delete), R.drawable.ic_fluent_delete_28_regular, () -> new DeleteList(listID).setCallback(new Callback<>() { + @Override - public void onSuccess(Object o) { + public void onSuccess(Void result){ callback.run(); } @@ -819,7 +925,7 @@ public class UiUtils { .exec(accountID); }; if(relationship.following && GlobalUserPreferences.confirmUnfollow){ - showConfirmationAlert(activity, null, activity.getString(R.string.unfollow_confirmation, account.getDisplayUsername()), activity.getString(R.string.unfollow), R.drawable.ic_fluent_person_delete_24_regular, action); + showConfirmationAlert(activity, activity.getString(R.string.unfollow), activity.getString(R.string.unfollow_confirmation, account.getDisplayUsername()), activity.getString(R.string.unfollow), R.drawable.ic_fluent_person_delete_24_regular, action); }else{ action.run(); } @@ -827,17 +933,20 @@ public class UiUtils { } - public static void handleFollowRequest(Activity activity, Account account, String accountID, @Nullable String notificationID, boolean accepted, Relationship relationship, Consumer resultCallback) { + public static void handleFollowRequest(Activity activity, Account account, String accountID, @Nullable String notificationID, boolean accepted, Relationship relationship, Consumer progressCallback, Consumer resultCallback) { + progressCallback.accept(true); if (accepted) { new AuthorizeFollowRequest(account.id).setCallback(new Callback<>() { @Override public void onSuccess(Relationship rel) { E.post(new FollowRequestHandledEvent(accountID, true, account, rel)); + progressCallback.accept(false); resultCallback.accept(rel); } @Override public void onError(ErrorResponse error) { + progressCallback.accept(false); resultCallback.accept(relationship); error.showToast(activity); } @@ -849,11 +958,13 @@ public class UiUtils { E.post(new FollowRequestHandledEvent(accountID, false, account, rel)); if (notificationID != null) E.post(new NotificationDeletedEvent(notificationID)); + progressCallback.accept(false); resultCallback.accept(rel); } @Override public void onError(ErrorResponse error) { + progressCallback.accept(false); resultCallback.accept(relationship); error.showToast(activity); } @@ -965,6 +1076,12 @@ public class UiUtils { public static void enablePopupMenuIcons(Context context, PopupMenu menu) { Menu m = menu.getMenu(); + + // MOSHIDON disable menu icons on android 14 and higher because of InsetDrawables breaking + if (Build.VERSION.SDK_INT >= 34) { + return; + } + if (Build.VERSION.SDK_INT >= 29) { menu.setForceShowIcon(true); } else { @@ -1057,6 +1174,8 @@ public class UiUtils { // // COPIED FROM https://github.com/tuskyapp/Tusky/blob/develop/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt public static boolean looksLikeFediverseUrl(String urlString) { + if(urlString == null) + return false; URI uri; try { uri = new URI(urlString); @@ -1090,18 +1209,9 @@ public class UiUtils { } public static void pickAccount(Context context, String exceptFor, @StringRes int titleRes, @DrawableRes int iconRes, Consumer sessionConsumer, Consumer transformDialog) { - List sessions = AccountSessionManager.getInstance().getLoggedInAccounts() - .stream().filter(s -> !s.getID().equals(exceptFor)).collect(Collectors.toList()); - - AlertDialog.Builder builder = new M3AlertDialogBuilder(context) - .setItems( - sessions.stream().map(AccountSession::getFullUsername).toArray(String[]::new), - (dialog, which) -> sessionConsumer.accept(sessions.get(which)) - ) - .setTitle(titleRes == 0 ? R.string.choose_account : titleRes) - .setIcon(iconRes); - if (transformDialog != null) transformDialog.accept(builder); - builder.show(); + AccountSwitcherSheet sheet = new AccountSwitcherSheet((Activity) context, null, iconRes, titleRes == 0 ? R.string.choose_account : titleRes, exceptFor, false); + sheet.setOnClick((accountId, open) ->sessionConsumer.accept(AccountSessionManager.get(accountId))); + sheet.show(); } public static void restartApp() { @@ -1355,7 +1465,7 @@ public class UiUtils { return; } Optional account = results.accounts.stream() - .filter(a -> uri.equals(Uri.parse(a.url))).findAny(); + .filter(a -> uri.getPath().contains(a.username)).findAny(); if (account.isPresent()) { args.putParcelable("profileAccount", Parcels.wrap(account.get())); go.accept(ProfileFragment.class, args); @@ -1377,6 +1487,8 @@ public class UiUtils { } public static void copyText(View v, String text) { + if(GlobalUserPreferences.removeTrackingParams) + text=Tracking.cleanUrlsInText(text); Context context = v.getContext(); context.getSystemService(ClipboardManager.class).setPrimaryClip(ClipData.newPlainText(null, text)); if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || UiUtils.isMIUI()) { // Android 13+ SystemUI shows its own thing when you put things into the clipboard @@ -1403,6 +1515,10 @@ public class UiUtils { return !TextUtils.isEmpty(getSystemProperty("ro.build.version.emui")); } + public static boolean isMagic() { + return !TextUtils.isEmpty(getSystemProperty("ro.build.version.magic")); + } + public static int alphaBlendColors(int color1, int color2, float alpha) { float alpha0 = 1f - alpha; int r = Math.round(((color1 >> 16) & 0xFF) * alpha0 + ((color2 >> 16) & 0xFF) * alpha); @@ -1423,7 +1539,7 @@ public class UiUtils { public static boolean pickAccountForCompose(Activity activity, String accountID, Bundle args) { if (AccountSessionManager.getInstance().getLoggedInAccounts().size() > 1) { - UiUtils.pickAccount(activity, accountID, 0, 0, session -> { + UiUtils.pickAccount(activity, accountID, 0, R.drawable.ic_fluent_compose_28_regular, session -> { args.putString("account", session.getID()); Nav.go(activity, ComposeFragment.class, args); }, null); @@ -1525,17 +1641,6 @@ public class UiUtils { return intent; } - public static void populateAccountsMenu(String excludeAccountID, Menu menu, Consumer onClick) { - List sessions=AccountSessionManager.getInstance().getLoggedInAccounts(); - sessions.stream().filter(s -> !s.getID().equals(excludeAccountID)).forEach(s -> { - String username = "@"+s.self.username+"@"+s.domain; - menu.add(username).setOnMenuItemClickListener((c) -> { - onClick.accept(s); - return true; - }); - }); - } - public static void showFragmentForNotification(Context context, Notification n, String accountID, Bundle extras) { if (extras == null) extras = new Bundle(); extras.putString("account", accountID); @@ -1607,6 +1712,17 @@ public class UiUtils { return insets; } + public static void applyBottomInsetToFAB(View fab, WindowInsets insets){ + int inset; + if(Build.VERSION.SDK_INT>=29 && insets.getTappableElementInsets().bottom==0 /*&& wantsOverlaySystemNavigation()*/){ + int bottomInset=insets.getSystemWindowInsetBottom(); + inset=bottomInset>0 ? Math.max(V.dp(40), bottomInset) : 0; + }else{ + inset=0; + } + ((ViewGroup.MarginLayoutParams) fab.getLayoutParams()).bottomMargin=V.dp(16)+inset; + } + public static String formatDuration(Context context, int seconds){ if(seconds<3600){ int minutes=seconds/60; @@ -1623,10 +1739,48 @@ public class UiUtils { } } - public static void openSystemShareSheet(Context context, String url){ + public static Uri getFileProviderUri(Context context, File file){ + return FileProvider.getUriForFile(context, context.getPackageName()+".fileprovider", file); + } + + public static void openSystemShareSheet(Context context, Object obj){ Intent intent=new Intent(Intent.ACTION_SEND); intent.setType("text/plain"); + Account account; + String url; + String previewTitle; + + if(obj instanceof Account acc){ + account=acc; + url=acc.url; + previewTitle=context.getString(R.string.share_sheet_preview_profile, account.displayName); + }else if(obj instanceof Status st){ + account=st.account; + url=st.url; + String postText=st.getStrippedText(); + if(TextUtils.isEmpty(postText)){ + previewTitle=context.getString(R.string.share_sheet_preview_profile, account.displayName); + }else{ + if(postText.length()>100) + postText=postText.substring(0, 100)+"..."; + previewTitle=context.getString(R.string.share_sheet_preview_post, account.displayName, postText); + } + }else{ + throw new IllegalArgumentException("Unsupported share object type"); + } + intent.putExtra(Intent.EXTRA_TEXT, url); + intent.putExtra(Intent.EXTRA_TITLE, previewTitle); + ImageCache cache=ImageCache.getInstance(context); + try{ + File ava=cache.getFile(new UrlImageLoaderRequest(account.avatarStatic)); + if(ava==null || !ava.exists()) + ava=cache.getFile(new UrlImageLoaderRequest(account.avatar)); + if(ava!=null && ava.exists()){ + intent.setClipData(ClipData.newRawUri(null, getFileProviderUri(context, ava))); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + } + }catch(IOException ignore){} context.startActivity(Intent.createChooser(intent, context.getString(R.string.share_toot_title))); } @@ -1677,6 +1831,31 @@ public class UiUtils { return text; } + public static void goToInstanceAboutFragment(String instanceUrl, String accountID ,Context context){ + try { + new GetInstance() + .setCallback(new Callback<>(){ + @Override + public void onSuccess(Instance result){ + Bundle args = new Bundle(); + args.putParcelable("instance", Parcels.wrap(result)); + args.putString("account", accountID); + Nav.go((Activity) context, SettingsServerFragment.class, args); + } + + @Override + public void onError(ErrorResponse error){ + error.showToast(context); + } + }) + .wrapProgress((Activity) context, R.string.loading, true) + .execRemote(instanceUrl); + } catch (NullPointerException ignored) { + // maybe the url was malformed? + Toast.makeText(context, R.string.error, Toast.LENGTH_SHORT).show(); + } + } + private static final String[] pronounsUrls= new String[] { "pronouns.within.lgbt/", "pronouns.cc/pronouns/", @@ -1774,4 +1953,72 @@ public class UiUtils { public static ViewPropertyAnimator opacityOut(View v, float alpha){ return v.animate().alpha(alpha).setDuration(300).setInterpolator(CubicBezierInterpolator.DEFAULT); } + + public static void maybeShowTextCopiedToast(Context context){ + //show toast, android from S_V2 on has built-in popup, as documented in + //https://developer.android.com/develop/ui/views/touch-and-input/copy-paste#duplicate-notifications + if(Build.VERSION.SDK_INT<=Build.VERSION_CODES.S_V2){ + Toast.makeText(context, R.string.text_copied, Toast.LENGTH_SHORT).show(); + } + } + + public static boolean needShowClipboardToast(){ + return Build.VERSION.SDK_INT<=Build.VERSION_CODES.S_V2; + } + + public static void setAllPaddings(View view, int paddingDp){ + int pad=V.dp(paddingDp); + view.setPadding(pad, pad, pad, pad); + } + + public static ViewGroup.MarginLayoutParams makeLayoutParams(int width, int height, int marginStart, int marginTop, int marginEnd, int marginBottom){ + ViewGroup.MarginLayoutParams lp=new ViewGroup.MarginLayoutParams(width>0 ? V.dp(width) : width, height>0 ? V.dp(height) : height); + lp.topMargin=V.dp(marginTop); + lp.bottomMargin=V.dp(marginBottom); + lp.setMarginStart(V.dp(marginStart)); + lp.setMarginEnd(V.dp(marginEnd)); + return lp; + } + + public static CharSequence fixBulletListInString(Context context, @StringRes int res){ + SpannableStringBuilder msg=new SpannableStringBuilder(context.getText(res)); + BulletSpan[] spans=msg.getSpans(0, msg.length(), BulletSpan.class); + for(BulletSpan span:spans){ + BulletSpan betterSpan; + if(Build.VERSION.SDK_INTt1.name.equals(t2.name)); + UiUtils.updateList(oldList, hashtags, list, hashtagsAdapter, (t1, t2)->{ + if(t1 != null && t2 != null) + return t1.name.equals(t2.name); + else { + return false; + } + }); list.postDelayed(hashtagsDebouncer, 300); }else if(mode==Mode.EMOJIS){ @@ -278,7 +284,7 @@ public class ComposeAutocompleteViewController{ @Override public void onSuccess(SearchResults result){ currentRequest=null; - if(result.hashtags.isEmpty() || (result.hashtags.size()==1 && result.hashtags.get(0).name.equals(lastText.substring(1)))) + if(result.hashtags.isEmpty() || (result.hashtags.size()==1 && result.hashtags.get(0).name.equals(lastText.substring(1))) || mode!=Mode.HASHTAGS) return; List oldList=hashtags; hashtags=result.hashtags; diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/ComposeLanguageAlertViewController.java b/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/ComposeLanguageAlertViewController.java index 53016ffa1..bd7073885 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/ComposeLanguageAlertViewController.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/ComposeLanguageAlertViewController.java @@ -115,7 +115,7 @@ public class ComposeLanguageAlertViewController{ int i=0; boolean found=false; for(SpecialLocaleInfo li:specialLocales){ - if(previouslySelected.language != null && previouslySelected.language.equals(li.language)){ + if(null!=li.language&&li.language.equals(previouslySelected.language)){ selectedLocale=li.language; selectedIndex=i; found=true; diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/ComposeMediaViewController.java b/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/ComposeMediaViewController.java index 75c157d9c..7aee0ea29 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/ComposeMediaViewController.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/ComposeMediaViewController.java @@ -124,7 +124,7 @@ public class ComposeMediaViewController{ updateMediaAttachmentsLayout(); } } - + public boolean addMediaAttachment(Uri uri, String description){ if(getMediaAttachmentsCount()==MAX_ATTACHMENTS){ showMediaAttachmentError(fragment.getResources().getQuantityString(R.plurals.cant_add_more_than_x_attachments, MAX_ATTACHMENTS, MAX_ATTACHMENTS)); diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/ComposePollViewController.java b/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/ComposePollViewController.java index 9c3b64e7f..d4415b538 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/ComposePollViewController.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/ComposePollViewController.java @@ -82,9 +82,9 @@ public class ComposePollViewController{ maxPollOptionLength=instance.configuration.polls.maxCharactersPerOption; } else { if(instance!=null && instance.pollLimits!=null && instance.pollLimits.maxOptions>0) - maxPollOptions=instance.pollLimits.maxOptions; + maxPollOptions= (int) instance.pollLimits.maxOptions; if(instance!=null && instance.pollLimits!=null && instance.pollLimits.maxOptionChars>0) - maxPollOptionLength=instance.pollLimits.maxOptionChars; + maxPollOptionLength= (int) instance.pollLimits.maxOptionChars; } pollOptionsView=pollWrap.findViewById(R.id.poll_options); diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/DropdownSubmenuController.java b/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/DropdownSubmenuController.java new file mode 100644 index 000000000..4cfe3abaf --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/DropdownSubmenuController.java @@ -0,0 +1,190 @@ +package org.joinmastodon.android.ui.viewcontrollers; + +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Rect; +import android.text.TextUtils; +import android.view.View; +import android.view.ViewGroup; +import android.view.accessibility.AccessibilityNodeInfo; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.ui.BetterItemAnimator; +import org.joinmastodon.android.ui.utils.HideableSingleViewRecyclerAdapter; +import org.joinmastodon.android.ui.utils.UiUtils; + +import java.util.List; +import java.util.function.Consumer; + +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.StringRes; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import me.grishka.appkit.utils.BindableViewHolder; +import me.grishka.appkit.utils.MergeRecyclerAdapter; +import me.grishka.appkit.utils.V; +import me.grishka.appkit.views.UsableRecyclerView; + +public abstract class DropdownSubmenuController{ + protected List> items; + protected LinearLayout contentView; + protected UsableRecyclerView list; + protected TextView backItem; + protected final ToolbarDropdownMenuController dropdownController; + protected MergeRecyclerAdapter mergeAdapter; + protected ItemsAdapter itemsAdapter; + + public DropdownSubmenuController(ToolbarDropdownMenuController dropdownController){ + this.dropdownController=dropdownController; + } + + protected abstract CharSequence getBackItemTitle(); + public void onDismiss(){} + + protected void createView(){ + contentView=new LinearLayout(dropdownController.getActivity()); + contentView.setOrientation(LinearLayout.VERTICAL); + CharSequence backTitle=getBackItemTitle(); + if(!TextUtils.isEmpty(backTitle)){ + backItem=(TextView) dropdownController.getActivity().getLayoutInflater().inflate(R.layout.item_dropdown_menu, contentView, false); + ((LinearLayout.LayoutParams) backItem.getLayoutParams()).topMargin=V.dp(8); + backItem.setText(backTitle); + backItem.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_fluent_arrow_left_24_regular, 0, 0, 0); + backItem.setBackground(UiUtils.getThemeDrawable(dropdownController.getActivity(), android.R.attr.selectableItemBackground)); + backItem.setOnClickListener(v->dropdownController.popSubmenuController()); + backItem.setAccessibilityDelegate(new View.AccessibilityDelegate(){ + @Override + public void onInitializeAccessibilityNodeInfo(@NonNull View host, @NonNull AccessibilityNodeInfo info){ + super.onInitializeAccessibilityNodeInfo(host, info); + info.setText(info.getText()+". "+host.getResources().getString(R.string.back)); + } + }); + contentView.addView(backItem); + } + list=new UsableRecyclerView(dropdownController.getActivity()); + list.setLayoutManager(new LinearLayoutManager(dropdownController.getActivity())); + itemsAdapter=new ItemsAdapter(); + mergeAdapter=new MergeRecyclerAdapter(); + mergeAdapter.addAdapter(itemsAdapter); + list.setAdapter(mergeAdapter); + list.setPadding(0, backItem!=null ? 0 : V.dp(8), 0, V.dp(8)); + list.setClipToPadding(false); + list.setItemAnimator(new BetterItemAnimator()); + list.addItemDecoration(new RecyclerView.ItemDecoration(){ + private final Paint paint=new Paint(); + { + paint.setStyle(Paint.Style.STROKE); + paint.setStrokeWidth(V.dp(1)); + paint.setColor(UiUtils.getThemeColor(dropdownController.getActivity(), R.attr.colorM3OutlineVariant)); + } + + @Override + public void onDraw(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull RecyclerView.State state){ + for(int i=0;i{ + public final String title; + public final boolean hasSubmenu; + public final boolean dividerBefore; + public final T parentObject; + public final Consumer> onClick; + + public Item(String title, boolean hasSubmenu, boolean dividerBefore, T parentObject, Consumer> onClick){ + this.title=title; + this.hasSubmenu=hasSubmenu; + this.dividerBefore=dividerBefore; + this.parentObject=parentObject; + this.onClick=onClick; + } + + public Item(String title, boolean hasSubmenu, boolean dividerBefore, Consumer> onClick){ + this(title, hasSubmenu, dividerBefore, null, onClick); + } + + public Item(@StringRes int titleRes, boolean hasSubmenu, boolean dividerBefore, Consumer> onClick){ + this(dropdownController.getActivity().getString(titleRes), hasSubmenu, dividerBefore, null, onClick); + } + + private void performClick(){ + onClick.accept(this); + } + } + + protected class ItemsAdapter extends RecyclerView.Adapter{ + + @NonNull + @Override + public ItemHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){ + return new ItemHolder(); + } + + @Override + public void onBindViewHolder(@NonNull ItemHolder holder, int position){ + holder.bind(items.get(position)); + } + + @Override + public int getItemCount(){ + return items.size(); + } + } + + private class ItemHolder extends BindableViewHolder> implements UsableRecyclerView.Clickable{ + private final TextView text; + + public ItemHolder(){ + super(dropdownController.getActivity(), R.layout.item_dropdown_menu, list); + text=(TextView) itemView; + } + + @Override + public void onBind(Item item){ + text.setText(item.title); + text.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, item.hasSubmenu ? R.drawable.ic_arrow_right_24px : 0, 0); + } + + @Override + public void onClick(){ + item.performClick(); + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/HomeTimelineHashtagsMenuController.java b/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/HomeTimelineHashtagsMenuController.java new file mode 100644 index 000000000..74f4698e5 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/HomeTimelineHashtagsMenuController.java @@ -0,0 +1,111 @@ +package org.joinmastodon.android.ui.viewcontrollers; + +import android.app.Activity; +import android.os.Bundle; +import android.view.Gravity; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.ProgressBar; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.api.requests.tags.GetFollowedTags; +import org.joinmastodon.android.fragments.ManageFollowedHashtagsFragment; +import org.joinmastodon.android.model.Hashtag; +import org.joinmastodon.android.model.HeaderPaginationList; +import org.joinmastodon.android.ui.utils.HideableSingleViewRecyclerAdapter; +import org.joinmastodon.android.ui.utils.UiUtils; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +import androidx.recyclerview.widget.RecyclerView; +import me.grishka.appkit.Nav; +import me.grishka.appkit.api.APIRequest; +import me.grishka.appkit.api.Callback; +import me.grishka.appkit.api.ErrorResponse; +import me.grishka.appkit.utils.V; + +public class HomeTimelineHashtagsMenuController extends DropdownSubmenuController{ + private HideableSingleViewRecyclerAdapter largeProgressAdapter; + private HideableSingleViewRecyclerAdapter emptyAdapter; + private APIRequest currentRequest; + + public HomeTimelineHashtagsMenuController(ToolbarDropdownMenuController dropdownController){ + super(dropdownController); + items=new ArrayList<>(); + loadHashtags(); + } + + @Override + protected void createView(){ + super.createView(); + emptyAdapter=createEmptyView(R.drawable.ic_fluent_tag_24_regular, R.string.no_followed_hashtags_title, R.string.no_followed_hashtags_subtitle); + FrameLayout largeProgressView=new FrameLayout(dropdownController.getActivity()); + int pad=V.dp(32); + largeProgressView.setPadding(0, pad, 0, pad); + largeProgressView.setLayoutParams(new RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + ProgressBar progress=new ProgressBar(dropdownController.getActivity()); + largeProgressView.addView(progress, new FrameLayout.LayoutParams(V.dp(48), V.dp(48), Gravity.CENTER)); + largeProgressAdapter=new HideableSingleViewRecyclerAdapter(largeProgressView); + mergeAdapter.addAdapter(0, largeProgressAdapter); + emptyAdapter.setVisible(false); + mergeAdapter.addAdapter(0, emptyAdapter); + } + + @Override + protected CharSequence getBackItemTitle(){ + return dropdownController.getActivity().getString(R.string.followed_hashtags); + } + + @Override + public void onDismiss(){ + if(currentRequest!=null){ + currentRequest.cancel(); + currentRequest=null; + } + } + + private void onTagClick(Item item){ + dropdownController.dismiss(); + UiUtils.openHashtagTimeline(dropdownController.getActivity(), dropdownController.getAccountID(), item.parentObject); + } + + private void onManageTagsClick(){ + dropdownController.dismiss(); + Bundle args=new Bundle(); + args.putString("account", dropdownController.getAccountID()); + Nav.go(dropdownController.getActivity(), ManageFollowedHashtagsFragment.class, args); + } + + private void loadHashtags(){ + currentRequest=new GetFollowedTags(null, 200) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(HeaderPaginationList result){ + currentRequest=null; + dropdownController.resizeOnNextFrame(); + largeProgressAdapter.setVisible(false); + ((List) result).sort(Comparator.comparing(tag->tag.name)); + int prevSize=items.size(); + for(Hashtag tag:result){ + items.add(new Item<>("#"+tag.name, false, false, tag, HomeTimelineHashtagsMenuController.this::onTagClick)); + } + items.add(new Item(R.string.manage_hashtags, false, true, i->onManageTagsClick())); + itemsAdapter.notifyItemRangeInserted(prevSize, result.size()+1); + emptyAdapter.setVisible(result.isEmpty()); + } + + @Override + public void onError(ErrorResponse error){ + currentRequest=null; + Activity activity=dropdownController.getActivity(); + if(activity!=null) + error.showToast(activity); + dropdownController.popSubmenuController(); + + } + }) + .exec(dropdownController.getAccountID()); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/HomeTimelineListsMenuController.java b/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/HomeTimelineListsMenuController.java new file mode 100644 index 000000000..22c78468a --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/HomeTimelineListsMenuController.java @@ -0,0 +1,61 @@ +package org.joinmastodon.android.ui.viewcontrollers; + +import android.os.Bundle; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.fragments.CreateListFragment; +import org.joinmastodon.android.fragments.ManageListsFragment; +import org.joinmastodon.android.model.FollowList; +import org.joinmastodon.android.ui.utils.HideableSingleViewRecyclerAdapter; + +import java.util.ArrayList; +import java.util.List; + +import me.grishka.appkit.Nav; + +public class HomeTimelineListsMenuController extends DropdownSubmenuController{ + private final List lists; + private final HomeTimelineMenuController.Callback callback; + private HideableSingleViewRecyclerAdapter emptyAdapter; + + public HomeTimelineListsMenuController(ToolbarDropdownMenuController dropdownController, HomeTimelineMenuController.Callback callback){ + super(dropdownController); + this.lists=new ArrayList<>(callback.getLists()); + this.callback=callback; + items=new ArrayList<>(); + for(FollowList l:lists){ + items.add(new Item<>(l.title, false, false, l, this::onListSelected)); + } + items.add(new Item(dropdownController.getActivity().getString(R.string.create_list), false, true, i->{ + dropdownController.dismiss(); + Bundle args=new Bundle(); + args.putString("account", dropdownController.getAccountID()); + Nav.go(dropdownController.getActivity(), CreateListFragment.class, args); + })); + items.add(new Item(dropdownController.getActivity().getString(R.string.manage_lists), false, false, i->{ + dropdownController.dismiss(); + Bundle args=new Bundle(); + args.putString("account", dropdownController.getAccountID()); + Nav.go(dropdownController.getActivity(), ManageListsFragment.class, args); + })); + } + + @Override + protected CharSequence getBackItemTitle(){ + return dropdownController.getActivity().getString(R.string.lists); + } + + @Override + protected void createView(){ + super.createView(); + emptyAdapter=createEmptyView(R.drawable.ic_list_alt_24px, R.string.no_lists_title, R.string.no_lists_subtitle); + if(lists.isEmpty()){ + mergeAdapter.addAdapter(0, emptyAdapter); + } + } + + private void onListSelected(Item item){ + callback.onListSelected(item.parentObject); + dropdownController.dismiss(); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/HomeTimelineMenuController.java b/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/HomeTimelineMenuController.java new file mode 100644 index 000000000..2391d5b7b --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/HomeTimelineMenuController.java @@ -0,0 +1,39 @@ +package org.joinmastodon.android.ui.viewcontrollers; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.model.FollowList; + +import java.util.List; + +public class HomeTimelineMenuController extends DropdownSubmenuController{ + private Callback callback; + + public HomeTimelineMenuController(ToolbarDropdownMenuController dropdownController, Callback callback){ + super(dropdownController); + this.callback=callback; + items=List.of( + new Item(R.string.timeline_following, false, false, i->{ + callback.onFollowingSelected(); + dropdownController.dismiss(); + }), + new Item(R.string.local_timeline, false, false, i->{ + callback.onLocalSelected(); + dropdownController.dismiss(); + }), + new Item(R.string.lists, true, true, i->dropdownController.pushSubmenuController(new HomeTimelineListsMenuController(dropdownController, callback))), + new Item(R.string.followed_hashtags, true, false, i->dropdownController.pushSubmenuController(new HomeTimelineHashtagsMenuController(dropdownController))) + ); + } + + @Override + protected CharSequence getBackItemTitle(){ + return null; + } + + public interface Callback{ + void onFollowingSelected(); + void onLocalSelected(); + List getLists(); + void onListSelected(FollowList list); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/ToolbarDropdownMenuController.java b/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/ToolbarDropdownMenuController.java new file mode 100644 index 000000000..520ef51ef --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/ToolbarDropdownMenuController.java @@ -0,0 +1,270 @@ +package org.joinmastodon.android.ui.viewcontrollers; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; +import android.animation.ValueAnimator; +import android.app.Activity; +import android.content.Context; +import android.content.res.Resources; +import android.graphics.PixelFormat; +import android.graphics.Rect; +import android.view.Gravity; +import android.view.KeyEvent; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewTreeObserver; +import android.view.WindowManager; +import android.widget.FrameLayout; +import android.widget.Toolbar; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.ui.OutlineProviders; + +import java.util.ArrayList; +import java.util.List; + +import androidx.annotation.NonNull; +import me.grishka.appkit.utils.CubicBezierInterpolator; +import me.grishka.appkit.utils.V; + +public class ToolbarDropdownMenuController{ + private final HostFragment fragment; + private FrameLayout windowView; + private FrameLayout menuContainer; + private boolean dismissing; + private List controllerStack=new ArrayList<>(); + private Animator currentTransition; + + public ToolbarDropdownMenuController(HostFragment fragment){ + this.fragment=fragment; + } + + public void show(DropdownSubmenuController initialSubmenu){ + if(windowView!=null) + return; + + menuContainer=new FrameLayout(fragment.getActivity()); + menuContainer.setBackgroundResource(R.drawable.bg_m3_surface2); + menuContainer.setOutlineProvider(OutlineProviders.roundedRect(4)); + menuContainer.setClipToOutline(true); + menuContainer.setElevation(V.dp(6)); + View menuView=initialSubmenu.getView(); + menuView.setVisibility(View.VISIBLE); + menuContainer.addView(menuView, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + + windowView=new WindowView(fragment.getActivity()); + int pad=V.dp(16); + windowView.setPadding(pad, fragment.getToolbar().getHeight(), pad, pad); + windowView.setClipToPadding(false); + windowView.addView(menuContainer, new FrameLayout.LayoutParams(V.dp(200), ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.TOP | Gravity.START)); + + WindowManager.LayoutParams wlp=new WindowManager.LayoutParams(WindowManager.LayoutParams.TYPE_APPLICATION_PANEL); + wlp.format=PixelFormat.TRANSLUCENT; + wlp.token=fragment.getActivity().getWindow().getDecorView().getWindowToken(); + wlp.width=wlp.height=ViewGroup.LayoutParams.MATCH_PARENT; + wlp.flags=WindowManager.LayoutParams.FLAG_LAYOUT_ATTACHED_IN_DECOR; + wlp.setTitle(fragment.getActivity().getString(R.string.dropdown_menu)); + fragment.getActivity().getWindowManager().addView(windowView, wlp); + + menuContainer.setPivotX(V.dp(100)); + menuContainer.setPivotY(0); + menuContainer.setScaleX(.8f); + menuContainer.setScaleY(.8f); + menuContainer.setAlpha(0f); + menuContainer.animate() + .scaleX(1f) + .scaleY(1f) + .alpha(1f) + .setInterpolator(CubicBezierInterpolator.DEFAULT) + .setDuration(150) + .withLayer() + .start(); + controllerStack.add(initialSubmenu); + } + + public void dismiss(){ + if(windowView==null || dismissing) + return; + dismissing=true; + fragment.onDropdownWillDismiss(); + menuContainer.animate() + .scaleX(.8f) + .scaleY(.8f) + .alpha(0f) + .setInterpolator(CubicBezierInterpolator.DEFAULT) + .setDuration(150) + .withLayer() + .withEndAction(()->{ + controllerStack.clear(); + fragment.getActivity().getWindowManager().removeView(windowView); + menuContainer.removeAllViews(); + dismissing=false; + windowView=null; + menuContainer=null; + fragment.onDropdownDismissed(); + }) + .start(); + } + + public void pushSubmenuController(DropdownSubmenuController controller){ + View prevView=menuContainer.getChildAt(menuContainer.getChildCount()-1); + View newView=controller.getView(); + newView.setVisibility(View.VISIBLE); + menuContainer.addView(newView, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + controllerStack.add(controller); + animateTransition(prevView, newView, true); + } + + public void popSubmenuController(){ + if(menuContainer.getChildCount()<=1) + throw new IllegalStateException(); + DropdownSubmenuController controller=controllerStack.remove(controllerStack.size()-1); + controller.onDismiss(); + View top=menuContainer.getChildAt(menuContainer.getChildCount()-1); + View prev=menuContainer.getChildAt(menuContainer.getChildCount()-2); + prev.setVisibility(View.VISIBLE); + animateTransition(prev, top, false); + } + + private void animateTransition(View bottomView, View topView, boolean adding){ + if(currentTransition!=null) + currentTransition.cancel(); + int origBottom=menuContainer.getBottom(); + menuContainer.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener(){ + private final Rect tmpRect=new Rect(); + + @Override + public boolean onPreDraw(){ + menuContainer.getViewTreeObserver().removeOnPreDrawListener(this); + + AnimatorSet set=new AnimatorSet(); + ObjectAnimator slideIn; + set.playTogether( + ObjectAnimator.ofInt(menuContainer, "bottom", origBottom, menuContainer.getTop()+(adding ? topView : bottomView).getHeight()), + slideIn=ObjectAnimator.ofFloat(topView, View.TRANSLATION_X, adding ? menuContainer.getWidth() : 0, adding ? 0 : menuContainer.getWidth()), + ObjectAnimator.ofFloat(bottomView, View.TRANSLATION_X, adding ? 0 : -menuContainer.getWidth()/4f, adding ? -menuContainer.getWidth()/4f : 0), + ObjectAnimator.ofFloat(bottomView, View.ALPHA, adding ? 1f : 0f, adding ? 0f : 1f) + ); + set.setDuration(300); + set.setInterpolator(CubicBezierInterpolator.DEFAULT); + set.addListener(new AnimatorListenerAdapter(){ + @Override + public void onAnimationEnd(Animator animation){ + bottomView.setClipBounds(null); + bottomView.setTranslationX(0); + bottomView.setAlpha(1f); + topView.setTranslationX(0); + topView.setAlpha(1f); + if(adding){ + bottomView.setVisibility(View.GONE); + }else{ + menuContainer.removeView(topView); + } + currentTransition=null; + } + }); + slideIn.addUpdateListener(animation->{ + tmpRect.set(0, 0, Math.round(topView.getX()-bottomView.getX()), bottomView.getHeight()); + bottomView.setClipBounds(tmpRect); + }); + currentTransition=set; + set.start(); + + return true; + } + }); + } + + public void resizeOnNextFrame(){ + if(currentTransition!=null) + currentTransition.cancel(); + if(windowView==null) + return; + int origBottom=menuContainer.getBottom(); + menuContainer.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener(){ + @Override + public boolean onPreDraw(){ + menuContainer.getViewTreeObserver().removeOnPreDrawListener(this); + + ObjectAnimator anim=ObjectAnimator.ofInt(menuContainer, "bottom", origBottom, menuContainer.getBottom()); + anim.setDuration(300); + anim.setInterpolator(CubicBezierInterpolator.DEFAULT); + anim.addListener(new AnimatorListenerAdapter(){ + @Override + public void onAnimationEnd(Animator animation){ + currentTransition=null; + } + }); + currentTransition=anim; + anim.start(); + + return true; + } + }); + } + + Activity getActivity(){ + return fragment.getActivity(); + } + + String getAccountID(){ + return fragment.getAccountID(); + } + + private class WindowView extends FrameLayout{ + private final Rect tmpRect=new Rect(); + public WindowView(@NonNull Context context){ + super(context); + } + + @Override + public boolean onTouchEvent(MotionEvent ev){ + for(int i=0;i1) + popSubmenuController(); + else + dismiss(); + } + return true; + } + return super.dispatchKeyEvent(event); + } + } + + public interface HostFragment{ + // Fragment methods + Activity getActivity(); + Resources getResources(); + Toolbar getToolbar(); + String getAccountID(); + + // Callbacks + void onDropdownWillDismiss(); + void onDropdownDismissed(); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/AccountViewHolder.java b/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/AccountViewHolder.java index 62074ae7b..a18175f76 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/AccountViewHolder.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/AccountViewHolder.java @@ -16,6 +16,7 @@ import android.view.View; import android.view.ViewGroup; import android.widget.CheckBox; import android.widget.FrameLayout; +import android.widget.ImageButton; import android.widget.ImageView; import android.widget.PopupMenu; import android.widget.ProgressBar; @@ -26,7 +27,7 @@ import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.R; import org.joinmastodon.android.api.requests.accounts.SetAccountFollowed; import org.joinmastodon.android.api.session.AccountSessionManager; -import org.joinmastodon.android.fragments.ListsFragment; +import org.joinmastodon.android.fragments.AddAccountToListsFragment; import org.joinmastodon.android.fragments.ProfileFragment; import org.joinmastodon.android.fragments.report.ReportReasonChoiceFragment; import org.joinmastodon.android.model.Account; @@ -43,7 +44,9 @@ import java.util.HashMap; import java.util.Objects; import java.util.Optional; import java.util.function.Consumer; +import java.util.function.Predicate; +import androidx.annotation.LayoutRes; import me.grishka.appkit.Nav; import me.grishka.appkit.api.Callback; import me.grishka.appkit.api.ErrorResponse; @@ -53,7 +56,7 @@ import me.grishka.appkit.views.UsableRecyclerView; public class AccountViewHolder extends BindableViewHolder implements ImageLoaderViewHolder, UsableRecyclerView.Clickable, UsableRecyclerView.LongClickable{ private final TextView name, username, followers, pronouns, bio; - private final ImageView avatar; + public final ImageView avatar, botIcon; private final FrameLayout accessory; private final ProgressBarButton button; private final PopupMenu contextMenu; @@ -62,18 +65,25 @@ public class AccountViewHolder extends BindableViewHolder impl private final CheckableRelativeLayout view; private final View checkbox; private final ProgressBar actionProgress; + private final ImageButton menuButton; private final String accountID; private final Fragment fragment; private final HashMap relationships; private Consumer onClick; + private Predicate onLongClick; + private Consumer onCustomMenuItemSelected; private AccessoryType accessoryType; private boolean showBio; private boolean checked; public AccountViewHolder(Fragment fragment, ViewGroup list, HashMap relationships){ - super(fragment.getActivity(), R.layout.item_account_list, list); + this(fragment, list, relationships, R.layout.item_account_list); + } + + public AccountViewHolder(Fragment fragment, ViewGroup list, HashMap relationships, @LayoutRes int layout){ + super(fragment.getActivity(), layout, list); this.fragment=fragment; this.accountID=Objects.requireNonNull(fragment.getArguments().getString("account")); this.relationships=relationships; @@ -82,6 +92,7 @@ public class AccountViewHolder extends BindableViewHolder impl name=findViewById(R.id.name); username=findViewById(R.id.username); avatar=findViewById(R.id.avatar); + botIcon=findViewById(R.id.bot_icon); accessory=findViewById(R.id.accessory); button=findViewById(R.id.button); menuAnchor=findViewById(R.id.menu_anchor); @@ -90,6 +101,7 @@ public class AccountViewHolder extends BindableViewHolder impl bio=findViewById(R.id.bio); checkbox=findViewById(R.id.checkbox); actionProgress=findViewById(R.id.action_progress); + menuButton=findViewById(R.id.options_btn); avatar.setOutlineProvider(OutlineProviders.roundedRect(10)); avatar.setClipToOutline(true); @@ -100,7 +112,8 @@ public class AccountViewHolder extends BindableViewHolder impl contextMenu=new PopupMenu(fragment.getActivity(), menuAnchor); contextMenu.inflate(R.menu.profile); contextMenu.setOnMenuItemClickListener(this::onContextMenuItemSelected); - if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.P && !UiUtils.isEMUI()) + menuButton.setOnClickListener(v->showMenuFromButton()); + if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.P && !UiUtils.isEMUI() && !UiUtils.isMagic()) contextMenu.getMenu().setGroupDividerEnabled(true); UiUtils.enablePopupMenuIcons(fragment.getContext(), contextMenu); @@ -135,6 +148,8 @@ public class AccountViewHolder extends BindableViewHolder impl pronouns.setVisibility(pronounsString.isPresent() ? View.VISIBLE : View.GONE); pronounsString.ifPresent(p -> HtmlParser.setTextWithCustomEmoji(pronouns, p, item.account.emojis)); + botIcon.setVisibility(item.account.bot ? View.VISIBLE : View.GONE); + /* unused in megalodon boolean hasVerifiedLink=item.verifiedLink!=null; if(!hasVerifiedLink) @@ -170,8 +185,8 @@ public class AccountViewHolder extends BindableViewHolder impl avatar.setImageDrawable(image); }else{ item.emojiHelper.setImageDrawable(index-1, image); - name.invalidate(); - bio.invalidate(); + name.setText(name.getText()); + bio.setText(bio.getText()); } if(image instanceof Animatable a && !a.isRunning()) @@ -209,6 +224,10 @@ public class AccountViewHolder extends BindableViewHolder impl @Override public boolean onLongClick(float x, float y){ + if(onLongClick!=null && onLongClick.test(this)) + return true; + if(accessoryType==AccessoryType.MENU || !prepareMenu()) + return false; if(relationships==null) return false; Relationship relationship=relationships.get(item.account.id); @@ -247,7 +266,6 @@ public class AccountViewHolder extends BindableViewHolder impl menuAnchor.setTranslationX(x); menuAnchor.setTranslationY(y); contextMenu.show(); - return true; } @@ -262,10 +280,10 @@ public class AccountViewHolder extends BindableViewHolder impl }); } - private void setActionProgressVisible(boolean visible){ + public void setActionProgressVisible(boolean visible){ if(visible) actionProgress.setIndeterminateTintList(button.getTextColors()); -// TODO button.setTextVisible(!visible); + button.setTextVisible(!visible); actionProgress.setVisibility(visible ? View.VISIBLE : View.GONE); button.setClickable(!visible); } @@ -278,10 +296,7 @@ public class AccountViewHolder extends BindableViewHolder impl int id=item.getItemId(); if(id==R.id.share){ - Intent intent=new Intent(Intent.ACTION_SEND); - intent.setType("text/plain"); - intent.putExtra(Intent.EXTRA_TEXT, account.url); - fragment.startActivity(Intent.createChooser(intent, item.getTitle())); + UiUtils.openSystemShareSheet(fragment.getActivity(), account); }else if(id==R.id.mute){ UiUtils.confirmToggleMuteUser(fragment.getActivity(), accountID, account, relationship.muting, this::updateRelationship); }else if(id==R.id.block){ @@ -318,11 +333,12 @@ public class AccountViewHolder extends BindableViewHolder impl .wrapProgress(fragment.getActivity(), R.string.loading, false) .exec(accountID); }else if(id==R.id.manage_user_lists){ - final Bundle args=new Bundle(); + Bundle args=new Bundle(); args.putString("account", accountID); - args.putString("profileAccount", account.id); - args.putString("profileDisplayUsername", account.getDisplayUsername()); - Nav.go(fragment.getActivity(), ListsFragment.class, args); + args.putParcelable("targetAccount", Parcels.wrap(account)); + Nav.go(fragment.getActivity(), AddAccountToListsFragment.class, args); + }else if(onCustomMenuItemSelected!=null){ + onCustomMenuItemSelected.accept(item); } return true; } @@ -336,6 +352,14 @@ public class AccountViewHolder extends BindableViewHolder impl onClick=listener; } + public void setOnLongClickListener(Predicate onLongClick){ + this.onLongClick=onLongClick; + } + + public void setOnCustomMenuItemSelectedListener(Consumer onCustomMenuItemSelected){ + this.onCustomMenuItemSelected=onCustomMenuItemSelected; + } + public void setStyle(AccessoryType accessoryType, boolean showBio){ if(accessoryType!=this.accessoryType){ this.accessoryType=accessoryType; @@ -343,20 +367,29 @@ public class AccountViewHolder extends BindableViewHolder impl case NONE -> { button.setVisibility(View.GONE); checkbox.setVisibility(View.GONE); + menuButton.setVisibility(View.GONE); } case CHECKBOX -> { button.setVisibility(View.GONE); checkbox.setVisibility(View.VISIBLE); + menuButton.setVisibility(View.GONE); checkbox.setBackground(new CheckBox(checkbox.getContext()).getButtonDrawable()); } case RADIOBUTTON -> { button.setVisibility(View.GONE); checkbox.setVisibility(View.VISIBLE); + menuButton.setVisibility(View.GONE); checkbox.setBackground(new RadioButton(checkbox.getContext()).getButtonDrawable()); } - case BUTTON -> { + case BUTTON, CUSTOM_BUTTON -> { button.setVisibility(View.VISIBLE); checkbox.setVisibility(View.GONE); + menuButton.setVisibility(View.GONE); + } + case MENU -> { + button.setVisibility(View.GONE); + checkbox.setVisibility(View.GONE); + menuButton.setVisibility(View.VISIBLE); } } view.setCheckable(accessoryType==AccessoryType.CHECKBOX || accessoryType==AccessoryType.RADIOBUTTON); @@ -365,15 +398,68 @@ public class AccountViewHolder extends BindableViewHolder impl bio.setVisibility(showBio ? View.VISIBLE : View.GONE); } + private boolean prepareMenu(){ + if(relationships==null) + return false; + Relationship relationship=relationships.get(item.account.id); + if(relationship==null) + return false; + Menu menu=contextMenu.getMenu(); + Account account=item.account; + + menu.findItem(R.id.share).setTitle(R.string.share_user); + menu.findItem(R.id.mute).setTitle(fragment.getString(relationship.muting ? R.string.unmute_user : R.string.mute_user, account.getDisplayUsername())); + menu.findItem(R.id.block).setTitle(fragment.getString(relationship.blocking ? R.string.unblock_user : R.string.block_user, account.getDisplayUsername())); + menu.findItem(R.id.report).setTitle(fragment.getString(R.string.report_user, account.getDisplayUsername())); + MenuItem hideBoosts=menu.findItem(R.id.hide_boosts); + if(relationship.following){ + hideBoosts.setTitle(fragment.getString(relationship.showingReblogs ? R.string.hide_boosts_from_user : R.string.show_boosts_from_user, account.getDisplayUsername())); + hideBoosts.setVisible(true); + }else{ + hideBoosts.setVisible(false); + } + MenuItem blockDomain=menu.findItem(R.id.block_domain); + if(!account.isLocal()){ + blockDomain.setTitle(fragment.getString(relationship.domainBlocking ? R.string.unblock_domain : R.string.block_domain, account.getDomain())); + blockDomain.setVisible(true); + }else{ + blockDomain.setVisible(false); + } + menu.findItem(R.id.manage_user_lists).setVisible(relationship.following); + return true; + } + + private void showMenuFromButton(){ + if(!prepareMenu()) + return; + int[] xy={0, 0}; + itemView.getLocationInWindow(xy); + int x=xy[0], y=xy[1]; + menuButton.getLocationInWindow(xy); + menuAnchor.setTranslationX(xy[0]-x+menuButton.getWidth()/2f); + menuAnchor.setTranslationY(xy[1]-y+menuButton.getHeight()); + contextMenu.show(); + } + public void setChecked(boolean checked){ this.checked=checked; view.setChecked(checked); } + public PopupMenu getContextMenu(){ + return contextMenu; + } + + public ProgressBarButton getButton(){ + return button; + } + public enum AccessoryType{ NONE, BUTTON, CHECKBOX, - RADIOBUTTON + RADIOBUTTON, + MENU, + CUSTOM_BUTTON } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/AvatarPileListItemViewHolder.java b/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/AvatarPileListItemViewHolder.java new file mode 100644 index 000000000..1590f7df4 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/AvatarPileListItemViewHolder.java @@ -0,0 +1,42 @@ +package org.joinmastodon.android.ui.viewholders; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.view.ViewGroup; +import android.widget.LinearLayout; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.model.viewmodel.AvatarPileListItem; +import org.joinmastodon.android.ui.views.AvatarPileView; + +import me.grishka.appkit.imageloader.ImageLoaderViewHolder; +import me.grishka.appkit.utils.V; + +public class AvatarPileListItemViewHolder extends ListItemViewHolder> implements ImageLoaderViewHolder{ + private final AvatarPileView pile; + + public AvatarPileListItemViewHolder(Context context, ViewGroup parent){ + super(context, R.layout.item_generic_list, parent); + pile=new AvatarPileView(context); + LinearLayout.LayoutParams lp=new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT); + lp.topMargin=lp.bottomMargin=V.dp(-8); + view.addView(pile, lp); + view.setClipToPadding(false); + } + + @Override + public void onBind(AvatarPileListItem item){ + super.onBind(item); + pile.setVisibleAvatarCount(item.avatars.size()); + } + + @Override + public void setImage(int index, Drawable image){ + pile.avatars[index].setImageDrawable(image); + } + + @Override + public void clearImage(int index){ + pile.avatars[index].setImageResource(R.drawable.image_placeholder); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/OptionsListItemViewHolder.java b/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/OptionsListItemViewHolder.java new file mode 100644 index 000000000..30a326142 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/OptionsListItemViewHolder.java @@ -0,0 +1,33 @@ +package org.joinmastodon.android.ui.viewholders; + +import android.content.Context; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageButton; +import android.widget.PopupMenu; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.model.viewmodel.ListItemWithOptionsMenu; + +public class OptionsListItemViewHolder extends ListItemViewHolder>{ + private final PopupMenu menu; + private final ImageButton menuBtn; + + public OptionsListItemViewHolder(Context context, ViewGroup parent){ + super(context, R.layout.item_generic_list_options, parent); + menuBtn=findViewById(R.id.options_btn); + menu=new PopupMenu(context, menuBtn); + menuBtn.setOnClickListener(this::onMenuBtnClick); + + menu.setOnMenuItemClickListener(menuItem->{ + item.performItemSelected(menuItem); + return true; + }); + } + + private void onMenuBtnClick(View v){ + menu.getMenu().clear(); + item.performConfigureMenu(menu.getMenu()); + menu.show(); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/SwitchListItemViewHolder.java b/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/SwitchListItemViewHolder.java index 85cefd009..6dd042a2e 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/SwitchListItemViewHolder.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/SwitchListItemViewHolder.java @@ -2,10 +2,13 @@ package org.joinmastodon.android.ui.viewholders; import android.content.Context; import android.view.Gravity; +import android.view.View; import android.view.ViewGroup; import android.widget.LinearLayout; +import org.joinmastodon.android.R; import org.joinmastodon.android.model.viewmodel.CheckableListItem; +import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.ui.views.M3Switch; import me.grishka.appkit.utils.V; @@ -14,8 +17,17 @@ public class SwitchListItemViewHolder extends CheckableListItemViewHolder{ private final M3Switch sw; private boolean ignoreListener; - public SwitchListItemViewHolder(Context context, ViewGroup parent){ + public SwitchListItemViewHolder(Context context, ViewGroup parent, boolean separated){ super(context, parent); + if(separated){ + View separator=new View(context); + separator.setBackgroundColor(UiUtils.getThemeColor(context, R.attr.colorM3OutlineVariant)); + LinearLayout.LayoutParams lp=new LinearLayout.LayoutParams(V.dp(1), V.dp(32)); + lp.gravity=Gravity.TOP; + lp.setMarginStart(V.dp(16)); + lp.setMarginEnd(V.dp(-1)); + checkableLayout.addView(separator, lp); + } sw=new M3Switch(context); LinearLayout.LayoutParams lp=new LinearLayout.LayoutParams(V.dp(52), V.dp(32)); lp.gravity=Gravity.TOP; diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/views/AvatarPileView.java b/mastodon/src/main/java/org/joinmastodon/android/ui/views/AvatarPileView.java new file mode 100644 index 000000000..bdb050f0d --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/views/AvatarPileView.java @@ -0,0 +1,81 @@ +package org.joinmastodon.android.ui.views; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffXfermode; +import android.graphics.RectF; +import android.util.AttributeSet; +import android.view.Gravity; +import android.view.View; +import android.widget.ImageView; +import android.widget.LinearLayout; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.ui.OutlineProviders; + +import androidx.annotation.Nullable; +import me.grishka.appkit.utils.CustomViewHelper; + +public class AvatarPileView extends LinearLayout implements CustomViewHelper{ + public final ImageView[] avatars=new ImageView[3]; + private final Paint borderPaint=new Paint(Paint.ANTI_ALIAS_FLAG); + private final RectF tmpRect=new RectF(); + + public AvatarPileView(Context context){ + super(context); + init(); + } + + public AvatarPileView(Context context, @Nullable AttributeSet attrs){ + super(context, attrs); + init(); + } + + public AvatarPileView(Context context, @Nullable AttributeSet attrs, int defStyleAttr){ + super(context, attrs, defStyleAttr); + init(); + } + + private void init(){ + setLayerType(LAYER_TYPE_HARDWARE, null); + setPaddingRelative(dp(16), 0, 0, 0); + setClipToPadding(false); + for(int i=0;idst.height()){ + path.computeBounds(src, false); + matrix.setRotate(90, src.centerX(), src.centerY()); + matrix.postScale(-1f, 1f, src.centerX(), src.centerY()); + path.transform(matrix); + isReversed=true; + } + PathMeasure pm=new PathMeasure(path, false); + float[] pos=new float[2], tan=new float[2]; + pm.getPosTan(isReversed ? pm.getLength() : 0, pos, null); + src.left=pos[0]; + src.bottom=pos[1]; + pm.getPosTan(isReversed ? 0 : pm.getLength(), pos, null); + src.right=pos[0]; + src.top=pos[1]; + + matrix.setRectToRect(src, dst, Matrix.ScaleToFit.FILL); + if(startingPointX>endingPointX) + matrix.postScale(-1f, 1f, dst.centerX(), dst.centerY()); + if(startingPointY0 && getChildAt(0) instanceof EditText et){ - edit=et; + if(getChildCount()>0){ + firstChild=getChildAt(0); + if(firstChild instanceof EditText et) + edit=et; }else{ - throw new IllegalStateException("First child must be an EditText"); + throw new IllegalStateException("Must contain at least one child view"); } label=new TextView(getContext()); label.setTextSize(TypedValue.COMPLEX_UNIT_PX, labelTextSize); // label.setTextColor(labelColors==null ? edit.getHintTextColors() : labelColors); - origHintColors=edit.getHintTextColors(); - label.setText(edit.getHint()); + if(edit!=null){ + origHintColors=edit.getHintTextColors(); + label.setText(edit.getHint()); + } label.setSingleLine(); label.setPivotX(0f); label.setPivotY(0f); label.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO); LayoutParams lp=new LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.START | Gravity.TOP); - lp.setMarginStart(edit.getPaddingStart()+((LayoutParams)edit.getLayoutParams()).getMarginStart()); + lp.setMarginStart(firstChild.getPaddingStart()+((LayoutParams)firstChild.getLayoutParams()).getMarginStart()); addView(label, lp); - hintVisible=edit.getText().length()==0; + hintVisible=edit!=null && edit.getText().length()==0; if(hintVisible) label.setAlpha(0f); + else + animProgress=1; - edit.addTextChangedListener(new SimpleTextWatcher(this::onTextChanged)); + if(edit!=null) + edit.addTextChangedListener(new SimpleTextWatcher(this::onTextChanged)); errorView=new LinkedTextView(getContext()); errorView.setTextAppearance(R.style.m3_body_small); @@ -110,6 +119,18 @@ public class FloatingHintEditTextLayout extends FrameLayout implements CustomVie label.setText(edit.getHint()); } + public void setHint(CharSequence hint){ + label.setText(hint); + } + + public void setHint(@StringRes int hint){ + label.setText(hint); + } + + public TextView getLabel(){ + return label; + } + private void onTextChanged(Editable text){ if(errorState){ errorView.setVisibility(View.GONE); @@ -244,7 +265,7 @@ public class FloatingHintEditTextLayout extends FrameLayout implements CustomVie protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec){ if(errorView.getVisibility()!=GONE){ int width=MeasureSpec.getSize(widthMeasureSpec)-getPaddingLeft()-getPaddingRight(); - LayoutParams editLP=(LayoutParams) edit.getLayoutParams(); + LayoutParams editLP=(LayoutParams) firstChild.getLayoutParams(); width-=editLP.leftMargin+editLP.rightMargin; errorView.measure(width | MeasureSpec.EXACTLY, MeasureSpec.UNSPECIFIED); LayoutParams lp=(LayoutParams) errorView.getLayoutParams(); @@ -254,7 +275,7 @@ public class FloatingHintEditTextLayout extends FrameLayout implements CustomVie lp.leftMargin=editLP.leftMargin; editLP.bottomMargin=errorView.getMeasuredHeight(); }else{ - LayoutParams editLP=(LayoutParams) edit.getLayoutParams(); + LayoutParams editLP=(LayoutParams) firstChild.getLayoutParams(); editLP.bottomMargin=0; } super.onMeasure(widthMeasureSpec, heightMeasureSpec); @@ -355,7 +376,7 @@ public class FloatingHintEditTextLayout extends FrameLayout implements CustomVie protected void onBoundsChange(@NonNull Rect bounds){ super.onBoundsChange(bounds); int offset=dp(12); - wrapped.setBounds(edit.getLeft()-offset, edit.getTop()-offset, edit.getRight()+offset, edit.getBottom()+offset); + wrapped.setBounds(firstChild.getLeft()-offset, firstChild.getTop()-offset, firstChild.getRight()+offset, firstChild.getBottom()+offset); } } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/views/ListEditor.java b/mastodon/src/main/java/org/joinmastodon/android/ui/views/ListEditor.java index 1ebe466d2..de4454c36 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/views/ListEditor.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/views/ListEditor.java @@ -15,10 +15,10 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import org.joinmastodon.android.R; -import org.joinmastodon.android.model.ListTimeline; +import org.joinmastodon.android.model.FollowList; public class ListEditor extends LinearLayout { - private ListTimeline.RepliesPolicy policy = null; + private FollowList.RepliesPolicy policy = null; private final TextInputFrameLayout input; private final Button button; private final Switch exclusiveSwitch; @@ -42,10 +42,10 @@ public class ListEditor extends LinearLayout { findViewById(R.id.exclusive) .setOnClickListener(v -> exclusiveSwitch.setChecked(!exclusiveSwitch.isChecked())); - setRepliesPolicy(ListTimeline.RepliesPolicy.LIST); + setRepliesPolicy(FollowList.RepliesPolicy.LIST); } - public void applyList(String title, boolean exclusive, @Nullable ListTimeline.RepliesPolicy policy) { + public void applyList(String title, boolean exclusive, @Nullable FollowList.RepliesPolicy policy) { input.getEditText().setText(title); exclusiveSwitch.setChecked(exclusive); if (policy != null) setRepliesPolicy(policy); @@ -55,7 +55,7 @@ public class ListEditor extends LinearLayout { return input.getEditText().getText().toString(); } - public ListTimeline.RepliesPolicy getRepliesPolicy() { + public FollowList.RepliesPolicy getRepliesPolicy() { return policy; } @@ -63,7 +63,7 @@ public class ListEditor extends LinearLayout { return exclusiveSwitch.isChecked(); } - public void setRepliesPolicy(@NonNull ListTimeline.RepliesPolicy policy) { + public void setRepliesPolicy(@NonNull FollowList.RepliesPolicy policy) { this.policy = policy; switch (policy) { case FOLLOWED -> button.setText(R.string.sk_list_replies_policy_followed); @@ -74,11 +74,11 @@ public class ListEditor extends LinearLayout { private boolean onMenuItemClick(MenuItem i) { if (i.getItemId() == R.id.reply_policy_none) { - setRepliesPolicy(ListTimeline.RepliesPolicy.NONE); + setRepliesPolicy(FollowList.RepliesPolicy.NONE); } else if (i.getItemId() == R.id.reply_policy_followed) { - setRepliesPolicy(ListTimeline.RepliesPolicy.FOLLOWED); + setRepliesPolicy(FollowList.RepliesPolicy.FOLLOWED); } else if (i.getItemId() == R.id.reply_policy_list) { - setRepliesPolicy(ListTimeline.RepliesPolicy.LIST); + setRepliesPolicy(FollowList.RepliesPolicy.LIST); } return true; } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/views/ProgressBarButton.java b/mastodon/src/main/java/org/joinmastodon/android/ui/views/ProgressBarButton.java index ac0e4c9da..ae9497bdc 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/views/ProgressBarButton.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/views/ProgressBarButton.java @@ -1,23 +1,42 @@ package org.joinmastodon.android.ui.views; import android.content.Context; +import android.content.res.TypedArray; import android.graphics.Canvas; import android.util.AttributeSet; +import android.view.View; +import android.view.ViewGroup; import android.widget.Button; +import android.widget.ProgressBar; + +import org.joinmastodon.android.R; public class ProgressBarButton extends Button{ private boolean textVisible=true; + private ProgressBar progressBar; + private int progressBarID; public ProgressBarButton(Context context){ - super(context); + this(context, null); } public ProgressBarButton(Context context, AttributeSet attrs){ - super(context, attrs); + this(context, attrs, 0); } - public ProgressBarButton(Context context, AttributeSet attrs, int defStyleAttr){ - super(context, attrs, defStyleAttr); + public ProgressBarButton(Context context, AttributeSet attrs, int defStyle){ + super(context, attrs, defStyle); + TypedArray ta=context.obtainStyledAttributes(attrs, R.styleable.ProgressBarButton); + progressBarID=ta.getResourceId(R.styleable.ProgressBarButton_progressBar, 0); + ta.recycle(); + } + + @Override + protected void onAttachedToWindow(){ + super.onAttachedToWindow(); + if(progressBarID!=0){ + progressBar=((ViewGroup)getParent()).findViewById(progressBarID); + } } public void setTextVisible(boolean textVisible){ @@ -29,6 +48,19 @@ public class ProgressBarButton extends Button{ return textVisible; } + public void setProgressBarVisible(boolean visible){ + if(progressBar==null) + throw new IllegalStateException("progressBar is not set"); + if(visible){ + setTextVisible(false); + progressBar.setIndeterminateTintList(getTextColors()); + progressBar.setVisibility(View.VISIBLE); + }else{ + setTextVisible(true); + progressBar.setVisibility(View.GONE); + } + } + @Override protected void onDraw(Canvas canvas){ if(textVisible){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/views/ReorderableLinearLayout.java b/mastodon/src/main/java/org/joinmastodon/android/ui/views/ReorderableLinearLayout.java index c49bf56b1..c8d880f5a 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/views/ReorderableLinearLayout.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/views/ReorderableLinearLayout.java @@ -170,6 +170,7 @@ public class ReorderableLinearLayout extends LinearLayout implements CustomViewH else bottomSibling=null; dragListener.onSwapItems(prevIndex, index); + final View draggedView=this.draggedView; draggedView.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener(){ @Override public boolean onPreDraw(){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/views/RippleAnimationTextView.java b/mastodon/src/main/java/org/joinmastodon/android/ui/views/RippleAnimationTextView.java new file mode 100644 index 000000000..b44db321a --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/views/RippleAnimationTextView.java @@ -0,0 +1,170 @@ +package org.joinmastodon.android.ui.views; + +import android.animation.ArgbEvaluator; +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.text.Layout; +import android.util.AttributeSet; +import android.widget.TextView; + +import androidx.dynamicanimation.animation.FloatValueHolder; +import androidx.dynamicanimation.animation.SpringAnimation; +import androidx.dynamicanimation.animation.SpringForce; +import me.grishka.appkit.utils.CustomViewHelper; + +public class RippleAnimationTextView extends TextView implements CustomViewHelper{ + private final Paint animationPaint=new Paint(Paint.ANTI_ALIAS_FLAG); + private CharacterAnimationState[] charStates; + private final ArgbEvaluator colorEvaluator=new ArgbEvaluator(); + private int runningAnimCount=0; + private Runnable[] delayedAnimations1, delayedAnimations2; + + public RippleAnimationTextView(Context context){ + this(context, null); + } + + public RippleAnimationTextView(Context context, AttributeSet attrs){ + this(context, attrs, 0); + } + + public RippleAnimationTextView(Context context, AttributeSet attrs, int defStyle){ + super(context, attrs, defStyle); + } + + @Override + protected void onTextChanged(CharSequence text, int start, int lengthBefore, int lengthAfter){ + super.onTextChanged(text, start, lengthBefore, lengthAfter); + if(charStates!=null){ + for(CharacterAnimationState state:charStates){ + state.colorAnimation.cancel(); + state.shadowAnimation.cancel(); + state.scaleAnimation.cancel(); + } + for(Runnable r:delayedAnimations1){ + if(r!=null) + removeCallbacks(r); + } + for(Runnable r:delayedAnimations2){ + if(r!=null) + removeCallbacks(r); + } + } + charStates=new CharacterAnimationState[lengthAfter]; + delayedAnimations1=new Runnable[lengthAfter]; + delayedAnimations2=new Runnable[lengthAfter]; + } + + @Override + protected void onDraw(Canvas canvas){ + if(runningAnimCount==0 && !areThereDelayedAnimations()){ + super.onDraw(canvas); + return; + } + Layout layout=getLayout(); + animationPaint.set(getPaint()); + CharSequence text=layout.getText(); + for(int i=0;i{ + if(!state.colorAnimation.isRunning()) + runningAnimCount++; + state.colorAnimation.animateToFinalPosition(1f); + if(!state.shadowAnimation.isRunning()) + runningAnimCount++; + state.shadowAnimation.animateToFinalPosition(0.3f); + if(!state.scaleAnimation.isRunning()) + runningAnimCount++; + state.scaleAnimation.animateToFinalPosition(1.2f); + invalidate(); + + if(delayedAnimations1[finalI]!=null) + removeCallbacks(delayedAnimations1[finalI]); + if(delayedAnimations2[finalI]!=null) + removeCallbacks(delayedAnimations2[finalI]); + Runnable delay1=()->{ + if(!state.colorAnimation.isRunning()) + runningAnimCount++; + state.colorAnimation.animateToFinalPosition(0f); + if(!state.shadowAnimation.isRunning()) + runningAnimCount++; + state.shadowAnimation.animateToFinalPosition(0f); + invalidate(); + delayedAnimations1[finalI]=null; + }; + Runnable delay2=()->{ + if(!state.scaleAnimation.isRunning()) + runningAnimCount++; + state.scaleAnimation.animateToFinalPosition(1f); + delayedAnimations2[finalI]=null; + }; + delayedAnimations1[finalI]=delay1; + delayedAnimations2[finalI]=delay2; + postOnAnimationDelayed(delay1, 2000); + postOnAnimationDelayed(delay2, 100); + }, 20L*(i-startIndex)); + } + } + + private boolean areThereDelayedAnimations(){ + for(Runnable r:delayedAnimations1){ + if(r!=null) + return true; + } + for(Runnable r:delayedAnimations2){ + if(r!=null) + return true; + } + return false; + } + + private class CharacterAnimationState extends FloatValueHolder{ + private final SpringAnimation scaleAnimation, colorAnimation, shadowAnimation; + private final FloatValueHolder scale=new FloatValueHolder(1), color=new FloatValueHolder(), shadowAlpha=new FloatValueHolder(); + + public CharacterAnimationState(){ + scaleAnimation=new SpringAnimation(scale); + colorAnimation=new SpringAnimation(color); + shadowAnimation=new SpringAnimation(shadowAlpha); + setupSpring(scaleAnimation); + setupSpring(colorAnimation); + setupSpring(shadowAnimation); + } + + private void setupSpring(SpringAnimation anim){ + anim.setMinimumVisibleChange(0.01f); + anim.setSpring(new SpringForce().setStiffness(500f).setDampingRatio(0.175f)); + anim.addEndListener((animation, canceled, value, velocity)->runningAnimCount--); + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/views/WrappingLinearLayout.java b/mastodon/src/main/java/org/joinmastodon/android/ui/views/WrappingLinearLayout.java new file mode 100644 index 000000000..2b8352082 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/views/WrappingLinearLayout.java @@ -0,0 +1,128 @@ +package org.joinmastodon.android.ui.views; + +import android.content.Context; +import android.content.res.TypedArray; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewGroup; + +import org.joinmastodon.android.R; + +import java.util.ArrayList; + +/** + * Something like a horizontal LinearLayout, but wraps child views onto a new line if they don't fit + */ +public class WrappingLinearLayout extends ViewGroup{ + private int verticalGap, horizontalGap; + private ArrayList rowHeights=new ArrayList<>(); + + public WrappingLinearLayout(Context context){ + this(context, null); + } + + public WrappingLinearLayout(Context context, AttributeSet attrs){ + this(context, attrs, 0); + } + + public WrappingLinearLayout(Context context, AttributeSet attrs, int defStyle){ + super(context, attrs, defStyle); + TypedArray ta=context.obtainStyledAttributes(attrs, R.styleable.WrappingLinearLayout); + verticalGap=ta.getDimensionPixelOffset(R.styleable.WrappingLinearLayout_android_verticalGap, 0); + horizontalGap=ta.getDimensionPixelOffset(R.styleable.WrappingLinearLayout_android_horizontalGap, 0); + ta.recycle(); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec){ + int w=MeasureSpec.getSize(widthMeasureSpec)-getPaddingLeft()-getPaddingRight(); + int heightUsed=0, widthRemain=w, currentRowHeight=0; + rowHeights.clear(); + for(int i=0;iwidthRemain){ + // Doesn't fit into the current row. Start a new one. + heightUsed+=currentRowHeight+verticalGap; + rowHeights.add(currentRowHeight); + currentRowHeight=child.getMeasuredHeight()+verticalMargins; + widthRemain=w; + }else{ + // Does fit. Advance horizontally. + if(widthRemain=endPadding){ + xOffset+=childW+horizontalGap; + if(child.getLayoutParams() instanceof MarginLayoutParams mlp){ + xOffset+=mlp.leftMargin+mlp.rightMargin; + } + firstInRow=false; + }else if(currentRowIndexcontent:// {@link Uri} for a file + * instead of a file:/// {@link Uri}. + *

+ * A content URI allows you to grant read and write access using + * temporary access permissions. When you create an {@link Intent} containing + * a content URI, in order to send the content URI + * to a client app, you can also call {@link Intent#setFlags(int) Intent.setFlags()} to add + * permissions. These permissions are available to the client app for as long as the stack for + * a receiving {@link Activity} is active. For an {@link Intent} going to a + * {@link Service}, the permissions are available as long as the + * {@link Service} is running. + *

+ * In comparison, to control access to a file:/// {@link Uri} you have to modify the + * file system permissions of the underlying file. The permissions you provide become available to + * any app, and remain in effect until you change them. This level of access is + * fundamentally insecure. + *

+ * The increased level of file access security offered by a content URI + * makes FileProvider a key part of Android's security infrastructure. + *

+ * This overview of FileProvider includes the following topics: + *

+ *
    + *
  1. Defining a FileProvider
  2. + *
  3. Specifying Available Files
  4. + *
  5. Generating the Content URI for a File
  6. + *
  7. Granting Temporary Permissions to a URI
  8. + *
  9. Serving a Content URI to Another App
  10. + *
+ *

+ * Defining a FileProvider + *

+ * Extend FileProvider with a default constructor, and call super with an XML resource file that + * specifies the available files (see below for the structure of the XML file): + *

+ * public class MyFileProvider extends FileProvider {
+ *    public MyFileProvider() {
+ *        super(R.xml.file_paths)
+ *    }
+ * }
+ * 
+ * Add a + *
<provider> + * element to your app manifest. Set the android:name attribute to the FileProvider you + * created. Set the android:authorities attribute to a URI authority based on a + * domain you control; for example, if you control the domain mydomain.com you + * should use the authority com.mydomain.fileprovider. Set the + * android:exported attribute to false; the FileProvider does not need + * to be public. Set the android:grantUriPermissions attribute to true, to allow you to grant temporary + * access to files. For example: + *
+ * <manifest>
+ *    ...
+ *    <application>
+ *        ...
+ *        <provider
+ *            android:name="com.sample.MyFileProvider"
+ *            android:authorities="com.mydomain.fileprovider"
+ *            android:exported="false"
+ *            android:grantUriPermissions="true">
+ *            ...
+ *        </provider>
+ *        ...
+ *    </application>
+ * </manifest>
+ *

+ * It is possible to use FileProvider directly instead of extending it. However, this is not + * reliable and will causes crashes on some devices. + *

+ * Specifying Available Files + *

+ * A FileProvider can only generate a content URI for files in directories that you specify + * beforehand. To specify a directory, specify its storage area and path in XML, using child + * elements of the <paths> element. + * For example, the following paths element tells FileProvider that you intend to + * request content URIs for the images/ subdirectory of your private file area. + *

+ * <paths xmlns:android="http://schemas.android.com/apk/res/android">
+ *    <files-path name="my_images" path="images/"/>
+ *    ...
+ * </paths>
+ * 
+ *

+ * The <paths> element must contain one or more of the following child elements: + *

    + *
  • + *
    <files-path name="name" path="path" />
    + * Represents files in the files/ subdirectory of your app's internal storage + * area. This subdirectory is the same as the value returned by {@link Context#getFilesDir() + * Context.getFilesDir()}. + *
  • + *
  • <cache-path name="name" path="path" />
    + * Represents files in the cache subdirectory of your app's internal storage area. The root path + * of this subdirectory is the same as the value returned by {@link Context#getCacheDir() + * getCacheDir()}. + *
  • + *
    <external-path name="name" path="path" />
    + * Represents files in the root of the external storage area. The root path of this subdirectory + * is the same as the value returned by + * {@link Environment#getExternalStorageDirectory() Environment.getExternalStorageDirectory()}. + *
  • + *
    <external-files-path name="name" path="path"
    + *     />
    + * Represents files in the root of your app's external storage area. The root path of this + * subdirectory is the same as the value returned by + * {@link Context#getExternalFilesDir(String) Context.getExternalFilesDir(null)}. + *
  • + *
  • + *
    <external-cache-path name="name" path="path"
    + *     />
    + * Represents files in the root of your app's external cache area. The root path of this + * subdirectory is the same as the value returned by + * {@link Context#getExternalCacheDir() Context.getExternalCacheDir()}. + *
  • + *
    <external-media-path name="name" path="path"
    + *     />
    + * Represents files in the root of your app's external media area. The root path of this + * subdirectory is the same as the value returned by the first result of + * {@link Context#getExternalMediaDirs() Context.getExternalMediaDirs()}. + *

    Note: this directory is only available on API 21+ devices.

    + *
  • + *
+ *

+ * These child elements all use the same attributes: + *

    + *
  • + * name="name" + *

    + * A URI path segment. To enforce security, this value hides the name of the subdirectory + * you're sharing. The subdirectory name for this value is contained in the + * path attribute. + *

  • + *
  • + * path="path" + *

    + * The subdirectory you're sharing. While the name attribute is a URI path + * segment, the path value is an actual subdirectory name. Notice that the + * value refers to a subdirectory, not an individual file or files. You can't + * share a single file by its file name, nor can you specify a subset of files using + * wildcards. + *

  • + *
+ *

+ * You must specify a child element of <paths> for each directory that contains + * files for which you want content URIs. For example, these XML elements specify two directories: + *

+ * <paths xmlns:android="http://schemas.android.com/apk/res/android">
+ *    <files-path name="my_images" path="images/"/>
+ *    <files-path name="my_docs" path="docs/"/>
+ * </paths>
+ * 
+ *

+ * Put the <paths> element and its children in an XML file in your project. + * For example, you can add them to a new file called res/xml/file_paths.xml. + * + * To link this file to the FileProvider, pass it to super() in the constructor for the + * FileProvider you defined above, add a <meta-data> element as a child of the <provider> + * element that defines the FileProvider. Set the <meta-data> element's + * "android:name" attribute to android.support.FILE_PROVIDER_PATHS. Set the + * element's "android:resource" attribute to @xml/file_paths (notice that you + * don't specify the .xml extension). For example: + *

+ * <provider
+ *    android:name="com.sample.MyFileProvider"
+ *    android:authorities="com.mydomain.fileprovider"
+ *    android:exported="false"
+ *    android:grantUriPermissions="true">
+ *    <meta-data
+ *        android:name="android.support.FILE_PROVIDER_PATHS"
+ *        android:resource="@xml/file_paths" />
+ * </provider>
+ * 
+ *

+ * Generating the Content URI for a File + *

+ * To share a file with another app using a content URI, your app has to generate the content URI. + * To generate the content URI, create a new {@link File} for the file, then pass the {@link File} + * to {@link #getUriForFile(Context, String, File) getUriForFile()}. You can send the content URI + * returned by {@link #getUriForFile(Context, String, File) getUriForFile()} to another app in an + * {@link Intent}. The client app that receives the content URI can open the file + * and access its contents by calling + * {@link ContentResolver#openFileDescriptor(Uri, String) + * ContentResolver.openFileDescriptor} to get a {@link ParcelFileDescriptor}. + *

+ * For example, suppose your app is offering files to other apps with a FileProvider that has the + * authority com.mydomain.fileprovider. To get a content URI for the file + * default_image.jpg in the images/ subdirectory of your internal storage + * add the following code: + *

+ * File imagePath = new File(Context.getFilesDir(), "my_images");
+ * File newFile = new File(imagePath, "default_image.jpg");
+ * Uri contentUri = getUriForFile(getContext(), "com.mydomain.fileprovider", newFile);
+ * 
+ * As a result of the previous snippet, + * {@link #getUriForFile(Context, String, File) getUriForFile()} returns the content URI + * content://com.mydomain.fileprovider/my_images/default_image.jpg. + *

+ * Granting Temporary Permissions to a URI + *

+ * To grant an access permission to a content URI returned from + * {@link #getUriForFile(Context, String, File) getUriForFile()}, you can either grant the + * permission to a specific package or include the permission in an intent, as shown in the + * following sections. + *

Grant Permission to a Specific Package

+ *

+ * Call the method + * {@link Context#grantUriPermission(String, Uri, int) + * Context.grantUriPermission(package, Uri, mode_flags)} for the content:// + * {@link Uri}, using the desired mode flags. This grants temporary access permission for the + * content URI to the specified package, according to the value of the + * the mode_flags parameter, which you can set to + * {@link Intent#FLAG_GRANT_READ_URI_PERMISSION}, {@link Intent#FLAG_GRANT_WRITE_URI_PERMISSION} + * or both. The permission remains in effect until you revoke it by calling + * {@link Context#revokeUriPermission(Uri, int) revokeUriPermission()} or until the device + * reboots. + *

+ *

Include the Permission in an Intent

+ *

+ * To allow the user to choose which app receives the intent, and the permission to access the + * content, do the following: + *

+ *
    + *
  1. + * Put the content URI in an {@link Intent} by calling {@link Intent#setData(Uri) setData()}. + *
  2. + *
  3. + *

    + * Call the method {@link Intent#setFlags(int) Intent.setFlags()} with either + * {@link Intent#FLAG_GRANT_READ_URI_PERMISSION} or + * {@link Intent#FLAG_GRANT_WRITE_URI_PERMISSION} or both. + *

    + *

    + * To support devices that run a version between Android 4.1 (API level 16) and Android 5.1 + * (API level 22) inclusive, create a {@link ClipData} object from the content + * URI, and set the access permissions on the ClipData object: + *

    + *
    + * shareContentIntent.setClipData(ClipData.newRawUri("", contentUri));
    + * shareContentIntent.addFlags(
    + *         Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
    + * 
    + *
  4. + *
  5. + * Send the {@link Intent} to + * another app. Most often, you do this by calling + * {@link Activity#setResult(int, Intent) setResult()}. + *
  6. + *
+ *

+ * Permissions granted in an {@link Intent} remain in effect while the stack of the receiving + * {@link Activity} is active. When the stack finishes, the permissions are + * automatically removed. Permissions granted to one {@link Activity} in a client + * app are automatically extended to other components of that app. + *

+ * Serving a Content URI to Another App + *

+ * There are a variety of ways to serve the content URI for a file to a client app. One common way + * is for the client app to start your app by calling + * {@link Activity#startActivityForResult(Intent, int, Bundle) startActivityResult()}, + * which sends an {@link Intent} to your app to start an {@link Activity} in your app. + * In response, your app can immediately return a content URI to the client app or present a user + * interface that allows the user to pick a file. In the latter case, once the user picks the file + * your app can return its content URI. In both cases, your app returns the content URI in an + * {@link Intent} sent via {@link Activity#setResult(int, Intent) setResult()}. + *

+ *

+ * You can also put the content URI in a {@link ClipData} object and then add the + * object to an {@link Intent} you send to a client app. To do this, call + * {@link Intent#setClipData(ClipData) Intent.setClipData()}. When you use this approach, you can + * add multiple {@link ClipData} objects to the {@link Intent}, each with its own + * content URI. When you call {@link Intent#setFlags(int) Intent.setFlags()} on the {@link Intent} + * to set temporary access permissions, the same permissions are applied to all of the content + * URIs. + *

+ *

+ * Note: The {@link Intent#setClipData(ClipData) Intent.setClipData()} method is + * only available in platform version 16 (Android 4.1) and later. If you want to maintain + * compatibility with previous versions, you should send one content URI at a time in the + * {@link Intent}. Set the action to {@link Intent#ACTION_SEND} and put the URI in data by calling + * {@link Intent#setData setData()}. + *

+ * More Information + *

+ * To learn more about FileProvider, see the Android training class + * Sharing Files Securely with + * URIs. + *

+ */ +public class FileProvider extends ContentProvider { + private static final String[] COLUMNS = { + OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE }; + + private static final String + META_DATA_FILE_PROVIDER_PATHS = "android.support.FILE_PROVIDER_PATHS"; + + private static final String TAG_ROOT_PATH = "root-path"; + private static final String TAG_FILES_PATH = "files-path"; + private static final String TAG_CACHE_PATH = "cache-path"; + private static final String TAG_EXTERNAL = "external-path"; + private static final String TAG_EXTERNAL_FILES = "external-files-path"; + private static final String TAG_EXTERNAL_CACHE = "external-cache-path"; + private static final String TAG_EXTERNAL_MEDIA = "external-media-path"; + + private static final String ATTR_NAME = "name"; + private static final String ATTR_PATH = "path"; + + private static final String DISPLAYNAME_FIELD = "displayName"; + + private static final File DEVICE_ROOT = new File("/"); + + @GuardedBy("sCache") + private static final HashMap sCache = new HashMap<>(); + + // Do not use {@code mLocalPathStrategy} directly; access it via {@link #getLocalPathStrategy}. + @GuardedBy("this") + @Nullable private PathStrategy mLocalPathStrategy; + + private int mResourceId; + private String mAuthority; + + public FileProvider() { + mResourceId = 0; + } + + protected FileProvider(@XmlRes int resourceId) { + mResourceId = resourceId; + } + + /** + * The default FileProvider implementation does not need to be initialized. If you want to + * override this method, you must provide your own subclass of FileProvider. + */ + @Override + public boolean onCreate() { + return true; + } + + /** + * After the FileProvider is instantiated, this method is called to provide the system with + * information about the provider. + * + * @param context A {@link Context} for the current component. + * @param info A {@link ProviderInfo} for the new provider. + */ + @SuppressWarnings("StringSplitter") + @Override + public void attachInfo(@NonNull Context context, @NonNull ProviderInfo info) { + super.attachInfo(context, info); + + // Check our security attributes + if (info.exported) { + throw new SecurityException("Provider must not be exported"); + } + if (!info.grantUriPermissions) { + throw new SecurityException("Provider must grant uri permissions"); + } + + mAuthority = info.authority.split(";")[0]; + synchronized (sCache) { + sCache.remove(mAuthority); + } + } + + /** + * Return a content URI for a given {@link File}. Specific temporary + * permissions for the content URI can be set with + * {@link Context#grantUriPermission(String, Uri, int)}, or added + * to an {@link Intent} by calling {@link Intent#setData(Uri) setData()} and then + * {@link Intent#setFlags(int) setFlags()}; in both cases, the applicable flags are + * {@link Intent#FLAG_GRANT_READ_URI_PERMISSION} and + * {@link Intent#FLAG_GRANT_WRITE_URI_PERMISSION}. A FileProvider can only return a + * content {@link Uri} for file paths defined in their <paths> + * meta-data element. See the Class Overview for more information. + * + * @param context A {@link Context} for the current component. + * @param authority The authority of a {@link FileProvider} defined in a + * {@code } element in your app's manifest. + * @param file A {@link File} pointing to the filename for which you want a + * content {@link Uri}. + * @return A content URI for the file. + * @throws IllegalArgumentException When the given {@link File} is outside + * the paths supported by the provider. + */ + public static Uri getUriForFile(@NonNull Context context, @NonNull String authority, + @NonNull File file) { + final PathStrategy strategy = getPathStrategy(context, authority, 0); + return strategy.getUriForFile(file); + } + + /** + * Return a content URI for a given {@link File}. Specific temporary + * permissions for the content URI can be set with + * {@link Context#grantUriPermission(String, Uri, int)}, or added + * to an {@link Intent} by calling {@link Intent#setData(Uri) setData()} and then + * {@link Intent#setFlags(int) setFlags()}; in both cases, the applicable flags are + * {@link Intent#FLAG_GRANT_READ_URI_PERMISSION} and + * {@link Intent#FLAG_GRANT_WRITE_URI_PERMISSION}. A FileProvider can only return a + * content {@link Uri} for file paths defined in their <paths> + * meta-data element. See the Class Overview for more information. + * + * @param context A {@link Context} for the current component. + * @param authority The authority of a {@link FileProvider} defined in a + * {@code } element in your app's manifest. + * @param file A {@link File} pointing to the filename for which you want a + * content {@link Uri}. + * @param displayName The filename to be displayed. This can be used if the original filename + * is undesirable. + * @return A content URI for the file. + * @throws IllegalArgumentException When the given {@link File} is outside + * the paths supported by the provider. + */ + @SuppressLint("StreamFiles") + @NonNull + public static Uri getUriForFile(@NonNull Context context, @NonNull String authority, + @NonNull File file, @NonNull String displayName) { + Uri uri = getUriForFile(context, authority, file); + return uri.buildUpon().appendQueryParameter(DISPLAYNAME_FIELD, displayName).build(); + } + + /** + * Use a content URI returned by + * {@link #getUriForFile(Context, String, File) getUriForFile()} to get information about a file + * managed by the FileProvider. + * FileProvider reports the column names defined in {@link OpenableColumns}: + *
    + *
  • {@link OpenableColumns#DISPLAY_NAME}
  • + *
  • {@link OpenableColumns#SIZE}
  • + *
+ * For more information, see + * {@link ContentProvider#query(Uri, String[], String, String[], String) + * ContentProvider.query()}. + * + * @param uri A content URI returned by {@link #getUriForFile}. + * @param projection The list of columns to put into the {@link Cursor}. If null all columns are + * included. + * @param selection Selection criteria to apply. If null then all data that matches the content + * URI is returned. + * @param selectionArgs An array of {@link String}, containing arguments to bind to + * the selection parameter. The query method scans selection from left to + * right and iterates through selectionArgs, replacing the current "?" character in + * selection with the value at the current position in selectionArgs. The + * values are bound to selection as {@link String} values. + * @param sortOrder A {@link String} containing the column name(s) on which to sort + * the resulting {@link Cursor}. + * @return A {@link Cursor} containing the results of the query. + * + */ + @NonNull + @Override + public Cursor query(@NonNull Uri uri, @Nullable String[] projection, @Nullable String selection, + @Nullable String[] selectionArgs, + @Nullable String sortOrder) { + // ContentProvider has already checked granted permissions + final File file = getLocalPathStrategy().getFileForUri(uri); + String displayName = uri.getQueryParameter(DISPLAYNAME_FIELD); + + if (projection == null) { + projection = COLUMNS; + } + + String[] cols = new String[projection.length]; + Object[] values = new Object[projection.length]; + int i = 0; + for (String col : projection) { + if (OpenableColumns.DISPLAY_NAME.equals(col)) { + cols[i] = OpenableColumns.DISPLAY_NAME; + values[i++] = (displayName == null) ? file.getName() : displayName; + } else if (OpenableColumns.SIZE.equals(col)) { + cols[i] = OpenableColumns.SIZE; + values[i++] = file.length(); + } + } + + cols = copyOf(cols, i); + values = copyOf(values, i); + + final MatrixCursor cursor = new MatrixCursor(cols, 1); + cursor.addRow(values); + return cursor; + } + + /** + * Returns the MIME type of a content URI returned by + * {@link #getUriForFile(Context, String, File) getUriForFile()}. + * + * @param uri A content URI returned by + * {@link #getUriForFile(Context, String, File) getUriForFile()}. + * @return If the associated file has an extension, the MIME type associated with that + * extension; otherwise application/octet-stream. + */ + @Nullable + @Override + public String getType(@NonNull Uri uri) { + // ContentProvider has already checked granted permissions + final File file = getLocalPathStrategy().getFileForUri(uri); + + final int lastDot = file.getName().lastIndexOf('.'); + if (lastDot >= 0) { + final String extension = file.getName().substring(lastDot + 1); + final String mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension); + if (mime != null) { + return mime; + } + } + + return "application/octet-stream"; + } + + /** + * Unrestricted version of getType + * called, when caller does not have corresponding permissions + */ + //@Override + @SuppressWarnings("MissingOverride") + @Nullable + public String getTypeAnonymous(@NonNull Uri uri) { + return "application/octet-stream"; + } + + /** + * By default, this method throws an {@link UnsupportedOperationException}. You must + * subclass FileProvider if you want to provide different functionality. + */ + @Override + public Uri insert(@NonNull Uri uri, @NonNull ContentValues values) { + throw new UnsupportedOperationException("No external inserts"); + } + + /** + * By default, this method throws an {@link UnsupportedOperationException}. You must + * subclass FileProvider if you want to provide different functionality. + */ + @Override + public int update(@NonNull Uri uri, @NonNull ContentValues values, @Nullable String selection, + @Nullable String[] selectionArgs) { + throw new UnsupportedOperationException("No external updates"); + } + + /** + * Deletes the file associated with the specified content URI, as + * returned by {@link #getUriForFile(Context, String, File) getUriForFile()}. Notice that this + * method does not throw an {@link IOException}; you must check its return value. + * + * @param uri A content URI for a file, as returned by + * {@link #getUriForFile(Context, String, File) getUriForFile()}. + * @param selection Ignored. Set to {@code null}. + * @param selectionArgs Ignored. Set to {@code null}. + * @return 1 if the delete succeeds; otherwise, 0. + */ + @Override + public int delete(@NonNull Uri uri, @Nullable String selection, + @Nullable String[] selectionArgs) { + // ContentProvider has already checked granted permissions + final File file = getLocalPathStrategy().getFileForUri(uri); + return file.delete() ? 1 : 0; + } + + /** + * By default, FileProvider automatically returns the + * {@link ParcelFileDescriptor} for a file associated with a content:// + * {@link Uri}. To get the {@link ParcelFileDescriptor}, call + * {@link ContentResolver#openFileDescriptor(Uri, String) + * ContentResolver.openFileDescriptor}. + * + * To override this method, you must provide your own subclass of FileProvider. + * + * @param uri A content URI associated with a file, as returned by + * {@link #getUriForFile(Context, String, File) getUriForFile()}. + * @param mode Access mode for the file. May be "r" for read-only access, "rw" for read and + * write access, or "rwt" for read and write access that truncates any existing file. + * @return A new {@link ParcelFileDescriptor} with which you can access the file. + */ + @SuppressLint("UnknownNullness") // b/171012356 + @Override + public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode) + throws FileNotFoundException { + // ContentProvider has already checked granted permissions + final File file = getLocalPathStrategy().getFileForUri(uri); + final int fileMode = modeToMode(mode); + return ParcelFileDescriptor.open(file, fileMode); + } + + /** Return the local {@link PathStrategy}, creating it if necessary. */ + private PathStrategy getLocalPathStrategy() { + synchronized (this) { + if (mLocalPathStrategy == null) { + mLocalPathStrategy = getPathStrategy(getContext(), mAuthority, mResourceId); + } + + return mLocalPathStrategy; + } + } + + /** + * Return {@link PathStrategy} for given authority, either by parsing or + * returning from cache. + */ + private static PathStrategy getPathStrategy(Context context, String authority, int resourceId) { + PathStrategy strat; + synchronized (sCache) { + strat = sCache.get(authority); + if (strat == null) { + try { + strat = parsePathStrategy(context, authority, resourceId); + } catch (IOException e) { + throw new IllegalArgumentException( + "Failed to parse " + META_DATA_FILE_PROVIDER_PATHS + " meta-data", e); + } catch (XmlPullParserException e) { + throw new IllegalArgumentException( + "Failed to parse " + META_DATA_FILE_PROVIDER_PATHS + " meta-data", e); + } + sCache.put(authority, strat); + } + } + return strat; + } + + @VisibleForTesting + static XmlResourceParser getFileProviderPathsMetaData(Context context, String authority, + @Nullable ProviderInfo info, + int resourceId) { + if (info == null) { + throw new IllegalArgumentException( + "Couldn't find meta-data for provider with authority " + authority); + } + + if (info.metaData == null && resourceId != 0) { + info.metaData = new Bundle(1); + info.metaData.putInt(META_DATA_FILE_PROVIDER_PATHS, resourceId); + } + + final XmlResourceParser in = info.loadXmlMetaData( + context.getPackageManager(), META_DATA_FILE_PROVIDER_PATHS); + if (in == null) { + throw new IllegalArgumentException( + "Missing " + META_DATA_FILE_PROVIDER_PATHS + " meta-data"); + } + + return in; + } + + /** + * Parse and return {@link PathStrategy} for given authority as defined in + * {@link #META_DATA_FILE_PROVIDER_PATHS} {@code }. + * + * @see #getPathStrategy(Context, String, int) + */ + private static PathStrategy parsePathStrategy(Context context, String authority, int resourceId) + throws IOException, XmlPullParserException { + final SimplePathStrategy strat = new SimplePathStrategy(authority); + + final ProviderInfo info = context.getPackageManager() + .resolveContentProvider(authority, PackageManager.GET_META_DATA); + final XmlResourceParser in = getFileProviderPathsMetaData(context, authority, info, + resourceId); + + int type; + while ((type = in.next()) != END_DOCUMENT) { + if (type == START_TAG) { + final String tag = in.getName(); + + final String name = in.getAttributeValue(null, ATTR_NAME); + String path = in.getAttributeValue(null, ATTR_PATH); + + File target = null; + if (TAG_ROOT_PATH.equals(tag)) { + target = DEVICE_ROOT; + } else if (TAG_FILES_PATH.equals(tag)) { + target = context.getFilesDir(); + } else if (TAG_CACHE_PATH.equals(tag)) { + target = context.getCacheDir(); + } else if (TAG_EXTERNAL.equals(tag)) { + target = Environment.getExternalStorageDirectory(); + } else if (TAG_EXTERNAL_FILES.equals(tag)) { + File[] externalFilesDirs = context.getExternalFilesDirs(null); + if (externalFilesDirs.length > 0) { + target = externalFilesDirs[0]; + } + } else if (TAG_EXTERNAL_CACHE.equals(tag)) { + File[] externalCacheDirs = context.getExternalCacheDirs(); + if (externalCacheDirs.length > 0) { + target = externalCacheDirs[0]; + } + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP + && TAG_EXTERNAL_MEDIA.equals(tag)) { + File[] externalMediaDirs = Api21Impl.getExternalMediaDirs(context); + if (externalMediaDirs.length > 0) { + target = externalMediaDirs[0]; + } + } + + if (target != null) { + strat.addRoot(name, buildPath(target, path)); + } + } + } + + return strat; + } + + /** + * Strategy for mapping between {@link File} and {@link Uri}. + *

+ * Strategies must be symmetric so that mapping a {@link File} to a + * {@link Uri} and then back to a {@link File} points at the original + * target. + *

+ * Strategies must remain consistent across app launches, and not rely on + * dynamic state. This ensures that any generated {@link Uri} can still be + * resolved if your process is killed and later restarted. + * + * @see SimplePathStrategy + */ + interface PathStrategy { + /** + * Return a {@link Uri} that represents the given {@link File}. + */ + Uri getUriForFile(File file); + + /** + * Return a {@link File} that represents the given {@link Uri}. + */ + File getFileForUri(Uri uri); + } + + /** + * Strategy that provides access to files living under a narrow allowed list + * of filesystem roots. It will throw {@link SecurityException} if callers try + * accessing files outside the configured roots. + *

+ * For example, if configured with + * {@code addRoot("myfiles", context.getFilesDir())}, then + * {@code context.getFileStreamPath("foo.txt")} would map to + * {@code content://myauthority/myfiles/foo.txt}. + */ + static class SimplePathStrategy implements PathStrategy { + private final String mAuthority; + private final HashMap mRoots = new HashMap<>(); + + SimplePathStrategy(String authority) { + mAuthority = authority; + } + + /** + * Add a mapping from a name to a filesystem root. The provider only offers + * access to files that live under configured roots. + */ + void addRoot(String name, File root) { + if (TextUtils.isEmpty(name)) { + throw new IllegalArgumentException("Name must not be empty"); + } + + try { + // Resolve to canonical path to keep path checking fast + root = root.getCanonicalFile(); + } catch (IOException e) { + throw new IllegalArgumentException( + "Failed to resolve canonical path for " + root, e); + } + + mRoots.put(name, root); + } + + @Override + public Uri getUriForFile(File file) { + String path; + try { + path = file.getCanonicalPath(); + } catch (IOException e) { + throw new IllegalArgumentException("Failed to resolve canonical path for " + file); + } + + // Find the most-specific root path + Map.Entry mostSpecific = null; + for (Map.Entry root : mRoots.entrySet()) { + final String rootPath = root.getValue().getPath(); + if (path.startsWith(rootPath) && (mostSpecific == null + || rootPath.length() > mostSpecific.getValue().getPath().length())) { + mostSpecific = root; + } + } + + if (mostSpecific == null) { + throw new IllegalArgumentException( + "Failed to find configured root that contains " + path); + } + + // Start at first char of path under root + final String rootPath = mostSpecific.getValue().getPath(); + if (rootPath.endsWith("/")) { + path = path.substring(rootPath.length()); + } else { + path = path.substring(rootPath.length() + 1); + } + + // Encode the tag and path separately + path = Uri.encode(mostSpecific.getKey()) + '/' + Uri.encode(path, "/"); + return new Uri.Builder().scheme("content") + .authority(mAuthority).encodedPath(path).build(); + } + + @Override + public File getFileForUri(Uri uri) { + String path = uri.getEncodedPath(); + + final int splitIndex = path.indexOf('/', 1); + final String tag = Uri.decode(path.substring(1, splitIndex)); + path = Uri.decode(path.substring(splitIndex + 1)); + + final File root = mRoots.get(tag); + if (root == null) { + throw new IllegalArgumentException("Unable to find configured root for " + uri); + } + + File file = new File(root, path); + try { + file = file.getCanonicalFile(); + } catch (IOException e) { + throw new IllegalArgumentException("Failed to resolve canonical path for " + file); + } + + if (!file.getPath().startsWith(root.getPath())) { + throw new SecurityException("Resolved path jumped beyond configured root"); + } + + return file; + } + } + + /** + * Copied from ContentResolver.java + */ + private static int modeToMode(String mode) { + int modeBits; + if ("r".equals(mode)) { + modeBits = ParcelFileDescriptor.MODE_READ_ONLY; + } else if ("w".equals(mode) || "wt".equals(mode)) { + modeBits = ParcelFileDescriptor.MODE_WRITE_ONLY + | ParcelFileDescriptor.MODE_CREATE + | ParcelFileDescriptor.MODE_TRUNCATE; + } else if ("wa".equals(mode)) { + modeBits = ParcelFileDescriptor.MODE_WRITE_ONLY + | ParcelFileDescriptor.MODE_CREATE + | ParcelFileDescriptor.MODE_APPEND; + } else if ("rw".equals(mode)) { + modeBits = ParcelFileDescriptor.MODE_READ_WRITE + | ParcelFileDescriptor.MODE_CREATE; + } else if ("rwt".equals(mode)) { + modeBits = ParcelFileDescriptor.MODE_READ_WRITE + | ParcelFileDescriptor.MODE_CREATE + | ParcelFileDescriptor.MODE_TRUNCATE; + } else { + throw new IllegalArgumentException("Invalid mode: " + mode); + } + return modeBits; + } + + private static File buildPath(File base, String... segments) { + File cur = base; + for (String segment : segments) { + if (segment != null) { + cur = new File(cur, segment); + } + } + return cur; + } + + private static String[] copyOf(String[] original, int newLength) { + final String[] result = new String[newLength]; + System.arraycopy(original, 0, result, 0, newLength); + return result; + } + + private static Object[] copyOf(Object[] original, int newLength) { + final Object[] result = new Object[newLength]; + System.arraycopy(original, 0, result, 0, newLength); + return result; + } + + @RequiresApi(21) + static class Api21Impl { + private Api21Impl() { + // This class is not instantiable. + } + + @DoNotInline + static File[] getExternalMediaDirs(Context context) { + // Deprecated, otherwise this would belong on context as a public method. + return context.getExternalMediaDirs(); + } + } +} \ No newline at end of file diff --git a/mastodon/src/main/java/org/joinmastodon/android/utils/MastodonLanguage.java b/mastodon/src/main/java/org/joinmastodon/android/utils/MastodonLanguage.java index bcf588ec8..83135ffcd 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/utils/MastodonLanguage.java +++ b/mastodon/src/main/java/org/joinmastodon/android/utils/MastodonLanguage.java @@ -95,7 +95,7 @@ public class MastodonLanguage { private final MastodonLanguage fallbackLanguage; public LanguageResolver(Instance instanceInfo) { - String fallbackLanguageTag = (instanceInfo.languages != null && !instanceInfo.languages.isEmpty()) ? instanceInfo.languages.get(0) : ENGLISH.languageTag; + String fallbackLanguageTag = (instanceInfo != null && instanceInfo.languages != null && !instanceInfo.languages.isEmpty()) ? instanceInfo.languages.get(0) : ENGLISH.languageTag; fallbackLanguage = allLanguages.stream() .filter(l->l.languageTag.equalsIgnoreCase(fallbackLanguageTag)).findAny() .orElse(ENGLISH); diff --git a/mastodon/src/main/java/org/joinmastodon/android/utils/StatusFilterPredicate.java b/mastodon/src/main/java/org/joinmastodon/android/utils/StatusFilterPredicate.java deleted file mode 100644 index 814c9f8f9..000000000 --- a/mastodon/src/main/java/org/joinmastodon/android/utils/StatusFilterPredicate.java +++ /dev/null @@ -1,87 +0,0 @@ -package org.joinmastodon.android.utils; - -import org.joinmastodon.android.api.session.AccountSessionManager; -import org.joinmastodon.android.model.LegacyFilter; -import org.joinmastodon.android.model.FilterAction; -import org.joinmastodon.android.model.FilterContext; -import org.joinmastodon.android.model.Status; - -import java.time.Instant; -import java.util.List; -import java.util.Optional; -import java.util.function.Predicate; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -public class StatusFilterPredicate implements Predicate{ - private final List filters; - private final FilterContext context; - private final FilterAction action; - private LegacyFilter applyingFilter; - - /** - * @param context null makes the predicate pass automatically - * @param action defines what the predicate should check: - * status should not be hidden or should not display with warning - */ - public StatusFilterPredicate(List filters, FilterContext context, FilterAction action){ - this.filters = filters; - this.context = context; - this.action = action; - } - - public StatusFilterPredicate(List filters, FilterContext context){ - this(filters, context, FilterAction.HIDE); - } - - /** - * @param context null makes the predicate pass automatically - * @param action defines what the predicate should check: - * status should not be hidden or should not display with warning - */ - public StatusFilterPredicate(String accountID, FilterContext context, FilterAction action){ - filters=AccountSessionManager.getInstance().getAccount(accountID).wordFilters.stream().filter(f->f.context.contains(context)).collect(Collectors.toList()); - this.context = context; - this.action = action; - } - - /** - * @param context null makes the predicate pass automatically - */ - public StatusFilterPredicate(String accountID, FilterContext context){ - this(accountID, context, FilterAction.HIDE); - } - - /** - * @return whether the status should be displayed without being hidden/warned about. - * will always return true if the context is null. - * true = display this status, - * false = filter this status - */ - @Override - public boolean test(Status status){ - if (context == null) return true; - - Stream matchingFilters = status.filtered != null - // use server-provided per-status info (status.filtered) if available - ? status.filtered.stream().map(f -> f.filter) - // or fall back to cached filters - : filters.stream().filter(filter -> filter.matches(status)); - - Optional applyingFilter = matchingFilters - // discard expired filters - .filter(filter -> filter.expiresAt == null || filter.expiresAt.isAfter(Instant.now())) - // only apply filters for given context - .filter(filter -> filter.context.contains(context)) - // treating filterAction = null (from filters list) as FilterAction.HIDE - .filter(filter -> filter.filterAction == null ? action == FilterAction.HIDE : filter.filterAction == action) - .findAny(); - - this.applyingFilter = applyingFilter.orElse(null); - return applyingFilter.isEmpty(); - } - - public LegacyFilter getApplyingFilter() { - return applyingFilter; - } -} diff --git a/mastodon/src/main/java/org/joinmastodon/android/utils/Tracking.java b/mastodon/src/main/java/org/joinmastodon/android/utils/Tracking.java new file mode 100644 index 000000000..4edbe3798 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/utils/Tracking.java @@ -0,0 +1,107 @@ +package org.joinmastodon.android.utils; + +import android.net.Uri; +import android.util.Patterns; + +import androidx.annotation.NonNull; + +import java.util.Arrays; +import java.util.regex.Matcher; + +// Inspired by https://github.com/GeopJr/Tuba/blob/91a036edff9ab1ffb38d5b54a33023e5db551051/src/Utils/Tracking.vala + +public class Tracking{ + /* https://github.com/brave/brave-core/blob/face8d58ab81422480c8c05b9ba5d518e1a2d227/components/query_filter/utils.cc#L23-L119 */ + private static final String[] TRACKING_IDS={ + // Strip any utm_ based ones + "utm_", + // https://github.com/brave/brave-browser/issues/4239 + "fbclid", "gclid", "msclkid", "mc_eid", + // New Facebook one + "mibexid", + // https://github.com/brave/brave-browser/issues/9879 + "dclid", + // https://github.com/brave/brave-browser/issues/13644 + "oly_anon_id", "oly_enc_id", + // https://github.com/brave/brave-browser/issues/11579 + "_openstat", + // https://github.com/brave/brave-browser/issues/11817 + "vero_conv", "vero_id", + // https://github.com/brave/brave-browser/issues/13647 + "wickedid", + // https://github.com/brave/brave-browser/issues/11578 + "yclid", + // https://github.com/brave/brave-browser/issues/8975 + "__s", + // https://github.com/brave/brave-browser/issues/17451 + "rb_clickid", + // https://github.com/brave/brave-browser/issues/17452 + "s_cid", + // https://github.com/brave/brave-browser/issues/17507 + "ml_subscriber", "ml_subscriber_hash", + // https://github.com/brave/brave-browser/issues/18020 + "twclid", + // https://github.com/brave/brave-browser/issues/18758 + "gbraid", "wbraid", + // https://github.com/brave/brave-browser/issues/9019 + "_hsenc", "__hssc", "__hstc", "__hsfp", "hsCtaTracking", + // https://github.com/brave/brave-browser/issues/22082 + "oft_id", "oft_k", "oft_lk", "oft_d", "oft_c", "oft_ck", "oft_ids", "oft_sk", + // https://github.com/brave/brave-browser/issues/11580 + "igshid", + // Instagram Threads + "ad_id", "adset_id", "campaign_id", "ad_name", "adset_name", "campaign_name", "placement", + // Reddit + "share_id", "ref", "ref_share", + }; + + /** + * Tries to remove tracking parameters from a URL. + * + * @param url The original URL with tracking parameters + * @return The URL with the tracking parameters removed. + */ + @NonNull + public static String removeTrackingParameters(@NonNull String url){ + Uri uri=Uri.parse(url); + if(uri==null || !uri.isHierarchical()) + return url; + Uri.Builder uriBuilder=uri.buildUpon().clearQuery(); + + // Iterate over existing parameters and add them back if they are not tracking parameters + for(String paramName : uri.getQueryParameterNames()){ + if(!isTrackingParameter(paramName)){ + for(String paramValue : uri.getQueryParameters(paramName)){ + uriBuilder.appendQueryParameter(paramName, paramValue); + } + } + } + + return uriBuilder.build().toString(); + } + + /** + * Cleans URLs within the provided text, removing the tracking parameters from them. + * + * @param text The text that may contain URLs. + * @return The given text with cleaned URLs. + */ + public static String cleanUrlsInText(String text){ + Matcher matcher=Patterns.WEB_URL.matcher(text); + StringBuffer sb=new StringBuffer(); + + while(matcher.find()){ + String url=matcher.group(); + matcher.appendReplacement(sb, removeTrackingParameters(url)); + } + matcher.appendTail(sb); + return sb.toString(); + } + + /** + * Returns true if the given parameter is used for tracking. + */ + private static boolean isTrackingParameter(String parameter){ + return Arrays.stream(TRACKING_IDS).anyMatch(trackingId->parameter.toLowerCase().contains(trackingId)); + } +} \ No newline at end of file diff --git a/mastodon/src/main/res/animator/squish.xml b/mastodon/src/main/res/animator/squish.xml new file mode 100644 index 000000000..19f77360d --- /dev/null +++ b/mastodon/src/main/res/animator/squish.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/color/bookmark_icon.xml b/mastodon/src/main/res/color/bookmark_icon.xml index 442c8fbbc..2b6f9dd7d 100644 --- a/mastodon/src/main/res/color/bookmark_icon.xml +++ b/mastodon/src/main/res/color/bookmark_icon.xml @@ -1,5 +1,5 @@ - + \ No newline at end of file diff --git a/mastodon/src/main/res/color/compose_button.xml b/mastodon/src/main/res/color/compose_button.xml index c8c71a186..a261383f4 100644 --- a/mastodon/src/main/res/color/compose_button.xml +++ b/mastodon/src/main/res/color/compose_button.xml @@ -1,6 +1,5 @@ - - - + + \ No newline at end of file diff --git a/mastodon/src/main/res/color/favorite_icon.xml b/mastodon/src/main/res/color/favorite_icon.xml index 201576cd7..a459076eb 100644 --- a/mastodon/src/main/res/color/favorite_icon.xml +++ b/mastodon/src/main/res/color/favorite_icon.xml @@ -1,5 +1,5 @@ - + \ No newline at end of file diff --git a/mastodon/src/main/res/color/like_icon.xml b/mastodon/src/main/res/color/like_icon.xml index 242d79986..e0346ce88 100644 --- a/mastodon/src/main/res/color/like_icon.xml +++ b/mastodon/src/main/res/color/like_icon.xml @@ -1,5 +1,5 @@ - + \ No newline at end of file diff --git a/mastodon/src/main/res/color/m3_on_secondary_container_overlay.xml b/mastodon/src/main/res/color/m3_on_secondary_container_overlay.xml index f93eec7c8..db014251d 100644 --- a/mastodon/src/main/res/color/m3_on_secondary_container_overlay.xml +++ b/mastodon/src/main/res/color/m3_on_secondary_container_overlay.xml @@ -1,4 +1,4 @@ - + \ No newline at end of file diff --git a/mastodon/src/main/res/color/m3_on_surface_alpha12.xml b/mastodon/src/main/res/color/m3_on_surface_alpha12.xml new file mode 100644 index 000000000..371f86123 --- /dev/null +++ b/mastodon/src/main/res/color/m3_on_surface_alpha12.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/color/translate_icon.xml b/mastodon/src/main/res/color/translate_icon.xml new file mode 100644 index 000000000..bb9ba30d6 --- /dev/null +++ b/mastodon/src/main/res/color/translate_icon.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable-anydpi-v24/ic_ntf_logo.xml b/mastodon/src/main/res/drawable-anydpi-v24/ic_ntf_logo.xml new file mode 100644 index 000000000..bddf44291 --- /dev/null +++ b/mastodon/src/main/res/drawable-anydpi-v24/ic_ntf_logo.xml @@ -0,0 +1,22 @@ + + + + + + diff --git a/mastodon/src/main/res/drawable-anydpi-v26/ic_launcher_foreground.xml b/mastodon/src/main/res/drawable-anydpi-v26/ic_launcher_foreground.xml index bcac18917..838912036 100644 --- a/mastodon/src/main/res/drawable-anydpi-v26/ic_launcher_foreground.xml +++ b/mastodon/src/main/res/drawable-anydpi-v26/ic_launcher_foreground.xml @@ -1,21 +1,25 @@ - - - - - - - - - + xmlns:aapt="http://schemas.android.com/aapt" + android:width="108dp" + android:height="108dp" + android:viewportWidth="43.043" + android:viewportHeight="43.043"> + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable-hdpi/ic_ntf_logo.png b/mastodon/src/main/res/drawable-hdpi/ic_ntf_logo.png new file mode 100644 index 000000000..75650e517 Binary files /dev/null and b/mastodon/src/main/res/drawable-hdpi/ic_ntf_logo.png differ diff --git a/mastodon/src/main/res/drawable-mdpi/ic_ntf_logo.png b/mastodon/src/main/res/drawable-mdpi/ic_ntf_logo.png new file mode 100644 index 000000000..4ab4d9158 Binary files /dev/null and b/mastodon/src/main/res/drawable-mdpi/ic_ntf_logo.png differ diff --git a/mastodon/src/main/res/drawable-v24/ic_launcher_monochrome.xml b/mastodon/src/main/res/drawable-v24/ic_launcher_monochrome.xml index 0a4d6a75f..19df2cdce 100644 --- a/mastodon/src/main/res/drawable-v24/ic_launcher_monochrome.xml +++ b/mastodon/src/main/res/drawable-v24/ic_launcher_monochrome.xml @@ -1,5 +1,21 @@ - - - + + + + + + diff --git a/mastodon/src/main/res/drawable-xhdpi/ic_ntf_logo.png b/mastodon/src/main/res/drawable-xhdpi/ic_ntf_logo.png new file mode 100644 index 000000000..0fcae9442 Binary files /dev/null and b/mastodon/src/main/res/drawable-xhdpi/ic_ntf_logo.png differ diff --git a/mastodon/src/main/res/drawable-xxhdpi/ic_ntf_logo.png b/mastodon/src/main/res/drawable-xxhdpi/ic_ntf_logo.png new file mode 100644 index 000000000..f5497cbf3 Binary files /dev/null and b/mastodon/src/main/res/drawable-xxhdpi/ic_ntf_logo.png differ diff --git a/mastodon/src/main/res/drawable-xxxhdpi/ic_fluent_task_list_ltr_24_regular.xml b/mastodon/src/main/res/drawable-xxxhdpi/ic_fluent_task_list_ltr_24_regular.xml new file mode 100644 index 000000000..d000a4700 --- /dev/null +++ b/mastodon/src/main/res/drawable-xxxhdpi/ic_fluent_task_list_ltr_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/bg_bottom_sheet.xml b/mastodon/src/main/res/drawable/bg_bottom_sheet.xml index bf1a899ef..92ee0279c 100644 --- a/mastodon/src/main/res/drawable/bg_bottom_sheet.xml +++ b/mastodon/src/main/res/drawable/bg_bottom_sheet.xml @@ -1,15 +1,9 @@ - - + + - - - - - - - + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/bg_button_m3_elevated.xml b/mastodon/src/main/res/drawable/bg_button_m3_elevated.xml new file mode 100644 index 000000000..9354428dc --- /dev/null +++ b/mastodon/src/main/res/drawable/bg_button_m3_elevated.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/bg_button_m3_filled.xml b/mastodon/src/main/res/drawable/bg_button_m3_filled.xml index d3eacf621..21be7cacd 100644 --- a/mastodon/src/main/res/drawable/bg_button_m3_filled.xml +++ b/mastodon/src/main/res/drawable/bg_button_m3_filled.xml @@ -14,7 +14,7 @@ - + diff --git a/mastodon/src/main/res/drawable/bg_button_m3_filled_icon.xml b/mastodon/src/main/res/drawable/bg_button_m3_filled_icon.xml new file mode 100644 index 000000000..2c4ea8a12 --- /dev/null +++ b/mastodon/src/main/res/drawable/bg_button_m3_filled_icon.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/bg_button_m3_icon_label.xml b/mastodon/src/main/res/drawable/bg_button_m3_icon_label.xml new file mode 100644 index 000000000..73eec74d5 --- /dev/null +++ b/mastodon/src/main/res/drawable/bg_button_m3_icon_label.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/bg_button_m3_tonal_icon.xml b/mastodon/src/main/res/drawable/bg_button_m3_tonal_icon.xml new file mode 100644 index 000000000..81a9482a9 --- /dev/null +++ b/mastodon/src/main/res/drawable/bg_button_m3_tonal_icon.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/bg_handle_help.xml b/mastodon/src/main/res/drawable/bg_handle_help.xml new file mode 100644 index 000000000..ebfec2f2a --- /dev/null +++ b/mastodon/src/main/res/drawable/bg_handle_help.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/bg_m3_filled_text_field.xml b/mastodon/src/main/res/drawable/bg_m3_filled_text_field.xml new file mode 100644 index 000000000..a2159e9af --- /dev/null +++ b/mastodon/src/main/res/drawable/bg_m3_filled_text_field.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/bg_m3_filled_text_field_error.xml b/mastodon/src/main/res/drawable/bg_m3_filled_text_field_error.xml new file mode 100644 index 000000000..6c18cd7d2 --- /dev/null +++ b/mastodon/src/main/res/drawable/bg_m3_filled_text_field_error.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/bg_note_edit.xml b/mastodon/src/main/res/drawable/bg_note_edit.xml index 721d75923..536ee0367 100644 --- a/mastodon/src/main/res/drawable/bg_note_edit.xml +++ b/mastodon/src/main/res/drawable/bg_note_edit.xml @@ -3,5 +3,5 @@ android:tint="@color/m3_primary_alpha11" android:tintMode="src_over"> - + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/bg_popup.xml b/mastodon/src/main/res/drawable/bg_popup.xml index b1adf556f..238e36090 100644 --- a/mastodon/src/main/res/drawable/bg_popup.xml +++ b/mastodon/src/main/res/drawable/bg_popup.xml @@ -3,13 +3,13 @@ - + - + diff --git a/mastodon/src/main/res/drawable/bg_rect_ripple.xml b/mastodon/src/main/res/drawable/bg_rect_ripple.xml new file mode 100644 index 000000000..a6322aabf --- /dev/null +++ b/mastodon/src/main/res/drawable/bg_rect_ripple.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/bg_search_field.xml b/mastodon/src/main/res/drawable/bg_search_field.xml index 3347a9d6b..02fc62d8c 100644 --- a/mastodon/src/main/res/drawable/bg_search_field.xml +++ b/mastodon/src/main/res/drawable/bg_search_field.xml @@ -1,5 +1,5 @@ - - + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/bg_spinner.xml b/mastodon/src/main/res/drawable/bg_spinner.xml new file mode 100644 index 000000000..0cfe96358 --- /dev/null +++ b/mastodon/src/main/res/drawable/bg_spinner.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/bg_user_info.xml b/mastodon/src/main/res/drawable/bg_user_info.xml new file mode 100644 index 000000000..c0fa41703 --- /dev/null +++ b/mastodon/src/main/res/drawable/bg_user_info.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/divider_inset_16dp.xml b/mastodon/src/main/res/drawable/divider_inset_16dp.xml new file mode 100644 index 000000000..15394d995 --- /dev/null +++ b/mastodon/src/main/res/drawable/divider_inset_16dp.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/fg_link_card.xml b/mastodon/src/main/res/drawable/fg_link_card.xml new file mode 100644 index 000000000..5b4f100f8 --- /dev/null +++ b/mastodon/src/main/res/drawable/fg_link_card.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/fg_onboarding_ava.xml b/mastodon/src/main/res/drawable/fg_onboarding_ava.xml new file mode 100644 index 000000000..c0dbe1234 --- /dev/null +++ b/mastodon/src/main/res/drawable/fg_onboarding_ava.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/fg_user_info_ava.xml b/mastodon/src/main/res/drawable/fg_user_info_ava.xml new file mode 100644 index 000000000..e36ace291 --- /dev/null +++ b/mastodon/src/main/res/drawable/fg_user_info_ava.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/ic_arrow_drop_down_24px.xml b/mastodon/src/main/res/drawable/ic_arrow_drop_down_24px.xml new file mode 100644 index 000000000..56d1b3b04 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_arrow_drop_down_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_arrow_right_24px.xml b/mastodon/src/main/res/drawable/ic_arrow_right_24px.xml new file mode 100644 index 000000000..55e7c6463 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_arrow_right_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_arrow_upward_24px.xml b/mastodon/src/main/res/drawable/ic_arrow_upward_24px.xml new file mode 100644 index 000000000..15d81d9d6 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_arrow_upward_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_badge_24px.xml b/mastodon/src/main/res/drawable/ic_badge_24px.xml new file mode 100644 index 000000000..489eff0b5 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_badge_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_bookmark_fill1_24px.xml b/mastodon/src/main/res/drawable/ic_bookmark_fill1_24px.xml new file mode 100644 index 000000000..2b286bbc1 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_bookmark_fill1_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_boost_24px.xml b/mastodon/src/main/res/drawable/ic_boost_24px.xml new file mode 100644 index 000000000..40373a17d --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_boost_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_boost_disabled_24px.xml b/mastodon/src/main/res/drawable/ic_boost_disabled_24px.xml new file mode 100644 index 000000000..859b454e8 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_boost_disabled_24px.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/mastodon/src/main/res/drawable/ic_boost_fill_alt_24px.xml b/mastodon/src/main/res/drawable/ic_boost_fill_alt_24px.xml new file mode 100644 index 000000000..259725654 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_boost_fill_alt_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_boost_private.xml b/mastodon/src/main/res/drawable/ic_boost_private.xml new file mode 100644 index 000000000..6a2187dc9 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_boost_private.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/ic_bot.xml b/mastodon/src/main/res/drawable/ic_bot.xml new file mode 100644 index 000000000..9095124ed --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_bot.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/ic_campaign_24px.xml b/mastodon/src/main/res/drawable/ic_campaign_24px.xml new file mode 100644 index 000000000..0f34633b9 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_campaign_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_category_academia.xml b/mastodon/src/main/res/drawable/ic_category_academia.xml deleted file mode 100644 index 4e0ac054a..000000000 --- a/mastodon/src/main/res/drawable/ic_category_academia.xml +++ /dev/null @@ -1,42 +0,0 @@ - - - - - - - - - - - - - - diff --git a/mastodon/src/main/res/drawable/ic_category_activism.xml b/mastodon/src/main/res/drawable/ic_category_activism.xml deleted file mode 100644 index 5001d06f8..000000000 --- a/mastodon/src/main/res/drawable/ic_category_activism.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - diff --git a/mastodon/src/main/res/drawable/ic_category_all.xml b/mastodon/src/main/res/drawable/ic_category_all.xml deleted file mode 100644 index f94bf550c..000000000 --- a/mastodon/src/main/res/drawable/ic_category_all.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - diff --git a/mastodon/src/main/res/drawable/ic_category_art.xml b/mastodon/src/main/res/drawable/ic_category_art.xml deleted file mode 100644 index f0b8d0bc3..000000000 --- a/mastodon/src/main/res/drawable/ic_category_art.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - diff --git a/mastodon/src/main/res/drawable/ic_category_food.xml b/mastodon/src/main/res/drawable/ic_category_food.xml deleted file mode 100644 index a514aeb3d..000000000 --- a/mastodon/src/main/res/drawable/ic_category_food.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - diff --git a/mastodon/src/main/res/drawable/ic_category_furry.xml b/mastodon/src/main/res/drawable/ic_category_furry.xml deleted file mode 100644 index b97a508fc..000000000 --- a/mastodon/src/main/res/drawable/ic_category_furry.xml +++ /dev/null @@ -1,54 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/mastodon/src/main/res/drawable/ic_category_games.xml b/mastodon/src/main/res/drawable/ic_category_games.xml deleted file mode 100644 index e09596a00..000000000 --- a/mastodon/src/main/res/drawable/ic_category_games.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - diff --git a/mastodon/src/main/res/drawable/ic_category_general.xml b/mastodon/src/main/res/drawable/ic_category_general.xml deleted file mode 100644 index d0a2de42c..000000000 --- a/mastodon/src/main/res/drawable/ic_category_general.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - diff --git a/mastodon/src/main/res/drawable/ic_category_journalism.xml b/mastodon/src/main/res/drawable/ic_category_journalism.xml deleted file mode 100644 index 7decfc895..000000000 --- a/mastodon/src/main/res/drawable/ic_category_journalism.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - diff --git a/mastodon/src/main/res/drawable/ic_category_lgbt.xml b/mastodon/src/main/res/drawable/ic_category_lgbt.xml deleted file mode 100644 index 80b9af044..000000000 --- a/mastodon/src/main/res/drawable/ic_category_lgbt.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - diff --git a/mastodon/src/main/res/drawable/ic_category_music.xml b/mastodon/src/main/res/drawable/ic_category_music.xml deleted file mode 100644 index a2018b4e9..000000000 --- a/mastodon/src/main/res/drawable/ic_category_music.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/mastodon/src/main/res/drawable/ic_category_regional.xml b/mastodon/src/main/res/drawable/ic_category_regional.xml deleted file mode 100644 index bd128fba5..000000000 --- a/mastodon/src/main/res/drawable/ic_category_regional.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - diff --git a/mastodon/src/main/res/drawable/ic_category_tech.xml b/mastodon/src/main/res/drawable/ic_category_tech.xml deleted file mode 100644 index e9e82f891..000000000 --- a/mastodon/src/main/res/drawable/ic_category_tech.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/mastodon/src/main/res/drawable/ic_category_unknown.xml b/mastodon/src/main/res/drawable/ic_category_unknown.xml deleted file mode 100644 index 8f76682f5..000000000 --- a/mastodon/src/main/res/drawable/ic_category_unknown.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/mastodon/src/main/res/drawable/ic_confirmation_number_24px.xml b/mastodon/src/main/res/drawable/ic_confirmation_number_24px.xml new file mode 100644 index 000000000..d5c4b03f3 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_confirmation_number_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_feed_48px.xml b/mastodon/src/main/res/drawable/ic_feed_48px.xml new file mode 100644 index 000000000..72fc5dc66 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_feed_48px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_alert_24_selector.xml b/mastodon/src/main/res/drawable/ic_fluent_alert_24_selector.xml index 7d2eb9fe8..f7f922cdd 100644 --- a/mastodon/src/main/res/drawable/ic_fluent_alert_24_selector.xml +++ b/mastodon/src/main/res/drawable/ic_fluent_alert_24_selector.xml @@ -1,8 +1,8 @@ - - - + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_alert_24_selector_for_tabbar.xml b/mastodon/src/main/res/drawable/ic_fluent_alert_24_selector_for_tabbar.xml new file mode 100644 index 000000000..64e6a9d69 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_alert_24_selector_for_tabbar.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_alert_off_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_alert_off_24_regular.xml new file mode 100644 index 000000000..9a652f86b --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_alert_off_24_regular.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_alert_off_28_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_alert_off_28_regular.xml new file mode 100644 index 000000000..149dcf7c9 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_alert_off_28_regular.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_alert_urgent_24_filled.xml b/mastodon/src/main/res/drawable/ic_fluent_alert_urgent_24_filled.xml new file mode 100644 index 000000000..67d12759d --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_alert_urgent_24_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_arrow_autofit_down_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_arrow_autofit_down_24_regular.xml new file mode 100644 index 000000000..1d39a6993 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_arrow_autofit_down_24_regular.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/ic_fluent_arrow_circle_up_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_arrow_circle_up_24_regular.xml new file mode 100644 index 000000000..aa0de6685 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_arrow_circle_up_24_regular.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/ic_fluent_arrow_export_24_filled.xml b/mastodon/src/main/res/drawable/ic_fluent_arrow_export_24_filled.xml new file mode 100644 index 000000000..d0b0c0b1b --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_arrow_export_24_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_arrow_forward_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_arrow_forward_24_regular.xml index 0a3fb4e2e..7700eb0c7 100644 --- a/mastodon/src/main/res/drawable/ic_fluent_arrow_forward_24_regular.xml +++ b/mastodon/src/main/res/drawable/ic_fluent_arrow_forward_24_regular.xml @@ -1,3 +1,3 @@ - + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/ic_fluent_arrow_import_24_filled.xml b/mastodon/src/main/res/drawable/ic_fluent_arrow_import_24_filled.xml new file mode 100644 index 000000000..267c0b811 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_arrow_import_24_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_arrow_repeat_all_24_very_filled.xml b/mastodon/src/main/res/drawable/ic_fluent_arrow_repeat_all_24_very_filled.xml index f64be4759..18c63baef 100644 --- a/mastodon/src/main/res/drawable/ic_fluent_arrow_repeat_all_24_very_filled.xml +++ b/mastodon/src/main/res/drawable/ic_fluent_arrow_repeat_all_24_very_filled.xml @@ -3,21 +3,21 @@ android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"> - - - - + + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_arrow_up_16_filled.xml b/mastodon/src/main/res/drawable/ic_fluent_arrow_up_16_filled.xml deleted file mode 100644 index 39a248513..000000000 --- a/mastodon/src/main/res/drawable/ic_fluent_arrow_up_16_filled.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/mastodon/src/main/res/drawable/ic_fluent_book_exclamation_mark_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_book_exclamation_mark_24_regular.xml new file mode 100644 index 000000000..9c86090e1 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_book_exclamation_mark_24_regular.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_bot_16_filled.xml b/mastodon/src/main/res/drawable/ic_fluent_bot_16_filled.xml new file mode 100644 index 000000000..e8e056b79 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_bot_16_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_bot_20_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_bot_20_regular.xml new file mode 100644 index 000000000..e182b6fee --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_bot_20_regular.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_bot_24_filled.xml b/mastodon/src/main/res/drawable/ic_fluent_bot_24_filled.xml new file mode 100644 index 000000000..e05adcb6f --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_bot_24_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_camera_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_camera_24_regular.xml new file mode 100644 index 000000000..f48869406 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_camera_24_regular.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_chat_24_filled.xml b/mastodon/src/main/res/drawable/ic_fluent_chat_24_filled.xml index 4757c7084..f9f5537c5 100644 --- a/mastodon/src/main/res/drawable/ic_fluent_chat_24_filled.xml +++ b/mastodon/src/main/res/drawable/ic_fluent_chat_24_filled.xml @@ -1,3 +1,3 @@ - + diff --git a/mastodon/src/main/res/drawable/ic_fluent_chat_multiple_24_regular_text.xml b/mastodon/src/main/res/drawable/ic_fluent_chat_multiple_24_regular_text.xml index d65c346f9..eb2b42580 100644 --- a/mastodon/src/main/res/drawable/ic_fluent_chat_multiple_24_regular_text.xml +++ b/mastodon/src/main/res/drawable/ic_fluent_chat_multiple_24_regular_text.xml @@ -3,13 +3,13 @@ android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"> - - - + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_chat_settings_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_chat_settings_24_regular.xml new file mode 100644 index 000000000..f0c00234f --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_chat_settings_24_regular.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_clock_24_filled_enabled.xml b/mastodon/src/main/res/drawable/ic_fluent_clock_24_filled_enabled.xml new file mode 100644 index 000000000..3501bc6ad --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_clock_24_filled_enabled.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_clock_24_selector.xml b/mastodon/src/main/res/drawable/ic_fluent_clock_24_selector.xml new file mode 100644 index 000000000..8e7826194 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_clock_24_selector.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/ic_fluent_comment_mention_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_comment_mention_24_regular.xml new file mode 100644 index 000000000..fd8802896 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_comment_mention_24_regular.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_compose_28_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_compose_28_regular.xml new file mode 100644 index 000000000..ce4daeecd --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_compose_28_regular.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_custom_alert_latest_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_custom_alert_latest_24_regular.xml new file mode 100644 index 000000000..fef28319b --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_custom_alert_latest_24_regular.xml @@ -0,0 +1,12 @@ + + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_double_tap_swipe_right_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_double_tap_swipe_right_24_regular.xml new file mode 100644 index 000000000..246962e19 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_double_tap_swipe_right_24_regular.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/ic_fluent_double_tap_swipe_up_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_double_tap_swipe_up_24_regular.xml new file mode 100644 index 000000000..9ba137e44 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_double_tap_swipe_up_24_regular.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_drafts_24_selector.xml b/mastodon/src/main/res/drawable/ic_fluent_drafts_24_selector.xml new file mode 100644 index 000000000..b88fafb4b --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_drafts_24_selector.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/ic_fluent_eye_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_eye_24_regular.xml index e1be468be..7cd0b6757 100644 --- a/mastodon/src/main/res/drawable/ic_fluent_eye_24_regular.xml +++ b/mastodon/src/main/res/drawable/ic_fluent_eye_24_regular.xml @@ -1,3 +1,3 @@ - + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/ic_fluent_eye_tracking_off_24_filled.xml b/mastodon/src/main/res/drawable/ic_fluent_eye_tracking_off_24_filled.xml new file mode 100644 index 000000000..06e3a0b90 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_eye_tracking_off_24_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_eye_tracking_on_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_eye_tracking_on_24_regular.xml new file mode 100644 index 000000000..46f2eebbc --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_eye_tracking_on_24_regular.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/ic_fluent_lists_28_selector.xml b/mastodon/src/main/res/drawable/ic_fluent_lists_28_selector.xml new file mode 100644 index 000000000..dc4d0296a --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_lists_28_selector.xml @@ -0,0 +1,5 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_notepad_20_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_notepad_20_regular.xml new file mode 100644 index 000000000..67619d7cf --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_notepad_20_regular.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_notepad_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_notepad_24_regular.xml new file mode 100644 index 000000000..ab746c901 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_notepad_24_regular.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_paint_brush_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_paint_brush_24_regular.xml new file mode 100644 index 000000000..ccb3b275f --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_paint_brush_24_regular.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_person_delete_note_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_person_delete_note_24_regular.xml new file mode 100644 index 000000000..70827606b --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_person_delete_note_24_regular.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_phone_vibrate_24_filled.xml b/mastodon/src/main/res/drawable/ic_fluent_phone_vibrate_24_filled.xml new file mode 100644 index 000000000..c182f3802 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_phone_vibrate_24_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_phone_vibrate_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_phone_vibrate_24_regular.xml new file mode 100644 index 000000000..7d20e5607 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_phone_vibrate_24_regular.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_save_24_filled.xml b/mastodon/src/main/res/drawable/ic_fluent_save_24_filled.xml new file mode 100644 index 000000000..98638052c --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_save_24_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_save_24_selector.xml b/mastodon/src/main/res/drawable/ic_fluent_save_24_selector.xml new file mode 100644 index 000000000..6618d92dd --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_save_24_selector.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/ic_fluent_send_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_send_24_regular.xml index c5a542eaa..a26d4548f 100644 --- a/mastodon/src/main/res/drawable/ic_fluent_send_24_regular.xml +++ b/mastodon/src/main/res/drawable/ic_fluent_send_24_regular.xml @@ -1,3 +1,3 @@ - + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/ic_fluent_shield_prohibited_28_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_shield_prohibited_28_regular.xml new file mode 100644 index 000000000..3c8aaab1e --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_shield_prohibited_28_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_speaker_2_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_speaker_2_24_regular.xml new file mode 100644 index 000000000..8ba6f262d --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_speaker_2_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_speaker_2_28_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_speaker_2_28_regular.xml new file mode 100644 index 000000000..486228e0e --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_speaker_2_28_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_speaker_mute_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_speaker_mute_24_regular.xml new file mode 100644 index 000000000..8cd5f52dc --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_speaker_mute_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_speaker_mute_28_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_speaker_mute_28_regular.xml new file mode 100644 index 000000000..b45d213d3 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_speaker_mute_28_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_translate_24_filled.xml b/mastodon/src/main/res/drawable/ic_fluent_translate_24_filled.xml new file mode 100644 index 000000000..381063145 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_translate_24_filled.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/ic_fluent_translate_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_translate_24_regular.xml index bab48ab1e..d913c311f 100644 --- a/mastodon/src/main/res/drawable/ic_fluent_translate_24_regular.xml +++ b/mastodon/src/main/res/drawable/ic_fluent_translate_24_regular.xml @@ -1,3 +1,3 @@ - + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/ic_fluent_video_clip_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_video_clip_24_regular.xml new file mode 100644 index 000000000..98eeae2db --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_video_clip_24_regular.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_gnome_logo.xml b/mastodon/src/main/res/drawable/ic_gnome_logo.xml new file mode 100644 index 000000000..206422fdf --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_gnome_logo.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_help_24px.xml b/mastodon/src/main/res/drawable/ic_help_24px.xml new file mode 100644 index 000000000..b19cb3450 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_help_24px.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/ic_info_fill1_24px.xml b/mastodon/src/main/res/drawable/ic_info_fill1_24px.xml new file mode 100644 index 000000000..077f39342 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_info_fill1_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_launcher_background.xml b/mastodon/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 000000000..ca3826a46 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mastodon/src/main/res/drawable/ic_launcher_foreground.xml b/mastodon/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 000000000..4bbbc045d --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,25 @@ + + + + + + + diff --git a/mastodon/src/main/res/drawable/ic_launcher_monochrome.xml b/mastodon/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 000000000..7de0ba5ab --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,19 @@ + + + + + + + diff --git a/mastodon/src/main/res/drawable/ic_list_alt_24px.xml b/mastodon/src/main/res/drawable/ic_list_alt_24px.xml new file mode 100644 index 000000000..5341250ce --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_list_alt_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_m3_cancel.xml b/mastodon/src/main/res/drawable/ic_m3_cancel.xml new file mode 100644 index 000000000..d11a326a2 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_m3_cancel.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/ic_more_vert_24px.xml b/mastodon/src/main/res/drawable/ic_more_vert_24px.xml new file mode 100644 index 000000000..bb501448f --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_more_vert_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_ntf_logo.xml b/mastodon/src/main/res/drawable/ic_ntf_logo.xml deleted file mode 100644 index d7eb886b3..000000000 --- a/mastodon/src/main/res/drawable/ic_ntf_logo.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - diff --git a/mastodon/src/main/res/drawable/ic_private_boost_24px.xml b/mastodon/src/main/res/drawable/ic_private_boost_24px.xml new file mode 100644 index 000000000..3c6670a9f --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_private_boost_24px.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/mastodon/src/main/res/drawable/ic_private_boost_fill_alt_24px.xml b/mastodon/src/main/res/drawable/ic_private_boost_fill_alt_24px.xml new file mode 100644 index 000000000..6929d74aa --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_private_boost_fill_alt_24px.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/mastodon/src/main/res/drawable/ic_switch_account_24px.xml b/mastodon/src/main/res/drawable/ic_switch_account_24px.xml new file mode 100644 index 000000000..f496736ba --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_switch_account_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_translate.xml b/mastodon/src/main/res/drawable/ic_translate.xml new file mode 100644 index 000000000..19aaed81b --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_translate.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/ic_waving_hand_24px.xml b/mastodon/src/main/res/drawable/ic_waving_hand_24px.xml new file mode 100644 index 000000000..b43de6812 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_waving_hand_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/logo.xml b/mastodon/src/main/res/drawable/logo.xml index 3420a89f2..aec5187b0 100644 --- a/mastodon/src/main/res/drawable/logo.xml +++ b/mastodon/src/main/res/drawable/logo.xml @@ -1,34 +1,31 @@ + android:width="109.08dp" + android:height="18.02dp" + android:viewportWidth="109.08" + android:viewportHeight="18.02"> - diff --git a/mastodon/src/main/res/drawable/seekbar_video_player.xml b/mastodon/src/main/res/drawable/seekbar_video_player.xml index d3c7ce58d..685a6a9d3 100644 --- a/mastodon/src/main/res/drawable/seekbar_video_player.xml +++ b/mastodon/src/main/res/drawable/seekbar_video_player.xml @@ -2,7 +2,7 @@ - + diff --git a/mastodon/src/main/res/drawable/seekbar_video_player_thumb.xml b/mastodon/src/main/res/drawable/seekbar_video_player_thumb.xml index eb8bc3bc4..aef817f27 100644 --- a/mastodon/src/main/res/drawable/seekbar_video_player_thumb.xml +++ b/mastodon/src/main/res/drawable/seekbar_video_player_thumb.xml @@ -1,5 +1,5 @@ - + \ No newline at end of file diff --git a/mastodon/src/main/res/interpolator-v21/m3_sys_motion_easing_emphasized_accelerate.xml b/mastodon/src/main/res/interpolator-v21/m3_sys_motion_easing_emphasized_accelerate.xml new file mode 100644 index 000000000..2527b6607 --- /dev/null +++ b/mastodon/src/main/res/interpolator-v21/m3_sys_motion_easing_emphasized_accelerate.xml @@ -0,0 +1,22 @@ + + + + diff --git a/mastodon/src/main/res/interpolator-v21/m3_sys_motion_easing_standard_accelerate.xml b/mastodon/src/main/res/interpolator-v21/m3_sys_motion_easing_standard_accelerate.xml new file mode 100644 index 000000000..513147958 --- /dev/null +++ b/mastodon/src/main/res/interpolator-v21/m3_sys_motion_easing_standard_accelerate.xml @@ -0,0 +1,22 @@ + + + + diff --git a/mastodon/src/main/res/layout/alert_invite_link.xml b/mastodon/src/main/res/layout/alert_invite_link.xml new file mode 100644 index 000000000..a188985b6 --- /dev/null +++ b/mastodon/src/main/res/layout/alert_invite_link.xml @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/layout/compose_action.xml b/mastodon/src/main/res/layout/compose_action.xml index ab0d6fe4d..441cbb0a6 100644 --- a/mastodon/src/main/res/layout/compose_action.xml +++ b/mastodon/src/main/res/layout/compose_action.xml @@ -11,7 +11,7 @@ android:id="@+id/language_btn" style="@style/Widget.Mastodon.M3.Button.Text" android:layout_width="wrap_content" - android:layout_height="match_parent" + android:layout_height="wrap_content" android:paddingStart="12dp" android:paddingEnd="12dp" android:drawableStart="@drawable/ic_fluent_local_language_16_regular" @@ -32,8 +32,19 @@ android:tint="?colorM3OnSurfaceVariant" android:contentDescription="@string/sk_schedule_or_draft" android:tooltipText="@string/sk_schedule_or_draft" + android:visibility="gone" tools:targetApi="o" /> + +