diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 000000000..ea2fb6af3 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,12 @@ +# These are supported funding model platforms + +github: LucasGGamerM +custom: ["https://liberapay.com/LucasGGamerM/donate", liberapay.com] +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 +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 new file mode 100644 index 000000000..9f1bb06e9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,40 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: bug +assignees: '' + +--- + +**Describe the bug** + +A clear and concise description of what the bug is. + +**To reproduce** + +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Does this happen in the official app?** + +Does this issue also occur with the respective upstream release? +> 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! + +**Screenshots and screen recordings** + +If applicable, add screenshots (and screen recordings, if possible) to help explain your problem. + +**Version** + +Moshidon version: [e.g. v1.1.4+fork.#] + +**Crash log** + +If you know your way around Android development tools, please consider attaching a crash log, if possible. diff --git a/.github/ISSUE_TEMPLATE/feature-ui-request.md b/.github/ISSUE_TEMPLATE/feature-ui-request.md new file mode 100644 index 000000000..7a56c359a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-ui-request.md @@ -0,0 +1,20 @@ +--- +name: Feature/UI request +about: Suggest an idea for this project +title: '' +labels: feature +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +If applicable: a clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/something-else.md b/.github/ISSUE_TEMPLATE/something-else.md new file mode 100644 index 000000000..995600f1e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/something-else.md @@ -0,0 +1,10 @@ +--- +name: It's something else… +about: Issues that can't be categorized as feature requests or bug reports +title: '' +labels: '' +assignees: '' + +--- + + 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/.github/workflows/validate-gradle-wrapper.yml b/.github/workflows/validate-gradle-wrapper.yml new file mode 100644 index 000000000..b5f0b31ac --- /dev/null +++ b/.github/workflows/validate-gradle-wrapper.yml @@ -0,0 +1,11 @@ +name: Validate Gradle Wrapper + +on: [pull_request, push] + +jobs: + validation: + name: Validation + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: gradle/wrapper-validation-action@v1 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 2f062e8fd..dbde9f559 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,183 @@ -Mastodon for Android -====================== +![MoshidonLogo](mastodon/src/main/res/mipmap-xhdpi/ic_launcher_round.png) -[![Crowdin](https://badges.crowdin.net/mastodon-for-android/localized.svg)](https://crowdin.com/project/mastodon-for-android) +# Moshidon, the material you mastodon client! -This is the repository for the official Android app for Mastodon. +> 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. -[Get it on F-Droid](https://f-droid.org/packages/org.joinmastodon.android/) -[Get it on Google Play](https://play.google.com/store/apps/details?id=org.joinmastodon.android) -Or get the APK from the [The Releases Section](https://github.com/mastodon/mastodon-android/releases/latest). +[![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) -## Contributing +[![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) -Our goal is delivering a polished, professionally designed and user-friendly app. We proceed according to wireframes provided by a professional UX designer that works with Mastodon gGmbH. This means that any outside contributions that change the app visually must first be coordinated with the UX designer. *This can take time.* Furthermore, we work off of an internal roadmap and aim for feature-parity and consistency with our iOS app. The iOS app is designated as the "primary" between the two, therefore, if you want to request features, please do so in the [Mastodon for iOS](https://github.com/mastodon/mastodon-ios) repository, as you are requesting a feature to be both in iOS and Android (exceptions being system integrations specific to Android). On the other hand, any contributions that improve existing functionality, performance, or accessibility should not have any roadblocks to being merged. -If you would like to help translate the app into your language, please go to [Crowdin](https://crowdin.com/project/mastodon-for-android). If your language is not listed in the Crowdin project, please create an issue and we will add it. Please do not create pull requests that modify `strings.xml` files for languages other than English. +[![Translation status](https://translate.codeberg.org/widgets/moshidon/-/svg-badge.svg)](https://translate.codeberg.org/engage/moshidon/) +  +[![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 F-Droid +  +Get it on IzzyOnDroid + +## Help out the project by donating at: https://github.com/sponsors/LucasGGamerM! +### We also support LiberaPay at: https://liberapay.com/LucasGGamerM/donate (Currently broken) + +### 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 “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.** + +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! + +### **Image description viewer** + +**Allows you to quickly check whether an image or video has an alternative text attached to it.** + +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! + +### **Reminder to add alt text to attached media** + +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. + +### **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 + +**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.** + +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. + +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 + +### Stable variant + +All stable version downloads can be found on the [Releases](https://github.com/LucasGGamerM/moshidon/releases) page. + +**`moshidon.apk`** + +Variant with an integrated updater. If you download Moshidon from here (and not from an app store), just download the regular `moshidon.apk`. + +### Nightly variant + +All nightly builds can be downloaded at [Nightly Releases](https://github.com/LucasGGamerM/moshidon-nightly/releases) page. + +**`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. + +--- + + +## Detailed changes + +### 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)) +* 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 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) +* [List favorited posts](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/favs-list) +* [Accept/reject follow requests](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/follow-requests) +* [Display content warning title above text](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/cw-above-text) +* [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) +* [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)) +* [Set spoiler height independently to content height](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:spoiler-height-independent) ([Closes issue](https://github.com/mastodon/mastodon-android/issues/166)) +* [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) + + +### Visual + +* [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) + ## Building @@ -32,4 +191,14 @@ As this app is using Java 17 features, you need JDK 17 or newer to build it. Oth This project is released under the [GPL-3 License](./LICENSE). -The Mastodon name and logo are trademarks of Mastodon gGmbH. If you intend to redistribute a modified version of this app, use a unique name and icon for your app that does not mistakenly imply any official connection with or endorsement by Mastodon gGmbH. +## Links + +[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 new file mode 100644 index 000000000..402335c59 --- /dev/null +++ b/_config.yml @@ -0,0 +1,2 @@ +title: Moshidon +layout: default diff --git a/_layouts/default.html b/_layouts/default.html new file mode 100644 index 000000000..1007430d6 --- /dev/null +++ b/_layouts/default.html @@ -0,0 +1,17 @@ + + + + + + + Moshidon + + + + + +
+ {{ content }} +
+ + diff --git a/build.gradle b/build.gradle index c47158438..1bf260e0d 100644 --- a/build.gradle +++ b/build.gradle @@ -3,10 +3,16 @@ buildscript { repositories { google() mavenCentral() + maven { + url "https://www.jitpack.io" + content { + includeModule 'com.github.UnifiedPush', 'android-connector' + } + } + mavenLocal() } dependencies { - classpath "com.android.tools.build:gradle:7.4.2" - classpath "com.google.android.libraries.mapsplatform.secrets-gradle-plugin:secrets-gradle-plugin:2.0.1" + classpath 'com.android.tools.build:gradle:8.0.0' // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files } diff --git a/crowdin.yml b/crowdin.yml deleted file mode 100644 index 99e5b7d99..000000000 --- a/crowdin.yml +++ /dev/null @@ -1,5 +0,0 @@ -files: - - source: /mastodon/src/main/res/values/strings.xml - translation: /mastodon/src/main/res/values-%android_code%/strings.xml - - source: /fastlane/metadata/android/en-US/*.txt - translation: /fastlane/metadata/android/%locale%/%original_file_name% diff --git a/fastlane/metadata/android/ar-SA/full_description.txt b/fastlane/metadata/android/ar-SA/full_description.txt deleted file mode 100644 index 6c5e0d50b..000000000 --- a/fastlane/metadata/android/ar-SA/full_description.txt +++ /dev/null @@ -1,16 +0,0 @@ -ماستدون هي أكبر شبكة اجتماعية لا مركزيَّة على الإنترنت. بدلاً من كونها على موقع ويب واحد مركزي، هي عبارة عن شبكة من ملايين المستخدمين في مجتمعات مُستقلَّة يمكنهم جميعًا التفاعل مع بعضهم البعض بسلاسة. بغض النظر عن اهتماماتك، يمكنك مقابلة أشخاص متحمسين ينشرون عنها في ماستودون! - -اِنضم إلَى مُجتَمع وأنشئ مِلَفَّكَ التَّعريفِيّ. ابحث عن أشخاص رائعين، تابعهم واقرأ منشوراتهم في خطٍّ زمني خالٍ من الإعلانات. عبِّر عَن نَفسِكَ باِستخدام رُموزٍ تَعبيرِيَّةٍ مُخصَّصَة، أو صُوَر، أو صُوَرٍ مُتحَرِّكَة، أو مَقاطِعٍ مَرئِّيَة أو مَقاطِعٍ صَوتِيَّةٍ فِي مَنشوراتٍ ذَاتُ خَمسِمائَة حَرف. رُدّ على سَلاسِلِ المَنشوراتِ، وأعِد تَدوينَ مَنشُوراتِ أيِّ شَخصٍ لِمُشارَكَةِ الأُمُورِ الرَّائِعَة. اِبحَث عَن حِساباتٍ جَديدَةٍ لِمُتابَعَتِها، وَعَن وُسُومٍ شَائِعَةٍ لِتَوسيعِ شَبَكَتِك. - -ماستدون مبني بتركيزٍ على الأمان والخصوصيَّة. حدِّد ما إذا أردتَ مُشارَكَةَ مَنشُوراتِكَ مَعَ مُتابِعيك، أو الأشخاصِ الَّذينَ أشَرتَ إليهِم فَقَط أو العالَمَ بأسرِه. تتيح لك تحذيرات المحتوى إخفاء المنشورات التي تحتوي على مواد حساسة أو محفِّزَة حتى تكون مستعد للتفاعل مع محتواها. لكل مجتمع إرشاداته الخاصة ومشرفيه الخاصين للحفاظ على أمان أعضائه، كما تُساعد أدوات الحظر والإبلاغ القوية في منع إساءة الاستخدام. - -مَزيدٌ مِنَ المَزايَا: - -• النمط الداكِن: قراءة المنشورات في النمط المضيء، الداكِن أو الأسود الحقيقي -• استطلاعات الرأي: اسأل المُتابعين عن آرائِهِم وسَتُسجَّل الأصوات -• الاستكشاف: الأوسِمَة والحِسابات الرائجة على بُعد نقرة واحِدَة -• الإشعارات: احصل على الجديد بشأن المُتابعات، الرُدود وعمليات إعادة التدوين -• المشاركة: انشر مباشرة على ماستودون من أي لوح مُشاركة في أي تطبيق -• الجاذبية: جالب الحظ لدينا هو فيل رائع، سَتراه يظهر فجأة في السطح بين الفينة والأُخرى - -مَاستودُون هي مُنَظَّمَةُ غَيرُ رِبحِيَّةٍ مُسَجَّلَة. مُساهَمَاتُكَ هِي الدَّاعِمُ المُباشِرُ لعَمَلِيَّةِ التَّطوير. لا توجد إعلانات، لا تسييل ولا رأس مال استثماري، نحن نخطط للبقاء على هذا النحو. diff --git a/fastlane/metadata/android/ar-SA/short_description.txt b/fastlane/metadata/android/ar-SA/short_description.txt deleted file mode 100644 index f74b3b892..000000000 --- a/fastlane/metadata/android/ar-SA/short_description.txt +++ /dev/null @@ -1 +0,0 @@ -شَبَكةٌ اِجتِماعِيَّةٌ لَا مَركزِيَّة \ No newline at end of file diff --git a/fastlane/metadata/android/ar-SA/title.txt b/fastlane/metadata/android/ar-SA/title.txt deleted file mode 100644 index 720bea6d7..000000000 --- a/fastlane/metadata/android/ar-SA/title.txt +++ /dev/null @@ -1 +0,0 @@ -مَاستودُون \ No newline at end of file diff --git a/fastlane/metadata/android/be-BY/full_description.txt b/fastlane/metadata/android/be-BY/full_description.txt deleted file mode 100644 index f702b54fe..000000000 --- a/fastlane/metadata/android/be-BY/full_description.txt +++ /dev/null @@ -1,16 +0,0 @@ -Mastodon - гэта найбуйнейшая дэцэнтралізаваная сацыяльная сетка ў Інтэрнэце. Замест аднаго вэб-сайта, гэта сетка з незалежных супольнасцяў з мільёнамі карыстальнікаў, якія могуць бесперашкодна ўзаемадзейнічаць адно з адным. Незалежна ад таго, чым вы захапляецеся, вы знойдзеце людзей, якія пішуць пра гэта ў Mastodon! - -Далучайцеся да супольнасці і стварыце свой профіль. Знаходзьце цікавых людзей, сачыце за імі і чытайце іх допісы ў храналагічнай стужцы без рэкламы. Выяўляйце сябе з дапамогай карыстальніцкіх эмодзі, малюнкаў, GIF-файлаў, відэа і аўдыя ў допісах на 500 знакаў. Удзельнічайце ў абмеркаваннях і прасоўвайце выдатныя допісы ад іншых людзей. Знаходзьце новыя ўліковыя запісы, на якія можна падпісацца, і папулярныя хэштэгі, каб пашырыць сваю сетку. - -Mastodon створаны з акцэнтам на прыватнасць і бяспеку. Вырашайце з кім абагульваць вашы допісы: з вашымі падпісчыкамі, толькі з людзьмі, якіх вы згадваеце або з усім светам. Папярэджанні аб змесціве дазваляюць схаваць допісы, якія змяшчаюць канфідэнцыяльны або правакацыйны матэрыял, пакуль вы не будзеце гатовыя да яго. Кожная суполка мае ўласныя правілы і мадэратараў, якія забяспечваюць бяспеку яе ўдзельнікаў, а надзейныя інструменты блакіроўкі і сістэма скарг дапамагаюць прадухіліць злоўжыванні. - -Больш функцый: - -• Цёмны рэжым: чытайце допісы ў светлым, цёмным або сапраўдным чорным рэжыме -• Апытанні: пытайцеся ў падпісчыкаў іх меркаванне і падлічвайце галасы -• Даследуйце: папулярныя хэштэгі і ўліковыя запісы — адным дотыкам -• Апавяшчэнні: атрымлівайце апавяшчэнні аб новых падпісчыках, адказах і рэпостах -• Абагульванне: публікуйце непасрэдна ў Mastodon з любога меню абагульвання ў любой праграме -• Прывабнасць: нашым талісманам з'яўляецца чароўны слон, і час ад часу ён будзеце з'яўляцца перад вамі - -Mastodon з'яўляецца зарэгістраванай некамерцыйнай арганізацыяй і яго развіццё падтрымліваецца непасрэдна вашымі ахвяраваннямі. У нас няма рэкламы, манетызацыі і венчурнага капіталу, і мы плануем так і працягваць. diff --git a/fastlane/metadata/android/be-BY/short_description.txt b/fastlane/metadata/android/be-BY/short_description.txt deleted file mode 100644 index 1cc9c0e20..000000000 --- a/fastlane/metadata/android/be-BY/short_description.txt +++ /dev/null @@ -1 +0,0 @@ -Дэцэнтралізаваная сацыяльная сетка \ No newline at end of file diff --git a/fastlane/metadata/android/be-BY/title.txt b/fastlane/metadata/android/be-BY/title.txt deleted file mode 100644 index 8123241a0..000000000 --- a/fastlane/metadata/android/be-BY/title.txt +++ /dev/null @@ -1 +0,0 @@ -Mastodon \ No newline at end of file diff --git a/fastlane/metadata/android/bn-BD/full_description.txt b/fastlane/metadata/android/bn-BD/full_description.txt deleted file mode 100644 index 11d74abb1..000000000 --- a/fastlane/metadata/android/bn-BD/full_description.txt +++ /dev/null @@ -1,16 +0,0 @@ -Mastodon is the largest decentralized social network on the internet. Instead of a single website, it’s a network of millions of users in independent communities that can all interact with one another, seamlessly. No matter what you’re into, you can meet passionate people posting about it on Mastodon! - -Join a community and create your profile. Find and follow fascinating folks and read their posts in an ad-free, chronological timeline. Express yourself with custom emoji, images, GIFs, videos, and audio in 500-character posts. Reply to threads and reblog posts from anyone to share great stuff. Find new accounts to follow and trending hashtags to expand your network. - -Mastodon is built with a focus on privacy and safety. Decide whether your posts are shared with your followers, just the people you mention, or the whole world. Content warnings let you hide posts containing sensitive or triggering material until you're ready to engage with them. Each community has its own guidelines and moderators to keep its members safe, and robust blocking and reporting tools help prevent abuse. - -More features: - -• Dark Mode: Read posts in light, dark, or true black mode -• Polls: Ask followers for their opinion and tally the votes -• Explore: Trending hashtags and accounts are a tap away -• Notifications: Get notified about new follows, replies, and reblogs -• Sharing: Post directly to Mastodon from any share sheet in any app -• Cuteness: Our mascot is an adorable elephant, and you'll see them pop up from time to time - -Mastodon is a registered nonprofit and development is supported directly by your donations. There’s no advertising, no monetization, and no venture capital, and we plan to keep it that way. diff --git a/fastlane/metadata/android/bn-BD/short_description.txt b/fastlane/metadata/android/bn-BD/short_description.txt deleted file mode 100644 index 8f5a9b847..000000000 --- a/fastlane/metadata/android/bn-BD/short_description.txt +++ /dev/null @@ -1 +0,0 @@ -Decentralized social network \ No newline at end of file diff --git a/fastlane/metadata/android/bn-BD/title.txt b/fastlane/metadata/android/bn-BD/title.txt deleted file mode 100644 index 8123241a0..000000000 --- a/fastlane/metadata/android/bn-BD/title.txt +++ /dev/null @@ -1 +0,0 @@ -Mastodon \ No newline at end of file diff --git a/fastlane/metadata/android/bs-BA/full_description.txt b/fastlane/metadata/android/bs-BA/full_description.txt deleted file mode 100644 index 038392cdd..000000000 --- a/fastlane/metadata/android/bs-BA/full_description.txt +++ /dev/null @@ -1,16 +0,0 @@ -Mastodon je največa decentralizovana socijalna mreža na internetu. Umjesto jednog websajta, ovo je netvork od nekoliko miliona korisnika na zasebnim platformama / zajednicama koje komuniciraju medjusobno i prenose podatke. Bez obzira čime se bavite, uvijek možete naći osobe sa slićnim interesima na Mastodonu! - -Učlani se i napravi svoj profil. Find and follow fascinating folks and read their posts in an ad-free, chronological timeline. Izrazi svoja osjećanja odadtno koristeći smajlije, slike, GIF-ove, videa ili audio snimke. Limit je 500 znakova. Komentiraj ili prenesi objave na svoj profil od bilo koga ako vam se dopada sadrzaj. Potraži nove osobe ili popularne haštagove da obogatiš svoj netvork. - -Mastodon je gradjen sa ciljem za bolju sigurnost i privatnost. Odluči da li objavljuješ nešto javno, samo prijateljima, samo osobama koje spomeneš ili cijelom svijetu. Napomene o osjetljivom sadrzaju vas obavjestavaju i pitaju za odobrenje ako zelite da ih vidite. Svaka platforma / zajednica ima svoja zasebna pravila koristenja i moderatore koji cuvaju platformu sigurnom,. Uz to imate opcije za blokadu bilo koga ili bilo cega sto vam se ne svidja. - -Vise opcija: - -Tamna pozadina: Citajte objave u svijetloj, tamnoj ili crnoj varijanti -Ankete: Pitajte pratioce za njihova misljenja i ocijene glasanjem -Izvidi: Hastagovi i korisnici su vam samo jedan klik udaljeni -Obavijesti: Primajte obavijesti o novim pratiocima, komentarima ili re-objavama -Dijeljenje: Objavljujte na Mastodonu sa bilo koje kompatibilne aplikacije -Ljepota: Nasa maskota je slon i vidjet cete ga s vremena na vrijeme - -Mastodon je registrovan kao neprofitabilan i dalje finansiranje razvijanja direkntno zavisi od donacija. Nema reklamnog sadrzaja, nema monetizacije. Sve je besplatno i tako treba i da ostane. diff --git a/fastlane/metadata/android/bs-BA/short_description.txt b/fastlane/metadata/android/bs-BA/short_description.txt deleted file mode 100644 index e813f2ebb..000000000 --- a/fastlane/metadata/android/bs-BA/short_description.txt +++ /dev/null @@ -1 +0,0 @@ -Decentralizovana socijalna mreža \ No newline at end of file diff --git a/fastlane/metadata/android/bs-BA/title.txt b/fastlane/metadata/android/bs-BA/title.txt deleted file mode 100644 index 8123241a0..000000000 --- a/fastlane/metadata/android/bs-BA/title.txt +++ /dev/null @@ -1 +0,0 @@ -Mastodon \ No newline at end of file diff --git a/fastlane/metadata/android/ca-ES/full_description.txt b/fastlane/metadata/android/ca-ES/full_description.txt deleted file mode 100644 index 2a68ed6e8..000000000 --- a/fastlane/metadata/android/ca-ES/full_description.txt +++ /dev/null @@ -1,16 +0,0 @@ -Mastodon és la xarxa social descentralitzada més gran d'Internet. En lloc d'un únic lloc web, és una xarxa de milions d'usuaris en comunitats independents que poden interactuar entre ells sense problemes. No importa quines siguin la teva afició, pots conèixer gent apassionada que publica sobre ella a Mastodon! - -Uneix-te a una comunitat i crea el teu perfil. Troba i segueix gent fascinant i llegeix les seves publicacions en una línia de temps cronològica sense anuncis. Expressa't amb emojis, imatges, GIF, vídeos i àudio personalitzats en publicacions de 500 caràcters. Respon als fils i impulsa les publicacions de qualsevol persona per compartir coses fantàstiques. Troba nous comptes per seguir i etiquetes en tendència per ampliar la teva xarxa. - -Mastodon està construït amb un enfocament en la privadesa i la seguretat. Decideix si les teves publicacions es comparteixen amb els teus seguidors, només amb les persones que esmentes o amb tot el món. Els advertiments de contingut et permeten amagar publicacions que continguin material sensible o amagar-les fins que estiguis llest per consultar-les. Cada comunitat té les seves pròpies directrius i moderadors per mantenir els seus membres segurs, i les eines de bloqueig i informació robustes ajuden a prevenir l'abús. - -Més funcionalitats: - -• Mode fosc: Llegir entrades en mode clar, fosc o veritablement negre -• Enquestes: Pregunta als teus seguidors la seva opinió i compta els vots -• Explora: Etiquetes en tendència i comptes són a un sol toc -• Notificacions: Rep notificacions sobre nous seguidors, respostes i impulsos -• Compartir: Publica directament a Mastodon des de qualsevol full compartit de qualsevol aplicació -• Tendresa: La nostra mascota és un elefant adorable i veuràs que apareix de tant en tant - -Mastodon és una organització sense ànim de lucre registrada i el desenvolupament se sosté directament amb les teves donacions. No hi ha publicitat, cap monetització i cap capital de risc, i planegem mantenir-ho així. diff --git a/fastlane/metadata/android/ca-ES/short_description.txt b/fastlane/metadata/android/ca-ES/short_description.txt deleted file mode 100644 index f878c2ede..000000000 --- a/fastlane/metadata/android/ca-ES/short_description.txt +++ /dev/null @@ -1 +0,0 @@ -Xarxa social descentralitzada \ No newline at end of file diff --git a/fastlane/metadata/android/ca-ES/title.txt b/fastlane/metadata/android/ca-ES/title.txt deleted file mode 100644 index 8123241a0..000000000 --- a/fastlane/metadata/android/ca-ES/title.txt +++ /dev/null @@ -1 +0,0 @@ -Mastodon \ No newline at end of file diff --git a/fastlane/metadata/android/cs-CZ/full_description.txt b/fastlane/metadata/android/cs-CZ/full_description.txt deleted file mode 100644 index 6d24a5dcf..000000000 --- a/fastlane/metadata/android/cs-CZ/full_description.txt +++ /dev/null @@ -1,16 +0,0 @@ -Mastodon je největší decentralizovanou sociální sítí na internetu. Místo jediné webové stránky je to síť pro miliony uživatelů v nezávislých komunitách, ve kterých mohou všichni vzájemně a bezproblémově komunikovat. Bez ohledu na to, co vás baví, můžete se setkat s vášnivými lidmi, kteří o tom přispívají na Mastodon! - -Připojte se ke komunitě a vytvořte svůj profil. Najděte a sledujte fascinující lidi a přečtěte si jejich příspěvky v bezreklamní chronologické časové linii. Vyjádřete se pomocí vlastních emoji, obrázků, GIFů, videí a zvuku v 500-znakových příspěvcích. Odpovězte na vlákna a boostujte příspěvky od kohokoliv, abyste mohli sdílet skvělé věci. Najděte nové účty pro sledování a populární hashtagy pro rozšíření vaší sítě. - -Mastodon je postaven se zaměřením na soukromí a bezpečnost. Rozhodněte, zda jsou vaše příspěvky sdíleny se vašimi sledujícími, jen s lidmi, které zmíníte, nebo s celým světem. Upozornění na obsah vám umožní skrýt příspěvky obsahující citlivý nebo spouštěcí materiál, dokud se s nimi nezačnete zabývat. Každá komunita má vlastní pokyny a moderátory, aby udržela své členy v bezpečí, a robustní blokování a nahlašovací nástroje pomáhácí předcházení zneužití. - -Více funkcí: - -• Tmavý režim: Čtěte příspěvky ve světlém, tmavém nebo pravém černém režimu -• Ankety: Požádejte sledující o jejich názor a sečtěte jejich hlasy -• Objevit: Populární hashtagy a účty jsou pryč na jedno klepnutí -• Oznámení: Dostávejte oznámení o nových sledujících, odpovědích a boostech -• Sdílení: Odesílání přímo do Mastodonu z libovolného seznamu sdílení v jakékoliv aplikaci -• Roztomilost: Naším maskotem je roztomilý slon, kterého čas od času uvidíte - -Mastodon je registrovaný neziskový projekt a vývojový program je podporován přímo vašimi dary. Neexistuje žádná reklama, žádná monetizace a žádný rizikový kapitál a máme v plánu to udržet. diff --git a/fastlane/metadata/android/cs-CZ/short_description.txt b/fastlane/metadata/android/cs-CZ/short_description.txt deleted file mode 100644 index 4845d243e..000000000 --- a/fastlane/metadata/android/cs-CZ/short_description.txt +++ /dev/null @@ -1 +0,0 @@ -Decentralizovaná sociální síť \ No newline at end of file diff --git a/fastlane/metadata/android/cs-CZ/title.txt b/fastlane/metadata/android/cs-CZ/title.txt deleted file mode 100644 index 8123241a0..000000000 --- a/fastlane/metadata/android/cs-CZ/title.txt +++ /dev/null @@ -1 +0,0 @@ -Mastodon \ No newline at end of file diff --git a/fastlane/metadata/android/da-DK/full_description.txt b/fastlane/metadata/android/da-DK/full_description.txt deleted file mode 100644 index 9f012b18f..000000000 --- a/fastlane/metadata/android/da-DK/full_description.txt +++ /dev/null @@ -1,16 +0,0 @@ -Mastodon er det største decentraliserede sociale netværk på internet. Frem for ét enkelt website, er det i stedet et netværk af millioner af brugere i uafhængige fællesskaber, som alle kan interagere med hinanden. Uanset interessesfære, kan man møde engagerede personer, som skriver herom på Mastodon! - -Find et fællesskab og opret din profil. Find og følg fascinerende folk og læs deres indlæg i en reklamefri, kronologisk tidslinje. Udtryk dig selv med tilpassede emojis, billeder, GIF'er, videoer og lyd i 500-tegns indlæg. Svar på tråde og genpost indlæg fra enhver for dele alt det gode. Find nye konti at følge, og populære hashtags, for at udvide dit netværk. - -Mastodon er bygget med fokus på fortrolighed og sikkerhed. Afgør, hvorvidt dine indlæg skal deles med Følgere, blot dem du nævner eller hele verden. Indholdsadvarsler muliggør at skjule indlæg med sensitivt eller udløsende indhold, indtil du er klar til at læse dem. Hvert fællesskab har deres egne retningslinjer og moderatorer til at holde deres medlemmer sikre, og robuste blokerings- og anmeldelsesværktøjer hjælper med at forhindre misbrug. - -Flere funktioner: - -• Mørk tilstand: Læs indlæg i lys, mørk eller ægte sort tilstand -• Afstemninger: Spørg Følgere om deres mening og stemme -• Udforsk: Populære hashtags og konti er ét tryk væk -• Notifikationer: Få besked om nye Følgere, svar og genpostninger -• Deling: Post direkte til Mastodon fra enhver apps delingsfunktion -• Nuttethed: Vores maskot er en yndig elefant, som du vil se dukke op fra tid til anden - -Mastodon er en registreret nonprofit, hvis udvikling direkte understøttes af dine donationer. Der er ingen annoncering, ingen indtægtsgenerering og ingen risikovillig kapital, og intentionen er, at det forbliver sådan. diff --git a/fastlane/metadata/android/da-DK/short_description.txt b/fastlane/metadata/android/da-DK/short_description.txt deleted file mode 100644 index 078a2ae35..000000000 --- a/fastlane/metadata/android/da-DK/short_description.txt +++ /dev/null @@ -1 +0,0 @@ -Decentraliseret socialt netværk \ No newline at end of file diff --git a/fastlane/metadata/android/da-DK/title.txt b/fastlane/metadata/android/da-DK/title.txt deleted file mode 100644 index 8123241a0..000000000 --- a/fastlane/metadata/android/da-DK/title.txt +++ /dev/null @@ -1 +0,0 @@ -Mastodon \ No newline at end of file diff --git a/fastlane/metadata/android/de-DE/full_description.txt b/fastlane/metadata/android/de-DE/full_description.txt deleted file mode 100644 index d8881b49f..000000000 --- a/fastlane/metadata/android/de-DE/full_description.txt +++ /dev/null @@ -1,16 +0,0 @@ -Mastodon ist das größte dezentralisierte soziale Netzwerk im Internet. Statt einer einzigen Webseite ist es ein Netzwerk von Millionen von Benutzer*innen in unabhängigen Gemeinschaften, die alle miteinander interagieren können. Egal, was du magst, auf Mastodon kannst du begeisterte Menschen treffen, die darüber schreiben! - -Tritt einer Gemeinschaft bei und erstelle dein Profil. Entdecke und folge faszinierenden Menschen und lese ihre Beiträge in einer Werbefreien, chronologischen Timeline. Drücke dich mit eigenen Emojis, Bildern, GIFs, Videos und Klängen in 500-Zeichen-Beiträgen aus. Antworte auf Themen und teile Beiträge von anderen, um tolle Dinge zu verbreiten. Finde neue Konten zum Folgen und angesagte Hashtags, um dein Netzwerk zu erweitern. - -Mastodon wurde mit einem Schwerpunkt auf Privatsphäre und Sicherheit gebaut. Entscheide, ob du deine Beiträge mit deinen Followern, nur mit den Menschen, die du erwähnst, oder mit der ganzen Welt teilen möchtest. Mit Inhaltswarnungen kannst du Beiträge mit sensiblem oder bedenklichen Inhalten ausblenden, bis du bereit bist, dich damit auseinanderzusetzen. Jede Gemeinschaft hat ihre eigenen Regeln und Moderator*innen, um die Sicherheit ihrer Mitglieder zu gewährleisten, sowie robuste Sperr- und Meldewerkzeuge, um Missbrauch vorzubeugen. - -Weitere Funktionen: - -• Dunkler Modus: Beiträge im hellen, dunklen oder schwarzen Modus lesen -• Umfragen: frage deine Follower nach ihrer Meinung und zähle die Stimmen -• Entdecken: trendende Hashtags und Profile sind nur einen Fingertipp entfernt -• Benachrichtigungen: erhalte Benachrichtigungen über neue Follower, Antworten und geteilte Beiträge -• Teilen: veröffentliche auf Mastodon aus jeder beliebigen anderen App -• Niedlichkeit: unser Maskottchen ist ein entzückender Elefant und du wirst ihn von Zeit zu Zeit auftauchen sehen - -Mastodon ist eine eingetragene gemeinnützige Organisation und die Entwicklung wird direkt durch deine Spenden unterstützt. Es gibt keine Werbung, keine Monetarisierung und kein Risikokapital und so soll es auch bleiben. diff --git a/fastlane/metadata/android/de-DE/short_description.txt b/fastlane/metadata/android/de-DE/short_description.txt deleted file mode 100644 index 387b4ea55..000000000 --- a/fastlane/metadata/android/de-DE/short_description.txt +++ /dev/null @@ -1 +0,0 @@ -Dezentrales soziales Netzwerk \ No newline at end of file diff --git a/fastlane/metadata/android/de-DE/title.txt b/fastlane/metadata/android/de-DE/title.txt deleted file mode 100644 index 8123241a0..000000000 --- a/fastlane/metadata/android/de-DE/title.txt +++ /dev/null @@ -1 +0,0 @@ -Mastodon \ No newline at end of file diff --git a/fastlane/metadata/android/el-GR/full_description.txt b/fastlane/metadata/android/el-GR/full_description.txt deleted file mode 100644 index 6f999f707..000000000 --- a/fastlane/metadata/android/el-GR/full_description.txt +++ /dev/null @@ -1,16 +0,0 @@ -Το Mastodon είναι το μεγαλύτερο αποκεντρωμένο κοινωνικό δίκτυο στο διαδίκτυο. Αντί για μία ενιαία ιστοσελίδα, είναι ένα δίκτυο εκατομμυρίων χρηστών σε ανεξάρτητες κοινότητες που μπορούν όλοι να αλληλεπιδράσουν μεταξύ τους, απρόσκοπτα. Δεν έχει σημασία τί σας αρέσει, μπορείτε να συναντήσετε παθιασμένους ανθρώπους να μιλάνε γι' αυτό στο Mastodon! - -Εγγραφείτε σε μια κοινότητα και δημιουργήστε το προφίλ σας. Βρείτε και ακολουθήστε συναρπαστικούς ανθρώπους και διαβάσετε τις αναρτήσεις τους σε μία χωρίς διαφημίσεις, χρονολογική ροή. Εκφραστείτε με προσαρμοσμένα emoji, εικόνες, GIF, βίντεο και ήχο σε αναρτήσεις 500 χαρακτήρων. Απαντήστε σε νήματα και παραθέστε αναρτήσεις από οποιονδήποτε για να μοιραστείτε μεγάλα πράγματα. Βρείτε νέους λογαριασμούς για να ακολουθήσετε και δημοφιλείς ετικέτες για να επεκτείνετε το δίκτυό σας. - -Το Mastodon είναι κατασκευασμένο με έμφαση στην ιδιωτικότητα και την ασφάλεια. Αποφασίστε αν οι αναρτήσεις σας μοιράζονται με τους ακόλουθούς σας, μόνο τα άτομα που αναφέρετε ή ολόκληρο τον κόσμο. Προειδοποιήσεις περιεχομένου σας επιτρέπουν να κρύψετε αναρτήσεις που περιέχουν ευαίσθητο ή ανησυχητικό υλικό μέχρι να είστε έτοιμοι να συμμετάσχετε μαζί τους. Κάθε κοινότητα έχει τις δικές της οδηγίες και συντονιστές για να κρατήσει τα μέλη της ασφαλή και ισχυρά εργαλεία αποκλεισμού και αναφοράς βοηθούν στην πρόληψη της κατάχρησης. - -Περισσότερα χαρακτηριστικά: - -• Σκοτεινή λειτουργία: Διαβάστε αναρτήσεις σε φωτεινή, σκοτεινή ή την απόλυτα μαύρη λειτουργία -• Δημοσκοπήσεις: Ρωτήστε τους ακόλουθους για τη γνώμη τους και συγκεντρώστε ψήφους -• Εξερεύνηση: Δημοφιλείς ετικέτες και λογαριασμοί είναι ένα άγγιγμα μακριά -• Ειδοποιήσεις: Ενημερωθείτε για νέους ακόλουθους, απαντήσεις και παραθέσεις -• Κοινοποίηση: Αναρτήστε απευθείας στο Mastodon από οποιαδήποτε καρτέλα κοινής χρήσης σε οποιαδήποτε εφαρμογή -• Χάρη: Η μασκότ μας είναι ένας αξιολάτρευτος ελέφαντας και θα τον δείτε να εμφανίζεται πού και πού - -Το Mastodon είναι μη κερδοσκοπική και η ανάπτυξη υποστηρίζεται άμεσα από τις δωρεές σας. Δεν υπάρχουν διαφημίσεις, δεν γίνεται δημιουργία εσόδων και χωρίς επιχειρηματικά κεφάλαια και σκοπεύουμε να το διατηρήσουμε έτσι. diff --git a/fastlane/metadata/android/el-GR/short_description.txt b/fastlane/metadata/android/el-GR/short_description.txt deleted file mode 100644 index f67c145d0..000000000 --- a/fastlane/metadata/android/el-GR/short_description.txt +++ /dev/null @@ -1 +0,0 @@ -Αποκεντρωμένο κοινωνικό δίκτυο \ No newline at end of file diff --git a/fastlane/metadata/android/el-GR/title.txt b/fastlane/metadata/android/el-GR/title.txt deleted file mode 100644 index 8123241a0..000000000 --- a/fastlane/metadata/android/el-GR/title.txt +++ /dev/null @@ -1 +0,0 @@ -Mastodon \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt deleted file mode 100644 index 11d74abb1..000000000 --- a/fastlane/metadata/android/en-US/full_description.txt +++ /dev/null @@ -1,16 +0,0 @@ -Mastodon is the largest decentralized social network on the internet. Instead of a single website, it’s a network of millions of users in independent communities that can all interact with one another, seamlessly. No matter what you’re into, you can meet passionate people posting about it on Mastodon! - -Join a community and create your profile. Find and follow fascinating folks and read their posts in an ad-free, chronological timeline. Express yourself with custom emoji, images, GIFs, videos, and audio in 500-character posts. Reply to threads and reblog posts from anyone to share great stuff. Find new accounts to follow and trending hashtags to expand your network. - -Mastodon is built with a focus on privacy and safety. Decide whether your posts are shared with your followers, just the people you mention, or the whole world. Content warnings let you hide posts containing sensitive or triggering material until you're ready to engage with them. Each community has its own guidelines and moderators to keep its members safe, and robust blocking and reporting tools help prevent abuse. - -More features: - -• Dark Mode: Read posts in light, dark, or true black mode -• Polls: Ask followers for their opinion and tally the votes -• Explore: Trending hashtags and accounts are a tap away -• Notifications: Get notified about new follows, replies, and reblogs -• Sharing: Post directly to Mastodon from any share sheet in any app -• Cuteness: Our mascot is an adorable elephant, and you'll see them pop up from time to time - -Mastodon is a registered nonprofit and development is supported directly by your donations. There’s no advertising, no monetization, and no venture capital, and we plan to keep it that way. diff --git a/fastlane/metadata/android/en-US/images/featureGraphic.png b/fastlane/metadata/android/en-US/images/featureGraphic.png deleted file mode 100644 index 5fca7fe2d..000000000 Binary files a/fastlane/metadata/android/en-US/images/featureGraphic.png and /dev/null differ diff --git a/fastlane/metadata/android/en-US/images/icon.png b/fastlane/metadata/android/en-US/images/icon.png deleted file mode 100644 index 7614125a3..000000000 Binary files a/fastlane/metadata/android/en-US/images/icon.png and /dev/null differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png deleted file mode 100644 index 28e33314b..000000000 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png and /dev/null differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png deleted file mode 100644 index a716ffc75..000000000 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png and /dev/null differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png deleted file mode 100644 index abd82faca..000000000 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png and /dev/null differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png deleted file mode 100644 index 968d0c83a..000000000 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png and /dev/null differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png deleted file mode 100644 index 4a13f8a6c..000000000 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png and /dev/null differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png deleted file mode 100644 index 4cc207b7b..000000000 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png and /dev/null differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/7.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/7.png deleted file mode 100644 index 064840bbd..000000000 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/7.png and /dev/null differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/8.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/8.png deleted file mode 100644 index fc9d0f0ae..000000000 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/8.png and /dev/null differ diff --git a/fastlane/metadata/android/en-US/short_description.txt b/fastlane/metadata/android/en-US/short_description.txt deleted file mode 100644 index 8f5a9b847..000000000 --- a/fastlane/metadata/android/en-US/short_description.txt +++ /dev/null @@ -1 +0,0 @@ -Decentralized social network \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/title.txt b/fastlane/metadata/android/en-US/title.txt deleted file mode 100644 index 8123241a0..000000000 --- a/fastlane/metadata/android/en-US/title.txt +++ /dev/null @@ -1 +0,0 @@ -Mastodon \ No newline at end of file diff --git a/fastlane/metadata/android/es-ES/full_description.txt b/fastlane/metadata/android/es-ES/full_description.txt deleted file mode 100644 index a8fdb6125..000000000 --- a/fastlane/metadata/android/es-ES/full_description.txt +++ /dev/null @@ -1,16 +0,0 @@ -Mastodon es la red social descentralizada más grande de internet. En lugar de ser una sola web, es una red de millones de usuarios en comunidades independientes que pueden interactuar entre ellas de forma transparente. Sin importar qué es lo que hagas, ¡podrás encontrar gente apasionada escribiendo sobre ello en Mastodon! - -Únete a una comunidad y crea tu perfil. Encuentra y sigue a gente fascinante y lea sus publicaciones sin anuncios y de forma cronológica. Exprésate con emojis personalizados, imágenes, GIFs, vídeos y audio en publicaciónes de 500 caracteres. Responde a hilos e rebloguea publicaciones de cualquiera para compartir contenido genial. Encuentra nuevas cuentas para seguir y los hashtags en tendencia para expandir tu red. - -Mastodon está construido con un enfoque en la privacidad y la seguridad. Decide si tus publicaciones se comparten con tus seguidores, solo a la gente que menciones, o a todo el mundo. Las advertencias de contenido te permiten esconder publicaciones con contenido sensible o limitarlas de tu visión hasta que estés listo para interactuar con ellas. Cada comunidad tiene sus propias directrices y moderadores para mantener la seguridad de sus miembros, y unas sólidas herramientas de bloqueo y denuncia que ayudan a evitar los abusos. - -Más características: - -• Modo oscuro: Lee las publicaciones en modo claro, oscuro o negro real -• Encuestas: Pide opinión a tus seguidores y cuenta los votos -• Explora: Hashtags y cuentas en tendencia a un solo toque -• Notificaciones: Recibe notificaciones sobre nuevos seguidores, respuestas y reblogueos -• Compartir: Publica directamente a Mastodon desde cualquier hoja de acción en cualquier aplicación -• Preciosidad: Nuestra mascota es un elefante adorable, y verás que aparece de vez en cuando - -Mastodon es una organización sin ánimo de lucro registrada y su desarrollo se financia directamente con tus donaciones. No hay publicidad, ni monetización, ni capital de riesgo, y tenemos previsto mantenerlo así. diff --git a/fastlane/metadata/android/es-ES/short_description.txt b/fastlane/metadata/android/es-ES/short_description.txt deleted file mode 100644 index c07b06a57..000000000 --- a/fastlane/metadata/android/es-ES/short_description.txt +++ /dev/null @@ -1 +0,0 @@ -Red social descentralizada \ No newline at end of file diff --git a/fastlane/metadata/android/es-ES/title.txt b/fastlane/metadata/android/es-ES/title.txt deleted file mode 100644 index 8123241a0..000000000 --- a/fastlane/metadata/android/es-ES/title.txt +++ /dev/null @@ -1 +0,0 @@ -Mastodon \ No newline at end of file diff --git a/fastlane/metadata/android/eu-ES/full_description.txt b/fastlane/metadata/android/eu-ES/full_description.txt deleted file mode 100644 index afb57fe4b..000000000 --- a/fastlane/metadata/android/eu-ES/full_description.txt +++ /dev/null @@ -1,16 +0,0 @@ -Mastodon Interneteko sare sozial deszentralizatu handiena da. Webgune bakar bat izan ordez, beren artean elkarreragin dezaketen komunitate independenteetako milioika erabiltzailek osatutako sarea da. Zure interesak direnak direla ere, jende interesgarria aurkituko duzu Mastodonen! - -Batu komunitate batera eta sortu zure profila. Bilatu eta jarraitu jende zoragarria eta irakurri beren bidalketak, publizitaterik gabeko denbora-lerro kronologikoan. Adierazi nahi duzuna 500 karaktereko bidalketetan emoji pertsonalizatuak, irudiak, GIFak, bideoak eta audioak erabiliz. Erantzun edozeinen hariak eta eman bultzada bidalketei edukiak partekatzeko. Bilatu jarraitzeko kontu berriak eta traolen joerak zure sarea zabaltzeko. - -Mastodon pribatutasunean eta segurtasunean arreta jarriz eraikia dago. Erabaki zure bidalketak norekin partekatu: zure jarraitzaileekin, aipatzen dituzunekin edo mundu osoarekin. Edukiaren abisuek aukera ematen dute eduki sentibera edo zuregan eragina izan dezaketen bidalketak zuk erabaki arte ezkutatzeko. Komunitate bakoitzak bere gidalerro eta moderatzaileak ditu, bertako kideak seguru mantentzeko. Baita blokeatzeko eta salatzeko tresna sendoak ere abusuak galarazteko. - -Ezaugarri gehiago: - -• Modu iluna: Irakurri bidalketak modu argi, ilun edo benetako beltzean -• Inkestak: Galdetu jarraitzaileei beren iritzia eta zenbatu botoak -• Esploratu: Traolen joerak eta kontuak erraz aurkitu -• Jakinarazpenak: Jarraitzaile berrien, erantzunen edo bultzaden berri jaso -• Partekatzea: Argitaratu zuzenean Mastodonen edozein aplikaziotako partekatze-orritik -• Edertasuna: Gure maskota elefante maitagarri bat da eta tarteka agertuko zaizu - -Mastodon erregistratutako irabazi asmorik gabeko elkartea da eta zure dohaintzen bidez babesten da garapena. Ez dago iragarkirik, irabazi asmorik eta inbertsio kapitalik, eta horrela jarraitzeko asmoa dugu. diff --git a/fastlane/metadata/android/eu-ES/short_description.txt b/fastlane/metadata/android/eu-ES/short_description.txt deleted file mode 100644 index 8174e06c9..000000000 --- a/fastlane/metadata/android/eu-ES/short_description.txt +++ /dev/null @@ -1 +0,0 @@ -Sare sozial deszentralizatua \ No newline at end of file diff --git a/fastlane/metadata/android/eu-ES/title.txt b/fastlane/metadata/android/eu-ES/title.txt deleted file mode 100644 index 8123241a0..000000000 --- a/fastlane/metadata/android/eu-ES/title.txt +++ /dev/null @@ -1 +0,0 @@ -Mastodon \ No newline at end of file diff --git a/fastlane/metadata/android/fa-IR/full_description.txt b/fastlane/metadata/android/fa-IR/full_description.txt deleted file mode 100644 index 17b352388..000000000 --- a/fastlane/metadata/android/fa-IR/full_description.txt +++ /dev/null @@ -1,16 +0,0 @@ -ماستودون بزرگترین شبکه اجتماعی غیرمتمرکز در اینترنت است. به جای یک وب سایت واحد، شبکه ای متشکل از میلیون ها کاربر در جوامع مستقل است که همگی می توانند به صورت یکپارچه با یکدیگر تعامل داشته باشند. مهم نیست که به چه چیزی علاقه دارید، می توانید با افراد پرشوری که درباره آن در ماستودون فرسته ارسال می‌کنند صحبت کنید! - -به یک اجتماع بپیوندید و نمایه خود را ایجاد کنید. افراد جذاب را پیدا کنید و پی‌گیری کنید و فرسته‌های آنها را در یک خط زمانی بدون تبلیغات و زمانی بخوانید. با ایموجی‌های سفارشی، تصاویر، گیف‌ها، ویدیوها و صدا در فرسته‌های 500 کاراکتری، خود را بیان کنید. برای به اشتراک گذاشتن مطالب عالی، به موضوعات پاسخ دهید و فرسته‌های هر کسی را مجددا ری‌بلاگ کنید. اکانت های جدیدی را برای پی‌گیری پیدا کنید و برچسب‌های پرطرفدار را برای گسترش شبکه خود پیدا کنید. - -ماستودون با تمرکز بر حریم خصوصی و ایمنی ساخته شده است. تصمیم بگیرید که آیا پست‌های شما با پیگیرهایتان، فقط افرادی که نام می‌برید، یا کل دنیا به اشتراک گذاشته شود. هشدارهای محتوا به شما امکان می‌دهند پست‌های حاوی مطالب حساس یا محرک را پنهان کنید تا زمانی که آماده تعامل با آنها باشید. هر اجتماع‌ای دستورالعمل‌ها و ناظران خود را دارد تا اعضای خود را ایمن نگه دارد و ابزارهای قوی مسدود کردن و گزارش‌دهی به جلوگیری از سوء استفاده کمک می‌کند. - -قابلیت های دیگر: - -• حالت تاریک: فرسته‌ها را در حالت روشن، تاریک، یا سیاه بخوانید -• نظرسنجی: از پی‌گیران خود نظر بخواهید و رای‌ها را جمع‌آوری کنید -• کاوش: برچسب‌ها و حساب‌های پرطرفدار یک ضربه فاصله دارند -• آگاهی‌ها: از پی‌گیری‌ها، پاسخ‌های جدید و ری‌بلاگ‌ها مطلع شوید -• اشتراک گذاری: از هر برگه اشتراکی در هر برنامه، مستقیماً به ماستودون ارسال کنید -• ناز: طلسم ما یک فیل شایان ستایش است، و شما هر از گاهی آنها را خواهید دید - -ماستودون یک سازمان غیرانتفاعی ثبت شده است و توسعه مستقیماً توسط اعانه های مالی شما پشتیبانی می شود. نه تبلیغاتی وجود دارد،نه کسب درآمدی و نه سرمایه گذاری خطرپذیری و ما قصد داریم آن را به همین شکل حفظ کنیم. diff --git a/fastlane/metadata/android/fa-IR/short_description.txt b/fastlane/metadata/android/fa-IR/short_description.txt deleted file mode 100644 index 52c057a71..000000000 --- a/fastlane/metadata/android/fa-IR/short_description.txt +++ /dev/null @@ -1 +0,0 @@ -شبکه اجتماعی نامتمرکز \ No newline at end of file diff --git a/fastlane/metadata/android/fa-IR/title.txt b/fastlane/metadata/android/fa-IR/title.txt deleted file mode 100644 index 8123241a0..000000000 --- a/fastlane/metadata/android/fa-IR/title.txt +++ /dev/null @@ -1 +0,0 @@ -Mastodon \ No newline at end of file diff --git a/fastlane/metadata/android/fi-FI/full_description.txt b/fastlane/metadata/android/fi-FI/full_description.txt deleted file mode 100644 index f326ec870..000000000 --- a/fastlane/metadata/android/fi-FI/full_description.txt +++ /dev/null @@ -1,16 +0,0 @@ -Mastodon on internetin suurin hajautettu sosiaalinen verkosto. Yhden verkkopalvelun sijaan, se on miljoonien itsenäisissä yhteisöissä olevien käyttäjien verkosto, jotka voivat olla vuorovaikutuksessa toistensa kanssa saumattomasti. Riippumatta siitä, mistä olet kiinnostunut, voit tavata samanmielisiä ihmisiä, jotka julkaisevat aiheesta Mastodonissa! - -Liity yhteisöön ja luo itsellesi tili. Löydä ja seuraa kiehtovia ihmisiä ja lue heidän julkaisunsa ilman mainoksia, kronologisella aikajanalla. Ilmaise itseäsi mukautetuilla emojeilla, kuvilla, videoilla ja audiolla 500 merkin pituisissa julkaisuissa. Vastaa viestiketjuihin ja edelleen jaa julkaisuja keneltä tahansa, jakaaksesi hienoja juttuja. Löydä uusia tilejä seurattavaksi ja suosittuja aihetunnisteita laajentaaksesi verkostoasi. - -Mastodon on rakennettu keskittyen yksityisyyteen ja turvallisuuteen. Päätä, jaetaanko julkaisusi seuraajille, vain mainitsemillesi ihmisille vai koko maailmalle. Sisältövaroitusten avulla, voit piilottaa julkaisut, jotka sisältävät arkaluontoista tai laukaisevaa materiaalia, kunnes olet valmis käsittelemään niitä. Jokaisella yhteisöllä on omat ohjeistonsa ja valvojansa, jotka pitävät jäsenensä turvassa, ja tehokkaat esto- ja ilmiantotyökalut auttavat torjumaan väärinkäytöksiä. - -Lisää ominaisuuksia: - -• Tumma tila: Lue julkaisut vaaleassa, tummassa tai mustan tummassa tilassa -• Kyselyt: Kysy seuraajilta heidän mielipidettään ja laske äänet -• Tutustu: Suositut aihetunnisteet ja tilit ovat vain napsautuksen päässä -• Ilmoitukset: Saat ilmoituksen uusista seuraajista, vastauksista ja tehostuksista -• Jakaminen: Julkaise suoraan Mastodoniin minkä tahansa sovelluksen jakovalikon kautta -• Suloisuus: Maskottimme on ihastuttava mastodontti ja näet sen ajoittain - -Mastodon on rekisteröity voittoa tavoittelematon organisaatio ja kehitystä tuetaan suoraan lahjoituksillasi. Ei mainontaa, kaupallistamista eikä riskipääomaa, ja aiomme pitää sen sellaisena. diff --git a/fastlane/metadata/android/fi-FI/short_description.txt b/fastlane/metadata/android/fi-FI/short_description.txt deleted file mode 100644 index 50bb8a556..000000000 --- a/fastlane/metadata/android/fi-FI/short_description.txt +++ /dev/null @@ -1 +0,0 @@ -Hajautettu sosiaalinen verkosto \ No newline at end of file diff --git a/fastlane/metadata/android/fi-FI/title.txt b/fastlane/metadata/android/fi-FI/title.txt deleted file mode 100644 index 8123241a0..000000000 --- a/fastlane/metadata/android/fi-FI/title.txt +++ /dev/null @@ -1 +0,0 @@ -Mastodon \ No newline at end of file diff --git a/fastlane/metadata/android/fil-PH/full_description.txt b/fastlane/metadata/android/fil-PH/full_description.txt deleted file mode 100644 index 3457f09fb..000000000 --- a/fastlane/metadata/android/fil-PH/full_description.txt +++ /dev/null @@ -1,16 +0,0 @@ -Ang Mastodon ay ang pinakamalaking desentralisadong social network sa internet. Sa halip na isang solong website, ito ay isang network ng milyun-milyong mga gumagamit sa mga independiyenteng komunidad na lahat ay maaaring makipag-ugnay sa isa ' t isa, nang walang putol. Hindi mahalaga kung ano ang iyong naroroon, maaari mong matugunan ang mga madamdaming tao na nagpo-post tungkol dito sa Mastodon! - -Sumali sa isang komunidad at lumikha ng iyong profile. Hanapin at sundin ang mga kamangha-manghang mga tao at basahin ang kanilang mga post sa isang ad-free, magkakasunod na timeline. Ipahayag ang iyong sarili gamit ang pasadyang emoji, mga imahe, GIF, Video, at audio sa 500-character na mga post. Tumugon sa mga thread at reblog post mula sa sinuman para magbahagi ng magagandang bagay. Maghanap ng mga bagong account na susundan at nagte-trend na mga hashtag para mapalawak ang iyong network. - -Ang Mastodon ay binuo na may pagtuon sa pribado at kaligtasan. Magpasya ka kung ang iyong mga post ay ibinabahagi sa iyong mga tagasunod, ang mga taong binanggit mo lamang, o ang buong mundo. Hinahayaan ka ng mga babala sa nilalaman na itago ang mga post na naglalaman ng sensitibo o nakaka-trigger na materyal hanggang sa handa ka nang makisali sa kanila. Ang bawat komunidad ay may sariling mga alituntunin at moderator para mapanatiling ligtas ang mga miyembro nito, at ang matatag na mga tool sa pag-block at pag-uulat ay makakatulong na maiwasan ang pang-aabuso. - -Higit pang mga tampok: - -* Madilim na Mode: Basahin ang mga post sa liwanag, madilim, o maitim na mode -* Mga botohan: hilingin sa mga tagasunod ang kanilang opinyon at tally ang mga boto -* Galugarin: nagte-trend hashtags at mga account ay isang tap ang layo -* Mga Abiso: Maabisuhan tungkol sa mga bagong sumusunod, tugon, at reblogs -* Pagbabahagi: Mag-Post nang direkta sa Mastodon mula sa anumang share sheet sa anumang app -* Cuteness: ang aming maskot ay isang kaibig-ibig na elepante, at makikita mo silang pop up paminsan-minsan - -Mastodon ay isang rehistradong nonprofit at pag-unlad ay suportado nang direkta sa pamamagitan ng iyong mga donasyon. Walang advertising, walang monetization, at walang venture capital, at plano naming panatilihin ito sa ganoong paraan. diff --git a/fastlane/metadata/android/fil-PH/short_description.txt b/fastlane/metadata/android/fil-PH/short_description.txt deleted file mode 100644 index 30480a243..000000000 --- a/fastlane/metadata/android/fil-PH/short_description.txt +++ /dev/null @@ -1 +0,0 @@ -Desentralisadong social network \ No newline at end of file diff --git a/fastlane/metadata/android/fil-PH/title.txt b/fastlane/metadata/android/fil-PH/title.txt deleted file mode 100644 index 8123241a0..000000000 --- a/fastlane/metadata/android/fil-PH/title.txt +++ /dev/null @@ -1 +0,0 @@ -Mastodon \ No newline at end of file diff --git a/fastlane/metadata/android/fr-FR/full_description.txt b/fastlane/metadata/android/fr-FR/full_description.txt deleted file mode 100644 index 2f95d86e4..000000000 --- a/fastlane/metadata/android/fr-FR/full_description.txt +++ /dev/null @@ -1,16 +0,0 @@ -Mastodon est le plus grand réseau social décentralisé sur Internet. Au lieu d’un site Web unique, c’est un réseau de millions d’utilisateurs dans des communautés indépendantes qui peuvent tous interagir les uns avec les autres, de manière transparente. Peu importe ce que vous êtes, vous pouvez rencontrer des gens passionnés qui publient à ce sujet sur Mastodon ! - -Rejoignez une communauté et créez votre profil. Trouvez et suivez des personnes fascinantes et lisez leurs messages chronologiquement et sans publicité. Exprimez-vous avec des émojis personnalisés, des images, des GIFs, des vidéos et de l’audio dans des messages de 500 caractères. Répondez aux sujets de discussions et aux reblogues de n’importe qui pour partager des choses géniales. Trouvez de nouveaux comptes à suivre et des hashtags tendance pour étendre votre réseau. - -Mastodon est construit en mettant l’accent sur la vie privée et la sécurité. Décidez si vos messages sont partagés avec vos abonnés, les personnes que vous mentionnez, ou le monde entier. Les avertissements de contenu vous permettent de masquer les messages au contenu sensible jusqu’à ce que vous soyez prêt à vous engager avec eux. Chaque communauté a ses propres directives et modérateurs pour assurer la sécurité de ses membres, et de solides outils de blocage et de signalement aident à prévenir les abus. - -Plus de fonctionnalités : - -• Mode Sombre : Lisez les messages en mode clair, sombre ou vrai noir -• Sondages : Demandez l’opinion à vos abonnés et comptez les votes -• Explorer : Les hashtags et les comptes tendance sont à portée de main -• Notifications : Soyez informé des nouveaux abonnements, réponses et reblogs -• Partage : Publiez directement sur Mastodon à partir de n’importe quelle feuille de partage dans n’importe quelle application -• Cuteness : Notre mascotte est un adorable éléphant, et vous la verrez apparaître de temps en temps - -Mastodon est un organisme sans but lucratif enregistré et le développement est soutenu directement par vos dons. Il n’y a pas de publicité, pas de monétisation, pas de capital-risque, et nous prévoyons de continuer ainsi. diff --git a/fastlane/metadata/android/fr-FR/short_description.txt b/fastlane/metadata/android/fr-FR/short_description.txt deleted file mode 100644 index c5266d577..000000000 --- a/fastlane/metadata/android/fr-FR/short_description.txt +++ /dev/null @@ -1 +0,0 @@ -Réseau social décentralisé \ No newline at end of file diff --git a/fastlane/metadata/android/fr-FR/title.txt b/fastlane/metadata/android/fr-FR/title.txt deleted file mode 100644 index 8123241a0..000000000 --- a/fastlane/metadata/android/fr-FR/title.txt +++ /dev/null @@ -1 +0,0 @@ -Mastodon \ No newline at end of file diff --git a/fastlane/metadata/android/ga-IE/full_description.txt b/fastlane/metadata/android/ga-IE/full_description.txt deleted file mode 100644 index 11d74abb1..000000000 --- a/fastlane/metadata/android/ga-IE/full_description.txt +++ /dev/null @@ -1,16 +0,0 @@ -Mastodon is the largest decentralized social network on the internet. Instead of a single website, it’s a network of millions of users in independent communities that can all interact with one another, seamlessly. No matter what you’re into, you can meet passionate people posting about it on Mastodon! - -Join a community and create your profile. Find and follow fascinating folks and read their posts in an ad-free, chronological timeline. Express yourself with custom emoji, images, GIFs, videos, and audio in 500-character posts. Reply to threads and reblog posts from anyone to share great stuff. Find new accounts to follow and trending hashtags to expand your network. - -Mastodon is built with a focus on privacy and safety. Decide whether your posts are shared with your followers, just the people you mention, or the whole world. Content warnings let you hide posts containing sensitive or triggering material until you're ready to engage with them. Each community has its own guidelines and moderators to keep its members safe, and robust blocking and reporting tools help prevent abuse. - -More features: - -• Dark Mode: Read posts in light, dark, or true black mode -• Polls: Ask followers for their opinion and tally the votes -• Explore: Trending hashtags and accounts are a tap away -• Notifications: Get notified about new follows, replies, and reblogs -• Sharing: Post directly to Mastodon from any share sheet in any app -• Cuteness: Our mascot is an adorable elephant, and you'll see them pop up from time to time - -Mastodon is a registered nonprofit and development is supported directly by your donations. There’s no advertising, no monetization, and no venture capital, and we plan to keep it that way. diff --git a/fastlane/metadata/android/ga-IE/short_description.txt b/fastlane/metadata/android/ga-IE/short_description.txt deleted file mode 100644 index 8f5a9b847..000000000 --- a/fastlane/metadata/android/ga-IE/short_description.txt +++ /dev/null @@ -1 +0,0 @@ -Decentralized social network \ No newline at end of file diff --git a/fastlane/metadata/android/ga-IE/title.txt b/fastlane/metadata/android/ga-IE/title.txt deleted file mode 100644 index 8123241a0..000000000 --- a/fastlane/metadata/android/ga-IE/title.txt +++ /dev/null @@ -1 +0,0 @@ -Mastodon \ No newline at end of file diff --git a/fastlane/metadata/android/gd-GB/full_description.txt b/fastlane/metadata/android/gd-GB/full_description.txt deleted file mode 100644 index 4c552836c..000000000 --- a/fastlane/metadata/android/gd-GB/full_description.txt +++ /dev/null @@ -1,16 +0,0 @@ -’S e an lìonra sòisealta sgaoilte as motha air an eadar-lìon a th’ ann am Mastodon. Seach aon làrach-lìn a-mhàin, ’s e lìonra de mhilleanan de dhaoine ann an coimhearsnachdan neo-eisimeileach a th’ ann agus ’s urrainn dhan a h-uile duine bruidhinn ri chèile fhathast gun duilgheadas. Ge b’ e dè na rudan a tha ùidh agad annta, coinnichidh tu ri daoine a sgrìobhas mun dèidhinn air Mastodon! - -Faigh ballrachd ann an coimhearsnachd ’s cruthaich pròifil dhut. Lorg is lean daoine inntinneach leugh na postaichean aca air loidhne-ama cheart gun sanasachd. Cuir thu fhèin an cèill le Emojis gnàthaichte, dealbhan, GIFs, videothan is fuaimean ann am postaichean le 500 caractar. Freagair ri snàithleanan is brosnaich postaichean le neach sam bith airson deagh rudan a cho-roinneadh. Lorg cunntasan ùra ri leantainn is tagaichean hais a’ treandadh airson an lìonra agad a leudachadh. - -Chaidh Mastodon a thogail leis an aire air prìobhaideachd is sàbhailteachd. Tha e an urra riut fhèin an co-roinn thu post leis an luchd-leantainn agad, leis na daoine air an doir thu iomradh a-mhàin no leis an t-saoghal mhòr. Leigidh rabhaidhean susbainte leat postaichean sa bheil susbaint fhrionasach fhalach is cha leig daoine leas coimhead air ach nuair a bhios iad deònach. Tha riaghailtean is maoir fa leth aig gach coimhearsnachd airson a buill a chumail sàbhailte agus cuidichidh innealan bacaidh is gearain le dìon o dhroch-dhìol. - -Gleusan eile: - -• Modh dorcha: Leugh postaichean le modh soilleir, dorcha no dubh dorcha -• Cunntasan-bheachd: Faighnich dhen luchd-leantainn dè am beachd is faigh cunntas nam bhòt -• Rùraich: Ruig tagaichean hais is cunntasan a’ treandadh le aon ghnogag -• Brathan: Faigh brathan mu luchd-leantainn, freagairtean is brosnachaidhean ùra -• Co-roinn: Postaich gu Mastodon gu dìreach o shiota co-roinnidh ann an aplacaid sam bith -• Stampachd: ’S e ailbhean ealanta a tha san t-suaichnean againn is nochdaidh e o àm gu àm - -’S e bhuidheann neo-phrothaideach clàraichte a th’ ann am Mastodon a gheibh taic dhìreach o na tabhartasan agad. Chan eil sanasachd, airgeadachadh no calpa iomairte sam bith ann agus tha fainear dhuinn ’ga chumail mar sin. diff --git a/fastlane/metadata/android/gd-GB/short_description.txt b/fastlane/metadata/android/gd-GB/short_description.txt deleted file mode 100644 index 9989353d8..000000000 --- a/fastlane/metadata/android/gd-GB/short_description.txt +++ /dev/null @@ -1 +0,0 @@ -Lìonra sòisealta sgaoilte \ No newline at end of file diff --git a/fastlane/metadata/android/gd-GB/title.txt b/fastlane/metadata/android/gd-GB/title.txt deleted file mode 100644 index 8123241a0..000000000 --- a/fastlane/metadata/android/gd-GB/title.txt +++ /dev/null @@ -1 +0,0 @@ -Mastodon \ No newline at end of file diff --git a/fastlane/metadata/android/gl-ES/full_description.txt b/fastlane/metadata/android/gl-ES/full_description.txt deleted file mode 100644 index f87c26138..000000000 --- a/fastlane/metadata/android/gl-ES/full_description.txt +++ /dev/null @@ -1,16 +0,0 @@ -Mastodon é a rede social descentralizada máis grande de internet. Non é unha soa web, é unha rede de millóns de persoas en comunidades independentes que poden interactuar entre elas, sen problema. Sexan cales fosen os teus intereses, podes atopar persoas comentando ese tema en Mastodon! - -Únete a unha comunidade e crea un perfil. Atopa e sigue a persoas abraiantes lendo as súas publicacións en cronoloxías sen publicidade e orde cronolóxica. Exprésate usando emojis personalizados, imaxes, GIFs, vídeos e audio con publicacións de 500 caracteres. Responde aos fíos e promove publicacións doutras persoas que creas relevantes. Atopa novas contas e segue os cancelos en voga para facer medrar a túa rede. - -Mastodon está creado pensando na privacidade e seguridade. Decide con quen compartes as túas publicacións, só coas seguidoras ou persoas que mencionas, ou con todo o mundo. Os avisos sobre o contido permiten agochar contido sensible ou material que podería crear ansiedade ata que estás preparada para velo. Cada comunidade ten as súas normas e a moderación coida da seguridade das persoas da instancia, con ferramentas para denunciar e bloquear e así evitar abusos. - -Máis características: - -• Modo Escuro: ler publicacións en modo claro, escuro ou negro verdadeiro -• Enquisas: pregúntalle ás seguidoras a súa opinión e recolle os votos -• Explorar: Cancelos e contas en voga facilmente accesibles -• Notificacións: recibe notificacións sobre seguimentos, respostas e promocións -• Compartir: publica directamente en Mastodon desde o menú de calquera app -• Fermosura: a nosa mascota é un elefante moi feitiño, que verás a miúdo por aquí - -Mastodon é unha organización rexistrada sen ánimo de lucro cuxo desenvolvemento está financiado por doazóns. Non hai publicidade, nen monetización, sen inversións de capital risco, e pretendemos seguir así. diff --git a/fastlane/metadata/android/gl-ES/short_description.txt b/fastlane/metadata/android/gl-ES/short_description.txt deleted file mode 100644 index 33849fd5f..000000000 --- a/fastlane/metadata/android/gl-ES/short_description.txt +++ /dev/null @@ -1 +0,0 @@ -Rede social descentralizada \ No newline at end of file diff --git a/fastlane/metadata/android/gl-ES/title.txt b/fastlane/metadata/android/gl-ES/title.txt deleted file mode 100644 index 8123241a0..000000000 --- a/fastlane/metadata/android/gl-ES/title.txt +++ /dev/null @@ -1 +0,0 @@ -Mastodon \ No newline at end of file diff --git a/fastlane/metadata/android/he-IL/full_description.txt b/fastlane/metadata/android/he-IL/full_description.txt deleted file mode 100644 index 255f0d10a..000000000 --- a/fastlane/metadata/android/he-IL/full_description.txt +++ /dev/null @@ -1,16 +0,0 @@ -מסטודון היא הרשת החברתית המבוזרת הגדולה ביותר באינטרנט. במקום אתר אחד, מסטודון היא רשת של מיליוני משתמשים בקהילות עצמאיות שיכולות לפעול ביחד באופן חלק. לא משנה מה הקטע שלכם, אתם יכולים לפגוש אנשים שמתעניינים בו וכותבים עליו במסטודון! - -הצטרפו לקהילה וצרו את המשתמש שלכם. Find and follow fascinating folks and read their posts in an ad-free, chronological timeline. הביעו את עצמכם באמצעות אמוג׳ים מעוצבים, תמונות, גיפים, סרטונים, ושמע ברשומות של עד 500 תווים. הגיבו לשרשורים ועשו ״ריבלוג״ לרשומות של כל מי שאתם רוצים כדי לשתף דברים מגניבים. מצאו חשבונות חדשים לעקוב אחריהם והאשטגים טרנדיים כדי להרחיב את רשתותיכם. - -מסטודון בנויה עם פוקוס על פרטיות ובטיחות. קבעו האם הרשומות יתפרסמו עבור העוקבים שלכם, רק האנשים שאתם מציינים, או כל העולם. אזהרות תוכן מאפשרות לכם להסתיר רשומות המכילות תוכן רגיש או שעלול להוות טריגר עד שתהיו מוכנים להתמודד איתן. לכל קהילה כללים ומנהלים משלה שמטרתם לשמור על כך שכל חבריה יהיו בטוחים, וכלי חסימה ודיווח ענפים כדי לעזור למנוע שימוש לרעה. - -תכונות נוספות: - -• מצב חשוך: קראו רשומות במצב בהיר, חשוך, או שחור־אמיתי -• סקרים: שאלו את העוקבים שלכם מה דעתם וספרו את הקולות -• לגלות: האשטגים טרנדיים וחשבונות הם רק מרחק לחיצה -• עדכונים: קבלו עדכונים אודות עוקבים חדשים, תגובות, וריבלוגים -• שיתוף: העלו ישירות למסטודון מכל עמוד שיתוף בכל אפליקציה -• חמידות: הקמע שלנו הוא פיל מקסים, ואתם תראו אותם מופיעים פה ושם - -מסטודון רשומה כארגון ללא מטרות רווח והפיתוח ממומן ישירות מתרומותכם. אין שום פרסומות, שום תרגום מובנה של הצלחה לכסף, ושום הון־סיכון, ואנחנו מתכננים לשמור על זה כך. diff --git a/fastlane/metadata/android/he-IL/short_description.txt b/fastlane/metadata/android/he-IL/short_description.txt deleted file mode 100644 index 1ab7358a4..000000000 --- a/fastlane/metadata/android/he-IL/short_description.txt +++ /dev/null @@ -1 +0,0 @@ -רשת חברתית מבוזרת \ No newline at end of file diff --git a/fastlane/metadata/android/he-IL/title.txt b/fastlane/metadata/android/he-IL/title.txt deleted file mode 100644 index 8123241a0..000000000 --- a/fastlane/metadata/android/he-IL/title.txt +++ /dev/null @@ -1 +0,0 @@ -Mastodon \ No newline at end of file diff --git a/fastlane/metadata/android/hi-IN/full_description.txt b/fastlane/metadata/android/hi-IN/full_description.txt deleted file mode 100644 index 721f53d45..000000000 --- a/fastlane/metadata/android/hi-IN/full_description.txt +++ /dev/null @@ -1,16 +0,0 @@ -Mastodon इंटरनेट का सबसे बड़ा डिसेंट्रलाइज़्ड सोशल नेटवर्क है। एक सिंगल वेबसाइट के जगह, ये हज़ारों आज़ाद ग्रुपों के लाखों यूज़रों का एक नेटवर्क है जो एक दूसरे से आसानी से बात करते है। चाहे आपकी जो भी दिलचस्पी हो, आपको उसके ऊपर पोस्ट करनेवाले लोग Mastodon पे ज़रूर मिलेंगे! - -कोई ग्रुप जॉइन करें और अपना प्रोफाइल बनाएं। दिलचस्प लोगों को ढूंढ़ें और फॉलो करें और उनके पोस्ट पढ़ें बिना किसी ऐड के। Express yourself with custom emoji, images, GIFs, videos, and audio in 500-character posts. Reply to threads and reblog posts from anyone to share great stuff. Find new accounts to follow and trending hashtags to expand your network. - -Mastodon is built with a focus on privacy and safety. Decide whether your posts are shared with your followers, just the people you mention, or the whole world. Content warnings let you hide posts containing sensitive or triggering material until you're ready to engage with them. Each community has its own guidelines and moderators to keep its members safe, and robust blocking and reporting tools help prevent abuse. - -एक्स्ट्रा फीचर: - -• डार्क मोड: पोस्ट लाइट, डार्क, या प्योर ब्लैक मोड में पढ़ें -• Polls: Ask followers for their opinion and tally the votes -• Explore: Trending hashtags and accounts are a tap away -• Notifications: Get notified about new follows, replies, and reblogs -• Sharing: Post directly to Mastodon from any share sheet in any app -• क्यूटपन: हमारा मैस्कॉट एक प्यारा हाथी है, और आप उसे समय-समय पे देखेंगे - -Mastodon is a registered nonprofit and development is supported directly by your donations. There’s no advertising, no monetization, and no venture capital, and we plan to keep it that way. diff --git a/fastlane/metadata/android/hi-IN/short_description.txt b/fastlane/metadata/android/hi-IN/short_description.txt deleted file mode 100644 index a5d13620f..000000000 --- a/fastlane/metadata/android/hi-IN/short_description.txt +++ /dev/null @@ -1 +0,0 @@ -डिसेंट्रलाइज़्ड सोशल नेटवर्क \ No newline at end of file diff --git a/fastlane/metadata/android/hi-IN/title.txt b/fastlane/metadata/android/hi-IN/title.txt deleted file mode 100644 index 8123241a0..000000000 --- a/fastlane/metadata/android/hi-IN/title.txt +++ /dev/null @@ -1 +0,0 @@ -Mastodon \ No newline at end of file diff --git a/fastlane/metadata/android/hr-HR/full_description.txt b/fastlane/metadata/android/hr-HR/full_description.txt deleted file mode 100644 index d35e32d83..000000000 --- a/fastlane/metadata/android/hr-HR/full_description.txt +++ /dev/null @@ -1,16 +0,0 @@ -Mastodon je najveća decentralizirana društvena mreža na internetu. Umjesto jedne web stranice, to je mreža milijuna korisnika u neovisnim zajednicama koje sve mogu međusobno komunicirati. Bez obzira na to što te zanima, možeš upoznati strastvene ljude koji o tome objavljuju na Mastodonu! - -Pridruži se zajednici i kreiraj svoj profil. Find and follow fascinating folks and read their posts in an ad-free, chronological timeline. Izrazi se prilagođenim emojijima, slikama, GIF-ovima, videozapisima i zvukom u objavama od 500 znakova. Odgovori na teme i reblogaj postove od bilo koga da podijeliš sjajne stvari. Pronađi nove račune koje ćeš pratiti i popularne hashtagove kako bi proširio/la svoju mrežu. - -Mastodon je izgrađen s fokusom na privatnost i sigurnost. Odluči hoće li se tvoje objave dijeliti s tvojim sljedbenicima, samo s osobama koje spominjete ili s cijelim svijetom. Upozorenja o sadržaju omogućuju ti da sakriješ postove koji sadrže osjetljivi ili izazovni materijal dok ne budeš spreman za interakciju s njima. Svaka zajednica ima vlastite smjernice i moderatore kako bi zaštitili svoje članove, a robusni alati za blokiranje i prijavljivanje pomažu u sprječavanju zlouporabe. - -Više značajki: - -• Tamni način rada: čitaj postove u svijetlom, tamnom ili stvarno crnom načinu -• Ankete: Pitaj sljedbenike za mišljenje i zbroji glasove -• Istraži: popularni hashtagovi i računi udaljeni su samo jedan dodir -• Obavijesti: primaj obavijesti o novim pratiteljima, odgovorima i reblogovima -• Dijeljenje: objavi izravno na Mastodonu s bilo kojeg lista za dijeljenje u bilo kojoj aplikaciji -• Slatkoća: Naša maskota je ljupki slon i vidjet ćeš ih kako iskaču s vremena na vrijeme - -Mastodon je registrirana neprofitna organizacija i razvoj je podržan izravno vašim donacijama. Nema oglašavanja, nema monetizacije i rizičnog kapitala, a planiramo takvi i ostati. diff --git a/fastlane/metadata/android/hr-HR/short_description.txt b/fastlane/metadata/android/hr-HR/short_description.txt deleted file mode 100644 index 15e9dc7f1..000000000 --- a/fastlane/metadata/android/hr-HR/short_description.txt +++ /dev/null @@ -1 +0,0 @@ -Decentralizirana društvena mreža \ No newline at end of file diff --git a/fastlane/metadata/android/hr-HR/title.txt b/fastlane/metadata/android/hr-HR/title.txt deleted file mode 100644 index 8123241a0..000000000 --- a/fastlane/metadata/android/hr-HR/title.txt +++ /dev/null @@ -1 +0,0 @@ -Mastodon \ No newline at end of file diff --git a/fastlane/metadata/android/hu-HU/full_description.txt b/fastlane/metadata/android/hu-HU/full_description.txt deleted file mode 100644 index 21bcef2a3..000000000 --- a/fastlane/metadata/android/hu-HU/full_description.txt +++ /dev/null @@ -1,16 +0,0 @@ -A Mastodon a legnagyobb decentralizált közösségi hálózat az interneten. Egyetlen weboldal helyett, ez több millió felhasználóból álló, független közösségek hálózata, amelyek egymással kapcsolatba tudnak lépni, zökkenőmentesen. Nem számít, mi az érdeklődésed, a Mastodonon találkozhatsz róla posztoló lelkes emberekkel! - -Csatlakozz egy közösséghez és készítsd el a profilodat. Találj meg és kövess be fantasztikus embereket, olvasd el a bejegyzéseiket reklámmentesen, időrendben. Fejezd ki magad egyedi hangulatjelekkel, képekkel, GIFekkel, videókkal és hanggal, 500 karakter hosszúságú bejegyzésekben. Válaszolj szálakban, told meg bárki bejegyzését, hogy megoszthass szuper dolgokat. Fedezz fel új fiókokat amiket követhetsz és felkapott hashtageket, hogy bővíthesd a kapcsolataidat. - -A Mastodon az adatvédelemre és a biztonságra összpontosítva épült. Döntsd el, hogy a bejegyzéseidet csak a követőiddel, csak azokkal akiket megemlítesz, vagy az egész világgal osztod meg. A tartalomfigyelmeztetések elrejthetővé teszik az érzékeny vagy ingerlő tartalmakat addig, amíg nem vagy kész azok megtekintésére. Minden közösségnek saját irényelvei és moderátorai vannak arra, hogy biztonságban tudják a tagjaikat. Erőteljes letiltási és bejelentési eszközök segítik a visszaélések megelőzését. - -További funkciók: - -• Sötét mód: Olvasd a bejegyzéseket világos, sötét vagy teljesen fekete módban -• Szavazás: Kérd ki a követőid véleményét és összesítsd a szavazataikat -• Felfedezés: A felkapott hashtagek és fiókok egyetlen kattintásra vannak -• Értesítések: Értesülj az új követőidről, válaszokról, megtolásokról -• Megosztás: Írj bejegyzést Mastodonra bármely app megosztási funkciójával -• Cukiság: A kabalánk egy cuki elefánt, mely fel fog bukkanni időről időre - -A Mastodon egy bejegyzett non-profit szervezet, a fejlesztés közvetlenül a te adományaidból történik. Nincs hirdetés, se monetizáció, se kockázati tőke, és ez így is fog maradni. diff --git a/fastlane/metadata/android/hu-HU/short_description.txt b/fastlane/metadata/android/hu-HU/short_description.txt deleted file mode 100644 index f9d530ba8..000000000 --- a/fastlane/metadata/android/hu-HU/short_description.txt +++ /dev/null @@ -1 +0,0 @@ -Decentralizált közösségi hálózat \ No newline at end of file diff --git a/fastlane/metadata/android/hu-HU/title.txt b/fastlane/metadata/android/hu-HU/title.txt deleted file mode 100644 index 8123241a0..000000000 --- a/fastlane/metadata/android/hu-HU/title.txt +++ /dev/null @@ -1 +0,0 @@ -Mastodon \ No newline at end of file diff --git a/fastlane/metadata/android/hy-AM/full_description.txt b/fastlane/metadata/android/hy-AM/full_description.txt deleted file mode 100644 index df833deba..000000000 --- a/fastlane/metadata/android/hy-AM/full_description.txt +++ /dev/null @@ -1,16 +0,0 @@ -Մաստոդոնը համացանցի ամենամեծ ապակենտրոնացված սոցցանցն է։ Այն մի կայք չէ, այլ իրար հետ կապակցված անկախ համայնքների միլիոնավոր օգտատերերից կազմված ցանց։ No matter what you’re into, you can meet passionate people posting about it on Mastodon! - -Միացեք համայնքին և ստեղծեք հաշիվ։ Find and follow fascinating folks and read their posts in an ad-free, chronological timeline. Express yourself with custom emoji, images, GIFs, videos, and audio in 500-character posts. Reply to threads and reblog posts from anyone to share great stuff. Find new accounts to follow and trending hashtags to expand your network. - -Mastodon is built with a focus on privacy and safety. Decide whether your posts are shared with your followers, just the people you mention, or the whole world. Զգայուն կամ հրահրող թեմաներով գրառումները կարելի է թաքցնել նախազգուշացումներով։ Each community has its own guidelines and moderators to keep its members safe, and robust blocking and reporting tools help prevent abuse. - -Ավելին՝ - -• Մուգ տարբերակ՝ կարդացեք գրառումներ մուգ, բաց կամ իսկական սև տարբերակներում -• Հարցումներ՝ իմացեք ձեր հետևորդների կարծիքը և հաշվեք ձայները -• Explore: Trending hashtags and accounts are a tap away -• Notifications: Get notified about new follows, replies, and reblogs -• Sharing: Post directly to Mastodon from any share sheet in any app -• Cuteness: Our mascot is an adorable elephant, and you'll see them pop up from time to time - -Մաստոդոնը գրանցված շահույթ չհետապնդող կազմակերպություն է, և աջակցվում է ձեր նվիրաբերություններով։ There’s no advertising, no monetization, and no venture capital, and we plan to keep it that way. diff --git a/fastlane/metadata/android/hy-AM/short_description.txt b/fastlane/metadata/android/hy-AM/short_description.txt deleted file mode 100644 index 08de8d594..000000000 --- a/fastlane/metadata/android/hy-AM/short_description.txt +++ /dev/null @@ -1 +0,0 @@ -Ապակենտրոնացված սոցիալական ցանց \ No newline at end of file diff --git a/fastlane/metadata/android/hy-AM/title.txt b/fastlane/metadata/android/hy-AM/title.txt deleted file mode 100644 index 8123241a0..000000000 --- a/fastlane/metadata/android/hy-AM/title.txt +++ /dev/null @@ -1 +0,0 @@ -Mastodon \ No newline at end of file diff --git a/fastlane/metadata/android/id-ID/full_description.txt b/fastlane/metadata/android/id-ID/full_description.txt deleted file mode 100644 index 976143f20..000000000 --- a/fastlane/metadata/android/id-ID/full_description.txt +++ /dev/null @@ -1,16 +0,0 @@ -Mastodon dalah jaringan sosial terdesentralisasi terbesar di internet. Bukan hanya satu situs web, ini adalah jaringan dari jutaan pengguna dalam komunitas tersendiri yang dapat saling interaksi antar sesama, tanpa batasan. Apapun yang kamu minati, kamu dapat bertemu orang-orang baru yang mengirimkan apa yang mereka minati di Mastodon! - -Bergabunglah dalam sebuah komunitas dan buat profil kalian. Temukan dan ikuti orang-orang yang menarik dan baca postingan mereka dalam linimasa yang kronologis serta bebas iklan. Ekspresikan diri Anda dengan emoji kustom, gambar, GIF, video, dan audio dalam kiriman dengan batasan 500 karakter. Balas ke utasan dan bagikan kiriman dari siapa pun ke pengikut Anda untuk membagikan hal-hal yang keren. Temukan akun baru untuk diikuti dan tagar yang sedang tren untuk memperluas jejaring Anda. - -Mastodon dibuat dengan fokus pada privasi dan keamanan. Tentukan apakah kiriman Anda dibagikan kepada pengikut, hanya orang yang disebut, atau seluruh dunia. Peringatan konten memungkinkan Anda untuk menyembunyikan kiriman yang berisi material sensitif atau memicu sampai Anda siap untuk terlibat dengan mereka. Setiap komunitas memiliki pedoman dan moderator sendiri-sendiri untuk menjaga anggotanya aman, dan alat pemblokiran dan pelaporan yang kokoh membantu mencegah pelecehan. - -Fitur lainnya: - -• Mode Gelap: Baca kiriman dalam mode terang, gelap, atau gelap asli -• Pemungutan suara: Tanya pengikut tentang opini mereka dan hitung pilihannya -• Jelajahi: Tagar dan akun tren dengan satu ketuk -• Pemberitahuan: Dapatkan pemberitahuan tentang pengikut, balasan, dan pembagian baru -• Pembagian: Kirim langsung ke Mastodon dari lembar pembagian apa pun dalam aplikasi apa pun -• Kelucuan: Maskot kami adalah seekor gajah yang lucu, dan Anda akan melihat dia muncul dari waktu ke waktu - -Mastodon adalah nirlaba yang terdaftar dan pengembangan didukung secara langsung dari donasi Anda. Tanpa periklanan, tanpa monetisasi, dan tanpa kapitalisme ventura, dan kami berencana untuk tetap seperti itu. diff --git a/fastlane/metadata/android/id-ID/short_description.txt b/fastlane/metadata/android/id-ID/short_description.txt deleted file mode 100644 index 50444bdea..000000000 --- a/fastlane/metadata/android/id-ID/short_description.txt +++ /dev/null @@ -1 +0,0 @@ -Jaringan sosial terdesentralisasi \ No newline at end of file diff --git a/fastlane/metadata/android/id-ID/title.txt b/fastlane/metadata/android/id-ID/title.txt deleted file mode 100644 index 8123241a0..000000000 --- a/fastlane/metadata/android/id-ID/title.txt +++ /dev/null @@ -1 +0,0 @@ -Mastodon \ No newline at end of file diff --git a/fastlane/metadata/android/ig-NG/full_description.txt b/fastlane/metadata/android/ig-NG/full_description.txt deleted file mode 100644 index 11d74abb1..000000000 --- a/fastlane/metadata/android/ig-NG/full_description.txt +++ /dev/null @@ -1,16 +0,0 @@ -Mastodon is the largest decentralized social network on the internet. Instead of a single website, it’s a network of millions of users in independent communities that can all interact with one another, seamlessly. No matter what you’re into, you can meet passionate people posting about it on Mastodon! - -Join a community and create your profile. Find and follow fascinating folks and read their posts in an ad-free, chronological timeline. Express yourself with custom emoji, images, GIFs, videos, and audio in 500-character posts. Reply to threads and reblog posts from anyone to share great stuff. Find new accounts to follow and trending hashtags to expand your network. - -Mastodon is built with a focus on privacy and safety. Decide whether your posts are shared with your followers, just the people you mention, or the whole world. Content warnings let you hide posts containing sensitive or triggering material until you're ready to engage with them. Each community has its own guidelines and moderators to keep its members safe, and robust blocking and reporting tools help prevent abuse. - -More features: - -• Dark Mode: Read posts in light, dark, or true black mode -• Polls: Ask followers for their opinion and tally the votes -• Explore: Trending hashtags and accounts are a tap away -• Notifications: Get notified about new follows, replies, and reblogs -• Sharing: Post directly to Mastodon from any share sheet in any app -• Cuteness: Our mascot is an adorable elephant, and you'll see them pop up from time to time - -Mastodon is a registered nonprofit and development is supported directly by your donations. There’s no advertising, no monetization, and no venture capital, and we plan to keep it that way. diff --git a/fastlane/metadata/android/ig-NG/short_description.txt b/fastlane/metadata/android/ig-NG/short_description.txt deleted file mode 100644 index 8f5a9b847..000000000 --- a/fastlane/metadata/android/ig-NG/short_description.txt +++ /dev/null @@ -1 +0,0 @@ -Decentralized social network \ No newline at end of file diff --git a/fastlane/metadata/android/ig-NG/title.txt b/fastlane/metadata/android/ig-NG/title.txt deleted file mode 100644 index 8123241a0..000000000 --- a/fastlane/metadata/android/ig-NG/title.txt +++ /dev/null @@ -1 +0,0 @@ -Mastodon \ No newline at end of file diff --git a/fastlane/metadata/android/is-IS/full_description.txt b/fastlane/metadata/android/is-IS/full_description.txt deleted file mode 100644 index 77e5f7193..000000000 --- a/fastlane/metadata/android/is-IS/full_description.txt +++ /dev/null @@ -1,30 +0,0 @@ -Mastodon er stærsta ómiðstýrða samfélagsnetið á internetinu. Í staðinn fyrir að vera á inu vefsvæði, er þetta net með milljónum notenda í -sjálfstæðum samfélögum, sem geta óhindrað átt í samskiptum við hvern annan. Sama hvað þú ert að pæla, alltaf geturðu hitt áhugasamt fólk í gegnum -færslur á Mastodon! - -Taktu þátt í samfélagi og útbúðu notandasnið fyrir þig. Finndu og fylgstu með áhugaverðu fólki og lestu færslurnar þeirra á -auglýsingalausri, raðaðri tímalínu. Tjáðu þig með sérsniðnum emoji-táknum, myndum, GIF-hreyfimyndum, myndskeiðum -og hljóðskrám í 500-stafa færslum. Svaraðu spjallþráðum og endurbirtu færslur frá hverjum sem er til að deila -frábæru efni. Finndu nýja notendur til að fylgjast með og skoðaðu vinsæl myllumerki til að -útvíkka netið þitt. - -Mastodon er byggt með áherslu á gagnaleynd og öryggi. Ákveddu hvort færslunum þínum sé deilt með þeim sem fylgjast með þér, aðeins -fólkinu sem þú minnist á, eða allri veröldinni. Viðvaranir vegna efnis gera þér kleift að fela færslur sem innihalda -viðkvæmt eða eldfimt efni þangað til þú ert í stuði til að eiga við slíkt. Hvert samfélag er með sínar eigin reglur og umsjónarmenn til að passa upp á -öryggi meðlimanna, auk áreiðanlegra verkfæra til að útiloka aðila og -meðhöndla kærur, sem hjálpar til við að koma í veg fyrir misnotkun. - -Fleiri eiginleikar: - -• Dökkur hamur: Lestu færslur í ljósum, dökkum eða sönnum kolsvörtum ham -• Kannanir: Spyrðu fylgjendur um skoðanir þeirra og teldu atkvæðin -• Uppgötva: Vinsæl myllumerki og notendaaðgangar eru við hendina -• Tilkynningar: Fáðu tilkynningar um nýja fylgjendur, svör og endurbirtingar -• Deiling: Birtu beint á Mastodon frá hvaða deilingarblaði sem er í hvaða -forriti sem er -• Krúttlegheit: Gæludýrið okkar er vinalegur loðfíll sem þú gætir rekist á -öðru hverju - -Mastodon er skráð óhagnaðardrifin sjálfseignarstofnun og er þróun þess -drifin áfram með styrkjum frá þér. Það eru engar auglýsingar, engin gjaldtaka og engir áhættufjárfestar - við -höfum hugsað okkur að halda því þannig. diff --git a/fastlane/metadata/android/is-IS/short_description.txt b/fastlane/metadata/android/is-IS/short_description.txt deleted file mode 100644 index 60c4e2e6b..000000000 --- a/fastlane/metadata/android/is-IS/short_description.txt +++ /dev/null @@ -1 +0,0 @@ -Dreifstýrt samfélagsnet \ No newline at end of file diff --git a/fastlane/metadata/android/is-IS/title.txt b/fastlane/metadata/android/is-IS/title.txt deleted file mode 100644 index 8123241a0..000000000 --- a/fastlane/metadata/android/is-IS/title.txt +++ /dev/null @@ -1 +0,0 @@ -Mastodon \ No newline at end of file diff --git a/fastlane/metadata/android/it-IT/full_description.txt b/fastlane/metadata/android/it-IT/full_description.txt deleted file mode 100644 index 0e3968792..000000000 --- a/fastlane/metadata/android/it-IT/full_description.txt +++ /dev/null @@ -1,16 +0,0 @@ -Mastodon è il più grande social network decentralizzato su Internet. Invece di un singolo sito web, è una rete di milioni di utenti in comunità indipendenti che possono interagire tra loro, senza soluzione di continuità. Non importa cosa ti piaccia, puoi incontrare persone appassionate pubblicando a riguardo su Mastodon! - -Unisciti a una comunità e crea il tuo profilo. Trova e segui persone affascinanti e leggi i loro post in una timeline cronologica senza pubblicità. Esprimiti con emoji personalizzate, immagini, GIF, filmati e audio in 500 caratteri. Rispondi a thread e post condivisi da chiunque per condividere grandi cose. Trova nuovi account da seguire e hashtags in tendenza per espandere la tua rete. - -Mastodon è costruito con attenzione sulla privacy e sulla sicurezza. Decidi se i tuoi post saranno visibili solo ai tuoi seguaci, alle persone che menzioni o al mondo intero. Gli avvertimenti sul contenuto ti permettono di nascondere i post contenenti materiale sensibile o provocatorio fino a quando non sarai pronto a interagire con loro. Ogni comunità ha le proprie linee guida e moderatori per mantenere i propri membri sicuri. Robusti strumenti di blocco e segnalazione aiutano a prevenire gli abusi. - -Altre caratteristiche: - -• Modalità Scura: Leggi i post in modo chiaro, scuro o in nero -• Sondaggi: Chiedi ai seguaci il loro parere e conteggia i voti -• Esplora: hashtags di tendenza e account sono a portata di tocco -• Notifiche: Ricevi una notifica su nuovi seguaci, risposte e condivisioni -• Condivisione: Pubblica direttamente su Mastodon da qualsiasi foglio di condivisione in qualsiasi app -• Bellezza: La nostra mascotte è un elefante adorabile, e lo vedrete apparire di tanto in tanto - -Mastodon è una no-profit registrata e lo sviluppo è supportato direttamente dalle vostre donazioni. Non c'è pubblicità, monetizzazione e capitale di rischio, e abbiamo intenzione di mantenerlo in quel modo. diff --git a/fastlane/metadata/android/it-IT/short_description.txt b/fastlane/metadata/android/it-IT/short_description.txt deleted file mode 100644 index bab3bf728..000000000 --- a/fastlane/metadata/android/it-IT/short_description.txt +++ /dev/null @@ -1 +0,0 @@ -Social network decentralizzato \ No newline at end of file diff --git a/fastlane/metadata/android/it-IT/title.txt b/fastlane/metadata/android/it-IT/title.txt deleted file mode 100644 index 8123241a0..000000000 --- a/fastlane/metadata/android/it-IT/title.txt +++ /dev/null @@ -1 +0,0 @@ -Mastodon \ No newline at end of file diff --git a/fastlane/metadata/android/ja-JP/full_description.txt b/fastlane/metadata/android/ja-JP/full_description.txt deleted file mode 100644 index 30d284098..000000000 --- a/fastlane/metadata/android/ja-JP/full_description.txt +++ /dev/null @@ -1,16 +0,0 @@ -Mastodon は、インターネット上で最大の分散型ソーシャルネットワークです。 Mastodon は単一のウェブサイトではなく、それぞれ独立したコミュニティに参加している何百万人ものユーザーによって構成されたネットワークなのです。ユーザーたちはその中で、誰もがお互いとシームレスにやり取りできます。 あなたの興味関心がどんな分野にあっても、きっと Mastodon のどこかで同じ情熱を投稿している仲間がいることでしょう。 - -まずはコミュニティに参加して、自分のプロフィールを作成しましょう。 魅力的なユーザーを見つけたら、フォローしてタイムラインで投稿を見てみましょう。タイムラインに広告は出てきません。順番も時系列順です。 そして、500 文字まで使える投稿で自分を表現してみましょう。カスタム絵文字や画像、GIF、動画、音声も使用できます。 スレッドに返事したり、他の誰かの面白い投稿をブーストして共有したりすることもできます。 興味のあるアカウントとホットなハッシュタグをどんどん見つけて、あなた自身のネットワークを広げていきましょう。 - -Mastondon はプライバシーと安全性を重視しています。 自分の投稿をフォロワーだけに限定公開にするのか、メンションした特定のユーザーだけに共有するのか、全世界に放流するのか、自由に決められます。 また、入力中の投稿について「ちょっとセンシティブな内容だな」と思ったら、閲覧注意機能で内容を伏せることで、見たくない人に配慮した投稿が作成できます。 そして、各コミュニティにはそれぞれのガイドラインと管理者・モデレーターが存在し、コミュニティメンバーの安全を守っています。強力なブロック・通報機能も、不正利用の防止をお手伝いします。 - -その他の機能: - -• ダークモード: ライトモードだけでなく、ダークモードや「真っ黒」モードで投稿を閲覧 -• アンケート: フォロワーたちの意見を投票形式で集計 -• 探索: 話題のハッシュタグやアカウントに 1 タップでアクセス -• 通知: 新しいフォローやリプライ、ブーストがあった時に通知 -• 共有: どのアプリからでも「共有」メニューを通じて Mastodon へ直接投稿 -• 癒し: Mastodon が誇る象のマスコット(かわいい)が、画面にお邪魔したり、しなかったり - -Mastodon は公認の非営利アプリです。開発は全てユーザーの寄付から成り立っています。 広告なし、アフィリエイトなし、第三者組織による出資なし。今でも、そしてこれからもそんなアプリであり続けるために、我々は日々努力し続けています。 diff --git a/fastlane/metadata/android/ja-JP/short_description.txt b/fastlane/metadata/android/ja-JP/short_description.txt deleted file mode 100644 index 5462788bd..000000000 --- a/fastlane/metadata/android/ja-JP/short_description.txt +++ /dev/null @@ -1 +0,0 @@ -分散型ソーシャルネットワーク \ No newline at end of file diff --git a/fastlane/metadata/android/ja-JP/title.txt b/fastlane/metadata/android/ja-JP/title.txt deleted file mode 100644 index 8123241a0..000000000 --- a/fastlane/metadata/android/ja-JP/title.txt +++ /dev/null @@ -1 +0,0 @@ -Mastodon \ No newline at end of file diff --git a/fastlane/metadata/android/ka-GE/full_description.txt b/fastlane/metadata/android/ka-GE/full_description.txt deleted file mode 100644 index 11d74abb1..000000000 --- a/fastlane/metadata/android/ka-GE/full_description.txt +++ /dev/null @@ -1,16 +0,0 @@ -Mastodon is the largest decentralized social network on the internet. Instead of a single website, it’s a network of millions of users in independent communities that can all interact with one another, seamlessly. No matter what you’re into, you can meet passionate people posting about it on Mastodon! - -Join a community and create your profile. Find and follow fascinating folks and read their posts in an ad-free, chronological timeline. Express yourself with custom emoji, images, GIFs, videos, and audio in 500-character posts. Reply to threads and reblog posts from anyone to share great stuff. Find new accounts to follow and trending hashtags to expand your network. - -Mastodon is built with a focus on privacy and safety. Decide whether your posts are shared with your followers, just the people you mention, or the whole world. Content warnings let you hide posts containing sensitive or triggering material until you're ready to engage with them. Each community has its own guidelines and moderators to keep its members safe, and robust blocking and reporting tools help prevent abuse. - -More features: - -• Dark Mode: Read posts in light, dark, or true black mode -• Polls: Ask followers for their opinion and tally the votes -• Explore: Trending hashtags and accounts are a tap away -• Notifications: Get notified about new follows, replies, and reblogs -• Sharing: Post directly to Mastodon from any share sheet in any app -• Cuteness: Our mascot is an adorable elephant, and you'll see them pop up from time to time - -Mastodon is a registered nonprofit and development is supported directly by your donations. There’s no advertising, no monetization, and no venture capital, and we plan to keep it that way. diff --git a/fastlane/metadata/android/ka-GE/short_description.txt b/fastlane/metadata/android/ka-GE/short_description.txt deleted file mode 100644 index 8f5a9b847..000000000 --- a/fastlane/metadata/android/ka-GE/short_description.txt +++ /dev/null @@ -1 +0,0 @@ -Decentralized social network \ No newline at end of file diff --git a/fastlane/metadata/android/ka-GE/title.txt b/fastlane/metadata/android/ka-GE/title.txt deleted file mode 100644 index 8123241a0..000000000 --- a/fastlane/metadata/android/ka-GE/title.txt +++ /dev/null @@ -1 +0,0 @@ -Mastodon \ No newline at end of file diff --git a/fastlane/metadata/android/kab-KAB/full_description.txt b/fastlane/metadata/android/kab-KAB/full_description.txt deleted file mode 100644 index b18ce17f5..000000000 --- a/fastlane/metadata/android/kab-KAB/full_description.txt +++ /dev/null @@ -1,16 +0,0 @@ -Mastodon d azeṭṭa anmetti asrummsan meqqren deg internet. Ideg ara yili d asmel web asuf, d azeṭṭa n yimelyan n yiseqdacen deg temɣiwin tilelliyin i izemren ad myigwent gar-asent, s wudem afrawan. Akken ibɣu yili usentel i tḥemmleḍ, tzemreḍ ad temlileḍ imdanen i d-isuffuɣen ɣef usentel-nni ɣef Mastodon! - -Rnu ɣer temɣiwent syen snulfu-d amaɣnu-inek. Find and follow fascinating folks and read their posts in an ad-free, chronological timeline. Mmel iḥulfan-ik s yimujiten, tugniwin, GIFs, tividyutin d yimeslawen udmawanen deg tsuffaɣ n 500 yisekkilen. Ttekki deg usqerdec, talseḍ asuffeɣ n tsuffaɣ n yimdanen i beṭṭu n taktiwin igerrzen. Af imiḍanen ara tḍefreḍ akked hashtags mucaεen i wakken ad tesnerniḍ azeṭṭa-inek. - -Mastodon yettwabna s tikci n wazal i tbaḍnit d tɣellist. Gzem-itt deg ṛṛay ma yella tisuffaɣ-inek·inem ad ttwabḍunt akked yineḍfaren-ik·im, akked yimdanen kan i d-tbedreḍ neɣ akked yimdanen meṛṛa. Ilɣa n ugbur ad ak·akem-yeǧǧ d teffreḍ tisuffaɣ ideg yella ugbur amḥalfu neɣ yir agbur alamma d asmi ara twejdeḍ ad tkecmeḍ ɣer-sen. Yal tamɣiwent ɣur-s ilugan-ines d yiseɣyaden-is i wakken ad teḍmentaɣellist n yiεeggalen-is, akked yifecka iǧehden i usewḥel d tummla n yineqqisen mgal yir aseqdec. - -Ugar n temahilin: - -• Askar aberkan: Γeṛ tisuffaɣ deg uskar aceεlal, aberkan neɣ aberkan aḥeqqani -• Isenqaden: Ssuter ṛṛay n yineḍfaren syen smiḍen afran -• Snirem: Hashtags d yimiḍanen mucaεen llan ɣef wafus -• Ilɣa: Ṭṭef ilɣa ɣef yineḍfaren, tiririyin d wallus n usuffeɣ imaynuten -• Beṭṭu: Azen srid ɣer Mastodon seg kra n tferkit n beṭṭu deg kra n usnas -• Ucbiḥ: Lfal-nneɣ d ilu icebḥen aṭas, ad t-tetttwaliḍ yettban-d sya ɣer da - -Mastodon d takebbanit ur nettnadi ara ɣef tedrimt, asnerni-ines yettili-d s tewsa-nni i as-tettmuddum. Ulac adellel, ur njemmeε tadrimt, ur nesεi win aɣ-d-yettakken tadrimt. Akka i nettxemmim ad nkemmel abrid-nneɣ. diff --git a/fastlane/metadata/android/kab-KAB/short_description.txt b/fastlane/metadata/android/kab-KAB/short_description.txt deleted file mode 100644 index 971228b78..000000000 --- a/fastlane/metadata/android/kab-KAB/short_description.txt +++ /dev/null @@ -1 +0,0 @@ -Azeṭṭa anmetti asrummsan \ No newline at end of file diff --git a/fastlane/metadata/android/kab-KAB/title.txt b/fastlane/metadata/android/kab-KAB/title.txt deleted file mode 100644 index 8123241a0..000000000 --- a/fastlane/metadata/android/kab-KAB/title.txt +++ /dev/null @@ -1 +0,0 @@ -Mastodon \ No newline at end of file diff --git a/fastlane/metadata/android/ko-KR/full_description.txt b/fastlane/metadata/android/ko-KR/full_description.txt deleted file mode 100644 index 651586450..000000000 --- a/fastlane/metadata/android/ko-KR/full_description.txt +++ /dev/null @@ -1,16 +0,0 @@ -마스토돈은 인터넷에서 가장 큰 분산 소셜 네트워크입니다. 단 하나의 통일된 웹사이트 대신, 수백만 명의 사용자들이, 서로 경계 없이 소통할 수 있는 독립적인 커뮤니티의 네트워크입니다. 당신이 어디에 속하든간에, 마스토돈에 열정적으로 게시물을 남기는 사람들을 만날 수 있습니다! - -커뮤니티에 가입하고 프로필을 생성하세요. 매력적인 사람들을 찾아 팔로우하고 그들의 게시물을 최신 순으로 정렬된 타임라인에서 광고 없이 확인하세요. 커스텀 에모지, 그림, 움짤, 동영상, 소리와 함께 500자의 글로 당신을 표현하세요. 아무에게나 글타래에 답장을 하고 게시물을 리블로그하여 멋진 것들을 공유하세요. 새로 팔로우 할 계정이나 유행 중인 해시태그를 찾아 당신의 인맥을 넓히세요. - -마스토돈은 개인정보 보호와 안전에 중점을 두고 만들어졌습니다. 당신의 게시물을 팔로워들에게만 공개할지, 언급한 사람들에게만 공유할지, 아니면 전세계에 공유할 지 선택하세요. 열람주의는 민감하거나 남들에게 껄끄러울 수 있는 게시물을 마음의 준비가 된 사람들만 열람하도록 숨길 수 있도록 해줍니다. 각각의 커뮤니티는 구성원들을 안전하게 지키기 위한 각자의 규정과 중재자들을 가지고 있으며, 남용을 방지하기 위한 강력한 차단 도구와 신고 도구를 가지고 있습니다. - -더 많은 기능: - -• 다크모드: 게시물을 밝음, 어두움, 진정한 검정 모드에서 읽으세요 -• 투표: 팔로워들에게 의견을 물어보고 투표를 집계하세요 -• 탐색: 유행 중인 해시태그와 계정을 탭 한 번에 볼 수 있습니다 -• 알림: 새로운 팔로우, 답글, 리블로그에 대한 알림을 받으세요 -• 공유: 어떤 앱에서든 공유 기능으로 바로 마스토돈에 게시하세요 -• 귀여움: 우리의 마스코트는 귀여운 코끼리입니다, 때때로 이들을 볼 수 있습니다 - -마스토돈은 등록된 상호이며 여러분들의 직접적인 기부를 통해 개발되고 있습니다. 광고도, 유료화도, 벤처 캐피탈도 없습니다, 그리고 계속 그렇게 할 생각입니다. diff --git a/fastlane/metadata/android/ko-KR/short_description.txt b/fastlane/metadata/android/ko-KR/short_description.txt deleted file mode 100644 index ec9ce14fc..000000000 --- a/fastlane/metadata/android/ko-KR/short_description.txt +++ /dev/null @@ -1 +0,0 @@ -분산화된 소셜 네트워크 \ No newline at end of file diff --git a/fastlane/metadata/android/ko-KR/title.txt b/fastlane/metadata/android/ko-KR/title.txt deleted file mode 100644 index b7f77331a..000000000 --- a/fastlane/metadata/android/ko-KR/title.txt +++ /dev/null @@ -1 +0,0 @@ -마스토돈 \ No newline at end of file diff --git a/fastlane/metadata/android/lt-LT/full_description.txt b/fastlane/metadata/android/lt-LT/full_description.txt deleted file mode 100644 index 5e04fff05..000000000 --- a/fastlane/metadata/android/lt-LT/full_description.txt +++ /dev/null @@ -1,16 +0,0 @@ -Mastodon – didžiausias decentralizuotas socialinis tinklas internete. Vietoj vienos svetainės tai yra milijonų naudotojų, priklausančių nepriklausomoms bendruomenėms, kurios gali sklandžiai bendrauti tarpusavyje, tinklas. Nesvarbu, kuo domiesi, Mastodon gali sutikti aistringų žmonių, skelbiančių apie tai! - -Prisijunk prie bendruomenės ir susikurk savo profilį. Rask ir sek žavius žmones bei skaityk jų įrašus chronologinėje laiko skalėje be reklamų. Išreikšk save su pasirinktais jaustukais, vaizdais, GIF, vaizdo ir garso įrašais 500 simbolių įrašuose. Atsakyk į gijas ir perrašyk bet kurio asmens įrašus, kad galėtum dalytis puikiais dalykais. Ieškok naujų paskyrų sekti ir tendencingų saitažodžių, kad praplėstum savo tinklą. - -Mastodon sukurtas daugiausia dėmesio skiriant privatumui ir saugumui. Nuspręsk, ar tavo įrašai bus bendrinami tavo sekėjams, tik tavo paminėtiems žmonėms, ar visam pasauliui. Turinio įspėjimai leidžia paslėpti įrašus, kuriuose yra jautrios ar dirginančios medžiagos, kol būsi pasiruošęs (-usi) su jais bendrauti. Kiekviena bendruomenė turi savo gaires ir prižiūrėtojus, kad jos nariai būtų saugūs, o patikimi blokavimo ir pranešimo įrankiai padeda užkirsti kelią piktnaudžiavimui. - -Daugiau funkcijų: - -• Tamsusis režimas: skaityk įrašus šviesiu, tamsiu arba tikru juodu režimu -• Apklausos: paklausk sekėjų nuomonės ir suskaičiuok balsus -• Naršyti: tendencingos saitažodžiai ir paskyros – vos nuo vieno prisilietimo -• Pranešimai: gauk pranešimus apie naujus sekėjus, atsakymus ir tinklaraščių perrašymus -• Bendrinimas: skelbk tiesiogiai į Mastodon iš bet kurio bendrinimo lapo bet kurioje programėlėje -• Mielumas: mūsų talismanas yra žavus drambliukas, kurį retkarčiais pamatysi - -Mastodon yra registruota ne pelno siekianti organizacija, kurios plėtra yra tiesiogiai paremta aukomis. Nėra jokios reklamos, jokių monetizacijos ir rizikos kapitalo, ir mes planuojame, kad taip ir liks. diff --git a/fastlane/metadata/android/lt-LT/short_description.txt b/fastlane/metadata/android/lt-LT/short_description.txt deleted file mode 100644 index ed68c4fbd..000000000 --- a/fastlane/metadata/android/lt-LT/short_description.txt +++ /dev/null @@ -1 +0,0 @@ -Decentralizuotas socialinis tinklas \ No newline at end of file diff --git a/fastlane/metadata/android/lt-LT/title.txt b/fastlane/metadata/android/lt-LT/title.txt deleted file mode 100644 index 8123241a0..000000000 --- a/fastlane/metadata/android/lt-LT/title.txt +++ /dev/null @@ -1 +0,0 @@ -Mastodon \ No newline at end of file diff --git a/fastlane/metadata/android/my-MM/full_description.txt b/fastlane/metadata/android/my-MM/full_description.txt deleted file mode 100644 index f5242216f..000000000 --- a/fastlane/metadata/android/my-MM/full_description.txt +++ /dev/null @@ -1,16 +0,0 @@ -Mastodon (မက်စ်စတိုဒွန်) ဟာ အင်တာနက်ပေါ်မှာ ဗဟိုထိန်းချုပ်မှုကင်းမဲ့တဲ့ အကြီးမားဆုံးသော လူမှုကွန်ရက်တစ်ခုဖြစ်ပါတယ်။ ဝက်ဘ်ဆိုဒ်တစ်ခုတည်းဖြစ်မယ့်အစား Mastodon ဟာ လွတ်လပ်တဲ့လူမှုအသိုက်အဝန်း (community) တေွက သုံးစွဲသူသန်းချီကို တစ်ယောက်နဲ့တစ်ယောက် လွယ်လွယ်ကူကူ၊ ချောချောမွေ့မွေ့နဲ့ အပြန်အလှန် ဆက်သွယ်ချိတ်ဆက်ပေးတဲ့ ကွန်ရက်တစ်ခုဖြစ်ပါတယ်။ သင်ဘာကိုပဲ စိတ်ဝင်စားနေပါစေ Mastodon ပေါ်မှာ သင်စိတ်ဝင်စားတာနဲ့ပတ်သက်ပြီး စိတ်အားထက်သန်တဲ့လူတွေနဲ့ တေွ့ဆုံနိုင်မှာဖြစ်ပါတယ်။ - -လူမှုအသိုက်အဝန်းတစ်ခုကိုချိတ်ဆက်ပြီး ပရိုဖိုင်းတစ်ခုဖန်တီးပါ။ ပြီးနောက် စွဲမက်ဖွယ်ကောင်းသူတေွကို ရှာဖွေဖော်လိုးလုပ်ပြီး သူတို့ရဲ့ပို့စ်တွေကို ကြော်ငြာကင်းမဲ့ပြီး အချိန်နှင့်တပြေးညီရှိတဲ့ တိုင်းမ်လိုင်းမှာ ဖတ်ရှုလိုက်ပါ။ သင့်ကိုယ်သင် စိတ်ကြိုက်အီမိုဂျီတွေ၊ ရုပ်ပုံတွေ၊ ဂစ်ဖ်တွေ၊ ဗီဒီယိုတွေ၊ အသံဖိုင်တွေသုံးပြီး အလုံးရေ ၅၀၀ ဆန့်တဲ့ ပို့စ်တွေနဲ့ ဖော်ပြနိုင်သလို စာတွဲ (thread) တွေကို ရီပလိုင်းပြန်လိုက်၊ တခြားသူတွေရဲ့ပို့စ်မိုက်မိုက်လေးတွေကို ရီဘလော့လုပ်လိုက်လည်း လုပ်နိုင်ပါသေးတယ်။ သင့်ကွန်ရက်ကိုဖြန့်ကျက်ဖို့ရာအတွက် အကောင့်အသစ်တွေနဲ့ ရေပန်းစားနေတဲ့ ဟက်ရှ်တက်တွေကို ရှာဖွေဖော်လိုးလုပ်လိုက်ပါ။ - -Mastodon ဟာ ကိုယ်ရေးအချက်အလက်လုံခြုံမှုနဲ့ ဘေးကင်းစိတ်ချရမှုတို့ကို အာရုံစိုက်ပြီး တည်ဆောက်ထားတာဖြစ်ပါတယ်။ သင့်ပို့စ်တွေကို ဖော်လိုဝါတွေဆီကိုပဲ မျှဝေမလား၊ ဒါမှမဟုတ် မန်းရှင်းလုပ်ထားတဲ့သူတွေကိုပဲ မျှဝေမလား၊ ဒါမှမဟုတ် တစ်ကမ္ဘာလုံးကိုမျှဝေမလားဆိုတာ ဆုံးဖြတ်နိုင်ပါတယ်။ အကြောင်းအရာသတိပေးချက်တွေက ထိလွယ်ရှလွယ်အကြောင်းအရာတွေနဲ့ လှုံ့ဆော်မှုဖြစ်စေတဲ့အကြောင်းအရာတွေ ပါဝင်တဲ့ပို့စ်တွေကို သင်ထိတွေ့ဆက်ဆံဖို့ အဆင်သင့်ဖြစ်သည်အထိ ဖျောက်ထားပေးမှာပါ။ အဖွဲ့ဝင်တွေကို လုံလုံခြုံခြုံဖြစ်စေဖို့အတွက် လူမှုအသိုက်အဝန်းတစ်ခုစီမှာ သူတို့ကိုယ်ပိုင်လမ်းညွှန်ချက်တွေနဲ့ စီမံကွပ်ကဲသူတွေရှိကြပြီး လူမှုကွန်ရက်ပေါ် အလွဲသုံးစားလုပ်မှုကို ကာကွယ်ဖို့အတွက် ခိုင်မာအားကောင်းတဲ့ ပိတ်ပင်မှုစနစ်တွေနဲ့ သတင်းပို့ကိရိယာတွေလည်း ရှိပါတယ်။ - -နောက်ထပ် feature များ - - -• အမှောင်မုဒ် - ပို့စ်တွေကို အလင်း၊ အမှောင်နဲ့ အနက်စစ်စစ်မုဒ်တွေမှာ ဖတ်ရှုလိုက်ပါ -• စစ်တမ်းများ - ဖော်လိုဝါတွေရဲ့ အမြင်တွေကိုမေးမြန်းပြီး စစ်တမ်းကောက်ပါ -• စူးစမ်းရှာဖွေခြင်း - ရေပန်းစားနေတဲ့ ဟက်ရှ်တက်တွေနဲ့ အကောင့်တွေဟာ တစ်ချက်နှိပ်ရုံပဲရှိပါတယ် -• နိုတီများ - ဖောလိုးအသစ်တွေ၊ ရီပလိုင်းအသစ်တွေနဲ့ ရီဘလော့အသစ်တွေကို သိရှိလိုက်ပါ -• မျှဝေခြင်း - မည်သည့်အက်ပ်မျှဝေလွှာကပဲဖြစ်ဖြစ် Mastodon ဆီကို တိုက်ရိုက်ပို့စ်တင်လိုက်ပါ -• ချစ်စရာကောင်းမှု - ကျွန်တော်တို့ရဲ့လာဘ်ကောင်လေးက ချစ်ဖို့ကောင်းတဲ့ဆင်လေးဖြစ်ပြီး တစ်ခါတစ်ခါပေါ်ပေါ်လာတာကို တွေ့ရမှာပါ - -Mastodon ဟာ မှတ်ပုံတင်ပြီးသား အကျိုးအမြတ်မယူတဲ့ လူမှုကွန်ရက်တစ်ခုဖြစ်ပြီး Mastodon ရဲ့ ဖွံ့ဖြိုးတိုးတက်မှုကိုလည်း သင့်ရဲ့လှူဒါန်းမှုတွေက တိုက်ရိုက်ထောက်ပံ့ပေးထားပါတယ်။ Mastodon မှာ ကြော်ငြာ၊ ပိုက်ဆံရှာဖွေတာနဲ့ အကျိုးတူရင်းနှီးငွေတွေမရှိသလို ဒီအတိုင်းပဲဆက်သွားဖို့လည်း စီစဉ်ထားပါတယ်။ diff --git a/fastlane/metadata/android/my-MM/short_description.txt b/fastlane/metadata/android/my-MM/short_description.txt deleted file mode 100644 index 09db8d3f7..000000000 --- a/fastlane/metadata/android/my-MM/short_description.txt +++ /dev/null @@ -1 +0,0 @@ -ဗဟိုထိန်းချုပ်မှုကင်းမဲ့သော လူမှုကွန်ရက် \ No newline at end of file diff --git a/fastlane/metadata/android/my-MM/title.txt b/fastlane/metadata/android/my-MM/title.txt deleted file mode 100644 index 8123241a0..000000000 --- a/fastlane/metadata/android/my-MM/title.txt +++ /dev/null @@ -1 +0,0 @@ -Mastodon \ No newline at end of file diff --git a/fastlane/metadata/android/nl-NL/full_description.txt b/fastlane/metadata/android/nl-NL/full_description.txt deleted file mode 100644 index 45df10f28..000000000 --- a/fastlane/metadata/android/nl-NL/full_description.txt +++ /dev/null @@ -1,16 +0,0 @@ -Mastodon is het grootste gedecentraliseerde sociale netwerk op het internet. In plaats van één enkele website is het een netwerk van miljoenen gebruikers in onafhankelijke communities die allemaal naadloos met elkaar kunnen communiceren. Wat je ook leuk vindt, je kunt gepassioneerde mensen volgen die het met je willen delen op Mastodon! - -Meld je aan bij een community en maak je profiel aan. Vind en volg fascinerende mensen en lees hun berichten in een advertentievrije, chronologische tijdlijn. Druk jezelf uit met aangepaste emoji, afbeeldingen, GIF’s, video’s en audio in berichten van 500 karakters. Reageer op discussies en boost berichten van anderen om geweldige dingen te delen. Vind nieuwe accounts om te volgen en hashtags om je netwerk uit te breiden. - -Mastodon is gebouwd met een focus op privacy en veiligheid. Bepaal zelf of je berichten met je volgers, alleen de mensen die je noemt, of de hele wereld worden gedeeld. Inhoudswaarschuwingen laten je berichten verbergen die gevoelig of aanmatigend materiaal bevatten, totdat je er klaar voor bent om ze te bekijken. Iedere community heeft zijn eigen richtlijnen en beheerders om de leden een veilige omgeving te bieden. Met goede blokkeertools en mogelijkheden om berichten te rapporteren voorkomen we misbruik. - -Meer mogelijkheden: - -• Donkere modus: berichten lezen in lichte modus, donker modus of echt zwarte modus -• Polls: vraag je volgers om hun mening en tel de stemmen -• Ontdekken: trending hashtags en accounts onder handbereik -• Meldingen: ontvang een melding bij nieuwe volgers, reacties en boosts -• Delen: deel direct op Mastodon vanuit elke app -• Superschattig: onze mascotte is een schattige olifant die af en toe in beeld komt - -Mastodon is een geregistreerde non-profit en de ontwikkeling wordt direct ondersteund door jouw donaties. Er zijn geen advertenties, geen pogingen om geld te verdienen, geen investeerders en we willen dat zo houden. diff --git a/fastlane/metadata/android/nl-NL/short_description.txt b/fastlane/metadata/android/nl-NL/short_description.txt deleted file mode 100644 index 4bd25c64c..000000000 --- a/fastlane/metadata/android/nl-NL/short_description.txt +++ /dev/null @@ -1 +0,0 @@ -Decentraal sociaal netwerk \ No newline at end of file diff --git a/fastlane/metadata/android/nl-NL/title.txt b/fastlane/metadata/android/nl-NL/title.txt deleted file mode 100644 index 8123241a0..000000000 --- a/fastlane/metadata/android/nl-NL/title.txt +++ /dev/null @@ -1 +0,0 @@ -Mastodon \ No newline at end of file diff --git a/fastlane/metadata/android/no-NO/full_description.txt b/fastlane/metadata/android/no-NO/full_description.txt deleted file mode 100644 index 5b4493205..000000000 --- a/fastlane/metadata/android/no-NO/full_description.txt +++ /dev/null @@ -1,16 +0,0 @@ -Mastodon er det største desentraliserte sosiale nettverket på internett. I stedet for en nettside, er det et nettverk av millioner av brukere i uavhengige samfunn som alle kan samhandle med hverandre, sømløst. Uansett hva du er, kan du møte lidenskapelige folk som legger ut om det på Mastodon! - -Bli med i et samfunn og opprett profilen din. Finn og følg fascinerende mennesker og les deres innlegg i en reklamefri, kronologisk tidslinje. Uttrykk deg selv med egendefinerte emoji, bilder, GIFs, videoer og lyd i innlegg på 500 tegn. Svar på tråder og fremhev fra alle til alle å dele gode ting. Finn nye kontoer å følge og trendende emneknagger for å utvide nettverket ditt. - -Mastodon er bygget med fokus på personvern og sikkerhet. Bestem om innleggene dine er delt med dine tilhengere, bare personene du nevner, eller hele verden. Innholdsadvarsler lar deg skjule innlegg som inneholder følsomt eller utløsende materiale, inntil du er klar til å engasjere deg med dem. Hvert samfunn har egne retningslinjer og moderatorer for å holde medlemmene trygge, og å blokkere å rapporterings verktøy for å forhindre misbruk. - -Flere funksjoner: - -• Mørk modus: Lese innlegg i lys, mørkt eller svart modus -• Avstemminger: Be følgende om deres mening og alliert stemmene -• Utforske: Trendende emneknagger og kontoer er et trykk unna -• Varslinger: Få beskjed om nye følgere, svar og fremhevinger -• Deling: Post direkte til Mastodon fra et hvilket som helst delingsark i en app -• Nytten: Vår maskot er en eventyrlig elefant, og du vil se den poppe fra tid til annen - -Mastodon er en registrert nonprofit, og utvikling støttes direkte av dine donasjoner. Det er ikke reklame, ingen investorer, og vi har tenkt å holde det på denne måten. diff --git a/fastlane/metadata/android/no-NO/short_description.txt b/fastlane/metadata/android/no-NO/short_description.txt deleted file mode 100644 index 41bafb080..000000000 --- a/fastlane/metadata/android/no-NO/short_description.txt +++ /dev/null @@ -1 +0,0 @@ -Desentralisert sosialt nettverk \ No newline at end of file diff --git a/fastlane/metadata/android/no-NO/title.txt b/fastlane/metadata/android/no-NO/title.txt deleted file mode 100644 index 8123241a0..000000000 --- a/fastlane/metadata/android/no-NO/title.txt +++ /dev/null @@ -1 +0,0 @@ -Mastodon \ No newline at end of file diff --git a/fastlane/metadata/android/oc-FR/full_description.txt b/fastlane/metadata/android/oc-FR/full_description.txt deleted file mode 100644 index 11d74abb1..000000000 --- a/fastlane/metadata/android/oc-FR/full_description.txt +++ /dev/null @@ -1,16 +0,0 @@ -Mastodon is the largest decentralized social network on the internet. Instead of a single website, it’s a network of millions of users in independent communities that can all interact with one another, seamlessly. No matter what you’re into, you can meet passionate people posting about it on Mastodon! - -Join a community and create your profile. Find and follow fascinating folks and read their posts in an ad-free, chronological timeline. Express yourself with custom emoji, images, GIFs, videos, and audio in 500-character posts. Reply to threads and reblog posts from anyone to share great stuff. Find new accounts to follow and trending hashtags to expand your network. - -Mastodon is built with a focus on privacy and safety. Decide whether your posts are shared with your followers, just the people you mention, or the whole world. Content warnings let you hide posts containing sensitive or triggering material until you're ready to engage with them. Each community has its own guidelines and moderators to keep its members safe, and robust blocking and reporting tools help prevent abuse. - -More features: - -• Dark Mode: Read posts in light, dark, or true black mode -• Polls: Ask followers for their opinion and tally the votes -• Explore: Trending hashtags and accounts are a tap away -• Notifications: Get notified about new follows, replies, and reblogs -• Sharing: Post directly to Mastodon from any share sheet in any app -• Cuteness: Our mascot is an adorable elephant, and you'll see them pop up from time to time - -Mastodon is a registered nonprofit and development is supported directly by your donations. There’s no advertising, no monetization, and no venture capital, and we plan to keep it that way. diff --git a/fastlane/metadata/android/oc-FR/short_description.txt b/fastlane/metadata/android/oc-FR/short_description.txt deleted file mode 100644 index 8f5a9b847..000000000 --- a/fastlane/metadata/android/oc-FR/short_description.txt +++ /dev/null @@ -1 +0,0 @@ -Decentralized social network \ No newline at end of file diff --git a/fastlane/metadata/android/oc-FR/title.txt b/fastlane/metadata/android/oc-FR/title.txt deleted file mode 100644 index 8123241a0..000000000 --- a/fastlane/metadata/android/oc-FR/title.txt +++ /dev/null @@ -1 +0,0 @@ -Mastodon \ No newline at end of file diff --git a/fastlane/metadata/android/pl-PL/full_description.txt b/fastlane/metadata/android/pl-PL/full_description.txt deleted file mode 100644 index 19328bce7..000000000 --- a/fastlane/metadata/android/pl-PL/full_description.txt +++ /dev/null @@ -1,16 +0,0 @@ -Mastodon to największa zdecentralizowana sieć społecznościowa w Internecie. Zamiast jednej strony internetowej, jest to sieć milionów użytkowników w niezależnych społecznościach, które mogą ze sobą wchodzić w interakcje. Niezależnie od swoich zainteresowań, momżesz poznać interesujących ludzi piszących o nich na Mastodonie! - -Dołącz do społeczności i utwórz swój profil. Znajdź i obserwuj fascynujących ludzi i przeczytaj ich posty w bezreklamowej, chronologicznej osi czasu. Wyrażaj siebie za pomocą niestandardowych emoji, obrazów, GIFów, filmów i audio w 500-znakowych wpisach. Odpowiadaj na wątki i podawaj dalej posty od każdego, aby dzielić się wspaniałymi rzeczami. Odnajduj nowe konta do obserwowania i zyskujące popularność hashtagi, by poszerzać swoją sieć. - -Mastodon został zaprojektowany z myślą o prywatności i bezpieczeństwie. Decyduj, czy Twoje wpisy są udostępniane osobom obserwującym Cię, tylko wzmiankowanym, czy całemu światu. Ostrzeżenia dotyczące zawartości pozwalają ukrywać posty zawierające wrażliwe lub wyzywające materiały, dopóki nie będziesz gotów się z nimi pogodzić. Każda społeczność ma własne wytyczne i moderatorów, aby zapewniać swoim członkom bezpieczeństwo, a także solidne narzędzia blokowania i raportowania pomagające zapobiegać nadużyciom. - -Więcej funkcji: - -• Tryb ciemny: Czytaj wpisy w jasnym, ciemnym lub czarnym trybie -• Ankiety: Poproś obserwujących o ich opinię i poznaj ich głosy -• Odkrywaj: Najpopularniejsze hashtagi i konta są dostępne za jednym dotknięciem -• Powiadomienia: Otrzymuj powiadomienia o nowych obserwacjach, odpowiedziach i udostępnieniach -• Udostępnianie: Publikuj bezpośrednio na Mastodonie z menu udostępniania w dowolnej aplikacji -• Słodycz: Nasza maskotka to uroczy słoń i zobaczysz go pojawiającego się od czasu do czasu - -Mastodon to zarejestrowana organizacja non-profit, a rozwój jest wspierany bezpośrednio przez darowizny. Nie ma reklam, monetyzacji, ani kapitału inwestycyjnego i planujemy, by tak pozostało. diff --git a/fastlane/metadata/android/pl-PL/short_description.txt b/fastlane/metadata/android/pl-PL/short_description.txt deleted file mode 100644 index 4284443ab..000000000 --- a/fastlane/metadata/android/pl-PL/short_description.txt +++ /dev/null @@ -1 +0,0 @@ -Zdecentralizowana sieć społecznościowa \ No newline at end of file diff --git a/fastlane/metadata/android/pl-PL/title.txt b/fastlane/metadata/android/pl-PL/title.txt deleted file mode 100644 index 8123241a0..000000000 --- a/fastlane/metadata/android/pl-PL/title.txt +++ /dev/null @@ -1 +0,0 @@ -Mastodon \ No newline at end of file diff --git a/fastlane/metadata/android/pt-BR/full_description.txt b/fastlane/metadata/android/pt-BR/full_description.txt deleted file mode 100644 index 399eb5e04..000000000 --- a/fastlane/metadata/android/pt-BR/full_description.txt +++ /dev/null @@ -1,16 +0,0 @@ -Mastodon é a maior rede social descentralizada na internet. Ao invés de ser um ‘website’, é uma rede de milhões de usuários em comunidades independentes que podem comunicar-se uma à outra, de forma transparente. Independente do que você gostar, você pode encontrar pessoas dedicadas a isso no Mastodon! - -Junte-se a uma comunidade e crie o seu perfil. Encontre e siga pessoas fascinantes e leia seus posts em uma linha cronológica sem anúncios. Se expresse com emojis personalizados, imagens, GIFs, vídeos e áudio em publicações de 500 caracteres. Responda a tópicos e reblogue publicações de qualquer um para compartilhar coisas ótimas. Encontre contas novas para seguir e hashtags em alta para expandir sua rede. - -O Mastodon foi construído com foco em privacidade e segurança. Decida se seus posts serão compartilhados com seus seguidores, apenas com as pessoas que você menciona, ou com o mundo inteiro. Avisos de conteúdo permitem que você oculte mensagens que contenham conteúdo sensível até você estar pronto para interagir com elas. Cada comunidade tem suas próprias diretrizes e moderadores para manter seus membros seguros, e ferramentas robustas de bloqueio e denúncias ajudam a prevenir abusos. - -Mais detalhes: - -• Modo escuro: Leia as publicações no modo claro, escuro ou preto verdadeiro -• Enquetes: Peça as opiniões de seus seguidores e registre os resultados -• Explorar: Hashtags e contas em destaque estão a um toque de distância -• Notificações: Seja notificado sobre novos seguidores, respostas e reblogs -• Compartilhar: Poste diretamente ao Mastodon pela página de compartilhamento de qualquer aplicativo -• Fofura: Nossa mascote é um elefante fofinho, e você vai vê-lo por aí ocasionalmente - -Mastodon é uma instituição sem fins lucrativos e o desenvolvimento é suportado diretamente pelas suas doações. Não há publicidade, não há monetização, nem capital de risco, e planejamos manter-se assim. diff --git a/fastlane/metadata/android/pt-BR/short_description.txt b/fastlane/metadata/android/pt-BR/short_description.txt deleted file mode 100644 index 33849fd5f..000000000 --- a/fastlane/metadata/android/pt-BR/short_description.txt +++ /dev/null @@ -1 +0,0 @@ -Rede social descentralizada \ No newline at end of file diff --git a/fastlane/metadata/android/pt-BR/title.txt b/fastlane/metadata/android/pt-BR/title.txt deleted file mode 100644 index 8123241a0..000000000 --- a/fastlane/metadata/android/pt-BR/title.txt +++ /dev/null @@ -1 +0,0 @@ -Mastodon \ No newline at end of file diff --git a/fastlane/metadata/android/pt-PT/full_description.txt b/fastlane/metadata/android/pt-PT/full_description.txt deleted file mode 100644 index 6cd147686..000000000 --- a/fastlane/metadata/android/pt-PT/full_description.txt +++ /dev/null @@ -1,16 +0,0 @@ -O Mastodon é a maior rede social descentralizada da Internet. Em vez de ser um único site, é uma rede de milhões de utilizadores em comunidades independentes que podem facilmente interagir uns com os outros. Independemente dos teus gostos, consegues encontrar pessoas que os partilhem no Mastodon! - -Junta-te a uma comunidade e cria o teu perfil. Encontra e segue gente fascinante, e lê as suas publicações numa cronologia sem anúncios. Expressa-te com emojis personalizados imagens, GIFs, vídeos e áudio em publicações com até 500 caracteres. Responde a tópicos e promove publicações de qualquer pessoa para partilhares ótimas coisas. Encontra novas contas e tendências a seguir para expandires a tua rede. - -O Mastodon é construído com foco na privacidade e segurança. Decide se as tuas publicações são partilhadas com os teus seguidores, apenas com as pessoas mencionadas, ou com o mundo inteiro. Avisos de conteúdo permitem-te esconder publicações que contenham material sensível ou provocatório até estares pronto(a) para o veres. Cada comunidade tem as suas regras e moderadores que mantêm os seus membros seguros, bem como ferramentas robustas de bloqueio e denúncias que ajudam a prevenir abusos. - -Mais funcionalidades: - -• Modo escuro: Lê as publicações no modo claro, escuro ou preto absoluto -• Sondagens: Pede aos teus seguidores a sua opinião e conta os votos -• Explorar: “hashtags” e contas em destaque estão a um toque de distância -• Notificações: Recebe notificações sobre novos seguidores, respostas e partilhas -• Partilhar: Publica diretamente no Mastodon a partir de qualquer aplicação -• Fofura: A nossa mascote é um elefante adorável, e vais vê-lo aparecer de vez em quando - -A Mastodon é uma instituição sem fins lucrativos e o desenvolvimento é apoiado diretamente pelas tuas doações. Não existe publicidade, monetização nem capital de risco, e pretendemos que se mantenha assim. diff --git a/fastlane/metadata/android/pt-PT/short_description.txt b/fastlane/metadata/android/pt-PT/short_description.txt deleted file mode 100644 index 33849fd5f..000000000 --- a/fastlane/metadata/android/pt-PT/short_description.txt +++ /dev/null @@ -1 +0,0 @@ -Rede social descentralizada \ No newline at end of file diff --git a/fastlane/metadata/android/pt-PT/title.txt b/fastlane/metadata/android/pt-PT/title.txt deleted file mode 100644 index 8123241a0..000000000 --- a/fastlane/metadata/android/pt-PT/title.txt +++ /dev/null @@ -1 +0,0 @@ -Mastodon \ No newline at end of file diff --git a/fastlane/metadata/android/ro-RO/full_description.txt b/fastlane/metadata/android/ro-RO/full_description.txt deleted file mode 100644 index 69a53b957..000000000 --- a/fastlane/metadata/android/ro-RO/full_description.txt +++ /dev/null @@ -1,16 +0,0 @@ -Mastodon este cea mai mare rețea socială descentralizată de pe internet. În loc de un singur site, este o rețea de milioane de utilizatori din comunități independente care pot interacționa cu ceilalți, fără nici o întrerupere. Indiferent în ce te afli, poți întâlni oameni pasionați care postează despre asta pe Mastodon! - -Alătură-te unei comunități și creează-ți profilul. Find and follow fascinating folks and read their posts in an ad-free, chronological timeline. Exprimă-te cu emoji-uri personalizate, imagini, GIF-uri, videoclipuri și audio în postări de 500 de caractere. Răspunde la subiectele de discuție și impulsionează postările de la oricine pentru a împărtăși lucruri minunate. Găsește conturi noi de urmărit și haștag-uri populare pentru a-ți extinde rețeaua. - -Mastodon a fost construit cu accent pe confidențialitate și siguranță. Decide dacă postările tale sunt partajate cu urmăritorii tăi, doar cu cei pe care îi menționezi sau cu întreaga lume. Avertismentele de conținut vă permit să ascundeți postările care conțin materiale sensibile sau declanșatoare până când sunteți gata să le implicați. Fiecare comunitate are propriile sale orientări și proprii moderatori pentru a-și menține membrii în siguranță, iar instrumentele solide de blocare și raportare contribuie la prevenirea abuzurilor. - -Mai multe caracteristici: - -• Mod întunecat: Citește postările în modul luminos, întunecat sau negru total -• Sondaje: Cereți celor care vă urmăresc opinia lor și numărați voturile -• Explorează: Hașhtag-urile populare și conturile sunt la o apăsare distanță -• Notificări: Primiți notificări despre noi urmăritori, răspunsuri și impulsionări -• Distribuire: Postează direct pe Mastodon din orice foaie de partajare în orice aplicație -• Drăgălășenie: Mascota noastră este un elefant adorabil, și îi veți vedea apărând din când în când - -Mastodon este o organizație non-profit înregistrată, iar dezvoltarea este sprijinită direct de donațiile tale. Nu există publicitate, monetizare și capital de risc, și intenționăm să păstrăm lucrurile astfel. diff --git a/fastlane/metadata/android/ro-RO/short_description.txt b/fastlane/metadata/android/ro-RO/short_description.txt deleted file mode 100644 index d8216713b..000000000 --- a/fastlane/metadata/android/ro-RO/short_description.txt +++ /dev/null @@ -1 +0,0 @@ -Rețea socială descentralizată \ No newline at end of file diff --git a/fastlane/metadata/android/ro-RO/title.txt b/fastlane/metadata/android/ro-RO/title.txt deleted file mode 100644 index 8123241a0..000000000 --- a/fastlane/metadata/android/ro-RO/title.txt +++ /dev/null @@ -1 +0,0 @@ -Mastodon \ No newline at end of file diff --git a/fastlane/metadata/android/ru-RU/full_description.txt b/fastlane/metadata/android/ru-RU/full_description.txt deleted file mode 100644 index 01db6f1a1..000000000 --- a/fastlane/metadata/android/ru-RU/full_description.txt +++ /dev/null @@ -1,16 +0,0 @@ -Mastodon — это крупнейшая распределённая социальная сеть в интернете. Вместо одного сайта, это сеть из независимых сообществ с миллионами пользователей, которые могут бесшовно взаимодействовать друг с другом. Вне зависимости от того, чем вы увлекаетесь, вы всегда найдёте себе единомышленников в Mastodon! - -Вступите в сообщество по интересу и создайте свой профиль. Ищите и подписывайтесь на увлекательных пользователей, читайте их посты без рекламы в хронологической ленте. Выражайте себя в 500-символьных постах, дополняя их пользовательскими эмодзи, изображениями, гифками, видео и аудио. Участвуйте в обсуждениях и продвигайте отличные посты от других людей. Расширяйте свой кругозор, находя новых интересных людей и следя за актуальными хэштегами. - -Mastodon создан с акцентом на конфиденциальность и безопасность. Решайте с кем вы хотите поделиться своими постами: своими подписчиками, только упомянутыми людьми или же вообще со всем миром. Предупреждения о содержимом позволят вам скрыть посты содержащие материалы деликатного или шокирующего характера. В каждом сообществе свои правила и модераторы, следящие за порядком, а надёжные инструменты блокировки и система жалоб помогают предотвращать злоупотребление. - -Ещё больше возможностей: - -• Темы на любой вкус: читайте посты в светлом, тёмном или OLED режимах -• Опросы: спрашивайте мнение подписчиков и подсчитывайте их голоса -• Обзор: в одно касание найдите актуальные хэштеги, посты и профили -• Уведомления: узнавайте о новых подписках, ответах и продвижениях -• Делитесь в Mastodon содержимым из любого приложения -• Милота: Наш талисман является восхитительным слоном, и вы будете видеть его время от времени - -Mastodon является зарегистрированной некоммерческой организацией, его разработка поддерживается непосредственно вашими пожертвованиями. У нас нет рекламы, монетизации и венчурного капитала, и мы не планируем это менять. diff --git a/fastlane/metadata/android/ru-RU/short_description.txt b/fastlane/metadata/android/ru-RU/short_description.txt deleted file mode 100644 index b95eb0558..000000000 --- a/fastlane/metadata/android/ru-RU/short_description.txt +++ /dev/null @@ -1 +0,0 @@ -Децентрализованная социальная сеть \ No newline at end of file diff --git a/fastlane/metadata/android/ru-RU/title.txt b/fastlane/metadata/android/ru-RU/title.txt deleted file mode 100644 index 8123241a0..000000000 --- a/fastlane/metadata/android/ru-RU/title.txt +++ /dev/null @@ -1 +0,0 @@ -Mastodon \ No newline at end of file diff --git a/fastlane/metadata/android/si-LK/full_description.txt b/fastlane/metadata/android/si-LK/full_description.txt deleted file mode 100644 index 11d74abb1..000000000 --- a/fastlane/metadata/android/si-LK/full_description.txt +++ /dev/null @@ -1,16 +0,0 @@ -Mastodon is the largest decentralized social network on the internet. Instead of a single website, it’s a network of millions of users in independent communities that can all interact with one another, seamlessly. No matter what you’re into, you can meet passionate people posting about it on Mastodon! - -Join a community and create your profile. Find and follow fascinating folks and read their posts in an ad-free, chronological timeline. Express yourself with custom emoji, images, GIFs, videos, and audio in 500-character posts. Reply to threads and reblog posts from anyone to share great stuff. Find new accounts to follow and trending hashtags to expand your network. - -Mastodon is built with a focus on privacy and safety. Decide whether your posts are shared with your followers, just the people you mention, or the whole world. Content warnings let you hide posts containing sensitive or triggering material until you're ready to engage with them. Each community has its own guidelines and moderators to keep its members safe, and robust blocking and reporting tools help prevent abuse. - -More features: - -• Dark Mode: Read posts in light, dark, or true black mode -• Polls: Ask followers for their opinion and tally the votes -• Explore: Trending hashtags and accounts are a tap away -• Notifications: Get notified about new follows, replies, and reblogs -• Sharing: Post directly to Mastodon from any share sheet in any app -• Cuteness: Our mascot is an adorable elephant, and you'll see them pop up from time to time - -Mastodon is a registered nonprofit and development is supported directly by your donations. There’s no advertising, no monetization, and no venture capital, and we plan to keep it that way. diff --git a/fastlane/metadata/android/si-LK/short_description.txt b/fastlane/metadata/android/si-LK/short_description.txt deleted file mode 100644 index 8f5a9b847..000000000 --- a/fastlane/metadata/android/si-LK/short_description.txt +++ /dev/null @@ -1 +0,0 @@ -Decentralized social network \ No newline at end of file diff --git a/fastlane/metadata/android/si-LK/title.txt b/fastlane/metadata/android/si-LK/title.txt deleted file mode 100644 index 8123241a0..000000000 --- a/fastlane/metadata/android/si-LK/title.txt +++ /dev/null @@ -1 +0,0 @@ -Mastodon \ No newline at end of file diff --git a/fastlane/metadata/android/sl-SI/full_description.txt b/fastlane/metadata/android/sl-SI/full_description.txt deleted file mode 100644 index e907f0316..000000000 --- a/fastlane/metadata/android/sl-SI/full_description.txt +++ /dev/null @@ -1,16 +0,0 @@ -Mastodon je največje decentralizirano družbeno omrežje na internetu. Namesto enega samega spletišča ga tvorijo milijoni uporabnikov v neodvisnih skupnostih, ki lahko med seboj komunicirajo brez težav. Ne glede na to, kaj vas zanima, lahko srečate predane ljudi, ki o tem objavljajo na Mastodonu! - -Pridružite se skupnosti in ustvarite svoj profil. Poiščite in sledite zanimivim osebam ter berite njihove objave na časovnici brez oglasov v kronološkem zaporedju. Izrazite se s čustvenčki po meri, slikami, GIF-i, videoposnetki in zvočnimi posnetki v objavah z največ 500 znaki. Odgovarjajte na niti in poobjavite objave drugih, da delite dobro z drugimi. Poiščite nove račune za sledenje ter ključnike v trendu, da razširite svoje omrežje. - -Mastodon je izdelan s poudarkom na zasebnosti in varnosti. Odločite se, ali se vaše objave delijo z vašimi sledilci, zgolj z omenjenimi ali s celim svetom. Opozorila o vsebini omogočajo skrivanje objav, ki vsebujejo občutljive ali netilne zadeve, vse dokler niste pripravljeni, da se z njimi spopadete. Vsak skupnost ima svoja lastna pravila in moderatorje, ki varujejo svoje člane, ter robustna orodja za blokiranje in poročanje, ki pomagajo preprečiti žalitve in kršitve človeškega dostojanstva ter pravic. - -Dodatne funkcionalnosti: - -• Temni način: objave berite v svetlem, temnem ali povsem črnem načinu; -• Ankete: vprašajte sledilce o njihovem mnenju in preštejte njihove glasove; -• Razišči: ključniki in računi v trendu so le en tap stran; -• Obvestila: bodite obveščeni o novih sledenjih, odgovorih in poobjavah; -• Skupna raba: objavljajte neposredno v Mastodon s poljubne preglednice v skupni rabi; -• Srčkano: naša maskota je ljubek slon in videli boste, kako se sem ter tja pojavi. - -Mastodon je registrirana neprofitna organizacija, razvoj pa podpirajo neposredno vaše donacije. Je brez oglaševanja, monetizacije in brez rizičnega kapitala; nameravamo ga takšnega tudi obdržati. diff --git a/fastlane/metadata/android/sl-SI/short_description.txt b/fastlane/metadata/android/sl-SI/short_description.txt deleted file mode 100644 index d5c29d5d8..000000000 --- a/fastlane/metadata/android/sl-SI/short_description.txt +++ /dev/null @@ -1 +0,0 @@ -Decentralizirano družbeno omrežje \ No newline at end of file diff --git a/fastlane/metadata/android/sl-SI/title.txt b/fastlane/metadata/android/sl-SI/title.txt deleted file mode 100644 index 8123241a0..000000000 --- a/fastlane/metadata/android/sl-SI/title.txt +++ /dev/null @@ -1 +0,0 @@ -Mastodon \ No newline at end of file diff --git a/fastlane/metadata/android/sv-SE/full_description.txt b/fastlane/metadata/android/sv-SE/full_description.txt deleted file mode 100644 index adb5b20da..000000000 --- a/fastlane/metadata/android/sv-SE/full_description.txt +++ /dev/null @@ -1,16 +0,0 @@ -Mastodon är det största decentraliserade sociala nätverket på internet. I stället för en enda webbplats är det ett nätverk av miljontals användare på oberoende servrar som alla kan interagera med varandra, sömlöst. Oavsett vad du är intresserad av kan du träffa passionerade personer som diskuterar ämnet på Mastodon! - -Gå med på en server och skapa din profil. Hitta och följ fascinerande människor och läs deras inlägg i en annonsfri, kronologisk tidslinje. Uttryck dig med anpassade emoji, bilder, GIF:ar, videor och ljud i 500-teckensinlägg. Svara på trådar och boostar från vem som helst för att dela bra saker. Hitta nya konton att följa och trendande hashtaggar för att utöka ditt nätverk. - -Mastodon är byggt med fokus på integritet och trygghet. Bestäm om dina inlägg delas med dina följare, bara personer du omnämner, eller hela världen. Innehållsvarningar låter dig dölja inlägg som innehåller känsligt eller triggande material tills du är redo att interagera med dem. Varje server har sina egna riktlinjer och moderatorer för att hålla sina medlemmar trygga, och robusta blockerings- och rapporteringsverktyg för att förhindra missbruk. - -Fler funktioner: - -• Mörkt läge: Läs inlägg i ljust, mörkt eller helsvart läge -• Omröstningar: Fråga följare om deras åsikt och sammanställ deras röster -• Utforska: Trendande hashtaggar och konton är ett tryck bort -• Notiser: Bli meddelad om nya följare, svar och boostar -• Delning: Posta direkt till Mastodon från delningsbladet i alla appar -• Gullighet: Vår maskot är en bedårande elefant, och du kommer att se dem dyka upp då och då - -Mastodon är en registrerad ideell förening och utvecklingen stöds direkt av dina donationer. Det finns ingen reklam, ingen monetarisering, och inget riskkapital, och vi planerar att behålla det på det sättet. diff --git a/fastlane/metadata/android/sv-SE/short_description.txt b/fastlane/metadata/android/sv-SE/short_description.txt deleted file mode 100644 index ab718f23a..000000000 --- a/fastlane/metadata/android/sv-SE/short_description.txt +++ /dev/null @@ -1 +0,0 @@ -Decentraliserat socialt nätverk \ No newline at end of file diff --git a/fastlane/metadata/android/sv-SE/title.txt b/fastlane/metadata/android/sv-SE/title.txt deleted file mode 100644 index 8123241a0..000000000 --- a/fastlane/metadata/android/sv-SE/title.txt +++ /dev/null @@ -1 +0,0 @@ -Mastodon \ No newline at end of file diff --git a/fastlane/metadata/android/th-TH/full_description.txt b/fastlane/metadata/android/th-TH/full_description.txt deleted file mode 100644 index dbebaeb3e..000000000 --- a/fastlane/metadata/android/th-TH/full_description.txt +++ /dev/null @@ -1,16 +0,0 @@ -Mastodon เป็นเครือข่ายสังคมแบบกระจายศูนย์ที่ใหญ่ที่สุดบนอินเทอร์เน็ต ซึ่งไม่ได้เป็นเว็บไซต์เดียว แต่เป็นเครือข่ายของผู้ใช้หลายล้านคนในชุมชนอิสระที่ทุกคนสามารถโต้ตอบซึ่งกันและกันได้แบบไร้รอยต่อ ไม่ว่าคุณจะชอบอะไร คุณก็พบคนที่ชื่นชอบเหมือนกันโพสต์เกี่ยวกับสิ่งที่คุณชอบได้บน Mastodon! - -เข้าร่วมชุมชนและสร้างโปรไฟล์ ค้นหาและติดตามผู้คนที่น่าสนใจและอ่านโพสต์ของเขาในเส้นเวลาที่ไม่มีโฆษณาและเรียงตามลำดับเวลา แสดงความรู้สึกของตัวคุณเองด้วยอีโมจิที่กำหนดเอง รูปภาพ GIF วิดีโอ และเสียงในโพสต์ 500 ตัวอักษร ตอบกลับและดันโพสต์จากคนอื่น ๆ เพื่อแชร์สิ่งดี ๆ และค้นหาบัญชีใหม่ ๆ ที่จะติดตามและแฮชแท็กที่เป็นที่นิยมเพื่อขยายเครือข่ายของคุณ - -Mastodon สร้างขึ้นโดยเน้นความเป็นส่วนตัวและความปลอดภัยเป็นสำคัญ คุณสามารถตัดสินใจได้ว่าโพสต์ของคุณจะถูกแชร์กับผู้ติดตามของคุณ คนที่คุณกล่าวถึง หรือคนทั้งโลกก็ได้ ฟังก์ชั่นคำเตือนเนื้อหาช่วยให้คุณซ่อนโพสต์ที่มีเนื้อหาที่ละเอียดอ่อนจนกว่าคุณจะพร้อมเห็นเนื้อหานั้น แต่ละชุมชนจะมีหลักเกณฑ์และผู้ควบคุมเป็นของตนเองเพื่อรักษาความปลอดภัยของสมาชิก รวมถึงเครื่องมือการปิดกั้นและรายงานที่มีประสิทธิภาพเพื่อช่วยป้องกันการกระทำผิด - -คุณสมบัติอื่น ๆ: - -• โหมดมืด: อ่านโพสต์ในโหมดสว่าง มืด หรือโหมดมืดดำสนิท -• การสำรวจความคิดเห็น: สำรวจความคิดเห็นของผู้ติดตามและนับจำนวนการลงคะแนน -• สำรวจ: แตะปุ่มเดียวเพื่อดูแฮชแท็กและบัญชีที่เป็นที่นิยม -• การแจ้งเตือน: รับการแจ้งเตือนเกี่ยวกับผู้ติดตามใหม่ การตอบกลับ และการดันโพสต์ -• การแชร์: โพสต์ลง Mastodon ได้โดยตรงจากแอปอื่น ๆ ที่อยู่ในเครื่อง -• ความน่ารัก: มาสคอตของเราเป็นช้างน่ารัก และคุณจะเห็นมันโผล่ออกมาเป็นระยะ ๆ - -Mastodon เป็นองค์กรไม่แสวงหาผลกำไรที่จดทะเบียนแล้ว และการพัฒนาได้รับการสนับสนุนจากเงินบริจาคของคุณโดยตรง ดังนั้นจึงไม่มีโฆษณา ไม่มีการทำกำไร และไม่มีการร่วมลงทุน และเรามีแผนจะทำให้เป็นอย่างนี้ต่อไป diff --git a/fastlane/metadata/android/th-TH/short_description.txt b/fastlane/metadata/android/th-TH/short_description.txt deleted file mode 100644 index 89d66e663..000000000 --- a/fastlane/metadata/android/th-TH/short_description.txt +++ /dev/null @@ -1 +0,0 @@ -เครือข่ายสังคมแบบกระจายศูนย์ \ No newline at end of file diff --git a/fastlane/metadata/android/th-TH/title.txt b/fastlane/metadata/android/th-TH/title.txt deleted file mode 100644 index 8123241a0..000000000 --- a/fastlane/metadata/android/th-TH/title.txt +++ /dev/null @@ -1 +0,0 @@ -Mastodon \ No newline at end of file diff --git a/fastlane/metadata/android/tr-TR/full_description.txt b/fastlane/metadata/android/tr-TR/full_description.txt deleted file mode 100644 index c92773229..000000000 --- a/fastlane/metadata/android/tr-TR/full_description.txt +++ /dev/null @@ -1,16 +0,0 @@ -Mastodon, internetteki merkezi olmayan en büyük sosyal ağdır. Tek bir web siteye bağlı kalmaksızın, milyonlarca kullanıcının bağımsız olarak birbiri ile kolayca etkileşebileceği bir ağdır. Hangi konuyla ilgili olduğunuz önemli değil, Mastodon'da onunla ilgili gönderi paylaşan tutkulu insanlarla tanışabilirsiniz! - -Bir topluluğa katılın ve profilinizi oluşturun. Olağanüstü kişileri bulun ve takip edin; gönderilerini reklamsız ve kronolojik bir zaman çizelgesinde okuyun. Gönderilerinizde şimdilik 500 karakter sınırlamasıyla kendinizi emojiler, görseller, GIFler, videolar ve sesler ile ifade edin. Harika içerikler paylaşmak için başlıklara yanıt yazın, insanların gönderilerini yineleyin. Ağınızı genişletmek için takip edilecek yeni hesaplar ve hashtagler bulun. - -Mastodon, mahremiyet ve güvenliğe odaklanılarak inşa edilmiştir. Gönderilerinizi takipçilerinizle mi, sadece bahsettiğiniz kişilerle mi yoksa tüm dünyayla mı paylaşılacağına karar verin. İçerik uyarıları, hassas veya tetikleyici materyal içeren gönderileri, siz onlarla etkileşim kurmaya hazır olana kadar gizlemenize olanak tanır. Her topluluk, üyelerini güvende tutmak için kendi kurallarına ve moderatörlerine; istismarı önlemek için de güçlü engelleme ve bildirme araçlarına sahiptir. - -Diğer özellikler: - -• Karanlık Mod: Gönderileri aydınlık, karanlık ya da gerçek karanlık modunda okuyabilirsin -• Anketler: Takipçilere fikirlerini sorun ve oylarını görün -• Keşfet: Öne çıkan etiketlerler ve hesaplar bir tık uzağınızda -• Bildirimler: Yeni takipçilerden, yanıtlardan ve yeniden paylaşımlardan haberiniz olsun -• Paylaşım: Doğrudan Mastodon'a herhangi bir türde gönderi paylaş -• Sevimlilik: Maskotumuz şirin bir fil ve onu uygulamada zaman zaman göreceksiniz - -Mastodon kar amacı gütmeyen bir kuruluştur ve geliştirilmesi doğrudan bağışlarınızla sağlanmaktadır. Reklam yok, para kazanma güdüsü yok, risk sermayesi yok ve bu şekilde devam etmeyi planlıyoruz. diff --git a/fastlane/metadata/android/tr-TR/short_description.txt b/fastlane/metadata/android/tr-TR/short_description.txt deleted file mode 100644 index ee54f4aae..000000000 --- a/fastlane/metadata/android/tr-TR/short_description.txt +++ /dev/null @@ -1 +0,0 @@ -Merkezi olmayan sosyal ağ \ No newline at end of file diff --git a/fastlane/metadata/android/tr-TR/title.txt b/fastlane/metadata/android/tr-TR/title.txt deleted file mode 100644 index 8123241a0..000000000 --- a/fastlane/metadata/android/tr-TR/title.txt +++ /dev/null @@ -1 +0,0 @@ -Mastodon \ No newline at end of file diff --git a/fastlane/metadata/android/uk-UA/full_description.txt b/fastlane/metadata/android/uk-UA/full_description.txt deleted file mode 100644 index f60cf8e5f..000000000 --- a/fastlane/metadata/android/uk-UA/full_description.txt +++ /dev/null @@ -1,16 +0,0 @@ -Mastodon — найбільша децентралізована соціальна мережа в інтернеті. Це не один сайт, а мережа з мільйонів користувачів у незалежних спільнотах, які взаємодіють одна з одною. Незалежно від того, чим ви займаєтеся, ви можете зустріти людей зі спільними інтересами, які пишуть про це на Mastodon! - -Приєднуйтесь до спільноти та створіть свій профіль. Знайдіть і підпишіться на цікавих людей та читайте дописи у вільний від реклами стрічці. Виразіть себе за допомогою користувацьких емоджі, зображень, GIF, відео й аудіо у дописах, обмеженими 500 символами. Відповідайте на теми та поширюйте дописи від будь-кого, щоб ділитися цікавим. Знаходьте нових користувачів, щоб підписатися та слідкуйте за популярними хештегами, щоб розширити свій кругозір. - -Mastodon будується з акцентом на конфіденційність та безпеку. Вирішіть, чи будуть ваші дописи доступні лише підписникам, або для людей, яких ви згадали, чи взагалі для всіх. Попередження про вміст дозволяють приховати дописи, що містять чутливі або провокаційні матеріали, доки ви не будете готові взаємодіяти з ними. Кожна спільнота має власні правила та модераторів, які забезпечують безпеку учасників, а надійні інструменти блокування та система скарг допомагають запобігти зловживанням. - -Більше можливостей: - -• Темна тема: Читайте дописи у світлому, темному або повністю чорному режимі -• Опитування: Запитайте у підписників їхню думку та підраховуйте голоси -• Досліджуйте: Популярні хештеги та користувачі на відстані одного дотику -• Сповіщення: Отримуйте сповіщення про нові підписки, відповіді та поширення -• Діліться: Публікуйте безпосередньо в Mastodon через будь-яке меню "поділитися" у будь-якому додатку -• Миле: Наш талісман - чарівне слоненя, яке час від часу ви помічатимете - -Mastodon є зареєстрованою некомерційною організацією і розробка підтримується безпосередньо вашими пожертвуваннями. Тут немає реклами, монетизації та венчурного капіталу. І ми плануємо, що так буде й надалі. diff --git a/fastlane/metadata/android/uk-UA/short_description.txt b/fastlane/metadata/android/uk-UA/short_description.txt deleted file mode 100644 index 4e4dcd741..000000000 --- a/fastlane/metadata/android/uk-UA/short_description.txt +++ /dev/null @@ -1 +0,0 @@ -Децентралізована соціальна мережа \ No newline at end of file diff --git a/fastlane/metadata/android/uk-UA/title.txt b/fastlane/metadata/android/uk-UA/title.txt deleted file mode 100644 index 8123241a0..000000000 --- a/fastlane/metadata/android/uk-UA/title.txt +++ /dev/null @@ -1 +0,0 @@ -Mastodon \ No newline at end of file diff --git a/fastlane/metadata/android/ur-IN/full_description.txt b/fastlane/metadata/android/ur-IN/full_description.txt deleted file mode 100644 index 11d74abb1..000000000 --- a/fastlane/metadata/android/ur-IN/full_description.txt +++ /dev/null @@ -1,16 +0,0 @@ -Mastodon is the largest decentralized social network on the internet. Instead of a single website, it’s a network of millions of users in independent communities that can all interact with one another, seamlessly. No matter what you’re into, you can meet passionate people posting about it on Mastodon! - -Join a community and create your profile. Find and follow fascinating folks and read their posts in an ad-free, chronological timeline. Express yourself with custom emoji, images, GIFs, videos, and audio in 500-character posts. Reply to threads and reblog posts from anyone to share great stuff. Find new accounts to follow and trending hashtags to expand your network. - -Mastodon is built with a focus on privacy and safety. Decide whether your posts are shared with your followers, just the people you mention, or the whole world. Content warnings let you hide posts containing sensitive or triggering material until you're ready to engage with them. Each community has its own guidelines and moderators to keep its members safe, and robust blocking and reporting tools help prevent abuse. - -More features: - -• Dark Mode: Read posts in light, dark, or true black mode -• Polls: Ask followers for their opinion and tally the votes -• Explore: Trending hashtags and accounts are a tap away -• Notifications: Get notified about new follows, replies, and reblogs -• Sharing: Post directly to Mastodon from any share sheet in any app -• Cuteness: Our mascot is an adorable elephant, and you'll see them pop up from time to time - -Mastodon is a registered nonprofit and development is supported directly by your donations. There’s no advertising, no monetization, and no venture capital, and we plan to keep it that way. diff --git a/fastlane/metadata/android/ur-IN/short_description.txt b/fastlane/metadata/android/ur-IN/short_description.txt deleted file mode 100644 index 8f5a9b847..000000000 --- a/fastlane/metadata/android/ur-IN/short_description.txt +++ /dev/null @@ -1 +0,0 @@ -Decentralized social network \ No newline at end of file diff --git a/fastlane/metadata/android/ur-IN/title.txt b/fastlane/metadata/android/ur-IN/title.txt deleted file mode 100644 index 8123241a0..000000000 --- a/fastlane/metadata/android/ur-IN/title.txt +++ /dev/null @@ -1 +0,0 @@ -Mastodon \ No newline at end of file diff --git a/fastlane/metadata/android/vi-VN/full_description.txt b/fastlane/metadata/android/vi-VN/full_description.txt deleted file mode 100644 index acf313167..000000000 --- a/fastlane/metadata/android/vi-VN/full_description.txt +++ /dev/null @@ -1,16 +0,0 @@ -Mastodon là mạng xã hội liên hợp lớn nhất trên internet. Thay vì một trang web duy nhất, nó là một mạng lưới hàng triệu người trong các máy chủ độc lập, tất cả đều có thể tương tác với nhau một cách liền mạch. Bất kể bạn thích gì, bạn đều có thể gặp gỡ những người đăng tút về nó trên Mastodon! - -Tham gia một máy chủ và tạo trang hồ sơ của bạn. Tìm, theo dõi những người thú vị và đọc tút của họ theo trình tự thời gian, không có quảng cáo. Thể hiện bản thân bằng emoji, hình ảnh, GIF, video và âm thanh trong tút tối đa 500 ký tự. Trả lời tút và đăng lại tút từ bất kỳ ai để chia sẻ những điều tuyệt vời. Tìm những người mới để theo dõi và các hashtag nổi bật để mở rộng mạng lưới của bạn. - -Mastodon được xây dựng tập trung vào sự riêng tư và an toàn. Quyết định xem tút của bạn được chia sẻ với những người theo dõi, chỉ những người bạn nhắc đến hay cả thế giới. Nội dung ẩn cho phép bạn ẩn các tút chứa nội dung nhạy cảm hoặc chơi chữ cho đến khi bạn sẵn sàng tương tác với chúng. Mỗi máy chủ có các nguyên tắc riêng và kiểm duyệt viên riêng để giữ an toàn cho các thành viên, song song với các công cụ chặn và báo cáo mạnh mẽ giúp ngăn chặn hành vi bậy. - -Tính năng khác: - -• Chế độ Tối: Đọc tút ở chế độ sáng, tối hoặc đen -• Bình chọn: Hỏi cộng đồng về ý kiến của họ và đếm lượt bình chọn -• Khám phá: Xem hashtag nổi bật và tài khoản chỉ bằng một nhấn -• Thông báo: Nhận thông báo về người theo dõi, lượt trả lời và đăng lại mới -• Chia sẻ: Đăng trực tiếp lên Mastodon từ bất kỳ ứng dụng nào -• Đáng yêu: Linh vật của chúng tôi là một chú voi ma mút và bạn sẽ thấy anh ấy thỉnh thoảng xuất hiện - -Mastodon là một tổ chức phi lợi nhuận đã đăng ký và được hỗ trợ trực tiếp bởi các khoản đóng góp của bạn. Không có quảng cáo, không kiếm tiền và không có vốn đầu tư mạo hiểm và chúng tôi dự định sẽ giữ nguyên như vậy. diff --git a/fastlane/metadata/android/vi-VN/short_description.txt b/fastlane/metadata/android/vi-VN/short_description.txt deleted file mode 100644 index 3d1d2379b..000000000 --- a/fastlane/metadata/android/vi-VN/short_description.txt +++ /dev/null @@ -1 +0,0 @@ -Mạng xã hội liên hợp \ No newline at end of file diff --git a/fastlane/metadata/android/vi-VN/title.txt b/fastlane/metadata/android/vi-VN/title.txt deleted file mode 100644 index 8123241a0..000000000 --- a/fastlane/metadata/android/vi-VN/title.txt +++ /dev/null @@ -1 +0,0 @@ -Mastodon \ No newline at end of file diff --git a/fastlane/metadata/android/zh-CN/full_description.txt b/fastlane/metadata/android/zh-CN/full_description.txt deleted file mode 100644 index 69812ffba..000000000 --- a/fastlane/metadata/android/zh-CN/full_description.txt +++ /dev/null @@ -1,16 +0,0 @@ -Mastodon 是互联网上最大的去中心化社交网络。 它不是一个网站,而是由独立社区节点及其数以百万计的用户组成的网络,所有这些用户都能够无缝地相互交流。 无论你关注什么话题,你都能在 Mastodon 上找到兴趣相投的人进行交流。 - -加入一个社区节点并创建你的账户。 查找、关注有趣的同好,无广告、无时间线干扰地阅读他们的帖子。 借助自定义 emoji、图像、GIF、视频和音频,在最多 500 字的帖文中表达自我。 通过回复或转发其他人的帖文来分享美好的事物。 通过准寻新账户并关注热门话题标签来扩展你的社交网络。 - -Mastodon 以隐私和安全为首要目标。 你可以自主决定帖文的分享分享对象,可以是你的关注者、你提到的人或是整个世界。 在你做好充足的互动准备之前,内容警告可以隐藏包含敏感或刺激内容的帖文。 每个社区都有自己的规则和管理员来保证其成员安全,同时还有强力的屏蔽和举报工具来避免滥用。 - -更多功能: - -• 暗色模式:在浅色、深色或纯黑模式下阅读帖文 -• 投票:询问关注者的意见并统计他们的投票 -• 探索:热门的话题标签及账号只有一触之遥 -• 通知:获取关注、回复和转发相关的通知提醒 -• 分享:从其他应用中的分享菜单中直接发布到 Mastodon -• 吉祥物:你会不时地看到我们可爱的长毛象 - -Mastodon 是一个直接由用户捐赠支持、已注册非营利开发项目。 它没有广告、没有商业化,也没有风险资本,并且我们也计划保持这种方式。 diff --git a/fastlane/metadata/android/zh-CN/short_description.txt b/fastlane/metadata/android/zh-CN/short_description.txt deleted file mode 100644 index 046004359..000000000 --- a/fastlane/metadata/android/zh-CN/short_description.txt +++ /dev/null @@ -1 +0,0 @@ -分布式社交网络 \ No newline at end of file diff --git a/fastlane/metadata/android/zh-CN/title.txt b/fastlane/metadata/android/zh-CN/title.txt deleted file mode 100644 index 8123241a0..000000000 --- a/fastlane/metadata/android/zh-CN/title.txt +++ /dev/null @@ -1 +0,0 @@ -Mastodon \ No newline at end of file diff --git a/fastlane/metadata/android/zh-TW/full_description.txt b/fastlane/metadata/android/zh-TW/full_description.txt deleted file mode 100644 index 62315058a..000000000 --- a/fastlane/metadata/android/zh-TW/full_description.txt +++ /dev/null @@ -1,16 +0,0 @@ -Mastodon 是網際網路上最大的去中心化社群網路。 它是一個由能無縫互動的獨立社群中,數百萬使用者組成的網路,而非單一網站。 無論您對什麼事情感興趣,您都能在 Mastodon 上遇到充滿熱情的人們討論該話題。 - -加入社群並建立您的個人檔案。 尋找並跟隨迷人的朋友們,並在無廣告、按時間順序排列的時間軸上閱讀他們的嘟文。 在 500 個字元的嘟文中使用自訂表情符號、GIF、視訊與音訊來表達您自己。 回覆任何人的話題與轉發貼文以分享精彩內容。 尋找要跟隨的新帳號與熱門主題標籤來拓展您的網路。 - -Mastodon 以隱私與安全為要。 決定您的嘟文要與您的跟隨者分享、只與您提及的人們分享,又或是與全世界分享。 內容警告可讓您隱藏包含敏感或可能觸發強烈情緒反應的嘟文,直到您準備好與它們進行互動。 每個社群都有它們自己的指導方針與管理原來確保其成員安全,強大的封鎖與回報工具有助於防止濫用。 - -更多功能: - -• 深色模式:以淺色、深色或純黑色模式閱讀貼文 -• 投票:詢問跟隨者們的意見並計票 -• 探索:僅需輕點一下,即可看到熱門主題標籤與帳號 -• 通知:取得關於新跟隨者們、回覆與轉發的通知 -• 分享:從任何應用程式中的分享表中直接發表嘟文到 Mastodon 中 -• 可愛:我們的吉祥物是一隻可愛的大象,您會不時看到牠出現 - -Mastodon 是一家註冊的非營利組織,您的捐款會直接支援開發工作。 沒有廣告、沒有貨幣化、沒有風險投資,我們計畫維持這種狀態。 沒有廣告、沒有貨幣化、沒有風險投資,我們計畫維持這種狀態。 diff --git a/fastlane/metadata/android/zh-TW/short_description.txt b/fastlane/metadata/android/zh-TW/short_description.txt deleted file mode 100644 index a5b8d0d55..000000000 --- a/fastlane/metadata/android/zh-TW/short_description.txt +++ /dev/null @@ -1 +0,0 @@ -去中心化社群網路 \ No newline at end of file diff --git a/fastlane/metadata/android/zh-TW/title.txt b/fastlane/metadata/android/zh-TW/title.txt deleted file mode 100644 index 8123241a0..000000000 --- a/fastlane/metadata/android/zh-TW/title.txt +++ /dev/null @@ -1 +0,0 @@ -Mastodon \ No newline at end of file diff --git a/fix-metadata-markdown-lists.sh b/fix-metadata-markdown-lists.sh new file mode 100644 index 000000000..3de9d970a --- /dev/null +++ b/fix-metadata-markdown-lists.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +find metadata -name '*.txt' -exec sed -Ei 's/^[–—─•·*]\s+/- /' {} \; diff --git a/gradle.properties b/gradle.properties index 3c6cdff76..cd211a89f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -16,4 +16,8 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 # https://developer.android.com/topic/libraries/support-library/androidx-rn android.useAndroidX=true # Automatically convert third-party libraries to use AndroidX -android.enableJetifier=false \ No newline at end of file +android.enableJetifier=false +android.defaults.buildfeatures.buildconfig=true +android.nonTransitiveRClass=true +android.nonFinalResIds=false +org.gradle.configuration-cache=true \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index e708b1c02..c1962a79e 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index e9cd6f21f..2c3425d49 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,7 @@ -#Sat Jun 03 23:40:27 MSK 2023 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip +distributionSha256Sum=e111cb9948407e26351227dabce49822fb88c37ee72f1d1582a69c68af2e702f +distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-bin.zip +networkTimeout=10000 zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 4f906e0c8..aeb74cbb4 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ -#!/usr/bin/env sh +#!/bin/sh # -# Copyright 2015 the original author or authors. +# Copyright © 2015-2021 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -17,67 +17,98 @@ # ############################################################################## -## -## Gradle start up script for UN*X -## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# ############################################################################## # Attempt to set APP_HOME + # Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +MAX_FD=maximum warn () { echo "$*" -} +} >&2 die () { echo echo "$*" echo exit 1 -} +} >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar @@ -87,9 +118,9 @@ CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -98,7 +129,7 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" + JAVACMD=java which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the @@ -106,80 +137,109 @@ location of your Java installation." fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi -fi - -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi - -# For Cygwin or MSYS, switch paths to Windows format before running java -if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi - # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" - fi - i=`expr $i + 1` - done - case $i in - 0) set -- ;; - 1) set -- "$args0" ;; - 2) set -- "$args0" "$args1" ;; - 3) set -- "$args0" "$args1" "$args2" ;; - 4) set -- "$args0" "$args1" "$args2" "$args3" ;; - 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=`save "$@"` +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat index ac1b06f93..6689b85be 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -14,7 +14,7 @@ @rem limitations under the License. @rem -@if "%DEBUG%" == "" @echo off +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -25,7 +25,8 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @@ -40,7 +41,7 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute +if %ERRORLEVEL% equ 0 goto execute echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. @@ -75,13 +76,15 @@ set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal diff --git a/img/banner.png b/img/banner.png new file mode 100644 index 000000000..3ef635151 Binary files /dev/null and b/img/banner.png differ diff --git a/img/f-droid-badge.png b/img/f-droid-badge.png new file mode 100644 index 000000000..ca83f5a39 Binary files /dev/null and b/img/f-droid-badge.png differ diff --git a/img/ic_fluent_animal_cat_24_regular.svg b/img/ic_fluent_animal_cat_24_regular.svg new file mode 100644 index 000000000..83c0f5290 --- /dev/null +++ b/img/ic_fluent_animal_cat_24_regular.svg @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/img/izzy-badge.png b/img/izzy-badge.png new file mode 100644 index 000000000..0af969fbb Binary files /dev/null and b/img/izzy-badge.png differ diff --git a/img/saunarepo-badge.svg b/img/saunarepo-badge.svg new file mode 100644 index 000000000..72474fd30 --- /dev/null +++ b/img/saunarepo-badge.svg @@ -0,0 +1 @@ +SAUNAREPOSAUNAREPO \ No newline at end of file diff --git a/mastodon/build.gradle b/mastodon/build.gradle index 2b14c2f6a..84594b801 100644 --- a/mastodon/build.gradle +++ b/mastodon/build.gradle @@ -1,18 +1,71 @@ plugins { id 'com.android.application' - id 'com.google.android.libraries.mapsplatform.secrets-gradle-plugin' +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } } android { compileSdk 33 defaultConfig { - applicationId "org.joinmastodon.android" + manifestPlaceholders = [oAuthScheme:"moshidon-android-auth"] + archivesBaseName = "moshidon" + applicationId "org.joinmastodon.android.moshinda" minSdk 23 targetSdk 33 - versionCode 84 - versionName "2.3.0" + versionCode 104 + versionName "2.2.4+fork.104.moshinda" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - resConfigs "ar-rSA", "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", "ka-rGE", "kab", "ko-rKR", "lt-rLT", "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" + 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 { @@ -23,22 +76,36 @@ android { } debug{ debuggable true - } - appcenterPrivateBeta{ - initWith release - minifyEnabled false - shrinkResources false - versionNameSuffix "-priv-beta" - } - appcenterPublicBeta{ - initWith release - versionNameSuffix "-beta" + versionNameSuffix '-debug' + applicationIdSuffix '.debug' + manifestPlaceholders = [oAuthScheme:"moshidon-android-debug-auth"] } githubRelease{ initWith release } - githubDebug{ - initWith debug + 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 } + fdroidRelease { + initWith release +// signingConfig signingConfigs.release } } compileOptions { @@ -47,27 +114,26 @@ android { coreLibraryDesugaringEnabled true } sourceSets{ - appcenterPrivateBeta{ - setRoot "src/appcenter" - } - appcenterPublicBeta{ - setRoot "src/appcenter" - } githubRelease{ setRoot "src/github" } - githubDebug{ - setRoot "src/github" + debug { + setRoot "src/debug" } } - lintOptions{ - checkReleaseBuilds false + namespace 'org.joinmastodon.android' + lint { abortOnError false + checkReleaseBuilds false + } + + buildFeatures { + buildConfig true } } dependencies { - api 'androidx.annotation:annotation:1.3.0' + api 'androidx.annotation:annotation:1.6.0' implementation 'com.squareup.okhttp3:okhttp:3.14.9' implementation 'me.grishka.litex:recyclerview:1.2.1.1' implementation 'me.grishka.litex:swiperefreshlayout:1.1.0.1' @@ -77,19 +143,15 @@ dependencies { implementation 'me.grishka.litex:viewpager2:1.0.0' implementation 'me.grishka.litex:palette:1.0.0' implementation 'me.grishka.appkit:appkit:1.2.16' - implementation 'com.google.code.gson:gson:2.8.9' + implementation 'com.google.code.gson:gson:2.9.0' implementation 'org.jsoup:jsoup:1.14.3' implementation 'com.squareup:otto:1.3.8' implementation 'de.psdev:async-otto:1.0.3' implementation 'org.parceler:parceler-api:1.1.12' + implementation 'com.github.bottom-software-foundation:bottom-java:2.1.0' annotationProcessor 'org.parceler:parceler:1.1.12' coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.3' - - def appCenterSdkVersion = "4.4.2" - appcenterPrivateBetaImplementation "com.microsoft.appcenter:appcenter-crashes:${appCenterSdkVersion}" - appcenterPrivateBetaImplementation "com.microsoft.appcenter:appcenter-distribute:${appCenterSdkVersion}" - appcenterPublicBetaImplementation "com.microsoft.appcenter:appcenter-crashes:${appCenterSdkVersion}" - appcenterPublicBetaImplementation "com.microsoft.appcenter:appcenter-distribute:${appCenterSdkVersion}" + implementation 'com.github.UnifiedPush:android-connector:2.1.1' androidTestImplementation 'androidx.test:core:1.5.0' androidTestImplementation 'androidx.test.ext:junit:1.1.5' diff --git a/mastodon/proguard-rules.pro b/mastodon/proguard-rules.pro index 34f83b1bf..39538ac74 100644 --- a/mastodon/proguard-rules.pro +++ b/mastodon/proguard-rules.pro @@ -30,6 +30,9 @@ *; } +# i don't know how proguard works +-keep class org.joinmastodon.android.** { *; } + # Keep all enums for debugging purposes -keepnames public enum * { *; @@ -40,14 +43,52 @@ @com.squareup.otto.Subscribe ; } --keep class com.microsoft.appcenter.** { - *; -} +-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 * { *; } -keep class **$$Parcelable { *; } --keep class org.joinmastodon.android.AppCenterWrapper { *; } +##---------------Begin: proguard configuration for Gson ---------- +# Gson uses generic type information stored in a class file when working with fields. Proguard +# removes such information by default, so configure it to keep all of it. +-keepattributes Signature --keepattributes LineNumberTable \ No newline at end of file +# For using GSON @Expose annotation +-keepattributes *Annotation* + +# Gson specific classes +-dontwarn sun.misc.** +#-keep class com.google.gson.stream.** { *; } + +# Application classes that will be serialized/deserialized over Gson +-keep class com.google.gson.examples.android.model.** { ; } + +# Prevent proguard from stripping interface information from TypeAdapter, TypeAdapterFactory, +# JsonSerializer, JsonDeserializer instances (so they can be used in @JsonAdapter) +-keep class * extends com.google.gson.TypeAdapter +-keep class * implements com.google.gson.TypeAdapterFactory +-keep class * implements com.google.gson.JsonSerializer +-keep class * implements com.google.gson.JsonDeserializer + +# Prevent R8 from leaving Data object members always null +-keepclassmembers,allowobfuscation class * { + @com.google.gson.annotations.SerializedName ; +} + +# Retain generic signatures of TypeToken and its subclasses with R8 version 3.0 and higher. +-keep,allowobfuscation,allowshrinking class com.google.gson.reflect.TypeToken +-keep,allowobfuscation,allowshrinking class * extends com.google.gson.reflect.TypeToken + +##---------------End: proguard configuration for Gson ---------- + + +-dontobfuscate diff --git a/mastodon/src/androidTest/java/org/joinmastodon/android/fragments/ThreadFragmentTest.java b/mastodon/src/androidTest/java/org/joinmastodon/android/fragments/ThreadFragmentTest.java new file mode 100644 index 000000000..38a2a489c --- /dev/null +++ b/mastodon/src/androidTest/java/org/joinmastodon/android/fragments/ThreadFragmentTest.java @@ -0,0 +1,113 @@ +package org.joinmastodon.android.fragments; + +import static org.junit.Assert.*; + +import org.joinmastodon.android.events.StatusCountersUpdatedEvent; +import org.joinmastodon.android.events.StatusUpdatedEvent; +import org.joinmastodon.android.model.Status; +import org.joinmastodon.android.model.StatusContext; +import org.junit.Test; + +import java.time.Instant; +import java.util.List; + +public class ThreadFragmentTest { + + private Status fakeStatus(String id, String inReplyTo) { + Status status = Status.ofFake(id, null, null); + status.inReplyToId = inReplyTo; + return status; + } + + private ThreadFragment.NeighborAncestryInfo fakeInfo(Status s, Status d, Status a) { + return new ThreadFragment.NeighborAncestryInfo(s, d, a); + } + + @Test + public void mapNeighborhoodAncestry() { + StatusContext context = new StatusContext(); + context.ancestors = List.of( + fakeStatus("oldest ancestor", null), + fakeStatus("younger ancestor", "oldest ancestor") + ); + Status mainStatus = fakeStatus("main status", "younger ancestor"); + context.descendants = List.of( + fakeStatus("first reply", "main status"), + fakeStatus("reply to first reply", "first reply"), + fakeStatus("third level reply", "reply to first reply"), + fakeStatus("another reply", "main status") + ); + + List neighbors = + ThreadFragment.mapNeighborhoodAncestry(mainStatus, context); + + assertEquals(List.of( + fakeInfo(context.ancestors.get(0), context.ancestors.get(1), null), + fakeInfo(context.ancestors.get(1), mainStatus, context.ancestors.get(0)), + fakeInfo(mainStatus, context.descendants.get(0), context.ancestors.get(1)), + fakeInfo(context.descendants.get(0), context.descendants.get(1), mainStatus), + fakeInfo(context.descendants.get(1), context.descendants.get(2), context.descendants.get(0)), + fakeInfo(context.descendants.get(2), null, context.descendants.get(1)), + fakeInfo(context.descendants.get(3), null, null) + ), neighbors); + } + + @Test + public void maybeApplyMainStatus() { + ThreadFragment fragment = new ThreadFragment(); + fragment.contextInitiallyRendered = true; + fragment.mainStatus = Status.ofFake("123456", "original text", Instant.EPOCH); + + Status update1 = Status.ofFake("123456", "updated text", Instant.EPOCH); + update1.editedAt = Instant.ofEpochSecond(1); + fragment.updatedStatus = update1; + StatusUpdatedEvent event1 = (StatusUpdatedEvent) fragment.maybeApplyMainStatus(); + assertEquals("fired update event", update1, event1.status); + assertEquals("updated main status", update1, fragment.mainStatus); + + Status update2 = Status.ofFake("123456", "updated text", Instant.EPOCH); + update2.favouritesCount = 123; + fragment.updatedStatus = update2; + StatusCountersUpdatedEvent event2 = (StatusCountersUpdatedEvent) fragment.maybeApplyMainStatus(); + assertEquals("only fired counter update event", update2.id, event2.id); + assertEquals("updated counter is correct", 123, event2.favorites); + assertEquals("updated main status", update2, fragment.mainStatus); + + Status update3 = Status.ofFake("123456", "whatever", Instant.EPOCH); + fragment.contextInitiallyRendered = false; + fragment.updatedStatus = update3; + assertNull("no update when context hasn't been rendered", fragment.maybeApplyMainStatus()); + } + + @Test + public void sortStatusContext() { + StatusContext context = new StatusContext(); + context.ancestors = List.of( + fakeStatus("younger ancestor", "oldest ancestor"), + fakeStatus("oldest ancestor", null) + ); + context.descendants = List.of( + fakeStatus("reply to first reply", "first reply"), + fakeStatus("third level reply", "reply to first reply"), + fakeStatus("first reply", "main status"), + fakeStatus("another reply", "main status") + ); + + ThreadFragment.sortStatusContext( + fakeStatus("main status", "younger ancestor"), + context + ); + List expectedAncestors = List.of( + fakeStatus("oldest ancestor", null), + fakeStatus("younger ancestor", "oldest ancestor") + ); + List expectedDescendants = List.of( + fakeStatus("first reply", "main status"), + fakeStatus("reply to first reply", "first reply"), + fakeStatus("third level reply", "reply to first reply"), + fakeStatus("another reply", "main status") + ); + + // TODO: ??? i have no idea how this code works. it certainly doesn't return what i'd expect + } +} \ No newline at end of file diff --git a/mastodon/src/androidTest/java/org/joinmastodon/android/ui/utils/UiUtilsTest.java b/mastodon/src/androidTest/java/org/joinmastodon/android/ui/utils/UiUtilsTest.java new file mode 100644 index 000000000..d9ea49e14 --- /dev/null +++ b/mastodon/src/androidTest/java/org/joinmastodon/android/ui/utils/UiUtilsTest.java @@ -0,0 +1,265 @@ +package org.joinmastodon.android.ui.utils; + +import static org.junit.Assert.*; + +import android.content.Context; +import android.content.res.Resources; +import android.util.Pair; + +import org.joinmastodon.android.MastodonApp; +import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.model.AccountField; +import org.joinmastodon.android.model.Instance; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Optional; + +public class UiUtilsTest { + @BeforeClass + public static void createDummySession() { + Instance dummyInstance = new Instance(); + dummyInstance.uri = "test.tld"; + Account dummyAccount = new Account(); + dummyAccount.id = "123456"; + AccountSessionManager.getInstance().addAccount(dummyInstance, null, dummyAccount, null, null); + } + + @AfterClass + public static void cleanUp() { + AccountSessionManager.getInstance().removeAccount("test.tld_123456"); + } + + @Test + public void parseFediverseHandle() { + assertEquals( + Optional.of(Pair.create("megalodon", Optional.of("floss.social"))), + UiUtils.parseFediverseHandle("megalodon@floss.social") + ); + + assertEquals( + Optional.of(Pair.create("megalodon", Optional.of("floss.social"))), + UiUtils.parseFediverseHandle("@megalodon@floss.social") + ); + + assertEquals( + Optional.of(Pair.create("megalodon", Optional.empty())), + UiUtils.parseFediverseHandle("@megalodon") + ); + + assertEquals( + Optional.of(Pair.create("megalodon", Optional.of("floss.social"))), + UiUtils.parseFediverseHandle("mailto:megalodon@floss.social") + ); + + assertEquals( + Optional.empty(), + UiUtils.parseFediverseHandle("megalodon") + ); + + assertEquals( + Optional.empty(), + UiUtils.parseFediverseHandle("this is not a fedi handle") + ); + + assertEquals( + Optional.empty(), + UiUtils.parseFediverseHandle("not@a-domain") + ); + } + + @Test + public void acctMatches() { + assertTrue("local account, domain not specified", UiUtils.acctMatches( + "test.tld_123456", + "someone", + "someone", + null + )); + + assertTrue("domain not specified", UiUtils.acctMatches( + "test.tld_123456", + "someone@somewhere.social", + "someone", + null + )); + + assertTrue("local account, domain specified, different casing", UiUtils.acctMatches( + "test.tld_123456", + "SomeOne", + "someone", + "Test.TLD" + )); + + assertFalse("username doesn't match", UiUtils.acctMatches( + "test.tld_123456", + "someone-else@somewhere.social", + "someone", + "somewhere.social" + )); + + assertFalse("domain doesn't match", UiUtils.acctMatches( + "test.tld_123456", + "someone@somewhere.social", + "someone", + "somewhere.else" + )); + } + + private final String[] args = new String[] { "Megalodon", "♡" }; + + private String gen(String format, CharSequence... args) { + return UiUtils.generateFormattedString(format, args).toString(); + } + @Test + public void generateFormattedString() { + assertEquals( + "ordered substitution", + "Megalodon reacted with ♡", + gen("%s reacted with %s", args) + ); + + assertEquals( + "1 2 3 4 5", + gen("%s %s %s %s %s", "1", "2", "3", "4", "5") + ); + + assertEquals( + "indexed substitution", + "with ♡ was reacted by Megalodon", + gen("with %2$s was reacted by %1$s", args) + ); + + assertEquals( + "indexed substitution, in order", + "Megalodon reacted with ♡", + gen("%1$s reacted with %2$s", args) + ); + + assertEquals( + "indexed substitution, 0-based", + "Megalodon reacted with ♡", + gen("%0$s reacted with %1$s", args) + ); + + assertEquals( + "indexed substitution, 5 items", + "5 4 3 2 1", + gen("%5$s %4$s %3$s %2$s %1$s", "1", "2", "3", "4", "5") + ); + + assertEquals( + "one argument missing", + "Megalodon reacted with ♡", + gen("reacted with %s", args) + ); + + assertEquals( + "multiple arguments missing", + "Megalodon reacted with ♡", + gen("reacted with", args) + ); + + assertEquals( + "multiple arguments missing, numbers in expeced positions", + "1 2 x 3 4 5", + gen("%s x %s", "1", "2", "3", "4", "5") + ); + + assertEquals( + "one leading and trailing space", + "Megalodon reacted with ♡", + gen(" reacted with ", args) + ); + + assertEquals( + "multiple leading and trailing spaces", + "Megalodon reacted with ♡", + gen(" reacted with ", args) + ); + + assertEquals( + "invalid format produces expected invalid result", + "Megalodon reacted with % s ♡", + gen("reacted with % s", args) + ); + + assertEquals( + "plain string as format, all arguments get added", + "a x b c", + gen("x", new String[] { "a", "b", "c" }) + ); + + assertEquals("empty input produces empty output", "", gen("")); + + // not supported: +// assertEquals("a b a", gen("%1$s %2$s %2$s %1$s", new String[] { "a", "b", "c" })); +// assertEquals("x", gen("%s %1$s %2$s %1$s %s", new String[] { "a", "b", "c" })); + } + + private AccountField makeField(String name, String value) { + AccountField f = new AccountField(); + f.name = name; + f.value = value; + return f; + } + + private Account fakeAccount(AccountField... fields) { + Account a = new Account(); + a.fields = Arrays.asList(fields); + return a; + } + + @Test + public void extractPronouns() { + assertEquals("they", UiUtils.extractPronouns(MastodonApp.context, fakeAccount( + makeField("name and pronouns", "https://pronouns.site"), + makeField("pronouns", "they"), + makeField("pronouns something", "bla bla") + )).orElseThrow()); + + assertTrue(UiUtils.extractPronouns(MastodonApp.context, fakeAccount()).isEmpty()); + + assertEquals("it/its", UiUtils.extractPronouns(MastodonApp.context, fakeAccount( + makeField("pronouns pronouns pronouns", "hi hi hi"), + makeField("pronouns", "it/its"), + makeField("the pro's nouns", "professional") + )).orElseThrow()); + + assertEquals("she/he", UiUtils.extractPronouns(MastodonApp.context, fakeAccount( + makeField("my name is", "jeanette shork, apparently"), + makeField("my pronouns are", "she/he") + )).orElseThrow()); + + assertEquals("they/them", UiUtils.extractPronouns(MastodonApp.context, fakeAccount( + makeField("pronouns", "https://pronouns.cc/pronouns/they/them") + )).orElseThrow()); + + Context german = UiUtils.getLocalizedContext(MastodonApp.context, Locale.GERMAN); + + assertEquals("sie/ihr", UiUtils.extractPronouns(german, fakeAccount( + makeField("pronomen lauten", "sie/ihr"), + makeField("pronouns are", "she/her"), + makeField("die pronomen", "stehen oben") + )).orElseThrow()); + + assertEquals("er/ihm", UiUtils.extractPronouns(german, fakeAccount( + makeField("die pronomen", "stehen unten"), + makeField("pronomen sind", "er/ihm"), + makeField("pronouns are", "he/him") + )).orElseThrow()); + + assertEquals("* (asterisk)", UiUtils.extractPronouns(MastodonApp.context, fakeAccount( + makeField("pronouns", "-- * (asterisk) --") + )).orElseThrow()); + + assertEquals("they/(she?)", UiUtils.extractPronouns(MastodonApp.context, fakeAccount( + makeField("pronouns", "they/(she?)...") + )).orElseThrow()); + } +} \ No newline at end of file diff --git a/mastodon/src/androidTest/java/org/joinmastodon/android/utils/StatusFilterPredicateTest.java b/mastodon/src/androidTest/java/org/joinmastodon/android/utils/StatusFilterPredicateTest.java new file mode 100644 index 000000000..8ad1ff689 --- /dev/null +++ b/mastodon/src/androidTest/java/org/joinmastodon/android/utils/StatusFilterPredicateTest.java @@ -0,0 +1,101 @@ +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()), + noAltText = Status.ofFake(null, "display me with a warning", Instant.now()), + withAltText = 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); + + noAltText.mediaAttachments = Attachment.createFakeAttachments("fakeurl", new ColorDrawable()); + withAltText.mediaAttachments = Attachment.createFakeAttachments("fakeurl", new ColorDrawable()); + for (Attachment mediaAttachment : withAltText.mediaAttachments) { + mediaAttachment.description = "Alt Text"; + } + } + + @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)); + } + + @Test + public void testAltTextFilterNoPass() { + assertFalse("should not pass because of no alt text", + new StatusFilterPredicate(allFilters, HOME).test(noAltText)); + } + + @Test + public void testAltTextFilterPass() { + assertTrue("should pass because of alt text", + new StatusFilterPredicate(allFilters, HOME).test(withAltText)); + } +} \ No newline at end of file diff --git a/mastodon/src/appcenter/java/org/joinmastodon/android/AppCenterWrapper.java b/mastodon/src/appcenter/java/org/joinmastodon/android/AppCenterWrapper.java deleted file mode 100644 index e027da17c..000000000 --- a/mastodon/src/appcenter/java/org/joinmastodon/android/AppCenterWrapper.java +++ /dev/null @@ -1,23 +0,0 @@ -package org.joinmastodon.android; - -import android.app.Application; -import android.util.Log; - -import com.microsoft.appcenter.AppCenter; -import com.microsoft.appcenter.crashes.Crashes; -import com.microsoft.appcenter.distribute.Distribute; -import com.microsoft.appcenter.distribute.UpdateTrack; - -public class AppCenterWrapper{ - private static final String TAG="AppCenterWrapper"; - - public static void init(Application app){ - if(AppCenter.isConfigured()) - return; - Log.i(TAG, "initializing AppCenter SDK, build type is "+BuildConfig.BUILD_TYPE); - - if(BuildConfig.BUILD_TYPE.equals("appcenterPrivateBeta")) - Distribute.setUpdateTrack(UpdateTrack.PRIVATE); - AppCenter.start(app, BuildConfig.appCenterKey, Distribute.class, Crashes.class); - } -} diff --git a/mastodon/src/github/AndroidManifest.xml b/mastodon/src/debug/AndroidManifest.xml similarity index 86% rename from mastodon/src/github/AndroidManifest.xml rename to mastodon/src/debug/AndroidManifest.xml index a75f12de6..aba7ef431 100644 --- a/mastodon/src/github/AndroidManifest.xml +++ b/mastodon/src/debug/AndroidManifest.xml @@ -1,10 +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 b607c8e46..73ca393d8 100644 --- a/mastodon/src/github/java/org/joinmastodon/android/updater/GithubSelfUpdaterImpl.java +++ b/mastodon/src/github/java/org/joinmastodon/android/updater/GithubSelfUpdaterImpl.java @@ -14,18 +14,22 @@ 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; @@ -36,7 +40,7 @@ import okhttp3.Response; @Keep public class GithubSelfUpdaterImpl extends GithubSelfUpdater{ - private static final long CHECK_PERIOD=24*3600*1000L; + private static final long CHECK_PERIOD=6*3600*1000L; private static final String TAG="GithubSelfUpdater"; private UpdateState state=UpdateState.NO_UPDATE; @@ -59,6 +63,7 @@ public class GithubSelfUpdaterImpl extends GithubSelfUpdater{ 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; @@ -82,6 +87,7 @@ public class GithubSelfUpdaterImpl extends GithubSelfUpdater{ .remove("apkURL") .remove("checkedByBuild") .remove("downloadID") + .remove("changelog") .apply(); } } @@ -101,59 +107,79 @@ public class GithubSelfUpdaterImpl extends GithubSelfUpdater{ } } + @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/mastodon/mastodon-android/releases/latest") + .url("https://api.github.com/repos/LucasGGamerM/moshidon/releases") .build(); Call call=MastodonAPIController.getHttpClient().newCall(req); try(Response resp=call.execute()){ - JsonObject obj=JsonParser.parseReader(resp.body().charStream()).getAsJsonObject(); - String tag=obj.get("tag_name").getAsString(); - Pattern pattern=Pattern.compile("v?(\\d+)\\.(\\d+)(?:\\.(\\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=matcher.group(3)!=null ? Integer.parseInt(matcher.group(3)) : 0; - Matcher curMatcher=pattern.matcher(BuildConfig.VERSION_NAME); - if(!curMatcher.find()){ - Log.w(TAG, "actuallyCheckForUpdates: current version has wrong format: "+BuildConfig.VERSION_NAME); - return; - } - int curMajor=Integer.parseInt(curMatcher.group(1)), curMinor=Integer.parseInt(curMatcher.group(2)), curRevision=matcher.group(3)!=null ? Integer.parseInt(curMatcher.group(3)) : 0; - long newVersion=((long)newMajor << 32) | ((long)newMinor << 16) | newRevision; - long curVersion=((long)curMajor << 32) | ((long)curMinor << 16) | curRevision; - if(newVersion>curVersion || forceUpdate){ - forceUpdate=false; - String version=newMajor+"."+newMinor; - if(matcher.group(3)!=null) - version+="."+newRevision; - Log.d(TAG, "actuallyCheckForUpdates: new version: "+version); - for(JsonElement el:obj.getAsJsonArray("assets")){ - JsonObject asset=el.getAsJsonObject(); - if("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(); + JsonArray arr=JsonParser.parseReader(resp.body().charStream()).getAsJsonArray(); + for (JsonElement jsonElement : arr) { + JsonObject obj = jsonElement.getAsJsonObject(); + if (obj.get("prerelease").getAsBoolean() && !GlobalUserPreferences.enablePreReleases) continue; - UpdateInfo info=new UpdateInfo(); - info.size=size; - info.version=version; - this.info=info; + 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 || forceUpdate){ + forceUpdate=false; + 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(); - getPrefs().edit() - .putLong("apkSize", size) - .putString("version", version) - .putString("apkURL", url) - .putInt("checkedByBuild", BuildConfig.VERSION_CODE) - .remove("downloadID") - .apply(); + UpdateInfo info=new UpdateInfo(); + info.size=size; + info.version=version; + info.changelog=changelog; + this.info=info; - break; + 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; } - getPrefs().edit().putLong("lastCheck", System.currentTimeMillis()).apply(); }catch(Exception x){ Log.w(TAG, "actuallyCheckForUpdates", x); }finally{ diff --git a/mastodon/src/main/AndroidManifest.xml b/mastodon/src/main/AndroidManifest.xml index 8343a374b..6e37876a0 100644 --- a/mastodon/src/main/AndroidManifest.xml +++ b/mastodon/src/main/AndroidManifest.xml @@ -1,15 +1,21 @@ - - + + + + @@ -24,11 +30,12 @@ @@ -36,28 +43,32 @@ - - - - - - - - - - - + + + + + + - + - + @@ -83,6 +94,25 @@ + + + + + + + + + + + + diff --git a/mastodon/src/main/assets/blocks.txt b/mastodon/src/main/assets/blocks.txt new file mode 100644 index 000000000..e7cf9991f --- /dev/null +++ b/mastodon/src/main/assets/blocks.txt @@ -0,0 +1,147 @@ +13bells.com +1611.social +4aem.com +5dollah.click +adachi.party +adtension.com +annihilation.social +anon-kenkai.com +asbestos.cafe +bae.st +banepo.st +bassam.social +battlepenguin.video +beefyboys.win +boymoder.biz +brainsoap.net +breastmilk.club +brighteon.social +cachapa.xyz +canary.fedinuke.example.com +catgirl.life +cawfee.club +childlove.space +clew.lol +clubcyberia.co +contrapointsfan.club +crucible.world +cum.camp +cum.salon +decayable.ink +dembased.xyz +detroitriotcity.com +djsumdog.com +eientei.org +eveningzoo.club +fluf.club +freak.university +freeatlantis.com +freespeechextremist.com +froth.zone +gameliberty.club +gearlandia.haus +genderheretics.xyz +geofront.rocks +gleasonator.com +glee.li +glindr.org +goyim.app +h5q.net +haeder.net +handholding.io +hitchhiker.social +iddqd.social +kitsunemimi.club +kiwifarms.cc +kurosawa.moe +kyaruc.moe +leafposter.club +lewdieheaven.com +liberdon.com +ligma.pro +lolicon.rocks +lolison.network +lolison.top +lovingexpressions.net +makemysarcophagus.com +marsey.moe +mastinator.com +merovingian.club +midwaytrades.com +mirr0r.city +morale.ch +mouse.services +mugicha.club +narrativerry.xyz +natehiggers.online +needs.vodka +neenster.org +nicecrew.digital +nnia.space +noagendasocial.com +noagendasocial.nl +noagendatube.com +nobodyhasthe.biz +norwoodzero.net +nyanide.com +onionfarms.org +pawlicker.com +pawoo.net +pedo.school +peervideo.club +piazza.today +pibvt.net +pieville.net +pisskey.io +plagu.ee +poa.st +poast.org +poast.tv +poster.place +prospeech.space +quodverum.com +r18.social +rakket.app +rapemeat.express +rapemeat.solutions +rayci.st +rebelbase.site +ryona.agency +sad.cab +schwartzwelt.xyz +seal.cafe +shaw.app +shigusegubu.club +shitpost.cloud +shortstacksran.ch +silliness.observer +skinheads.eu +skinheads.io +skinheads.social +skinheads.uk +skippers-bin.com +skyshanty.xyz +slash.cl +sleepy.cafe +smuglo.li +sneed.social +sonichu.com +spinster.xyz +springbo.cc +strelizia.net +tastingtraffic.net +teci.world +theapex.social +thechimp.zone +thenobody.club +thepostearthdestination.com +tkammer.de +trumpislovetrumpis.life +truthsocial.co.in +usualsuspects.lol +varishangout.net +vtuberfan.social +wolfgirl.bar +xn--p1abe3d.xn--80asehdb +yggdrasil.social +youjo.love diff --git a/mastodon/src/main/ic_launcher-playstore.png b/mastodon/src/main/ic_launcher-playstore.png index 7dd254b71..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/com/hootsuite/nachos/ChipConfiguration.java b/mastodon/src/main/java/com/hootsuite/nachos/ChipConfiguration.java new file mode 100644 index 000000000..0b8fb3b7a --- /dev/null +++ b/mastodon/src/main/java/com/hootsuite/nachos/ChipConfiguration.java @@ -0,0 +1,78 @@ +package com.hootsuite.nachos; + +import android.content.res.ColorStateList; + +public class ChipConfiguration { + + private final int mChipHorizontalSpacing; + private final ColorStateList mChipBackground; + private final int mChipCornerRadius; + private final int mChipTextColor; + private final int mChipTextSize; + private final int mChipHeight; + private final int mChipVerticalSpacing; + private final int mMaxAvailableWidth; + + /** + * Creates a new ChipConfiguration. You can pass in {@code -1} or {@code null} for any of the parameters to indicate that parameter should be + * ignored. + * + * @param chipHorizontalSpacing the amount of horizontal space (in pixels) to put between consecutive chips + * @param chipBackground the {@link ColorStateList} to set as the background of the chips + * @param chipCornerRadius the corner radius of the chip background, in pixels + * @param chipTextColor the color to set as the text color of the chips + * @param chipTextSize the font size (in pixels) to use for the text of the chips + * @param chipHeight the height (in pixels) of each chip + * @param chipVerticalSpacing the amount of vertical space (in pixels) to put between chips on consecutive lines + * @param maxAvailableWidth the maximum available with for a chip (the width of a full line of text in the text view) + */ + ChipConfiguration(int chipHorizontalSpacing, + ColorStateList chipBackground, + int chipCornerRadius, + int chipTextColor, + int chipTextSize, + int chipHeight, + int chipVerticalSpacing, + int maxAvailableWidth) { + mChipHorizontalSpacing = chipHorizontalSpacing; + mChipBackground = chipBackground; + mChipCornerRadius = chipCornerRadius; + mChipTextColor = chipTextColor; + mChipTextSize = chipTextSize; + mChipHeight = chipHeight; + mChipVerticalSpacing = chipVerticalSpacing; + mMaxAvailableWidth = maxAvailableWidth; + } + + public int getChipHorizontalSpacing() { + return mChipHorizontalSpacing; + } + + public ColorStateList getChipBackground() { + return mChipBackground; + } + + public int getChipCornerRadius() { + return mChipCornerRadius; + } + + public int getChipTextColor() { + return mChipTextColor; + } + + public int getChipTextSize() { + return mChipTextSize; + } + + public int getChipHeight() { + return mChipHeight; + } + + public int getChipVerticalSpacing() { + return mChipVerticalSpacing; + } + + public int getMaxAvailableWidth() { + return mMaxAvailableWidth; + } +} diff --git a/mastodon/src/main/java/com/hootsuite/nachos/NachoTextView.java b/mastodon/src/main/java/com/hootsuite/nachos/NachoTextView.java new file mode 100644 index 000000000..f91ebf81d --- /dev/null +++ b/mastodon/src/main/java/com/hootsuite/nachos/NachoTextView.java @@ -0,0 +1,1132 @@ +package com.hootsuite.nachos; + +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.content.res.ColorStateList; +import android.content.res.TypedArray; +import android.graphics.Color; +import android.graphics.Paint; +import android.text.Editable; +import android.text.Layout; +import android.text.TextUtils; +import android.text.TextWatcher; +import android.util.AttributeSet; +import android.util.Log; +import android.util.Pair; +import android.view.GestureDetector; +import android.view.MotionEvent; +import android.view.View; +import android.view.inputmethod.EditorInfo; +import android.widget.Adapter; +import android.widget.AdapterView; +import android.widget.AutoCompleteTextView; +import android.widget.ListAdapter; +import android.widget.MultiAutoCompleteTextView; + +import androidx.annotation.ColorInt; +import androidx.annotation.ColorRes; +import androidx.annotation.DimenRes; +import androidx.annotation.Dimension; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.hootsuite.nachos.chip.Chip; +import com.hootsuite.nachos.chip.ChipInfo; +import com.hootsuite.nachos.chip.ChipSpan; +import com.hootsuite.nachos.chip.ChipSpanChipCreator; +import com.hootsuite.nachos.terminator.ChipTerminatorHandler; +import com.hootsuite.nachos.terminator.DefaultChipTerminatorHandler; +import com.hootsuite.nachos.tokenizer.ChipTokenizer; +import com.hootsuite.nachos.tokenizer.SpanChipTokenizer; +import com.hootsuite.nachos.validator.ChipifyingNachoValidator; +import com.hootsuite.nachos.validator.IllegalCharacterIdentifier; +import com.hootsuite.nachos.validator.NachoValidator; + +import org.joinmastodon.android.R; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +/** + * An editable TextView extending {@link MultiAutoCompleteTextView} that supports "chipifying" pieces of text and displaying suggestions for segments of the text. + *

The ChipTokenizer

+ * To customize chipifying with this class you can provide a custom {@link ChipTokenizer} by calling {@link #setChipTokenizer(ChipTokenizer)}. + * By default the {@link SpanChipTokenizer} is used. + *

Chip Terminators

+ * To set which characters trigger the creation of a chip, call {@link #addChipTerminator(char, int)} or {@link #setChipTerminators(Map)}. + * For example if tapping enter should cause all unchipped text to become chipped, call + * {@code chipSuggestionTextView.addChipTerminator('\n', ChipTerminatorHandler.BEHAVIOR_CHIPIFY_ALL);} + * To completely customize how chips are created when text is entered in this text view you can provide a custom {@link ChipTerminatorHandler} + * through {@link #setChipTerminatorHandler(ChipTerminatorHandler)} + *

Illegal Characters

+ * To prevent a character from being typed you can call {@link #setIllegalCharacterIdentifier(IllegalCharacterIdentifier)}} to identify characters + * that should be considered illegal. + *

Suggestions

+ * To provide suggestions you must provide an {@link android.widget.Adapter} by calling {@link #setAdapter(ListAdapter)} + *

UI Customization

+ * This view defines six custom attributes (all of which are optional): + *
    + *
  • chipHorizontalSpacing - the horizontal space between chips
  • + *
  • chipBackground - the background color of the chip
  • + *
  • chipCornerRadius - the corner radius of the chip background
  • + *
  • chipTextColor - the color of the chip text
  • + *
  • chipTextSize - the font size of the chip text
  • + *
  • chipHeight - the height of a single chip
  • + *
  • chipVerticalSpacing - the vertical space between chips on consecutive lines + *
      + *
    • Note: chipVerticalSpacing is only used if a chipHeight is also set
    • + *
    + *
  • + *
+ * The values of these attributes will be passed to the ChipTokenizer through {@link ChipTokenizer#applyConfiguration(Editable, ChipConfiguration)} + *

Validation

+ * This class can perform validation when certain events occur (such as losing focus). When the validation occurs is decided by + * {@link AutoCompleteTextView}. To perform validation, set a {@link NachoValidator}: + *
+ *         nachoTextView.setNachoValidator(new ChipifyingNachoValidator());
+ *     
+ * Note: The NachoValidator will be ignored if a ChipTokenizer is not set. To perform validation without a ChipTokenizer you can use + * {@link AutoCompleteTextView}'s built-in {@link AutoCompleteTextView.Validator Validator} through {@link #setValidator(Validator)} + *

Editing Chips

+ * This class also supports editing chips on touch. To enable this behavior call {@link #enableEditChipOnTouch(boolean, boolean)}. To disable this + * behavior you can call {@link #disableEditChipOnTouch()} + *

Example Setup:

+ * A standard setup for this class could look something like the following: + *
+ *         String[] suggestions = new String[]{"suggestion 1", "suggestion 2"};
+ *         ArrayAdapter<String> adapter = new ArrayAdapter<>(this, android.R.layout.simple_dropdown_item_1line, suggestions);
+ *         nachoTextView.setAdapter(adapter);
+ *         nachoTextView.addChipTerminator('\n', ChipTerminatorHandler.BEHAVIOR_CHIPIFY_ALL);
+ *         nachoTextView.addChipTerminator(' ', ChipTerminatorHandler.BEHAVIOR_CHIPIFY_TO_TERMINATOR);
+ *         nachoTextView.setIllegalCharacters('@');
+ *         nachoTextView.setNachoValidator(new ChipifyingNachoValidator());
+ *         nachoTextView.enableEditChipOnTouch(true, true);
+ *         nachoTextView.setOnChipClickListener(new NachoTextView.OnChipClickListener() {
+ *            {@literal @Override}
+ *             public void onChipClick(Chip chip, MotionEvent motionEvent) {
+ *                 // Handle click event
+ *             }
+ *         });
+ *         nachoTextView.setOnChipRemoveListener(new NachoTextView.OnChipRemoveListener() {
+ *            {@literal @Override}
+ *             public void onChipRemove(Chip chip) {
+ *                 // Handle remove event
+ *             }
+ *         });
+ *     
+ * + * @see SpanChipTokenizer + * @see DefaultChipTerminatorHandler + * @see ChipifyingNachoValidator + */ +public class NachoTextView extends MultiAutoCompleteTextView implements TextWatcher, AdapterView.OnItemClickListener { + + // UI Attributes + private int mChipHorizontalSpacing = -1; + private ColorStateList mChipBackground = null; + private int mChipCornerRadius = -1; + private int mChipTextColor = Color.TRANSPARENT; + private int mChipTextSize = -1; + private int mChipHeight = -1; + private int mChipVerticalSpacing = -1; + + private int mDefaultPaddingTop = 0; + private int mDefaultPaddingBottom = 0; + /** + * Flag to keep track of the padding state so we only update the padding when necessary + */ + private boolean mUsingDefaultPadding = true; + + // Touch events + @Nullable + private OnChipClickListener mOnChipClickListener; + private GestureDetector singleTapDetector; + private boolean mEditChipOnTouchEnabled; + private boolean mMoveChipToEndOnEdit; + private boolean mChipifyUnterminatedTokensOnEdit; + + // Text entry + @Nullable + private ChipTokenizer mChipTokenizer; + @Nullable + private ChipTerminatorHandler mChipTerminatorHandler; + @Nullable + private NachoValidator mNachoValidator; + @Nullable + private IllegalCharacterIdentifier illegalCharacterIdentifier; + + @Nullable + private OnChipRemoveListener mOnChipRemoveListener; + private List mChipsToRemove = new ArrayList<>(); + private boolean mIgnoreTextChangedEvents; + private int mTextChangedStart; + private int mTextChangedEnd; + private boolean mIsPasteEvent; + + // Measurement + private boolean mMeasured; + + // Layout + private boolean mLayoutComplete; + + public NachoTextView(Context context) { + super(context); + init(null); + } + + public NachoTextView(Context context, AttributeSet attrs) { + super(context, attrs); + init(attrs); + } + + public NachoTextView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(attrs); + } + + private void init(@Nullable AttributeSet attrs) { + Context context = getContext(); + + if (attrs != null) { + TypedArray attributes = context.getTheme().obtainStyledAttributes( + attrs, + R.styleable.NachoTextView, + 0, + R.style.DefaultChipSuggestionTextView); + + try { + mChipHorizontalSpacing = attributes.getDimensionPixelSize(R.styleable.NachoTextView_chipHorizontalSpacing, -1); + mChipBackground = attributes.getColorStateList(R.styleable.NachoTextView_chipBackground); + mChipCornerRadius = attributes.getDimensionPixelSize(R.styleable.NachoTextView_chipCornerRadius, -1); + mChipTextColor = attributes.getColor(R.styleable.NachoTextView_chipTextColor, Color.TRANSPARENT); + mChipTextSize = attributes.getDimensionPixelSize(R.styleable.NachoTextView_chipTextSize, -1); + mChipHeight = attributes.getDimensionPixelSize(R.styleable.NachoTextView_chipHeight, -1); + mChipVerticalSpacing = attributes.getDimensionPixelSize(R.styleable.NachoTextView_chipVerticalSpacing, -1); + } finally { + attributes.recycle(); + } + } + + mDefaultPaddingTop = getPaddingTop(); + mDefaultPaddingBottom = getPaddingBottom(); + + singleTapDetector = new GestureDetector(getContext(), new SingleTapListener()); + + setImeOptions(EditorInfo.IME_FLAG_NO_FULLSCREEN); + addTextChangedListener(this); + setChipTokenizer(new SpanChipTokenizer<>(context, new ChipSpanChipCreator(), ChipSpan.class)); + setChipTerminatorHandler(new DefaultChipTerminatorHandler()); + setOnItemClickListener(this); + + updatePadding(); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + + if (!mMeasured && getWidth() > 0) { + // Refresh the tokenizer for width changes + invalidateChips(); + mMeasured = true; + } + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + super.onLayout(changed, left, top, right, bottom); + + if (!mLayoutComplete) { + invalidateChips(); + mLayoutComplete = true; + } + } + + /** + * Updates the padding based on whether or not any chips are present to avoid the view from changing heights when chips are inserted/deleted. + * Extra padding is added when there are no chips. When there are chips the padding is reverted to its defaults. This only affects top and bottom + * padding because the chips only affect the height of the view. + */ + private void updatePadding() { + if (mChipHeight != -1) { + boolean chipsArePresent = !getAllChips().isEmpty(); + if (!chipsArePresent && mUsingDefaultPadding) { + mUsingDefaultPadding = false; + Paint paint = getPaint(); + Paint.FontMetricsInt fm = paint.getFontMetricsInt(); + int textHeight = fm.descent - fm.ascent; + // Calculate how tall the view should be if there were chips + int newTextHeight = mChipHeight + (mChipVerticalSpacing != -1 ? mChipVerticalSpacing : 0); + // We need to add half our missing height above and below the text by increasing top and bottom padding + int paddingAdjustment = (newTextHeight - textHeight) / 2; + super.setPadding(getPaddingLeft(), mDefaultPaddingTop + paddingAdjustment, getPaddingRight(), mDefaultPaddingBottom + paddingAdjustment); + } else if (chipsArePresent && !mUsingDefaultPadding) { + // If there are chips we can revert to default padding + mUsingDefaultPadding = true; + super.setPadding(getPaddingLeft(), mDefaultPaddingTop, getPaddingRight(), mDefaultPaddingBottom); + } + } + } + + /** + * Sets the padding on this View. The left and right padding will be handled as they normally would in a TextView. The top and bottom padding passed + * here will be the padding that is used when there are one or more chips in the text view. When there are no chips present, the padding will be + * increased to make sure the overall height of the text view stays the same, since chips take up more vertical space than plain text. + * + * @param left the left padding in pixels + * @param top the top padding in pixels + * @param right the right padding in pixels + * @param bottom the bottom padding in pixels + */ + @Override + public void setPadding(int left, int top, int right, int bottom) { + // Call the super method so that left and right padding are updated + // top and bottom padding will be handled in updatePadding() + super.setPadding(left, top, right, bottom); + mDefaultPaddingTop = top; + mDefaultPaddingBottom = bottom; + updatePadding(); + } + + public int getChipHorizontalSpacing() { + return mChipHorizontalSpacing; + } + + public void setChipHorizontalSpacing(@DimenRes int chipHorizontalSpacingResId) { + mChipHorizontalSpacing = getContext().getResources().getDimensionPixelSize(chipHorizontalSpacingResId); + invalidateChips(); + } + + public ColorStateList getChipBackground() { + return mChipBackground; + } + + public void setChipBackgroundResource(@ColorRes int chipBackgroundResId) { + setChipBackground(getContext().getColorStateList(chipBackgroundResId)); + } + + public void setChipBackground(ColorStateList chipBackground) { + mChipBackground = chipBackground; + invalidateChips(); + } + + /** + * @return The chip background corner radius value, in pixels. + */ + @Dimension + public int getChipCornerRadius() { + return mChipCornerRadius; + } + + /** + * Sets the chip background corner radius. + * + * @param chipCornerRadiusResId The dimension resource with the corner radius value. + */ + public void setChipCornerRadiusResource(@DimenRes int chipCornerRadiusResId) { + setChipCornerRadius(getContext().getResources().getDimensionPixelSize(chipCornerRadiusResId)); + } + + /** + * Sets the chip background corner radius. + * + * @param chipCornerRadius The corner radius value, in pixels. + */ + public void setChipCornerRadius(@Dimension int chipCornerRadius) { + mChipCornerRadius = chipCornerRadius; + invalidateChips(); + } + + + public int getChipTextColor() { + return mChipTextColor; + } + + public void setChipTextColorResource(@ColorRes int chipTextColorResId) { + setChipTextColor(getContext().getColor(chipTextColorResId)); + } + + public void setChipTextColor(@ColorInt int chipTextColor) { + mChipTextColor = chipTextColor; + invalidateChips(); + } + + public int getChipTextSize() { + return mChipTextSize; + } + + public void setChipTextSize(@DimenRes int chipTextSizeResId) { + mChipTextSize = getContext().getResources().getDimensionPixelSize(chipTextSizeResId); + invalidateChips(); + } + + public int getChipHeight() { + return mChipHeight; + } + + public void setChipHeight(@DimenRes int chipHeightResId) { + mChipHeight = getContext().getResources().getDimensionPixelSize(chipHeightResId); + invalidateChips(); + } + + public int getChipVerticalSpacing() { + return mChipVerticalSpacing; + } + + public void setChipVerticalSpacing(@DimenRes int chipVerticalSpacingResId) { + mChipVerticalSpacing = getContext().getResources().getDimensionPixelSize(chipVerticalSpacingResId); + invalidateChips(); + } + + @Nullable + public ChipTokenizer getChipTokenizer() { + return mChipTokenizer; + } + + /** + * Sets the {@link ChipTokenizer} to be used by this ChipSuggestionTextView. + * Note that a Tokenizer set here will override any Tokenizer set by {@link #setTokenizer(Tokenizer)} + * + * @param chipTokenizer the {@link ChipTokenizer} to set + */ + public void setChipTokenizer(@Nullable ChipTokenizer chipTokenizer) { + mChipTokenizer = chipTokenizer; + if (mChipTokenizer != null) { + setTokenizer(new ChipTokenizerWrapper(mChipTokenizer)); + } else { + setTokenizer(null); + } + invalidateChips(); + } + + public void setOnChipClickListener(@Nullable OnChipClickListener onChipClickListener) { + mOnChipClickListener = onChipClickListener; + } + + public void setOnChipRemoveListener(@Nullable OnChipRemoveListener onChipRemoveListener) { + mOnChipRemoveListener = onChipRemoveListener; + } + + public void setChipTerminatorHandler(@Nullable ChipTerminatorHandler chipTerminatorHandler) { + mChipTerminatorHandler = chipTerminatorHandler; + } + + public void setNachoValidator(@Nullable NachoValidator nachoValidator) { + mNachoValidator = nachoValidator; + } + + /** + * @see ChipTerminatorHandler#setChipTerminators(Map) + */ + public void setChipTerminators(@Nullable Map chipTerminators) { + if (mChipTerminatorHandler != null) { + mChipTerminatorHandler.setChipTerminators(chipTerminators); + } + } + + /** + * @see ChipTerminatorHandler#addChipTerminator(char, int) + */ + public void addChipTerminator(char character, int behavior) { + if (mChipTerminatorHandler != null) { + mChipTerminatorHandler.addChipTerminator(character, behavior); + } + } + + /** + * @see ChipTerminatorHandler#setPasteBehavior(int) + */ + public void setPasteBehavior(int pasteBehavior) { + if (mChipTerminatorHandler != null) { + mChipTerminatorHandler.setPasteBehavior(pasteBehavior); + } + } + + /** + * Sets the {@link IllegalCharacterIdentifier} that will identify characters that should + * not show up in the field when typed (i.e. they will be deleted as soon as they are entered). + * If a character is listed as both a chip terminator character and an illegal character, + * it will be treated as an illegal character. + * + * @param illegalCharacterIdentifier the identifier to use + */ + public void setIllegalCharacterIdentifier(@Nullable IllegalCharacterIdentifier illegalCharacterIdentifier) { + this.illegalCharacterIdentifier = illegalCharacterIdentifier; + } + + /** + * Applies any updated configuration parameters to any existing chips and all future chips in the text view. + * + * @see ChipTokenizer#applyConfiguration(Editable, ChipConfiguration) + */ + public void invalidateChips() { + beginUnwatchedTextChange(); + + if (mChipTokenizer != null) { + Editable text = getText(); + int availableWidth = getWidth() - getCompoundPaddingLeft() - getCompoundPaddingRight(); + ChipConfiguration configuration = new ChipConfiguration( + mChipHorizontalSpacing, + mChipBackground, + mChipCornerRadius, + mChipTextColor, + mChipTextSize, + mChipHeight, + mChipVerticalSpacing, + availableWidth); + + mChipTokenizer.applyConfiguration(text, configuration); + } + + endUnwatchedTextChange(); + } + + /** + * Enables editing chips on touch events. When a touch event occurs, the touched chip will be put in editing mode. To later disable this behavior + * call {@link #disableEditChipOnTouch()}. + *

+ * Note: If an {@link OnChipClickListener} is set it's behavior will override the behavior described here if it's + * {@link OnChipClickListener#onChipClick(Chip, MotionEvent)} method returns true. If that method returns false, the touched chip will be put + * in editing mode as expected. + *

+ * + * @param moveChipToEnd if true, the chip will also be moved to the end of the text when it is put in editing mode + * @param chipifyUnterminatedTokens if true, all unterminated tokens will be chipified before the touched chip is put in editing mode + * @see #disableEditChipOnTouch() + */ + public void enableEditChipOnTouch(boolean moveChipToEnd, boolean chipifyUnterminatedTokens) { + mEditChipOnTouchEnabled = true; + mMoveChipToEndOnEdit = moveChipToEnd; + mChipifyUnterminatedTokensOnEdit = chipifyUnterminatedTokens; + } + + /** + * Disables editing chips on touch events. To re-enable this behavior call {@link #enableEditChipOnTouch(boolean, boolean)}. + * + * @see #enableEditChipOnTouch(boolean, boolean) + */ + public void disableEditChipOnTouch() { + mEditChipOnTouchEnabled = false; + } + + /** + * Puts the provided Chip in editing mode (i.e. reverts it to an unchipified token whose text can be edited). + * + * @param chip the chip to edit + * @param moveChipToEnd if true, the chip will also be moved to the end of the text + */ + public void setEditingChip(Chip chip, boolean moveChipToEnd) { + if (mChipTokenizer == null) { + return; + } + + beginUnwatchedTextChange(); + + Editable text = getText(); + if (moveChipToEnd) { + // Move the chip text to the end of the text + text.append(chip.getText()); + // Delete the existing chip + mChipTokenizer.deleteChipAndPadding(chip, text); + // Move the cursor to the end of the text + setSelection(text.length()); + } else { + int chipStart = mChipTokenizer.findChipStart(chip, text); + mChipTokenizer.revertChipToToken(chip, text); + setSelection(mChipTokenizer.findTokenEnd(text, chipStart)); + } + + endUnwatchedTextChange(); + } + + @Override + public boolean onTouchEvent(@NonNull MotionEvent event) { + boolean wasHandled = false; + clearChipStates(); + Chip touchedChip = findTouchedChip(event); + if (touchedChip != null && isFocused() && singleTapDetector.onTouchEvent(event)) { + touchedChip.setState(View.PRESSED_SELECTED_STATE_SET); + if (onChipClicked(touchedChip)) { + wasHandled = true; + } + if (mOnChipClickListener != null) { + mOnChipClickListener.onChipClick(touchedChip, event); + } + } + + // Getting NullPointerException inside Editor.updateFloatingToolbarVisibility (Editor.java:1520) + // primarily seen in Samsung Nougat devices. + boolean superOnTouch = false; + try { + superOnTouch = super.onTouchEvent(event); + } catch (NullPointerException e) { + Log.w("Nacho", String.format("Error during touch event of type [%d]", event.getAction()), e); + // can't handle or reproduce, but will monitor the error + } + + return wasHandled || superOnTouch; + } + + @Nullable + private Chip findTouchedChip(MotionEvent event) { + if (mChipTokenizer == null) { + return null; + } + + Editable text = getText(); + int offset = getOffsetForPosition(event.getX(), event.getY()); + List chips = getAllChips(); + for (Chip chip : chips) { + int chipStart = mChipTokenizer.findChipStart(chip, text); + int chipEnd = mChipTokenizer.findChipEnd(chip, text); // This is actually the index of the character just past the end of the chip + // When a touch event occurs getOffsetForPosition will either return the index of the first character of the span or the index of the + // character one past the end of the span + // This matches up perfectly with chipStart and chipEnd so we can just directly compare them... + if (chipStart <= offset && offset <= chipEnd) { + float startX = getXForIndex(chipStart); + float endX = getXForIndex(chipEnd - 1); + float eventX = event.getX(); + // ... however, when comparing the x coordinate we need to use (chipEnd - 1) because chipEnd will give us the x coordinate of the + // beginning of the next span since that is actually what chipEnd holds. We want the x coordinate of the end of the current span so + // we use (chipEnd - 1) + if (startX <= eventX && eventX <= endX) { + return chip; + } + } + } + return null; + } + + /** + * Implement this method to handle chip clicked events. + * + * @param chip the chip that was clicked + * @return true if the event was handled, otherwise false + */ + public boolean onChipClicked(Chip chip) { + boolean wasHandled = false; + if (mEditChipOnTouchEnabled) { + if (mChipifyUnterminatedTokensOnEdit) { + chipifyAllUnterminatedTokens(); + } + setEditingChip(chip, mMoveChipToEndOnEdit); + wasHandled = true; + } + return wasHandled; + } + + private float getXForIndex(int index) { + Layout layout = getLayout(); + return layout.getPrimaryHorizontal(index); + } + + private void clearChipStates() { + for (Chip chip : getAllChips()) { + chip.setState(View.EMPTY_STATE_SET); + } + } + + @Override + public boolean onTextContextMenuItem(int id) { + int start = getSelectionStart(); + int end = getSelectionEnd(); + switch (id) { + case android.R.id.cut: + try { + setClipboardData(ClipData.newPlainText(null, getTextWithPlainTextSpans(start, end))); + } catch (StringIndexOutOfBoundsException e) { + throw new StringIndexOutOfBoundsException( + String.format( + "%s \nError cutting text index [%s, %s] for text [%s] and substring [%s]", + e.getMessage(), + start, + end, + getText().toString(), + getText().subSequence(start, end))); + } + getText().delete(getSelectionStart(), getSelectionEnd()); + return true; + case android.R.id.copy: + try { + setClipboardData(ClipData.newPlainText(null, getTextWithPlainTextSpans(start, end))); + } catch (StringIndexOutOfBoundsException e) { + throw new StringIndexOutOfBoundsException( + String.format( + "%s \nError copying text index [%s, %s] for text [%s] and substring [%s]", + e.getMessage(), + start, + end, + getText().toString(), + getText().subSequence(start, end))); + } + return true; + case android.R.id.paste: + mIsPasteEvent = true; + boolean returnValue = super.onTextContextMenuItem(id); + mIsPasteEvent = false; + return returnValue; + default: + return super.onTextContextMenuItem(id); + } + } + + private void setClipboardData(ClipData clip) { + ClipboardManager clipboard = (ClipboardManager) getContext(). + getSystemService(Context.CLIPBOARD_SERVICE); + clipboard.setPrimaryClip(clip); + } + + /** + * If a {@link android.widget.AutoCompleteTextView.Validator Validator} was set, this method will validate the entire text. + * (Overrides the superclass method which only validates the current token) + */ + @Override + public void performValidation() { + if (mNachoValidator == null || mChipTokenizer == null) { + super.performValidation(); + return; + } + + CharSequence text = getText(); + if (!TextUtils.isEmpty(text) && !mNachoValidator.isValid(mChipTokenizer, text)) { + setRawText(mNachoValidator.fixText(mChipTokenizer, text)); + } + } + + /** + * From the point this method is called to when {@link #endUnwatchedTextChange()} is called, all TextChanged events will be ignored + */ + private void beginUnwatchedTextChange() { + mIgnoreTextChangedEvents = true; + } + + /** + * After this method is called TextChanged events will resume being handled. + * This method also calls {@link #updatePadding()} in case the unwatched changed created/destroyed chips + */ + private void endUnwatchedTextChange() { + updatePadding(); + mIgnoreTextChangedEvents = false; + } + + /** + * Sets the contents of this text view without performing any processing (nothing will be chipified, no characters will be removed etc.) + * + * @param text the text to set + */ + private void setRawText(CharSequence text) { + beginUnwatchedTextChange(); + super.setText(text); + endUnwatchedTextChange(); + } + + /** + * Sets the contents of this text view to contain the provided list of strings. The text view will be cleared then each string in the list will + * be chipified and appended to the text. + * + * @param chipValues the list of strings to chipify and set as the contents of the text view or null to clear the text view + */ + public void setText(@Nullable List chipValues) { + if (mChipTokenizer == null) { + return; + } + beginUnwatchedTextChange(); + + Editable text = getText(); + text.clear(); + + if (chipValues != null) { + for (String chipValue : chipValues) { + CharSequence chippedText = mChipTokenizer.terminateToken(chipValue, null); + text.append(chippedText); + } + } + setSelection(text.length()); + + endUnwatchedTextChange(); + } + + public void setTextWithChips(@Nullable List chips) { + if (mChipTokenizer == null) { + return; + } + beginUnwatchedTextChange(); + + Editable text = getText(); + text.clear(); + + if (chips != null) { + for (ChipInfo chipInfo : chips) { + CharSequence chippedText = mChipTokenizer.terminateToken(chipInfo.getText(), chipInfo.getData()); + text.append(chippedText); + } + } + setSelection(text.length()); + endUnwatchedTextChange(); + } + + @Override + public void onItemClick(AdapterView adapterView, View view, int position, long id) { + if (mChipTokenizer == null) { + return; + } + Adapter adapter = getAdapter(); + if (adapter == null) { + return; + } + beginUnwatchedTextChange(); + + Object data = getDataForSuggestion(adapter, position); + CharSequence text = getFilter().convertResultToString(adapter.getItem(position)); + + clearComposingText(); + int end = getSelectionEnd(); + Editable editable = getText(); + int start = mChipTokenizer.findTokenStart(editable, end); + + // guard against java.lang.StringIndexOutOfBoundsException + start = Math.min(Math.max(0, start), editable.length()); + end = Math.min(Math.max(0, end), editable.length()); + if (end < start) { + end = start; + } + + editable.replace(start, end, mChipTokenizer.terminateToken(text, data)); + + endUnwatchedTextChange(); + } + + /** + * Returns a object that will be associated with a chip that is about to be created for the item at {@code position} in {@code adapter} because that + * item was just tapped. + * + * @param adapter the adapter supplying the suggestions + * @param position the position of the suggestion that was tapped + * @return the data object + */ + protected Object getDataForSuggestion(@NonNull Adapter adapter, int position) { + return adapter.getItem(position); + } + + /** + * If there is a ChipTokenizer set, this method will do nothing. Instead we wait until the OnItemClickListener is triggered to actually perform + * the text replacement so we can also associate the suggestion data with it. + *

+ * If there is no ChipTokenizer set, we call through to the super method. + * + * @param text the text to be chipified + */ + @Override + protected void replaceText(CharSequence text) { + // If we have a ChipTokenizer, this will be handled by our OnItemClickListener so we can do nothing here. + // If we don't have a ChipTokenizer, we'll use the default behavior + if (mChipTokenizer == null) { + super.replaceText(text); + } + } + + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + if (mIgnoreTextChangedEvents) { + return; + } + + mTextChangedStart = start; + mTextChangedEnd = start + after; + + // Check for backspace + if (mChipTokenizer != null) { + if (count > 0 && after < count) { + int end = start + count; + Editable message = getText(); + Chip[] chips = mChipTokenizer.findAllChips(start, end, message); + + for (Chip chip : chips) { + int spanStart = mChipTokenizer.findChipStart(chip, message); + int spanEnd = mChipTokenizer.findChipEnd(chip, message); + if ((spanStart < end) && (spanEnd > start)) { + // Add to remove list + mChipsToRemove.add(chip); + } + } + } + } + } + + @Override + public void onTextChanged(@NonNull CharSequence textChanged, int start, int before, int count) { + } + + @Override + public void afterTextChanged(Editable message) { + if (mIgnoreTextChangedEvents) { + return; + } + + // Avoid triggering text changed events from changes we make in this method + beginUnwatchedTextChange(); + + // Handle backspace key + if (mChipTokenizer != null) { + Iterator iterator = mChipsToRemove.iterator(); + while (iterator.hasNext()) { + Chip chip = iterator.next(); + iterator.remove(); + mChipTokenizer.deleteChip(chip, message); + if (mOnChipRemoveListener != null) { + mOnChipRemoveListener.onChipRemove(chip); + } + } + } + + // Handle an illegal or chip terminator character + if (message.length() >= mTextChangedEnd && message.length() >= mTextChangedStart) { + handleTextChanged(mTextChangedStart, mTextChangedEnd); + } + + endUnwatchedTextChange(); + } + + private void handleTextChanged(int start, int end) { + if (start == end) { + // If start and end are the same there was text deleted, so this type of event can be ignored + return; + } + + // First remove any illegal characters + Editable text = getText(); + CharSequence subText = text.subSequence(start, end); + CharSequence withoutIllegalCharacters = removeIllegalCharacters(subText); + + // Check if illegal characters were found + if (withoutIllegalCharacters.length() < subText.length()) { + text.replace(start, end, withoutIllegalCharacters); + end = start + withoutIllegalCharacters.length(); + clearComposingText(); + } + + if (start == end) { + // If start and end are the same here, it means only illegal characters were inserted so there's nothing left to do + return; + } + + // Then handle chip terminator characters + if (mChipTokenizer != null && mChipTerminatorHandler != null) { + int newSelectionIndex = mChipTerminatorHandler.findAndHandleChipTerminators(mChipTokenizer, getText(), start, end, mIsPasteEvent); + if (newSelectionIndex > 0) { + setSelection(newSelectionIndex); + } + } + } + + private CharSequence removeIllegalCharacters(CharSequence text) { + StringBuilder newText = new StringBuilder(); + + for (int i = 0; i < text.length(); i++) { + char theChar = text.charAt(i); + if (!isIllegalCharacter(theChar)) { + newText.append(theChar); + } + } + + return newText; + } + + private boolean isIllegalCharacter(char character) { + if (illegalCharacterIdentifier != null) { + return illegalCharacterIdentifier.isCharacterIllegal(character); + } + return false; + } + + /** + * Chipifies all existing plain text in the field + */ + public void chipifyAllUnterminatedTokens() { + beginUnwatchedTextChange(); + chipifyAllUnterminatedTokens(getText()); + endUnwatchedTextChange(); + } + + private void chipifyAllUnterminatedTokens(Editable text) { + if (mChipTokenizer != null) { + mChipTokenizer.terminateAllTokens(text); + } + } + + /** + * Replaces the text from start (inclusive) to end (exclusive) with a chip + * containing the same text + * + * @param start the index of the first character to replace + * @param end one more than the index of the last character to replace + */ + public void chipify(int start, int end) { + beginUnwatchedTextChange(); + chipify(start, end, getText(), null); + endUnwatchedTextChange(); + } + + private void chipify(int start, int end, Editable text, Object data) { + if (mChipTokenizer != null) { + CharSequence textToChip = text.subSequence(start, end); + CharSequence chippedText = mChipTokenizer.terminateToken(textToChip, data); + text.replace(start, end, chippedText); + } + } + + private CharSequence getTextWithPlainTextSpans(int start, int end) { + Editable editable = getText(); + String selectedText = editable.subSequence(start, end).toString(); + + if (mChipTokenizer != null) { + List chips = Arrays.asList(mChipTokenizer.findAllChips(start, end, editable)); + Collections.reverse(chips); + for (Chip chip : chips) { + String chipText = chip.getText().toString(); + int chipStart = mChipTokenizer.findChipStart(chip, editable) - start; + int chipEnd = mChipTokenizer.findChipEnd(chip, editable) - start; + selectedText = selectedText.substring(0, chipStart) + chipText + selectedText.substring(chipEnd, selectedText.length()); + } + } + return selectedText; + } + + /** + * @return all of the chips currently in the text view - this does not include any unchipped text + */ + @NonNull + public List getAllChips() { + Editable text = getText(); + return mChipTokenizer != null ? Arrays.asList(mChipTokenizer.findAllChips(0, text.length(), text)) : new ArrayList(); + } + + /** + * Returns a List of the string values of all the chips in the text (obtained through {@link Chip#getText()}). + * This does not include the text of any unterminated tokens. + * + * @return the List of chip values + */ + @NonNull + public List getChipValues() { + List chipValues = new ArrayList<>(); + + List chips = getAllChips(); + for (Chip chip : chips) { + chipValues.add(chip.getText().toString()); + } + + return chipValues; + } + + /** + * Returns a List of the string values of all the tokens (unchipped text) in the text + * (obtained through {@link ChipTokenizer#findAllTokens(CharSequence)}). This does not include any chipped text. + * + * @return the List of token values + */ + @NonNull + public List getTokenValues() { + List tokenValues = new ArrayList<>(); + + if (mChipTokenizer != null) { + Editable text = getText(); + List> unterminatedTokenIndexes = mChipTokenizer.findAllTokens(text); + for (Pair indexes : unterminatedTokenIndexes) { + String tokenValue = text.subSequence(indexes.first, indexes.second).toString(); + tokenValues.add(tokenValue); + } + } + + return tokenValues; + } + + /** + * Returns a combination of the chip values and token values in the text. + * + * @return the List of all chip and token values + * @see #getChipValues() + * @see #getTokenValues() + */ + @NonNull + public List getChipAndTokenValues() { + List chipAndTokenValues = new ArrayList<>(); + chipAndTokenValues.addAll(getChipValues()); + chipAndTokenValues.addAll(getTokenValues()); + return chipAndTokenValues; + } + + @Override + public String toString() { + try { + return getTextWithPlainTextSpans(0, getText().length()).toString(); + } catch (ClassCastException ex) { // Exception is thrown by cast in getText() on some LG devices + return super.toString(); + } catch (StringIndexOutOfBoundsException e) { + throw new StringIndexOutOfBoundsException(String.format("%s \nError converting toString() [%s]", e.getMessage(), getText().toString())); + } + } + + private class ChipTokenizerWrapper implements Tokenizer { + + @NonNull + private ChipTokenizer mChipTokenizer; + + public ChipTokenizerWrapper(@NonNull ChipTokenizer chipTokenizer) { + mChipTokenizer = chipTokenizer; + } + + @Override + public int findTokenStart(CharSequence text, int cursor) { + return mChipTokenizer.findTokenStart(text, cursor); + } + + @Override + public int findTokenEnd(CharSequence text, int cursor) { + return mChipTokenizer.findTokenEnd(text, cursor); + } + + @Override + public CharSequence terminateToken(CharSequence text) { + return mChipTokenizer.terminateToken(text, null); + } + } + + public interface OnChipClickListener { + + /** + * Called when a chip in this TextView is touched. This callback is triggered by the {@link MotionEvent#ACTION_UP} event. + * + * @param chip the {@link Chip} that was touched + * @param event the {@link MotionEvent} that caused the touch + */ + void onChipClick(Chip chip, MotionEvent event); + } + + public interface OnChipRemoveListener { + + /** + * Called when a chip in this TextView is removed + * + * @param chip the {@link Chip} that was removed + */ + void onChipRemove(Chip chip); + } + + private class SingleTapListener extends GestureDetector.SimpleOnGestureListener { + + /** + * @param e the {@link MotionEvent} passed to the GestureDetector + * @return true if singleTapUp (click) was detected + */ + @Override + public boolean onSingleTapUp(MotionEvent e) { + return true; + } + } +} diff --git a/mastodon/src/main/java/com/hootsuite/nachos/chip/Chip.java b/mastodon/src/main/java/com/hootsuite/nachos/chip/Chip.java new file mode 100644 index 000000000..f07707cd3 --- /dev/null +++ b/mastodon/src/main/java/com/hootsuite/nachos/chip/Chip.java @@ -0,0 +1,29 @@ +package com.hootsuite.nachos.chip; + +import androidx.annotation.Nullable; + +public interface Chip { + + /** + * @return the text represented by this Chip + */ + CharSequence getText(); + + /** + * @return the data associated with this Chip or null if no data is associated with it + */ + @Nullable + Object getData(); + + /** + * @return the width of the Chip or -1 if the Chip hasn't been given the chance to calculate its width + */ + int getWidth(); + + /** + * Sets the UI state. + * + * @param stateSet one of the state constants in {@link android.view.View} + */ + void setState(int[] stateSet); +} diff --git a/mastodon/src/main/java/com/hootsuite/nachos/chip/ChipCreator.java b/mastodon/src/main/java/com/hootsuite/nachos/chip/ChipCreator.java new file mode 100644 index 000000000..c38f5cff1 --- /dev/null +++ b/mastodon/src/main/java/com/hootsuite/nachos/chip/ChipCreator.java @@ -0,0 +1,44 @@ +package com.hootsuite.nachos.chip; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.hootsuite.nachos.ChipConfiguration; + +/** + * Interface to allow the creation and configuration of chips + * + * @param The type of {@link Chip} that the implementation will create/configure + */ +public interface ChipCreator { + + /** + * Creates a chip from the given context and text. Use this method when creating a brand new chip from a piece of text. + * + * @param context the {@link Context} to use to initialize the chip + * @param text the text the Chip should represent + * @param data the data to associate with the Chip, or null to associate no data + * @return the created chip + */ + C createChip(@NonNull Context context, @NonNull CharSequence text, @Nullable Object data); + + /** + * Creates a chip from the given context and existing chip. Use this method when recreating a chip from an existing one. + * + * @param context the {@link Context} to use to initialize the chip + * @param existingChip the chip that the created chip should be based on + * @return the created chip + */ + C createChip(@NonNull Context context, @NonNull C existingChip); + + /** + * Applies the given {@link ChipConfiguration} to the given {@link Chip}. Use this method to customize the appearance/behavior of a chip before + * adding it to the text. + * + * @param chip the chip to configure + * @param chipConfiguration the configuration to apply to the chip + */ + void configureChip(@NonNull C chip, @NonNull ChipConfiguration chipConfiguration); +} diff --git a/mastodon/src/main/java/com/hootsuite/nachos/chip/ChipInfo.java b/mastodon/src/main/java/com/hootsuite/nachos/chip/ChipInfo.java new file mode 100644 index 000000000..69b8ea4c4 --- /dev/null +++ b/mastodon/src/main/java/com/hootsuite/nachos/chip/ChipInfo.java @@ -0,0 +1,20 @@ +package com.hootsuite.nachos.chip; + +public class ChipInfo { + + private final CharSequence mText; + private final Object mData; + + public ChipInfo(CharSequence text, Object data) { + this.mText = text; + this.mData = data; + } + + public CharSequence getText() { + return mText; + } + + public Object getData() { + return mData; + } +} diff --git a/mastodon/src/main/java/com/hootsuite/nachos/chip/ChipSpan.java b/mastodon/src/main/java/com/hootsuite/nachos/chip/ChipSpan.java new file mode 100644 index 000000000..e3cc57922 --- /dev/null +++ b/mastodon/src/main/java/com/hootsuite/nachos/chip/ChipSpan.java @@ -0,0 +1,510 @@ +package com.hootsuite.nachos.chip; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Rect; +import android.graphics.RectF; +import android.graphics.drawable.Drawable; +import android.text.style.ImageSpan; + +import androidx.annotation.Dimension; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.joinmastodon.android.R; + +/** + * A Span that displays text and an optional icon inside of a material design chip. The chip's dimensions, colors etc. can be extensively customized + * through the various setter methods available in this class. + * The basic structure of the chip is the following: + * For chips with the icon on right: + *

+ *
+ *                                  (chip vertical spacing / 2)
+ *                  ----------------------------------------------------------
+ *                |                                                            |
+ * (left margin)  |  (padding edge)   text   (padding between image)   icon    |   (right margin)
+ *                |                                                            |
+ *                  ----------------------------------------------------------
+ *                                  (chip vertical spacing / 2)
+ *
+ *      
+ * For chips with the icon on the left (see {@link #setShowIconOnLeft(boolean)}): + *
+ *
+ *                                  (chip vertical spacing / 2)
+ *                  ----------------------------------------------------------
+ *                |                                                            |
+ * (left margin)  |   icon  (padding between image)   text   (padding edge)    |   (right margin)
+ *                |                                                            |
+ *                  ----------------------------------------------------------
+ *                                  (chip vertical spacing / 2)
+ *     
+ */ +public class ChipSpan extends ImageSpan implements Chip { + + private static final float SCALE_PERCENT_OF_CHIP_HEIGHT = 0.70f; + private static final boolean ICON_ON_LEFT_DEFAULT = true; + + private int[] mStateSet = new int[]{}; + + private String mEllipsis; + + private ColorStateList mDefaultBackgroundColor; + private ColorStateList mBackgroundColor; + private int mTextColor; + private int mCornerRadius = -1; + private int mIconBackgroundColor; + + private int mTextSize = -1; + private int mPaddingEdgePx; + private int mPaddingBetweenImagePx; + private int mLeftMarginPx; + private int mRightMarginPx; + private int mMaxAvailableWidth = -1; + + private CharSequence mText; + private String mTextToDraw; + + private Drawable mIcon; + private boolean mShowIconOnLeft = ICON_ON_LEFT_DEFAULT; + + private int mChipVerticalSpacing = 0; + private int mChipHeight = -1; + private int mChipWidth = -1; + private int mIconWidth; + + private int mCachedSize = -1; + + private Object mData; + + /** + * Constructs a new ChipSpan. + * + * @param context a {@link Context} that will be used to retrieve default configurations from resource files + * @param text the text for the ChipSpan to display + * @param icon an optional icon (can be {@code null}) for the ChipSpan to display + */ + public ChipSpan(@NonNull Context context, @NonNull CharSequence text, @Nullable Drawable icon, Object data) { + super(icon); + mIcon = icon; + mText = text; + mTextToDraw = mText.toString(); + + mEllipsis = context.getString(R.string.chip_ellipsis); + + mDefaultBackgroundColor = context.getColorStateList(R.color.chip_material_background); + mBackgroundColor = mDefaultBackgroundColor; + + mTextColor = context.getColor(R.color.chip_default_text_color); + mIconBackgroundColor = context.getColor(R.color.chip_default_icon_background_color); + + Resources resources = context.getResources(); + mPaddingEdgePx = resources.getDimensionPixelSize(R.dimen.chip_default_padding_edge); + mPaddingBetweenImagePx = resources.getDimensionPixelSize(R.dimen.chip_default_padding_between_image); + mLeftMarginPx = resources.getDimensionPixelSize(R.dimen.chip_default_left_margin); + mRightMarginPx = resources.getDimensionPixelSize(R.dimen.chip_default_right_margin); + + mData = data; + } + + /** + * Copy constructor to recreate a ChipSpan from an existing one + * + * @param context a {@link Context} that will be used to retrieve default configurations from resource files + * @param chipSpan the ChipSpan to copy + */ + public ChipSpan(@NonNull Context context, @NonNull ChipSpan chipSpan) { + this(context, chipSpan.getText(), chipSpan.getDrawable(), chipSpan.getData()); + + mDefaultBackgroundColor = chipSpan.mDefaultBackgroundColor; + mTextColor = chipSpan.mTextColor; + mIconBackgroundColor = chipSpan.mIconBackgroundColor; + mCornerRadius = chipSpan.mCornerRadius; + + mTextSize = chipSpan.mTextSize; + mPaddingEdgePx = chipSpan.mPaddingEdgePx; + mPaddingBetweenImagePx = chipSpan.mPaddingBetweenImagePx; + mLeftMarginPx = chipSpan.mLeftMarginPx; + mRightMarginPx = chipSpan.mRightMarginPx; + mMaxAvailableWidth = chipSpan.mMaxAvailableWidth; + + mShowIconOnLeft = chipSpan.mShowIconOnLeft; + + mChipVerticalSpacing = chipSpan.mChipVerticalSpacing; + mChipHeight = chipSpan.mChipHeight; + + mStateSet = chipSpan.mStateSet; + } + + @Override + public Object getData() { + return mData; + } + + /** + * Sets the height of the chip. This height should not include any extra spacing (for extra vertical spacing call {@link #setChipVerticalSpacing(int)}). + * The background of the chip will fill the full height provided here. If this method is never called, the chip will have the height of one full line + * of text by default. If {@code -1} is passed here, the chip will revert to this default behavior. + * + * @param chipHeight the height to set in pixels + */ + public void setChipHeight(int chipHeight) { + mChipHeight = chipHeight; + } + + /** + * Sets the vertical spacing to include in between chips. Half of the value set here will be placed as empty space above the chip and half the value + * will be placed as empty space below the chip. Therefore chips on consecutive lines will have the full value as vertical space in between them. + * This spacing is achieved by adjusting the font metrics used by the text view containing these chips; however it does not come into effect until + * at least one chip is created. Note that vertical spacing is dependent on having a fixed chip height (set in {@link #setChipHeight(int)}). If a + * height is not specified in that method, the value set here will be ignored. + * + * @param chipVerticalSpacing the vertical spacing to set in pixels + */ + public void setChipVerticalSpacing(int chipVerticalSpacing) { + mChipVerticalSpacing = chipVerticalSpacing; + } + + /** + * Sets the font size for the chip's text. If this method is never called, the chip text will have the same font size as the text in the TextView + * containing this chip by default. If {@code -1} is passed here, the chip will revert to this default behavior. + * + * @param size the font size to set in pixels + */ + public void setTextSize(int size) { + mTextSize = size; + invalidateCachedSize(); + } + + /** + * Sets the color for the chip's text. + * + * @param color the color to set (as a hexadecimal number in the form 0xAARRGGBB) + */ + public void setTextColor(int color) { + mTextColor = color; + } + + /** + * Sets where the icon (if an icon was provided in the constructor) will appear. + * + * @param showIconOnLeft if true, the icon will appear on the left, otherwise the icon will appear on the right + */ + public void setShowIconOnLeft(boolean showIconOnLeft) { + this.mShowIconOnLeft = showIconOnLeft; + invalidateCachedSize(); + } + + /** + * Sets the left margin. This margin will appear as empty space (it will not share the chip's background color) to the left of the chip. + * + * @param leftMarginPx the left margin to set in pixels + */ + public void setLeftMargin(int leftMarginPx) { + mLeftMarginPx = leftMarginPx; + invalidateCachedSize(); + } + + /** + * Sets the right margin. This margin will appear as empty space (it will not share the chip's background color) to the right of the chip. + * + * @param rightMarginPx the right margin to set in pixels + */ + public void setRightMargin(int rightMarginPx) { + this.mRightMarginPx = rightMarginPx; + invalidateCachedSize(); + } + + /** + * Sets the background color. To configure which color in the {@link ColorStateList} is shown you can call {@link #setState(int[])}. + * Passing {@code null} here will cause the chip to revert to it's default background. + * + * @param backgroundColor a {@link ColorStateList} containing backgrounds for different states. + * @see #setState(int[]) + */ + public void setBackgroundColor(@Nullable ColorStateList backgroundColor) { + mBackgroundColor = backgroundColor != null ? backgroundColor : mDefaultBackgroundColor; + } + + /** + * Sets the chip background corner radius. + * + * @param cornerRadius The corner radius value, in pixels. + */ + public void setCornerRadius(@Dimension int cornerRadius) { + mCornerRadius = cornerRadius; + } + + /** + * Sets the icon background color. This is the color of the circle that gets drawn behind the icon passed to the + * {@link #ChipSpan(Context, CharSequence, Drawable, Object)} constructor} + * + * @param iconBackgroundColor the icon background color to set (as a hexadecimal number in the form 0xAARRGGBB) + */ + public void setIconBackgroundColor(int iconBackgroundColor) { + mIconBackgroundColor = iconBackgroundColor; + } + + public void setMaxAvailableWidth(int maxAvailableWidth) { + mMaxAvailableWidth = maxAvailableWidth; + invalidateCachedSize(); + } + + /** + * Sets the UI state. This state will be reflected in the background color drawn for the chip. + * + * @param stateSet one of the state constants in {@link android.view.View} + * @see #setBackgroundColor(ColorStateList) + */ + @Override + public void setState(int[] stateSet) { + this.mStateSet = stateSet != null ? stateSet : new int[]{}; + } + + @Override + public CharSequence getText() { + return mText; + } + + @Override + public int getWidth() { + // If we haven't actually calculated a chip width yet just return -1, otherwise return the chip width + margins + return mChipWidth != -1 ? (mLeftMarginPx + mChipWidth + mRightMarginPx) : -1; + } + + @Override + public int getSize(Paint paint, CharSequence text, int start, int end, Paint.FontMetricsInt fm) { + boolean usingFontMetrics = (fm != null); + + // Adjust the font metrics regardless of whether or not there is a cached size so that the text view can maintain its height + if (usingFontMetrics) { + adjustFontMetrics(paint, fm); + } + + if (mCachedSize == -1 && usingFontMetrics) { + mIconWidth = (mIcon != null) ? calculateChipHeight(fm.top, fm.bottom) : 0; + + int actualWidth = calculateActualWidth(paint); + mCachedSize = actualWidth; + + if (mMaxAvailableWidth != -1) { + int maxAvailableWidthMinusMargins = mMaxAvailableWidth - mLeftMarginPx - mRightMarginPx; + if (actualWidth > maxAvailableWidthMinusMargins) { + mTextToDraw = mText + mEllipsis; + + while ((calculateActualWidth(paint) > maxAvailableWidthMinusMargins) && mTextToDraw.length() > 0) { + int lastCharacterIndex = mTextToDraw.length() - mEllipsis.length() - 1; + if (lastCharacterIndex < 0) { + break; + } + mTextToDraw = mTextToDraw.substring(0, lastCharacterIndex) + mEllipsis; + } + + // Avoid a negative width + mChipWidth = Math.max(0, maxAvailableWidthMinusMargins); + mCachedSize = mMaxAvailableWidth; + } + } + } + + return mCachedSize; + } + + private int calculateActualWidth(Paint paint) { + // Only change the text size if a text size was set + if (mTextSize != -1) { + paint.setTextSize(mTextSize); + } + + int totalPadding = mPaddingEdgePx; + + // Find text width + Rect bounds = new Rect(); + paint.getTextBounds(mTextToDraw, 0, mTextToDraw.length(), bounds); + int textWidth = bounds.width(); + + if (mIcon != null) { + totalPadding += mPaddingBetweenImagePx; + } else { + totalPadding += mPaddingEdgePx; + } + + mChipWidth = totalPadding + textWidth + mIconWidth; + return getWidth(); + } + + public void invalidateCachedSize() { + mCachedSize = -1; + } + + /** + * Adjusts the provided font metrics to make it seem like the font takes up {@code mChipHeight + mChipVerticalSpacing} pixels in height. + * This effectively ensures that the TextView will have a height equal to {@code mChipHeight + mChipVerticalSpacing} + whatever padding it has set. + * In {@link #draw(Canvas, CharSequence, int, int, float, int, int, int, Paint)} the chip itself is drawn to that it is vertically centered with + * {@code mChipVerticalSpacing / 2} pixels of space above and below it + * + * @param paint the paint whose font metrics should be adjusted + * @param fm the font metrics object to populate through {@link Paint#getFontMetricsInt(Paint.FontMetricsInt)} + */ + private void adjustFontMetrics(Paint paint, Paint.FontMetricsInt fm) { + // Only actually adjust font metrics if we have a chip height set + if (mChipHeight != -1) { + paint.getFontMetricsInt(fm); + int textHeight = fm.descent - fm.ascent; + // Break up the vertical spacing in half because half will go above the chip, half will go below the chip + int halfSpacing = mChipVerticalSpacing / 2; + + // Given that the text is centered vertically within the chip, the amount of space above or below the text (inbetween the text and chip) + // is half their difference in height: + int spaceBetweenChipAndText = (mChipHeight - textHeight) / 2; + + int textTop = fm.top; + int chipTop = fm.top - spaceBetweenChipAndText; + + int textBottom = fm.bottom; + int chipBottom = fm.bottom + spaceBetweenChipAndText; + + // The text may have been taller to begin with so we take the most negative coordinate (highest up) to be the top of the content + int topOfContent = Math.min(textTop, chipTop); + // Same as above but we want the largest positive coordinate (lowest down) to be the bottom of the content + int bottomOfContent = Math.max(textBottom, chipBottom); + + // Shift the top up by halfSpacing and the bottom down by halfSpacing + int topOfContentWithSpacing = topOfContent - halfSpacing; + int bottomOfContentWithSpacing = bottomOfContent + halfSpacing; + + // Change the font metrics so that the TextView thinks the font takes up the vertical space of a chip + spacing + fm.ascent = topOfContentWithSpacing; + fm.descent = bottomOfContentWithSpacing; + fm.top = topOfContentWithSpacing; + fm.bottom = bottomOfContentWithSpacing; + } + } + + private int calculateChipHeight(int top, int bottom) { + // If a chip height was set we can return that, otherwise calculate it from top and bottom + return mChipHeight != -1 ? mChipHeight : bottom - top; + } + + @Override + public void draw(Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, Paint paint) { + // Shift everything mLeftMarginPx to the left to create an empty space on the left (creating the margin) + x += mLeftMarginPx; + if (mChipHeight != -1) { + // If we set a chip height, adjust to vertically center chip in the line + // Adding (bottom - top) / 2 shifts the chip down so the top of it will be centered vertically + // Subtracting (mChipHeight / 2) shifts the chip back up so that the center of it will be centered vertically (as desired) + top += ((bottom - top) / 2) - (mChipHeight / 2); + bottom = top + mChipHeight; + } + + // Perform actual drawing + drawBackground(canvas, x, top, bottom, paint); + drawText(canvas, x, top, bottom, paint, mTextToDraw); + if (mIcon != null) { + drawIcon(canvas, x, top, bottom, paint); + } + } + + private void drawBackground(Canvas canvas, float x, int top, int bottom, Paint paint) { + int backgroundColor = mBackgroundColor.getColorForState(mStateSet, mBackgroundColor.getDefaultColor()); + paint.setColor(backgroundColor); + int height = calculateChipHeight(top, bottom); + RectF rect = new RectF(x, top, x + mChipWidth, bottom); + int cornerRadius = (mCornerRadius != -1) ? mCornerRadius : height / 2; + canvas.drawRoundRect(rect, cornerRadius, cornerRadius, paint); + paint.setColor(mTextColor); + } + + private void drawText(Canvas canvas, float x, int top, int bottom, Paint paint, CharSequence text) { + if (mTextSize != -1) { + paint.setTextSize(mTextSize); + } + int height = calculateChipHeight(top, bottom); + Paint.FontMetrics fm = paint.getFontMetrics(); + + // The top value provided here is the y coordinate for the very top of the chip + // The y coordinate we are calculating is where the baseline of the text will be drawn + // Our objective is to have the midpoint between the top and baseline of the text be in line with the vertical center of the chip + // First we add height / 2 which will put the baseline at the vertical center of the chip + // Then we add half the height of the text which will lower baseline so that the midpoint is at the vertical center of the chip as desired + float adjustedY = top + ((height / 2) + ((-fm.top - fm.bottom) / 2)); + + // The x coordinate provided here is the left-most edge of the chip + // If there is no icon or the icon is on the right, then the text will start at the left-most edge, but indented with the edge padding, so we + // add mPaddingEdgePx + // If there is an icon and it's on the left, the text will start at the left-most edge, but indented by the combined width of the icon and + // the padding between the icon and text, so we add (mIconWidth + mPaddingBetweenImagePx) + float adjustedX = x + ((mIcon == null || !mShowIconOnLeft) ? mPaddingEdgePx : (mIconWidth + mPaddingBetweenImagePx)); + + canvas.drawText(text, 0, text.length(), adjustedX, adjustedY, paint); + } + + private void drawIcon(Canvas canvas, float x, int top, int bottom, Paint paint) { + drawIconBackground(canvas, x, top, bottom, paint); + drawIconBitmap(canvas, x, top, bottom, paint); + } + + private void drawIconBackground(Canvas canvas, float x, int top, int bottom, Paint paint) { + int height = calculateChipHeight(top, bottom); + + paint.setColor(mIconBackgroundColor); + + // Since it's a circle the diameter is equal to the height, so the radius == diameter / 2 == height / 2 + int radius = height / 2; + // The coordinates that get passed to drawCircle are for the center of the circle + // x is the left edge of the chip, (x + mChipWidth) is the right edge of the chip + // So the center of the circle is one radius distance from either the left or right edge (depending on which side the icon is being drawn on) + float circleX = mShowIconOnLeft ? (x + radius) : (x + mChipWidth - radius); + // The y coordinate is always just one radius distance from the top + canvas.drawCircle(circleX, top + radius, radius, paint); + + paint.setColor(mTextColor); + } + + private void drawIconBitmap(Canvas canvas, float x, int top, int bottom, Paint paint) { + int height = calculateChipHeight(top, bottom); + + // Create a scaled down version of the bitmap to fit within the circle (whose diameter == height) + Bitmap iconBitmap = Bitmap.createBitmap(mIcon.getIntrinsicWidth(), mIcon.getIntrinsicHeight(), Bitmap.Config.ARGB_8888); + Bitmap scaledIconBitMap = scaleDown(iconBitmap, (float) height * SCALE_PERCENT_OF_CHIP_HEIGHT, true); + iconBitmap.recycle(); + Canvas bitmapCanvas = new Canvas(scaledIconBitMap); + mIcon.setBounds(0, 0, bitmapCanvas.getWidth(), bitmapCanvas.getHeight()); + mIcon.draw(bitmapCanvas); + + // We are drawing a square icon inside of a circle + // The coordinates we pass to canvas.drawBitmap have to be for the top-left corner of the bitmap + // The bitmap should be inset by half of (circle width - bitmap width) + // Since it's a circle, the circle's width is equal to it's height which is equal to the chip height + float xInsetWithinCircle = (height - bitmapCanvas.getWidth()) / 2; + + // The icon x coordinate is going to be insetWithinCircle pixels away from the left edge of the circle + // If the icon is on the left, the left edge of the circle is just x + // If the icon is on the right, the left edge of the circle is x + mChipWidth - height + float iconX = mShowIconOnLeft ? (x + xInsetWithinCircle) : (x + mChipWidth - height + xInsetWithinCircle); + + // The y coordinate works the same way (only it's always from the top edge) + float yInsetWithinCircle = (height - bitmapCanvas.getHeight()) / 2; + float iconY = top + yInsetWithinCircle; + + canvas.drawBitmap(scaledIconBitMap, iconX, iconY, paint); + } + + private Bitmap scaleDown(Bitmap realImage, float maxImageSize, boolean filter) { + float ratio = Math.min(maxImageSize / realImage.getWidth(), maxImageSize / realImage.getHeight()); + int width = Math.round(ratio * realImage.getWidth()); + int height = Math.round(ratio * realImage.getHeight()); + return Bitmap.createScaledBitmap(realImage, width, height, filter); + } + + @Override + public String toString() { + return mText.toString(); + } +} diff --git a/mastodon/src/main/java/com/hootsuite/nachos/chip/ChipSpanChipCreator.java b/mastodon/src/main/java/com/hootsuite/nachos/chip/ChipSpanChipCreator.java new file mode 100644 index 000000000..8e0278f0c --- /dev/null +++ b/mastodon/src/main/java/com/hootsuite/nachos/chip/ChipSpanChipCreator.java @@ -0,0 +1,60 @@ +package com.hootsuite.nachos.chip; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.graphics.Color; + +import androidx.annotation.NonNull; + +import com.hootsuite.nachos.ChipConfiguration; + +public class ChipSpanChipCreator implements ChipCreator { + + @Override + public ChipSpan createChip(@NonNull Context context, @NonNull CharSequence text, Object data) { + return new ChipSpan(context, text, null, data); + } + + @Override + public ChipSpan createChip(@NonNull Context context, @NonNull ChipSpan existingChip) { + return new ChipSpan(context, existingChip); + } + + @Override + public void configureChip(@NonNull ChipSpan chip, @NonNull ChipConfiguration chipConfiguration) { + int chipHorizontalSpacing = chipConfiguration.getChipHorizontalSpacing(); + ColorStateList chipBackground = chipConfiguration.getChipBackground(); + int chipCornerRadius = chipConfiguration.getChipCornerRadius(); + int chipTextColor = chipConfiguration.getChipTextColor(); + int chipTextSize = chipConfiguration.getChipTextSize(); + int chipHeight = chipConfiguration.getChipHeight(); + int chipVerticalSpacing = chipConfiguration.getChipVerticalSpacing(); + int maxAvailableWidth = chipConfiguration.getMaxAvailableWidth(); + + if (chipHorizontalSpacing != -1) { + chip.setLeftMargin(chipHorizontalSpacing / 2); + chip.setRightMargin(chipHorizontalSpacing / 2); + } + if (chipBackground != null) { + chip.setBackgroundColor(chipBackground); + } + if (chipCornerRadius != -1) { + chip.setCornerRadius(chipCornerRadius); + } + if (chipTextColor != Color.TRANSPARENT) { + chip.setTextColor(chipTextColor); + } + if (chipTextSize != -1) { + chip.setTextSize(chipTextSize); + } + if (chipHeight != -1) { + chip.setChipHeight(chipHeight); + } + if (chipVerticalSpacing != -1) { + chip.setChipVerticalSpacing(chipVerticalSpacing); + } + if (maxAvailableWidth != -1) { + chip.setMaxAvailableWidth(maxAvailableWidth); + } + } +} diff --git a/mastodon/src/main/java/com/hootsuite/nachos/terminator/ChipTerminatorHandler.java b/mastodon/src/main/java/com/hootsuite/nachos/terminator/ChipTerminatorHandler.java new file mode 100644 index 000000000..f97d430ea --- /dev/null +++ b/mastodon/src/main/java/com/hootsuite/nachos/terminator/ChipTerminatorHandler.java @@ -0,0 +1,95 @@ +package com.hootsuite.nachos.terminator; + +import android.text.Editable; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.hootsuite.nachos.tokenizer.ChipTokenizer; + +import java.util.Map; + +/** + * This interface is used to handle the management of characters that should trigger the creation of chips in a text view. + * + * @see ChipTokenizer + */ +public interface ChipTerminatorHandler { + + /** + * When a chip terminator character is encountered in newly inserted text, all tokens in the whole text view will be chipified + */ + int BEHAVIOR_CHIPIFY_ALL = 0; + + /** + * When a chip terminator character is encountered in newly inserted text, only the current token (that in which the chip terminator character + * was found) will be chipified. This token may extend beyond where the chip terminator character was located. + */ + int BEHAVIOR_CHIPIFY_CURRENT_TOKEN = 1; + + /** + * When a chip terminator character is encountered in newly inserted text, only the text from the previous chip up until the chip terminator + * character will be chipified. This may not be an entire token. + */ + int BEHAVIOR_CHIPIFY_TO_TERMINATOR = 2; + + /** + * Constant for use with {@link #setPasteBehavior(int)}. Use this if a paste should behave the same as a standard text input (the chip temrinators + * will all behave according to their pre-determined behavior set through {@link #addChipTerminator(char, int)} or {@link #setChipTerminators(Map)}). + */ + int PASTE_BEHAVIOR_USE_DEFAULT = -1; + + /** + * Sets all the characters that will be marked as chip terminators. This will replace any previously set chip terminators. + * + * @param chipTerminators a map of characters to be marked as chip terminators to behaviors that describe how to respond to the characters, or null + * to remove all chip terminators + */ + void setChipTerminators(@Nullable Map chipTerminators); + + /** + * Adds a character as a chip terminator. When the provided character is encountered in entered text, the nearby text will be chipified according + * to the behavior provided here. + * {@code behavior} Must be one of: + *
    + *
  • {@link #BEHAVIOR_CHIPIFY_ALL}
  • + *
  • {@link #BEHAVIOR_CHIPIFY_CURRENT_TOKEN}
  • + *
  • {@link #BEHAVIOR_CHIPIFY_TO_TERMINATOR}
  • + *
+ * + * @param character the character to mark as a chip terminator + * @param behavior the behavior describing how to respond to the chip terminator + */ + void addChipTerminator(char character, int behavior); + + /** + * Customizes the way paste events are handled. + * If one of: + *
    + *
  • {@link #BEHAVIOR_CHIPIFY_ALL}
  • + *
  • {@link #BEHAVIOR_CHIPIFY_CURRENT_TOKEN}
  • + *
  • {@link #BEHAVIOR_CHIPIFY_TO_TERMINATOR}
  • + *
+ * is passed, all chip terminators will be handled with that behavior when a paste event occurs. + * If {@link #PASTE_BEHAVIOR_USE_DEFAULT} is passed, whatever behavior is configured for a particular chip terminator + * (through {@link #setChipTerminators(Map)} or {@link #addChipTerminator(char, int)} will be used for that chip terminator + * + * @param pasteBehavior the behavior to use on a paste event + */ + void setPasteBehavior(int pasteBehavior); + + /** + * Parses the provided text looking for characters marked as chip terminators through {@link #addChipTerminator(char, int)} and {@link #setChipTerminators(Map)}. + * The provided {@link Editable} will be modified if chip terminators are encountered. + * + * @param tokenizer the {@link ChipTokenizer} to use to identify and chipify tokens in the text + * @param text the text in which to search for chip terminators tokens to be chipped + * @param start the index at which to begin looking for chip terminators (inclusive) + * @param end the index at which to end looking for chip terminators (exclusive) + * @param isPasteEvent true if this handling is for a paste event in which case the behavior set in {@link #setPasteBehavior(int)} will be used, + * otherwise false + * @return an non-negative integer indicating the index where the cursor (selection) should be placed once the handling is complete, + * or a negative integer indicating that the cursor should not be moved. + */ + int findAndHandleChipTerminators(@NonNull ChipTokenizer tokenizer, @NonNull Editable text, int start, int end, boolean isPasteEvent); +} diff --git a/mastodon/src/main/java/com/hootsuite/nachos/terminator/DefaultChipTerminatorHandler.java b/mastodon/src/main/java/com/hootsuite/nachos/terminator/DefaultChipTerminatorHandler.java new file mode 100644 index 000000000..75cb9b0dc --- /dev/null +++ b/mastodon/src/main/java/com/hootsuite/nachos/terminator/DefaultChipTerminatorHandler.java @@ -0,0 +1,115 @@ +package com.hootsuite.nachos.terminator; + +import android.text.Editable; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.hootsuite.nachos.tokenizer.ChipTokenizer; + +import java.util.HashMap; +import java.util.Map; + +public class DefaultChipTerminatorHandler implements ChipTerminatorHandler { + + @Nullable + private Map mChipTerminators; + private int mPasteBehavior = BEHAVIOR_CHIPIFY_TO_TERMINATOR; + + @Override + public void setChipTerminators(@Nullable Map chipTerminators) { + mChipTerminators = chipTerminators; + } + + @Override + public void addChipTerminator(char character, int behavior) { + if (mChipTerminators == null) { + mChipTerminators = new HashMap<>(); + } + + mChipTerminators.put(character, behavior); + } + + @Override + public void setPasteBehavior(int pasteBehavior) { + mPasteBehavior = pasteBehavior; + } + + @Override + public int findAndHandleChipTerminators(@NonNull ChipTokenizer tokenizer, @NonNull Editable text, int start, int end, boolean isPasteEvent) { + // If we don't have a tokenizer or any chip terminators, there's nothing to look for + if (mChipTerminators == null) { + return -1; + } + + TextIterator textIterator = new TextIterator(text, start, end); + int selectionIndex = -1; + + characterLoop: + while (textIterator.hasNextCharacter()) { + char theChar = textIterator.nextCharacter(); + if (isChipTerminator(theChar)) { + int behavior = (isPasteEvent && mPasteBehavior != PASTE_BEHAVIOR_USE_DEFAULT) ? mPasteBehavior : mChipTerminators.get(theChar); + int newSelection = -1; + switch (behavior) { + case BEHAVIOR_CHIPIFY_ALL: + selectionIndex = handleChipifyAll(textIterator, tokenizer); + break characterLoop; + case BEHAVIOR_CHIPIFY_CURRENT_TOKEN: + newSelection = handleChipifyCurrentToken(textIterator, tokenizer); + break; + case BEHAVIOR_CHIPIFY_TO_TERMINATOR: + newSelection = handleChipifyToTerminator(textIterator, tokenizer); + break; + } + + if (newSelection != -1) { + selectionIndex = newSelection; + } + } + } + + return selectionIndex; + } + + private int handleChipifyAll(TextIterator textIterator, ChipTokenizer tokenizer) { + textIterator.deleteCharacter(true); + tokenizer.terminateAllTokens(textIterator.getText()); + return textIterator.totalLength(); + } + + private int handleChipifyCurrentToken(TextIterator textIterator, ChipTokenizer tokenizer) { + textIterator.deleteCharacter(true); + Editable text = textIterator.getText(); + int index = textIterator.getIndex(); + int tokenStart = tokenizer.findTokenStart(text, index); + int tokenEnd = tokenizer.findTokenEnd(text, index); + if (tokenStart < tokenEnd) { + CharSequence chippedText = tokenizer.terminateToken(text.subSequence(tokenStart, tokenEnd), null); + textIterator.replace(tokenStart, tokenEnd, chippedText); + return tokenStart + chippedText.length(); + } + return -1; + } + + private int handleChipifyToTerminator(TextIterator textIterator, ChipTokenizer tokenizer) { + Editable text = textIterator.getText(); + int index = textIterator.getIndex(); + if (index > 0) { + int tokenStart = tokenizer.findTokenStart(text, index); + if (tokenStart < index) { + CharSequence chippedText = tokenizer.terminateToken(text.subSequence(tokenStart, index), null); + textIterator.replace(tokenStart, index + 1, chippedText); + } else { + textIterator.deleteCharacter(false); + } + } else { + textIterator.deleteCharacter(false); + } + return -1; + } + + private boolean isChipTerminator(char character) { + return mChipTerminators != null && mChipTerminators.keySet().contains(character); + } +} diff --git a/mastodon/src/main/java/com/hootsuite/nachos/terminator/TextIterator.java b/mastodon/src/main/java/com/hootsuite/nachos/terminator/TextIterator.java new file mode 100644 index 000000000..7c25d4c9f --- /dev/null +++ b/mastodon/src/main/java/com/hootsuite/nachos/terminator/TextIterator.java @@ -0,0 +1,63 @@ +package com.hootsuite.nachos.terminator; + +import android.text.Editable; + +public class TextIterator { + + private Editable mText; + private int mStart; + private int mEnd; + + private int mIndex; + + public TextIterator(Editable text, int start, int end) { + mText = text; + mStart = start; + mEnd = end; + + mIndex = mStart - 1; // Subtract 1 so that the first call to nextCharacter() will return the first character + } + + public int totalLength() { + return mText.length(); + } + + public int windowLength() { + return mEnd - mStart; + } + + public Editable getText() { + return mText; + } + + public int getIndex() { + return mIndex; + } + + public boolean hasNextCharacter() { + return (mIndex + 1) < mEnd; + } + + public char nextCharacter() { + mIndex++; + return mText.charAt(mIndex); + } + + public void deleteCharacter(boolean maintainIndex) { + mText.replace(mIndex, mIndex + 1, ""); + if (!maintainIndex) { + mIndex--; + } + mEnd--; + } + + public void replace(int replaceStart, int replaceEnd, CharSequence chippedText) { + mText.replace(replaceStart, replaceEnd, chippedText); + + // Update indexes + int newLength = chippedText.length(); + int oldLength = replaceEnd - replaceStart; + mIndex = replaceStart + newLength - 1; + mEnd += newLength - oldLength; + } +} diff --git a/mastodon/src/main/java/com/hootsuite/nachos/tokenizer/BaseChipTokenizer.java b/mastodon/src/main/java/com/hootsuite/nachos/tokenizer/BaseChipTokenizer.java new file mode 100644 index 000000000..ba946b5b0 --- /dev/null +++ b/mastodon/src/main/java/com/hootsuite/nachos/tokenizer/BaseChipTokenizer.java @@ -0,0 +1,89 @@ +package com.hootsuite.nachos.tokenizer; + +import android.text.Editable; +import android.text.Spanned; +import android.util.Pair; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.hootsuite.nachos.ChipConfiguration; +import com.hootsuite.nachos.chip.Chip; + +import java.util.ArrayList; +import java.util.List; + +/** + * Base implementation of the {@link ChipTokenizer} interface that performs no actions and returns default values. + * This class allows for the easy creation of a ChipTokenizer that only implements some of the methods of the interface. + */ +public abstract class BaseChipTokenizer implements ChipTokenizer { + + @Override + public void applyConfiguration(Editable text, ChipConfiguration chipConfiguration) { + // Do nothing + } + + @Override + public int findTokenStart(CharSequence charSequence, int i) { + // Do nothing + return 0; + } + + @Override + public int findTokenEnd(CharSequence charSequence, int i) { + // Do nothing + return 0; + } + + @NonNull + @Override + public List> findAllTokens(CharSequence text) { + // Do nothing + return new ArrayList<>(); + } + + @Override + public CharSequence terminateToken(CharSequence charSequence, @Nullable Object data) { + // Do nothing + return charSequence; + } + + @Override + public void terminateAllTokens(Editable text) { + // Do nothing + } + + @Override + public int findChipStart(Chip chip, Spanned text) { + // Do nothing + return 0; + } + + @Override + public int findChipEnd(Chip chip, Spanned text) { + // Do nothing + return 0; + } + + @NonNull + @Override + public Chip[] findAllChips(int start, int end, Spanned text) { + return new Chip[]{}; + } + + @Override + public void revertChipToToken(Chip chip, Editable text) { + // Do nothing + } + + @Override + public void deleteChip(Chip chip, Editable text) { + // Do nothing + } + + @Override + public void deleteChipAndPadding(Chip chip, Editable text) { + // Do nothing + } +} diff --git a/mastodon/src/main/java/com/hootsuite/nachos/tokenizer/ChipTokenizer.java b/mastodon/src/main/java/com/hootsuite/nachos/tokenizer/ChipTokenizer.java new file mode 100644 index 000000000..32d8f0614 --- /dev/null +++ b/mastodon/src/main/java/com/hootsuite/nachos/tokenizer/ChipTokenizer.java @@ -0,0 +1,134 @@ +package com.hootsuite.nachos.tokenizer; + +import android.text.Editable; +import android.text.Spanned; +import android.util.Pair; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.hootsuite.nachos.ChipConfiguration; +import com.hootsuite.nachos.chip.Chip; + +import java.util.List; + +/** + * An extension of {@link android.widget.MultiAutoCompleteTextView.Tokenizer Tokenizer} that provides extra support + * for chipification. + *

+ * In the context of this interface, a token is considered to be plain (non-chipped) text. Once a token is terminated it becomes or contains a chip. + *

+ *

+ * The CharSequences passed to the ChipTokenizer methods may contain both chipped text + * and plain text so the tokenizer must have some method of distinguishing between the two (e.g. using a delimeter character. + * The {@link #terminateToken(CharSequence, Object)} method is where a chip can be formed and returned to replace the plain text. + * Whatever class the implementation deems to represent a chip, must implement the {@link Chip} interface. + *

+ * + * @see SpanChipTokenizer + */ +public interface ChipTokenizer { + + /** + * Configures this ChipTokenizer to produce chips with the provided attributes. For each of these attributes, {@code -1} or {@code null} may be + * passed to indicate that the attribute may be ignored. + *

+ * This will also apply the provided {@link ChipConfiguration} to any existing chips in the provided text. + *

+ * + * @param text the text in which to search for existing chips to apply the configuration to + * @param chipConfiguration a {@link ChipConfiguration} containing customizations for the chips produced by this class + */ + void applyConfiguration(Editable text, ChipConfiguration chipConfiguration); + + /** + * Returns the start of the token that ends at offset + * cursor within text. + */ + int findTokenStart(CharSequence text, int cursor); + + /** + * Returns the end of the token (minus trailing punctuation) + * that begins at offset cursor within text. + */ + int findTokenEnd(CharSequence text, int cursor); + + /** + * Searches through {@code text} for any tokens. + * + * @param text the text in which to search for un-terminated tokens + * @return a list of {@link Pair}s of the form (startIndex, endIndex) containing the locations of all + * unterminated tokens + */ + @NonNull + List> findAllTokens(CharSequence text); + + /** + * Returns text, modified, if necessary, to ensure that + * it ends with a token terminator (for example a space or comma). + */ + CharSequence terminateToken(CharSequence text, @Nullable Object data); + + /** + * Terminates (converts from token into chip) all unterminated tokens in the provided text. + * This method CAN alter the provided text. + * + * @param text the text in which to terminate all tokens + */ + void terminateAllTokens(Editable text); + + /** + * Finds the index of the first character in {@code text} that is a part of {@code chip} + * + * @param chip the chip whose start should be found + * @param text the text in which to search for the start of {@code chip} + * @return the start index of the chip + */ + int findChipStart(Chip chip, Spanned text); + + /** + * Finds the index of the character after the last character in {@code text} that is a part of {@code chip} + * + * @param chip the chip whose end should be found + * @param text the text in which to search for the end of {@code chip} + * @return the end index of the chip + */ + int findChipEnd(Chip chip, Spanned text); + + /** + * Searches through {@code text} for any chips + * + * @param start index to start looking for terminated tokens (inclusive) + * @param end index to end looking for terminated tokens (exclusive) + * @param text the text in which to search for terminated tokens + * @return a list of objects implementing the {@link Chip} interface to represent the terminated tokens + */ + @NonNull + Chip[] findAllChips(int start, int end, Spanned text); + + /** + * Effectively does the opposite of {@link #terminateToken(CharSequence, Object)} by reverting the provided chip back into a token. + * This method CAN alter the provided text. + * + * @param chip the chip to revert into a token + * @param text the text in which the chip resides + */ + void revertChipToToken(Chip chip, Editable text); + + /** + * Removes a chip and any text it encompasses from {@code text}. This method CAN alter the provided text. + * + * @param chip the chip to remove + * @param text the text to remove the chip from + */ + void deleteChip(Chip chip, Editable text); + + /** + * Removes a chip, any text it encompasses AND any padding text (such as spaces) that may have been inserted when the chip was created in + * {@link #terminateToken(CharSequence, Object)} or after. This method CAN alter the provided text. + * + * @param chip the chip to remove + * @param text the text to remove the chip and padding from + */ + void deleteChipAndPadding(Chip chip, Editable text); +} diff --git a/mastodon/src/main/java/com/hootsuite/nachos/tokenizer/SpanChipTokenizer.java b/mastodon/src/main/java/com/hootsuite/nachos/tokenizer/SpanChipTokenizer.java new file mode 100644 index 000000000..511bdb9bb --- /dev/null +++ b/mastodon/src/main/java/com/hootsuite/nachos/tokenizer/SpanChipTokenizer.java @@ -0,0 +1,246 @@ +package com.hootsuite.nachos.tokenizer; + +import android.content.Context; +import android.text.Editable; +import android.text.SpannableString; +import android.text.Spanned; +import android.util.Pair; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.hootsuite.nachos.ChipConfiguration; +import com.hootsuite.nachos.chip.Chip; +import com.hootsuite.nachos.chip.ChipCreator; +import com.hootsuite.nachos.chip.ChipSpan; + +import java.lang.reflect.Array; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +/** + * A default implementation of {@link ChipTokenizer}. + * This implementation does the following: + *
    + *
  • Surrounds each token with a space and the Unit Separator ASCII control character (31) - See the diagram below + *
      + *
    • The spaces are included so that android keyboards can distinguish the chips as different words and provide accurate + * autocorrect suggestions
    • + *
    + *
  • + *
  • Replaces each token with a {@link ChipSpan} containing the same text, once the token terminates
  • + *
  • Uses the values passed to {@link #applyConfiguration(Editable, ChipConfiguration)} to configure any ChipSpans that get created
  • + *
+ * Each terminated token will therefore look like the following (this is what will be returned from {@link #terminateToken(CharSequence, Object)}): + *
+ *  -----------------------------------------------------------
+ *  | SpannableString                                         |
+ *  |   ----------------------------------------------------  |
+ *  |   | ChipSpan                                         |  |
+ *  |   |                                                  |  |
+ *  |   |  space   separator    text    separator   space  |  |
+ *  |   |                                                  |  |
+ *  |   ----------------------------------------------------  |
+ *  -----------------------------------------------------------
+ * 
+ * + * @see ChipSpan + */ +public class SpanChipTokenizer implements ChipTokenizer { + + /** + * The character used to separate chips internally is the US (Unit Separator) ASCII control character. + * This character is used because it's untypable so we have complete control over when chips are created. + */ + public static final char CHIP_SPAN_SEPARATOR = 31; + public static final char AUTOCORRECT_SEPARATOR = ' '; + + private Context mContext; + + @Nullable + private ChipConfiguration mChipConfiguration; + @NonNull + private ChipCreator mChipCreator; + @NonNull + private Class mChipClass; + + private Comparator> mReverseTokenIndexesSorter = new Comparator>() { + @Override + public int compare(Pair lhs, Pair rhs) { + return rhs.first - lhs.first; + } + }; + + public SpanChipTokenizer(Context context, @NonNull ChipCreator chipCreator, @NonNull Class chipClass) { + mContext = context; + mChipCreator = chipCreator; + mChipClass = chipClass; + } + + @Override + public void applyConfiguration(Editable text, ChipConfiguration chipConfiguration) { + mChipConfiguration = chipConfiguration; + + for (C chip : findAllChips(0, text.length(), text)) { + // Recreate the chips with the new configuration + int chipStart = findChipStart(chip, text); + deleteChip(chip, text); + text.insert(chipStart, terminateToken(mChipCreator.createChip(mContext, chip))); + } + } + + @Override + public int findTokenStart(CharSequence text, int cursor) { + int i = cursor; + + // Work backwards until we find a CHIP_SPAN_SEPARATOR + while (i > 0 && text.charAt(i - 1) != CHIP_SPAN_SEPARATOR) { + i--; + } + // Work forwards to skip over any extra whitespace at the beginning of the token + while (i > 0 && i < text.length() && Character.isWhitespace(text.charAt(i))) { + i++; + } + return i; + } + + @Override + public int findTokenEnd(CharSequence text, int cursor) { + int i = cursor; + int len = text.length(); + + // Work forwards till we find a CHIP_SPAN_SEPARATOR + while (i < len) { + if (text.charAt(i) == CHIP_SPAN_SEPARATOR) { + return (i - 1); // subtract one because the CHIP_SPAN_SEPARATOR will be preceded by a space + } else { + i++; + } + } + return len; + } + + @NonNull + @Override + public List> findAllTokens(CharSequence text) { + List> unterminatedTokens = new ArrayList<>(); + + boolean insideChip = false; + // Iterate backwards through the text (to avoid messing up indexes) + for (int index = text.length() - 1; index >= 0; index--) { + char theCharacter = text.charAt(index); + + // Every time we hit a CHIP_SPAN_SEPARATOR character we switch from being inside to outside + // or outside to inside a chip + // This check must happen before the whitespace check because CHIP_SPAN_SEPARATOR is considered a whitespace character + if (theCharacter == CHIP_SPAN_SEPARATOR) { + insideChip = !insideChip; + continue; + } + + // Completely skip over whitespace + if (Character.isWhitespace(theCharacter)) { + continue; + } + + // If we're ever outside a chip, see if the text we're in is a viable token for chipification + if (!insideChip) { + int tokenStart = findTokenStart(text, index); + int tokenEnd = findTokenEnd(text, index); + + // Can only actually be chipified if there's at least one character between them + if (tokenEnd - tokenStart >= 1) { + unterminatedTokens.add(new Pair<>(tokenStart, tokenEnd)); + index = tokenStart; + } + } + } + return unterminatedTokens; + } + + @Override + public CharSequence terminateToken(CharSequence text, @Nullable Object data) { + // Remove leading/trailing whitespace + CharSequence trimmedText = text.toString().trim(); + return terminateToken(mChipCreator.createChip(mContext, trimmedText, data)); + } + + private CharSequence terminateToken(C chip) { + // Surround the text with CHIP_SPAN_SEPARATOR and spaces + // The spaces allow autocorrect to correctly identify words + String chipSeparator = Character.toString(CHIP_SPAN_SEPARATOR); + String autoCorrectSeparator = Character.toString(AUTOCORRECT_SEPARATOR); + CharSequence textWithSeparator = autoCorrectSeparator + chipSeparator + chip.getText() + chipSeparator + autoCorrectSeparator; + + // Build the container object to house the ChipSpan and space + SpannableString spannableString = new SpannableString(textWithSeparator); + + // Attach the ChipSpan + if (mChipConfiguration != null) { + mChipCreator.configureChip(chip, mChipConfiguration); + } + spannableString.setSpan(chip, 0, textWithSeparator.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + return spannableString; + } + + @Override + public void terminateAllTokens(Editable text) { + List> unterminatedTokens = findAllTokens(text); + // Sort in reverse order (so index changes don't affect anything) + Collections.sort(unterminatedTokens, mReverseTokenIndexesSorter); + for (Pair indexes : unterminatedTokens) { + int start = indexes.first; + int end = indexes.second; + CharSequence textToChip = text.subSequence(start, end); + CharSequence chippedText = terminateToken(textToChip, null); + text.replace(start, end, chippedText); + } + } + + @Override + public int findChipStart(Chip chip, Spanned text) { + return text.getSpanStart(chip); + } + + @Override + public int findChipEnd(Chip chip, Spanned text) { + return text.getSpanEnd(chip); + } + + @SuppressWarnings("unchecked") + @NonNull + @Override + public C[] findAllChips(int start, int end, Spanned text) { + C[] spansArray = text.getSpans(start, end, mChipClass); + return (spansArray != null) ? spansArray : (C[]) Array.newInstance(mChipClass, 0); + } + + @Override + public void revertChipToToken(Chip chip, Editable text) { + int chipStart = findChipStart(chip, text); + int chipEnd = findChipEnd(chip, text); + text.removeSpan(chip); + text.replace(chipStart, chipEnd, chip.getText()); + } + + @Override + public void deleteChip(Chip chip, Editable text) { + int chipStart = findChipStart(chip, text); + int chipEnd = findChipEnd(chip, text); + text.removeSpan(chip); + // On the emulator for some reason the text automatically gets deleted and chipStart and chipEnd end up both being -1, so in that case we + // don't need to call text.delete(...) + if (chipStart != chipEnd) { + text.delete(chipStart, chipEnd); + } + } + + @Override + public void deleteChipAndPadding(Chip chip, Editable text) { + // This implementation does not add any extra padding outside of the span so we can just delete the chip normally + deleteChip(chip, text); + } +} diff --git a/mastodon/src/main/java/com/hootsuite/nachos/validator/ChipifyingNachoValidator.java b/mastodon/src/main/java/com/hootsuite/nachos/validator/ChipifyingNachoValidator.java new file mode 100644 index 000000000..fa1aa5717 --- /dev/null +++ b/mastodon/src/main/java/com/hootsuite/nachos/validator/ChipifyingNachoValidator.java @@ -0,0 +1,32 @@ +package com.hootsuite.nachos.validator; + +import android.text.SpannableStringBuilder; +import android.util.Pair; + +import androidx.annotation.NonNull; + +import com.hootsuite.nachos.tokenizer.ChipTokenizer; + +import java.util.List; + +/** + * A {@link NachoValidator} that deems text to be invalid if it contains + * unterminated tokens and fixes the text by chipifying all the unterminated tokens. + */ +public class ChipifyingNachoValidator implements NachoValidator { + + @Override + public boolean isValid(@NonNull ChipTokenizer chipTokenizer, CharSequence text) { + + // The text is considered valid if there are no unterminated tokens (everything is a chip) + List> unterminatedTokens = chipTokenizer.findAllTokens(text); + return unterminatedTokens.isEmpty(); + } + + @Override + public CharSequence fixText(@NonNull ChipTokenizer chipTokenizer, CharSequence invalidText) { + SpannableStringBuilder newText = new SpannableStringBuilder(invalidText); + chipTokenizer.terminateAllTokens(newText); + return newText; + } +} diff --git a/mastodon/src/main/java/com/hootsuite/nachos/validator/IllegalCharacterIdentifier.java b/mastodon/src/main/java/com/hootsuite/nachos/validator/IllegalCharacterIdentifier.java new file mode 100644 index 000000000..76f3f858f --- /dev/null +++ b/mastodon/src/main/java/com/hootsuite/nachos/validator/IllegalCharacterIdentifier.java @@ -0,0 +1,5 @@ +package com.hootsuite.nachos.validator; + +public interface IllegalCharacterIdentifier { + boolean isCharacterIllegal(Character c); +} diff --git a/mastodon/src/main/java/com/hootsuite/nachos/validator/NachoValidator.java b/mastodon/src/main/java/com/hootsuite/nachos/validator/NachoValidator.java new file mode 100644 index 000000000..70438c155 --- /dev/null +++ b/mastodon/src/main/java/com/hootsuite/nachos/validator/NachoValidator.java @@ -0,0 +1,29 @@ +package com.hootsuite.nachos.validator; + +import androidx.annotation.NonNull; + +import com.hootsuite.nachos.tokenizer.ChipTokenizer; + +/** + * Interface used to ensure that a given CharSequence complies to a particular format. + */ +public interface NachoValidator { + + /** + * Validates the specified text. + * + * @return true If the text currently in the text editor is valid. + * @see #fixText(ChipTokenizer, CharSequence) + */ + boolean isValid(@NonNull ChipTokenizer chipTokenizer, CharSequence text); + + /** + * Corrects the specified text to make it valid. + * + * @param invalidText A string that doesn't pass validation: isValid(invalidText) + * returns false + * @return A string based on invalidText such as invoking isValid() on it returns true. + * @see #isValid(ChipTokenizer, CharSequence) + */ + CharSequence fixText(@NonNull ChipTokenizer chipTokenizer, CharSequence invalidText); +} diff --git a/mastodon/src/main/java/name/fraser/neil/plaintext/LICENSE b/mastodon/src/main/java/name/fraser/neil/plaintext/LICENSE new file mode 100644 index 000000000..d64569567 --- /dev/null +++ b/mastodon/src/main/java/name/fraser/neil/plaintext/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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. diff --git a/mastodon/src/main/java/name/fraser/neil/plaintext/diff_match_patch.java b/mastodon/src/main/java/name/fraser/neil/plaintext/diff_match_patch.java new file mode 100644 index 000000000..9d07867de --- /dev/null +++ b/mastodon/src/main/java/name/fraser/neil/plaintext/diff_match_patch.java @@ -0,0 +1,2471 @@ +/* + * Diff Match and Patch + * Copyright 2018 The diff-match-patch Authors. + * https://github.com/google/diff-match-patch + * + * 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 name.fraser.neil.plaintext; + +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; +import java.net.URLEncoder; +import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/* + * Functions for diff, match and patch. + * Computes the difference between two texts to create a patch. + * Applies the patch onto another text, allowing for errors. + * + * @author fraser@google.com (Neil Fraser) + */ + +/** + * Class containing the diff, match and patch methods. + * Also contains the behaviour settings. + */ +public class diff_match_patch { + + // Defaults. + // Set these on your diff_match_patch instance to override the defaults. + + /** + * Number of seconds to map a diff before giving up (0 for infinity). + */ + public float Diff_Timeout = 1.0f; + /** + * Cost of an empty edit operation in terms of edit characters. + */ + public short Diff_EditCost = 4; + /** + * At what point is no match declared (0.0 = perfection, 1.0 = very loose). + */ + public float Match_Threshold = 0.5f; + /** + * How far to search for a match (0 = exact location, 1000+ = broad match). + * A match this many characters away from the expected location will add + * 1.0 to the score (0.0 is a perfect match). + */ + public int Match_Distance = 1000; + /** + * When deleting a large block of text (over ~64 characters), how close do + * the contents have to be to match the expected contents. (0.0 = perfection, + * 1.0 = very loose). Note that Match_Threshold controls how closely the + * end points of a delete need to match. + */ + public float Patch_DeleteThreshold = 0.5f; + /** + * Chunk size for context length. + */ + public short Patch_Margin = 4; + + /** + * The number of bits in an int. + */ + private short Match_MaxBits = 32; + + /** + * Internal class for returning results from diff_linesToChars(). + * Other less paranoid languages just use a three-element array. + */ + protected static class LinesToCharsResult { + protected String chars1; + protected String chars2; + protected List lineArray; + + protected LinesToCharsResult(String chars1, String chars2, + List lineArray) { + this.chars1 = chars1; + this.chars2 = chars2; + this.lineArray = lineArray; + } + } + + + // DIFF FUNCTIONS + + + /** + * The data structure representing a diff is a Linked list of Diff objects: + * {Diff(Operation.DELETE, "Hello"), Diff(Operation.INSERT, "Goodbye"), + * Diff(Operation.EQUAL, " world.")} + * which means: delete "Hello", add "Goodbye" and keep " world." + */ + public enum Operation { + DELETE, INSERT, EQUAL + } + + /** + * Find the differences between two texts. + * Run a faster, slightly less optimal diff. + * This method allows the 'checklines' of diff_main() to be optional. + * Most of the time checklines is wanted, so default to true. + * @param text1 Old string to be diffed. + * @param text2 New string to be diffed. + * @return Linked List of Diff objects. + */ + public LinkedList diff_main(String text1, String text2) { + return diff_main(text1, text2, true); + } + + /** + * Find the differences between two texts. + * @param text1 Old string to be diffed. + * @param text2 New string to be diffed. + * @param checklines Speedup flag. If false, then don't run a + * line-level diff first to identify the changed areas. + * If true, then run a faster slightly less optimal diff. + * @return Linked List of Diff objects. + */ + public LinkedList diff_main(String text1, String text2, + boolean checklines) { + // Set a deadline by which time the diff must be complete. + long deadline; + if (Diff_Timeout <= 0) { + deadline = Long.MAX_VALUE; + } else { + deadline = System.currentTimeMillis() + (long) (Diff_Timeout * 1000); + } + return diff_main(text1, text2, checklines, deadline); + } + + /** + * Find the differences between two texts. Simplifies the problem by + * stripping any common prefix or suffix off the texts before diffing. + * @param text1 Old string to be diffed. + * @param text2 New string to be diffed. + * @param checklines Speedup flag. If false, then don't run a + * line-level diff first to identify the changed areas. + * If true, then run a faster slightly less optimal diff. + * @param deadline Time when the diff should be complete by. Used + * internally for recursive calls. Users should set DiffTimeout instead. + * @return Linked List of Diff objects. + */ + private LinkedList diff_main(String text1, String text2, + boolean checklines, long deadline) { + // Check for null inputs. + if (text1 == null || text2 == null) { + throw new IllegalArgumentException("Null inputs. (diff_main)"); + } + + // Check for equality (speedup). + LinkedList diffs; + if (text1.equals(text2)) { + diffs = new LinkedList(); + if (text1.length() != 0) { + diffs.add(new Diff(Operation.EQUAL, text1)); + } + return diffs; + } + + // Trim off common prefix (speedup). + int commonlength = diff_commonPrefix(text1, text2); + String commonprefix = text1.substring(0, commonlength); + text1 = text1.substring(commonlength); + text2 = text2.substring(commonlength); + + // Trim off common suffix (speedup). + commonlength = diff_commonSuffix(text1, text2); + String commonsuffix = text1.substring(text1.length() - commonlength); + text1 = text1.substring(0, text1.length() - commonlength); + text2 = text2.substring(0, text2.length() - commonlength); + + // Compute the diff on the middle block. + diffs = diff_compute(text1, text2, checklines, deadline); + + // Restore the prefix and suffix. + if (commonprefix.length() != 0) { + diffs.addFirst(new Diff(Operation.EQUAL, commonprefix)); + } + if (commonsuffix.length() != 0) { + diffs.addLast(new Diff(Operation.EQUAL, commonsuffix)); + } + + diff_cleanupMerge(diffs); + return diffs; + } + + /** + * Find the differences between two texts. Assumes that the texts do not + * have any common prefix or suffix. + * @param text1 Old string to be diffed. + * @param text2 New string to be diffed. + * @param checklines Speedup flag. If false, then don't run a + * line-level diff first to identify the changed areas. + * If true, then run a faster slightly less optimal diff. + * @param deadline Time when the diff should be complete by. + * @return Linked List of Diff objects. + */ + private LinkedList diff_compute(String text1, String text2, + boolean checklines, long deadline) { + LinkedList diffs = new LinkedList(); + + if (text1.length() == 0) { + // Just add some text (speedup). + diffs.add(new Diff(Operation.INSERT, text2)); + return diffs; + } + + if (text2.length() == 0) { + // Just delete some text (speedup). + diffs.add(new Diff(Operation.DELETE, text1)); + return diffs; + } + + String longtext = text1.length() > text2.length() ? text1 : text2; + String shorttext = text1.length() > text2.length() ? text2 : text1; + int i = longtext.indexOf(shorttext); + if (i != -1) { + // Shorter text is inside the longer text (speedup). + Operation op = (text1.length() > text2.length()) ? + Operation.DELETE : Operation.INSERT; + diffs.add(new Diff(op, longtext.substring(0, i))); + diffs.add(new Diff(Operation.EQUAL, shorttext)); + diffs.add(new Diff(op, longtext.substring(i + shorttext.length()))); + return diffs; + } + + if (shorttext.length() == 1) { + // Single character string. + // After the previous speedup, the character can't be an equality. + diffs.add(new Diff(Operation.DELETE, text1)); + diffs.add(new Diff(Operation.INSERT, text2)); + return diffs; + } + + // Check to see if the problem can be split in two. + String[] hm = diff_halfMatch(text1, text2); + if (hm != null) { + // A half-match was found, sort out the return data. + String text1_a = hm[0]; + String text1_b = hm[1]; + String text2_a = hm[2]; + String text2_b = hm[3]; + String mid_common = hm[4]; + // Send both pairs off for separate processing. + LinkedList diffs_a = diff_main(text1_a, text2_a, + checklines, deadline); + LinkedList diffs_b = diff_main(text1_b, text2_b, + checklines, deadline); + // Merge the results. + diffs = diffs_a; + diffs.add(new Diff(Operation.EQUAL, mid_common)); + diffs.addAll(diffs_b); + return diffs; + } + + if (checklines && text1.length() > 100 && text2.length() > 100) { + return diff_lineMode(text1, text2, deadline); + } + + return diff_bisect(text1, text2, deadline); + } + + /** + * Do a quick line-level diff on both strings, then rediff the parts for + * greater accuracy. + * This speedup can produce non-minimal diffs. + * @param text1 Old string to be diffed. + * @param text2 New string to be diffed. + * @param deadline Time when the diff should be complete by. + * @return Linked List of Diff objects. + */ + private LinkedList diff_lineMode(String text1, String text2, + long deadline) { + // Scan the text on a line-by-line basis first. + LinesToCharsResult a = diff_linesToChars(text1, text2); + text1 = a.chars1; + text2 = a.chars2; + List linearray = a.lineArray; + + LinkedList diffs = diff_main(text1, text2, false, deadline); + + // Convert the diff back to original text. + diff_charsToLines(diffs, linearray); + // Eliminate freak matches (e.g. blank lines) + diff_cleanupSemantic(diffs); + + // Rediff any replacement blocks, this time character-by-character. + // Add a dummy entry at the end. + diffs.add(new Diff(Operation.EQUAL, "")); + int count_delete = 0; + int count_insert = 0; + String text_delete = ""; + String text_insert = ""; + ListIterator pointer = diffs.listIterator(); + Diff thisDiff = pointer.next(); + while (thisDiff != null) { + switch (thisDiff.operation) { + case INSERT: + count_insert++; + text_insert += thisDiff.text; + break; + case DELETE: + count_delete++; + text_delete += thisDiff.text; + break; + case EQUAL: + // Upon reaching an equality, check for prior redundancies. + if (count_delete >= 1 && count_insert >= 1) { + // Delete the offending records and add the merged ones. + pointer.previous(); + for (int j = 0; j < count_delete + count_insert; j++) { + pointer.previous(); + pointer.remove(); + } + for (Diff subDiff : diff_main(text_delete, text_insert, false, + deadline)) { + pointer.add(subDiff); + } + } + count_insert = 0; + count_delete = 0; + text_delete = ""; + text_insert = ""; + break; + } + thisDiff = pointer.hasNext() ? pointer.next() : null; + } + diffs.removeLast(); // Remove the dummy entry at the end. + + return diffs; + } + + /** + * Find the 'middle snake' of a diff, split the problem in two + * and return the recursively constructed diff. + * See Myers 1986 paper: An O(ND) Difference Algorithm and Its Variations. + * @param text1 Old string to be diffed. + * @param text2 New string to be diffed. + * @param deadline Time at which to bail if not yet complete. + * @return LinkedList of Diff objects. + */ + protected LinkedList diff_bisect(String text1, String text2, + long deadline) { + // Cache the text lengths to prevent multiple calls. + int text1_length = text1.length(); + int text2_length = text2.length(); + int max_d = (text1_length + text2_length + 1) / 2; + int v_offset = max_d; + int v_length = 2 * max_d; + int[] v1 = new int[v_length]; + int[] v2 = new int[v_length]; + for (int x = 0; x < v_length; x++) { + v1[x] = -1; + v2[x] = -1; + } + v1[v_offset + 1] = 0; + v2[v_offset + 1] = 0; + int delta = text1_length - text2_length; + // If the total number of characters is odd, then the front path will + // collide with the reverse path. + boolean front = (delta % 2 != 0); + // Offsets for start and end of k loop. + // Prevents mapping of space beyond the grid. + int k1start = 0; + int k1end = 0; + int k2start = 0; + int k2end = 0; + for (int d = 0; d < max_d; d++) { + // Bail out if deadline is reached. + if (System.currentTimeMillis() > deadline) { + break; + } + + // Walk the front path one step. + for (int k1 = -d + k1start; k1 <= d - k1end; k1 += 2) { + int k1_offset = v_offset + k1; + int x1; + if (k1 == -d || (k1 != d && v1[k1_offset - 1] < v1[k1_offset + 1])) { + x1 = v1[k1_offset + 1]; + } else { + x1 = v1[k1_offset - 1] + 1; + } + int y1 = x1 - k1; + while (x1 < text1_length && y1 < text2_length + && text1.charAt(x1) == text2.charAt(y1)) { + x1++; + y1++; + } + v1[k1_offset] = x1; + if (x1 > text1_length) { + // Ran off the right of the graph. + k1end += 2; + } else if (y1 > text2_length) { + // Ran off the bottom of the graph. + k1start += 2; + } else if (front) { + int k2_offset = v_offset + delta - k1; + if (k2_offset >= 0 && k2_offset < v_length && v2[k2_offset] != -1) { + // Mirror x2 onto top-left coordinate system. + int x2 = text1_length - v2[k2_offset]; + if (x1 >= x2) { + // Overlap detected. + return diff_bisectSplit(text1, text2, x1, y1, deadline); + } + } + } + } + + // Walk the reverse path one step. + for (int k2 = -d + k2start; k2 <= d - k2end; k2 += 2) { + int k2_offset = v_offset + k2; + int x2; + if (k2 == -d || (k2 != d && v2[k2_offset - 1] < v2[k2_offset + 1])) { + x2 = v2[k2_offset + 1]; + } else { + x2 = v2[k2_offset - 1] + 1; + } + int y2 = x2 - k2; + while (x2 < text1_length && y2 < text2_length + && text1.charAt(text1_length - x2 - 1) + == text2.charAt(text2_length - y2 - 1)) { + x2++; + y2++; + } + v2[k2_offset] = x2; + if (x2 > text1_length) { + // Ran off the left of the graph. + k2end += 2; + } else if (y2 > text2_length) { + // Ran off the top of the graph. + k2start += 2; + } else if (!front) { + int k1_offset = v_offset + delta - k2; + if (k1_offset >= 0 && k1_offset < v_length && v1[k1_offset] != -1) { + int x1 = v1[k1_offset]; + int y1 = v_offset + x1 - k1_offset; + // Mirror x2 onto top-left coordinate system. + x2 = text1_length - x2; + if (x1 >= x2) { + // Overlap detected. + return diff_bisectSplit(text1, text2, x1, y1, deadline); + } + } + } + } + } + // Diff took too long and hit the deadline or + // number of diffs equals number of characters, no commonality at all. + LinkedList diffs = new LinkedList(); + diffs.add(new Diff(Operation.DELETE, text1)); + diffs.add(new Diff(Operation.INSERT, text2)); + return diffs; + } + + /** + * Given the location of the 'middle snake', split the diff in two parts + * and recurse. + * @param text1 Old string to be diffed. + * @param text2 New string to be diffed. + * @param x Index of split point in text1. + * @param y Index of split point in text2. + * @param deadline Time at which to bail if not yet complete. + * @return LinkedList of Diff objects. + */ + private LinkedList diff_bisectSplit(String text1, String text2, + int x, int y, long deadline) { + String text1a = text1.substring(0, x); + String text2a = text2.substring(0, y); + String text1b = text1.substring(x); + String text2b = text2.substring(y); + + // Compute both diffs serially. + LinkedList diffs = diff_main(text1a, text2a, false, deadline); + LinkedList diffsb = diff_main(text1b, text2b, false, deadline); + + diffs.addAll(diffsb); + return diffs; + } + + /** + * Split two texts into a list of strings. Reduce the texts to a string of + * hashes where each Unicode character represents one line. + * @param text1 First string. + * @param text2 Second string. + * @return An object containing the encoded text1, the encoded text2 and + * the List of unique strings. The zeroth element of the List of + * unique strings is intentionally blank. + */ + protected LinesToCharsResult diff_linesToChars(String text1, String text2) { + List lineArray = new ArrayList(); + Map lineHash = new HashMap(); + // e.g. linearray[4] == "Hello\n" + // e.g. linehash.get("Hello\n") == 4 + + // "\x00" is a valid character, but various debuggers don't like it. + // So we'll insert a junk entry to avoid generating a null character. + lineArray.add(""); + + // Allocate 2/3rds of the space for text1, the rest for text2. + String chars1 = diff_linesToCharsMunge(text1, lineArray, lineHash, 40000); + String chars2 = diff_linesToCharsMunge(text2, lineArray, lineHash, 65535); + return new LinesToCharsResult(chars1, chars2, lineArray); + } + + /** + * Split a text into a list of strings. Reduce the texts to a string of + * hashes where each Unicode character represents one line. + * @param text String to encode. + * @param lineArray List of unique strings. + * @param lineHash Map of strings to indices. + * @param maxLines Maximum length of lineArray. + * @return Encoded string. + */ + private String diff_linesToCharsMunge(String text, List lineArray, + Map lineHash, int maxLines) { + int lineStart = 0; + int lineEnd = -1; + String line; + StringBuilder chars = new StringBuilder(); + // Walk the text, pulling out a substring for each line. + // text.split('\n') would would temporarily double our memory footprint. + // Modifying text would create many large strings to garbage collect. + while (lineEnd < text.length() - 1) { + lineEnd = text.indexOf('\n', lineStart); + if (lineEnd == -1) { + lineEnd = text.length() - 1; + } + line = text.substring(lineStart, lineEnd + 1); + + if (lineHash.containsKey(line)) { + chars.append(String.valueOf((char) (int) lineHash.get(line))); + } else { + if (lineArray.size() == maxLines) { + // Bail out at 65535 because + // String.valueOf((char) 65536).equals(String.valueOf(((char) 0))) + line = text.substring(lineStart); + lineEnd = text.length(); + } + lineArray.add(line); + lineHash.put(line, lineArray.size() - 1); + chars.append(String.valueOf((char) (lineArray.size() - 1))); + } + lineStart = lineEnd + 1; + } + return chars.toString(); + } + + /** + * Rehydrate the text in a diff from a string of line hashes to real lines of + * text. + * @param diffs List of Diff objects. + * @param lineArray List of unique strings. + */ + protected void diff_charsToLines(List diffs, + List lineArray) { + StringBuilder text; + for (Diff diff : diffs) { + text = new StringBuilder(); + for (int j = 0; j < diff.text.length(); j++) { + text.append(lineArray.get(diff.text.charAt(j))); + } + diff.text = text.toString(); + } + } + + /** + * Determine the common prefix of two strings + * @param text1 First string. + * @param text2 Second string. + * @return The number of characters common to the start of each string. + */ + public int diff_commonPrefix(String text1, String text2) { + // Performance analysis: https://neil.fraser.name/news/2007/10/09/ + int n = Math.min(text1.length(), text2.length()); + for (int i = 0; i < n; i++) { + if (text1.charAt(i) != text2.charAt(i)) { + return i; + } + } + return n; + } + + /** + * Determine the common suffix of two strings + * @param text1 First string. + * @param text2 Second string. + * @return The number of characters common to the end of each string. + */ + public int diff_commonSuffix(String text1, String text2) { + // Performance analysis: https://neil.fraser.name/news/2007/10/09/ + int text1_length = text1.length(); + int text2_length = text2.length(); + int n = Math.min(text1_length, text2_length); + for (int i = 1; i <= n; i++) { + if (text1.charAt(text1_length - i) != text2.charAt(text2_length - i)) { + return i - 1; + } + } + return n; + } + + /** + * Determine if the suffix of one string is the prefix of another. + * @param text1 First string. + * @param text2 Second string. + * @return The number of characters common to the end of the first + * string and the start of the second string. + */ + protected int diff_commonOverlap(String text1, String text2) { + // Cache the text lengths to prevent multiple calls. + int text1_length = text1.length(); + int text2_length = text2.length(); + // Eliminate the null case. + if (text1_length == 0 || text2_length == 0) { + return 0; + } + // Truncate the longer string. + if (text1_length > text2_length) { + text1 = text1.substring(text1_length - text2_length); + } else if (text1_length < text2_length) { + text2 = text2.substring(0, text1_length); + } + int text_length = Math.min(text1_length, text2_length); + // Quick check for the worst case. + if (text1.equals(text2)) { + return text_length; + } + + // Start by looking for a single character match + // and increase length until no match is found. + // Performance analysis: https://neil.fraser.name/news/2010/11/04/ + int best = 0; + int length = 1; + while (true) { + String pattern = text1.substring(text_length - length); + int found = text2.indexOf(pattern); + if (found == -1) { + return best; + } + length += found; + if (found == 0 || text1.substring(text_length - length).equals( + text2.substring(0, length))) { + best = length; + length++; + } + } + } + + /** + * Do the two texts share a substring which is at least half the length of + * the longer text? + * This speedup can produce non-minimal diffs. + * @param text1 First string. + * @param text2 Second string. + * @return Five element String array, containing the prefix of text1, the + * suffix of text1, the prefix of text2, the suffix of text2 and the + * common middle. Or null if there was no match. + */ + protected String[] diff_halfMatch(String text1, String text2) { + if (Diff_Timeout <= 0) { + // Don't risk returning a non-optimal diff if we have unlimited time. + return null; + } + String longtext = text1.length() > text2.length() ? text1 : text2; + String shorttext = text1.length() > text2.length() ? text2 : text1; + if (longtext.length() < 4 || shorttext.length() * 2 < longtext.length()) { + return null; // Pointless. + } + + // First check if the second quarter is the seed for a half-match. + String[] hm1 = diff_halfMatchI(longtext, shorttext, + (longtext.length() + 3) / 4); + // Check again based on the third quarter. + String[] hm2 = diff_halfMatchI(longtext, shorttext, + (longtext.length() + 1) / 2); + String[] hm; + if (hm1 == null && hm2 == null) { + return null; + } else if (hm2 == null) { + hm = hm1; + } else if (hm1 == null) { + hm = hm2; + } else { + // Both matched. Select the longest. + hm = hm1[4].length() > hm2[4].length() ? hm1 : hm2; + } + + // A half-match was found, sort out the return data. + if (text1.length() > text2.length()) { + return hm; + //return new String[]{hm[0], hm[1], hm[2], hm[3], hm[4]}; + } else { + return new String[]{hm[2], hm[3], hm[0], hm[1], hm[4]}; + } + } + + /** + * Does a substring of shorttext exist within longtext such that the + * substring is at least half the length of longtext? + * @param longtext Longer string. + * @param shorttext Shorter string. + * @param i Start index of quarter length substring within longtext. + * @return Five element String array, containing the prefix of longtext, the + * suffix of longtext, the prefix of shorttext, the suffix of shorttext + * and the common middle. Or null if there was no match. + */ + private String[] diff_halfMatchI(String longtext, String shorttext, int i) { + // Start with a 1/4 length substring at position i as a seed. + String seed = longtext.substring(i, i + longtext.length() / 4); + int j = -1; + String best_common = ""; + String best_longtext_a = "", best_longtext_b = ""; + String best_shorttext_a = "", best_shorttext_b = ""; + while ((j = shorttext.indexOf(seed, j + 1)) != -1) { + int prefixLength = diff_commonPrefix(longtext.substring(i), + shorttext.substring(j)); + int suffixLength = diff_commonSuffix(longtext.substring(0, i), + shorttext.substring(0, j)); + if (best_common.length() < suffixLength + prefixLength) { + best_common = shorttext.substring(j - suffixLength, j) + + shorttext.substring(j, j + prefixLength); + best_longtext_a = longtext.substring(0, i - suffixLength); + best_longtext_b = longtext.substring(i + prefixLength); + best_shorttext_a = shorttext.substring(0, j - suffixLength); + best_shorttext_b = shorttext.substring(j + prefixLength); + } + } + if (best_common.length() * 2 >= longtext.length()) { + return new String[]{best_longtext_a, best_longtext_b, + best_shorttext_a, best_shorttext_b, best_common}; + } else { + return null; + } + } + + /** + * Reduce the number of edits by eliminating semantically trivial equalities. + * @param diffs LinkedList of Diff objects. + */ + public void diff_cleanupSemantic(LinkedList diffs) { + if (diffs.isEmpty()) { + return; + } + boolean changes = false; + Deque equalities = new ArrayDeque(); // Double-ended queue of qualities. + String lastEquality = null; // Always equal to equalities.peek().text + ListIterator pointer = diffs.listIterator(); + // Number of characters that changed prior to the equality. + int length_insertions1 = 0; + int length_deletions1 = 0; + // Number of characters that changed after the equality. + int length_insertions2 = 0; + int length_deletions2 = 0; + Diff thisDiff = pointer.next(); + while (thisDiff != null) { + if (thisDiff.operation == Operation.EQUAL) { + // Equality found. + equalities.push(thisDiff); + length_insertions1 = length_insertions2; + length_deletions1 = length_deletions2; + length_insertions2 = 0; + length_deletions2 = 0; + lastEquality = thisDiff.text; + } else { + // An insertion or deletion. + if (thisDiff.operation == Operation.INSERT) { + length_insertions2 += thisDiff.text.length(); + } else { + length_deletions2 += thisDiff.text.length(); + } + // Eliminate an equality that is smaller or equal to the edits on both + // sides of it. + if (lastEquality != null && (lastEquality.length() + <= Math.max(length_insertions1, length_deletions1)) + && (lastEquality.length() + <= Math.max(length_insertions2, length_deletions2))) { + //System.out.println("Splitting: '" + lastEquality + "'"); + // Walk back to offending equality. + while (thisDiff != equalities.peek()) { + thisDiff = pointer.previous(); + } + pointer.next(); + + // Replace equality with a delete. + pointer.set(new Diff(Operation.DELETE, lastEquality)); + // Insert a corresponding an insert. + pointer.add(new Diff(Operation.INSERT, lastEquality)); + + equalities.pop(); // Throw away the equality we just deleted. + if (!equalities.isEmpty()) { + // Throw away the previous equality (it needs to be reevaluated). + equalities.pop(); + } + if (equalities.isEmpty()) { + // There are no previous equalities, walk back to the start. + while (pointer.hasPrevious()) { + pointer.previous(); + } + } else { + // There is a safe equality we can fall back to. + thisDiff = equalities.peek(); + while (thisDiff != pointer.previous()) { + // Intentionally empty loop. + } + } + + length_insertions1 = 0; // Reset the counters. + length_insertions2 = 0; + length_deletions1 = 0; + length_deletions2 = 0; + lastEquality = null; + changes = true; + } + } + thisDiff = pointer.hasNext() ? pointer.next() : null; + } + + // Normalize the diff. + if (changes) { + diff_cleanupMerge(diffs); + } + diff_cleanupSemanticLossless(diffs); + + // Find any overlaps between deletions and insertions. + // e.g: abcxxxxxxdef + // -> abcxxxdef + // e.g: xxxabcdefxxx + // -> defxxxabc + // Only extract an overlap if it is as big as the edit ahead or behind it. + pointer = diffs.listIterator(); + Diff prevDiff = null; + thisDiff = null; + if (pointer.hasNext()) { + prevDiff = pointer.next(); + if (pointer.hasNext()) { + thisDiff = pointer.next(); + } + } + while (thisDiff != null) { + if (prevDiff.operation == Operation.DELETE && + thisDiff.operation == Operation.INSERT) { + String deletion = prevDiff.text; + String insertion = thisDiff.text; + int overlap_length1 = this.diff_commonOverlap(deletion, insertion); + int overlap_length2 = this.diff_commonOverlap(insertion, deletion); + if (overlap_length1 >= overlap_length2) { + if (overlap_length1 >= deletion.length() / 2.0 || + overlap_length1 >= insertion.length() / 2.0) { + // Overlap found. Insert an equality and trim the surrounding edits. + pointer.previous(); + pointer.add(new Diff(Operation.EQUAL, + insertion.substring(0, overlap_length1))); + prevDiff.text = + deletion.substring(0, deletion.length() - overlap_length1); + thisDiff.text = insertion.substring(overlap_length1); + // pointer.add inserts the element before the cursor, so there is + // no need to step past the new element. + } + } else { + if (overlap_length2 >= deletion.length() / 2.0 || + overlap_length2 >= insertion.length() / 2.0) { + // Reverse overlap found. + // Insert an equality and swap and trim the surrounding edits. + pointer.previous(); + pointer.add(new Diff(Operation.EQUAL, + deletion.substring(0, overlap_length2))); + prevDiff.operation = Operation.INSERT; + prevDiff.text = + insertion.substring(0, insertion.length() - overlap_length2); + thisDiff.operation = Operation.DELETE; + thisDiff.text = deletion.substring(overlap_length2); + // pointer.add inserts the element before the cursor, so there is + // no need to step past the new element. + } + } + thisDiff = pointer.hasNext() ? pointer.next() : null; + } + prevDiff = thisDiff; + thisDiff = pointer.hasNext() ? pointer.next() : null; + } + } + + /** + * Look for single edits surrounded on both sides by equalities + * which can be shifted sideways to align the edit to a word boundary. + * e.g: The cat came. -> The cat came. + * @param diffs LinkedList of Diff objects. + */ + public void diff_cleanupSemanticLossless(LinkedList diffs) { + String equality1, edit, equality2; + String commonString; + int commonOffset; + int score, bestScore; + String bestEquality1, bestEdit, bestEquality2; + // Create a new iterator at the start. + ListIterator pointer = diffs.listIterator(); + Diff prevDiff = pointer.hasNext() ? pointer.next() : null; + Diff thisDiff = pointer.hasNext() ? pointer.next() : null; + Diff nextDiff = pointer.hasNext() ? pointer.next() : null; + // Intentionally ignore the first and last element (don't need checking). + while (nextDiff != null) { + if (prevDiff.operation == Operation.EQUAL && + nextDiff.operation == Operation.EQUAL) { + // This is a single edit surrounded by equalities. + equality1 = prevDiff.text; + edit = thisDiff.text; + equality2 = nextDiff.text; + + // First, shift the edit as far left as possible. + commonOffset = diff_commonSuffix(equality1, edit); + if (commonOffset != 0) { + commonString = edit.substring(edit.length() - commonOffset); + equality1 = equality1.substring(0, equality1.length() - commonOffset); + edit = commonString + edit.substring(0, edit.length() - commonOffset); + equality2 = commonString + equality2; + } + + // Second, step character by character right, looking for the best fit. + bestEquality1 = equality1; + bestEdit = edit; + bestEquality2 = equality2; + bestScore = diff_cleanupSemanticScore(equality1, edit) + + diff_cleanupSemanticScore(edit, equality2); + while (edit.length() != 0 && equality2.length() != 0 + && edit.charAt(0) == equality2.charAt(0)) { + equality1 += edit.charAt(0); + edit = edit.substring(1) + equality2.charAt(0); + equality2 = equality2.substring(1); + score = diff_cleanupSemanticScore(equality1, edit) + + diff_cleanupSemanticScore(edit, equality2); + // The >= encourages trailing rather than leading whitespace on edits. + if (score >= bestScore) { + bestScore = score; + bestEquality1 = equality1; + bestEdit = edit; + bestEquality2 = equality2; + } + } + + if (!prevDiff.text.equals(bestEquality1)) { + // We have an improvement, save it back to the diff. + if (bestEquality1.length() != 0) { + prevDiff.text = bestEquality1; + } else { + pointer.previous(); // Walk past nextDiff. + pointer.previous(); // Walk past thisDiff. + pointer.previous(); // Walk past prevDiff. + pointer.remove(); // Delete prevDiff. + pointer.next(); // Walk past thisDiff. + pointer.next(); // Walk past nextDiff. + } + thisDiff.text = bestEdit; + if (bestEquality2.length() != 0) { + nextDiff.text = bestEquality2; + } else { + pointer.remove(); // Delete nextDiff. + nextDiff = thisDiff; + thisDiff = prevDiff; + } + } + } + prevDiff = thisDiff; + thisDiff = nextDiff; + nextDiff = pointer.hasNext() ? pointer.next() : null; + } + } + + /** + * Given two strings, compute a score representing whether the internal + * boundary falls on logical boundaries. + * Scores range from 6 (best) to 0 (worst). + * @param one First string. + * @param two Second string. + * @return The score. + */ + private int diff_cleanupSemanticScore(String one, String two) { + if (one.length() == 0 || two.length() == 0) { + // Edges are the best. + return 6; + } + + // Each port of this function behaves slightly differently due to + // subtle differences in each language's definition of things like + // 'whitespace'. Since this function's purpose is largely cosmetic, + // the choice has been made to use each language's native features + // rather than force total conformity. + char char1 = one.charAt(one.length() - 1); + char char2 = two.charAt(0); + boolean nonAlphaNumeric1 = !Character.isLetterOrDigit(char1); + boolean nonAlphaNumeric2 = !Character.isLetterOrDigit(char2); + boolean whitespace1 = nonAlphaNumeric1 && Character.isWhitespace(char1); + boolean whitespace2 = nonAlphaNumeric2 && Character.isWhitespace(char2); + boolean lineBreak1 = whitespace1 + && Character.getType(char1) == Character.CONTROL; + boolean lineBreak2 = whitespace2 + && Character.getType(char2) == Character.CONTROL; + boolean blankLine1 = lineBreak1 && BLANKLINEEND.matcher(one).find(); + boolean blankLine2 = lineBreak2 && BLANKLINESTART.matcher(two).find(); + + if (blankLine1 || blankLine2) { + // Five points for blank lines. + return 5; + } else if (lineBreak1 || lineBreak2) { + // Four points for line breaks. + return 4; + } else if (nonAlphaNumeric1 && !whitespace1 && whitespace2) { + // Three points for end of sentences. + return 3; + } else if (whitespace1 || whitespace2) { + // Two points for whitespace. + return 2; + } else if (nonAlphaNumeric1 || nonAlphaNumeric2) { + // One point for non-alphanumeric. + return 1; + } + return 0; + } + + // Define some regex patterns for matching boundaries. + private Pattern BLANKLINEEND + = Pattern.compile("\\n\\r?\\n\\Z", Pattern.DOTALL); + private Pattern BLANKLINESTART + = Pattern.compile("\\A\\r?\\n\\r?\\n", Pattern.DOTALL); + + /** + * Reduce the number of edits by eliminating operationally trivial equalities. + * @param diffs LinkedList of Diff objects. + */ + public void diff_cleanupEfficiency(LinkedList diffs) { + if (diffs.isEmpty()) { + return; + } + boolean changes = false; + Deque equalities = new ArrayDeque(); // Double-ended queue of equalities. + String lastEquality = null; // Always equal to equalities.peek().text + ListIterator pointer = diffs.listIterator(); + // Is there an insertion operation before the last equality. + boolean pre_ins = false; + // Is there a deletion operation before the last equality. + boolean pre_del = false; + // Is there an insertion operation after the last equality. + boolean post_ins = false; + // Is there a deletion operation after the last equality. + boolean post_del = false; + Diff thisDiff = pointer.next(); + Diff safeDiff = thisDiff; // The last Diff that is known to be unsplittable. + while (thisDiff != null) { + if (thisDiff.operation == Operation.EQUAL) { + // Equality found. + if (thisDiff.text.length() < Diff_EditCost && (post_ins || post_del)) { + // Candidate found. + equalities.push(thisDiff); + pre_ins = post_ins; + pre_del = post_del; + lastEquality = thisDiff.text; + } else { + // Not a candidate, and can never become one. + equalities.clear(); + lastEquality = null; + safeDiff = thisDiff; + } + post_ins = post_del = false; + } else { + // An insertion or deletion. + if (thisDiff.operation == Operation.DELETE) { + post_del = true; + } else { + post_ins = true; + } + /* + * Five types to be split: + * ABXYCD + * AXCD + * ABXC + * AXCD + * ABXC + */ + if (lastEquality != null + && ((pre_ins && pre_del && post_ins && post_del) + || ((lastEquality.length() < Diff_EditCost / 2) + && ((pre_ins ? 1 : 0) + (pre_del ? 1 : 0) + + (post_ins ? 1 : 0) + (post_del ? 1 : 0)) == 3))) { + //System.out.println("Splitting: '" + lastEquality + "'"); + // Walk back to offending equality. + while (thisDiff != equalities.peek()) { + thisDiff = pointer.previous(); + } + pointer.next(); + + // Replace equality with a delete. + pointer.set(new Diff(Operation.DELETE, lastEquality)); + // Insert a corresponding an insert. + pointer.add(thisDiff = new Diff(Operation.INSERT, lastEquality)); + + equalities.pop(); // Throw away the equality we just deleted. + lastEquality = null; + if (pre_ins && pre_del) { + // No changes made which could affect previous entry, keep going. + post_ins = post_del = true; + equalities.clear(); + safeDiff = thisDiff; + } else { + if (!equalities.isEmpty()) { + // Throw away the previous equality (it needs to be reevaluated). + equalities.pop(); + } + if (equalities.isEmpty()) { + // There are no previous questionable equalities, + // walk back to the last known safe diff. + thisDiff = safeDiff; + } else { + // There is an equality we can fall back to. + thisDiff = equalities.peek(); + } + while (thisDiff != pointer.previous()) { + // Intentionally empty loop. + } + post_ins = post_del = false; + } + + changes = true; + } + } + thisDiff = pointer.hasNext() ? pointer.next() : null; + } + + if (changes) { + diff_cleanupMerge(diffs); + } + } + + /** + * Reorder and merge like edit sections. Merge equalities. + * Any edit section can move as long as it doesn't cross an equality. + * @param diffs LinkedList of Diff objects. + */ + public void diff_cleanupMerge(LinkedList diffs) { + diffs.add(new Diff(Operation.EQUAL, "")); // Add a dummy entry at the end. + ListIterator pointer = diffs.listIterator(); + int count_delete = 0; + int count_insert = 0; + String text_delete = ""; + String text_insert = ""; + Diff thisDiff = pointer.next(); + Diff prevEqual = null; + int commonlength; + while (thisDiff != null) { + switch (thisDiff.operation) { + case INSERT: + count_insert++; + text_insert += thisDiff.text; + prevEqual = null; + break; + case DELETE: + count_delete++; + text_delete += thisDiff.text; + prevEqual = null; + break; + case EQUAL: + if (count_delete + count_insert > 1) { + boolean both_types = count_delete != 0 && count_insert != 0; + // Delete the offending records. + pointer.previous(); // Reverse direction. + while (count_delete-- > 0) { + pointer.previous(); + pointer.remove(); + } + while (count_insert-- > 0) { + pointer.previous(); + pointer.remove(); + } + if (both_types) { + // Factor out any common prefixies. + commonlength = diff_commonPrefix(text_insert, text_delete); + if (commonlength != 0) { + if (pointer.hasPrevious()) { + thisDiff = pointer.previous(); + assert thisDiff.operation == Operation.EQUAL + : "Previous diff should have been an equality."; + thisDiff.text += text_insert.substring(0, commonlength); + pointer.next(); + } else { + pointer.add(new Diff(Operation.EQUAL, + text_insert.substring(0, commonlength))); + } + text_insert = text_insert.substring(commonlength); + text_delete = text_delete.substring(commonlength); + } + // Factor out any common suffixies. + commonlength = diff_commonSuffix(text_insert, text_delete); + if (commonlength != 0) { + thisDiff = pointer.next(); + thisDiff.text = text_insert.substring(text_insert.length() + - commonlength) + thisDiff.text; + text_insert = text_insert.substring(0, text_insert.length() + - commonlength); + text_delete = text_delete.substring(0, text_delete.length() + - commonlength); + pointer.previous(); + } + } + // Insert the merged records. + if (text_delete.length() != 0) { + pointer.add(new Diff(Operation.DELETE, text_delete)); + } + if (text_insert.length() != 0) { + pointer.add(new Diff(Operation.INSERT, text_insert)); + } + // Step forward to the equality. + thisDiff = pointer.hasNext() ? pointer.next() : null; + } else if (prevEqual != null) { + // Merge this equality with the previous one. + prevEqual.text += thisDiff.text; + pointer.remove(); + thisDiff = pointer.previous(); + pointer.next(); // Forward direction + } + count_insert = 0; + count_delete = 0; + text_delete = ""; + text_insert = ""; + prevEqual = thisDiff; + break; + } + thisDiff = pointer.hasNext() ? pointer.next() : null; + } + if (diffs.getLast().text.length() == 0) { + diffs.removeLast(); // Remove the dummy entry at the end. + } + + /* + * Second pass: look for single edits surrounded on both sides by equalities + * which can be shifted sideways to eliminate an equality. + * e.g: ABAC -> ABAC + */ + boolean changes = false; + // Create a new iterator at the start. + // (As opposed to walking the current one back.) + pointer = diffs.listIterator(); + Diff prevDiff = pointer.hasNext() ? pointer.next() : null; + thisDiff = pointer.hasNext() ? pointer.next() : null; + Diff nextDiff = pointer.hasNext() ? pointer.next() : null; + // Intentionally ignore the first and last element (don't need checking). + while (nextDiff != null) { + if (prevDiff.operation == Operation.EQUAL && + nextDiff.operation == Operation.EQUAL) { + // This is a single edit surrounded by equalities. + if (thisDiff.text.endsWith(prevDiff.text)) { + // Shift the edit over the previous equality. + thisDiff.text = prevDiff.text + + thisDiff.text.substring(0, thisDiff.text.length() + - prevDiff.text.length()); + nextDiff.text = prevDiff.text + nextDiff.text; + pointer.previous(); // Walk past nextDiff. + pointer.previous(); // Walk past thisDiff. + pointer.previous(); // Walk past prevDiff. + pointer.remove(); // Delete prevDiff. + pointer.next(); // Walk past thisDiff. + thisDiff = pointer.next(); // Walk past nextDiff. + nextDiff = pointer.hasNext() ? pointer.next() : null; + changes = true; + } else if (thisDiff.text.startsWith(nextDiff.text)) { + // Shift the edit over the next equality. + prevDiff.text += nextDiff.text; + thisDiff.text = thisDiff.text.substring(nextDiff.text.length()) + + nextDiff.text; + pointer.remove(); // Delete nextDiff. + nextDiff = pointer.hasNext() ? pointer.next() : null; + changes = true; + } + } + prevDiff = thisDiff; + thisDiff = nextDiff; + nextDiff = pointer.hasNext() ? pointer.next() : null; + } + // If shifts were made, the diff needs reordering and another shift sweep. + if (changes) { + diff_cleanupMerge(diffs); + } + } + + /** + * loc is a location in text1, compute and return the equivalent location in + * text2. + * e.g. "The cat" vs "The big cat", 1->1, 5->8 + * @param diffs List of Diff objects. + * @param loc Location within text1. + * @return Location within text2. + */ + public int diff_xIndex(List diffs, int loc) { + int chars1 = 0; + int chars2 = 0; + int last_chars1 = 0; + int last_chars2 = 0; + Diff lastDiff = null; + for (Diff aDiff : diffs) { + if (aDiff.operation != Operation.INSERT) { + // Equality or deletion. + chars1 += aDiff.text.length(); + } + if (aDiff.operation != Operation.DELETE) { + // Equality or insertion. + chars2 += aDiff.text.length(); + } + if (chars1 > loc) { + // Overshot the location. + lastDiff = aDiff; + break; + } + last_chars1 = chars1; + last_chars2 = chars2; + } + if (lastDiff != null && lastDiff.operation == Operation.DELETE) { + // The location was deleted. + return last_chars2; + } + // Add the remaining character length. + return last_chars2 + (loc - last_chars1); + } + + /** + * Convert a Diff list into a pretty HTML report. + * @param diffs List of Diff objects. + * @return HTML representation. + */ + public String diff_prettyHtml(List diffs) { + StringBuilder html = new StringBuilder(); + for (Diff aDiff : diffs) { + String text = aDiff.text.replace("&", "&").replace("<", "<") + .replace(">", ">").replace("\n", "¶
"); + switch (aDiff.operation) { + case INSERT: + html.append("").append(text) + .append(""); + break; + case DELETE: + html.append("").append(text) + .append(""); + break; + case EQUAL: + html.append("").append(text).append(""); + break; + } + } + return html.toString(); + } + + /** + * Compute and return the source text (all equalities and deletions). + * @param diffs List of Diff objects. + * @return Source text. + */ + public String diff_text1(List diffs) { + StringBuilder text = new StringBuilder(); + for (Diff aDiff : diffs) { + if (aDiff.operation != Operation.INSERT) { + text.append(aDiff.text); + } + } + return text.toString(); + } + + /** + * Compute and return the destination text (all equalities and insertions). + * @param diffs List of Diff objects. + * @return Destination text. + */ + public String diff_text2(List diffs) { + StringBuilder text = new StringBuilder(); + for (Diff aDiff : diffs) { + if (aDiff.operation != Operation.DELETE) { + text.append(aDiff.text); + } + } + return text.toString(); + } + + /** + * Compute the Levenshtein distance; the number of inserted, deleted or + * substituted characters. + * @param diffs List of Diff objects. + * @return Number of changes. + */ + public int diff_levenshtein(List diffs) { + int levenshtein = 0; + int insertions = 0; + int deletions = 0; + for (Diff aDiff : diffs) { + switch (aDiff.operation) { + case INSERT: + insertions += aDiff.text.length(); + break; + case DELETE: + deletions += aDiff.text.length(); + break; + case EQUAL: + // A deletion and an insertion is one substitution. + levenshtein += Math.max(insertions, deletions); + insertions = 0; + deletions = 0; + break; + } + } + levenshtein += Math.max(insertions, deletions); + return levenshtein; + } + + /** + * Crush the diff into an encoded string which describes the operations + * required to transform text1 into text2. + * E.g. =3\t-2\t+ing -> Keep 3 chars, delete 2 chars, insert 'ing'. + * Operations are tab-separated. Inserted text is escaped using %xx notation. + * @param diffs List of Diff objects. + * @return Delta text. + */ + public String diff_toDelta(List diffs) { + StringBuilder text = new StringBuilder(); + for (Diff aDiff : diffs) { + switch (aDiff.operation) { + case INSERT: + try { + text.append("+").append(URLEncoder.encode(aDiff.text, "UTF-8") + .replace('+', ' ')).append("\t"); + } catch (UnsupportedEncodingException e) { + // Not likely on modern system. + throw new Error("This system does not support UTF-8.", e); + } + break; + case DELETE: + text.append("-").append(aDiff.text.length()).append("\t"); + break; + case EQUAL: + text.append("=").append(aDiff.text.length()).append("\t"); + break; + } + } + String delta = text.toString(); + if (delta.length() != 0) { + // Strip off trailing tab character. + delta = delta.substring(0, delta.length() - 1); + delta = unescapeForEncodeUriCompatability(delta); + } + return delta; + } + + /** + * Given the original text1, and an encoded string which describes the + * operations required to transform text1 into text2, compute the full diff. + * @param text1 Source string for the diff. + * @param delta Delta text. + * @return Array of Diff objects or null if invalid. + * @throws IllegalArgumentException If invalid input. + */ + public LinkedList diff_fromDelta(String text1, String delta) + throws IllegalArgumentException { + LinkedList diffs = new LinkedList(); + int pointer = 0; // Cursor in text1 + String[] tokens = delta.split("\t"); + for (String token : tokens) { + if (token.length() == 0) { + // Blank tokens are ok (from a trailing \t). + continue; + } + // Each token begins with a one character parameter which specifies the + // operation of this token (delete, insert, equality). + String param = token.substring(1); + switch (token.charAt(0)) { + case '+': + // decode would change all "+" to " " + param = param.replace("+", "%2B"); + try { + param = URLDecoder.decode(param, "UTF-8"); + } catch (UnsupportedEncodingException e) { + // Not likely on modern system. + throw new Error("This system does not support UTF-8.", e); + } catch (IllegalArgumentException e) { + // Malformed URI sequence. + throw new IllegalArgumentException( + "Illegal escape in diff_fromDelta: " + param, e); + } + diffs.add(new Diff(Operation.INSERT, param)); + break; + case '-': + // Fall through. + case '=': + int n; + try { + n = Integer.parseInt(param); + } catch (NumberFormatException e) { + throw new IllegalArgumentException( + "Invalid number in diff_fromDelta: " + param, e); + } + if (n < 0) { + throw new IllegalArgumentException( + "Negative number in diff_fromDelta: " + param); + } + String text; + try { + text = text1.substring(pointer, pointer += n); + } catch (StringIndexOutOfBoundsException e) { + throw new IllegalArgumentException("Delta length (" + pointer + + ") larger than source text length (" + text1.length() + + ").", e); + } + if (token.charAt(0) == '=') { + diffs.add(new Diff(Operation.EQUAL, text)); + } else { + diffs.add(new Diff(Operation.DELETE, text)); + } + break; + default: + // Anything else is an error. + throw new IllegalArgumentException( + "Invalid diff operation in diff_fromDelta: " + token.charAt(0)); + } + } + if (pointer != text1.length()) { + throw new IllegalArgumentException("Delta length (" + pointer + + ") smaller than source text length (" + text1.length() + ")."); + } + return diffs; + } + + + // MATCH FUNCTIONS + + + /** + * Locate the best instance of 'pattern' in 'text' near 'loc'. + * Returns -1 if no match found. + * @param text The text to search. + * @param pattern The pattern to search for. + * @param loc The location to search around. + * @return Best match index or -1. + */ + public int match_main(String text, String pattern, int loc) { + // Check for null inputs. + if (text == null || pattern == null) { + throw new IllegalArgumentException("Null inputs. (match_main)"); + } + + loc = Math.max(0, Math.min(loc, text.length())); + if (text.equals(pattern)) { + // Shortcut (potentially not guaranteed by the algorithm) + return 0; + } else if (text.length() == 0) { + // Nothing to match. + return -1; + } else if (loc + pattern.length() <= text.length() + && text.substring(loc, loc + pattern.length()).equals(pattern)) { + // Perfect match at the perfect spot! (Includes case of null pattern) + return loc; + } else { + // Do a fuzzy compare. + return match_bitap(text, pattern, loc); + } + } + + /** + * Locate the best instance of 'pattern' in 'text' near 'loc' using the + * Bitap algorithm. Returns -1 if no match found. + * @param text The text to search. + * @param pattern The pattern to search for. + * @param loc The location to search around. + * @return Best match index or -1. + */ + protected int match_bitap(String text, String pattern, int loc) { + assert (Match_MaxBits == 0 || pattern.length() <= Match_MaxBits) + : "Pattern too long for this application."; + + // Initialise the alphabet. + Map s = match_alphabet(pattern); + + // Highest score beyond which we give up. + double score_threshold = Match_Threshold; + // Is there a nearby exact match? (speedup) + int best_loc = text.indexOf(pattern, loc); + if (best_loc != -1) { + score_threshold = Math.min(match_bitapScore(0, best_loc, loc, pattern), + score_threshold); + // What about in the other direction? (speedup) + best_loc = text.lastIndexOf(pattern, loc + pattern.length()); + if (best_loc != -1) { + score_threshold = Math.min(match_bitapScore(0, best_loc, loc, pattern), + score_threshold); + } + } + + // Initialise the bit arrays. + int matchmask = 1 << (pattern.length() - 1); + best_loc = -1; + + int bin_min, bin_mid; + int bin_max = pattern.length() + text.length(); + // Empty initialization added to appease Java compiler. + int[] last_rd = new int[0]; + for (int d = 0; d < pattern.length(); d++) { + // Scan for the best match; each iteration allows for one more error. + // Run a binary search to determine how far from 'loc' we can stray at + // this error level. + bin_min = 0; + bin_mid = bin_max; + while (bin_min < bin_mid) { + if (match_bitapScore(d, loc + bin_mid, loc, pattern) + <= score_threshold) { + bin_min = bin_mid; + } else { + bin_max = bin_mid; + } + bin_mid = (bin_max - bin_min) / 2 + bin_min; + } + // Use the result from this iteration as the maximum for the next. + bin_max = bin_mid; + int start = Math.max(1, loc - bin_mid + 1); + int finish = Math.min(loc + bin_mid, text.length()) + pattern.length(); + + int[] rd = new int[finish + 2]; + rd[finish + 1] = (1 << d) - 1; + for (int j = finish; j >= start; j--) { + int charMatch; + if (text.length() <= j - 1 || !s.containsKey(text.charAt(j - 1))) { + // Out of range. + charMatch = 0; + } else { + charMatch = s.get(text.charAt(j - 1)); + } + if (d == 0) { + // First pass: exact match. + rd[j] = ((rd[j + 1] << 1) | 1) & charMatch; + } else { + // Subsequent passes: fuzzy match. + rd[j] = (((rd[j + 1] << 1) | 1) & charMatch) + | (((last_rd[j + 1] | last_rd[j]) << 1) | 1) | last_rd[j + 1]; + } + if ((rd[j] & matchmask) != 0) { + double score = match_bitapScore(d, j - 1, loc, pattern); + // This match will almost certainly be better than any existing + // match. But check anyway. + if (score <= score_threshold) { + // Told you so. + score_threshold = score; + best_loc = j - 1; + if (best_loc > loc) { + // When passing loc, don't exceed our current distance from loc. + start = Math.max(1, 2 * loc - best_loc); + } else { + // Already passed loc, downhill from here on in. + break; + } + } + } + } + if (match_bitapScore(d + 1, loc, loc, pattern) > score_threshold) { + // No hope for a (better) match at greater error levels. + break; + } + last_rd = rd; + } + return best_loc; + } + + /** + * Compute and return the score for a match with e errors and x location. + * @param e Number of errors in match. + * @param x Location of match. + * @param loc Expected location of match. + * @param pattern Pattern being sought. + * @return Overall score for match (0.0 = good, 1.0 = bad). + */ + private double match_bitapScore(int e, int x, int loc, String pattern) { + float accuracy = (float) e / pattern.length(); + int proximity = Math.abs(loc - x); + if (Match_Distance == 0) { + // Dodge divide by zero error. + return proximity == 0 ? accuracy : 1.0; + } + return accuracy + (proximity / (float) Match_Distance); + } + + /** + * Initialise the alphabet for the Bitap algorithm. + * @param pattern The text to encode. + * @return Hash of character locations. + */ + protected Map match_alphabet(String pattern) { + Map s = new HashMap(); + char[] char_pattern = pattern.toCharArray(); + for (char c : char_pattern) { + s.put(c, 0); + } + int i = 0; + for (char c : char_pattern) { + s.put(c, s.get(c) | (1 << (pattern.length() - i - 1))); + i++; + } + return s; + } + + + // PATCH FUNCTIONS + + + /** + * Increase the context until it is unique, + * but don't let the pattern expand beyond Match_MaxBits. + * @param patch The patch to grow. + * @param text Source text. + */ + protected void patch_addContext(Patch patch, String text) { + if (text.length() == 0) { + return; + } + String pattern = text.substring(patch.start2, patch.start2 + patch.length1); + int padding = 0; + + // Look for the first and last matches of pattern in text. If two different + // matches are found, increase the pattern length. + while (text.indexOf(pattern) != text.lastIndexOf(pattern) + && pattern.length() < Match_MaxBits - Patch_Margin - Patch_Margin) { + padding += Patch_Margin; + pattern = text.substring(Math.max(0, patch.start2 - padding), + Math.min(text.length(), patch.start2 + patch.length1 + padding)); + } + // Add one chunk for good luck. + padding += Patch_Margin; + + // Add the prefix. + String prefix = text.substring(Math.max(0, patch.start2 - padding), + patch.start2); + if (prefix.length() != 0) { + patch.diffs.addFirst(new Diff(Operation.EQUAL, prefix)); + } + // Add the suffix. + String suffix = text.substring(patch.start2 + patch.length1, + Math.min(text.length(), patch.start2 + patch.length1 + padding)); + if (suffix.length() != 0) { + patch.diffs.addLast(new Diff(Operation.EQUAL, suffix)); + } + + // Roll back the start points. + patch.start1 -= prefix.length(); + patch.start2 -= prefix.length(); + // Extend the lengths. + patch.length1 += prefix.length() + suffix.length(); + patch.length2 += prefix.length() + suffix.length(); + } + + /** + * Compute a list of patches to turn text1 into text2. + * A set of diffs will be computed. + * @param text1 Old text. + * @param text2 New text. + * @return LinkedList of Patch objects. + */ + public LinkedList patch_make(String text1, String text2) { + if (text1 == null || text2 == null) { + throw new IllegalArgumentException("Null inputs. (patch_make)"); + } + // No diffs provided, compute our own. + LinkedList diffs = diff_main(text1, text2, true); + if (diffs.size() > 2) { + diff_cleanupSemantic(diffs); + diff_cleanupEfficiency(diffs); + } + return patch_make(text1, diffs); + } + + /** + * Compute a list of patches to turn text1 into text2. + * text1 will be derived from the provided diffs. + * @param diffs Array of Diff objects for text1 to text2. + * @return LinkedList of Patch objects. + */ + public LinkedList patch_make(LinkedList diffs) { + if (diffs == null) { + throw new IllegalArgumentException("Null inputs. (patch_make)"); + } + // No origin string provided, compute our own. + String text1 = diff_text1(diffs); + return patch_make(text1, diffs); + } + + /** + * Compute a list of patches to turn text1 into text2. + * text2 is ignored, diffs are the delta between text1 and text2. + * @param text1 Old text + * @param text2 Ignored. + * @param diffs Array of Diff objects for text1 to text2. + * @return LinkedList of Patch objects. + * @deprecated Prefer patch_make(String text1, LinkedList diffs). + */ + @Deprecated public LinkedList patch_make(String text1, String text2, + LinkedList diffs) { + return patch_make(text1, diffs); + } + + /** + * Compute a list of patches to turn text1 into text2. + * text2 is not provided, diffs are the delta between text1 and text2. + * @param text1 Old text. + * @param diffs Array of Diff objects for text1 to text2. + * @return LinkedList of Patch objects. + */ + public LinkedList patch_make(String text1, LinkedList diffs) { + if (text1 == null || diffs == null) { + throw new IllegalArgumentException("Null inputs. (patch_make)"); + } + + LinkedList patches = new LinkedList(); + if (diffs.isEmpty()) { + return patches; // Get rid of the null case. + } + Patch patch = new Patch(); + int char_count1 = 0; // Number of characters into the text1 string. + int char_count2 = 0; // Number of characters into the text2 string. + // Start with text1 (prepatch_text) and apply the diffs until we arrive at + // text2 (postpatch_text). We recreate the patches one by one to determine + // context info. + String prepatch_text = text1; + String postpatch_text = text1; + for (Diff aDiff : diffs) { + if (patch.diffs.isEmpty() && aDiff.operation != Operation.EQUAL) { + // A new patch starts here. + patch.start1 = char_count1; + patch.start2 = char_count2; + } + + switch (aDiff.operation) { + case INSERT: + patch.diffs.add(aDiff); + patch.length2 += aDiff.text.length(); + postpatch_text = postpatch_text.substring(0, char_count2) + + aDiff.text + postpatch_text.substring(char_count2); + break; + case DELETE: + patch.length1 += aDiff.text.length(); + patch.diffs.add(aDiff); + postpatch_text = postpatch_text.substring(0, char_count2) + + postpatch_text.substring(char_count2 + aDiff.text.length()); + break; + case EQUAL: + if (aDiff.text.length() <= 2 * Patch_Margin + && !patch.diffs.isEmpty() && aDiff != diffs.getLast()) { + // Small equality inside a patch. + patch.diffs.add(aDiff); + patch.length1 += aDiff.text.length(); + patch.length2 += aDiff.text.length(); + } + + if (aDiff.text.length() >= 2 * Patch_Margin && !patch.diffs.isEmpty()) { + // Time for a new patch. + if (!patch.diffs.isEmpty()) { + patch_addContext(patch, prepatch_text); + patches.add(patch); + patch = new Patch(); + // Unlike Unidiff, our patch lists have a rolling context. + // https://github.com/google/diff-match-patch/wiki/Unidiff + // Update prepatch text & pos to reflect the application of the + // just completed patch. + prepatch_text = postpatch_text; + char_count1 = char_count2; + } + } + break; + } + + // Update the current character count. + if (aDiff.operation != Operation.INSERT) { + char_count1 += aDiff.text.length(); + } + if (aDiff.operation != Operation.DELETE) { + char_count2 += aDiff.text.length(); + } + } + // Pick up the leftover patch if not empty. + if (!patch.diffs.isEmpty()) { + patch_addContext(patch, prepatch_text); + patches.add(patch); + } + + return patches; + } + + /** + * Given an array of patches, return another array that is identical. + * @param patches Array of Patch objects. + * @return Array of Patch objects. + */ + public LinkedList patch_deepCopy(LinkedList patches) { + LinkedList patchesCopy = new LinkedList(); + for (Patch aPatch : patches) { + Patch patchCopy = new Patch(); + for (Diff aDiff : aPatch.diffs) { + Diff diffCopy = new Diff(aDiff.operation, aDiff.text); + patchCopy.diffs.add(diffCopy); + } + patchCopy.start1 = aPatch.start1; + patchCopy.start2 = aPatch.start2; + patchCopy.length1 = aPatch.length1; + patchCopy.length2 = aPatch.length2; + patchesCopy.add(patchCopy); + } + return patchesCopy; + } + + /** + * Merge a set of patches onto the text. Return a patched text, as well + * as an array of true/false values indicating which patches were applied. + * @param patches Array of Patch objects + * @param text Old text. + * @return Two element Object array, containing the new text and an array of + * boolean values. + */ + public Object[] patch_apply(LinkedList patches, String text) { + if (patches.isEmpty()) { + return new Object[]{text, new boolean[0]}; + } + + // Deep copy the patches so that no changes are made to originals. + patches = patch_deepCopy(patches); + + String nullPadding = patch_addPadding(patches); + text = nullPadding + text + nullPadding; + patch_splitMax(patches); + + int x = 0; + // delta keeps track of the offset between the expected and actual location + // of the previous patch. If there are patches expected at positions 10 and + // 20, but the first patch was found at 12, delta is 2 and the second patch + // has an effective expected position of 22. + int delta = 0; + boolean[] results = new boolean[patches.size()]; + for (Patch aPatch : patches) { + int expected_loc = aPatch.start2 + delta; + String text1 = diff_text1(aPatch.diffs); + int start_loc; + int end_loc = -1; + if (text1.length() > this.Match_MaxBits) { + // patch_splitMax will only provide an oversized pattern in the case of + // a monster delete. + start_loc = match_main(text, + text1.substring(0, this.Match_MaxBits), expected_loc); + if (start_loc != -1) { + end_loc = match_main(text, + text1.substring(text1.length() - this.Match_MaxBits), + expected_loc + text1.length() - this.Match_MaxBits); + if (end_loc == -1 || start_loc >= end_loc) { + // Can't find valid trailing context. Drop this patch. + start_loc = -1; + } + } + } else { + start_loc = match_main(text, text1, expected_loc); + } + if (start_loc == -1) { + // No match found. :( + results[x] = false; + // Subtract the delta for this failed patch from subsequent patches. + delta -= aPatch.length2 - aPatch.length1; + } else { + // Found a match. :) + results[x] = true; + delta = start_loc - expected_loc; + String text2; + if (end_loc == -1) { + text2 = text.substring(start_loc, + Math.min(start_loc + text1.length(), text.length())); + } else { + text2 = text.substring(start_loc, + Math.min(end_loc + this.Match_MaxBits, text.length())); + } + if (text1.equals(text2)) { + // Perfect match, just shove the replacement text in. + text = text.substring(0, start_loc) + diff_text2(aPatch.diffs) + + text.substring(start_loc + text1.length()); + } else { + // Imperfect match. Run a diff to get a framework of equivalent + // indices. + LinkedList diffs = diff_main(text1, text2, false); + if (text1.length() > this.Match_MaxBits + && diff_levenshtein(diffs) / (float) text1.length() + > this.Patch_DeleteThreshold) { + // The end points match, but the content is unacceptably bad. + results[x] = false; + } else { + diff_cleanupSemanticLossless(diffs); + int index1 = 0; + for (Diff aDiff : aPatch.diffs) { + if (aDiff.operation != Operation.EQUAL) { + int index2 = diff_xIndex(diffs, index1); + if (aDiff.operation == Operation.INSERT) { + // Insertion + text = text.substring(0, start_loc + index2) + aDiff.text + + text.substring(start_loc + index2); + } else if (aDiff.operation == Operation.DELETE) { + // Deletion + text = text.substring(0, start_loc + index2) + + text.substring(start_loc + diff_xIndex(diffs, + index1 + aDiff.text.length())); + } + } + if (aDiff.operation != Operation.DELETE) { + index1 += aDiff.text.length(); + } + } + } + } + } + x++; + } + // Strip the padding off. + text = text.substring(nullPadding.length(), text.length() + - nullPadding.length()); + return new Object[]{text, results}; + } + + /** + * Add some padding on text start and end so that edges can match something. + * Intended to be called only from within patch_apply. + * @param patches Array of Patch objects. + * @return The padding string added to each side. + */ + public String patch_addPadding(LinkedList patches) { + short paddingLength = this.Patch_Margin; + String nullPadding = ""; + for (short x = 1; x <= paddingLength; x++) { + nullPadding += String.valueOf((char) x); + } + + // Bump all the patches forward. + for (Patch aPatch : patches) { + aPatch.start1 += paddingLength; + aPatch.start2 += paddingLength; + } + + // Add some padding on start of first diff. + Patch patch = patches.getFirst(); + LinkedList diffs = patch.diffs; + if (diffs.isEmpty() || diffs.getFirst().operation != Operation.EQUAL) { + // Add nullPadding equality. + diffs.addFirst(new Diff(Operation.EQUAL, nullPadding)); + patch.start1 -= paddingLength; // Should be 0. + patch.start2 -= paddingLength; // Should be 0. + patch.length1 += paddingLength; + patch.length2 += paddingLength; + } else if (paddingLength > diffs.getFirst().text.length()) { + // Grow first equality. + Diff firstDiff = diffs.getFirst(); + int extraLength = paddingLength - firstDiff.text.length(); + firstDiff.text = nullPadding.substring(firstDiff.text.length()) + + firstDiff.text; + patch.start1 -= extraLength; + patch.start2 -= extraLength; + patch.length1 += extraLength; + patch.length2 += extraLength; + } + + // Add some padding on end of last diff. + patch = patches.getLast(); + diffs = patch.diffs; + if (diffs.isEmpty() || diffs.getLast().operation != Operation.EQUAL) { + // Add nullPadding equality. + diffs.addLast(new Diff(Operation.EQUAL, nullPadding)); + patch.length1 += paddingLength; + patch.length2 += paddingLength; + } else if (paddingLength > diffs.getLast().text.length()) { + // Grow last equality. + Diff lastDiff = diffs.getLast(); + int extraLength = paddingLength - lastDiff.text.length(); + lastDiff.text += nullPadding.substring(0, extraLength); + patch.length1 += extraLength; + patch.length2 += extraLength; + } + + return nullPadding; + } + + /** + * Look through the patches and break up any which are longer than the + * maximum limit of the match algorithm. + * Intended to be called only from within patch_apply. + * @param patches LinkedList of Patch objects. + */ + public void patch_splitMax(LinkedList patches) { + short patch_size = Match_MaxBits; + String precontext, postcontext; + Patch patch; + int start1, start2; + boolean empty; + Operation diff_type; + String diff_text; + ListIterator pointer = patches.listIterator(); + Patch bigpatch = pointer.hasNext() ? pointer.next() : null; + while (bigpatch != null) { + if (bigpatch.length1 <= Match_MaxBits) { + bigpatch = pointer.hasNext() ? pointer.next() : null; + continue; + } + // Remove the big old patch. + pointer.remove(); + start1 = bigpatch.start1; + start2 = bigpatch.start2; + precontext = ""; + while (!bigpatch.diffs.isEmpty()) { + // Create one of several smaller patches. + patch = new Patch(); + empty = true; + patch.start1 = start1 - precontext.length(); + patch.start2 = start2 - precontext.length(); + if (precontext.length() != 0) { + patch.length1 = patch.length2 = precontext.length(); + patch.diffs.add(new Diff(Operation.EQUAL, precontext)); + } + while (!bigpatch.diffs.isEmpty() + && patch.length1 < patch_size - Patch_Margin) { + diff_type = bigpatch.diffs.getFirst().operation; + diff_text = bigpatch.diffs.getFirst().text; + if (diff_type == Operation.INSERT) { + // Insertions are harmless. + patch.length2 += diff_text.length(); + start2 += diff_text.length(); + patch.diffs.addLast(bigpatch.diffs.removeFirst()); + empty = false; + } else if (diff_type == Operation.DELETE && patch.diffs.size() == 1 + && patch.diffs.getFirst().operation == Operation.EQUAL + && diff_text.length() > 2 * patch_size) { + // This is a large deletion. Let it pass in one chunk. + patch.length1 += diff_text.length(); + start1 += diff_text.length(); + empty = false; + patch.diffs.add(new Diff(diff_type, diff_text)); + bigpatch.diffs.removeFirst(); + } else { + // Deletion or equality. Only take as much as we can stomach. + diff_text = diff_text.substring(0, Math.min(diff_text.length(), + patch_size - patch.length1 - Patch_Margin)); + patch.length1 += diff_text.length(); + start1 += diff_text.length(); + if (diff_type == Operation.EQUAL) { + patch.length2 += diff_text.length(); + start2 += diff_text.length(); + } else { + empty = false; + } + patch.diffs.add(new Diff(diff_type, diff_text)); + if (diff_text.equals(bigpatch.diffs.getFirst().text)) { + bigpatch.diffs.removeFirst(); + } else { + bigpatch.diffs.getFirst().text = bigpatch.diffs.getFirst().text + .substring(diff_text.length()); + } + } + } + // Compute the head context for the next patch. + precontext = diff_text2(patch.diffs); + precontext = precontext.substring(Math.max(0, precontext.length() + - Patch_Margin)); + // Append the end context for this patch. + if (diff_text1(bigpatch.diffs).length() > Patch_Margin) { + postcontext = diff_text1(bigpatch.diffs).substring(0, Patch_Margin); + } else { + postcontext = diff_text1(bigpatch.diffs); + } + if (postcontext.length() != 0) { + patch.length1 += postcontext.length(); + patch.length2 += postcontext.length(); + if (!patch.diffs.isEmpty() + && patch.diffs.getLast().operation == Operation.EQUAL) { + patch.diffs.getLast().text += postcontext; + } else { + patch.diffs.add(new Diff(Operation.EQUAL, postcontext)); + } + } + if (!empty) { + pointer.add(patch); + } + } + bigpatch = pointer.hasNext() ? pointer.next() : null; + } + } + + /** + * Take a list of patches and return a textual representation. + * @param patches List of Patch objects. + * @return Text representation of patches. + */ + public String patch_toText(List patches) { + StringBuilder text = new StringBuilder(); + for (Patch aPatch : patches) { + text.append(aPatch); + } + return text.toString(); + } + + /** + * Parse a textual representation of patches and return a List of Patch + * objects. + * @param textline Text representation of patches. + * @return List of Patch objects. + * @throws IllegalArgumentException If invalid input. + */ + public List patch_fromText(String textline) + throws IllegalArgumentException { + List patches = new LinkedList(); + if (textline.length() == 0) { + return patches; + } + List textList = Arrays.asList(textline.split("\n")); + LinkedList text = new LinkedList(textList); + Patch patch; + Pattern patchHeader + = Pattern.compile("^@@ -(\\d+),?(\\d*) \\+(\\d+),?(\\d*) @@$"); + Matcher m; + char sign; + String line; + while (!text.isEmpty()) { + m = patchHeader.matcher(text.getFirst()); + if (!m.matches()) { + throw new IllegalArgumentException( + "Invalid patch string: " + text.getFirst()); + } + patch = new Patch(); + patches.add(patch); + patch.start1 = Integer.parseInt(m.group(1)); + if (m.group(2).length() == 0) { + patch.start1--; + patch.length1 = 1; + } else if (m.group(2).equals("0")) { + patch.length1 = 0; + } else { + patch.start1--; + patch.length1 = Integer.parseInt(m.group(2)); + } + + patch.start2 = Integer.parseInt(m.group(3)); + if (m.group(4).length() == 0) { + patch.start2--; + patch.length2 = 1; + } else if (m.group(4).equals("0")) { + patch.length2 = 0; + } else { + patch.start2--; + patch.length2 = Integer.parseInt(m.group(4)); + } + text.removeFirst(); + + while (!text.isEmpty()) { + try { + sign = text.getFirst().charAt(0); + } catch (IndexOutOfBoundsException e) { + // Blank line? Whatever. + text.removeFirst(); + continue; + } + line = text.getFirst().substring(1); + line = line.replace("+", "%2B"); // decode would change all "+" to " " + try { + line = URLDecoder.decode(line, "UTF-8"); + } catch (UnsupportedEncodingException e) { + // Not likely on modern system. + throw new Error("This system does not support UTF-8.", e); + } catch (IllegalArgumentException e) { + // Malformed URI sequence. + throw new IllegalArgumentException( + "Illegal escape in patch_fromText: " + line, e); + } + if (sign == '-') { + // Deletion. + patch.diffs.add(new Diff(Operation.DELETE, line)); + } else if (sign == '+') { + // Insertion. + patch.diffs.add(new Diff(Operation.INSERT, line)); + } else if (sign == ' ') { + // Minor equality. + patch.diffs.add(new Diff(Operation.EQUAL, line)); + } else if (sign == '@') { + // Start of next patch. + break; + } else { + // WTF? + throw new IllegalArgumentException( + "Invalid patch mode '" + sign + "' in: " + line); + } + text.removeFirst(); + } + } + return patches; + } + + + /** + * Class representing one diff operation. + */ + public static class Diff { + /** + * One of: INSERT, DELETE or EQUAL. + */ + public Operation operation; + /** + * The text associated with this diff operation. + */ + public String text; + + /** + * Constructor. Initializes the diff with the provided values. + * @param operation One of INSERT, DELETE or EQUAL. + * @param text The text being applied. + */ + public Diff(Operation operation, String text) { + // Construct a diff with the specified operation and text. + this.operation = operation; + this.text = text; + } + + /** + * Display a human-readable version of this Diff. + * @return text version. + */ + public String toString() { + String prettyText = this.text.replace('\n', '\u00b6'); + return "Diff(" + this.operation + ",\"" + prettyText + "\")"; + } + + /** + * Create a numeric hash value for a Diff. + * This function is not used by DMP. + * @return Hash value. + */ + @Override + public int hashCode() { + final int prime = 31; + int result = (operation == null) ? 0 : operation.hashCode(); + result += prime * ((text == null) ? 0 : text.hashCode()); + return result; + } + + /** + * Is this Diff equivalent to another Diff? + * @param obj Another Diff to compare against. + * @return true or false. + */ + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + Diff other = (Diff) obj; + if (operation != other.operation) { + return false; + } + if (text == null) { + if (other.text != null) { + return false; + } + } else if (!text.equals(other.text)) { + return false; + } + return true; + } + } + + + /** + * Class representing one patch operation. + */ + public static class Patch { + public LinkedList diffs; + public int start1; + public int start2; + public int length1; + public int length2; + + /** + * Constructor. Initializes with an empty list of diffs. + */ + public Patch() { + this.diffs = new LinkedList(); + } + + /** + * Emulate GNU diff's format. + * Header: @@ -382,8 +481,9 @@ + * Indices are printed as 1-based, not 0-based. + * @return The GNU diff string. + */ + public String toString() { + String coords1, coords2; + if (this.length1 == 0) { + coords1 = this.start1 + ",0"; + } else if (this.length1 == 1) { + coords1 = Integer.toString(this.start1 + 1); + } else { + coords1 = (this.start1 + 1) + "," + this.length1; + } + if (this.length2 == 0) { + coords2 = this.start2 + ",0"; + } else if (this.length2 == 1) { + coords2 = Integer.toString(this.start2 + 1); + } else { + coords2 = (this.start2 + 1) + "," + this.length2; + } + StringBuilder text = new StringBuilder(); + text.append("@@ -").append(coords1).append(" +").append(coords2) + .append(" @@\n"); + // Escape the body of the patch with %xx notation. + for (Diff aDiff : this.diffs) { + switch (aDiff.operation) { + case INSERT: + text.append('+'); + break; + case DELETE: + text.append('-'); + break; + case EQUAL: + text.append(' '); + break; + } + try { + text.append(URLEncoder.encode(aDiff.text, "UTF-8").replace('+', ' ')) + .append("\n"); + } catch (UnsupportedEncodingException e) { + // Not likely on modern system. + throw new Error("This system does not support UTF-8.", e); + } + } + return unescapeForEncodeUriCompatability(text.toString()); + } + } + + /** + * Unescape selected chars for compatability with JavaScript's encodeURI. + * In speed critical applications this could be dropped since the + * receiving application will certainly decode these fine. + * Note that this function is case-sensitive. Thus "%3f" would not be + * unescaped. But this is ok because it is only called with the output of + * URLEncoder.encode which returns uppercase hex. + * + * Example: "%3F" -> "?", "%24" -> "$", etc. + * + * @param str The string to escape. + * @return The escaped string. + */ + private static String unescapeForEncodeUriCompatability(String str) { + return str.replace("%21", "!").replace("%7E", "~") + .replace("%27", "'").replace("%28", "(").replace("%29", ")") + .replace("%3B", ";").replace("%2F", "/").replace("%3F", "?") + .replace("%3A", ":").replace("%40", "@").replace("%26", "&") + .replace("%3D", "=").replace("%2B", "+").replace("%24", "$") + .replace("%2C", ",").replace("%23", "#"); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/AudioPlayerService.java b/mastodon/src/main/java/org/joinmastodon/android/AudioPlayerService.java index 4a01c13f0..067446d3e 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/AudioPlayerService.java +++ b/mastodon/src/main/java/org/joinmastodon/android/AudioPlayerService.java @@ -169,7 +169,8 @@ public class AudioPlayerService extends Service{ } updateNotification(false, false); - getSystemService(AudioManager.class).requestAudioFocus(audioFocusChangeListener, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN); + int audiofocus = GlobalUserPreferences.overlayMedia ? AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK : AudioManager.AUDIOFOCUS_GAIN; + getSystemService(AudioManager.class).requestAudioFocus(audioFocusChangeListener, AudioManager.STREAM_MUSIC, audiofocus); player=new MediaPlayer(); player.setOnPreparedListener(this::onPlayerPrepared); @@ -265,7 +266,7 @@ public class AudioPlayerService extends Service{ private void updateNotification(boolean dismissable, boolean removeNotification){ Notification.Builder bldr=new Notification.Builder(this) .setSmallIcon(R.drawable.ic_ntf_logo) - .setContentTitle(status.account.displayName) + .setContentTitle(status.account.getDisplayName()) .setContentText(HtmlParser.strip(status.content)) .setOngoing(!dismissable) .setShowWhen(false) @@ -280,7 +281,7 @@ public class AudioPlayerService extends Service{ if(playerReady){ boolean isPlaying=player.isPlaying(); - bldr.addAction(new Notification.Action.Builder(Icon.createWithResource(this, isPlaying ? R.drawable.ic_pause_24 : R.drawable.ic_play_24), + bldr.addAction(new Notification.Action.Builder(Icon.createWithResource(this, isPlaying ? R.drawable.ic_fluent_pause_24_filled : R.drawable.ic_fluent_play_24_filled), getString(isPlaying ? R.string.pause : R.string.play), PendingIntent.getBroadcast(this, 2, new Intent(ACTION_PLAY_PAUSE), PendingIntent.FLAG_IMMUTABLE)) .build()); diff --git a/mastodon/src/main/java/org/joinmastodon/android/ExitActivity.java b/mastodon/src/main/java/org/joinmastodon/android/ExitActivity.java new file mode 100644 index 000000000..54a5ccbc4 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ExitActivity.java @@ -0,0 +1,24 @@ +package org.joinmastodon.android; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; + +public class ExitActivity extends Activity { + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + finishAndRemoveTask(); + } + + public static void exit(Context context) { + Intent intent = new Intent(context, ExitActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK); + context.startActivity(intent); + } + +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ExternalShareActivity.java b/mastodon/src/main/java/org/joinmastodon/android/ExternalShareActivity.java index 218c16f62..382456256 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ExternalShareActivity.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ExternalShareActivity.java @@ -3,21 +3,25 @@ package org.joinmastodon.android; import android.app.Fragment; import android.content.ClipData; import android.content.Intent; -import android.graphics.drawable.ColorDrawable; import android.net.Uri; import android.os.Bundle; import android.text.TextUtils; +import android.util.Pair; import android.widget.Toast; +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.M3AlertDialogBuilder; +import org.joinmastodon.android.ui.AccountSwitcherSheet; import org.joinmastodon.android.ui.utils.UiUtils; +import org.jsoup.internal.StringUtil; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Optional; +import java.util.function.BiConsumer; import androidx.annotation.Nullable; import me.grishka.appkit.FragmentStackActivity; @@ -28,21 +32,51 @@ public class ExternalShareActivity extends FragmentStackActivity{ UiUtils.setUserPreferredTheme(this); super.onCreate(savedInstanceState); if(savedInstanceState==null){ + Optional text = Optional.ofNullable(getIntent().getStringExtra(Intent.EXTRA_TEXT)); + Optional>> fediHandle = text.flatMap(UiUtils::parseFediverseHandle); + boolean isFediUrl = text.map(UiUtils::looksLikeFediverseUrl).orElse(false); + boolean isOpenable = isFediUrl || fediHandle.isPresent(); + List sessions=AccountSessionManager.getInstance().getLoggedInAccounts(); - if(sessions.isEmpty()){ + if (sessions.isEmpty()){ Toast.makeText(this, R.string.err_not_logged_in, Toast.LENGTH_SHORT).show(); finish(); - }else if(sessions.size()==1){ + } else if (isOpenable || sessions.size() > 1) { + AccountSwitcherSheet sheet = new AccountSwitcherSheet(this, null, true, isOpenable); + sheet.setOnClick((accountId, open) -> { + if (open && text.isPresent()) { + BiConsumer, Bundle> callback = (clazz, args) -> { + if (clazz == null) { + Toast.makeText(this, R.string.sk_open_in_app_failed, Toast.LENGTH_SHORT).show(); + // TODO: do something about the window getting leaked + sheet.dismiss(); + finish(); + return; + } + args.putString("fromExternalShare", clazz.getSimpleName()); + Intent intent = new Intent(this, MainActivity.class); + intent.putExtras(args); + finish(); + startActivity(intent); + }; + + fediHandle + .>map(handle -> + UiUtils.lookupAccountHandle(this, accountId, handle, callback)) + .or(() -> + UiUtils.lookupURL(this, accountId, text.get(), callback)) + .ifPresent(req -> + req.wrapProgress(this, R.string.loading, true, d -> { + UiUtils.transformDialogForLookup(this, accountId, isFediUrl ? text.get() : null, d); + d.setOnDismissListener((x) -> finish()); + })); + } else { + openComposeFragment(accountId); + } + }); + sheet.show(); + } else if (sessions.size() == 1) { openComposeFragment(sessions.get(0).getID()); - }else{ - getWindow().setBackgroundDrawable(new ColorDrawable(0xff000000)); - new M3AlertDialogBuilder(this) - .setItems(sessions.stream().map(as->"@"+as.self.username+"@"+as.domain).toArray(String[]::new), (dialog, which)->{ - openComposeFragment(sessions.get(which).getID()); - }) - .setTitle(R.string.choose_account) - .setOnCancelListener(dialog -> finish()) - .show(); } } } @@ -51,7 +85,20 @@ public class ExternalShareActivity extends FragmentStackActivity{ getWindow().setBackgroundDrawable(null); Intent intent=getIntent(); - String text=intent.getStringExtra(Intent.EXTRA_TEXT); + StringBuilder builder=new StringBuilder(); + String subject = ""; + if (intent.hasExtra(Intent.EXTRA_SUBJECT)) { + subject = intent.getStringExtra(Intent.EXTRA_SUBJECT); + if (!StringUtil.isBlank(subject)) builder.append(subject).append("\n\n"); + } + if (intent.hasExtra(Intent.EXTRA_TEXT)) { + String extra = intent.getStringExtra(Intent.EXTRA_TEXT); + if (!StringUtil.isBlank(extra)) { + if (extra.startsWith(subject)) extra = extra.substring(subject.length()).trim(); + builder.append(extra).append("\n\n"); + } + } + String text=builder.toString(); List mediaUris; if(Intent.ACTION_SEND.equals(intent.getAction())){ Uri singleUri=intent.getParcelableExtra(Intent.EXTRA_STREAM); @@ -77,6 +124,7 @@ public class ExternalShareActivity extends FragmentStackActivity{ args.putString("account", accountID); if(!TextUtils.isEmpty(text)) args.putString("prefilledText", text); + args.putInt("selectionStart", StringUtil.isBlank(subject) ? 0 : subject.length()); if(mediaUris!=null && !mediaUris.isEmpty()) args.putParcelableArrayList("mediaAttachments", toArrayList(mediaUris)); Fragment fragment=new ComposeFragment(); diff --git a/mastodon/src/main/java/org/joinmastodon/android/GlobalUserPreferences.java b/mastodon/src/main/java/org/joinmastodon/android/GlobalUserPreferences.java index 7be513f96..fd0f2420f 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/GlobalUserPreferences.java +++ b/mastodon/src/main/java/org/joinmastodon/android/GlobalUserPreferences.java @@ -1,34 +1,182 @@ package org.joinmastodon.android; +import static org.joinmastodon.android.api.MastodonAPIController.gson; +import static org.joinmastodon.android.api.session.AccountLocalPreferences.ColorPreference.MATERIAL3; + 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; + +import org.joinmastodon.android.api.session.AccountLocalPreferences; +import org.joinmastodon.android.api.session.AccountLocalPreferences.ColorPreference; +import org.joinmastodon.android.api.session.AccountSession; +import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.model.ContentType; +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.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"; + public static boolean playGifs; public static boolean useCustomTabs; public static boolean altTextReminders, confirmUnfollow, confirmBoost, confirmDeletePost; public static ThemePreference theme; - private static SharedPreferences getPrefs(){ + // MEGALODON + public static boolean trueBlackTheme; + public static boolean loadNewPosts; + public static boolean showNewPostsButton; + public static boolean toolbarMarquee; + public static boolean disableSwipe; + public static boolean enableDeleteNotifications; + public static boolean translateButtonOpenedOnly; + public static boolean uniformNotificationIcon; + public static boolean reduceMotion; + public static boolean showAltIndicator; + public static boolean showNoAltIndicator; + public static boolean enablePreReleases; + public static PrefixRepliesMode prefixReplies; + public static boolean collapseLongPosts; + 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; + public static boolean displayPronounsInTimelines, displayPronounsInThreads, displayPronounsInUserListings; + public static boolean overlayMedia; + public static boolean showSuicideHelp; + public static boolean underlinedLinks; + public static ColorPreference color; + public static boolean likeIcon; + + // 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 loadRemoteAccountFollowers; + public static boolean mentionRebloggerAutomatically; + public static boolean showPostsWithoutAlt; + public static boolean showMediaPreview; + + 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{ + T value=gson.fromJson(json, type); + return value==null ? orElse : value; + }catch(JsonSyntaxException ignored){ + return orElse; + } + } + + public static > T enumValue(Class enumType, String name) { + try { return Enum.valueOf(enumType, name); } + catch (NullPointerException npe) { return null; } } public static void load(){ SharedPreferences prefs=getPrefs(); + playGifs=prefs.getBoolean("playGifs", true); useCustomTabs=prefs.getBoolean("useCustomTabs", true); - altTextReminders=prefs.getBoolean("altTextReminders", false); - confirmUnfollow=prefs.getBoolean("confirmUnfollow", false); + theme=ThemePreference.values()[prefs.getInt("theme", 0)]; + altTextReminders=prefs.getBoolean("altTextReminders", true); + confirmUnfollow=prefs.getBoolean("confirmUnfollow", true); confirmBoost=prefs.getBoolean("confirmBoost", false); confirmDeletePost=prefs.getBoolean("confirmDeletePost", true); + + // MEGALODON + trueBlackTheme=prefs.getBoolean("trueBlackTheme", false); + loadNewPosts=prefs.getBoolean("loadNewPosts", true); + showNewPostsButton=prefs.getBoolean("showNewPostsButton", true); + toolbarMarquee=prefs.getBoolean("toolbarMarquee", true); + disableSwipe=prefs.getBoolean("disableSwipe", false); + enableDeleteNotifications=prefs.getBoolean("enableDeleteNotifications", false); + translateButtonOpenedOnly=prefs.getBoolean("translateButtonOpenedOnly", false); + uniformNotificationIcon=prefs.getBoolean("uniformNotificationIcon", false); + reduceMotion=prefs.getBoolean("reduceMotion", false); + showAltIndicator=prefs.getBoolean("showAltIndicator", true); + showNoAltIndicator=prefs.getBoolean("showNoAltIndicator", true); + enablePreReleases=prefs.getBoolean("enablePreReleases", false); + prefixReplies=PrefixRepliesMode.valueOf(prefs.getString("prefixReplies", PrefixRepliesMode.NEVER.name())); + collapseLongPosts=prefs.getBoolean("collapseLongPosts", true); + spectatorMode=prefs.getBoolean("spectatorMode", false); + 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); + displayPronounsInThreads=prefs.getBoolean("displayPronounsInThreads", true); + displayPronounsInUserListings=prefs.getBoolean("displayPronounsInUserListings", true); + overlayMedia=prefs.getBoolean("overlayMedia", false); + showSuicideHelp=prefs.getBoolean("showSuicideHelp", true); + underlinedLinks=prefs.getBoolean("underlinedLinks", true); + 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); + loadRemoteAccountFollowers=prefs.getBoolean("loadRemoteAccountFollowers", true); + mentionRebloggerAutomatically=prefs.getBoolean("mentionRebloggerAutomatically", false); + showPostsWithoutAlt=prefs.getBoolean("showPostsWithoutAlt", true); + showMediaPreview=prefs.getBoolean("showMediaPreview", true); + theme=ThemePreference.values()[prefs.getInt("theme", 0)]; + + + if (prefs.contains("prefixRepliesWithRe")) { + prefixReplies = prefs.getBoolean("prefixRepliesWithRe", false) + ? PrefixRepliesMode.TO_OTHERS : PrefixRepliesMode.NEVER; + prefs.edit() + .putString("prefixReplies", prefixReplies.name()) + .remove("prefixRepliesWithRe") + .apply(); + } + + int migrationLevel=prefs.getInt("migrationLevel", BuildConfig.VERSION_CODE); + if(migrationLevel < 61) + migrateToUpstreamVersion61(); + if(migrationLevel < BuildConfig.VERSION_CODE) + prefs.edit().putInt("migrationLevel", BuildConfig.VERSION_CODE).apply(); } public static void save(){ @@ -40,45 +188,126 @@ public class GlobalUserPreferences{ .putBoolean("confirmUnfollow", confirmUnfollow) .putBoolean("confirmBoost", confirmBoost) .putBoolean("confirmDeletePost", confirmDeletePost) + + // MEGALODON + .putBoolean("loadNewPosts", loadNewPosts) + .putBoolean("showNewPostsButton", showNewPostsButton) + .putBoolean("trueBlackTheme", trueBlackTheme) + .putBoolean("toolbarMarquee", toolbarMarquee) + .putBoolean("disableSwipe", disableSwipe) + .putBoolean("enableDeleteNotifications", enableDeleteNotifications) + .putBoolean("translateButtonOpenedOnly", translateButtonOpenedOnly) + .putBoolean("uniformNotificationIcon", uniformNotificationIcon) + .putBoolean("reduceMotion", reduceMotion) + .putBoolean("showAltIndicator", showAltIndicator) + .putBoolean("showNoAltIndicator", showNoAltIndicator) + .putBoolean("enablePreReleases", enablePreReleases) + .putString("prefixReplies", prefixReplies.name()) + .putBoolean("collapseLongPosts", collapseLongPosts) + .putBoolean("spectatorMode", spectatorMode) + .putBoolean("autoHideFab", autoHideFab) + .putBoolean("allowRemoteLoading", allowRemoteLoading) + .putString("autoRevealEqualSpoilers", autoRevealEqualSpoilers.name()) + .putBoolean("forwardReportDefault", forwardReportDefault) + .putBoolean("disableM3PillActiveIndicator", disableM3PillActiveIndicator) + .putBoolean("showNavigationLabels", showNavigationLabels) + .putBoolean("displayPronounsInTimelines", displayPronounsInTimelines) + .putBoolean("displayPronounsInThreads", displayPronounsInThreads) + .putBoolean("displayPronounsInUserListings", displayPronounsInUserListings) + .putBoolean("overlayMedia", overlayMedia) + .putBoolean("showSuicideHelp", showSuicideHelp) + .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("loadRemoteAccountFollowers", loadRemoteAccountFollowers) + .putBoolean("hapticFeedback", hapticFeedback) + .putBoolean("mentionRebloggerAutomatically", mentionRebloggerAutomatically) + .putBoolean("showDividers", showDividers) + .putBoolean("relocatePublishButton", relocatePublishButton) + .putBoolean("enableDeleteNotifications", enableDeleteNotifications) + .putBoolean("showPostsWithoutAlt", showPostsWithoutAlt) + .putBoolean("showMediaPreview", showMediaPreview) + .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 static void resetPreReplySheets(){ - getPreReplyPrefs().edit().clear().apply(); - } - public enum ThemePreference{ AUTO, LIGHT, DARK } - public enum PreReplySheetType{ - OLD_POST, - NON_MUTUAL + public enum AutoRevealMode { + NEVER, + THREADS, + DISCUSSIONS } + + public enum PrefixRepliesMode { + NEVER, + ALWAYS, + TO_OTHERS + } + + + //region preferences migrations + + private static void migrateToUpstreamVersion61(){ + Log.d(TAG, "Migrating preferences to upstream version 61!!"); + + Type accountsDefaultContentTypesType = new TypeToken>() {}.getType(); + Type pinnedTimelinesType = new TypeToken>>() {}.getType(); + Type recentLanguagesType = new TypeToken>>() {}.getType(); + + // migrate global preferences + SharedPreferences prefs=getPrefs(); + altTextReminders=!prefs.getBoolean("disableAltTextReminder", false); + confirmBoost=prefs.getBoolean("confirmBeforeReblog", false); + toolbarMarquee=!prefs.getBoolean("disableMarquee", false); + + save(); + + // migrate local preferences + AccountSessionManager asm=AccountSessionManager.getInstance(); + // reset: Set accountsWithContentTypesEnabled=prefs.getStringSet("accountsWithContentTypesEnabled", new HashSet<>()); + Map accountsDefaultContentTypes=fromJson(prefs.getString("accountsDefaultContentTypes", null), accountsDefaultContentTypesType, new HashMap<>()); + Map> pinnedTimelines=fromJson(prefs.getString("pinnedTimelines", null), pinnedTimelinesType, new HashMap<>()); + Set accountsWithLocalOnlySupport=prefs.getStringSet("accountsWithLocalOnlySupport", new HashSet<>()); + Set accountsInGlitchMode=prefs.getStringSet("accountsInGlitchMode", new HashSet<>()); + Map> recentLanguages=fromJson(prefs.getString("recentLanguages", null), recentLanguagesType, new HashMap<>()); + + for(AccountSession session : asm.getLoggedInAccounts()){ + String accountID=session.getID(); + AccountLocalPreferences localPrefs=session.getLocalPreferences(); + localPrefs.revealCWs=prefs.getBoolean("alwaysExpandContentWarnings", false); + localPrefs.recentLanguages=recentLanguages.get(accountID); + // reset: localPrefs.contentTypesEnabled=accountsWithContentTypesEnabled.contains(accountID); + localPrefs.defaultContentType=accountsDefaultContentTypes.getOrDefault(accountID, ContentType.PLAIN); + localPrefs.showInteractionCounts=prefs.getBoolean("showInteractionCounts", false); + localPrefs.timelines=pinnedTimelines.getOrDefault(accountID, TimelineDefinition.getDefaultTimelines(accountID)); + localPrefs.localOnlySupported=accountsWithLocalOnlySupport.contains(accountID); + localPrefs.glitchInstance=accountsInGlitchMode.contains(accountID); + localPrefs.publishButtonText=prefs.getString("publishButtonText", null); + localPrefs.keepOnlyLatestNotification=prefs.getBoolean("keepOnlyLatestNotification", false); + localPrefs.showReplies=prefs.getBoolean("showReplies", true); + localPrefs.showBoosts=prefs.getBoolean("showBoosts", true); + + if(session.getInstance().map(Instance::isAkkoma).orElse(false)){ + localPrefs.timelineReplyVisibility=prefs.getString("replyVisibility", null); + } + + localPrefs.save(); + } + } + + //endregion + } diff --git a/mastodon/src/main/java/org/joinmastodon/android/MainActivity.java b/mastodon/src/main/java/org/joinmastodon/android/MainActivity.java index 5cb222cbb..979a160fe 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/MainActivity.java +++ b/mastodon/src/main/java/org/joinmastodon/android/MainActivity.java @@ -1,59 +1,88 @@ 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.Application; +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; import android.widget.Toast; 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; -import org.joinmastodon.android.fragments.SplashFragment; import org.joinmastodon.android.fragments.ThreadFragment; import org.joinmastodon.android.fragments.onboarding.AccountActivationFragment; +import org.joinmastodon.android.fragments.onboarding.CustomWelcomeFragment; import org.joinmastodon.android.model.Notification; import org.joinmastodon.android.model.SearchResults; import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.updater.GithubSelfUpdater; +import org.joinmastodon.android.utils.ProvidesAssistContent; import org.parceler.Parcels; -import java.lang.reflect.InvocationTargetException; - import androidx.annotation.Nullable; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.PrintWriter; +import java.time.Instant; + import me.grishka.appkit.FragmentStackActivity; import me.grishka.appkit.Nav; import me.grishka.appkit.api.Callback; import me.grishka.appkit.api.ErrorResponse; -public class MainActivity extends FragmentStackActivity{ +public class MainActivity extends FragmentStackActivity implements ProvidesAssistContent { private static final String TAG="MainActivity"; @Override protected void onCreate(@Nullable Bundle savedInstanceState){ - UiUtils.setUserPreferredTheme(this); + AccountSession session=getCurrentSession(); + UiUtils.setUserPreferredTheme(this, session); super.onCreate(savedInstanceState); + Thread.UncaughtExceptionHandler defaultHandler=Thread.getDefaultUncaughtExceptionHandler(); + Thread.setDefaultUncaughtExceptionHandler((t, e)->{ + File file=new File(MastodonApp.context.getFilesDir(), "crash.log"); + try(FileOutputStream out=new FileOutputStream(file)){ + PrintWriter writer=new PrintWriter(out); + writer.println(BuildConfig.VERSION_NAME+" ("+BuildConfig.VERSION_CODE+")"); + writer.println(Instant.now().toString()); + writer.println(); + e.printStackTrace(writer); + writer.flush(); + }catch(IOException x){ + Log.e(TAG, "Error writing crash.log", x); + }finally{ + defaultHandler.uncaughtException(t, e); + } + }); + if(savedInstanceState==null){ restartHomeFragment(); } - if(BuildConfig.BUILD_TYPE.startsWith("appcenter")){ - // Call the appcenter SDK wrapper through reflection because it is only present in beta builds - try{ - Class.forName("org.joinmastodon.android.AppCenterWrapper").getMethod("init", Application.class).invoke(null, getApplication()); - }catch(ClassNotFoundException|NoSuchMethodException|IllegalAccessException|InvocationTargetException ignore){} - }else if(GithubSelfUpdater.needSelfUpdating()){ + if(GithubSelfUpdater.needSelfUpdating()){ GithubSelfUpdater.getInstance().maybeCheckForUpdates(); } } @@ -61,11 +90,12 @@ public class MainActivity extends FragmentStackActivity{ @Override protected void onNewIntent(Intent intent){ super.onNewIntent(intent); - if(intent.getBooleanExtra("fromNotification", false)){ + AccountSessionManager.getInstance().maybeUpdateLocalInfo(); + if (intent.hasExtra("fromExternalShare")) showFragmentForExternalShare(intent.getExtras()); + else if (intent.getBooleanExtra("fromNotification", false)) { String accountID=intent.getStringExtra("accountID"); - AccountSession accountSession; try{ - accountSession=AccountSessionManager.getInstance().getAccount(accountID); + AccountSessionManager.getInstance().getAccount(accountID); }catch(IllegalStateException x){ return; } @@ -133,23 +163,26 @@ public class MainActivity extends FragmentStackActivity{ } private void showFragmentForNotification(Notification notification, String accountID){ - Fragment fragment; - Bundle args=new Bundle(); - args.putString("account", accountID); - args.putBoolean("_can_go_back", true); try{ notification.postprocess(); }catch(ObjectValidationException x){ Log.w("MainActivity", x); return; } - if(notification.status!=null){ - fragment=new ThreadFragment(); - args.putParcelable("status", Parcels.wrap(notification.status)); - }else{ - fragment=new ProfileFragment(); - args.putParcelable("profileAccount", Parcels.wrap(notification.account)); - } + Bundle args = new Bundle(); + args.putBoolean("noTransition", true); + UiUtils.showFragmentForNotification(this, notification, accountID, args); + } + + private void showFragmentForExternalShare(Bundle args) { + String className = args.getString("fromExternalShare"); + Fragment fragment = switch (className) { + case "ThreadFragment" -> new ThreadFragment(); + case "ProfileFragment" -> new ProfileFragment(); + default -> null; + }; + if (fragment == null) return; + args.putBoolean("_can_go_back", true); fragment.setArguments(args); showFragment(fragment); } @@ -171,31 +204,137 @@ public class MainActivity extends FragmentStackActivity{ } } + /** + * when opening app through a notification: if (thread) fragment "can go back", clear back stack + * and show home fragment. upstream's implementation doesn't require this as it opens home first + * and then immediately switches to the notification's ThreadFragment. this causes a black + * screen in megalodon, for some reason, so i'm working around this that way. + */ + @Override + public void onBackPressed() { + Fragment currentFragment = getFragmentManager().findFragmentById( + (fragmentContainers.get(fragmentContainers.size() - 1)).getId() + ); + Bundle currentArgs = currentFragment.getArguments(); + if (fragmentContainers.size() != 1 + || currentArgs == null + || !currentArgs.getBoolean("_can_go_back", false)) { + super.onBackPressed(); + return; + } + if (currentArgs.getBoolean("_finish_on_back", false)) { + finish(); + } else if (currentArgs.containsKey("account")) { + Bundle args = new Bundle(); + args.putString("account", currentArgs.getString("account")); + if (getIntent().getBooleanExtra("fromNotification", false)) { + args.putString("tab", "notifications"); + } + Fragment fragment=new HomeFragment(); + fragment.setArguments(args); + showFragmentClearingBackStack(fragment); + } + } + +// @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); + if (fl.getVisibility() == View.VISIBLE) { + return getFragmentManager().findFragmentById(fl.getId()); + } + } + return null; + } + + @Override + public void onProvideAssistContent(AssistContent assistContent) { + super.onProvideAssistContent(assistContent); + Fragment fragment = getCurrentFragment(); + if (fragment != null) callFragmentToProvideAssistContent(fragment, assistContent); + } + + public AccountSession getCurrentSession(){ + AccountSession session; + Bundle args=new Bundle(); + Intent intent=getIntent(); + if(intent.hasExtra("fromExternalShare")) { + return AccountSessionManager.getInstance() + .getAccount(intent.getStringExtra("account")); + } + + boolean fromNotification = intent.getBooleanExtra("fromNotification", false); + boolean hasNotification = intent.hasExtra("notification"); + if(fromNotification){ + String accountID=intent.getStringExtra("accountID"); + try{ + session=AccountSessionManager.getInstance().getAccount(accountID); + if(!hasNotification) args.putString("tab", "notifications"); + }catch(IllegalStateException x){ + session=AccountSessionManager.getInstance().getLastActiveAccount(); + } + }else{ + session=AccountSessionManager.getInstance().getLastActiveAccount(); + } + return session; + } + + public void restartActivity(){ + finish(); + startActivity(new Intent(this, MainActivity.class)); + } + public void restartHomeFragment(){ if(AccountSessionManager.getInstance().getLoggedInAccounts().isEmpty()){ - showFragmentClearingBackStack(new SplashFragment()); + showFragmentClearingBackStack(new CustomWelcomeFragment()); }else{ - AccountSessionManager.getInstance().maybeUpdateLocalInfo(); AccountSession session; Bundle args=new Bundle(); Intent intent=getIntent(); - if(intent.getBooleanExtra("fromNotification", false)){ + if(intent.hasExtra("fromExternalShare")) { + AccountSessionManager.getInstance() + .setLastActiveAccountID(intent.getStringExtra("account")); + AccountSessionManager.getInstance().maybeUpdateLocalInfo( + AccountSessionManager.getInstance().getLastActiveAccount()); + showFragmentForExternalShare(intent.getExtras()); + return; + } + + boolean fromNotification = intent.getBooleanExtra("fromNotification", false); + boolean hasNotification = intent.hasExtra("notification"); + if(fromNotification){ String accountID=intent.getStringExtra("accountID"); try{ session=AccountSessionManager.getInstance().getAccount(accountID); - if(!intent.hasExtra("notification")) - args.putString("tab", "notifications"); + if(!hasNotification) args.putString("tab", "notifications"); }catch(IllegalStateException x){ session=AccountSessionManager.getInstance().getLastActiveAccount(); } }else{ session=AccountSessionManager.getInstance().getLastActiveAccount(); } + AccountSessionManager.getInstance().maybeUpdateLocalInfo(session); args.putString("account", session.getID()); Fragment fragment=session.activated ? new HomeFragment() : new AccountActivationFragment(); fragment.setArguments(args); - showFragmentClearingBackStack(fragment); - if(intent.getBooleanExtra("fromNotification", false) && intent.hasExtra("notification")){ + if(fromNotification && hasNotification){ // 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{ @@ -204,11 +343,12 @@ public class MainActivity extends FragmentStackActivity{ }catch(BadParcelableException x){ Log.w(TAG, x); } - }else if(intent.getBooleanExtra("compose", false)){ + } else if (intent.getBooleanExtra("compose", false)){ showCompose(); - }else if(Intent.ACTION_VIEW.equals(intent.getAction())){ + } else if (Intent.ACTION_VIEW.equals(intent.getAction())){ handleURL(intent.getData(), null); - }else{ + } else { + showFragmentClearingBackStack(fragment); maybeRequestNotificationsPermission(); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/OAuthActivity.java b/mastodon/src/main/java/org/joinmastodon/android/OAuthActivity.java index 553074599..f239bf3f0 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/OAuthActivity.java +++ b/mastodon/src/main/java/org/joinmastodon/android/OAuthActivity.java @@ -61,6 +61,9 @@ public class OAuthActivity extends Activity{ @Override public void onSuccess(Token token){ new GetOwnAccount() + // in case the instance (looking at pixelfed) wants to redirect to a + // website, we need to pass a context so we can launch a browser + .setContext(OAuthActivity.this) .setCallback(new Callback<>(){ @Override public void onSuccess(Account account){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/PanicResponderActivity.java b/mastodon/src/main/java/org/joinmastodon/android/PanicResponderActivity.java new file mode 100644 index 000000000..716907a67 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/PanicResponderActivity.java @@ -0,0 +1,49 @@ +package org.joinmastodon.android; + +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; + +import org.joinmastodon.android.api.requests.oauth.RevokeOauthToken; +import org.joinmastodon.android.api.session.AccountSession; +import org.joinmastodon.android.api.session.AccountSessionManager; + +import me.grishka.appkit.api.Callback; +import me.grishka.appkit.api.ErrorResponse; + + +public class PanicResponderActivity extends Activity { + public static final String PANIC_TRIGGER_ACTION = "info.guardianproject.panic.action.TRIGGER"; + + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + final Intent intent = getIntent(); + if (intent != null && PANIC_TRIGGER_ACTION.equals(intent.getAction())) { + AccountSessionManager.getInstance().getLoggedInAccounts().forEach(accountSession -> logOut(accountSession.getID())); + ExitActivity.exit(this); + } + finishAndRemoveTask(); + } + + private void logOut(String accountID){ + AccountSession session=AccountSessionManager.getInstance().getAccount(accountID); + new RevokeOauthToken(session.app.clientId, session.app.clientSecret, session.token.accessToken) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(Object result){ + onLoggedOut(accountID); + } + + @Override + public void onError(ErrorResponse error){ + onLoggedOut(accountID); + } + }) + .exec(accountID); + } + + private void onLoggedOut(String accountID){ + AccountSessionManager.getInstance().removeAccount(accountID); + } +} \ No newline at end of file diff --git a/mastodon/src/main/java/org/joinmastodon/android/PushNotificationReceiver.java b/mastodon/src/main/java/org/joinmastodon/android/PushNotificationReceiver.java index ca7dd64bd..11f3048d1 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/PushNotificationReceiver.java +++ b/mastodon/src/main/java/org/joinmastodon/android/PushNotificationReceiver.java @@ -1,32 +1,52 @@ package org.joinmastodon.android; +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; import android.app.NotificationChannelGroup; import android.app.NotificationManager; import android.app.PendingIntent; +import android.app.RemoteInput; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; -import android.graphics.Bitmap; import android.graphics.drawable.Drawable; -import android.graphics.drawable.Icon; +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; +import org.joinmastodon.android.api.requests.statuses.SetStatusFavorited; +import org.joinmastodon.android.api.requests.statuses.SetStatusReblogged; import org.joinmastodon.android.api.session.AccountSession; import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.model.Mention; +import org.joinmastodon.android.model.NotificationAction; +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; +import java.util.ArrayList; import java.util.Arrays; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.UUID; import java.util.stream.Collectors; import me.grishka.appkit.api.Callback; @@ -39,9 +59,15 @@ public class PushNotificationReceiver extends BroadcastReceiver{ private static final String TAG="PushNotificationReceive"; public static final int NOTIFICATION_ID=178; + private static final String ACTION_KEY_TEXT_REPLY = "ACTION_KEY_TEXT_REPLY"; + + private static final int SUMMARY_ID = 791; + private static int notificationId = 0; + private static final Map notificationIdsForAccounts = new HashMap<>(); @Override public void onReceive(Context context, Intent intent){ + UiUtils.setUserPreferredTheme(context); if(BuildConfig.DEBUG){ Log.e(TAG, "received: "+intent); Bundle extras=intent.getExtras(); @@ -96,11 +122,47 @@ public class PushNotificationReceiver extends BroadcastReceiver{ Log.w(TAG, "onReceive: invalid push notification format"); } } + if(intent.getBooleanExtra("fromNotificationAction", false)){ + String accountID=intent.getStringExtra("accountID"); + int notificationId=intent.getIntExtra("notificationId", -1); + + if (notificationId >= 0){ + NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + notificationManager.cancel(accountID, notificationId); + } + + if(intent.hasExtra("notification")){ + org.joinmastodon.android.model.Notification notification=Parcels.unwrap(intent.getParcelableExtra("notification")); + String statusID=notification.status.id; + if (statusID != null) { + AccountSessionManager accountSessionManager = AccountSessionManager.getInstance(); + Preferences preferences = accountSessionManager.getAccount(accountID).preferences; + + switch (NotificationAction.values()[intent.getIntExtra("notificationAction", 0)]) { + case FAVORITE -> new SetStatusFavorited(statusID, true).exec(accountID); + case BOOKMARK -> new SetStatusBookmarked(statusID, true).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"); + } + } + }else{ + Log.e(TAG, "onReceive: Failed to load notification"); + } + } + } + + public void notifyUnifiedPush(Context context, String accountID, 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); } private void notify(Context context, PushNotification pn, String accountID, org.joinmastodon.android.model.Notification notification){ NotificationManager nm=context.getSystemService(NotificationManager.class); - Account self=AccountSessionManager.getInstance().getAccount(accountID).self; + AccountSession session=AccountSessionManager.get(accountID); + Account self=session.self; String accountName="@"+self.username+"@"+AccountSessionManager.getInstance().getAccount(accountID).domain; Notification.Builder builder; if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.O){ @@ -144,19 +206,178 @@ public class PushNotificationReceiver extends BroadcastReceiver{ .setContentText(pn.body) .setStyle(new Notification.BigTextStyle().bigText(pn.body)) .setSmallIcon(R.drawable.ic_ntf_logo) - .setContentIntent(PendingIntent.getActivity(context, accountID.hashCode() & 0xFFFF, contentIntent, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT)) + .setContentIntent(PendingIntent.getActivity(context, notificationId, contentIntent, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT)) .setWhen(notification==null ? System.currentTimeMillis() : notification.createdAt.toEpochMilli()) .setShowWhen(true) .setCategory(Notification.CATEGORY_SOCIAL) .setAutoCancel(true) .setLights(context.getColor(R.color.primary_700), 500, 1000) - .setColor(context.getColor(R.color.primary_700)); + .setColor(context.getColor(R.color.shortcut_icon_background)); + + if (!GlobalUserPreferences.uniformNotificationIcon) { + builder.setSmallIcon(switch (pn.notificationType) { + case FAVORITE -> GlobalUserPreferences.likeIcon ? R.drawable.ic_fluent_heart_24_filled : R.drawable.ic_fluent_star_24_filled; + case REBLOG -> R.drawable.ic_fluent_arrow_repeat_all_24_filled; + case FOLLOW -> R.drawable.ic_fluent_person_add_24_filled; + case MENTION -> R.drawable.ic_fluent_mention_24_filled; + case POLL -> R.drawable.ic_fluent_poll_24_filled; + case STATUS -> R.drawable.ic_fluent_chat_24_filled; + case UPDATE -> R.drawable.ic_fluent_history_24_filled; + case REPORT -> R.drawable.ic_fluent_warning_24_filled; + case SIGN_UP -> R.drawable.ic_fluent_person_available_24_filled; + }); + } + if(avatar!=null){ builder.setLargeIcon(UiUtils.getBitmapFromDrawable(avatar)); } if(AccountSessionManager.getInstance().getLoggedInAccounts().size()>1){ builder.setSubText(accountName); } - nm.notify(accountID, NOTIFICATION_ID, builder.build()); + + int id; + if(session.getLocalPreferences().keepOnlyLatestNotification){ + if(notificationIdsForAccounts.containsKey(accountID)){ + // we overwrite the existing notification + id=notificationIdsForAccounts.get(accountID); + }else{ + // there's no existing notification, so we increment + id=notificationId++; + // and store the notification id for this account + notificationIdsForAccounts.put(accountID, id); + } + }else{ + // we don't want to overwrite anything, therefore incrementing + id=notificationId++; + } + + if (notification != null){ + switch (pn.notificationType){ + case MENTION, STATUS -> { + if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N){ + builder.addAction(buildReplyAction(context, id, accountID, notification)); + } + builder.addAction(buildNotificationAction(context, id, accountID, notification, context.getString(R.string.button_favorite), NotificationAction.FAVORITE)); + 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.UNBOOST)); + } + case FOLLOW -> { + builder.addAction(buildNotificationAction(context, id, accountID, notification, context.getString(R.string.follow_back), NotificationAction.FOLLOW_BACK)); + } + } + } + + nm.notify(accountID, id, builder.build()); + } + + private Notification.Action buildNotificationAction(Context context, int notificationId, String accountID, org.joinmastodon.android.model.Notification notification, String title, NotificationAction action){ + Intent notificationIntent=new Intent(context, PushNotificationReceiver.class); + notificationIntent.putExtra("notificationId", notificationId); + notificationIntent.putExtra("fromNotificationAction", true); + notificationIntent.putExtra("accountID", accountID); + notificationIntent.putExtra("notificationAction", action.ordinal()); + notificationIntent.putExtra("notification", Parcels.wrap(notification)); + PendingIntent actionPendingIntent = PendingIntent.getBroadcast(context, new Random().nextInt(), notificationIntent, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_ONE_SHOT); + + return new Notification.Action.Builder(null, title, actionPendingIntent).build(); + } + + private Notification.Action buildReplyAction(Context context, int notificationId, String accountID, org.joinmastodon.android.model.Notification notification){ + String replyLabel = context.getResources().getString(R.string.button_reply); + RemoteInput remoteInput = new RemoteInput.Builder(ACTION_KEY_TEXT_REPLY) + .setLabel(replyLabel) + .build(); + + Intent notificationIntent=new Intent(context, PushNotificationReceiver.class); + notificationIntent.putExtra("notificationId", notificationId); + notificationIntent.putExtra("fromNotificationAction", true); + notificationIntent.putExtra("accountID", accountID); + notificationIntent.putExtra("notificationAction", NotificationAction.REPLY.ordinal()); + notificationIntent.putExtra("notification", Parcels.wrap(notification)); + + int flags = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ? PendingIntent.FLAG_MUTABLE | PendingIntent.FLAG_UPDATE_CURRENT : PendingIntent.FLAG_UPDATE_CURRENT; + PendingIntent replyPendingIntent = PendingIntent.getBroadcast(context, new Random().nextInt(), notificationIntent,flags); + return new Notification.Action.Builder(null, replyLabel, replyPendingIntent).addRemoteInput(remoteInput).build(); + } + + private void handleReplyAction(Context context, String accountID, Intent intent, org.joinmastodon.android.model.Notification notification, int notificationId, Preferences preferences) { + Bundle remoteInput = RemoteInput.getResultsFromIntent(intent); + if (remoteInput == null) { + Log.e(TAG, "handleReplyAction: Could not get reply input"); + return; + } + CharSequence input = remoteInput.getCharSequence(ACTION_KEY_TEXT_REPLY); + + // copied from ComposeFragment - TODO: generalize? + ArrayList mentions=new ArrayList<>(); + Status status = notification.status; + String ownID=AccountSessionManager.getInstance().getAccount(accountID).self.id; + if(!status.account.id.equals(ownID)) + mentions.add('@'+status.account.acct); + for(Mention mention:status.mentions){ + if(mention.id.equals(ownID)) + continue; + String m='@'+mention.acct; + if(!mentions.contains(m)) + mentions.add(m); + } + String initialText=mentions.isEmpty() ? "" : TextUtils.join(" ", mentions)+" "; + + CreateStatus.Request req=new CreateStatus.Request(); + req.status = initialText + input.toString(); + req.language = notification.status.language; + req.visibility = (notification.status.visibility == StatusPrivacy.PUBLIC && GlobalUserPreferences.defaultToUnlistedReplies ? StatusPrivacy.UNLISTED : notification.status.visibility); + req.inReplyToId = notification.status.id; + + if (notification.status.hasSpoiler() && + (GlobalUserPreferences.prefixReplies == ALWAYS + || (GlobalUserPreferences.prefixReplies == TO_OTHERS && !ownID.equals(notification.status.account.id))) + && !notification.status.spoilerText.startsWith("re: ")) { + req.spoilerText = "re: " + notification.status.spoilerText; + } + + new CreateStatus(req, UUID.randomUUID().toString()).setCallback(new Callback<>() { + @Override + public void onSuccess(Status status) { + NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + Notification.Builder builder = android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O ? + new Notification.Builder(context, accountID+"_"+notification.type) : + new Notification.Builder(context) + .setPriority(Notification.PRIORITY_DEFAULT) + .setDefaults(Notification.DEFAULT_SOUND | Notification.DEFAULT_VIBRATE); + + notification.status = status; + Intent contentIntent=new Intent(context, MainActivity.class); + contentIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + contentIntent.putExtra("fromNotification", true); + contentIntent.putExtra("accountID", accountID); + contentIntent.putExtra("notification", Parcels.wrap(notification)); + + Notification repliedNotification = builder.setSmallIcon(R.drawable.ic_ntf_logo) + .setContentTitle(context.getString(R.string.sk_notification_action_replied, notification.status.account.displayName)) + .setContentText(status.getStrippedText()) + .setCategory(Notification.CATEGORY_SOCIAL) + .setContentIntent(PendingIntent.getActivity(context, notificationId, contentIntent, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT)) + .build(); + notificationManager.notify(accountID, notificationId, repliedNotification); + } + + @Override + public void onError(ErrorResponse errorResponse) { + + } + }).exec(accountID); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/UnifiedPushNotificationReceiver.java b/mastodon/src/main/java/org/joinmastodon/android/UnifiedPushNotificationReceiver.java new file mode 100644 index 000000000..2fc060c0f --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/UnifiedPushNotificationReceiver.java @@ -0,0 +1,84 @@ +package org.joinmastodon.android; + +import android.content.Context; +import android.util.Log; + +import org.jetbrains.annotations.NotNull; +import org.joinmastodon.android.api.MastodonAPIController; +import org.joinmastodon.android.api.session.AccountSession; +import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.model.Notification; +import org.joinmastodon.android.model.PaginatedResponse; +import org.unifiedpush.android.connector.MessagingReceiver; + +import java.util.List; + +import me.grishka.appkit.api.Callback; +import me.grishka.appkit.api.ErrorResponse; + +public class UnifiedPushNotificationReceiver extends MessagingReceiver{ + private static final String TAG="UnifiedPushNotificationReceiver"; + + public UnifiedPushNotificationReceiver() { + super(); + } + + @Override + public void onNewEndpoint(@NotNull Context context, @NotNull String endpoint, @NotNull String instance) { + // Called when a new endpoint be used for sending push messages + Log.d(TAG, "onNewEndpoint: New Endpoint " + endpoint + " for "+ instance); + AccountSession account = AccountSessionManager.getInstance().tryGetAccount(instance); + if (account != null) + account.getPushSubscriptionManager().registerAccountForPush(null, endpoint); + } + + @Override + public void onRegistrationFailed(@NotNull Context context, @NotNull String instance) { + // called when the registration is not possible, eg. no network + Log.d(TAG, "onRegistrationFailed: " + instance); + //re-register for gcm + AccountSession account = AccountSessionManager.getInstance().tryGetAccount(instance); + if (account != null) + account.getPushSubscriptionManager().registerAccountForPush(null); + } + + @Override + public void onUnregistered(@NotNull Context context, @NotNull String instance) { + // called when this application is unregistered from receiving push messages + Log.d(TAG, "onUnregistered: " + instance); + //re-register for gcm + AccountSession account = AccountSessionManager.getInstance().tryGetAccount(instance); + if (account != null) + account.getPushSubscriptionManager().registerAccountForPush(null); + } + + @Override + public void onMessage(@NotNull Context context, @NotNull byte[] message, @NotNull String instance) { + // Called when a new message is received. The message contains the full POST body of the push message + AccountSession account = AccountSessionManager.getInstance().tryGetAccount(instance); + + if (account == null) + return; + + //this is stupid + // Mastodon stores the info to decrypt the message in the HTTP headers, which are not accessible in UnifiedPush, + // thus it is not possible to decrypt them. SO we need to re-request them from the server and transform them later on + // The official uses fcm and moves the headers to extra data, see + // https://github.com/mastodon/webpush-fcm-relay/blob/cac95b28d5364b0204f629283141ac3fb749e0c5/webpush-fcm-relay.go#L116 + // https://github.com/tuskyapp/Tusky/pull/2303#issue-1112080540 + account.getCacheController().getNotifications(null, 1, false, false, true, new Callback<>(){ + @Override + public void onSuccess(PaginatedResponse> result){ + result.items + .stream() + .findFirst() + .ifPresent(value->MastodonAPIController.runInBackground(()->new PushNotificationReceiver().notifyUnifiedPush(context, instance, value))); + } + + @Override + public void onError(ErrorResponse error){ + //professional error handling + } + }); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/ApiUtils.java b/mastodon/src/main/java/org/joinmastodon/android/api/ApiUtils.java index 8c588e468..a2cb79775 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/ApiUtils.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/ApiUtils.java @@ -11,7 +11,7 @@ public class ApiUtils{ //no instance } - public static > List enumSetToStrings(EnumSet e, Class cls){ +public static > List enumSetToStrings(EnumSet e, Class cls){ return e.stream().map(ev->{ try{ SerializedName annotation=cls.getField(ev.name()).getAnnotation(SerializedName.class); 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 6e226bb65..a904ac0a0 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java @@ -20,6 +20,7 @@ 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; @@ -36,6 +37,7 @@ import java.util.ArrayList; import java.util.Comparator; import java.util.EnumSet; import java.util.List; +import java.util.Objects; import java.util.function.Consumer; import me.grishka.appkit.api.Callback; @@ -44,7 +46,7 @@ import me.grishka.appkit.utils.WorkerThread; public class CacheController{ private static final String TAG="CacheController"; - private static final int DB_VERSION=3; + private static final int DB_VERSION=4; private static final WorkerThread databaseThread=new WorkerThread("databaseThread"); private static final Handler uiHandler=new Handler(Looper.getMainLooper()); @@ -80,12 +82,11 @@ public class CacheController{ Status status=MastodonAPIController.gson.fromJson(cursor.getString(0), Status.class); status.postprocess(); int flags=cursor.getInt(1); - status.hasGapAfter=((flags & POST_FLAG_GAP_AFTER)!=0); + status.hasGapAfter=((flags & POST_FLAG_GAP_AFTER)!=0) ? status.id : null; newMaxID=status.id; result.add(status); }while(cursor.moveToNext()); String _newMaxID=newMaxID; - AccountSessionManager.get(accountID).filterStatuses(result, FilterContext.HOME); uiHandler.post(()->callback.onSuccess(new CacheablePaginatedResponse<>(result, _newMaxID, true))); return; } @@ -93,13 +94,11 @@ public class CacheController{ Log.w(TAG, "getHomeTimeline: corrupted status object in database", x); } } - new GetHomeTimeline(maxID, null, count, null) + new GetHomeTimeline(maxID, null, count, null, AccountSessionManager.get(accountID).getLocalPreferences().timelineReplyVisibility) .setCallback(new Callback<>(){ @Override public void onSuccess(List result){ - ArrayList filtered=new ArrayList<>(result); - AccountSessionManager.get(accountID).filterStatuses(filtered, FilterContext.HOME); - callback.onSuccess(new CacheablePaginatedResponse<>(filtered, result.isEmpty() ? null : result.get(result.size()-1).id, false)); + callback.onSuccess(new CacheablePaginatedResponse<>(result, result.isEmpty() ? null : result.get(result.size()-1).id, false)); putHomeTimeline(result, maxID==null); } @@ -127,20 +126,45 @@ public class CacheController{ values.put("id", s.id); values.put("json", MastodonAPIController.gson.toJson(s)); int flags=0; - if(s.hasGapAfter) + if(Objects.equals(s.hasGapAfter, s.id)) flags|=POST_FLAG_GAP_AFTER; values.put("flags", flags); values.put("time", s.createdAt.getEpochSecond()); db.insertWithOnConflict("home_timeline", null, values, SQLiteDatabase.CONFLICT_REPLACE); } + if(!clear) + db.delete("home_timeline", "`id` NOT IN (SELECT `id` FROM `home_timeline` ORDER BY `time` DESC LIMIT ?)", new String[]{"1000"}); }); } - public void getNotifications(String maxID, int count, boolean onlyMentions, boolean forceReload, Callback>> callback){ + public void updateStatus(Status status) { + runOnDbThread((db)->{ + ContentValues statusUpdate=new ContentValues(1); + statusUpdate.put("json", MastodonAPIController.gson.toJson(status)); + db.update("home_timeline", statusUpdate, "id = ?", new String[] { status.id }); + }); + } + + public void updateNotification(Notification notification) { + runOnDbThread((db)->{ + ContentValues notificationUpdate=new ContentValues(1); + notificationUpdate.put("json", MastodonAPIController.gson.toJson(notification)); + String[] notificationArgs = new String[] { notification.id }; + db.update("notifications_all", notificationUpdate, "id = ?", notificationArgs); + db.update("notifications_mentions", notificationUpdate, "id = ?", notificationArgs); + db.update("notifications_posts", notificationUpdate, "id = ?", notificationArgs); + + ContentValues statusUpdate=new ContentValues(1); + statusUpdate.put("json", MastodonAPIController.gson.toJson(notification.status)); + db.update("home_timeline", statusUpdate, "id = ?", new String[] { notification.status.id }); + }); + } + + public void getNotifications(String maxID, int count, boolean onlyMentions, boolean onlyPosts, boolean forceReload, Callback>> callback){ cancelDelayedClose(); databaseThread.postRunnable(()->{ try{ - if(!onlyMentions && loadingNotifications){ + if(!onlyMentions && !onlyPosts && loadingNotifications){ synchronized(pendingNotificationsCallbacks){ pendingNotificationsCallbacks.add(callback); } @@ -148,7 +172,8 @@ public class CacheController{ } if(!forceReload){ SQLiteDatabase db=getOrOpenDatabase(); - try(Cursor cursor=db.query(onlyMentions ? "notifications_mentions" : "notifications_all", new String[]{"json"}, maxID==null ? null : "`id` result=new ArrayList<>(); cursor.moveToFirst(); @@ -168,9 +193,10 @@ public class CacheController{ Log.w(TAG, "getNotifications: corrupted notification object in database", x); } } - if(!onlyMentions) + if(!onlyMentions && !onlyPosts) loadingNotifications=true; - new GetNotifications(maxID, count, onlyMentions ? EnumSet.of(Notification.Type.MENTION): EnumSet.allOf(Notification.Type.class)) + boolean isAkkoma = AccountSessionManager.get(accountID).getInstance().map(Instance::isAkkoma).orElse(false); + new GetNotifications(maxID, count, onlyPosts ? EnumSet.of(Notification.Type.STATUS) : onlyMentions ? EnumSet.of(Notification.Type.MENTION): EnumSet.allOf(Notification.Type.class), isAkkoma) .setCallback(new Callback<>(){ @Override public void onSuccess(List result){ @@ -178,7 +204,7 @@ public class CacheController{ AccountSessionManager.get(accountID).filterStatusContainingObjects(filtered, n->n.status, FilterContext.NOTIFICATIONS); PaginatedResponse> res=new PaginatedResponse<>(filtered, result.isEmpty() ? null : result.get(result.size()-1).id); callback.onSuccess(res); - putNotifications(result, onlyMentions, maxID==null); + putNotifications(result, onlyMentions, onlyPosts, maxID==null); if(!onlyMentions){ loadingNotifications=false; synchronized(pendingNotificationsCallbacks){ @@ -214,9 +240,9 @@ public class CacheController{ }, 0); } - private void putNotifications(List notifications, boolean onlyMentions, boolean clear){ + private void putNotifications(List notifications, boolean onlyMentions, boolean onlyPosts, boolean clear){ runOnDbThread((db)->{ - String table=onlyMentions ? "notifications_mentions" : "notifications_all"; + String table=onlyPosts ? "notifications_posts" : onlyMentions ? "notifications_mentions" : "notifications_all"; if(clear) db.delete(table, null, null); ContentValues values=new ContentValues(4); @@ -259,6 +285,28 @@ public class CacheController{ public void deleteStatus(String id){ runOnDbThread((db)->{ + String gapId=null; + int gapFlags=0; + // select to-be-removed and newer row + try(Cursor cursor=db.query("home_timeline", new String[]{"id", "flags"}, "`time`>=(SELECT `time` FROM `home_timeline` WHERE `id`=?)", new String[]{id}, null, null, "`time` ASC", "2")){ + boolean hadGapAfter=false; + // always either one or two iterations (only one if there's no newer post) + while(cursor.moveToNext()){ + String currentId=cursor.getString(0); + int currentFlags=cursor.getInt(1); + if(currentId.equals(id)){ + hadGapAfter=((currentFlags & POST_FLAG_GAP_AFTER)!=0); + }else if(hadGapAfter){ + gapFlags=currentFlags|POST_FLAG_GAP_AFTER; + gapId=currentId; + } + } + } + if(gapId!=null){ + ContentValues values=new ContentValues(); + values.put("flags", gapFlags); + db.update("home_timeline", values, "`id`=?", new String[]{gapId}); + } db.delete("home_timeline", "`id`=?", new String[]{id}); }); } @@ -437,6 +485,7 @@ public class CacheController{ `time` INTEGER NOT NULL )"""); createRecentSearchesTable(db); + createPostsNotificationsTable(db); } @Override @@ -445,6 +494,10 @@ public class CacheController{ createRecentSearchesTable(db); } if(oldVersion<3){ + // MEGALODON + createPostsNotificationsTable(db); + } + if(oldVersion<4){ addTimeColumns(db); } } @@ -458,13 +511,26 @@ public class CacheController{ )"""); } + private void createPostsNotificationsTable(SQLiteDatabase db){ + db.execSQL(""" + CREATE TABLE `notifications_posts` ( + `id` VARCHAR(25) NOT NULL PRIMARY KEY, + `json` TEXT NOT NULL, + `flags` INTEGER NOT NULL DEFAULT 0, + `type` INTEGER NOT NULL, + `time` INTEGER NOT NULL + )"""); + } + private void addTimeColumns(SQLiteDatabase db){ db.execSQL("DELETE FROM `home_timeline`"); db.execSQL("DELETE FROM `notifications_all`"); db.execSQL("DELETE FROM `notifications_mentions`"); + db.execSQL("DELETE FROM `notifications_posts`"); db.execSQL("ALTER TABLE `home_timeline` ADD `time` INTEGER NOT NULL DEFAULT 0"); db.execSQL("ALTER TABLE `notifications_all` ADD `time` INTEGER NOT NULL DEFAULT 0"); db.execSQL("ALTER TABLE `notifications_mentions` ADD `time` INTEGER NOT NULL DEFAULT 0"); + db.execSQL("ALTER TABLE `notifications_posts` ADD `time` INTEGER NOT NULL DEFAULT 0"); } } 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 384378dfe..23be5d4b9 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java @@ -12,11 +12,16 @@ import com.google.gson.JsonParser; import com.google.gson.JsonSyntaxException; import org.joinmastodon.android.BuildConfig; +import org.joinmastodon.android.MastodonApp; import org.joinmastodon.android.api.gson.IsoInstantTypeAdapter; import org.joinmastodon.android.api.gson.IsoLocalDateTypeAdapter; import org.joinmastodon.android.api.session.AccountSession; +import org.joinmastodon.android.model.Status; +import org.joinmastodon.android.ui.utils.UiUtils; +import java.io.BufferedReader; import java.io.IOException; +import java.io.InputStreamReader; import java.io.Reader; import java.time.Instant; import java.time.LocalDate; @@ -38,12 +43,15 @@ import okhttp3.ResponseBody; public class MastodonAPIController{ private static final String TAG="MastodonAPIController"; - public static final Gson gson=new GsonBuilder() + public static final Gson gsonWithoutDeserializer = new GsonBuilder() .disableHtmlEscaping() .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) .registerTypeAdapter(Instant.class, new IsoInstantTypeAdapter()) .registerTypeAdapter(LocalDate.class, new IsoLocalDateTypeAdapter()) .create(); + public static final Gson gson = gsonWithoutDeserializer.newBuilder() + .registerTypeAdapter(Status.class, new Status.StatusDeserializer()) + .create(); private static WorkerThread thread=new WorkerThread("MastodonAPIController"); private static OkHttpClient httpClient=new OkHttpClient.Builder() .connectTimeout(60, TimeUnit.SECONDS) @@ -52,9 +60,26 @@ public class MastodonAPIController{ .build(); private AccountSession session; + private static List badDomains = new ArrayList<>(); static{ thread.start(); + try { + final BufferedReader reader = new BufferedReader(new InputStreamReader( + MastodonApp.context.getAssets().open("blocks.txt") + )); + String line; + while ((line = reader.readLine()) != null) { + if (line.isBlank() || line.startsWith("#")) continue; + String[] parts = line.replaceAll("\"", "").split("[\s,;]"); + if (parts.length == 0) continue; + String domain = parts[0].toLowerCase().trim(); + if (domain.isBlank()) continue; + badDomains.add(domain); + } + } catch (IOException e) { + e.printStackTrace(); + } } public MastodonAPIController(@Nullable AccountSession session){ @@ -62,14 +87,17 @@ public class MastodonAPIController{ } public void submitRequest(final MastodonAPIRequest req){ + final String host = req.getURL().getHost(); + 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(req.canceled) return; Request.Builder builder=new Request.Builder() .url(req.getURL().toString()) .method(req.getMethod(), req.getRequestBody()) - .header("User-Agent", "MastodonAndroid/"+BuildConfig.VERSION_NAME); + .header("User-Agent", "MoshidonAndroid/"+BuildConfig.VERSION_NAME); String token=null; if(session!=null) @@ -87,13 +115,13 @@ public class MastodonAPIController{ } Request hreq=builder.build(); - Call call=httpClient.newCall(hreq); + OkHttpClient client=req.timeout>0 + ? httpClient.newBuilder().readTimeout(req.timeout, TimeUnit.MILLISECONDS).build() + : httpClient; + Call call=client.newCall(hreq); synchronized(req){ req.okhttpCall=call; } - if(req.timeout>0){ - call.timeout().timeout(req.timeout, TimeUnit.MILLISECONDS); - } if(BuildConfig.DEBUG) Log.d(TAG, logTag(session)+"Sending request: "+hreq); @@ -143,6 +171,11 @@ public class MastodonAPIController{ respObj=null; } }catch(JsonIOException|JsonSyntaxException x){ + if (req.context != null && response.body().contentType().subtype().equals("html")) { + UiUtils.launchWebBrowser(req.context, response.request().url().toString()); + req.cancel(); + return; + } if(BuildConfig.DEBUG) Log.w(TAG, logTag(session)+response+" error parsing or reading body", x); req.onError(x.getLocalizedMessage(), response.code(), x); 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 f308d8720..99b0beb48 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java @@ -2,6 +2,7 @@ package org.joinmastodon.android.api; import android.app.Activity; import android.app.ProgressDialog; +import android.content.Context; import android.net.Uri; import android.util.Log; import android.util.Pair; @@ -20,8 +21,11 @@ import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.Optional; +import java.util.function.Consumer; import androidx.annotation.CallSuper; +import androidx.annotation.Nullable; import androidx.annotation.StringRes; import me.grishka.appkit.api.APIRequest; import me.grishka.appkit.api.Callback; @@ -43,11 +47,12 @@ public abstract class MastodonAPIRequest extends APIRequest{ TypeToken respTypeToken; Call okhttpCall; Token token; - boolean canceled; + boolean canceled, isRemote; Map headers; long timeout; private ProgressDialog progressDialog; protected boolean removeUnsupportedItems; + @Nullable Context context; public MastodonAPIRequest(HttpMethod method, String path, Class respClass){ this.path=path; @@ -101,10 +106,30 @@ public abstract class MastodonAPIRequest extends APIRequest{ return this; } - public MastodonAPIRequest wrapProgress(Activity activity, @StringRes int message, boolean cancelable){ - progressDialog=new ProgressDialog(activity); - progressDialog.setMessage(activity.getString(message)); + public MastodonAPIRequest execRemote(String domain) { + return execRemote(domain, null); + } + + public MastodonAPIRequest execRemote(String domain, @Nullable AccountSession remoteSession) { + this.isRemote = true; + return Optional.ofNullable(remoteSession) + .or(() -> AccountSessionManager.getInstance().getLoggedInAccounts().stream() + .filter(acc -> acc.domain.equals(domain)) + .findAny()) + .map(AccountSession::getID) + .map(this::exec) + .orElseGet(() -> this.execNoAuth(domain)); + } + + public MastodonAPIRequest wrapProgress(Context context, @StringRes int message, boolean cancelable){ + return wrapProgress(context, message, cancelable, null); + } + + public MastodonAPIRequest wrapProgress(Context context, @StringRes int message, boolean cancelable, Consumer transform){ + progressDialog=new ProgressDialog(context); + progressDialog.setMessage(context.getString(message)); progressDialog.setCancelable(cancelable); + if (transform != null) transform.accept(progressDialog); if(cancelable){ progressDialog.setOnCancelListener(dialog->cancel()); } @@ -128,8 +153,9 @@ public abstract class MastodonAPIRequest extends APIRequest{ headers.put(key, value); } - protected void setTimeout(long timeout){ + public MastodonAPIRequest setTimeout(long timeout){ this.timeout=timeout; + return this; } protected String getPathPrefix(){ @@ -165,9 +191,20 @@ public abstract class MastodonAPIRequest extends APIRequest{ return this; } + public MastodonAPIRequest setContext(Context context) { + this.context = context; + return this; + } + + @Nullable + public Context getContext() { + return context; + } + @CallSuper public void validateAndPostprocessResponse(T respObj, Response httpResponse) throws IOException{ if(respObj instanceof BaseModel){ + ((BaseModel) respObj).isRemote = isRemote; ((BaseModel) respObj).postprocess(); }else if(respObj instanceof List){ if(removeUnsupportedItems){ @@ -176,6 +213,7 @@ public abstract class MastodonAPIRequest extends APIRequest{ Object item=itr.next(); if(item instanceof BaseModel){ try{ + ((BaseModel) item).isRemote = isRemote; ((BaseModel) item).postprocess(); }catch(ObjectValidationException x){ Log.w(TAG, "Removing invalid object from list", x); @@ -183,15 +221,20 @@ public abstract class MastodonAPIRequest extends APIRequest{ } } } + // no idea why we're post-processing twice, but well, as long + // as upstream does it like this, i don't wanna break anything for(Object item:((List) respObj)){ if(item instanceof BaseModel){ + ((BaseModel) item).isRemote = isRemote; ((BaseModel) item).postprocess(); } } }else{ for(Object item:((List) respObj)){ - if(item instanceof BaseModel) + if(item instanceof BaseModel) { + ((BaseModel) item).isRemote = isRemote; ((BaseModel) item).postprocess(); + } } } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonErrorResponse.java b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonErrorResponse.java index 9dfbfdc83..7dedffd54 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonErrorResponse.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonErrorResponse.java @@ -5,8 +5,6 @@ import android.view.View; import android.widget.TextView; import android.widget.Toast; -import org.joinmastodon.android.R; - import me.grishka.appkit.api.ErrorResponse; public class MastodonErrorResponse extends ErrorResponse{ @@ -22,7 +20,7 @@ public class MastodonErrorResponse extends ErrorResponse{ @Override public void bindErrorView(View view){ - TextView text=view.findViewById(R.id.error_text); + TextView text=view.findViewById(me.grishka.appkit.R.id.error_text); text.setText(error); } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/PushSubscriptionManager.java b/mastodon/src/main/java/org/joinmastodon/android/api/PushSubscriptionManager.java index db6aecfea..046c55531 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/PushSubscriptionManager.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/PushSubscriptionManager.java @@ -120,9 +120,21 @@ public class PushSubscriptionManager{ return !TextUtils.isEmpty(deviceToken); } + public void registerAccountForPush(PushSubscription subscription){ - if(TextUtils.isEmpty(deviceToken)) - throw new IllegalStateException("No device push token available"); + // this function is used for registering push notifications using FCM + // to avoid NonFreeNet in F-Droid, this registration is disabled in it + // see https://github.com/LucasGGamerM/moshidon/issues/206 for more context + if(BuildConfig.BUILD_TYPE.equals("fdroidRelease") || TextUtils.isEmpty(deviceToken)){ + Log.d(TAG, "Skipping registering for FCM push notifications"); + return; + } + + String endpoint = "https://app.joinmastodon.org/relay-to/fcm/"+deviceToken+"/"; + registerAccountForPush(subscription, endpoint); + } + + public void registerAccountForPush(PushSubscription subscription, String endpoint){ MastodonAPIController.runInBackground(()->{ Log.d(TAG, "registerAccountForPush: started for "+accountID); String encodedPublicKey, encodedAuthKey, pushAccountID; @@ -151,12 +163,17 @@ public class PushSubscriptionManager{ Log.e(TAG, "registerAccountForPush: error generating encryption key", e); return; } - new RegisterForPushNotifications(deviceToken, + + //work-around for adding the randomAccountId + String newEndpoint = endpoint; + if (endpoint.startsWith("https://app.joinmastodon.org/relay-to/fcm/")) + newEndpoint += pushAccountID; + + new RegisterForPushNotifications(newEndpoint, encodedPublicKey, encodedAuthKey, subscription==null ? PushSubscription.Alerts.ofAll() : subscription.alerts, - subscription==null ? PushSubscription.Policy.ALL : subscription.policy, - pushAccountID) + subscription==null ? PushSubscription.Policy.ALL : subscription.policy) .setCallback(new Callback<>(){ @Override public void onSuccess(PushSubscription result){ @@ -367,7 +384,7 @@ public class PushSubscriptionManager{ for(AccountSession session:AccountSessionManager.getInstance().getLoggedInAccounts()){ if(session.pushSubscription==null || forceReRegister) session.getPushSubscriptionManager().registerAccountForPush(session.pushSubscription); - else if(session.needUpdatePushSettings) + else session.getPushSubscriptionManager().updatePushSettings(session.pushSubscription); } } 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 4d260489c..0526ab79b 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/StatusInteractionController.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/StatusInteractionController.java @@ -6,26 +6,39 @@ 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.events.ReblogDeletedEvent; import org.joinmastodon.android.events.StatusCountersUpdatedEvent; +import org.joinmastodon.android.events.StatusCreatedEvent; +import org.joinmastodon.android.events.StatusDeletedEvent; import org.joinmastodon.android.model.Status; +import org.joinmastodon.android.model.StatusPrivacy; import java.util.HashMap; +import java.util.function.Consumer; import me.grishka.appkit.api.Callback; import me.grishka.appkit.api.ErrorResponse; public class StatusInteractionController{ private final String accountID; + private final boolean updateCounters; 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){ + public StatusInteractionController(String accountID, boolean updateCounters) { this.accountID=accountID; + this.updateCounters=updateCounters; } - public void setFavorited(Status status, boolean favorited){ + public StatusInteractionController(String accountID){ + this(accountID, true); + } + + public void setFavorited(Status status, boolean favorited, Consumer cb){ if(!Looper.getMainLooper().isCurrentThread()) throw new IllegalStateException("Can only be called from main thread"); @@ -38,7 +51,9 @@ public class StatusInteractionController{ @Override public void onSuccess(Status result){ runningFavoriteRequests.remove(status.id); - E.post(new StatusCountersUpdatedEvent(result)); + result.favouritesCount = Math.max(0, status.favouritesCount + (favorited ? 1 : -1)); + cb.accept(result); + if(updateCounters) E.post(new StatusCountersUpdatedEvent(result)); } @Override @@ -46,24 +61,17 @@ public class StatusInteractionController{ runningFavoriteRequests.remove(status.id); error.showToast(MastodonApp.context); status.favourited=!favorited; - if(favorited) - status.favouritesCount--; - else - status.favouritesCount++; - E.post(new StatusCountersUpdatedEvent(status)); + cb.accept(status); + if(updateCounters) E.post(new StatusCountersUpdatedEvent(status)); } }) .exec(accountID); runningFavoriteRequests.put(status.id, req); status.favourited=favorited; - if(favorited) - status.favouritesCount++; - else - status.favouritesCount--; - E.post(new StatusCountersUpdatedEvent(status)); + if(updateCounters) E.post(new StatusCountersUpdatedEvent(status)); } - public void setReblogged(Status status, boolean reblogged){ + public void setReblogged(Status status, boolean reblogged, StatusPrivacy visibility, Consumer cb){ if(!Looper.getMainLooper().isCurrentThread()) throw new IllegalStateException("Can only be called from main thread"); @@ -71,12 +79,19 @@ public class StatusInteractionController{ if(current!=null){ current.cancel(); } - SetStatusReblogged req=(SetStatusReblogged) new SetStatusReblogged(status.id, reblogged) + SetStatusReblogged req=(SetStatusReblogged) new SetStatusReblogged(status.id, reblogged, visibility) .setCallback(new Callback<>(){ @Override - public void onSuccess(Status result){ + public void onSuccess(Status reblog){ + Status result=reblog.getContentStatus(); runningReblogRequests.remove(status.id); - E.post(new StatusCountersUpdatedEvent(result)); + result.reblogsCount = Math.max(0, status.reblogsCount + (reblogged ? 1 : -1)); + cb.accept(result); + if(updateCounters){ + E.post(new StatusCountersUpdatedEvent(result)); + if(reblogged) E.post(new StatusCreatedEvent(reblog, accountID)); + else E.post(new ReblogDeletedEvent(status.id, accountID)); + } } @Override @@ -84,24 +99,21 @@ public class StatusInteractionController{ runningReblogRequests.remove(status.id); error.showToast(MastodonApp.context); status.reblogged=!reblogged; - if(reblogged) - status.reblogsCount--; - else - status.reblogsCount++; - E.post(new StatusCountersUpdatedEvent(status)); + cb.accept(status); + if(updateCounters) E.post(new StatusCountersUpdatedEvent(status)); } }) .exec(accountID); runningReblogRequests.put(status.id, req); status.reblogged=reblogged; - if(reblogged) - status.reblogsCount++; - else - status.reblogsCount--; - E.post(new StatusCountersUpdatedEvent(status)); + if(updateCounters) E.post(new StatusCountersUpdatedEvent(status)); } public void setBookmarked(Status status, boolean bookmarked){ + setBookmarked(status, bookmarked, r->{}); + } + + public void setBookmarked(Status status, boolean bookmarked, Consumer cb){ if(!Looper.getMainLooper().isCurrentThread()) throw new IllegalStateException("Can only be called from main thread"); @@ -114,7 +126,8 @@ public class StatusInteractionController{ @Override public void onSuccess(Status result){ runningBookmarkRequests.remove(status.id); - E.post(new StatusCountersUpdatedEvent(result)); + cb.accept(result); + if(updateCounters) E.post(new StatusCountersUpdatedEvent(result)); } @Override @@ -122,12 +135,13 @@ public class StatusInteractionController{ runningBookmarkRequests.remove(status.id); error.showToast(MastodonApp.context); status.bookmarked=!bookmarked; - E.post(new StatusCountersUpdatedEvent(status)); + cb.accept(status); + if(updateCounters) E.post(new StatusCountersUpdatedEvent(status)); } }) .exec(accountID); runningBookmarkRequests.put(status.id, req); status.bookmarked=bookmarked; - E.post(new StatusCountersUpdatedEvent(status)); + if(updateCounters) E.post(new StatusCountersUpdatedEvent(status)); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/AuthorizeFollowRequest.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/AuthorizeFollowRequest.java new file mode 100644 index 000000000..13ca0fbbb --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/AuthorizeFollowRequest.java @@ -0,0 +1,11 @@ +package org.joinmastodon.android.api.requests.accounts; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.Relationship; + +public class AuthorizeFollowRequest extends MastodonAPIRequest{ + public AuthorizeFollowRequest(String id){ + super(HttpMethod.POST, "/follow_requests/"+id+"/authorize", Relationship.class); + setRequestBody(new Object()); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetAccountBlocks.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetAccountBlocks.java new file mode 100644 index 000000000..16d0403a4 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetAccountBlocks.java @@ -0,0 +1,16 @@ +package org.joinmastodon.android.api.requests.accounts; + +import com.google.gson.reflect.TypeToken; + +import org.joinmastodon.android.api.requests.HeaderPaginationRequest; +import org.joinmastodon.android.model.Account; + +public class GetAccountBlocks extends HeaderPaginationRequest{ + public GetAccountBlocks(String maxID, int limit){ + super(HttpMethod.GET, "/blocks", 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/accounts/GetAccountByHandle.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetAccountByHandle.java new file mode 100644 index 000000000..011a8bf91 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetAccountByHandle.java @@ -0,0 +1,15 @@ +package org.joinmastodon.android.api.requests.accounts; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.Account; + +public class GetAccountByHandle extends MastodonAPIRequest{ + /** + * note that this method usually only returns a result if the instance already knows about an + * account - so it makes sense for looking up local users, search might be preferred otherwise + */ + public GetAccountByHandle(String acct){ + super(HttpMethod.GET, "/accounts/lookup", Account.class); + addQueryParameter("acct", acct); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetAccountMutes.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetAccountMutes.java new file mode 100644 index 000000000..966b36c51 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetAccountMutes.java @@ -0,0 +1,16 @@ +package org.joinmastodon.android.api.requests.accounts; + +import com.google.gson.reflect.TypeToken; + +import org.joinmastodon.android.api.requests.HeaderPaginationRequest; +import org.joinmastodon.android.model.Account; + +public class GetAccountMutes extends HeaderPaginationRequest{ + public GetAccountMutes(String maxID, int limit){ + super(HttpMethod.GET, "/mutes/", 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/accounts/GetFollowRequests.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetFollowRequests.java new file mode 100644 index 000000000..46d94b6ec --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetFollowRequests.java @@ -0,0 +1,16 @@ +package org.joinmastodon.android.api.requests.accounts; + +import com.google.gson.reflect.TypeToken; + +import org.joinmastodon.android.api.requests.HeaderPaginationRequest; +import org.joinmastodon.android.model.Account; + +public class GetFollowRequests extends HeaderPaginationRequest{ + public GetFollowRequests(String maxID, int limit){ + super(HttpMethod.GET, "/follow_requests", 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/accounts/RejectFollowRequest.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/RejectFollowRequest.java new file mode 100644 index 000000000..fd9ea1d2b --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/RejectFollowRequest.java @@ -0,0 +1,11 @@ +package org.joinmastodon.android.api.requests.accounts; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.Relationship; + +public class RejectFollowRequest extends MastodonAPIRequest{ + public RejectFollowRequest(String id){ + super(HttpMethod.POST, "/follow_requests/"+id+"/reject", Relationship.class); + setRequestBody(new Object()); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/SetAccountFollowed.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/SetAccountFollowed.java index e38479f18..4d7ca8571 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/SetAccountFollowed.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/SetAccountFollowed.java @@ -5,9 +5,13 @@ import org.joinmastodon.android.model.Relationship; public class SetAccountFollowed extends MastodonAPIRequest{ public SetAccountFollowed(String id, boolean followed, boolean showReblogs){ + this(id, followed, showReblogs, false); + } + + public SetAccountFollowed(String id, boolean followed, boolean showReblogs, boolean notify){ super(HttpMethod.POST, "/accounts/"+id+"/"+(followed ? "follow" : "unfollow"), Relationship.class); if(followed) - setRequestBody(new Request(showReblogs, null)); + setRequestBody(new Request(showReblogs, notify)); else setRequestBody(new Object()); } 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 17f0a583d..7d6afadf6 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,8 +4,15 @@ import org.joinmastodon.android.api.MastodonAPIRequest; import org.joinmastodon.android.model.Relationship; public class SetAccountMuted extends MastodonAPIRequest{ - public SetAccountMuted(String id, boolean muted){ + public SetAccountMuted(String id, boolean muted, long duration){ super(HttpMethod.POST, "/accounts/"+id+"/"+(muted ? "mute" : "unmute"), Relationship.class); - setRequestBody(new Object()); + setRequestBody(new Request(duration)); + } + + private static class Request{ + public long duration; + public Request(long duration){ + this.duration=duration; + } } } 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 new file mode 100644 index 000000000..e9c9fe764 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/SetPrivateNote.java @@ -0,0 +1,19 @@ +package org.joinmastodon.android.api.requests.accounts; + +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); + } + + 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/announcements/AddAnnouncementReaction.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/announcements/AddAnnouncementReaction.java new file mode 100644 index 000000000..f92167cd2 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/announcements/AddAnnouncementReaction.java @@ -0,0 +1,11 @@ +package org.joinmastodon.android.api.requests.announcements; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.Status; + +public class AddAnnouncementReaction extends MastodonAPIRequest { + public AddAnnouncementReaction(String id, String emoji) { + super(HttpMethod.PUT, "/announcements/" + id + "/reactions/" + emoji, Object.class); + setRequestBody(new Object()); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/announcements/DeleteAnnouncementReaction.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/announcements/DeleteAnnouncementReaction.java new file mode 100644 index 000000000..e1a667f36 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/announcements/DeleteAnnouncementReaction.java @@ -0,0 +1,9 @@ +package org.joinmastodon.android.api.requests.announcements; + +import org.joinmastodon.android.api.MastodonAPIRequest; + +public class DeleteAnnouncementReaction extends MastodonAPIRequest { + public DeleteAnnouncementReaction(String id, String emoji) { + super(HttpMethod.DELETE, "/announcements/" + id + "/reactions/" + emoji, Object.class); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/announcements/DismissAnnouncement.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/announcements/DismissAnnouncement.java new file mode 100644 index 000000000..3227a2cd5 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/announcements/DismissAnnouncement.java @@ -0,0 +1,10 @@ +package org.joinmastodon.android.api.requests.announcements; + +import org.joinmastodon.android.api.MastodonAPIRequest; + +public class DismissAnnouncement extends MastodonAPIRequest{ + public DismissAnnouncement(String id){ + super(HttpMethod.POST, "/announcements/" + id + "/dismiss", Object.class); + setRequestBody(new Object()); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/announcements/GetAnnouncements.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/announcements/GetAnnouncements.java new file mode 100644 index 000000000..4bedba6d6 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/announcements/GetAnnouncements.java @@ -0,0 +1,15 @@ +package org.joinmastodon.android.api.requests.announcements; + +import com.google.gson.reflect.TypeToken; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.Announcement; + +import java.util.List; + +public class GetAnnouncements extends MastodonAPIRequest> { + public GetAnnouncements(boolean withDismissed) { + super(MastodonAPIRequest.HttpMethod.GET, "/announcements", new TypeToken<>(){}); + addQueryParameter("with_dismissed", withDismissed ? "true" : "false"); + } +} 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/GetInstance.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/instance/GetInstance.java index 84273ce22..2ed250b96 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/instance/GetInstance.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/instance/GetInstance.java @@ -7,4 +7,15 @@ public class GetInstance extends MastodonAPIRequest{ public GetInstance(){ super(HttpMethod.GET, "/instance", Instance.class); } + + public static class V2 extends MastodonAPIRequest{ + public V2(){ + super(HttpMethod.GET, "/instance", Instance.V2.class); + } + + @Override + protected String getPathPrefix() { + return "/api/v2"; + } + } } 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/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/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 new file mode 100644 index 000000000..19bda79ca --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/GetList.java @@ -0,0 +1,10 @@ +package org.joinmastodon.android.api.requests.lists; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.ListTimeline; + +public class GetList extends MastodonAPIRequest { + public GetList(String id) { + super(HttpMethod.GET, "/lists/" + id, ListTimeline.class); + } +} 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 fb8aa663b..1a276eed5 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 @@ -4,11 +4,15 @@ import com.google.gson.reflect.TypeToken; import org.joinmastodon.android.api.MastodonAPIRequest; import org.joinmastodon.android.model.FollowList; +import org.joinmastodon.android.model.ListTimeline; import java.util.List; public class GetLists extends MastodonAPIRequest>{ - public GetLists(){ - super(HttpMethod.GET, "/lists", new TypeToken<>(){}); - } + public GetLists() { + super(HttpMethod.GET, "/lists", new TypeToken<>(){}); + } + public GetLists(String accountID) { + super(HttpMethod.GET, "/accounts/"+accountID+"/lists", new TypeToken<>(){}); + } } 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/notifications/DismissNotification.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/DismissNotification.java new file mode 100644 index 000000000..5c2399774 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/DismissNotification.java @@ -0,0 +1,17 @@ +package org.joinmastodon.android.api.requests.notifications; + +import com.google.gson.reflect.TypeToken; + +import org.joinmastodon.android.api.ApiUtils; +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.Notification; + +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()); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/GetNotifications.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/GetNotifications.java index 3c5a162fb..33aefea52 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/GetNotifications.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/GetNotifications.java @@ -1,6 +1,5 @@ package org.joinmastodon.android.api.requests.notifications; -import com.google.gson.annotations.SerializedName; import com.google.gson.reflect.TypeToken; import org.joinmastodon.android.api.ApiUtils; @@ -11,18 +10,24 @@ import java.util.EnumSet; import java.util.List; public class GetNotifications extends MastodonAPIRequest>{ - public GetNotifications(String maxID, int limit, EnumSet includeTypes){ + public GetNotifications(String maxID, int limit, EnumSet includeTypes, boolean isPleromaInstance){ super(HttpMethod.GET, "/notifications", new TypeToken<>(){}); if(maxID!=null) addQueryParameter("max_id", maxID); if(limit>0) addQueryParameter("limit", ""+limit); if(includeTypes!=null){ - for(String type:ApiUtils.enumSetToStrings(includeTypes, Notification.Type.class)){ - addQueryParameter("types[]", type); - } - for(String type:ApiUtils.enumSetToStrings(EnumSet.complementOf(includeTypes), Notification.Type.class)){ - addQueryParameter("exclude_types[]", type); + if(!isPleromaInstance) { + for(String type:ApiUtils.enumSetToStrings(includeTypes, Notification.Type.class)){ + addQueryParameter("types[]", type); + } + for(String type:ApiUtils.enumSetToStrings(EnumSet.complementOf(includeTypes), Notification.Type.class)){ + addQueryParameter("exclude_types[]", type); + } + }else{ + for(String type:ApiUtils.enumSetToStrings(includeTypes, Notification.Type.class)){ + addQueryParameter("include_types[]", type); + } } } removeUnsupportedItems=true; diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/PleromaMarkNotificationsRead.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/PleromaMarkNotificationsRead.java new file mode 100644 index 000000000..384843b99 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/PleromaMarkNotificationsRead.java @@ -0,0 +1,30 @@ +package org.joinmastodon.android.api.requests.notifications; + +import android.text.TextUtils; + +import com.google.gson.reflect.TypeToken; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.Notification; + +import java.util.List; + +import okhttp3.MultipartBody; +import okhttp3.RequestBody; + +public class PleromaMarkNotificationsRead extends MastodonAPIRequest> { + private final String maxID; + public PleromaMarkNotificationsRead(String maxID) { + super(HttpMethod.POST, "/pleroma/notifications/read", new TypeToken<>(){}); + this.maxID = maxID; + } + + @Override + public RequestBody getRequestBody() { + MultipartBody.Builder builder=new MultipartBody.Builder() + .setType(MultipartBody.FORM); + if(!TextUtils.isEmpty(maxID)) + builder.addFormDataPart("max_id", maxID); + return builder.build(); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/RegisterForPushNotifications.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/RegisterForPushNotifications.java index b1ef8ace9..fb6cabcd9 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/RegisterForPushNotifications.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/RegisterForPushNotifications.java @@ -4,10 +4,10 @@ import org.joinmastodon.android.api.MastodonAPIRequest; import org.joinmastodon.android.model.PushSubscription; public class RegisterForPushNotifications extends MastodonAPIRequest{ - public RegisterForPushNotifications(String deviceToken, String encryptionKey, String authKey, PushSubscription.Alerts alerts, PushSubscription.Policy policy, String accountID){ + public RegisterForPushNotifications(String endpoint, String encryptionKey, String authKey, PushSubscription.Alerts alerts, PushSubscription.Policy policy){ super(HttpMethod.POST, "/push/subscription", PushSubscription.class); Request r=new Request(); - r.subscription.endpoint="https://app.joinmastodon.org/relay-to/fcm/"+deviceToken+"/"+accountID; + r.subscription.endpoint=endpoint; r.data.alerts=alerts; r.policy=policy; r.subscription.keys.p256dh=encryptionKey; 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 a9cf806f2..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="Mastodon for Android"; + public String clientName="Moshidon"; public String redirectUris=AccountSessionManager.REDIRECT_URI; public String scopes=AccountSessionManager.SCOPE; - public String website="https://app.joinmastodon.org/android"; + public String website="https://github.com/LucasGGamerM/moshidon"; } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/AddStatusReaction.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/AddStatusReaction.java new file mode 100644 index 000000000..536634655 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/AddStatusReaction.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 AddStatusReaction extends MastodonAPIRequest { + public AddStatusReaction(String id, String emoji) { + super(HttpMethod.POST, "/statuses/" + id + "/react/" + emoji, Status.class); + setRequestBody(new Object()); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/AkkomaTranslateStatus.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/AkkomaTranslateStatus.java new file mode 100644 index 000000000..f47f7c2c1 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/AkkomaTranslateStatus.java @@ -0,0 +1,11 @@ +package org.joinmastodon.android.api.requests.statuses; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.AkkomaTranslation; + +public class AkkomaTranslateStatus extends MastodonAPIRequest{ + public AkkomaTranslateStatus(String id, String lang){ + super(HttpMethod.GET, "/statuses/"+id+"/translations/"+lang.toUpperCase(), AkkomaTranslation.class); + } +} + diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/CreateStatus.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/CreateStatus.java index ef2109b83..6a241aa45 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/CreateStatus.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/CreateStatus.java @@ -1,6 +1,8 @@ package org.joinmastodon.android.api.requests.statuses; import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.ContentType; +import org.joinmastodon.android.model.ScheduledStatus; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.model.StatusPrivacy; @@ -9,12 +11,27 @@ import java.util.ArrayList; import java.util.List; public class CreateStatus extends MastodonAPIRequest{ + public static long EPOCH_OF_THE_YEAR_FIVE_THOUSAND=95617584000000L; + public static final Instant DRAFTS_AFTER_INSTANT=Instant.ofEpochMilli(EPOCH_OF_THE_YEAR_FIVE_THOUSAND - 1) /* end of 4999 */; + + public static Instant getDraftInstant() { + return DRAFTS_AFTER_INSTANT.plusMillis(System.currentTimeMillis()); + } + public CreateStatus(CreateStatus.Request req, String uuid){ super(HttpMethod.POST, "/statuses", Status.class); setRequestBody(req); addHeader("Idempotency-Key", uuid); } + public static class Scheduled extends MastodonAPIRequest{ + public Scheduled(CreateStatus.Request req, String uuid){ + super(HttpMethod.POST, "/statuses", ScheduledStatus.class); + setRequestBody(req); + addHeader("Idempotency-Key", uuid); + } + } + public static class Request{ public String status; public List mediaAttributes; @@ -22,11 +39,17 @@ public class CreateStatus extends MastodonAPIRequest{ public Poll poll; public String inReplyToId; public boolean sensitive; + public boolean localOnly; public String spoilerText; public StatusPrivacy visibility; public Instant scheduledAt; public String language; + public String quoteId; + public ContentType contentType; + + public boolean preview; + public static class Poll{ public ArrayList options=new ArrayList<>(); public int expiresIn; diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/DeleteStatus.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/DeleteStatus.java index edc8a70bc..3730a64f6 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/DeleteStatus.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/DeleteStatus.java @@ -7,4 +7,10 @@ public class DeleteStatus extends MastodonAPIRequest{ public DeleteStatus(String id){ super(HttpMethod.DELETE, "/statuses/"+id, Status.class); } + + public static class Scheduled extends MastodonAPIRequest { + public Scheduled(String id) { + super(HttpMethod.DELETE, "/scheduled_statuses/"+id, Object.class); + } + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/DeleteStatusReaction.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/DeleteStatusReaction.java new file mode 100644 index 000000000..133b09730 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/DeleteStatusReaction.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 DeleteStatusReaction extends MastodonAPIRequest { + public DeleteStatusReaction(String id, String emoji) { + super(HttpMethod.POST, "/statuses/" + id + "/unreact/" + emoji, Status.class); + setRequestBody(new Object()); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/GetScheduledStatuses.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/GetScheduledStatuses.java new file mode 100644 index 000000000..fec163f2f --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/GetScheduledStatuses.java @@ -0,0 +1,16 @@ +package org.joinmastodon.android.api.requests.statuses; + +import com.google.gson.reflect.TypeToken; + +import org.joinmastodon.android.api.requests.HeaderPaginationRequest; +import org.joinmastodon.android.model.ScheduledStatus; + +public class GetScheduledStatuses extends HeaderPaginationRequest{ + public GetScheduledStatuses(String maxID, int limit){ + super(HttpMethod.GET, "/scheduled_statuses", 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/statuses/GetStatusSourceText.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/GetStatusSourceText.java index f1dd895e3..1a1df5a5f 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/GetStatusSourceText.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/GetStatusSourceText.java @@ -2,17 +2,22 @@ package org.joinmastodon.android.api.requests.statuses; import org.joinmastodon.android.api.AllFieldsAreRequired; import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.api.RequiredField; import org.joinmastodon.android.model.BaseModel; +import org.joinmastodon.android.model.ContentType; public class GetStatusSourceText extends MastodonAPIRequest{ public GetStatusSourceText(String id){ super(HttpMethod.GET, "/statuses/"+id+"/source", Response.class); } - @AllFieldsAreRequired public static class Response extends BaseModel{ + @RequiredField public String id; + @RequiredField public String text; + @RequiredField public String spoilerText; + public ContentType contentType; } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/PleromaAddStatusReaction.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/PleromaAddStatusReaction.java new file mode 100644 index 000000000..fcf25cfb7 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/PleromaAddStatusReaction.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 PleromaAddStatusReaction extends MastodonAPIRequest { + public PleromaAddStatusReaction(String id, String emoji) { + super(HttpMethod.PUT, "/pleroma/statuses/" + id + "/reactions/" + emoji, Status.class); + setRequestBody(new Object()); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/PleromaDeleteStatusReaction.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/PleromaDeleteStatusReaction.java new file mode 100644 index 000000000..5657c23df --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/PleromaDeleteStatusReaction.java @@ -0,0 +1,10 @@ +package org.joinmastodon.android.api.requests.statuses; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.Status; + +public class PleromaDeleteStatusReaction extends MastodonAPIRequest { + public PleromaDeleteStatusReaction(String id, String emoji) { + super(HttpMethod.DELETE, "/pleroma/statuses/" + id + "/reactions/" + emoji, Status.class); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/PleromaGetStatusReactions.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/PleromaGetStatusReactions.java new file mode 100644 index 000000000..e344321db --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/PleromaGetStatusReactions.java @@ -0,0 +1,14 @@ +package org.joinmastodon.android.api.requests.statuses; + +import com.google.gson.reflect.TypeToken; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.EmojiReaction; + +import java.util.List; + +public class PleromaGetStatusReactions extends MastodonAPIRequest> { + public PleromaGetStatusReactions(String id, String emoji) { + super(HttpMethod.GET, "/pleroma/statuses/" + id + "/reactions/" + (emoji != null ? emoji : ""), new TypeToken<>(){}); + } +} 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/statuses/SetStatusPinned.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/SetStatusPinned.java new file mode 100644 index 000000000..e7c55e63f --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/SetStatusPinned.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 SetStatusPinned extends MastodonAPIRequest{ + public SetStatusPinned(String id, boolean pinned){ + super(HttpMethod.POST, "/statuses/"+id+"/"+(pinned ? "pin" : "unpin"), Status.class); + setRequestBody(new Object()); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/SetStatusReblogged.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/SetStatusReblogged.java index a28aed7ba..64b236778 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/SetStatusReblogged.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/SetStatusReblogged.java @@ -2,10 +2,17 @@ package org.joinmastodon.android.api.requests.statuses; import org.joinmastodon.android.api.MastodonAPIRequest; import org.joinmastodon.android.model.Status; +import org.joinmastodon.android.model.StatusPrivacy; public class SetStatusReblogged extends MastodonAPIRequest{ - public SetStatusReblogged(String id, boolean reblogged){ + public SetStatusReblogged(String id, boolean reblogged, StatusPrivacy visibility){ super(HttpMethod.POST, "/statuses/"+id+"/"+(reblogged ? "reblog" : "unreblog"), Status.class); - setRequestBody(new Object()); + Request req = new Request(); + req.visibility = visibility; + setRequestBody(req); + } + + public static class Request { + public StatusPrivacy visibility; } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/tags/GetFollowedHashtags.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/tags/GetFollowedHashtags.java new file mode 100644 index 000000000..b347fe235 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/tags/GetFollowedHashtags.java @@ -0,0 +1,28 @@ +package org.joinmastodon.android.api.requests.tags; + +import com.google.gson.reflect.TypeToken; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.api.requests.HeaderPaginationRequest; +import org.joinmastodon.android.model.Hashtag; + +import java.util.List; + +public class GetFollowedHashtags extends HeaderPaginationRequest { + public GetFollowedHashtags() { + this(null, null, -1, null); + } + + public GetFollowedHashtags(String maxID, String minID, int limit, String sinceID){ + super(HttpMethod.GET, "/followed_tags", new TypeToken<>(){}); + if(maxID!=null) + addQueryParameter("max_id", maxID); + if(minID!=null) + addQueryParameter("min_id", minID); + if(sinceID!=null) + addQueryParameter("since_id", sinceID); + if(limit>0) + addQueryParameter("limit", ""+limit); + } +} + diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/tags/GetHashtag.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/tags/GetHashtag.java new file mode 100644 index 000000000..2b4cf8f5c --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/tags/GetHashtag.java @@ -0,0 +1,11 @@ +package org.joinmastodon.android.api.requests.tags; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.Hashtag; + +public class GetHashtag extends MastodonAPIRequest { + public GetHashtag(String name){ + super(HttpMethod.GET, "/tags/"+name, Hashtag.class); + } +} + diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/tags/SetHashtagFollowed.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/tags/SetHashtagFollowed.java new file mode 100644 index 000000000..362e6f40d --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/tags/SetHashtagFollowed.java @@ -0,0 +1,11 @@ +package org.joinmastodon.android.api.requests.tags; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.Hashtag; + +public class SetHashtagFollowed extends MastodonAPIRequest{ + public SetHashtagFollowed(String name, boolean followed){ + super(HttpMethod.POST, "/tags/"+name+"/"+(followed ? "follow" : "unfollow"), Hashtag.class); + setRequestBody(new Object()); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetBubbleTimeline.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetBubbleTimeline.java new file mode 100644 index 000000000..fbb19a0f0 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetBubbleTimeline.java @@ -0,0 +1,22 @@ +package org.joinmastodon.android.api.requests.timelines; + +import android.text.TextUtils; + +import com.google.gson.reflect.TypeToken; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.Status; + +import java.util.List; + +public class GetBubbleTimeline extends MastodonAPIRequest> { + public GetBubbleTimeline(String maxID, int limit, String replyVisibility) { + super(HttpMethod.GET, "/timelines/bubble", new TypeToken<>(){}); + if(!TextUtils.isEmpty(maxID)) + addQueryParameter("max_id", maxID); + if(limit>0) + addQueryParameter("limit", limit+""); + if(replyVisibility != null) + addQueryParameter("reply_visibility", replyVisibility); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetHashtagTimeline.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetHashtagTimeline.java index 4a3c831df..a47c47d61 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetHashtagTimeline.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetHashtagTimeline.java @@ -8,13 +8,26 @@ import org.joinmastodon.android.model.Status; import java.util.List; public class GetHashtagTimeline extends MastodonAPIRequest>{ - public GetHashtagTimeline(String hashtag, String maxID, String minID, int limit){ + public GetHashtagTimeline(String hashtag, String maxID, String minID, int limit, List containsAny, List containsAll, List containsNone, boolean localOnly, String replyVisibility){ super(HttpMethod.GET, "/timelines/tag/"+hashtag, new TypeToken<>(){}); + if (localOnly) + addQueryParameter("local", "true"); if(maxID!=null) addQueryParameter("max_id", maxID); if(minID!=null) addQueryParameter("min_id", minID); if(limit>0) addQueryParameter("limit", ""+limit); + if(containsAny!=null) + for (String tag : containsAny) + addQueryParameter("any[]", tag); + if(containsAll!=null) + for (String tag : containsAll) + addQueryParameter("all[]", tag); + if(containsNone!=null) + for (String tag : containsNone) + addQueryParameter("none[]", tag); + if(replyVisibility != null) + addQueryParameter("reply_visibility", replyVisibility); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetHomeTimeline.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetHomeTimeline.java index a84978d2a..1a605cab2 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetHomeTimeline.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetHomeTimeline.java @@ -8,7 +8,7 @@ import org.joinmastodon.android.model.Status; import java.util.List; public class GetHomeTimeline extends MastodonAPIRequest>{ - public GetHomeTimeline(String maxID, String minID, int limit, String sinceID){ + public GetHomeTimeline(String maxID, String minID, int limit, String sinceID, String replyVisibility){ super(HttpMethod.GET, "/timelines/home", new TypeToken<>(){}); if(maxID!=null) addQueryParameter("max_id", maxID); @@ -18,5 +18,7 @@ public class GetHomeTimeline extends MastodonAPIRequest>{ addQueryParameter("since_id", sinceID); if(limit>0) addQueryParameter("limit", ""+limit); + if(replyVisibility != null) + addQueryParameter("reply_visibility", replyVisibility); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetListTimeline.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetListTimeline.java index 899893cc4..29dfb67ac 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetListTimeline.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetListTimeline.java @@ -7,16 +7,18 @@ import org.joinmastodon.android.model.Status; import java.util.List; -public class GetListTimeline extends MastodonAPIRequest>{ - public GetListTimeline(String listID, String maxID, String minID, int limit, String sinceID){ - super(HttpMethod.GET, "/timelines/list/"+listID, new TypeToken<>(){}); - if(maxID!=null) - addQueryParameter("max_id", maxID); - if(minID!=null) - addQueryParameter("min_id", minID); - if(limit>0) - addQueryParameter("limit", ""+limit); - if(sinceID!=null) - addQueryParameter("since_id", sinceID); - } +public class GetListTimeline extends MastodonAPIRequest> { + public GetListTimeline(String listID, String maxID, String minID, int limit, String sinceID, String replyVisibility) { + super(HttpMethod.GET, "/timelines/list/"+listID, new TypeToken<>(){}); + if(maxID!=null) + addQueryParameter("max_id", maxID); + if(minID!=null) + addQueryParameter("min_id", minID); + if(limit>0) + addQueryParameter("limit", ""+limit); + if(sinceID!=null) + addQueryParameter("since_id", sinceID); + if(replyVisibility != null) + addQueryParameter("reply_visibility", replyVisibility); + } } 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 4486f3f89..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, String minID, int limit, String sinceID){ + 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"); @@ -24,5 +24,7 @@ public class GetPublicTimeline extends MastodonAPIRequest>{ addQueryParameter("since_id", sinceID); if(limit>0) addQueryParameter("limit", limit+""); + if(replyVisibility != null) + addQueryParameter("reply_visibility", replyVisibility); } } 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 a1eb474f1..24439acbe 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 @@ -1,23 +1,95 @@ package org.joinmastodon.android.api.session; +import static org.joinmastodon.android.GlobalUserPreferences.fromJson; +import static org.joinmastodon.android.GlobalUserPreferences.enumValue; +import static org.joinmastodon.android.api.MastodonAPIController.gson; + import android.content.SharedPreferences; +import androidx.annotation.StringRes; + +import com.google.gson.reflect.TypeToken; + +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.TimelineDefinition; + +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + public class AccountLocalPreferences{ private final SharedPreferences prefs; public boolean showInteractionCounts; public boolean customEmojiInNames; - public boolean showCWs; + public boolean revealCWs; public boolean hideSensitiveMedia; public boolean serverSideFiltersSupported; - public AccountLocalPreferences(SharedPreferences prefs){ + // MEGALODON + public boolean showReplies; + public boolean showBoosts; + public ArrayList recentLanguages; + public boolean bottomEncoding; + public ContentType defaultContentType; + public boolean contentTypesEnabled; + public ArrayList timelines; + public boolean localOnlySupported; + public boolean glitchInstance; + public String publishButtonText; + public String timelineReplyVisibility; // akkoma-only + public boolean keepOnlyLatestNotification; + public boolean emojiReactionsEnabled; + public ShowEmojiReactions showEmojiReactions; + public ColorPreference color; + public ArrayList recentCustomEmoji; + + 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", true); + showInteractionCounts=prefs.getBoolean("interactionCounts", false); customEmojiInNames=prefs.getBoolean("emojiInNames", true); - showCWs=prefs.getBoolean("showCWs", true); + revealCWs=prefs.getBoolean("revealCWs", false); hideSensitiveMedia=prefs.getBoolean("hideSensitive", true); serverSideFiltersSupported=prefs.getBoolean("serverSideFilters", false); + + // MEGALODON + showReplies=prefs.getBoolean("showReplies", true); + showBoosts=prefs.getBoolean("showBoosts", true); + recentLanguages=fromJson(prefs.getString("recentLanguages", null), recentLanguagesType, new ArrayList<>()); + bottomEncoding=prefs.getBoolean("bottomEncoding", false); + defaultContentType=enumValue(ContentType.class, prefs.getString("defaultContentType", ContentType.PLAIN.name())); + contentTypesEnabled=prefs.getBoolean("contentTypesEnabled", true); + timelines=fromJson(prefs.getString("timelines", null), timelinesType, TimelineDefinition.getDefaultTimelines(session.getID())); + localOnlySupported=prefs.getBoolean("localOnlySupported", false); + glitchInstance=prefs.getBoolean("glitchInstance", false); + publishButtonText=prefs.getString("publishButtonText", null); + timelineReplyVisibility=prefs.getString("timelineReplyVisibility", null); + keepOnlyLatestNotification=prefs.getBoolean("keepOnlyLatestNotification", false); + emojiReactionsEnabled=prefs.getBoolean("emojiReactionsEnabled", session.getInstance().isPresent() && session.getInstance().get().isAkkoma()); + 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(){ @@ -28,13 +100,73 @@ public class AccountLocalPreferences{ prefs.edit().putLong("notificationsPauseTime", time).apply(); } + public ColorPreference getCurrentColor(){ + return color!=null ? color : GlobalUserPreferences.color!=null ? GlobalUserPreferences.color : ColorPreference.MATERIAL3; + } + public void save(){ prefs.edit() .putBoolean("interactionCounts", showInteractionCounts) .putBoolean("emojiInNames", customEmojiInNames) - .putBoolean("showCWs", showCWs) + .putBoolean("revealCWs", revealCWs) .putBoolean("hideSensitive", hideSensitiveMedia) .putBoolean("serverSideFilters", serverSideFiltersSupported) + + // MEGALODON + .putBoolean("showReplies", showReplies) + .putBoolean("showBoosts", showBoosts) + .putString("recentLanguages", gson.toJson(recentLanguages)) + .putBoolean("bottomEncoding", bottomEncoding) + .putString("defaultContentType", defaultContentType==null ? null : defaultContentType.name()) + .putBoolean("contentTypesEnabled", contentTypesEnabled) + .putString("timelines", gson.toJson(timelines)) + .putBoolean("localOnlySupported", localOnlySupported) + .putBoolean("glitchInstance", glitchInstance) + .putString("publishButtonText", publishButtonText) + .putString("timelineReplyVisibility", timelineReplyVisibility) + .putBoolean("keepOnlyLatestNotification", keepOnlyLatestNotification) + .putBoolean("emojiReactionsEnabled", emojiReactionsEnabled) + .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, + PURPLE, + PINK, + GREEN, + BLUE, + BROWN, + RED, + YELLOW, + NORD, + WHITE; + + public @StringRes int getName() { + return switch(this){ + case MATERIAL3 -> R.string.sk_color_palette_material3; + case PINK -> R.string.sk_color_palette_pink; + case PURPLE -> R.string.sk_color_palette_purple; + case GREEN -> R.string.sk_color_palette_green; + case BLUE -> R.string.sk_color_palette_blue; + 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; + }; + } + } + + public enum ShowEmojiReactions{ + HIDE_EMPTY, + ONLY_OPENED, + ALWAYS + } } 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 43190c73d..8f5a6fcba 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 @@ -3,6 +3,7 @@ package org.joinmastodon.android.api.session; import android.app.Activity; import android.content.Context; import android.content.SharedPreferences; +import android.net.Uri; import android.text.TextUtils; import android.util.Log; @@ -24,6 +25,7 @@ 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; @@ -36,6 +38,8 @@ import org.joinmastodon.android.utils.ObjectIdComparator; import java.io.File; import java.util.ArrayList; import java.util.List; +import java.util.Objects; +import java.util.Optional; import java.util.function.Consumer; import java.util.function.Function; @@ -62,7 +66,7 @@ public class AccountSession{ public AccountActivationInfo activationInfo; public Preferences preferences; private transient MastodonAPIController apiController; - private transient StatusInteractionController statusInteractionController; + private transient StatusInteractionController statusInteractionController, remoteStatusInteractionController; private transient CacheController cacheController; private transient PushSubscriptionManager pushSubscriptionManager; private transient SharedPreferences prefs; @@ -98,6 +102,12 @@ public class AccountSession{ return statusInteractionController; } + public StatusInteractionController getRemoteStatusInteractionController(){ + if(remoteStatusInteractionController==null) + remoteStatusInteractionController=new StatusInteractionController(getID(), false); + return remoteStatusInteractionController; + } + public CacheController getCacheController(){ if(cacheController==null) cacheController=new CacheController(getID()); @@ -114,12 +124,22 @@ public class AccountSession{ return '@'+self.username+'@'+domain; } + public void preferencesFromAccountSource(Account account) { + if (account != null && account.source != null && preferences != null) { + if (account.source.privacy != null) + preferences.postingDefaultVisibility = account.source.privacy; + if (account.source.language != null) + preferences.postingDefaultLanguage = account.source.language; + } + } + public void reloadPreferences(Consumer callback){ new GetPreferences() .setCallback(new Callback<>(){ @Override public void onSuccess(Preferences result){ preferences=result; + preferencesFromAccountSource(self); if(callback!=null) callback.accept(result); AccountSessionManager.getInstance().writeAccountsFile(); @@ -128,6 +148,9 @@ public class AccountSession{ @Override public void onError(ErrorResponse error){ Log.w(TAG, "Failed to load preferences for account "+getID()+": "+error); + if (preferences==null) + preferences=new Preferences(); + preferencesFromAccountSource(self); } }) .exec(getID()); @@ -198,7 +221,7 @@ public class AccountSession{ public void savePreferencesIfPending(){ if(preferencesNeedSaving){ - new UpdateAccountCredentialsPreferences(preferences, null, self.discoverable, self.source.indexable) + new UpdateAccountCredentialsPreferences(preferences, self.locked, self.discoverable, self.source.indexable) .setCallback(new Callback<>(){ @Override public void onSuccess(Account result){ @@ -218,57 +241,112 @@ public class AccountSession{ public AccountLocalPreferences getLocalPreferences(){ if(localPreferences==null) - localPreferences=new AccountLocalPreferences(getRawLocalPreferences()); + localPreferences=new AccountLocalPreferences(getRawLocalPreferences(), this); return localPreferences; } public void filterStatuses(List statuses, FilterContext context){ - filterStatusContainingObjects(statuses, Function.identity(), context); + filterStatuses(statuses, context, null); + } + + public void filterStatuses(List statuses, FilterContext context, Account profile){ + filterStatusContainingObjects(statuses, Function.identity(), context, profile); } public void filterStatusContainingObjects(List objects, Function extractor, FilterContext context){ - if(getLocalPreferences().serverSideFiltersSupported){ - // Even with server-side filters, clients are expected to remove statuses that match a filter that hides them - objects.removeIf(o->{ - Status s=extractor.apply(o); - if(s==null) - return false; - if(s.filtered==null) - return false; - for(FilterResult filter:s.filtered){ - if(filter.filter.isActive() && filter.filter.filterAction==FilterAction.HIDE) - return true; - } - return false; - }); - return; - } - if(wordFilters==null) - return; - for(T obj:objects){ + filterStatusContainingObjects(objects, extractor, context, null); + } + + private boolean statusIsOnOwnProfile(Status s, Account profile){ + return self != null && profile != null && s.account != null + && Objects.equals(self.id, profile.id) && Objects.equals(self.id, s.account.id); + } + + private boolean isFilteredType(Status s){ + AccountLocalPreferences localPreferences = getLocalPreferences(); + return (!localPreferences.showReplies && s.inReplyToId != null) + || (!localPreferences.showBoosts && s.reblog != null); + } + + public void filterStatusContainingObjects(List objects, Function extractor, FilterContext context, Account profile){ + AccountLocalPreferences localPreferences = getLocalPreferences(); + if(!localPreferences.serverSideFiltersSupported) for(T obj:objects){ Status s=extractor.apply(obj); if(s!=null && s.filtered!=null){ - getLocalPreferences().serverSideFiltersSupported=true; - getLocalPreferences().save(); - return; + localPreferences.serverSideFiltersSupported=true; + localPreferences.save(); + break; } } - objects.removeIf(o->{ - Status s=extractor.apply(o); - if(s==null) - return false; - for(LegacyFilter filter:wordFilters){ + + List removeUs=new ArrayList<>(); + for(int i=0; i0){ + // oops, we're about to remove an item that has a gap after... + // gotta find the previous status that's not also about to be removed + for(int j=i-1; j>=0; j--){ + T p=objects.get(j); + Status prev=extractor.apply(objects.get(j)); + if(prev!=null && !removeUs.contains(p)){ + prev.hasGapAfter=s.hasGapAfter; + break; + } + } + } + } + } + objects.removeAll(removeUs); + } + + public boolean filterStatusContainingObject(T object, Function extractor, FilterContext context, Account profile){ + Status s=extractor.apply(object); + if(s==null) + return false; + // don't hide own posts in own profile + if(statusIsOnOwnProfile(s, profile)) + return false; + if(isFilteredType(s) && (context == FilterContext.HOME || context == FilterContext.PUBLIC)) + return true; + // Even with server-side filters, clients are expected to remove statuses that match a filter that hides them + if(getLocalPreferences().serverSideFiltersSupported){ + for(FilterResult filter : s.filtered){ + if(filter.filter.isActive() && filter.filter.filterAction==FilterAction.HIDE) + return true; + } + }else if(wordFilters!=null){ + for(LegacyFilter filter : wordFilters){ if(filter.context.contains(context) && filter.matches(s) && filter.isActive()) return true; } - return false; - }); + } + return false; } public void updateAccountInfo(){ AccountSessionManager.getInstance().updateSessionLocalInfo(this); } + public Optional getInstance() { + return Optional.ofNullable(AccountSessionManager.getInstance().getInstanceInfo(domain)); + } + + public Uri getInstanceUri() { + return new Uri.Builder() + .scheme("https") + .authority(getInstance().map(i -> i.normalizedUri).orElse(domain)) + .build(); + } + + public String getDefaultAvatarUrl() { + return getInstance() + .map(instance->"https://"+domain+(instance.isAkkoma() ? "/images/avi.png" : "/avatars/original/missing.png")) + .orElse(""); + } + public boolean isNotificationsMentionsOnly(){ return getRawLocalPreferences().getBoolean("notificationsMentionsOnly", false); } 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 07e5059d4..6a80d5572 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,5 +1,7 @@ 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; @@ -15,6 +17,7 @@ import android.util.Log; import org.joinmastodon.android.BuildConfig; import org.joinmastodon.android.E; +import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.MainActivity; import org.joinmastodon.android.MastodonApp; import org.joinmastodon.android.R; @@ -33,6 +36,7 @@ import org.joinmastodon.android.model.EmojiCategory; import org.joinmastodon.android.model.LegacyFilter; import org.joinmastodon.android.model.Instance; import org.joinmastodon.android.model.Token; +import org.unifiedpush.android.connector.UnifiedPush; import java.io.File; import java.io.FileInputStream; @@ -47,6 +51,7 @@ import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; @@ -59,7 +64,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="mastodon-android-auth://callback"; + public static final String REDIRECT_URI = getRedirectURI(); private static final AccountSessionManager instance=new AccountSessionManager(); @@ -78,6 +83,17 @@ 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); File file=new File(MastodonApp.context.getFilesDir(), "accounts.json"); @@ -99,27 +115,43 @@ public class AccountSessionManager{ } public void addAccount(Instance instance, Token token, Account self, Application app, AccountActivationInfo activationInfo){ + Context context = MastodonApp.context; instances.put(instance.uri, instance); AccountSession session=new AccountSession(token, self, app, instance.uri, activationInfo==null, activationInfo); sessions.put(session.getID(), session); lastActiveAccountID=session.getID(); writeAccountsFile(); - updateInstanceEmojis(instance, instance.uri); - if(PushSubscriptionManager.arePushNotificationsAvailable()){ + + // write initial instance info to file immediately to avoid sessions without instance info + InstanceInfoStorageWrapper wrapper = new InstanceInfoStorageWrapper(); + wrapper.instance = instance; + MastodonAPIController.runInBackground(()->writeInstanceInfoFile(wrapper, instance.uri)); + + updateMoreInstanceInfo(instance, instance.uri); + if (!UnifiedPush.getDistributor(context).isEmpty()) { + UnifiedPush.registerApp( + context, + session.getID(), + new ArrayList<>(), + context.getPackageName() + ); + } else if(PushSubscriptionManager.arePushNotificationsAvailable()){ session.getPushSubscriptionManager().registerAccountForPush(null); } maybeUpdateShortcuts(); } public synchronized void writeAccountsFile(){ - File file=new File(MastodonApp.context.getFilesDir(), "accounts.json"); + File tmpFile = new File(MastodonApp.context.getFilesDir(), "accounts.json~"); + File file = new File(MastodonApp.context.getFilesDir(), "accounts.json"); try{ - try(FileOutputStream out=new FileOutputStream(file)){ + try(FileOutputStream out=new FileOutputStream(tmpFile)){ SessionsStorageWrapper w=new SessionsStorageWrapper(); w.accounts=new ArrayList<>(sessions.values()); OutputStreamWriter writer=new OutputStreamWriter(out, StandardCharsets.UTF_8); MastodonAPIController.gson.toJson(w, writer); writer.flush(); + if (!tmpFile.renameTo(file)) Log.e(TAG, "Error renaming " + tmpFile.getPath() + " to " + file.getPath()); } }catch(IOException x){ Log.e(TAG, "Error writing accounts file", x); @@ -149,6 +181,15 @@ public class AccountSessionManager{ return sessions.get(id); } + public static Optional getOptional(String id) { + return Optional.ofNullable(getInstance().tryGetAccount(id)); + } + + @Nullable + public AccountSession tryGetAccount(Account account) { + return sessions.get(account.getDomainFromURL() + "_" + account.id); + } + @Nullable public AccountSession getLastActiveAccount(){ if(sessions.isEmpty() || lastActiveAccountID==null) @@ -225,7 +266,7 @@ public class AccountSessionManager{ .path("/oauth/authorize") .appendQueryParameter("response_type", "code") .appendQueryParameter("client_id", result.clientId) - .appendQueryParameter("redirect_uri", "mastodon-android-auth://callback") + .appendQueryParameter("redirect_uri", REDIRECT_URI) .appendQueryParameter("scope", SCOPE) .build(); @@ -258,27 +299,32 @@ public class AccountSessionManager{ } public void maybeUpdateLocalInfo(){ + maybeUpdateLocalInfo(null); + } + + public void maybeUpdateLocalInfo(AccountSession activeSession){ long now=System.currentTimeMillis(); HashSet domains=new HashSet<>(); for(AccountSession session:sessions.values()){ domains.add(session.domain.toLowerCase()); - if(now-session.infoLastUpdated>24L*3600_000L){ + if(session == activeSession || now-session.infoLastUpdated>24L*3600_000L){ + session.reloadPreferences(null); updateSessionLocalInfo(session); } - if(!session.getLocalPreferences().serverSideFiltersSupported && now-session.filtersLastUpdated>3600_000L){ + if(session == activeSession || (session.getLocalPreferences().serverSideFiltersSupported && now-session.filtersLastUpdated>3600_000L)){ updateSessionWordFilters(session); } } if(loadedInstances){ - maybeUpdateCustomEmojis(domains); + maybeUpdateCustomEmojis(domains, activeSession != null ? activeSession.domain : null); } } - private void maybeUpdateCustomEmojis(Set domains){ + private void maybeUpdateCustomEmojis(Set domains, String activeDomain){ long now=System.currentTimeMillis(); for(String domain:domains){ Long lastUpdated=instancesLastUpdated.get(domain); - if(lastUpdated==null || now-lastUpdated>24L*3600_000L){ + if(domain.equals(activeDomain) || lastUpdated==null || now-lastUpdated>24L*3600_000L){ updateInstanceInfo(domain); } } @@ -291,6 +337,7 @@ public class AccountSessionManager{ public void onSuccess(Account result){ session.self=result; session.infoLastUpdated=System.currentTimeMillis(); + session.preferencesFromAccountSource(result); writeAccountsFile(); } @@ -326,7 +373,7 @@ public class AccountSessionManager{ @Override public void onSuccess(Instance instance){ instances.put(domain, instance); - updateInstanceEmojis(instance, domain); + updateMoreInstanceInfo(instance, domain); } @Override @@ -337,6 +384,21 @@ public class AccountSessionManager{ .execNoAuth(domain); } + public void updateMoreInstanceInfo(Instance instance, String domain) { + new GetInstance.V2().setCallback(new Callback<>() { + @Override + public void onSuccess(Instance.V2 v2) { + if (instance != null) instance.v2 = v2; + updateInstanceEmojis(instance, domain); + } + + @Override + public void onError(ErrorResponse errorResponse) { + updateInstanceEmojis(instance, domain); + } + }).execNoAuth(instance.uri); + } + private void updateInstanceEmojis(Instance instance, String domain){ new GetCustomEmojis() .setCallback(new Callback<>(){ @@ -354,7 +416,9 @@ public class AccountSessionManager{ @Override public void onError(ErrorResponse error){ - + InstanceInfoStorageWrapper wrapper=new InstanceInfoStorageWrapper(); + wrapper.instance = instance; + MastodonAPIController.runInBackground(()->writeInstanceInfoFile(wrapper, domain)); } }) .execNoAuth(domain); @@ -365,10 +429,13 @@ public class AccountSessionManager{ } private void writeInstanceInfoFile(InstanceInfoStorageWrapper emojis, String domain){ - try(FileOutputStream out=new FileOutputStream(getInstanceInfoFile(domain))){ + File file = getInstanceInfoFile(domain); + File tmpFile = new File(file.getPath() + "~"); + try(FileOutputStream out=new FileOutputStream(tmpFile)){ OutputStreamWriter writer=new OutputStreamWriter(out, StandardCharsets.UTF_8); MastodonAPIController.gson.toJson(emojis, writer); writer.flush(); + if (!tmpFile.renameTo(file)) Log.e(TAG, "Error renaming " + tmpFile.getPath() + " to " + file.getPath()); }catch(IOException x){ Log.w(TAG, "Error writing instance info file for "+domain, x); } @@ -388,7 +455,7 @@ public class AccountSessionManager{ } if(!loadedInstances){ loadedInstances=true; - maybeUpdateCustomEmojis(domains); + maybeUpdateCustomEmojis(domains, null); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/events/EmojiReactionsUpdatedEvent.java b/mastodon/src/main/java/org/joinmastodon/android/events/EmojiReactionsUpdatedEvent.java new file mode 100644 index 000000000..2e0ce6467 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/events/EmojiReactionsUpdatedEvent.java @@ -0,0 +1,19 @@ +package org.joinmastodon.android.events; + +import androidx.recyclerview.widget.RecyclerView; +import org.joinmastodon.android.model.EmojiReaction; +import java.util.List; + +public class EmojiReactionsUpdatedEvent{ + public final String id; + public final List reactions; + public final boolean updateTextPadding; + public RecyclerView.ViewHolder viewHolder; + + public EmojiReactionsUpdatedEvent(String id, List reactions, boolean updateTextPadding, RecyclerView.ViewHolder viewHolder){ + this.id=id; + this.reactions=reactions; + this.updateTextPadding=updateTextPadding; + this.viewHolder=viewHolder; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/events/FollowRequestHandledEvent.java b/mastodon/src/main/java/org/joinmastodon/android/events/FollowRequestHandledEvent.java new file mode 100644 index 000000000..84cf3ebd3 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/events/FollowRequestHandledEvent.java @@ -0,0 +1,18 @@ +package org.joinmastodon.android.events; + +import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.model.Relationship; + +public class FollowRequestHandledEvent { + public String accountID; + public boolean accepted; + public Account account; + public Relationship relationship; + + public FollowRequestHandledEvent(String accountID, boolean accepted, Account account, Relationship rel){ + this.accountID=accountID; + this.accepted=accepted; + this.account=account; + this.relationship=rel; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/events/HashtagUpdatedEvent.java b/mastodon/src/main/java/org/joinmastodon/android/events/HashtagUpdatedEvent.java new file mode 100644 index 000000000..679c65793 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/events/HashtagUpdatedEvent.java @@ -0,0 +1,11 @@ +package org.joinmastodon.android.events; + +public class HashtagUpdatedEvent { + public final String name; + public final boolean following; + + public HashtagUpdatedEvent(String name, boolean following) { + this.name = name; + this.following = following; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/events/ListUpdatedCreatedEvent.java b/mastodon/src/main/java/org/joinmastodon/android/events/ListUpdatedCreatedEvent.java new file mode 100644 index 000000000..26e0081e6 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/events/ListUpdatedCreatedEvent.java @@ -0,0 +1,17 @@ +package org.joinmastodon.android.events; + +import org.joinmastodon.android.model.ListTimeline; + +public class ListUpdatedCreatedEvent { + public final String id; + public final String title; + public final ListTimeline.RepliesPolicy repliesPolicy; + public final boolean exclusive; + + public ListUpdatedCreatedEvent(String id, String title, boolean exclusive, ListTimeline.RepliesPolicy repliesPolicy) { + this.id = id; + this.title = title; + this.exclusive = exclusive; + this.repliesPolicy = repliesPolicy; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/events/NotificationDeletedEvent.java b/mastodon/src/main/java/org/joinmastodon/android/events/NotificationDeletedEvent.java new file mode 100644 index 000000000..e45419df0 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/events/NotificationDeletedEvent.java @@ -0,0 +1,9 @@ +package org.joinmastodon.android.events; + +public class NotificationDeletedEvent{ + public final String id; + + public NotificationDeletedEvent(String id){ + this.id=id; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/events/ReblogDeletedEvent.java b/mastodon/src/main/java/org/joinmastodon/android/events/ReblogDeletedEvent.java new file mode 100644 index 000000000..fbc0edfe5 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/events/ReblogDeletedEvent.java @@ -0,0 +1,11 @@ +package org.joinmastodon.android.events; + +public class ReblogDeletedEvent{ + public final String statusID; + public final String accountID; + + public ReblogDeletedEvent(String statusID, String accountID){ + this.statusID=statusID; + this.accountID=accountID; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/events/ScheduledStatusCreatedEvent.java b/mastodon/src/main/java/org/joinmastodon/android/events/ScheduledStatusCreatedEvent.java new file mode 100644 index 000000000..b11fb2375 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/events/ScheduledStatusCreatedEvent.java @@ -0,0 +1,13 @@ +package org.joinmastodon.android.events; + +import org.joinmastodon.android.model.ScheduledStatus; + +public class ScheduledStatusCreatedEvent { + public final ScheduledStatus scheduledStatus; + public final String accountID; + + public ScheduledStatusCreatedEvent(ScheduledStatus scheduledStatus, String accountID){ + this.scheduledStatus = scheduledStatus; + this.accountID=accountID; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/events/ScheduledStatusDeletedEvent.java b/mastodon/src/main/java/org/joinmastodon/android/events/ScheduledStatusDeletedEvent.java new file mode 100644 index 000000000..3c2923387 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/events/ScheduledStatusDeletedEvent.java @@ -0,0 +1,11 @@ +package org.joinmastodon.android.events; + +public class ScheduledStatusDeletedEvent{ + public final String id; + public final String accountID; + + public ScheduledStatusDeletedEvent(String id, String accountID){ + this.id=id; + this.accountID=accountID; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/events/StatusCountersUpdatedEvent.java b/mastodon/src/main/java/org/joinmastodon/android/events/StatusCountersUpdatedEvent.java index c4f15282a..cdcf37104 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/events/StatusCountersUpdatedEvent.java +++ b/mastodon/src/main/java/org/joinmastodon/android/events/StatusCountersUpdatedEvent.java @@ -5,15 +5,18 @@ import org.joinmastodon.android.model.Status; public class StatusCountersUpdatedEvent{ public String id; public long favorites, reblogs, replies; - public boolean favorited, reblogged, bookmarked; + public boolean favorited, reblogged, bookmarked, pinned; + public Status status; public StatusCountersUpdatedEvent(Status s){ id=s.id; + status=s; favorites=s.favouritesCount; reblogs=s.reblogsCount; replies=s.repliesCount; favorited=s.favourited; reblogged=s.reblogged; bookmarked=s.bookmarked; + pinned=s.pinned; } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/events/StatusCreatedEvent.java b/mastodon/src/main/java/org/joinmastodon/android/events/StatusCreatedEvent.java index 1e6d7eb42..1c7d19e47 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/events/StatusCreatedEvent.java +++ b/mastodon/src/main/java/org/joinmastodon/android/events/StatusCreatedEvent.java @@ -9,5 +9,6 @@ public class StatusCreatedEvent{ public StatusCreatedEvent(Status status, String accountID){ this.status=status; this.accountID=accountID; + status.fromStatusCreated=true; } } 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/StatusUnpinnedEvent.java b/mastodon/src/main/java/org/joinmastodon/android/events/StatusUnpinnedEvent.java new file mode 100644 index 000000000..54925c076 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/events/StatusUnpinnedEvent.java @@ -0,0 +1,11 @@ +package org.joinmastodon.android.events; + +public class StatusUnpinnedEvent { + public final String id; + public final String accountID; + + public StatusUnpinnedEvent(String id, String accountID){ + this.id=id; + this.accountID=accountID; + } +} 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/AccountTimelineFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/AccountTimelineFragment.java index 0bfe29062..333e385ad 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/AccountTimelineFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/AccountTimelineFragment.java @@ -1,47 +1,41 @@ package org.joinmastodon.android.fragments; import android.app.Activity; +import android.net.Uri; import android.os.Bundle; import android.view.View; -import android.widget.HorizontalScrollView; -import android.widget.LinearLayout; import org.joinmastodon.android.R; import org.joinmastodon.android.api.requests.accounts.GetAccountStatuses; import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.events.RemoveAccountPostsEvent; -import org.joinmastodon.android.events.StatusCreatedEvent; +import org.joinmastodon.android.events.StatusUnpinnedEvent; import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.FilterContext; import org.joinmastodon.android.model.Status; -import org.joinmastodon.android.ui.drawables.EmptyDrawable; -import org.joinmastodon.android.ui.views.FilterChipView; +import org.joinmastodon.android.ui.displayitems.HeaderStatusDisplayItem; import org.parceler.Parcels; import java.util.Collections; import java.util.List; +import java.util.Optional; -import androidx.recyclerview.widget.RecyclerView; 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 AccountTimelineFragment extends StatusListFragment{ private Account user; private GetAccountStatuses.Filter filter; - private HorizontalScrollView filtersBar; - private FilterChipView defaultFilter, withRepliesFilter, mediaFilter; public AccountTimelineFragment(){ setListLayoutId(R.layout.recycler_fragment_no_refresh); } - public static AccountTimelineFragment newInstance(String accountID, Account profileAccount, boolean load){ + public static AccountTimelineFragment newInstance(String accountID, Account profileAccount, GetAccountStatuses.Filter filter, boolean load){ AccountTimelineFragment f=new AccountTimelineFragment(); Bundle args=new Bundle(); args.putString("account", accountID); args.putParcelable("profileAccount", Parcels.wrap(profileAccount)); + args.putString("filter", filter.toString()); if(!load) args.putBoolean("noAutoLoad", true); args.putBoolean("__is_tab", true); @@ -52,21 +46,20 @@ public class AccountTimelineFragment extends StatusListFragment{ @Override public void onAttach(Activity activity){ user=Parcels.unwrap(getArguments().getParcelable("profileAccount")); - filter=GetAccountStatuses.Filter.DEFAULT; + filter=GetAccountStatuses.Filter.valueOf(getArguments().getString("filter")); super.onAttach(activity); } @Override protected void doLoadData(int offset, int count){ - currentRequest=new GetAccountStatuses(user.id, offset>0 ? getMaxID() : null, null, count, filter) + currentRequest=new GetAccountStatuses(user.id, getMaxID(), null, count, filter) .setCallback(new SimpleCallback<>(this){ @Override public void onSuccess(List result){ - if(getActivity()==null) - return; - boolean empty=result.isEmpty(); - AccountSessionManager.get(accountID).filterStatuses(result, FilterContext.ACCOUNT); - onDataLoaded(result, !empty); + if(getActivity()==null) return; + boolean more=applyMaxID(result); + AccountSessionManager.get(accountID).filterStatuses(result, getFilterContext(), user); + onDataLoaded(result, more); } }) .exec(accountID); @@ -86,17 +79,40 @@ public class AccountTimelineFragment extends StatusListFragment{ } protected void onStatusCreated(Status status){ - if(!AccountSessionManager.getInstance().isSelf(accountID, status.account)) + AccountSessionManager asm = AccountSessionManager.getInstance(); + if(!asm.isSelf(accountID, status.account) || !asm.isSelf(accountID, user)) return; + if(filter==GetAccountStatuses.Filter.PINNED) return; if(filter==GetAccountStatuses.Filter.DEFAULT){ // Keep replies to self, discard all other replies if(status.inReplyToAccountId!=null && !status.inReplyToAccountId.equals(AccountSessionManager.getInstance().getAccount(accountID).self.id)) return; }else if(filter==GetAccountStatuses.Filter.MEDIA){ - if(status.mediaAttachments.isEmpty()) + if(Optional.ofNullable(status.mediaAttachments).map(List::isEmpty).orElse(true)) return; } prependItems(Collections.singletonList(status), true); + if (isOnTop()) scrollToTop(); + } + + protected void onStatusUnpinned(StatusUnpinnedEvent ev){ + if(!ev.accountID.equals(accountID) || filter!=GetAccountStatuses.Filter.PINNED) + return; + + Status status=getStatusByID(ev.id); + data.remove(status); + preloadedData.remove(status); + HeaderStatusDisplayItem item=findItemOfType(ev.id, HeaderStatusDisplayItem.class); + if(item==null) + return; + int index=displayItems.indexOf(item); + int lastIndex; + for(lastIndex=index;lastIndex defaultFilter; - case INCLUDE_REPLIES -> withRepliesFilter; - case MEDIA -> mediaFilter; - default -> throw new IllegalStateException("Unexpected value: "+filter); - }; - } - - private void onFilterClick(View v){ - GetAccountStatuses.Filter newFilter=(GetAccountStatuses.Filter) v.getTag(); - if(newFilter==filter) - return; - // TODO maybe cache the filtered timelines that were already loaded? - if(currentRequest!=null){ - currentRequest.cancel(); - currentRequest=null; - } - getViewForFilter(filter).setSelected(false); - filter=newFilter; - v.setSelected(true); - data.clear(); - preloadedData.clear(); - int size=displayItems.size(); - displayItems.clear(); - adapter.notifyItemRangeRemoved(0, size); - loaded=false; - dataLoading=true; - doLoadData(); + public Uri getWebUri(Uri.Builder base) { + // could return different uris based on filter (e.g. media -> "/media"), but i want to + // return the remote url to the user, and i don't know whether i'd need to append + // '#media' (akkoma/pleroma) or '/media' (glitch/mastodon) since i don't know anything + // about the remote instance. so, just returning the base url to the user instead + return Uri.parse(user.url); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/AnnouncementsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/AnnouncementsFragment.java new file mode 100644 index 000000000..b376f24dc --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/AnnouncementsFragment.java @@ -0,0 +1,119 @@ +package org.joinmastodon.android.fragments; + +import static java.util.stream.Collectors.toList; + +import android.app.Activity; +import android.net.Uri; +import android.os.Bundle; +import android.text.TextUtils; +import android.view.View; +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.announcements.GetAnnouncements; +import org.joinmastodon.android.api.requests.statuses.CreateStatus; +import org.joinmastodon.android.api.requests.statuses.GetScheduledStatuses; +import org.joinmastodon.android.api.session.AccountSession; +import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.events.ScheduledStatusCreatedEvent; +import org.joinmastodon.android.events.ScheduledStatusDeletedEvent; +import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.model.Announcement; +import org.joinmastodon.android.model.HeaderPaginationList; +import org.joinmastodon.android.model.Instance; +import org.joinmastodon.android.model.ScheduledStatus; +import org.joinmastodon.android.model.Status; +import org.joinmastodon.android.ui.displayitems.DummyStatusDisplayItem; +import org.joinmastodon.android.ui.displayitems.EmojiReactionsStatusDisplayItem; +import org.joinmastodon.android.ui.displayitems.HeaderStatusDisplayItem; +import org.joinmastodon.android.ui.displayitems.StatusDisplayItem; +import org.joinmastodon.android.ui.displayitems.TextStatusDisplayItem; +import org.joinmastodon.android.ui.text.HtmlParser; +import org.joinmastodon.android.ui.utils.UiUtils; +import org.parceler.Parcels; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import me.grishka.appkit.Nav; +import me.grishka.appkit.api.PaginatedList; +import me.grishka.appkit.api.SimpleCallback; + +public class AnnouncementsFragment extends BaseStatusListFragment { + private Instance instance; + private AccountSession session; + private List unreadIDs = null; + + @Override + public void onAttach(Activity activity){ + super.onAttach(activity); + setTitle(R.string.sk_announcements); + session = AccountSessionManager.getInstance().getAccount(accountID); + instance = AccountSessionManager.getInstance().getInstanceInfo(session.domain); + loadData(); + } + + @Override + protected List buildDisplayItems(Announcement a) { + if(TextUtils.isEmpty(a.content)) return List.of(); + Account instanceUser = new Account(); + instanceUser.id = instanceUser.acct = instanceUser.username = session.domain; + instanceUser.displayName = instance.title; + instanceUser.url = "https://"+session.domain+"/about"; + instanceUser.avatar = instanceUser.avatarStatic = instance.thumbnail; + instanceUser.emojis = List.of(); + Status fakeStatus = a.toStatus(); + TextStatusDisplayItem textItem = new TextStatusDisplayItem(a.id, HtmlParser.parse(a.content, a.emojis, a.mentions, a.tags, accountID), this, fakeStatus, true); + textItem.textSelectable = true; + + List items=new ArrayList<>(); + items.add(HeaderStatusDisplayItem.fromAnnouncement(a, fakeStatus, instanceUser, this, accountID, this::onMarkAsRead)); + items.add(textItem); + if(!isInstanceAkkoma()) items.add(new EmojiReactionsStatusDisplayItem(a.id, this, fakeStatus, accountID, false, true)); + return items; + } + + public void onMarkAsRead(String id) { + if (unreadIDs == null) return; + unreadIDs.remove(id); + if (unreadIDs.isEmpty()) setResult(true, null); + } + + @Override + protected void addAccountToKnown(Announcement s) {} + + @Override + public void onItemClick(String id) {} + + @Override + protected void doLoadData(int offset, int count){ + currentRequest=new GetAnnouncements(true) + .setCallback(new SimpleCallback<>(this){ + @Override + public void onSuccess(List result){ + if(getActivity()==null) return; + + // get unread items first + List data = result.stream().filter(a -> !a.read).collect(toList()); + if (data.isEmpty()) setResult(true, null); + else unreadIDs = data.stream().map(a -> a.id).collect(toList()); + + // append read items at the end + data.addAll(result.stream().filter(a -> a.read).collect(toList())); + onDataLoaded(data, false); + } + }) + .exec(accountID); + } + + @Override + public Uri getWebUri(Uri.Builder base) { + return isInstanceAkkoma() ? base.path("/announcements").build() : null; + } +} 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 a48022871..38b44a36b 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseStatusListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseStatusListFragment.java @@ -1,6 +1,7 @@ package org.joinmastodon.android.fragments; import android.app.Activity; +import android.app.assist.AssistContent; import android.content.res.Configuration; import android.graphics.Canvas; import android.graphics.Paint; @@ -11,17 +12,28 @@ import android.os.Bundle; import android.view.View; import android.view.ViewGroup; import android.view.WindowInsets; +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.MastodonAPIRequest; import org.joinmastodon.android.api.requests.accounts.GetAccountRelationships; import org.joinmastodon.android.api.requests.polls.SubmitPollVote; +import org.joinmastodon.android.api.requests.statuses.AkkomaTranslateStatus; import org.joinmastodon.android.api.requests.statuses.TranslateStatus; import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.api.session.AccountSessionManager; 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.Poll; import org.joinmastodon.android.model.Relationship; @@ -29,21 +41,29 @@ 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.sheets.NonMutualPreReplySheet; -import org.joinmastodon.android.ui.sheets.OldPostPreReplySheet; +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; import org.joinmastodon.android.ui.displayitems.GapStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.HashtagStatusDisplayItem; +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; +import org.joinmastodon.android.ui.displayitems.WarningFilteredStatusDisplayItem; 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; @@ -55,13 +75,17 @@ import java.util.List; import java.util.Locale; import java.util.Objects; 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; +import me.grishka.appkit.fragments.BaseRecyclerFragment; import me.grishka.appkit.imageloader.ImageLoaderRecyclerAdapter; import me.grishka.appkit.imageloader.ImageLoaderViewHolder; import me.grishka.appkit.imageloader.requests.ImageLoaderRequest; @@ -70,23 +94,38 @@ import me.grishka.appkit.utils.MergeRecyclerAdapter; import me.grishka.appkit.utils.V; import me.grishka.appkit.views.UsableRecyclerView; -public abstract class BaseStatusListFragment extends MastodonRecyclerFragment implements PhotoViewerHost, ScrollableToTop{ +public abstract class BaseStatusListFragment extends MastodonRecyclerFragment implements PhotoViewerHost, ScrollableToTop, IsOnTop, HasFab, ProvidesAssistContent.ProvidesWebUri { protected ArrayList displayItems=new ArrayList<>(); protected DisplayItemsAdapter adapter; protected String accountID; protected PhotoViewer currentPhotoViewer; + protected ImageButton fab; + protected int scrollDiff = 0; protected HashMap knownAccounts=new HashMap<>(); 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; public BaseStatusListFragment(){ super(20); + if (wantsComposeButton()) setListLayoutId(R.layout.recycler_fragment_with_fab); + } + + protected boolean wantsComposeButton() { + return false; } @Override public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); + if(GlobalUserPreferences.toolbarMarquee){ + setTitleMarqueeEnabled(false); + setSubtitleMarqueeEnabled(false); + } if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N) setRetainInstance(true); } @@ -120,7 +159,7 @@ public abstract class BaseStatusListFragment exten displayItems.clear(); } - protected void prependItems(List items, boolean notify){ + protected int prependItems(List items, boolean notify){ data.addAll(0, items); int offset=0; for(T s:items){ @@ -134,9 +173,12 @@ 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; } protected String getMaxID(){ + if(refreshing) return null; + if(maxID!=null) return maxID; if(!preloadedData.isEmpty()) return preloadedData.get(preloadedData.size()-1).getID(); else if(!data.isEmpty()) @@ -145,6 +187,12 @@ public abstract class BaseStatusListFragment exten return null; } + protected boolean applyMaxID(List result){ + boolean empty=result.isEmpty(); + if(!empty) maxID=result.get(result.size()-1).id; + return !empty; + } + protected abstract List buildDisplayItems(T s); protected abstract void addAccountToKnown(T s); @@ -194,7 +242,7 @@ public abstract class BaseStatusListFragment exten @Override public boolean startPhotoViewTransition(int index, @NonNull Rect outRect, @NonNull int[] outCornerRadius){ MediaAttachmentViewController holder=findPhotoViewHolder(index); - if(holder!=null){ + if(holder!=null && list!=null){ transitioningHolder=holder; View view=transitioningHolder.photo; int[] pos={0, 0}; @@ -262,21 +310,159 @@ public abstract class BaseStatusListFragment exten 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 + public @Nullable View getFab() { + if (getParentFragment() instanceof HasFab l) return l.getFab(); + else return fab; + } + + @Override + public void showFab() { + View fab = getFab(); + if (fab == null || fab.getVisibility() == View.VISIBLE) return; + fab.setVisibility(View.VISIBLE); + TranslateAnimation animate = new TranslateAnimation( + 0, + 0, + fab.getHeight() * 2, + 0); + animate.setDuration(300); + fab.startAnimation(animate); + } + + public boolean isScrolling() { + return currentlyScrolling; + } + + @Override + public void hideFab() { + View fab = getFab(); + if (fab == null || fab.getVisibility() != View.VISIBLE) return; + TranslateAnimation animate = new TranslateAnimation( + 0, + 0, + 0, + fab.getHeight() * 2); + animate.setDuration(300); + fab.startAnimation(animate); + fab.setVisibility(View.INVISIBLE); + scrollDiff = 0; + } + @Override public void onViewCreated(View view, Bundle savedInstanceState){ super.onViewCreated(view, savedInstanceState); + fab=view.findViewById(R.id.fab); + list.addOnScrollListener(new RecyclerView.OnScrollListener(){ @Override public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy){ if(currentPhotoViewer!=null) currentPhotoViewer.offsetView(-dx, -dy); + + View fab = getFab(); + if (fab!=null && GlobalUserPreferences.autoHideFab && dy != UiUtils.SCROLL_TO_TOP_DELTA) { + if (dy > 0 && fab.getVisibility() == View.VISIBLE) { + hideFab(); + } else if (dy < 0 && fab.getVisibility() != View.VISIBLE) { + if (list.getChildAt(0).getTop() == 0 || scrollDiff > 400) { + showFab(); + scrollDiff = 0; + } else { + scrollDiff += Math.abs(dy); + } + } + } + } + + @Override + public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) { + super.onScrollStateChanged(recyclerView, newState); + currentlyScrolling = newState != RecyclerView.SCROLL_STATE_IDLE; } }); list.addItemDecoration(new StatusListItemDecoration()); + list.addItemDecoration(new InsetStatusItemDecoration(this)); ((UsableRecyclerView)list).setSelectorBoundsProvider(new UsableRecyclerView.SelectorBoundsProvider(){ private Rect tmpRect=new Rect(); @Override public void getSelectorBounds(View view, Rect outRect){ + if(list!=view.getParent()) return; + boolean hasDescendant=false, hasAncestor=false, isWarning=false; + int lastIndex=-1, firstIndex=-1; if(((UsableRecyclerView) list).isIncludeMarginsInItemHitbox()){ list.getDecoratedBoundsWithMargins(view, outRect); }else{ @@ -292,23 +478,55 @@ public abstract class BaseStatusListFragment exten for(int i=0;i h){ String otherID=((StatusDisplayItem.Holder) holder).getItemID(); if(otherID.equals(id)){ + if (firstIndex < 0) firstIndex = i; + lastIndex = i; + StatusDisplayItem item = h.getItem(); + hasDescendant = item.hasDescendantNeighbor; + // no for direct descendants because main status (right above) is + // being displayed with an extended footer - no connected layout + hasAncestor = item.hasAncestoringNeighbor && !item.isDirectDescendant; list.getDecoratedBoundsWithMargins(child, tmpRect); outRect.left=Math.min(outRect.left, tmpRect.left); outRect.top=Math.min(outRect.top, tmpRect.top); outRect.right=Math.max(outRect.right, tmpRect.right); outRect.bottom=Math.max(outRect.bottom, tmpRect.bottom); + if (holder instanceof WarningFilteredStatusDisplayItem.Holder) { + isWarning = true; + } } } } } + // shifting the selection box down + // see also: FooterStatusDisplayItem#onBind (setMargins) + if (isWarning || firstIndex < 0 || lastIndex < 0 || + !(list.getChildViewHolder(list.getChildAt(lastIndex)) + instanceof FooterStatusDisplayItem.Holder)) return; + int prevIndex = firstIndex - 1, nextIndex = lastIndex + 1; + boolean prevIsWarning = prevIndex > 0 && prevIndex < list.getChildCount() && + list.getChildViewHolder(list.getChildAt(prevIndex)) + instanceof WarningFilteredStatusDisplayItem.Holder; + boolean nextIsWarning = nextIndex > 0 && nextIndex < list.getChildCount() && + list.getChildViewHolder(list.getChildAt(nextIndex)) + instanceof WarningFilteredStatusDisplayItem.Holder; + if (!prevIsWarning && hasAncestor) outRect.top += V.dp(4); + if (!nextIsWarning && hasDescendant) outRect.bottom += V.dp(4); } }); list.setItemAnimator(new BetterItemAnimator()); ((UsableRecyclerView) list).setIncludeMarginsInItemHitbox(true); updateToolbar(); + + if (wantsComposeButton() && !getArguments().getBoolean("__disable_fab", false)) { + fab.setVisibility(View.VISIBLE); + fab.setOnClickListener(this::onFabClick); + fab.setOnLongClickListener(this::onFabLongClick); + } else if (fab != null) { + fab.setVisibility(View.GONE); + } } @Override @@ -349,10 +567,14 @@ public abstract class BaseStatusListFragment exten protected void updatePoll(String itemID, Status status, Poll poll){ status.poll=poll; int firstOptionIndex=-1, footerIndex=-1; + int spoilerFirstOptionIndex=-1, spoilerFooterIndex=-1; + SpoilerStatusDisplayItem spoilerItem=null; int i=0; for(StatusDisplayItem item:displayItems){ if(item.parentID.equals(itemID)){ - if(item instanceof PollOptionStatusDisplayItem && firstOptionIndex==-1){ + if(item instanceof SpoilerStatusDisplayItem){ + spoilerItem=(SpoilerStatusDisplayItem) item; + }else if(item instanceof PollOptionStatusDisplayItem && firstOptionIndex==-1){ firstOptionIndex=i; }else if(item instanceof PollFooterStatusDisplayItem){ footerIndex=i; @@ -361,12 +583,39 @@ 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); int prevSize=pollItems.size(); + if(spoilerItem!=null){ + spoilerFirstOptionIndex=spoilerItem.contentItems.indexOf(pollItems.get(0)); + spoilerFooterIndex=spoilerItem.contentItems.indexOf(pollItems.get(pollItems.size()-1)); + } pollItems.clear(); StatusDisplayItem.buildPollItems(itemID, this, poll, status, pollItems); + if(spoilerItem!=null){ + spoilerItem.contentItems.subList(spoilerFirstOptionIndex, spoilerFooterIndex+1).clear(); + spoilerItem.contentItems.addAll(spoilerFirstOptionIndex, pollItems); + } if(prevSize!=pollItems.size()){ adapter.notifyItemRangeRemoved(firstOptionIndex, prevSize); adapter.notifyItemRangeInserted(firstOptionIndex, pollItems.size()); @@ -378,10 +627,13 @@ public abstract class BaseStatusListFragment exten public void onPollOptionClick(PollOptionStatusDisplayItem.Holder holder){ Poll poll=holder.getItem().poll; Poll.Option option=holder.getItem().option; - if(poll.multiple){ + // MEGALODON: always show vote button +// if(poll.multiple){ if(poll.selectedOptions==null) poll.selectedOptions=new ArrayList<>(); - if(poll.selectedOptions.contains(option)){ + boolean optionContained=poll.selectedOptions.contains(option); + if(!poll.multiple) poll.selectedOptions.clear(); + if(optionContained){ poll.selectedOptions.remove(option); holder.itemView.setSelected(false); }else{ @@ -390,6 +642,9 @@ public abstract class BaseStatusListFragment exten } for(int i=0;i exten } } } - }else{ - submitPollVote(holder.getItemID(), poll.id, Collections.singletonList(poll.options.indexOf(option))); - } +// }else{ +// submitPollVote(holder.getItemID(), poll.id, Collections.singletonList(poll.options.indexOf(option))); +// } } public void onPollVoteButtonClick(PollFooterStatusDisplayItem.Holder holder){ @@ -407,6 +662,14 @@ public abstract class BaseStatusListFragment exten submitPollVote(holder.getItemID(), poll.id, poll.selectedOptions.stream().map(opt->poll.options.indexOf(opt)).collect(Collectors.toList())); } + public void onPollViewResultsButtonClick(PollFooterStatusDisplayItem.Holder holder, boolean shown){ + for(int i=0;i choices){ if(refreshing) return; @@ -428,15 +691,39 @@ public abstract class BaseStatusListFragment exten public void onRevealSpoilerClick(SpoilerStatusDisplayItem.Holder holder){ Status status=holder.getItem().status; - toggleSpoiler(status, holder.getItemID()); + boolean isForQuote=holder.getItem().isForQuote; + toggleSpoiler(status, isForQuote, holder.getItemID()); } - protected void toggleSpoiler(Status status, String itemID){ + public void onVisibilityIconClick(HeaderStatusDisplayItem.Holder holder) { + Status status = holder.getItem().status; + if(holder.getItem().hasVisibilityToggle) holder.animateVisibilityToggle(false); + MediaGridStatusDisplayItem.Holder mediaGrid=findHolderOfType(holder.getItemID(), MediaGridStatusDisplayItem.Holder.class); + if(mediaGrid!=null){ + if(!status.sensitiveRevealed) mediaGrid.revealSensitive(); + else mediaGrid.hideSensitive(); + }else{ + status.sensitiveRevealed=false; + notifyItemChangedAfter(holder.getItem(), MediaGridStatusDisplayItem.class); + } + } + + public void onSensitiveRevealed(MediaGridStatusDisplayItem.Holder holder) { + HeaderStatusDisplayItem.Holder header=findHolderOfType(holder.getItemID(), HeaderStatusDisplayItem.Holder.class); + if(header!=null && header.getItem().hasVisibilityToggle) header.animateVisibilityToggle(true); + else notifyItemChangedBefore(holder.getItem(), HeaderStatusDisplayItem.class); + } + + protected void toggleSpoiler(Status status, boolean isForQuote, String itemID){ status.spoilerRevealed=!status.spoilerRevealed; - SpoilerStatusDisplayItem.Holder spoiler=findHolderOfType(itemID, SpoilerStatusDisplayItem.Holder.class); - if(spoiler!=null) - spoiler.rebind(); - SpoilerStatusDisplayItem spoilerItem=Objects.requireNonNull(findItemOfType(itemID, SpoilerStatusDisplayItem.class)); + if (!status.spoilerRevealed && !AccountSessionManager.get(accountID).getLocalPreferences().revealCWs) + status.sensitiveRevealed = false; + + List spoilers=findAllHoldersOfType(itemID, SpoilerStatusDisplayItem.Holder.class); + SpoilerStatusDisplayItem.Holder spoiler=spoilers.size() > 1 && isForQuote ? spoilers.get(1) : spoilers.get(0); + if(spoiler!=null) spoiler.rebind(); + else notifyItemChanged(itemID, SpoilerStatusDisplayItem.class); + SpoilerStatusDisplayItem spoilerItem=Objects.requireNonNull(spoiler.getItem()); int index=displayItems.indexOf(spoilerItem); if(status.spoilerRevealed){ @@ -446,11 +733,44 @@ public abstract class BaseStatusListFragment exten displayItems.subList(index+1, index+1+spoilerItem.contentItems.size()).clear(); adapter.notifyItemRangeRemoved(index+1, spoilerItem.contentItems.size()); } + + notifyItemChanged(itemID, TextStatusDisplayItem.class); + HeaderStatusDisplayItem.Holder header=findHolderOfType(itemID, HeaderStatusDisplayItem.Holder.class); + if(header!=null) header.rebind(); + else notifyItemChanged(itemID, HeaderStatusDisplayItem.class); + list.invalidateItemDecorations(); } - public void onGapClick(GapStatusDisplayItem.Holder item){} + public void onEnableExpandable(TextStatusDisplayItem.Holder holder, boolean expandable) { + 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(); + } + } + public void onToggleExpanded(Status status, String itemID) { + status.textExpanded = !status.textExpanded; + notifyItemChanged(itemID, TextStatusDisplayItem.class); + HeaderStatusDisplayItem.Holder header=findHolderOfType(itemID, HeaderStatusDisplayItem.Holder.class); + if(header!=null) header.animateExpandToggle(); + else notifyItemChanged(itemID, HeaderStatusDisplayItem.class); + } + + public void onGapClick(GapStatusDisplayItem.Holder item, boolean downwards){} + + public void onWarningClick(WarningFilteredStatusDisplayItem.Holder warning){ + int startPos = warning.getAbsoluteAdapterPosition(); + displayItems.remove(startPos); + displayItems.addAll(startPos, warning.filteredItems); + adapter.notifyItemRangeInserted(startPos, warning.filteredItems.size() - 1); + if (startPos == 0) scrollToTop(); + warning.getItem().status.filterRevealed = true; + } + + @Override public String getAccountID(){ return accountID; } @@ -498,9 +818,61 @@ public abstract class BaseStatusListFragment exten return null; } + /** + * Use this as a fallback if findHolderOfType fails to find the ViewHolder. + * It might still be bound but off-screen and therefore not a child of the RecyclerView - + * resulting in the ViewHolder displaying an outdated state once scrolled back into view. + */ + protected int notifyItemChanged(String id, Class type){ + boolean encounteredParent=false; + for(int i=0; i int notifyItemChangedAfter(StatusDisplayItem afterThis, Class type){ + int startIndex=displayItems.indexOf(afterThis); + if(startIndex == -1) throw new IllegalStateException("notifyItemChangedAfter didn't find the passed StatusDisplayItem"); + String parentID=afterThis.parentID; + for(int i=startIndex; i int notifyItemChangedBefore(StatusDisplayItem beforeThis, Class type){ + int startIndex=displayItems.indexOf(beforeThis); + if(startIndex == -1) throw new IllegalStateException("notifyItemChangedBefore didn't find the passed StatusDisplayItem"); + String parentID=beforeThis.parentID; + for(int i=startIndex; i>=0; i--){ + StatusDisplayItem item=displayItems.get(i); + if(!parentID.equals(item.parentID)) break; // didn't find anything + if(type.isInstance(item)){ + // found it + adapter.notifyItemChanged(i); + return i; + } + } + return -1; + } + @Nullable protected > H findHolderOfType(String id, Class type){ - for(int i=0;i itemHolder && itemHolder.getItemID().equals(id) && type.isInstance(holder)) return type.cast(holder); @@ -523,6 +895,11 @@ public abstract class BaseStatusListFragment exten smoothScrollRecyclerViewToTop(list); } + @Override + public boolean isOnTop() { + return isRecyclerViewOnTop(list); + } + protected int getListWidthForMediaLayout(){ return list.getWidth(); } @@ -569,14 +946,37 @@ public abstract class BaseStatusListFragment exten currentPhotoViewer.onPause(); } + public void onFabClick(View v){ + Bundle args=new Bundle(); + args.putString("account", accountID); + Nav.go(getActivity(), ComposeFragment.class, args); + } + + public boolean onFabLongClick(View v) { + return UiUtils.pickAccountForCompose(getActivity(), accountID); + } + private MediaAttachmentViewController makeNewMediaAttachmentView(MediaGridStatusDisplayItem.GridItemType type){ 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())); + } + public void togglePostTranslation(Status status, String itemID){ switch(status.translationState){ case LOADING -> { @@ -590,44 +990,64 @@ public abstract class BaseStatusListFragment exten status.translationState=Status.TranslationState.SHOWN; }else{ status.translationState=Status.TranslationState.LOADING; - new TranslateStatus(status.getContentStatus().id, Locale.getDefault().getLanguage()) - .setCallback(new Callback<>(){ + Consumer successCallback=(result)->{ + status.translation=result; + status.translationState=Status.TranslationState.SHOWN; + updateTranslation(itemID); + }; + MastodonAPIRequest req=isInstanceAkkoma() + ? new AkkomaTranslateStatus(status.getContentStatus().id, Locale.getDefault().getLanguage()).setCallback(new Callback<>(){ + @Override + public void onSuccess(AkkomaTranslation result){ + if(getActivity()!=null) successCallback.accept(result.toTranslation()); + } + @Override + public void onError(ErrorResponse error){ + if(getActivity()!=null) translationCallbackError(status, itemID); + } + }) + : new TranslateStatus(status.getContentStatus().id, Locale.getDefault().getLanguage()).setCallback(new Callback<>(){ @Override public void onSuccess(Translation result){ - if(getActivity()==null) - return; - status.translation=result; - status.translationState=Status.TranslationState.SHOWN; - updateTranslation(itemID); + if(getActivity()!=null) successCallback.accept(result); } @Override public void onError(ErrorResponse error){ - if(getActivity()==null) - return; - status.translationState=Status.TranslationState.HIDDEN; - updateTranslation(itemID); - new M3AlertDialogBuilder(getActivity()) - .setTitle(R.string.error) - .setMessage(R.string.translation_failed) - .setPositiveButton(R.string.ok, null) - .show(); + if(getActivity()!=null) translationCallbackError(status, itemID); } - }) - .exec(accountID); + }); + + // 1 minute + req.setTimeout(60000).exec(accountID); } } } updateTranslation(itemID); } + private void translationCallbackError(Status status, String itemID) { + status.translationState=Status.TranslationState.HIDDEN; + updateTranslation(itemID); + new M3AlertDialogBuilder(getActivity()) + .setTitle(R.string.error) + .setMessage(R.string.translation_failed) + .setPositiveButton(R.string.ok, null) + .show(); + } + private void updateTranslation(String itemID) { TextStatusDisplayItem.Holder text=findHolderOfType(itemID, TextStatusDisplayItem.Holder.class); if(text!=null){ text.updateTranslation(true); imgLoader.bindViewHolder((ImageLoaderRecyclerAdapter) list.getAdapter(), text, text.getAbsoluteAdapterPosition()); + }else{ + notifyItemChanged(itemID, TextStatusDisplayItem.class); } + if(isInstanceAkkoma()) + return; + SpoilerStatusDisplayItem.Holder spoiler=findHolderOfType(itemID, SpoilerStatusDisplayItem.Holder.class); if(spoiler!=null){ spoiler.rebind(); @@ -638,6 +1058,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 protected void onModifyItemViewHolder(BindableViewHolder holder){} + @Override + protected void onDataLoaded(List d, boolean more) { + if(getContext()==null) return; + super.onDataLoaded(d, more); + // more available, but the page isn't even full yet? seems wrong, let's load some more + if(more && data.size() < itemsPerPage){ + preloader.onScrolledToLastItem(); + } + } + + public void scrollBy(int x, int y) { + list.scrollBy(x, y); + } + protected class DisplayItemsAdapter extends UsableRecyclerView.Adapter> implements ImageLoaderRecyclerAdapter{ public DisplayItemsAdapter(){ @@ -720,9 +1159,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 @@ -745,7 +1184,8 @@ public abstract class BaseStatusListFragment exten // Do not draw dividers between hashtag and/or account rows if((ih instanceof HashtagStatusDisplayItem.Holder || ih instanceof AccountStatusDisplayItem.Holder) && (sh instanceof HashtagStatusDisplayItem.Holder || sh instanceof AccountStatusDisplayItem.Holder)) return false; - return !ih.getItemID().equals(sh.getItemID()) && ih.getItem().getType()!=StatusDisplayItem.Type.GAP; + if (!ih.getItem().isMainStatus && ih.getItem().hasDescendantNeighbor) return false; + return (!ih.getItemID().equals(sh.getItemID()) || sh instanceof ExtendedFooterStatusDisplayItem.Holder) && ih.getItem().getType()!=StatusDisplayItem.Type.GAP; } return false; } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/BookmarkedStatusListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/BookmarkedStatusListFragment.java index ecb737776..3831bb4e4 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/BookmarkedStatusListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/BookmarkedStatusListFragment.java @@ -1,10 +1,12 @@ package org.joinmastodon.android.fragments; import android.app.Activity; +import android.net.Uri; import org.joinmastodon.android.R; import org.joinmastodon.android.api.requests.statuses.GetBookmarkedStatuses; import org.joinmastodon.android.events.RemoveAccountPostsEvent; +import org.joinmastodon.android.model.FilterContext; import org.joinmastodon.android.model.HeaderPaginationList; import org.joinmastodon.android.model.Status; @@ -26,6 +28,7 @@ public class BookmarkedStatusListFragment extends StatusListFragment{ .setCallback(new SimpleCallback<>(this){ @Override public void onSuccess(HeaderPaginationList result){ + if(getActivity()==null) return; if(result.nextPageUri!=null) nextMaxID=result.nextPageUri.getQueryParameter("max_id"); else @@ -40,4 +43,14 @@ public class BookmarkedStatusListFragment extends StatusListFragment{ protected void onRemoveAccountPostsEvent(RemoveAccountPostsEvent ev){ // no-op } + + @Override + protected FilterContext getFilterContext() { + return FilterContext.ACCOUNT; + } + + @Override + public Uri getWebUri(Uri.Builder base) { + return base.path("/bookmarks").build(); + } } 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 f7a0a2225..90dee6085 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java @@ -1,12 +1,22 @@ package org.joinmastodon.android.fragments; +import static org.joinmastodon.android.GlobalUserPreferences.PrefixRepliesMode.ALWAYS; +import static org.joinmastodon.android.GlobalUserPreferences.PrefixRepliesMode.TO_OTHERS; +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; @@ -14,12 +24,15 @@ import android.graphics.drawable.Drawable; import android.graphics.drawable.LayerDrawable; import android.icu.text.BreakIterator; import android.net.Uri; +import android.os.Build; import android.os.Bundle; import android.provider.MediaStore; import android.text.Editable; +import android.text.SpannableStringBuilder; import android.text.Spanned; import android.text.TextUtils; import android.text.TextWatcher; +import android.text.format.DateFormat; import android.text.style.BackgroundColorSpan; import android.text.style.ForegroundColorSpan; import android.view.KeyEvent; @@ -41,8 +54,12 @@ import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.PopupMenu; import android.widget.ProgressBar; +import android.widget.ScrollView; 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; @@ -50,19 +67,26 @@ import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.R; import org.joinmastodon.android.api.MastodonErrorResponse; import org.joinmastodon.android.api.requests.statuses.CreateStatus; +import org.joinmastodon.android.api.requests.statuses.DeleteStatus; 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.AccountSearchFragment; import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.model.ContentType; import org.joinmastodon.android.model.Emoji; import org.joinmastodon.android.model.EmojiCategory; import org.joinmastodon.android.model.Instance; import org.joinmastodon.android.model.Mention; import org.joinmastodon.android.model.Preferences; +import org.joinmastodon.android.model.ScheduledStatus; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.model.StatusPrivacy; import org.joinmastodon.android.ui.CustomEmojiPopupKeyboard; @@ -74,18 +98,39 @@ 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.FileProvider; +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; import org.joinmastodon.android.ui.viewcontrollers.ComposeMediaViewController; import org.joinmastodon.android.ui.viewcontrollers.ComposePollViewController; import org.joinmastodon.android.ui.views.ComposeEditText; +import org.joinmastodon.android.ui.views.LinkedTextView; import org.joinmastodon.android.ui.views.SizeListenerLinearLayout; +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; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.format.FormatStyle; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.List; +import java.util.Locale; +import java.util.Objects; import java.util.UUID; +import java.util.function.Consumer; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; @@ -100,12 +145,19 @@ 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, CustomTransitionsFragment{ +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; private static final int AUTOCOMPLETE_ACCOUNT_RESULT=779; + private static final int SCHEDULED_STATUS_OPENED_RESULT=161; + + private static final String GLITCH_LOCAL_ONLY_SUFFIX = "👁"; + 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); @@ -118,7 +170,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr public LinearLayout mainLayout; private SizeListenerLinearLayout contentView; - private TextView selfName, selfUsername; + private TextView selfName, selfUsername, selfExtraText, extraText; private ImageView selfAvatar; private Account self; private String instanceDomain; @@ -128,8 +180,15 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr private String accountID; private int charCount, charLimit, trimmedCharCount; - private ImageButton mediaBtn, pollBtn, emojiBtn, spoilerBtn, languageBtn; + private Button publishButton, languageButton, scheduleTimeBtn; + private PopupMenu contentTypePopup, visibilityPopup, draftOptionsPopup; + private ImageButton publishButtonRelocated, mediaBtn, pollBtn, emojiBtn, spoilerBtn, draftsBtn, scheduleDraftDismiss, contentTypeBtn; + private View sensitiveBtn; private TextView replyText; + private LinearLayout scheduleDraftView; + private ScrollView scrollView; + private boolean initiallyScrolled = false; + private TextView scheduleDraftText; private Button visibilityBtn; private LinearLayout bottomBar; private View autocompleteDivider; @@ -137,17 +196,22 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr private List customEmojis; private CustomEmojiPopupKeyboard emojiKeyboard; private Status replyTo; + private Status quote; private String initialText; private String uuid; private EditText spoilerEdit; private View spoilerWrap; private boolean hasSpoiler; + private boolean sensitive; + private Instant scheduledAt = null; private ProgressBar sendProgress; private View sendingOverlay; private WindowManager wm; private StatusPrivacy statusVisibility=StatusPrivacy.PUBLIC; + private boolean localOnly; private ComposeAutocompleteSpan currentAutocompleteSpan; private FrameLayout mainEditTextWrap; + private ComposeLanguageAlertViewController.SelectedOption postLang; private ComposeAutocompleteViewController autocompleteViewController; @@ -156,9 +220,18 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr public Instance instance; public Status editingStatus; + public ScheduledStatus scheduledStatus; + private boolean redraftStatus; + + private Uri photoUri; + + private ContentType contentType; + private MastodonLanguage.LanguageResolver languageResolver; + private boolean creatingView; private boolean ignoreSelectionChanges=false; - private MenuItem publishButton; + private MenuItem actionItem; + private MenuItem draftMenuItem, undraftMenuItem, scheduleMenuItem, unscheduleMenuItem; private boolean wasDetached; private BackgroundColorSpan overLimitBG; @@ -171,17 +244,28 @@ 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"); - AccountSession session=AccountSessionManager.getInstance().getAccount(accountID); + AccountSession session=AccountSessionManager.get(accountID); + self=session.self; instanceDomain=session.domain; customEmojis=AccountSessionManager.getInstance().getCustomEmojis(instanceDomain); instance=AccountSessionManager.getInstance().getInstanceInfo(instanceDomain); + languageResolver=new MastodonLanguage.LanguageResolver(instance); + redraftStatus=getArguments().getBoolean("redraftStatus", false); + contentType=session.getLocalPreferences().defaultContentType; if(getArguments().containsKey("editStatus")){ editingStatus=Parcels.unwrap(getArguments().getParcelable("editStatus")); } + if(getArguments().containsKey("replyTo")) { + replyTo=Parcels.unwrap(getArguments().getParcelable("replyTo")); + } + if(getArguments().containsKey("quote")) { + quote=Parcels.unwrap(getArguments().getParcelable("quote")); + } if(instance==null){ Nav.finish(this); return; @@ -190,6 +274,10 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr AccountSessionManager.getInstance().updateInstanceInfo(instanceDomain); } + Bundle bundle = savedInstanceState != null ? savedInstanceState : getArguments(); + if (bundle.containsKey("scheduledStatus")) scheduledStatus=Parcels.unwrap(bundle.getParcelable("scheduledStatus")); + if (bundle.containsKey("scheduledAt")) scheduledAt=(Instant) bundle.getSerializable("scheduledAt"); + if(instance.maxTootChars>0) charLimit=instance.maxTootChars; else if(instance.configuration!=null && instance.configuration.statuses!=null && instance.configuration.statuses.maxCharacters>0) @@ -197,7 +285,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr else charLimit=500; - setTitle(editingStatus==null ? R.string.new_post : R.string.edit_post); +// setTitle(editingStatus==null ? R.string.new_post : R.string.edit_post); if(savedInstanceState!=null) postLang=Parcels.unwrap(savedInstanceState.getParcelable("postLang")); } @@ -205,6 +293,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr @Override public void onDestroy(){ super.onDestroy(); + E.unregister(this); mediaViewController.cancelAllUploads(); } @@ -224,16 +313,24 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr super.onDetach(); } + @SuppressLint("ClickableViewAccessibility") @Override public View onCreateContentView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState){ creatingView=true; - emojiKeyboard=new CustomEmojiPopupKeyboard(getActivity(), customEmojis, instanceDomain); + emojiKeyboard=new CustomEmojiPopupKeyboard(getActivity(), accountID, customEmojis, instanceDomain); emojiKeyboard.setListener(new CustomEmojiPopupKeyboard.Listener(){ @Override public void onEmojiSelected(Emoji emoji){ onCustomEmojiClick(emoji); } + @Override + public void onEmojiSelected(String emoji){ + if(getActivity().getCurrentFocus() instanceof EditText edit && edit == mainEditText){ + edit.getText().replace(edit.getSelectionStart(), edit.getSelectionEnd(), emoji); + } + } + @Override public void onBackspace(){ getActivity().dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL)); @@ -242,16 +339,37 @@ 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.name); - selfUsername=view.findViewById(R.id.username); - selfAvatar=view.findViewById(R.id.avatar); - HtmlParser.setTextWithCustomEmoji(selfName, self.displayName, self.emojis); + selfName=view.findViewById(R.id.self_name); + selfUsername=view.findViewById(R.id.self_username); + selfAvatar=view.findViewById(R.id.self_avatar); + selfExtraText=view.findViewById(R.id.self_extra_text); + HtmlParser.setTextWithCustomEmoji(selfName, self.getDisplayName(), self.emojis); selfUsername.setText('@'+self.username+'@'+instanceDomain); if(self.avatar!=null) ViewImageLoader.load(selfAvatar, null, new UrlImageLoaderRequest(self.avatar)); @@ -270,18 +388,58 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr emojiBtn=view.findViewById(R.id.btn_emoji); spoilerBtn=view.findViewById(R.id.btn_spoiler); visibilityBtn=view.findViewById(R.id.btn_visibility); - languageBtn=view.findViewById(R.id.btn_language); + contentTypeBtn=view.findViewById(R.id.btn_content_type); + scheduleDraftView=view.findViewById(R.id.schedule_draft_view); + scheduleDraftText=view.findViewById(R.id.schedule_draft_text); + scheduleDraftDismiss=view.findViewById(R.id.schedule_draft_dismiss); + scheduleTimeBtn=view.findViewById(R.id.scheduled_time_btn); + sensitiveBtn=view.findViewById(R.id.sensitive_item); replyText=view.findViewById(R.id.reply_text); - mediaBtn.setOnClickListener(v->openFilePicker()); + 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()); + if (isInstancePixelfed()) pollBtn.setVisibility(View.GONE); pollBtn.setOnClickListener(v->togglePoll()); emojiBtn.setOnClickListener(v->emojiKeyboard.toggleKeyboardPopup(mainEditText)); spoilerBtn.setOnClickListener(v->toggleSpoiler()); - languageBtn.setOnClickListener(v->showLanguageAlert()); - visibilityBtn.setOnClickListener(this::onVisibilityClick); Drawable arrow=getResources().getDrawable(R.drawable.ic_baseline_arrow_drop_down_18, getActivity().getTheme()).mutate(); arrow.setTint(UiUtils.getThemeColor(getActivity(), R.attr.colorM3OnSurface)); visibilityBtn.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, arrow, null); + + localOnly = savedInstanceState != null ? savedInstanceState.getBoolean("localOnly") : + editingStatus != null ? editingStatus.localOnly : replyTo != null && replyTo.localOnly; + + buildVisibilityPopup(visibilityBtn); + visibilityBtn.setOnClickListener(v->visibilityPopup.show()); + visibilityBtn.setOnTouchListener(visibilityPopup.getDragToOpenListener()); + + buildContentTypePopup(contentTypeBtn); + contentTypeBtn.setOnClickListener(v->contentTypePopup.show()); + contentTypeBtn.setOnTouchListener(contentTypePopup.getDragToOpenListener()); + + scheduleDraftDismiss.setOnClickListener(v->updateScheduledAt(null)); + scheduleTimeBtn.setOnClickListener(v->pickScheduledDateTime()); + + sensitiveBtn.setOnClickListener(v->toggleSensitive()); emojiKeyboard.setOnIconChangedListener(new PopupKeyboard.OnIconChangeListener(){ @Override public void onIconChanged(int icon){ @@ -312,17 +470,50 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr hasSpoiler=true; spoilerWrap.setVisibility(View.VISIBLE); spoilerBtn.setSelected(true); - }else if(editingStatus!=null && !TextUtils.isEmpty(editingStatus.spoilerText)){ + }else if(editingStatus!=null && editingStatus.hasSpoiler()){ hasSpoiler=true; spoilerWrap.setVisibility(View.VISIBLE); spoilerEdit.setText(getArguments().getString("sourceSpoiler", editingStatus.spoilerText)); spoilerBtn.setSelected(true); } - if(editingStatus!=null && editingStatus.visibility!=null) { - statusVisibility=editingStatus.visibility; + sensitive = savedInstanceState==null && editingStatus != null ? editingStatus.sensitive + : savedInstanceState!=null && savedInstanceState.getBoolean("sensitive", false); + if (sensitive) { + sensitiveBtn.setVisibility(View.VISIBLE); + sensitiveBtn.setSelected(true); } + + if (savedInstanceState != null) { + statusVisibility = (StatusPrivacy) savedInstanceState.getSerializable("visibility"); + } else if (editingStatus != null && editingStatus.visibility != null) { + statusVisibility = editingStatus.visibility; + } else { + loadDefaultStatusVisibility(savedInstanceState); + } + updateVisibilityIcon(); + visibilityPopup.getMenu().findItem(switch(statusVisibility){ + case PUBLIC -> R.id.vis_public; + case UNLISTED -> R.id.vis_unlisted; + case PRIVATE -> R.id.vis_followers; + case DIRECT -> R.id.vis_private; + case LOCAL -> R.id.vis_local; + }).setChecked(true); + visibilityPopup.getMenu().findItem(R.id.local_only).setChecked(localOnly); + + if (savedInstanceState != null && savedInstanceState.containsKey("contentType")) { + contentType = (ContentType) savedInstanceState.getSerializable("contentType"); + } else if (getArguments().containsKey("sourceContentType")) { + try { + String val = getArguments().getString("sourceContentType"); + if (val != null) contentType = ContentType.valueOf(val); + } catch (IllegalArgumentException ignored) {} + } + + int typeIndex=contentType.ordinal(); + contentTypePopup.getMenu().findItem(typeIndex).setChecked(true); + contentTypeBtn.setSelected(typeIndex != ContentType.UNSPECIFIED.ordinal() && typeIndex != ContentType.PLAIN.ordinal()); autocompleteViewController=new ComposeAutocompleteViewController(getActivity(), accountID); autocompleteViewController.setCompletionSelectedListener(new ComposeAutocompleteViewController.AutocompleteListener(){ @@ -372,6 +563,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(); @@ -380,14 +584,12 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr @Override public void onViewCreated(View view, Bundle savedInstanceState){ super.onViewCreated(view, savedInstanceState); - if(editingStatus==null) - loadDefaultStatusVisibility(savedInstanceState); contentView.setSizeListener(emojiKeyboard::onContentViewSizeChanged); InputMethodManager imm=getActivity().getSystemService(InputMethodManager.class); mainEditText.requestFocus(); - view.postDelayed(()->{ - imm.showSoftInput(mainEditText, 0); - }, 100); + view.postDelayed(()->{ + imm.showSoftInput(mainEditText, 0); + }, 100); sendProgress=view.findViewById(R.id.progress); sendProgress.setVisibility(View.GONE); @@ -465,13 +667,127 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr } }); spoilerEdit.addTextChangedListener(new SimpleTextWatcher(e->updateCharCounter())); - if(replyTo!=null){ - replyText.setText(getString(R.string.in_reply_to, replyTo.account.displayName)); + if(replyTo!=null || quote!=null){ + Status status = quote!=null ? quote : replyTo; + View replyWrap = view.findViewById(R.id.reply_wrap); + scrollView.getViewTreeObserver().addOnGlobalLayoutListener(() -> { + int scrollHeight = scrollView.getHeight(); + if (replyWrap.getMinimumHeight() != scrollHeight) { + replyWrap.setMinimumHeight(scrollHeight); + if (!initiallyScrolled) { + initiallyScrolled = true; + scrollView.post(() -> { + int bottom = scrollView.getChildAt(0).getBottom(); + int delta = bottom - (scrollView.getScrollY() + scrollView.getHeight()); + int space = GlobalUserPreferences.reduceMotion ? 0 : Math.min(V.dp(70), delta); + scrollView.scrollBy(0, delta - space); + if (!GlobalUserPreferences.reduceMotion) { + scrollView.postDelayed(() -> scrollView.smoothScrollBy(0, space), 130); + } + }); + } + } + }); + View originalPost=view.findViewById(R.id.original_post); + extraText=view.findViewById(R.id.extra_text); + originalPost.setVisibility(View.VISIBLE); + originalPost.setOnClickListener(v->{ + Bundle args=new Bundle(); + args.putString("account", accountID); + args.putParcelable("status", Parcels.wrap(status)); + imm.hideSoftInputFromWindow(view.getWindowToken(), 0); + Nav.go(getActivity(), ThreadFragment.class, args); + }); + + ImageView avatar = view.findViewById(R.id.avatar); + ViewImageLoader.load(avatar, null, new UrlImageLoaderRequest(status.account.avatar)); + ViewOutlineProvider roundCornersOutline=new ViewOutlineProvider(){ + @Override + public void getOutline(View view, Outline outline){ + outline.setRoundRect(0, 0, view.getWidth(), view.getHeight(), V.dp(12)); + } + }; + avatar.setOutlineProvider(roundCornersOutline); + avatar.setClipToOutline(true); + avatar.setOnClickListener(v->{ + Bundle args=new Bundle(); + args.putString("account", accountID); + args.putParcelable("profileAccount", Parcels.wrap(status.account)); + imm.hideSoftInputFromWindow(view.getWindowToken(), 0); + Nav.go(getActivity(), ProfileFragment.class, args); + }); + + Drawable visibilityIcon = getActivity().getDrawable(switch(status.visibility){ + case PUBLIC -> R.drawable.ic_fluent_earth_20_regular; + case UNLISTED -> R.drawable.ic_fluent_lock_open_20_regular; + case PRIVATE -> R.drawable.ic_fluent_lock_closed_20_filled; + case DIRECT -> R.drawable.ic_fluent_mention_20_regular; + case LOCAL -> R.drawable.ic_fluent_eye_20_regular; + }); + ImageView moreBtn = view.findViewById(R.id.more); + moreBtn.setImageDrawable(visibilityIcon); + moreBtn.setBackground(null); + + TextView name = view.findViewById(R.id.name); + name.setText(HtmlParser.parseCustomEmoji(status.account.getDisplayName(), status.account.emojis)); + UiUtils.loadCustomEmojiInTextView(name); + + String time = status==null || status.editedAt==null + ? UiUtils.formatRelativeTimestamp(getContext(), status.createdAt) + : getString(R.string.edited_timestamp, UiUtils.formatRelativeTimestamp(getContext(), status.editedAt)); + + ((TextView) view.findViewById(R.id.username)).setText(status.account.getDisplayUsername()); + view.findViewById(R.id.separator).setVisibility(time==null ? View.GONE : View.VISIBLE); + view.findViewById(R.id.time).setVisibility(time==null ? View.GONE : View.VISIBLE); + if(time!=null) ((TextView) view.findViewById(R.id.time)).setText(time); + + if (status.hasSpoiler()) { + TextView replyToSpoiler = view.findViewById(R.id.reply_to_spoiler); + replyToSpoiler.setVisibility(View.VISIBLE); + replyToSpoiler.setText(status.spoilerText); + LayerDrawable spoilerBg=(LayerDrawable) replyToSpoiler.getBackground().mutate(); + spoilerBg.setDrawableByLayerId(R.id.left_drawable, new SpoilerStripesDrawable(false)); + spoilerBg.setDrawableByLayerId(R.id.right_drawable, new SpoilerStripesDrawable(false)); + replyToSpoiler.setBackground(spoilerBg); + replyToSpoiler.setClipToOutline(true); + replyToSpoiler.setOutlineProvider(OutlineProviders.roundedRect(8)); + } + + SpannableStringBuilder content = HtmlParser.parse(status.content, status.emojis, status.mentions, status.tags, accountID); + LinkedTextView text = view.findViewById(R.id.text); + if (content.length() > 0) { + text.setText(content); + UiUtils.loadCustomEmojiInTextView(text); + } else { + view.findViewById(R.id.display_item_text) + .setLayoutParams(new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, V.dp(16))); + } + + replyText.setText(HtmlParser.parseCustomEmoji(getString(quote!=null? R.string.sk_quoting_user : R.string.in_reply_to, status.account.getDisplayName()), status.account.emojis)); + UiUtils.loadCustomEmojiInTextView(replyText); + int visibilityNameRes = switch (status.visibility) { + case PUBLIC -> R.string.visibility_public; + case UNLISTED -> R.string.sk_visibility_unlisted; + case PRIVATE -> R.string.visibility_followers_only; + case DIRECT -> R.string.visibility_private; + case LOCAL -> R.string.sk_local_only; + }; + replyText.setContentDescription(getString(R.string.in_reply_to, status.account.getDisplayName()) + ", " + getString(visibilityNameRes)); + 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(!replyTo.account.id.equals(ownID)) - mentions.add('@'+replyTo.account.acct); - for(Mention mention:replyTo.mentions){ + 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; String m='@'+mention.acct; @@ -484,14 +800,18 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr ignoreSelectionChanges=true; mainEditText.setSelection(mainEditText.length()); ignoreSelectionChanges=false; - if(!TextUtils.isEmpty(replyTo.spoilerText) && AccountSessionManager.getInstance().isSelf(accountID, replyTo.account)){ + if(!TextUtils.isEmpty(status.spoilerText)){ hasSpoiler=true; spoilerWrap.setVisibility(View.VISIBLE); - spoilerEdit.setText(replyTo.spoilerText); + String prefix = (GlobalUserPreferences.prefixReplies == ALWAYS + || (GlobalUserPreferences.prefixReplies == TO_OTHERS && !ownID.equals(status.account.id))) + && !status.spoilerText.startsWith("re: ") ? "re: " : ""; + spoilerEdit.setText(prefix + status.spoilerText); spoilerBtn.setSelected(true); } + if (status.language != null && !status.language.isEmpty()) setPostLanguage(status.language); } - }else{ + }else if (editingStatus==null || editingStatus.inReplyToId==null){ replyText.setVisibility(View.GONE); } if(savedInstanceState==null){ @@ -501,6 +821,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr ignoreSelectionChanges=true; mainEditText.setSelection(mainEditText.length()); ignoreSelectionChanges=false; + setPostLanguage(editingStatus.language); mediaViewController.onViewCreated(savedInstanceState);; }else{ String prefilledText=getArguments().getString("prefilledText"); @@ -511,6 +832,11 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr ignoreSelectionChanges=false; initialText=prefilledText; } + if (getArguments().containsKey("selectionStart") || getArguments().containsKey("selectionEnd")) { + int selectionStart=getArguments().getInt("selectionStart", 0); + int selectionEnd=getArguments().getInt("selectionEnd", selectionStart); + mainEditText.setSelection(selectionStart, selectionEnd); + } ArrayList mediaUris=getArguments().getParcelableArrayList("mediaAttachments"); if(mediaUris!=null && !mediaUris.isEmpty()){ for(Uri uri:mediaUris){ @@ -520,9 +846,12 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr } } + updateSensitive(); + updateHeaders(); + if(editingStatus!=null){ updateCharCounter(); - visibilityBtn.setEnabled(false); + visibilityBtn.setEnabled(redraftStatus); } updateMediaPollStates(); } @@ -538,21 +867,125 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr } } + @SuppressLint("ClickableViewAccessibility") @Override public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){ inflater.inflate(editingStatus==null ? R.menu.compose : R.menu.compose_edit, menu); - publishButton=menu.findItem(R.id.publish); + actionItem = menu.findItem(R.id.publish); + LinearLayout wrap=new LinearLayout(getActivity()); + getActivity().getLayoutInflater().inflate(R.layout.compose_action, wrap); + actionItem.setActionView(wrap); + actionItem.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS); + + 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(); + draftMenuItem=draftOptionsMenu.findItem(R.id.draft); + undraftMenuItem=draftOptionsMenu.findItem(R.id.undraft); + scheduleMenuItem=draftOptionsMenu.findItem(R.id.schedule); + unscheduleMenuItem=draftOptionsMenu.findItem(R.id.unschedule); + draftOptionsMenu.findItem(R.id.preview).setVisible(isInstanceAkkoma()); + draftOptionsPopup.setOnMenuItemClickListener(i->{ + int id=i.getItemId(); + if(id==R.id.draft) updateScheduledAt(getDraftInstant()); + else if(id==R.id.schedule) pickScheduledDateTime(); + else if(id==R.id.unschedule || id==R.id.undraft) updateScheduledAt(null); + else if(id==R.id.drafts) navigateToUnsentPosts(); + else if(id==R.id.preview) publish(true); + return true; + }); + UiUtils.enablePopupMenuIcons(getContext(), draftOptionsPopup); + + + languageButton = wrap.findViewById(R.id.language_btn); + languageButton.setOnClickListener(v->showLanguageAlert()); + languageButton.setOnLongClickListener(v->{ + if(!getLocalPrefs().bottomEncoding){ + getLocalPrefs().bottomEncoding=true; + getLocalPrefs().save(); + } + return false; + }); + if (!GlobalUserPreferences.relocatePublishButton) + publishButton.post(()->publishButton.setMinimumWidth(publishButton.getWidth())); + + (GlobalUserPreferences.relocatePublishButton ? publishButtonRelocated : publishButton).setOnClickListener(v->{ + Consumer draftCheckComplete=(isDraft)->{ + if(GlobalUserPreferences.altTextReminders && !isDraft) checkAltTextsAndPublish(); + else publish(); + }; + + boolean isAlreadyDraft=scheduledAt!=null && scheduledAt.isAfter(DRAFTS_AFTER_INSTANT); + if(editingStatus!=null && scheduledAt!=null && isAlreadyDraft) { + new M3AlertDialogBuilder(getActivity()) + .setTitle(R.string.sk_save_draft) + .setMessage(R.string.sk_save_draft_message) + .setPositiveButton(R.string.save, (d, w)->draftCheckComplete.accept(isAlreadyDraft)) + .setNegativeButton(R.string.publish, (d, w)->{ + updateScheduledAt(null); + draftCheckComplete.accept(false); + }) + .show(); + }else{ + draftCheckComplete.accept(isAlreadyDraft); + } + }); + draftsBtn.setOnClickListener(v-> draftOptionsPopup.show()); + draftsBtn.setOnTouchListener(draftOptionsPopup.getDragToOpenListener()); + updateScheduledAt(scheduledAt != null ? scheduledAt : scheduledStatus != null ? scheduledStatus.scheduledAt : null); + + Preferences prefs = AccountSessionManager.get(accountID).preferences; + if (postLang != null) setPostLanguage(postLang); + else setPostLanguage(prefs != null && prefs.postingDefaultLanguage != null && prefs.postingDefaultLanguage.length() > 0 + ? languageResolver.fromOrFallback(prefs.postingDefaultLanguage) + : languageResolver.getDefault()); + + if(isInstancePixelfed()) spoilerBtn.setVisibility(View.GONE); + if(isInstancePixelfed() || (editingStatus!=null && !redraftStatus)) { + // editing an already published post + draftsBtn.setVisibility(View.GONE); + } + updatePublishButtonState(); } + private void navigateToUnsentPosts() { + Bundle args=new Bundle(); + args.putString("account", accountID); + args.putBoolean("hide_fab", true); + InputMethodManager imm=getActivity().getSystemService(InputMethodManager.class); + imm.hideSoftInputFromWindow(draftsBtn.getWindowToken(), 0); + if (hasDraft()) { + Nav.go(getActivity(), ScheduledStatusListFragment.class, args); + } else { + // result for the previous ScheduledStatusList + setResult(true, null); + // finishing fragment in "onFragmentResult" + Nav.goForResult(getActivity(), ScheduledStatusListFragment.class, args, SCHEDULED_STATUS_OPENED_RESULT, this); + } + } + @Override public boolean onOptionsItemSelected(MenuItem item){ - if(item.getItemId()==R.id.publish){ - if(GlobalUserPreferences.altTextReminders && editingStatus==null) - checkAltTextsAndPublish(); - else - publish(); - } return true; } @@ -580,6 +1013,9 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr if(hasSpoiler){ charCount+=spoilerEdit.length(); } + if (localOnly && AccountSessionManager.get(accountID).getLocalPreferences().glitchInstance) { + charCount -= GLITCH_LOCAL_ONLY_SUFFIX.length(); + } charCounter.setText(String.valueOf(charLimit-charCount)); text.removeSpan(overLimitBG); @@ -598,11 +1034,28 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr updatePublishButtonState(); } + 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); + } else { + publishButton.setText(publishText); + } + } + 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((trimmedCharCount>0 || !mediaViewController.isEmpty()) && charCount<=charLimit && mediaViewController.getNonDoneAttachmentCount()==0 && (pollViewController.isEmpty() || pollViewController.getNonEmptyOptionsCount()>1)); + publishButton.setEnabled((!isInstancePixelfed() || !mediaViewController.isEmpty()) && (trimmedCharCount>0 || !mediaViewController.isEmpty()) && charCount<=charLimit && mediaViewController.getNonDoneAttachmentCount()==0 && (pollViewController.isEmpty() || pollViewController.getNonEmptyOptionsCount()>1)); } private void onCustomEmojiClick(Emoji emoji){ @@ -638,7 +1091,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr @Override protected int getNavigationIconDrawableResource(){ - return R.drawable.ic_baseline_close_24; + return R.drawable.ic_fluent_dismiss_24_regular; } @Override @@ -646,6 +1099,34 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr return true; } + private void createScheduledStatusFinish(ScheduledStatus result) { + wm.removeView(sendingOverlay); + sendingOverlay=null; + Toast.makeText(getContext(), scheduledAt.isAfter(DRAFTS_AFTER_INSTANT) ? + R.string.sk_draft_saved : R.string.sk_post_scheduled, Toast.LENGTH_SHORT).show(); + Nav.finish(ComposeFragment.this); + E.post(new ScheduledStatusCreatedEvent(result, accountID)); + } + + private void maybeDeleteScheduledPost(Runnable callback) { + if (scheduledStatus != null) { + new DeleteStatus.Scheduled(scheduledStatus.id).setCallback(new Callback<>() { + @Override + public void onSuccess(Object o) { + E.post(new ScheduledStatusDeletedEvent(scheduledStatus.id, accountID)); + callback.run(); + } + + @Override + public void onError(ErrorResponse error) { + handlePublishError(error); + } + }).exec(accountID); + } else { + callback.run(); + } + } + private void checkAltTextsAndPublish(){ int count=mediaViewController.getMissingAltTextAttachmentCount(); if(count==0){ @@ -669,6 +1150,10 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr } private void publish(){ + publish(false); + } + + private void publish(boolean preview){ sendingOverlay=new View(getActivity()); WindowManager.LayoutParams overlayParams=new WindowManager.LayoutParams(); overlayParams.type=WindowManager.LayoutParams.TYPE_APPLICATION_PANEL; @@ -679,25 +1164,41 @@ 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(this::actuallyPublish, this::handlePublishError); + mediaViewController.saveAltTextsBeforePublishing( + ()->actuallyPublish(preview), + this::handlePublishError); } - private void actuallyPublish(){ + private void actuallyPublish(boolean preview){ String text=mainEditText.getText().toString(); CreateStatus.Request req=new CreateStatus.Request(); + if("bottom".equals(postLang.encoding)){ + text=new StatusTextEncoder(Bottom::encode).encode(text); + req.spoilerText="bottom-encoded emoji spam"; + } + if(localOnly && + AccountSessionManager.get(accountID).getLocalPreferences().glitchInstance && + !GLITCH_LOCAL_ONLY_PATTERN.matcher(text).matches()){ + text+=" "+GLITCH_LOCAL_ONLY_SUFFIX; + } req.status=text; - req.visibility=statusVisibility; + req.localOnly=localOnly; + req.visibility=localOnly && instance.isAkkoma() ? StatusPrivacy.LOCAL : statusVisibility; + req.sensitive=sensitive; + req.contentType=contentType==ContentType.UNSPECIFIED ? null : contentType; + req.scheduledAt=scheduledAt; + req.preview=preview; if(!mediaViewController.isEmpty()){ req.mediaIds=mediaViewController.getAttachmentIDs(); if(editingStatus != null){ req.mediaAttributes=mediaViewController.getAttachmentAttributes(); } } - if(replyTo!=null){ - req.inReplyToId=replyTo.id; + if(replyTo!=null || (editingStatus != null && editingStatus.inReplyToId!=null)){ + req.inReplyToId=editingStatus!=null ? editingStatus.inReplyToId : replyTo.id; } if(!pollViewController.isEmpty()){ req.poll=pollViewController.getPollForRequest(); @@ -705,8 +1206,11 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr if(hasSpoiler && spoilerEdit.length()>0){ req.spoilerText=spoilerEdit.getText().toString(); } - if(postLang!=null){ - req.language=postLang.locale.toLanguageTag(); + if(postLang!=null && postLang.language!=null){ + req.language=postLang.language.getLanguage(); + } + if(quote != null){ + req.quoteId=quote.id; } if(uuid==null) uuid=UUID.randomUUID().toString(); @@ -714,18 +1218,44 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr Callback resCallback=new Callback<>(){ @Override public void onSuccess(Status result){ - wm.removeView(sendingOverlay); - sendingOverlay=null; - if(editingStatus==null){ - E.post(new StatusCreatedEvent(result, accountID)); - if(replyTo!=null){ - replyTo.repliesCount++; - E.post(new StatusCountersUpdatedEvent(replyTo)); - } - }else{ - E.post(new StatusUpdatedEvent(result)); + if(preview){ + openPreview(result); + return; } - Nav.finish(ComposeFragment.this); + + maybeDeleteScheduledPost(()->{ + wm.removeView(sendingOverlay); + sendingOverlay=null; + if(editingStatus==null || redraftStatus){ + E.post(new StatusCreatedEvent(result, accountID)); + if(replyTo!=null && !redraftStatus){ + replyTo.repliesCount++; + E.post(new StatusCountersUpdatedEvent(replyTo)); + } + }else{ + // pixelfed doesn't return the edited status :/ + Status editedStatus = result == null ? editingStatus : result; + if (result == null) { + editedStatus.text = req.status; + editedStatus.spoilerText = req.spoilerText; + editedStatus.sensitive = req.sensitive; + editedStatus.language = req.language; + // user will have to reload to see html + editedStatus.content = req.status; + } + E.post(new StatusUpdatedEvent(editedStatus)); + } + if(Build.VERSION.SDK_INT < Build.VERSION_CODES.O || !isStateSaved()){ + Nav.finish(ComposeFragment.this); + } + if(getArguments().getBoolean("navigateToStatus", false)){ + Bundle args=new Bundle(); + args.putString("account", accountID); + args.putParcelable("status", Parcels.wrap(result)); + if(replyTo!=null) args.putParcelable("inReplyToAccount", Parcels.wrap(replyTo)); + Nav.go(getActivity(), ThreadFragment.class, args); + } + }); } @Override @@ -734,22 +1264,51 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr } }; - if(editingStatus!=null){ + if(editingStatus!=null && !redraftStatus && !preview){ new EditStatus(req, editingStatus.id) .setCallback(resCallback) .exec(accountID); - }else{ + }else if(req.scheduledAt == null || preview){ new CreateStatus(req, uuid) .setCallback(resCallback) .exec(accountID); + }else if(req.scheduledAt.isAfter(Instant.now().plus(10, ChronoUnit.MINUTES))){ + // checking for 10 instead of 5 minutes (as per mastodon) because i really don't want + // bugs to occur because the client's clock is wrong by a minute or two - the api + // returns a status instead of a scheduled status if scheduled time is less than 5 + // minutes into the future and this is 1. unexpected for the user and 2. hard to handle + new CreateStatus.Scheduled(req, uuid) + .setCallback(new Callback<>() { + @Override + public void onSuccess(ScheduledStatus result) { + maybeDeleteScheduledPost(() -> { + createScheduledStatusFinish(result); + }); + } + + @Override + public void onError(ErrorResponse error) { + handlePublishError(error); + } + }).exec(accountID); + }else{ + new M3AlertDialogBuilder(getActivity()) + .setTitle(R.string.sk_scheduled_too_soon_title) + .setMessage(R.string.sk_scheduled_too_soon) + .setPositiveButton(R.string.ok, (a, b)->{}) + .show(); + handlePublishError(null); + (GlobalUserPreferences.relocatePublishButton ? publishButtonRelocated : publishButton).setEnabled(false); } + + if (replyTo == null) updateRecentLanguages(); } private void handlePublishError(ErrorResponse error){ 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) @@ -757,18 +1316,55 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr .setPositiveButton(R.string.retry, (dlg, btn)->publish()) .setNegativeButton(R.string.cancel, null) .show(); - }else{ + }else if(error!=null){ error.showToast(getActivity()); } } + private void openPreview(Status result){ + result.preview=true; + wm.removeView(sendingOverlay); + sendingOverlay=null; + (GlobalUserPreferences.relocatePublishButton ? publishButtonRelocated : publishButton).setEnabled(true); + V.setVisibilityAnimated(sendProgress, View.GONE); + InputMethodManager imm=getActivity().getSystemService(InputMethodManager.class); + imm.hideSoftInputFromWindow(contentView.getWindowToken(), 0); + + Bundle args=new Bundle(); + args.putString("account", accountID); + args.putParcelable("status", Parcels.wrap(result)); + if(replyTo!=null){ + args.putParcelable("inReplyTo", Parcels.wrap(replyTo)); + args.putParcelable("inReplyToAccount", Parcels.wrap(replyTo.account)); + } + Nav.go(getActivity(), ThreadFragment.class, args); + } + + private void updateRecentLanguages() { + if (postLang == null || postLang.language == null) return; + String language = postLang.language.getLanguage(); + AccountLocalPreferences prefs = AccountSessionManager.get(accountID).getLocalPreferences(); + prefs.recentLanguages.remove(language); + prefs.recentLanguages.add(0, language); + if (postLang.encoding != null) { + prefs.recentLanguages.remove(postLang.encoding); + prefs.recentLanguages.add(0, postLang.encoding); + } + if ("bottom".equals(postLang.encoding) && !prefs.bottomEncoding) prefs.bottomEncoding = true; + prefs.save(); + } + private boolean hasDraft(){ + if(getArguments().getBoolean("hasDraft", false)) return true; if(editingStatus!=null){ if(!mainEditText.getText().toString().equals(initialText)) return true; List existingMediaIDs=editingStatus.mediaAttachments.stream().map(a->a.id).collect(Collectors.toList()); if(!existingMediaIDs.equals(mediaViewController.getAttachmentIDs())) return true; + if(!statusVisibility.equals(editingStatus.visibility)) return true; + if(scheduledStatus != null && !scheduledStatus.scheduledAt.equals(scheduledAt)) return true; + if(sensitive != editingStatus.sensitive) return true; return pollViewController.isPollChanged(); } boolean pollFieldsHaveContent=pollViewController.getNonEmptyOptionsCount()>0; @@ -819,10 +1415,20 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr } private void confirmDiscardDraftAndFinish(){ - new M3AlertDialogBuilder(getActivity()) - .setTitle(editingStatus==null ? R.string.discard_draft : R.string.discard_changes) - .setPositiveButton(R.string.discard, (dialog, which)->Nav.finish(this)) - .setNegativeButton(R.string.cancel, null) + boolean attachmentsPending=mediaViewController.areAnyAttachmentsNotDone(); + if(attachmentsPending) new M3AlertDialogBuilder(getActivity()) + .setTitle(R.string.sk_unfinished_attachments) + .setMessage(R.string.sk_unfinished_attachments_message) + .setPositiveButton(R.string.ok, (d, w)->{}) + .setNegativeButton(R.string.discard, (d, w)->Nav.finish(this)) + .show(); + else new M3AlertDialogBuilder(getActivity()) + .setTitle(editingStatus!=null ? R.string.sk_confirm_save_changes : R.string.sk_confirm_save_draft) + .setPositiveButton(R.string.save, (d, w)->{ + updateScheduledAt(scheduledAt==null ? getDraftInstant() : scheduledAt); + publish(); + }) + .setNegativeButton(R.string.discard, (d, w)->Nav.finish(this)) .show(); } @@ -835,9 +1441,9 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr * *

For earlier versions use the built in docs ui via {@link Intent#ACTION_GET_CONTENT} */ - private void openFilePicker(){ + private void openFilePicker(boolean photoPicker){ Intent intent; - boolean usePhotoPicker=UiUtils.isPhotoPickerAvailable(); + boolean usePhotoPicker=photoPicker && UiUtils.isPhotoPickerAvailable(); if(usePhotoPicker){ intent=new Intent(MediaStore.ACTION_PICK_IMAGES); if(mediaViewController.getMaxAttachments()-mediaViewController.getMediaAttachmentsCount()>1) @@ -877,6 +1483,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 = FileProvider.getUriForFile(getContext(), getContext().getPackageName() + ".fileprovider", 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); + } } @@ -904,6 +1543,179 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr spoilerBtn.setSelected(false); mainEditText.requestFocus(); updateCharCounter(); + sensitiveBtn.setVisibility(mediaViewController.getMediaAttachmentsCount() > 0 ? View.VISIBLE : View.GONE); + } + updateSensitive(); + } + + private void toggleSensitive() { + sensitive=!sensitive; + sensitiveBtn.setSelected(sensitive); + } + + public void updateSensitive() { + sensitiveBtn.setVisibility(View.GONE); + if (!mediaViewController.isEmpty()) sensitiveBtn.setVisibility(View.VISIBLE); + if (mediaViewController.isEmpty()) sensitive = false; + } + + private void pickScheduledDateTime() { + LocalDateTime soon = LocalDateTime.now() + .plus(15, ChronoUnit.MINUTES) // so 14:59 doesn't get rounded up to… + .plus(1, ChronoUnit.HOURS) // …15:00, but rather 16:00 + .withMinute(0); + new DatePickerDialog(getActivity(), (datePicker, year, arrayMonth, dayOfMonth) -> { + new TimePickerDialog(getActivity(), (timePicker, hour, minute) -> { + LocalDateTime at=LocalDateTime.of(year, arrayMonth + 1, dayOfMonth, hour, minute); + updateScheduledAt(at.toInstant(ZoneId.systemDefault().getRules().getOffset(at))); + }, soon.getHour(), soon.getMinute(), DateFormat.is24HourFormat(getActivity())).show(); + }, soon.getYear(), soon.getMonthValue() - 1, soon.getDayOfMonth()).show(); + } + + private void updateScheduledAt(Instant scheduledAt) { + this.scheduledAt = scheduledAt; + updatePublishButtonState(); + V.setVisibilityAnimated(scheduleDraftView, scheduledAt == null ? View.GONE : View.VISIBLE); + draftMenuItem.setVisible(true); + scheduleMenuItem.setVisible(true); + undraftMenuItem.setVisible(false); + unscheduleMenuItem.setVisible(false); + if (scheduledAt != null) { + DateTimeFormatter formatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM).withLocale(Locale.getDefault()); + if (scheduledAt.isAfter(DRAFTS_AFTER_INSTANT)) { + draftMenuItem.setVisible(false); + undraftMenuItem.setVisible(true); + scheduleTimeBtn.setVisibility(View.GONE); + scheduleDraftText.setText(R.string.sk_compose_draft); + scheduleDraftText.setCompoundDrawablesWithIntrinsicBounds(R.drawable.ic_fluent_drafts_20_regular, 0, 0, 0); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + scheduleDraftDismiss.setTooltipText(getString(R.string.sk_compose_no_draft)); + } + scheduleDraftDismiss.setContentDescription(getString(R.string.sk_compose_no_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); + String at = scheduledAt.atZone(ZoneId.systemDefault()).format(formatter); + scheduleTimeBtn.setVisibility(View.VISIBLE); + scheduleTimeBtn.setText(at); + scheduleDraftText.setText(R.string.sk_compose_scheduled); + scheduleDraftText.setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + scheduleDraftDismiss.setTooltipText(getString(R.string.sk_compose_no_schedule)); + } + scheduleDraftDismiss.setContentDescription(getString(R.string.sk_compose_no_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.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(); + } + } + + private void updateHeaders() { + UiUtils.setExtraTextInfo(getContext(), selfExtraText, false, false, localOnly, null); + if (replyTo != null) UiUtils.setExtraTextInfo(getContext(), extraText, true, false, replyTo.localOnly || replyTo.visibility==StatusPrivacy.LOCAL, replyTo.account); + } + + private void buildVisibilityPopup(View v){ + visibilityPopup=new PopupMenu(getActivity(), v); + visibilityPopup.inflate(R.menu.compose_visibility); + Menu m=visibilityPopup.getMenu(); + if(isInstancePixelfed()){ + m.findItem(R.id.vis_private).setVisible(false); + } + MenuItem localOnlyItem=visibilityPopup.getMenu().findItem(R.id.local_only); + AccountLocalPreferences prefs=AccountSessionManager.get(accountID).getLocalPreferences(); + boolean prefsSaysSupported=prefs.localOnlySupported; + if(isInstanceAkkoma()){ + m.findItem(R.id.vis_local).setVisible(true); + }else if(localOnly || prefsSaysSupported){ + localOnlyItem.setVisible(true); + localOnlyItem.setChecked(localOnly); + Status status=editingStatus!=null ? editingStatus : replyTo; + if(!prefsSaysSupported){ + prefs.localOnlySupported=true; + if(GLITCH_LOCAL_ONLY_PATTERN.matcher(status.getStrippedText()).matches()){ + prefs.glitchInstance=true; + } + prefs.save(); + } + } + UiUtils.enablePopupMenuIcons(getActivity(), visibilityPopup); + if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.P) m.setGroupDividerEnabled(true); + visibilityPopup.setOnMenuItemClickListener(new PopupMenu.OnMenuItemClickListener(){ + @Override + public boolean onMenuItemClick(MenuItem item){ + int id=item.getItemId(); + if(id==R.id.vis_public){ + statusVisibility=StatusPrivacy.PUBLIC; + }else if(id==R.id.vis_unlisted){ + statusVisibility=StatusPrivacy.UNLISTED; + }else if(id==R.id.vis_followers){ + statusVisibility=StatusPrivacy.PRIVATE; + }else if(id==R.id.vis_private){ + statusVisibility=StatusPrivacy.DIRECT; + }else if(id==R.id.vis_local){ + statusVisibility=StatusPrivacy.LOCAL; + } + if(id==R.id.local_only){ + localOnly=!item.isChecked(); + item.setChecked(localOnly); + }else{ + item.setChecked(true); + } + updateVisibilityIcon(); + updateHeaders(); + return true; + } + }); + } + + @SuppressLint("ClickableViewAccessibility") + private void buildContentTypePopup(View btn) { + contentTypePopup=new PopupMenu(getActivity(), btn); + Menu m = contentTypePopup.getMenu(); + for(ContentType value : ContentType.values()){ + if(!value.supportedByInstance(instance)) continue; + m.add(0, value.ordinal(), Menu.NONE, value.getName()); + } + m.setGroupCheckable(0, true, true); + if (contentType!=ContentType.UNSPECIFIED || editingStatus!=null){ + // setting content type to null while editing will just leave it unchanged + m.findItem(ContentType.UNSPECIFIED.ordinal()).setVisible(false); + } + + contentTypePopup.setOnMenuItemClickListener(i->{ + uuid=null; + int index=i.getItemId(); + contentType=ContentType.values()[index]; + btn.setSelected(index!=ContentType.UNSPECIFIED.ordinal() && index!=ContentType.PLAIN.ordinal()); + i.setChecked(true); + return true; + }); + + if (!AccountSessionManager.get(accountID).getLocalPreferences().contentTypesEnabled) { + btn.setVisibility(View.GONE); } } @@ -926,43 +1738,21 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr menu.show(); } - private void loadDefaultStatusVisibility(Bundle savedInstanceState){ - if(getArguments().containsKey("replyTo")){ - replyTo=Parcels.unwrap(getArguments().getParcelable("replyTo")); - statusVisibility=replyTo.visibility; + private void loadDefaultStatusVisibility(Bundle savedInstanceState) { + if(replyTo != null) { + statusVisibility = (replyTo.visibility == StatusPrivacy.PUBLIC && GlobalUserPreferences.defaultToUnlistedReplies ? StatusPrivacy.UNLISTED : replyTo.visibility); } - // A saved privacy setting from a previous compose session wins over the reply visibility - if(savedInstanceState!=null){ - statusVisibility=(StatusPrivacy) savedInstanceState.getSerializable("visibility"); + AccountSessionManager asm = AccountSessionManager.getInstance(); + Preferences prefs=asm.getAccount(accountID).preferences; + if (prefs != null) { + // Only override the reply visibility if our preference is more private + // (and we're not replying to ourselves, or not at all) + if (prefs.postingDefaultVisibility.isLessVisibleThan(statusVisibility) && + (replyTo == null || !asm.isSelf(accountID, replyTo.account))) { + statusVisibility = prefs.postingDefaultVisibility; + } } - - Preferences prevPrefs=AccountSessionManager.getInstance().getAccount(accountID).preferences; - if(prevPrefs!=null){ - applyPreferencesForPostVisibility(prevPrefs, savedInstanceState); - } - AccountSessionManager.getInstance().getAccount(accountID).reloadPreferences(prefs->{ - applyPreferencesForPostVisibility(prefs, savedInstanceState); - }); - } - - private void applyPreferencesForPostVisibility(Preferences prefs, Bundle savedInstanceState){ - // Only override the reply visibility if our preference is more private - if(prefs.postingDefaultVisibility.isLessVisibleThan(statusVisibility)){ - // Map unlisted from the API onto public, because we don't have unlisted in the UI - statusVisibility=switch(prefs.postingDefaultVisibility){ - case PUBLIC, UNLISTED -> StatusPrivacy.PUBLIC; - case PRIVATE -> StatusPrivacy.PRIVATE; - case DIRECT -> StatusPrivacy.DIRECT; - }; - } - - // A saved privacy setting from a previous compose session wins over all - if(savedInstanceState!=null){ - statusVisibility=(StatusPrivacy) savedInstanceState.getSerializable("visibility"); - } - - updateVisibilityIcon(); } private void updateVisibilityIcon(){ @@ -972,17 +1762,21 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr statusVisibility=StatusPrivacy.PUBLIC; } visibilityBtn.setText(switch(statusVisibility){ - case PUBLIC, UNLISTED -> R.string.visibility_public; + case PUBLIC -> R.string.visibility_public; + case UNLISTED -> R.string.sk_visibility_unlisted; case PRIVATE -> R.string.visibility_followers_only; case DIRECT -> R.string.visibility_private; + case LOCAL -> R.string.sk_local_only; }); Drawable icon=getResources().getDrawable(switch(statusVisibility){ - case PUBLIC, UNLISTED -> R.drawable.ic_public_20px; - case PRIVATE -> R.drawable.ic_group_20px; - case DIRECT -> R.drawable.ic_alternate_email_20px; + case PUBLIC -> R.drawable.ic_fluent_earth_16_regular; + case UNLISTED -> R.drawable.ic_fluent_lock_open_16_regular; + case PRIVATE -> R.drawable.ic_fluent_lock_closed_16_filled; + case DIRECT -> R.drawable.ic_fluent_mention_16_regular; + case LOCAL -> R.drawable.ic_fluent_eye_16_regular; }, getActivity().getTheme()).mutate(); icon.setBounds(0, 0, V.dp(18), V.dp(18)); - icon.setTint(UiUtils.getThemeColor(getActivity(), R.attr.colorM3Primary)); + visibilityBtn.setCompoundDrawableTintList(getContext().getResources().getColorStateList(R.color.m3_primary_selector, getContext().getTheme())); visibilityBtn.setCompoundDrawablesRelative(icon, null, visibilityBtn.getCompoundDrawablesRelative()[2], null); } @@ -1058,6 +1852,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr private void showAutocomplete(){ UiUtils.beginLayoutTransition(bottomBar); + UiUtils.beginLayoutTransition(scheduleDraftView); View autocompleteView=autocompleteViewController.getView(); bottomBar.getLayoutParams().height=ViewGroup.LayoutParams.WRAP_CONTENT; bottomBar.requestLayout(); @@ -1067,7 +1862,8 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr private void hideAutocomplete(){ UiUtils.beginLayoutTransition(bottomBar); - bottomBar.getLayoutParams().height=V.dp(48); + UiUtils.beginLayoutTransition(scheduleDraftView); + bottomBar.getLayoutParams().height=V.dp(56); bottomBar.requestLayout(); autocompleteViewController.getView().setVisibility(View.INVISIBLE); autocompleteDivider.setVisibility(View.INVISIBLE); @@ -1109,6 +1905,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr return creatingView; } + @Override public String getAccountID(){ return accountID; } @@ -1118,8 +1915,8 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr } private void showLanguageAlert(){ - Preferences prefs=AccountSessionManager.getInstance().getAccount(accountID).preferences; - ComposeLanguageAlertViewController vc=new ComposeLanguageAlertViewController(getActivity(), prefs!=null ? prefs.postingDefaultLanguage : null, postLang, mainEditText.getText().toString()); + AccountSession session=AccountSessionManager.get(accountID); + ComposeLanguageAlertViewController vc=new ComposeLanguageAlertViewController(getActivity(), session.preferences!=null ? session.preferences.postingDefaultLanguage : null, postLang, mainEditText.getText().toString(), languageResolver, session); new M3AlertDialogBuilder(getActivity()) .setTitle(R.string.language) .setView(vc.getView()) @@ -1128,8 +1925,23 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr .show(); } - private void setPostLanguage(ComposeLanguageAlertViewController.SelectedOption language){ - postLang=language; + private void setPostLanguage(String lang) { + setPostLanguage(lang == null ? languageResolver.getDefault() : languageResolver.fromOrFallback(lang)); + } + + private void setPostLanguage(MastodonLanguage lang) { + setPostLanguage(new ComposeLanguageAlertViewController.SelectedOption(lang)); + } + + private void setPostLanguage(ComposeLanguageAlertViewController.SelectedOption opt){ + postLang=opt; + if (Objects.equals("bottom", opt.encoding)) { + languageButton.setText("\uD83E\uDD7A\uD83D\uDC49\uD83D\uDC48"); + languageButton.setContentDescription(opt.encoding); + return; + } + languageButton.setText(opt.language.getLanguageName()); + languageButton.setContentDescription(getActivity().getString(R.string.sk_post_language, opt.language.getDefaultName())); } @Override 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 5e3cc22c3..a82f95592 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeImageDescriptionFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeImageDescriptionFragment.java @@ -20,13 +20,15 @@ import android.view.inputmethod.InputMethodManager; import android.widget.EditText; import android.widget.ImageView; +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.M3AlertDialogBuilder; import org.joinmastodon.android.ui.photoviewer.PhotoViewer; +import org.joinmastodon.android.ui.utils.ColorPalette; import org.joinmastodon.android.ui.utils.UiUtils; -import org.joinmastodon.android.ui.views.FixedAspectRatioImageView; import java.util.Collections; @@ -42,22 +44,24 @@ public class ComposeImageDescriptionFragment extends MastodonToolbarFragment imp private String accountID, attachmentID; private EditText edit; - private FixedAspectRatioImageView image; + private ImageView image; private ContextThemeWrapper themeWrapper; private PhotoViewer photoViewer; @Override public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); - accountID=getArguments().getString("account"); - attachmentID=getArguments().getString("attachment"); setHasOptionsMenu(true); } @Override public void onAttach(Activity activity){ super.onAttach(activity); + accountID=getArguments().getString("account"); + attachmentID=getArguments().getString("attachment"); themeWrapper=new ContextThemeWrapper(activity, R.style.Theme_Mastodon_Dark); + ColorPalette.palettes.get(AccountSessionManager.get(accountID).getLocalPreferences().getCurrentColor()) + .apply(themeWrapper, GlobalUserPreferences.ThemePreference.DARK); setTitle(R.string.add_alt_text); } @@ -75,7 +79,7 @@ public class ComposeImageDescriptionFragment extends MastodonToolbarFragment imp int width=getArguments().getInt("width", 0); int height=getArguments().getInt("height", 0); if(width>0 && height>0){ - image.setAspectRatio(Math.max(1f, (float)width/height)); + // image.setAspectRatio(Math.max(1f, (float)width/height)); } image.setOnClickListener(v->openPhotoViewer()); Uri uri=getArguments().getParcelable("uri"); 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..9fd6bcef4 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/CustomLocalTimelineFragment.java @@ -0,0 +1,97 @@ +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.model.Filter; +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 org.joinmastodon.android.utils.StatusFilterPredicate; + +import java.util.List; +import java.util.stream.Collectors; + +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; + result=result.stream().filter(new StatusFilterPredicate(accountID, FilterContext.PUBLIC)).collect(Collectors.toList()); + 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 null; + } + + @Override + public Uri getWebUri(Uri.Builder base) { + return Uri.parse(domain); + } + + @Override + protected TimelineDefinition makeTimelineDefinition() { + return TimelineDefinition.ofCustomLocalTimeline(domain); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/EditTimelinesFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/EditTimelinesFragment.java new file mode 100644 index 000000000..9d03a10df --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/EditTimelinesFragment.java @@ -0,0 +1,516 @@ +package org.joinmastodon.android.fragments; + +import static android.view.Menu.NONE; +import static com.hootsuite.nachos.terminator.ChipTerminatorHandler.BEHAVIOR_CHIPIFY_ALL; +import static org.joinmastodon.android.ui.utils.UiUtils.makeBackItem; + +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; +import android.view.MenuItem; +import android.view.MotionEvent; +import android.view.SubMenu; +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; +import android.widget.PopupMenu; +import android.widget.Switch; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.ItemTouchHelper; +import androidx.recyclerview.widget.RecyclerView; + +import com.hootsuite.nachos.NachoTextView; + +import org.joinmastodon.android.R; +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.api.session.AccountSession; +import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.model.CustomLocalTimeline; +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; +import org.joinmastodon.android.ui.utils.UiUtils; +import org.joinmastodon.android.ui.views.TextInputFrameLayout; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +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{ + private String accountID; + private TimelinesAdapter adapter; + private final ItemTouchHelper itemTouchHelper; + private Menu optionsMenu; + private boolean updated; + private final Map timelineByMenuItem=new HashMap<>(); + private final List listTimelines=new ArrayList<>(); + private final List hashtags=new ArrayList<>(); + private MenuItem addHashtagItem; + private final List localTimelines = new ArrayList<>(); + + public EditTimelinesFragment(){ + super(10); + ItemTouchHelper.SimpleCallback itemTouchCallback=new ItemTouchHelperCallback(); + itemTouchHelper=new ItemTouchHelper(itemTouchCallback); + } + + @Override + public void onCreate(Bundle savedInstanceState){ + super.onCreate(savedInstanceState); + setHasOptionsMenu(true); + setTitle(R.string.sk_timelines); + accountID=getArguments().getString("account"); + + new GetLists().setCallback(new Callback<>(){ + @Override + public void onSuccess(List result){ + listTimelines.addAll(result); + updateOptionsMenu(); + } + + @Override + public void onError(ErrorResponse error){ + error.showToast(getContext()); + } + }).exec(accountID); + + new GetFollowedHashtags().setCallback(new Callback<>(){ + @Override + public void onSuccess(HeaderPaginationList result){ + hashtags.addAll(result); + updateOptionsMenu(); + } + + @Override + public void onError(ErrorResponse error){ + error.showToast(getContext()); + } + }).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); + itemTouchHelper.attachToRecyclerView(list); + refreshLayout.setEnabled(false); + list.addItemDecoration(new DividerItemDecoration(getActivity(), R.attr.colorM3OutlineVariant, 0.5f, 56, 16)); + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){ + this.optionsMenu=menu; + updateOptionsMenu(); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item){ + if(item.getItemId()==R.id.menu_back){ + updateOptionsMenu(); + optionsMenu.performIdentifierAction(R.id.menu_add_timeline, 0); + 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()); + adapter.notifyItemInserted(data.size()); + saveTimelines(); + updateOptionsMenu(); + } + + private void addNewLocalTimeline() { + FrameLayout inputWrap = new FrameLayout(getContext()); + EditText input = new EditText(getContext()); + input.setHint(R.string.sk_example_domain); + input.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI); + FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + params.setMargins(V.dp(16), V.dp(4), V.dp(16), V.dp(16)); + input.setLayoutParams(params); + inputWrap.addView(input); + new M3AlertDialogBuilder(getContext()).setTitle(R.string.mo_add_custom_server_local_timeline).setView(inputWrap) + .setPositiveButton(R.string.save, (d, which) -> { + 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); + item.setIcon(icon); + return item; + } + + private void updateOptionsMenu(){ + if(getActivity()==null) return; + optionsMenu.clear(); + timelineByMenuItem.clear(); + + SubMenu menu=optionsMenu.addSubMenu(0, R.id.menu_add_timeline, NONE, R.string.sk_timelines_add); + menu.getItem().setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS); + menu.getItem().setIcon(R.drawable.ic_fluent_add_24_regular); + + SubMenu timelinesMenu=menu.addSubMenu(R.string.sk_timeline); + timelinesMenu.getItem().setIcon(R.drawable.ic_fluent_timeline_24_regular); + SubMenu listsMenu=menu.addSubMenu(R.string.sk_list); + listsMenu.getItem().setIcon(R.drawable.ic_fluent_people_24_regular); + SubMenu hashtagsMenu=menu.addSubMenu(R.string.sk_hashtag); + hashtagsMenu.getItem().setIcon(R.drawable.ic_fluent_number_symbol_24_regular); + + MenuItem addLocalTimelines = menu.add(0, R.id.menu_add_local_timelines, NONE, R.string.local_timeline); + addLocalTimelines.setIcon(R.drawable.ic_fluent_add_24_regular); + + makeBackItem(timelinesMenu); + makeBackItem(listsMenu); + makeBackItem(hashtagsMenu); + + TimelineDefinition.getAllTimelines(accountID).stream().forEach(tl->addTimelineToOptions(tl, timelinesMenu)); + listTimelines.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)); + + timelinesMenu.getItem().setVisible(timelinesMenu.size()>0); + listsMenu.getItem().setVisible(listsMenu.size()>0); + hashtagsMenu.getItem().setVisible(hashtagsMenu.size()>0); + + UiUtils.enableOptionsMenuIcons(getContext(), optionsMenu, R.id.menu_add_timeline); + } + + private void saveTimelines(){ + updated=true; + AccountLocalPreferences prefs=AccountSessionManager.get(accountID).getLocalPreferences(); + if(data.isEmpty()) data.add(TimelineDefinition.HOME_TIMELINE); + prefs.timelines=data; + prefs.save(); + } + + private void removeTimeline(int position){ + data.remove(position); + adapter.notifyItemRemoved(position); + saveTimelines(); + updateOptionsMenu(); + } + + @Override + protected void doLoadData(int offset, int count){ + onDataLoaded(AccountSessionManager.get(accountID).getLocalPreferences().timelines); + updateOptionsMenu(); + } + + @Override + protected RecyclerView.Adapter getAdapter(){ + return adapter=new TimelinesAdapter(); + } + + @Override + public void scrollToTop(){ + smoothScrollRecyclerViewToTop(list); + } + + @Override + public void onDestroy(){ + super.onDestroy(); + if(updated) UiUtils.restartApp(); + } + + private boolean setTagListContent(NachoTextView editText, @Nullable List tags){ + if(tags==null || tags.isEmpty()) return false; + editText.setText(tags); + editText.chipifyAllUnterminatedTokens(); + return true; + } + + private NachoTextView prepareChipTextView(NachoTextView nacho){ + //I’ll Be Back + nacho.setChipTerminators( + Map.of( + ',', BEHAVIOR_CHIPIFY_ALL, + '\n', BEHAVIOR_CHIPIFY_ALL, + ' ', BEHAVIOR_CHIPIFY_ALL, + ';', BEHAVIOR_CHIPIFY_ALL + ) + ); + nacho.enableEditChipOnTouch(true, true); + nacho.setOnFocusChangeListener((v, hasFocus)->nacho.chipifyAllUnterminatedTokens()); + return nacho; + } + + @SuppressLint("ClickableViewAccessibility") + protected void makeTimelineEditor(@Nullable TimelineDefinition item, Consumer onSave, Runnable onRemove){ + Context ctx=getContext(); + View view=getActivity().getLayoutInflater().inflate(R.layout.edit_timeline, list, false); + + View divider=view.findViewById(R.id.divider); + Button advancedBtn=view.findViewById(R.id.advanced); + EditText editText=view.findViewById(R.id.input); + if(item!=null) editText.setText(item.getCustomTitle()); + editText.setHint(item!=null ? item.getDefaultTitle(ctx) : ctx.getString(R.string.sk_hashtag)); + + LinearLayout tagWrap=view.findViewById(R.id.tag_wrap); + boolean hashtagOptionsAvailable=item==null || item.getType()==TimelineDefinition.TimelineType.HASHTAG; + advancedBtn.setVisibility(hashtagOptionsAvailable ? View.VISIBLE : View.GONE); + advancedBtn.setOnClickListener(l->{ + advancedBtn.setSelected(!advancedBtn.isSelected()); + advancedBtn.setText(advancedBtn.isSelected() ? R.string.sk_advanced_options_hide : R.string.sk_advanced_options_show); + divider.setVisibility(advancedBtn.isSelected() ? View.VISIBLE : View.GONE); + tagWrap.setVisibility(advancedBtn.isSelected() ? View.VISIBLE : View.GONE); + UiUtils.beginLayoutTransition((ViewGroup) view); + }); + + Switch localOnlySwitch=view.findViewById(R.id.local_only_switch); + view.findViewById(R.id.local_only).setOnClickListener(l->localOnlySwitch.setChecked(!localOnlySwitch.isChecked())); + + EditText tagMain=view.findViewById(R.id.tag_main); + NachoTextView tagsAny=prepareChipTextView(view.findViewById(R.id.tags_any)); + NachoTextView tagsAll=prepareChipTextView(view.findViewById(R.id.tags_all)); + NachoTextView tagsNone=prepareChipTextView(view.findViewById(R.id.tags_none)); + + if(item!=null && hashtagOptionsAvailable){ + tagMain.setText(item.getHashtagName()); + boolean hasAdvanced=!TextUtils.isEmpty(item.getCustomTitle()) && !Objects.equals(item.getHashtagName(), item.getCustomTitle()); + hasAdvanced=setTagListContent(tagsAny, item.getHashtagAny()) || hasAdvanced; + hasAdvanced=setTagListContent(tagsAll, item.getHashtagAll()) || hasAdvanced; + hasAdvanced=setTagListContent(tagsNone, item.getHashtagNone()) || hasAdvanced; + if(item.isHashtagLocalOnly()){ + localOnlySwitch.setChecked(true); + hasAdvanced=true; + } + if(hasAdvanced){ + advancedBtn.setSelected(true); + advancedBtn.setText(R.string.sk_advanced_options_hide); + tagWrap.setVisibility(View.VISIBLE); + divider.setVisibility(View.VISIBLE); + } + } + + ImageButton btn=view.findViewById(R.id.button); + PopupMenu popup=new PopupMenu(ctx, btn); + TimelineDefinition.Icon currentIcon=item!=null ? item.getIcon() : TimelineDefinition.Icon.HASHTAG; + btn.setImageResource(currentIcon.iconRes); + btn.setTag(currentIcon.ordinal()); + btn.setContentDescription(ctx.getString(currentIcon.nameRes)); + btn.setOnTouchListener(popup.getDragToOpenListener()); + btn.setOnClickListener(l->popup.show()); + + Menu menu=popup.getMenu(); + TimelineDefinition.Icon defaultIcon=item!=null ? item.getDefaultIcon() : TimelineDefinition.Icon.HASHTAG; + menu.add(0, currentIcon.ordinal(), NONE, currentIcon.nameRes).setIcon(currentIcon.iconRes); + if(!currentIcon.equals(defaultIcon)){ + menu.add(0, defaultIcon.ordinal(), NONE, defaultIcon.nameRes).setIcon(defaultIcon.iconRes); + } + for(TimelineDefinition.Icon icon : TimelineDefinition.Icon.values()){ + if(icon.hidden || icon.ordinal()==(int) btn.getTag()) continue; + menu.add(0, icon.ordinal(), NONE, icon.nameRes).setIcon(icon.iconRes); + } + UiUtils.enablePopupMenuIcons(ctx, popup); + + popup.setOnMenuItemClickListener(menuItem->{ + TimelineDefinition.Icon icon=TimelineDefinition.Icon.values()[menuItem.getItemId()]; + btn.setImageResource(icon.iconRes); + btn.setTag(menuItem.getItemId()); + btn.setContentDescription(ctx.getString(icon.nameRes)); + return true; + }); + + AlertDialog.Builder builder=new M3AlertDialogBuilder(ctx) + .setTitle(item==null ? R.string.sk_add_timeline : R.string.sk_edit_timeline) + .setView(view) + .setPositiveButton(R.string.save, (d, which)->{ + String name=editText.getText().toString().trim(); + + String mainHashtag=tagMain.getText().toString().trim(); + if(item != null && item.getType()==TimelineDefinition.TimelineType.HASHTAG){ + tagsAny.chipifyAllUnterminatedTokens(); + tagsAll.chipifyAllUnterminatedTokens(); + tagsNone.chipifyAllUnterminatedTokens(); + if(TextUtils.isEmpty(mainHashtag)){ + mainHashtag=name; + name=null; + } + if(TextUtils.isEmpty(mainHashtag) && (item!=null && item.getType()==TimelineDefinition.TimelineType.HASHTAG)){ + Toast.makeText(ctx, R.string.sk_add_timeline_tag_error_empty, Toast.LENGTH_SHORT).show(); + onSave.accept(null); + return; + } + } + + TimelineDefinition tl=item!=null ? item : TimelineDefinition.ofHashtag(name); + TimelineDefinition.Icon icon=TimelineDefinition.Icon.values()[(int) btn.getTag()]; + tl.setIcon(icon); + tl.setTitle(name); + if(item == null || item.getType()==TimelineDefinition.TimelineType.HASHTAG){ + tl.setTagOptions( + mainHashtag, + tagsAny.getChipValues(), + tagsAll.getChipValues(), + tagsNone.getChipValues(), + localOnlySwitch.isChecked() + ); + } + onSave.accept(tl); + }) + .setNegativeButton(R.string.cancel, (d, which)->{}); + + if(onRemove!=null) builder.setNeutralButton(R.string.sk_remove, (d, which)->onRemove.run()); + + builder.show(); + btn.requestFocus(); + } + + private class TimelinesAdapter extends RecyclerView.Adapter{ + @NonNull + @Override + public TimelineViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){ + return new TimelineViewHolder(); + } + + @Override + public void onBindViewHolder(@NonNull TimelineViewHolder holder, int position){ + holder.bind(data.get(position)); + } + + @Override + public int getItemCount(){ + return data.size(); + } + } + + private class TimelineViewHolder extends BindableViewHolder implements UsableRecyclerView.Clickable{ + private final TextView title; + private final ImageView dragger; + + public TimelineViewHolder(){ + super(getActivity(), R.layout.item_text, list); + title=findViewById(R.id.title); + dragger=findViewById(R.id.dragger_thingy); + } + + @SuppressLint("ClickableViewAccessibility") + @Override + public void onBind(TimelineDefinition item){ + title.setText(item.getTitle(getContext())); + title.setCompoundDrawablesRelativeWithIntrinsicBounds(itemView.getContext().getDrawable(item.getIcon().iconRes), null, null, null); + dragger.setVisibility(View.VISIBLE); + dragger.setOnTouchListener((View v, MotionEvent event)->{ + if(event.getAction()==MotionEvent.ACTION_DOWN){ + itemTouchHelper.startDrag(this); + return true; + } + return false; + }); + } + + private void onSave(TimelineDefinition tl){ + saveTimelines(); + rebind(); + } + + private void onRemove(){ + removeTimeline(getAbsoluteAdapterPosition()); + } + + @SuppressLint("ClickableViewAccessibility") + @Override + public void onClick(){ + makeTimelineEditor(item, this::onSave, this::onRemove); + } + } + + private class ItemTouchHelperCallback extends ItemTouchHelper.SimpleCallback{ + public ItemTouchHelperCallback(){ + super(ItemTouchHelper.UP|ItemTouchHelper.DOWN, ItemTouchHelper.LEFT|ItemTouchHelper.RIGHT); + } + + @Override + public boolean onMove(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder, @NonNull RecyclerView.ViewHolder target){ + int fromPosition=viewHolder.getAbsoluteAdapterPosition(); + int toPosition=target.getAbsoluteAdapterPosition(); + if(Math.max(fromPosition, toPosition)>=data.size() || Math.min(fromPosition, toPosition)<0){ + return false; + }else{ + Collections.swap(data, fromPosition, toPosition); + adapter.notifyItemMoved(fromPosition, toPosition); + saveTimelines(); + return true; + } + } + + @Override + public void onSelectedChanged(@Nullable RecyclerView.ViewHolder viewHolder, int actionState){ + if(actionState==ItemTouchHelper.ACTION_STATE_DRAG && viewHolder!=null){ + viewHolder.itemView.animate().alpha(0.65f); + } + } + + @Override + public void clearView(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder){ + super.clearView(recyclerView, viewHolder); + viewHolder.itemView.animate().alpha(1f); + } + + @Override + public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction){ + int position=viewHolder.getAbsoluteAdapterPosition(); + removeTimeline(position); + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/FavoritedStatusListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/FavoritedStatusListFragment.java index f0779e71a..068186b33 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/FavoritedStatusListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/FavoritedStatusListFragment.java @@ -1,9 +1,11 @@ package org.joinmastodon.android.fragments; import android.app.Activity; +import android.net.Uri; import org.joinmastodon.android.R; import org.joinmastodon.android.api.requests.statuses.GetFavoritedStatuses; +import org.joinmastodon.android.model.FilterContext; import org.joinmastodon.android.model.HeaderPaginationList; import org.joinmastodon.android.model.Status; @@ -25,6 +27,7 @@ public class FavoritedStatusListFragment extends StatusListFragment{ .setCallback(new SimpleCallback<>(this){ @Override public void onSuccess(HeaderPaginationList result){ + if(getActivity()==null) return; if(result.nextPageUri!=null) nextMaxID=result.nextPageUri.getQueryParameter("max_id"); else @@ -34,4 +37,16 @@ public class FavoritedStatusListFragment extends StatusListFragment{ }) .exec(accountID); } + + @Override + protected FilterContext getFilterContext() { + return FilterContext.ACCOUNT; + } + + @Override + public Uri getWebUri(Uri.Builder base) { + return base.encodedPath(isInstanceAkkoma() + ? '/' + getSession().self.username + "#favorites" + : "/favourites").build(); + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/FeaturedHashtagsListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/FeaturedHashtagsListFragment.java index 8851aa348..2e506e2cd 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/FeaturedHashtagsListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/FeaturedHashtagsListFragment.java @@ -2,6 +2,7 @@ package org.joinmastodon.android.fragments; import android.graphics.Canvas; import android.graphics.Paint; +import android.net.Uri; import android.os.Bundle; import android.view.View; @@ -55,4 +56,9 @@ public class FeaturedHashtagsListFragment extends BaseStatusListFragment implements ScrollableToTop, ProvidesAssistContent.ProvidesWebUri { + private String accountID; + private Map relationships=Collections.emptyMap(); + private GetAccountRelationships relationshipsRequest; + private String nextMaxID; + + public FollowRequestsListFragment(){ + super(20); + } + + @Override + public void onCreate(Bundle savedInstanceState){ + super.onCreate(savedInstanceState); + accountID=getArguments().getString("account"); + loadData(); + } + + @Override + public void onAttach(Activity activity) { + super.onAttach(activity); + setTitle(R.string.sk_follow_requests); + } + + @Override + protected void doLoadData(int offset, int count){ + if(relationshipsRequest!=null){ + relationshipsRequest.cancel(); + relationshipsRequest=null; + } + currentRequest=new GetFollowRequests(offset==0 ? null : nextMaxID, count) + .setCallback(new SimpleCallback<>(this){ + @Override + public void onSuccess(HeaderPaginationList result){ + if(getActivity()==null) return; + if(result.nextPageUri!=null) + nextMaxID=result.nextPageUri.getQueryParameter("max_id"); + else + nextMaxID=null; + onDataLoaded(result.stream().map(AccountWrapper::new).collect(Collectors.toList()), false); + loadRelationships(); + } + }) + .exec(accountID); + } + + @Override + protected RecyclerView.Adapter getAdapter(){ + return new AccountsAdapter(); + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState){ + super.onViewCreated(view, savedInstanceState); + list.addItemDecoration(new RecyclerView.ItemDecoration(){ + @Override + public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state){ + outRect.bottom=outRect.left=outRect.right=V.dp(16); + if(parent.getChildAdapterPosition(view)==0) + outRect.top=V.dp(16); + } + }); + ((UsableRecyclerView)list).setDrawSelectorOnTop(true); + } + + private void loadRelationships(){ + relationships=Collections.emptyMap(); + relationshipsRequest=new GetAccountRelationships(data.stream().map(fs->fs.account.id).collect(Collectors.toList())); + relationshipsRequest.setCallback(new Callback<>(){ + @Override + public void onSuccess(List result){ + relationshipsRequest=null; + relationships=result.stream().collect(Collectors.toMap(rel->rel.id, Function.identity())); + if(list==null) + return; + for(int i=0;i implements ImageLoaderRecyclerAdapter{ + + public AccountsAdapter(){ + super(imgLoader); + } + + @Override + public void onBindViewHolder(AccountViewHolder holder, int position){ + holder.bind(data.get(position)); + super.onBindViewHolder(holder, position); + } + + @NonNull + @Override + public AccountViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){ + return new AccountViewHolder(); + } + + @Override + public int getItemCount(){ + return data.size(); + } + + @Override + public int getImageCountForItem(int position){ + return 2+data.get(position).emojiHelper.getImageCount(); + } + + @Override + public ImageLoaderRequest getImageRequest(int position, int image){ + AccountWrapper item=data.get(position); + if(image==0) + return item.avaRequest; + else if(image==1) + return item.coverRequest; + else + return item.emojiHelper.getImageRequest(image-2); + } + } + + // literally the same as AccountCardStatusDisplayItem and DiscoverAccountsFragment. code should be generalized + private class AccountViewHolder extends BindableViewHolder implements ImageLoaderViewHolder, UsableRecyclerView.DisableableClickable{ + private final ImageView cover, avatar; + private final TextView name, username, bio, followersCount, followingCount, postsCount, followersLabel, followingLabel, postsLabel; + private final ProgressBarButton actionButton, acceptButton, rejectButton; + private final ProgressBar actionProgress, acceptProgress, rejectProgress; + private final View actionWrap, acceptWrap, rejectWrap; + + private Relationship relationship; + + public AccountViewHolder(){ + super(getActivity(), R.layout.item_discover_account, list); + cover=findViewById(R.id.cover); + avatar=findViewById(R.id.avatar); + name=findViewById(R.id.name); + username=findViewById(R.id.username); + bio=findViewById(R.id.bio); + followersCount=findViewById(R.id.followers_count); + followersLabel=findViewById(R.id.followers_label); + followingCount=findViewById(R.id.following_count); + followingLabel=findViewById(R.id.following_label); + postsCount=findViewById(R.id.posts_count); + postsLabel=findViewById(R.id.posts_label); + actionButton=findViewById(R.id.action_btn); + actionProgress=findViewById(R.id.action_progress); + actionWrap=findViewById(R.id.action_btn_wrap); + acceptButton=findViewById(R.id.accept_btn); + acceptProgress=findViewById(R.id.accept_progress); + acceptWrap=findViewById(R.id.accept_btn_wrap); + rejectButton=findViewById(R.id.reject_btn); + rejectProgress=findViewById(R.id.reject_progress); + rejectWrap=findViewById(R.id.reject_btn_wrap); + + avatar.setOutlineProvider(OutlineProviders.roundedRect(15)); + avatar.setClipToOutline(true); + View border=findViewById(R.id.avatar_border); + border.setOutlineProvider(OutlineProviders.roundedRect(17)); + border.setClipToOutline(true); + cover.setOutlineProvider(OutlineProviders.roundedRect(9)); + cover.setClipToOutline(true); + itemView.setOutlineProvider(OutlineProviders.roundedRect(12)); + itemView.setClipToOutline(true); + actionButton.setOnClickListener(this::onActionButtonClick); + acceptButton.setOnClickListener(this::onFollowRequestButtonClick); + rejectButton.setOnClickListener(this::onFollowRequestButtonClick); + itemView.setOnClickListener(v->this.onClick()); + } + + @Override + public boolean isEnabled(){ + return false; + } + + @Override + public void onBind(AccountWrapper item){ + name.setText(item.parsedName); + username.setText('@'+item.account.acct); + bio.setText(item.parsedBio); + followersCount.setText(UiUtils.abbreviateNumber(item.account.followersCount)); + followingCount.setText(UiUtils.abbreviateNumber(item.account.followingCount)); + postsCount.setText(UiUtils.abbreviateNumber(item.account.statusesCount)); + followersLabel.setText(getResources().getQuantityString(R.plurals.followers, (int)Math.min(999, item.account.followersCount))); + followingLabel.setText(getResources().getQuantityString(R.plurals.following, (int)Math.min(999, item.account.followingCount))); + postsLabel.setText(getResources().getQuantityString(R.plurals.sk_posts_count_label, (int)(item.account.statusesCount%1000), item.account.statusesCount)); + followersCount.setVisibility(item.account.followersCount < 0 ? View.GONE : View.VISIBLE); + followersLabel.setVisibility(item.account.followersCount < 0 ? View.GONE : View.VISIBLE); + followingCount.setVisibility(item.account.followingCount < 0 ? View.GONE : View.VISIBLE); + followingLabel.setVisibility(item.account.followingCount < 0 ? View.GONE : View.VISIBLE); + relationship=relationships.get(item.account.id); + UiUtils.setExtraTextInfo(getContext(), null, true, false, false, item.account); + + if(relationship==null || !relationship.followedBy){ + actionWrap.setVisibility(View.GONE); + acceptWrap.setVisibility(View.VISIBLE); + rejectWrap.setVisibility(View.VISIBLE); + + acceptButton.setCompoundDrawableTintList(acceptButton.getTextColors()); + acceptProgress.setIndeterminateTintList(acceptButton.getTextColors()); + rejectButton.setCompoundDrawableTintList(rejectButton.getTextColors()); + rejectProgress.setIndeterminateTintList(rejectButton.getTextColors()); + }else{ + actionWrap.setVisibility(View.VISIBLE); + acceptWrap.setVisibility(View.GONE); + rejectWrap.setVisibility(View.GONE); + UiUtils.setRelationshipToActionButtonM3(relationship, actionButton); + } + } + + @Override + public void setImage(int index, Drawable image){ + if(index==0){ + avatar.setImageDrawable(image); + }else if(index==1){ + cover.setImageDrawable(image); + }else{ + item.emojiHelper.setImageDrawable(index-2, image); + name.invalidate(); + bio.invalidate(); + } + if(image instanceof Animatable a && !a.isRunning()) + a.start(); + } + + @Override + public void clearImage(int index){ + setImage(index, null); + } + + @Override + public void onClick(){ + Bundle args=new Bundle(); + args.putString("account", accountID); + args.putParcelable("profileAccount", Parcels.wrap(item.account)); + Nav.go(getActivity(), ProfileFragment.class, args); + } + + private void onFollowRequestButtonClick(View v) { + itemView.setHasTransientState(true); + UiUtils.handleFollowRequest((Activity) v.getContext(), item.account, accountID, null, v == acceptButton, relationship, rel -> { + if(getContext()==null) return; + itemView.setHasTransientState(false); + relationships.put(item.account.id, rel); + RecyclerView.Adapter adapter = getBindingAdapter(); + if (!rel.requested && !rel.followedBy && adapter != null) { + data.remove(item); + adapter.notifyItemRemoved(getLayoutPosition()); + } else { + rebind(); + } + }); + } + + private void onActionButtonClick(View v){ + itemView.setHasTransientState(true); + UiUtils.performAccountAction(getActivity(), item.account, accountID, relationship, actionButton, this::setActionProgressVisible, rel->{ + if(getContext()==null) return; + itemView.setHasTransientState(false); + relationships.put(item.account.id, rel); + rebind(); + }); + } + + private void setActionProgressVisible(boolean visible){ + actionButton.setTextVisible(!visible); + actionProgress.setVisibility(visible ? View.VISIBLE : View.GONE); + actionButton.setClickable(!visible); + } + } + + protected class AccountWrapper{ + public Account account; + public ImageLoaderRequest avaRequest, coverRequest; + public CustomEmojiHelper emojiHelper=new CustomEmojiHelper(); + public CharSequence parsedName, parsedBio; + + public AccountWrapper(Account account){ + this.account=account; + avaRequest=new UrlImageLoaderRequest( + TextUtils.isEmpty(account.avatar) ? AccountSessionManager.get(getAccountID()).getDefaultAvatarUrl() : account.avatar, + V.dp(50), V.dp(50)); + if(!TextUtils.isEmpty(account.header)) + coverRequest=new UrlImageLoaderRequest(account.header, 1000, 1000); + parsedBio=HtmlParser.parse(account.note, account.emojis, Collections.emptyList(), Collections.emptyList(), accountID); + if(account.emojis.isEmpty()){ + parsedName= account.getDisplayName(); + }else{ + parsedName=HtmlParser.parseCustomEmoji(account.getDisplayName(), account.emojis); + emojiHelper.setText(new SpannableStringBuilder(parsedName).append(parsedBio)); + } + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/FollowedHashtagsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/FollowedHashtagsFragment.java new file mode 100644 index 000000000..22739fc6d --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/FollowedHashtagsFragment.java @@ -0,0 +1,128 @@ +package org.joinmastodon.android.fragments; + +import android.net.Uri; +import android.os.Bundle; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.api.requests.tags.GetFollowedHashtags; +import org.joinmastodon.android.model.Hashtag; +import org.joinmastodon.android.model.HeaderPaginationList; +import org.joinmastodon.android.ui.DividerItemDecoration; +import org.joinmastodon.android.ui.utils.UiUtils; +import org.joinmastodon.android.utils.ProvidesAssistContent; + +import me.grishka.appkit.api.SimpleCallback; +import me.grishka.appkit.utils.BindableViewHolder; +import me.grishka.appkit.views.UsableRecyclerView; + +public class FollowedHashtagsFragment extends MastodonRecyclerFragment implements ScrollableToTop, ProvidesAssistContent.ProvidesWebUri { + private String nextMaxID; + private String accountID; + + public FollowedHashtagsFragment() { + super(20); + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + Bundle args=getArguments(); + accountID=args.getString("account"); + setTitle(R.string.sk_hashtags_you_follow); + } + + @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); + list.addItemDecoration(new DividerItemDecoration(getActivity(), R.attr.colorM3OutlineVariant, 0.5f, 56, 16)); + } + + @Override + protected void doLoadData(int offset, int count){ + currentRequest=new GetFollowedHashtags(offset==0 ? null : nextMaxID, null, count, null) + .setCallback(new SimpleCallback<>(this){ + @Override + public void onSuccess(HeaderPaginationList result){ + if(getActivity()==null) return; + if(result.nextPageUri!=null) + nextMaxID=result.nextPageUri.getQueryParameter("max_id"); + else + nextMaxID=null; + onDataLoaded(result, nextMaxID!=null); + } + }) + .exec(accountID); + } + + @Override + protected RecyclerView.Adapter getAdapter() { + return new HashtagsAdapter(); + } + + + @Override + public void scrollToTop() { + smoothScrollRecyclerViewToTop(list); + } + + @Override + public String getAccountID() { + return accountID; + } + + @Override + public Uri getWebUri(Uri.Builder base) { + return isInstanceAkkoma() ? null : base.path("/followed_tags").build(); + } + + private class HashtagsAdapter extends RecyclerView.Adapter{ + @NonNull + @Override + public HashtagViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){ + return new HashtagViewHolder(); + } + + @Override + public void onBindViewHolder(@NonNull HashtagViewHolder holder, int position) { + holder.bind(data.get(position)); + } + + @Override + public int getItemCount() { + return data.size(); + } + } + + private class HashtagViewHolder extends BindableViewHolder implements UsableRecyclerView.Clickable{ + private final TextView title; + + public HashtagViewHolder(){ + super(getActivity(), R.layout.item_text, list); + title=findViewById(R.id.title); + } + + @Override + public void onBind(Hashtag item) { + title.setText(item.name); + title.setCompoundDrawablesRelativeWithIntrinsicBounds(itemView.getContext().getDrawable(R.drawable.ic_fluent_number_symbol_24_regular), null, null, null); + } + + @Override + public void onClick() { + UiUtils.openHashtagTimeline(getActivity(), accountID, item.name); + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/HasAccountID.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/HasAccountID.java new file mode 100644 index 000000000..9934b2e17 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HasAccountID.java @@ -0,0 +1,32 @@ +package org.joinmastodon.android.fragments; + +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.model.Instance; + +import java.util.Optional; + +public interface HasAccountID { + String getAccountID(); + + default AccountSession getSession() { + return AccountSessionManager.getInstance().getAccount(getAccountID()); + } + + default boolean isInstanceAkkoma() { + return getInstance().map(Instance::isAkkoma).orElse(false); + } + + default boolean isInstancePixelfed() { + return getInstance().map(Instance::isPixelfed).orElse(false); + } + + default Optional getInstance() { + return getSession().getInstance(); + } + + default AccountLocalPreferences getLocalPrefs() { + return AccountSessionManager.get(getAccountID()).getLocalPreferences(); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/HasElevationOnScrollListener.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/HasElevationOnScrollListener.java new file mode 100644 index 000000000..0a110af44 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HasElevationOnScrollListener.java @@ -0,0 +1,7 @@ +package org.joinmastodon.android.fragments; + +import org.joinmastodon.android.utils.ElevationOnScrollListener; + +public interface HasElevationOnScrollListener { + ElevationOnScrollListener getElevationOnScrollListener(); +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/HasFab.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/HasFab.java new file mode 100644 index 000000000..937e64757 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HasFab.java @@ -0,0 +1,10 @@ +package org.joinmastodon.android.fragments; + +import android.view.View; + +public interface HasFab { + View getFab(); + void showFab(); + void hideFab(); + boolean isScrolling(); +} 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 542bf5ee2..b049673ff 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/HashtagTimelineFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HashtagTimelineFragment.java @@ -2,8 +2,10 @@ package org.joinmastodon.android.fragments; import android.app.Activity; import android.content.res.TypedArray; +import android.net.Uri; import android.os.Bundle; import android.text.SpannableStringBuilder; +import android.view.HapticFeedbackConstants; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; @@ -12,52 +14,82 @@ import android.view.ViewGroup; import android.widget.ImageButton; 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; import org.joinmastodon.android.model.Status; +import org.joinmastodon.android.model.TimelineDefinition; import org.joinmastodon.android.ui.text.SpacerSpan; +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; import me.grishka.appkit.api.SimpleCallback; -import me.grishka.appkit.utils.CubicBezierInterpolator; import me.grishka.appkit.utils.MergeRecyclerAdapter; import me.grishka.appkit.utils.SingleViewRecyclerAdapter; import me.grishka.appkit.utils.V; -public class HashtagTimelineFragment extends StatusListFragment{ +public class HashtagTimelineFragment extends PinnableStatusListFragment{ private Hashtag hashtag; private String hashtagName; - private ImageButton fab; private TextView headerTitle, headerSubtitle; private ProgressBarButton followButton; private ProgressBar followProgress; - private MenuItem followMenuItem; + private MenuItem followMenuItem, pinMenuItem, muteMenuItem; private boolean followRequestRunning; private boolean toolbarContentVisible; private String maxID; - public HashtagTimelineFragment(){ - setListLayoutId(R.layout.recycler_fragment_with_fab); + private List any; + private List all; + private List none; + private boolean following; + private boolean localOnly; + private Menu optionsMenu; + private MenuInflater optionsMenuInflater; + + private Optional filter = Optional.empty(); + + @Override + protected boolean wantsComposeButton() { + return true; } @Override public void onAttach(Activity activity){ super.onAttach(activity); + following=getArguments().getBoolean("following", false); + localOnly=getArguments().getBoolean("localOnly", false); + any=getArguments().getStringArrayList("any"); + all=getArguments().getStringArrayList("all"); + none=getArguments().getStringArrayList("none"); if(getArguments().containsKey("hashtag")){ hashtag=Parcels.unwrap(getArguments().getParcelable("hashtag")); hashtagName=hashtag.name; @@ -68,16 +100,71 @@ public class HashtagTimelineFragment extends StatusListFragment{ setHasOptionsMenu(true); } + private void updateMuteState(boolean newMute) { + muteMenuItem.setTitle(getString(newMute ? R.string.unmute_user : R.string.mute_user, "#" + hashtag)); + 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); + } + @Override protected void doLoadData(int offset, int count){ - currentRequest=new GetHashtagTimeline(hashtagName, offset==0 ? null : maxID, null, count) + currentRequest=new GetHashtagTimeline(hashtagName, getMaxID(), null, count, any, all, none, localOnly, getLocalPrefs().timelineReplyVisibility) .setCallback(new SimpleCallback<>(this){ @Override public void onSuccess(List result){ - if(!result.isEmpty()) - maxID=result.get(result.size()-1).id; - AccountSessionManager.get(accountID).filterStatuses(result, FilterContext.PUBLIC); - onDataLoaded(result, !result.isEmpty()); + if(getActivity()==null) return; + boolean more=applyMaxID(result); + AccountSessionManager.get(accountID).filterStatuses(result, getFilterContext()); + onDataLoaded(result, more); } }) .exec(accountID); @@ -102,6 +189,8 @@ public class HashtagTimelineFragment extends StatusListFragment{ 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){ @@ -112,14 +201,19 @@ public class HashtagTimelineFragment extends StatusListFragment{ boolean newToolbarVisibility=newAlpha>0.5f; if(newToolbarVisibility!=toolbarContentVisible){ toolbarContentVisible=newToolbarVisibility; - if(followMenuItem!=null) - followMenuItem.setVisible(toolbarContentVisible); + createOptionsMenu(); } } }); } - private void onFabClick(View v){ + @Override + public boolean onFabLongClick(View v) { + return UiUtils.pickAccountForCompose(getActivity(), accountID, '#'+hashtagName+' '); + } + + @Override + public void onFabClick(View v){ Bundle args=new Bundle(); args.putString("account", accountID); args.putString("prefilledText", '#'+hashtagName+' '); @@ -131,6 +225,16 @@ public class HashtagTimelineFragment extends StatusListFragment{ ((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((isInstanceAkkoma() ? "/tag/" : "/tags/") + hashtag).build(); + } + @Override protected RecyclerView.Adapter getAdapter(){ View header=getActivity().getLayoutInflater().inflate(R.layout.header_hashtag_timeline, list, false); @@ -146,10 +250,33 @@ public class HashtagTimelineFragment extends StatusListFragment{ return; setFollowed(!hashtag.following); }); + followButton.setOnLongClickListener(v->{ + if(hashtag==null) return false; + UiUtils.pickAccount(getActivity(), accountID, R.string.sk_follow_as, R.drawable.ic_fluent_person_add_28_regular, session -> { + new SetTagFollowed(hashtagName, true).setCallback(new Callback<>(){ + @Override + public void onSuccess(Hashtag hashtag) { + Toast.makeText( + getActivity(), + getString(R.string.sk_followed_as, session.self.getShortUsername()), + Toast.LENGTH_SHORT + ).show(); + } + + @Override + public void onError(ErrorResponse error) { + error.showToast(getActivity()); + } + }).exec(session.getID()); + }, null); + return true; + }); updateHeader(); MergeRecyclerAdapter mergeAdapter=new MergeRecyclerAdapter(); - mergeAdapter.addAdapter(new SingleViewRecyclerAdapter(header)); + if(!(getParentFragment() instanceof HomeTabFragment)){ + mergeAdapter.addAdapter(new SingleViewRecyclerAdapter(header)); + } mergeAdapter.addAdapter(super.getAdapter()); return mergeAdapter; } @@ -159,16 +286,55 @@ public class HashtagTimelineFragment extends StatusListFragment{ return 1; } + private void createOptionsMenu(){ + optionsMenu.clear(); + optionsMenuInflater.inflate(R.menu.hashtag_timeline, optionsMenu); + 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); + super.updatePinButton(pinMenuItem); + + 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 + public void updatePinButton(MenuItem pin){ + super.updatePinButton(pin); + if(toolbarContentVisible) UiUtils.insetPopupMenuIcon(getContext(), pin); + } + @Override public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){ - followMenuItem=menu.add(getString(hashtag!=null && hashtag.following ? R.string.unfollow_user : R.string.follow_user, "#"+hashtagName)); - followMenuItem.setVisible(toolbarContentVisible); + inflater.inflate(R.menu.hashtag_timeline, menu); + super.onCreateOptionsMenu(menu, inflater); + optionsMenu=menu; + optionsMenuInflater=inflater; + createOptionsMenu(); } @Override public boolean onOptionsItemSelected(MenuItem item){ - if(hashtag!=null){ + 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; } @@ -177,8 +343,7 @@ public class HashtagTimelineFragment extends StatusListFragment{ protected void onUpdateToolbar(){ super.onUpdateToolbar(); toolbarTitleView.setAlpha(toolbarContentVisible ? 1f : 0f); - if(followMenuItem!=null) - followMenuItem.setVisible(toolbarContentVisible); + createOptionsMenu(); } private void updateHeader(){ @@ -224,6 +389,11 @@ public class HashtagTimelineFragment extends StatusListFragment{ followProgress.setVisibility(View.GONE); 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); + } + 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); } } @@ -247,6 +417,7 @@ public class HashtagTimelineFragment extends StatusListFragment{ private void setFollowed(boolean followed){ if(followRequestRunning) return; + getToolbar().performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK); followButton.setTextVisible(false); followProgress.setVisibility(View.VISIBLE); followRequestRunning=true; 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 ed01e8884..78b8a3394 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeFragment.java @@ -3,8 +3,13 @@ package org.joinmastodon.android.fragments; import android.annotation.SuppressLint; import android.app.Fragment; import android.app.NotificationManager; +import android.app.assist.AssistContent; +import android.graphics.drawable.RippleDrawable; +import android.content.Intent; +import android.graphics.Outline; import android.os.Build; import android.os.Bundle; +import android.service.notification.StatusBarNotification; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -15,11 +20,13 @@ import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.TextView; +import androidx.annotation.IdRes; +import androidx.annotation.Nullable; + import com.squareup.otto.Subscribe; -import org.joinmastodon.android.BuildConfig; import org.joinmastodon.android.E; -import org.joinmastodon.android.PushNotificationReceiver; +import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.R; import org.joinmastodon.android.api.session.AccountSession; import org.joinmastodon.android.api.session.AccountSessionManager; @@ -30,18 +37,17 @@ import org.joinmastodon.android.fragments.onboarding.OnboardingFollowSuggestions import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.Notification; import org.joinmastodon.android.model.PaginatedResponse; -import org.joinmastodon.android.ui.sheets.AccountSwitcherSheet; +import org.joinmastodon.android.ui.AccountSwitcherSheet; import org.joinmastodon.android.ui.OutlineProviders; import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.ui.views.TabBar; import org.joinmastodon.android.utils.ObjectIdComparator; +import org.joinmastodon.android.utils.ProvidesAssistContent; import org.parceler.Parcels; import java.util.ArrayList; import java.util.List; -import androidx.annotation.IdRes; -import androidx.annotation.Nullable; import me.grishka.appkit.FragmentStackActivity; import me.grishka.appkit.Nav; import me.grishka.appkit.api.Callback; @@ -54,11 +60,11 @@ import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest; import me.grishka.appkit.utils.V; import me.grishka.appkit.views.FragmentRootLinearLayout; -public class HomeFragment extends AppKitFragment implements OnBackPressedListener{ +public class HomeFragment extends AppKitFragment implements OnBackPressedListener, ProvidesAssistContent, HasAccountID { private FragmentRootLinearLayout content; - private HomeTimelineFragment homeTimelineFragment; - private NotificationsListFragment notificationsFragment; - private DiscoverFragment searchFragment; + private HomeTabFragment homeTabFragment; + private NotificationsFragment notificationsFragment; + private DiscoverFragment discoverFragment; private ProfileFragment profileFragment; private TabBar tabBar; private View tabBarWrap; @@ -73,7 +79,7 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); accountID=getArguments().getString("account"); - setTitle(R.string.app_name); + setTitle(R.string.mo_app_name); if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N) setRetainInstance(true); @@ -81,13 +87,13 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene if(savedInstanceState==null){ Bundle args=new Bundle(); args.putString("account", accountID); - homeTimelineFragment=new HomeTimelineFragment(); - homeTimelineFragment.setArguments(args); + homeTabFragment=new HomeTabFragment(); + homeTabFragment.setArguments(args); args=new Bundle(args); args.putBoolean("noAutoLoad", true); - searchFragment=new DiscoverFragment(); - searchFragment.setArguments(args); - notificationsFragment=new NotificationsListFragment(); + discoverFragment=new DiscoverFragment(); + discoverFragment.setArguments(args); + notificationsFragment=new NotificationsFragment(); notificationsFragment.setArguments(args); args=new Bundle(args); args.putParcelable("profileAccount", Parcels.wrap(AccountSessionManager.getInstance().getAccount(accountID).self)); @@ -112,7 +118,7 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene content.setOrientation(LinearLayout.VERTICAL); FrameLayout fragmentContainer=new FrameLayout(getActivity()); - fragmentContainer.setId(R.id.fragment_wrap); + fragmentContainer.setId(me.grishka.appkit.R.id.fragment_wrap); content.addView(fragmentContainer, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)); inflater.inflate(R.layout.tab_bar, content); @@ -120,6 +126,34 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene tabBar.setListeners(this::onTabSelected, this::onTabLongClick); tabBarWrap=content.findViewById(R.id.tabbar_wrap); + // this one's for the pill haters (https://m3.material.io/components/navigation-bar/overview) + if(GlobalUserPreferences.disableM3PillActiveIndicator){ + tabBar.findViewById(R.id.tab_home_pill).setBackground(null); + tabBar.findViewById(R.id.tab_search_pill).setBackground(null); + tabBar.findViewById(R.id.tab_notifications_pill).setBackground(null); + tabBar.findViewById(R.id.tab_profile_pill).setBackgroundResource(R.drawable.bg_tab_profile); + + View[] tabs={ + tabBar.findViewById(R.id.tab_home), + tabBar.findViewById(R.id.tab_search), + tabBar.findViewById(R.id.tab_notifications), + tabBar.findViewById(R.id.tab_profile) + }; + + for(View tab : tabs){ + tab.setBackgroundResource(R.drawable.bg_tabbar_tab_ripple); + ((RippleDrawable) tab.getBackground()) + .setRadius(V.dp(GlobalUserPreferences.showNavigationLabels ? 56 : 42)); + } + } + + if(!GlobalUserPreferences.showNavigationLabels){ + tabBar.findViewById(R.id.tab_home_label).setVisibility(View.GONE); + tabBar.findViewById(R.id.tab_search_label).setVisibility(View.GONE); + tabBar.findViewById(R.id.tab_notifications_label).setVisibility(View.GONE); + tabBar.findViewById(R.id.tab_profile_label).setVisibility(View.GONE); + } + tabBarAvatar=tabBar.findViewById(R.id.tab_profile_ava); tabBarAvatar.setOutlineProvider(OutlineProviders.OVAL); tabBarAvatar.setClipToOutline(true); @@ -131,10 +165,10 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene if(savedInstanceState==null){ getChildFragmentManager().beginTransaction() - .add(R.id.fragment_wrap, homeTimelineFragment) - .add(R.id.fragment_wrap, searchFragment).hide(searchFragment) - .add(R.id.fragment_wrap, notificationsFragment).hide(notificationsFragment) - .add(R.id.fragment_wrap, profileFragment).hide(profileFragment) + .add(me.grishka.appkit.R.id.fragment_wrap, homeTabFragment) + .add(me.grishka.appkit.R.id.fragment_wrap, discoverFragment).hide(discoverFragment) + .add(me.grishka.appkit.R.id.fragment_wrap, notificationsFragment).hide(notificationsFragment) + .add(me.grishka.appkit.R.id.fragment_wrap, profileFragment).hide(profileFragment) .commit(); String defaultTab=getArguments().getString("tab"); @@ -158,18 +192,17 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene @Override public void onViewStateRestored(Bundle savedInstanceState){ super.onViewStateRestored(savedInstanceState); - if(savedInstanceState==null || homeTimelineFragment!=null) - return; - homeTimelineFragment=(HomeTimelineFragment) getChildFragmentManager().getFragment(savedInstanceState, "homeTimelineFragment"); - searchFragment=(DiscoverFragment) getChildFragmentManager().getFragment(savedInstanceState, "searchFragment"); - notificationsFragment=(NotificationsListFragment) getChildFragmentManager().getFragment(savedInstanceState, "notificationsFragment"); + if(savedInstanceState==null) return; + homeTabFragment=(HomeTabFragment) getChildFragmentManager().getFragment(savedInstanceState, "homeTabFragment"); + discoverFragment=(DiscoverFragment) getChildFragmentManager().getFragment(savedInstanceState, "searchFragment"); + notificationsFragment=(NotificationsFragment) getChildFragmentManager().getFragment(savedInstanceState, "notificationsFragment"); profileFragment=(ProfileFragment) getChildFragmentManager().getFragment(savedInstanceState, "profileFragment"); currentTab=savedInstanceState.getInt("selectedTab"); tabBar.selectTab(currentTab); Fragment current=fragmentForTab(currentTab); getChildFragmentManager().beginTransaction() - .hide(homeTimelineFragment) - .hide(searchFragment) + .hide(homeTabFragment) + .hide(discoverFragment) .hide(notificationsFragment) .hide(profileFragment) .show(current) @@ -203,17 +236,17 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene super.onApplyWindowInsets(insets.replaceSystemWindowInsets(insets.getSystemWindowInsetLeft(), 0, insets.getSystemWindowInsetRight(), insets.getSystemWindowInsetBottom())); } WindowInsets topOnlyInsets=insets.replaceSystemWindowInsets(0, insets.getSystemWindowInsetTop(), 0, 0); - homeTimelineFragment.onApplyWindowInsets(topOnlyInsets); - searchFragment.onApplyWindowInsets(topOnlyInsets); + homeTabFragment.onApplyWindowInsets(topOnlyInsets); + discoverFragment.onApplyWindowInsets(topOnlyInsets); notificationsFragment.onApplyWindowInsets(topOnlyInsets); profileFragment.onApplyWindowInsets(topOnlyInsets); } private Fragment fragmentForTab(@IdRes int tab){ if(tab==R.id.tab_home){ - return homeTimelineFragment; + return homeTabFragment; }else if(tab==R.id.tab_search){ - return searchFragment; + return discoverFragment; }else if(tab==R.id.tab_notifications){ return notificationsFragment; }else if(tab==R.id.tab_profile){ @@ -232,12 +265,15 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene private void onTabSelected(@IdRes int tab){ Fragment newFragment=fragmentForTab(tab); if(tab==currentTab){ - if(newFragment instanceof ScrollableToTop scrollable) + 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(); maybeTriggerLoading(newFragment); + if (newFragment instanceof HasFab fabulous && !fabulous.isScrolling()) fabulous.showFab(); currentTab=tab; ((FragmentStackActivity)getActivity()).invalidateSystemBarColors(this); } @@ -248,10 +284,14 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene lf.loadData(); }else if(newFragment instanceof DiscoverFragment){ ((DiscoverFragment) newFragment).loadData(); - } - if(newFragment instanceof NotificationsListFragment){ + }else if(newFragment instanceof NotificationsFragment){ + ((NotificationsFragment) newFragment).loadData(); NotificationManager nm=getActivity().getSystemService(NotificationManager.class); - nm.cancel(accountID, PushNotificationReceiver.NOTIFICATION_ID); + for (StatusBarNotification notification : nm.getActiveNotifications()) { + if (accountID.equals(notification.getTag())) { + nm.cancel(accountID, notification.getId()); + } + } } } @@ -259,12 +299,20 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene if(tab==R.id.tab_profile){ ArrayList options=new ArrayList<>(); for(AccountSession session:AccountSessionManager.getInstance().getLoggedInAccounts()){ - options.add(session.self.displayName+"\n("+session.self.username+"@"+session.domain+")"); + options.add(session.self.getDisplayName()+"\n("+session.self.username+"@"+session.domain+")"); } new AccountSwitcherSheet(getActivity(), this).show(); return true; } - if(tab==R.id.tab_home && BuildConfig.DEBUG){ + 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; + } + if(tab==R.id.tab_home){ Bundle args=new Bundle(); args.putString("account", accountID); Nav.go(getActivity(), OnboardingFollowSuggestionsFragment.class, args); @@ -275,20 +323,26 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene @Override public boolean onBackPressed(){ if(currentTab==R.id.tab_profile) - return profileFragment.onBackPressed(); + if (profileFragment.onBackPressed()) return true; if(currentTab==R.id.tab_search) - return searchFragment.onBackPressed(); - return false; + if (discoverFragment.onBackPressed()) return true; + if (currentTab!=R.id.tab_home) { + tabBar.selectTab(R.id.tab_home); + onTabSelected(R.id.tab_home); + return true; + } else { + return homeTabFragment.onBackPressed(); + } } @Override public void onSaveInstanceState(Bundle outState){ super.onSaveInstanceState(outState); outState.putInt("selectedTab", currentTab); - getChildFragmentManager().putFragment(outState, "homeTimelineFragment", homeTimelineFragment); - getChildFragmentManager().putFragment(outState, "searchFragment", searchFragment); - getChildFragmentManager().putFragment(outState, "notificationsFragment", notificationsFragment); - getChildFragmentManager().putFragment(outState, "profileFragment", profileFragment); + if (homeTabFragment.isAdded()) getChildFragmentManager().putFragment(outState, "homeTabFragment", homeTabFragment); + if (discoverFragment.isAdded()) getChildFragmentManager().putFragment(outState, "searchFragment", discoverFragment); + if (notificationsFragment.isAdded()) getChildFragmentManager().putFragment(outState, "notificationsFragment", notificationsFragment); + if (profileFragment.isAdded()) getChildFragmentManager().putFragment(outState, "profileFragment", profileFragment); } @Override @@ -297,7 +351,7 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene reloadNotificationsForUnreadCount(); } - private void reloadNotificationsForUnreadCount(){ + public void reloadNotificationsForUnreadCount(){ List[] notifications=new List[]{null}; String[] marker={null}; @@ -308,7 +362,7 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene } }); - AccountSessionManager.get(accountID).getCacheController().getNotifications(null, 40, false, true, new Callback<>(){ + AccountSessionManager.get(accountID).getCacheController().getNotifications(null, 40, false, false, true, new Callback<>(){ @Override public void onSuccess(PaginatedResponse> result){ notifications[0]=result.items; @@ -324,9 +378,9 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene @SuppressLint("DefaultLocale") private void updateUnreadCount(List notifications, String marker){ if(notifications.isEmpty() || ObjectIdComparator.INSTANCE.compare(notifications.get(0).id, marker)<=0){ - notificationsBadge.setVisibility(View.GONE); + V.setVisibilityAnimated(notificationsBadge, View.GONE); }else{ - notificationsBadge.setVisibility(View.VISIBLE); + V.setVisibilityAnimated(notificationsBadge, View.VISIBLE); if(ObjectIdComparator.INSTANCE.compare(notifications.get(notifications.size()-1).id, marker)>0){ notificationsBadge.setText(String.format("%d+", notifications.size())); }else{ @@ -346,16 +400,28 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene if(!ev.accountID.equals(accountID)) return; if(ev.clearUnread) - notificationsBadge.setVisibility(View.GONE); + V.setVisibilityAnimated(notificationsBadge, View.GONE); } @Subscribe public void onStatusDisplaySettingsChanged(StatusDisplaySettingsChangedEvent ev){ if(!ev.accountID.equals(accountID)) return; - if(homeTimelineFragment.loaded) + if(homeTabFragment.getCurrentFragment() instanceof LoaderFragment lf && lf.loaded + && lf instanceof BaseStatusListFragment homeTimelineFragment) homeTimelineFragment.rebuildAllDisplayItems(); - if(notificationsFragment.loaded) - notificationsFragment.rebuildAllDisplayItems(); + if(notificationsFragment.getCurrentFragment() instanceof LoaderFragment lf && lf.loaded + && lf instanceof BaseStatusListFragment l) + l.rebuildAllDisplayItems(); + } + + @Override + public String getAccountID() { + return accountID; + } + + @Override + public void onProvideAssistContent(AssistContent assistContent) { + callFragmentToProvideAssistContent(fragmentForTab(currentTab), assistContent); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTabFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTabFragment.java new file mode 100644 index 000000000..42aacbd6b --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTabFragment.java @@ -0,0 +1,777 @@ +package org.joinmastodon.android.fragments; + +import static org.joinmastodon.android.GlobalUserPreferences.reduceMotion; + +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.Fragment; +import android.app.FragmentTransaction; +import android.app.assist.AssistContent; +import android.content.Context; +import android.content.res.Configuration; +import android.os.Build; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.SubMenu; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewParent; +import android.view.ViewTreeObserver; +import android.widget.Button; +import android.widget.FrameLayout; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.PopupMenu; +import android.widget.TextView; +import android.widget.Toolbar; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; +import androidx.viewpager2.widget.ViewPager2; + +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.announcements.GetAnnouncements; +import org.joinmastodon.android.api.requests.lists.GetLists; +import org.joinmastodon.android.api.requests.tags.GetFollowedHashtags; +import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.events.HashtagUpdatedEvent; +import org.joinmastodon.android.events.ListDeletedEvent; +import org.joinmastodon.android.events.ListUpdatedCreatedEvent; +import org.joinmastodon.android.events.SelfUpdateStateChangedEvent; +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.TimelineDefinition; +import org.joinmastodon.android.ui.SimpleViewHolder; +import org.joinmastodon.android.ui.utils.UiUtils; +import org.joinmastodon.android.updater.GithubSelfUpdater; +import org.joinmastodon.android.utils.ElevationOnScrollListener; +import org.joinmastodon.android.utils.ProvidesAssistContent; + +import java.util.Collection; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Predicate; +import java.util.function.Supplier; + +import me.grishka.appkit.Nav; +import me.grishka.appkit.api.Callback; +import me.grishka.appkit.api.ErrorResponse; +import me.grishka.appkit.fragments.BaseRecyclerFragment; +import me.grishka.appkit.fragments.OnBackPressedListener; +import me.grishka.appkit.utils.CubicBezierInterpolator; +import me.grishka.appkit.utils.V; +import me.grishka.appkit.views.FragmentRootLinearLayout; + +public class HomeTabFragment extends MastodonToolbarFragment implements ScrollableToTop, OnBackPressedListener, HasFab, ProvidesAssistContent, HasElevationOnScrollListener { + private static final int ANNOUNCEMENTS_RESULT = 654; + + private String accountID; + private MenuItem announcements, announcementsAction, settings, settingsAction; +// private ImageView toolbarLogo; + private Button toolbarShowNewPostsBtn; + private boolean newPostsBtnShown; + private AnimatorSet currentNewPostsAnim; + private ViewPager2 pager; + private View switcher; + private FrameLayout toolbarFrame; + private ImageView timelineIcon; + private ImageView collapsedChevron; + private TextView timelineTitle; + private PopupMenu switcherPopup; + private final Map listItems = new HashMap<>(); + private final Map hashtagsItems = new HashMap<>(); + private List timelinesList; + private int count; + private Fragment[] fragments; + private FrameLayout[] tabViews; + private TimelineDefinition[] timelines; + private final Map timelinesByMenuItem = new HashMap<>(); + private SubMenu hashtagsMenu, listsMenu; + private PopupMenu overflowPopup; + private View overflowActionView = null; + private boolean announcementsBadged, settingsBadged; + private ImageButton fab; + private ElevationOnScrollListener elevationOnScrollListener; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + E.register(this); + accountID = getArguments().getString("account"); + timelinesList=AccountSessionManager.get(accountID).getLocalPreferences().timelines; + assert timelinesList!=null; + if(timelinesList.isEmpty()) timelinesList=List.of(TimelineDefinition.HOME_TIMELINE); + count=timelinesList.size(); + fragments=new Fragment[count]; + tabViews=new FrameLayout[count]; + timelines=new TimelineDefinition[count]; + if(GlobalUserPreferences.toolbarMarquee){ + setTitleMarqueeEnabled(false); + setSubtitleMarqueeEnabled(false); + } + } + + @Override + public void onAttach(Activity activity) { + super.onAttach(activity); + setHasOptionsMenu(true); + } + + @Override + public View onCreateContentView(LayoutInflater inflater, ViewGroup container, Bundle bundle) { + FragmentRootLinearLayout rootView = new FragmentRootLinearLayout(getContext()); + rootView.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); + FrameLayout view = new FrameLayout(getContext()); + view.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); + rootView.addView(view); + inflater.inflate(R.layout.compose_fab, view); + fab = view.findViewById(R.id.fab); + fab.setOnClickListener(this::onFabClick); + fab.setOnLongClickListener(this::onFabLongClick); + pager = new ViewPager2(getContext()); + toolbarFrame = (FrameLayout) LayoutInflater.from(getContext()).inflate(R.layout.home_toolbar, getToolbar(), false); + + if (fragments[0] == null) { + Bundle args = new Bundle(); + args.putString("account", accountID); + args.putBoolean("__is_tab", true); + args.putBoolean("__disable_fab", true); + args.putBoolean("onlyPosts", true); + + for (int i=0; i < timelinesList.size(); i++) { + TimelineDefinition tl = timelinesList.get(i); + fragments[i] = tl.getFragment(); + timelines[i] = tl; + } + + FragmentTransaction transaction = getChildFragmentManager().beginTransaction(); + for (int i = 0; i < count; i++) { + fragments[i].setArguments(timelines[i].populateArguments(new Bundle(args))); + FrameLayout tabView = new FrameLayout(getActivity()); + tabView.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); + tabView.setVisibility(View.GONE); + tabView.setId(i + 1); + transaction.add(i + 1, fragments[i]); + view.addView(tabView); + tabViews[i] = tabView; + } + transaction.commit(); + } + + view.addView(pager, new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); + + overflowActionView = UiUtils.makeOverflowActionView(getContext()); + overflowPopup = new PopupMenu(getContext(), overflowActionView); + overflowPopup.setOnMenuItemClickListener(this::onOptionsItemSelected); + overflowActionView.setOnClickListener(l -> overflowPopup.show()); + overflowActionView.setOnTouchListener(overflowPopup.getDragToOpenListener()); + + return rootView; + } + + @SuppressLint("ClickableViewAccessibility") + @Override + public void onViewCreated(View view, Bundle savedInstanceState){ + super.onViewCreated(view, savedInstanceState); + + timelineIcon = toolbarFrame.findViewById(R.id.timeline_icon); + timelineTitle = toolbarFrame.findViewById(R.id.timeline_title); + collapsedChevron = toolbarFrame.findViewById(R.id.collapsed_chevron); + switcher = toolbarFrame.findViewById(R.id.switcher_btn); + switcherPopup = new PopupMenu(getContext(), switcher); + switcherPopup.setOnMenuItemClickListener(this::onSwitcherItemSelected); + UiUtils.enablePopupMenuIcons(getContext(), switcherPopup); + switcher.setOnClickListener(v->switcherPopup.show()); + switcher.setOnTouchListener(switcherPopup.getDragToOpenListener()); + updateSwitcherMenu(); + + UiUtils.reduceSwipeSensitivity(pager); + pager.setUserInputEnabled(!GlobalUserPreferences.disableSwipe); + pager.setAdapter(new HomePagerAdapter()); + pager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() { + @Override + public void onPageSelected(int position){ + if (!reduceMotion) { + // setting this here because page transformer appears to fire too late so the + // animation can appear bumpy, especially when navigating to a further-away tab + switcher.setScaleY(0.85f); + switcher.setScaleX(0.85f); + switcher.setAlpha(0.65f); + } + updateSwitcherIcon(position); + if (!timelines[position].equals(TimelineDefinition.HOME_TIMELINE)) hideNewPostsButton(); + if (fragments[position] instanceof BaseRecyclerFragment page){ + if(!page.loaded && !page.isDataLoading()) page.loadData(); + } + } + }); + + if (!reduceMotion) { + pager.setPageTransformer((v, pos) -> { + if (reduceMotion || tabViews[pager.getCurrentItem()] != v) return; + float scaleFactor = Math.max(0.85f, 1 - Math.abs(pos) * 0.06f); + switcher.setScaleY(scaleFactor); + switcher.setScaleX(scaleFactor); + switcher.setAlpha(Math.max(0.65f, 1 - Math.abs(pos))); + }); + } + + updateToolbarLogo(); + + ViewTreeObserver vto = getToolbar().getViewTreeObserver(); + if (vto.isAlive()) { + vto.addOnGlobalLayoutListener(()->{ + Toolbar t=getToolbar(); + if(t==null) return; + int toolbarWidth=t.getWidth(); + if(toolbarWidth==0) return; + + int toolbarFrameWidth=toolbarFrame.getWidth(); + int actionsWidth=toolbarWidth-toolbarFrameWidth; + // margin (4) + padding (12) + icon (24) + margin (8) + chevron (16) + padding (12) + int switcherWidth=V.dp(76); + FrameLayout parent=((FrameLayout) toolbarShowNewPostsBtn.getParent()); + if(actionsWidth==parent.getPaddingStart()) return; + int paddingMax=Math.max(actionsWidth, switcherWidth); + int paddingEnd=(Math.max(0, switcherWidth-actionsWidth)); + + // toolbar frame goes from screen edge to beginning of right-aligned option buttons. + // centering button by applying the same space on the left + parent.setPaddingRelative(paddingMax, 0, paddingEnd, 0); + toolbarShowNewPostsBtn.setMaxWidth(toolbarWidth-paddingMax*2); + + switcher.setPivotX(V.dp(28)); // padding + half of icon + switcher.setPivotY(switcher.getHeight() / 2f); + }); + } + + elevationOnScrollListener = new ElevationOnScrollListener((FragmentRootLinearLayout) view, getToolbar()); + + if(GithubSelfUpdater.needSelfUpdating()){ + updateUpdateState(GithubSelfUpdater.getInstance().getState()); + } + + new GetLists().setCallback(new Callback<>() { + @Override + public void onSuccess(List lists) { + updateList(lists, listItems); + } + + @Override + public void onError(ErrorResponse error) { + error.showToast(getContext()); + } + }).exec(accountID); + + new GetFollowedHashtags().setCallback(new Callback<>() { + @Override + public void onSuccess(HeaderPaginationList hashtags) { + updateList(hashtags, hashtagsItems); + } + + @Override + public void onError(ErrorResponse error) { + error.showToast(getContext()); + } + }).exec(accountID); + + new GetAnnouncements(false).setCallback(new Callback<>() { + @Override + public void onSuccess(List result) { + if(getActivity()==null) return; + if (result.stream().anyMatch(a -> !a.read)) { + announcementsBadged = true; + announcements.setVisible(false); + announcementsAction.setVisible(true); + } + } + + @Override + public void onError(ErrorResponse error) { + error.showToast(getActivity()); + } + }).exec(accountID); + } + + public ElevationOnScrollListener getElevationOnScrollListener() { + return elevationOnScrollListener; + } + + private void onFabClick(View v){ + if (fragments[pager.getCurrentItem()] instanceof BaseStatusListFragment l) { + l.onFabClick(v); + } + } + + private boolean onFabLongClick(View v) { + if (fragments[pager.getCurrentItem()] instanceof BaseStatusListFragment l) { + return l.onFabLongClick(v); + } else { + return false; + } + } + + private void addListsToOverflowMenu() { + Context ctx = getContext(); + listsMenu.clear(); + listsMenu.getItem().setVisible(listItems.size() > 0); + UiUtils.insetPopupMenuIcon(ctx, UiUtils.makeBackItem(listsMenu)); + listItems.forEach((id, list) -> { + MenuItem item = listsMenu.add(Menu.NONE, id, Menu.NONE, list.title); + item.setIcon(R.drawable.ic_fluent_people_24_regular); + UiUtils.insetPopupMenuIcon(ctx, item); + }); + } + + private void addHashtagsToOverflowMenu() { + Context ctx = getContext(); + hashtagsMenu.clear(); + hashtagsMenu.getItem().setVisible(hashtagsItems.size() > 0); + UiUtils.insetPopupMenuIcon(ctx, UiUtils.makeBackItem(hashtagsMenu)); + hashtagsItems.entrySet().stream() + .sorted(Comparator.comparing(x -> x.getValue().name, String.CASE_INSENSITIVE_ORDER)) + .forEach(entry -> { + MenuItem item = hashtagsMenu.add(Menu.NONE, entry.getKey(), Menu.NONE, entry.getValue().name); + item.setIcon(R.drawable.ic_fluent_number_symbol_24_regular); + UiUtils.insetPopupMenuIcon(ctx, item); + }); + } + + public void updateToolbarLogo(){ + Toolbar toolbar = getToolbar(); + ViewParent parentView = toolbarFrame.getParent(); + if (parentView == toolbar) return; + if (parentView instanceof Toolbar parentToolbar) parentToolbar.removeView(toolbarFrame); + toolbar.addView(toolbarFrame, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT); + toolbar.setOnClickListener(v->scrollToTop()); + toolbar.setNavigationContentDescription(R.string.back); + toolbar.setContentInsetsAbsolute(0, toolbar.getContentInsetRight()); + + updateSwitcherIcon(pager.getCurrentItem()); + + toolbarShowNewPostsBtn=toolbarFrame.findViewById(R.id.show_new_posts_btn); + toolbarShowNewPostsBtn.setCompoundDrawableTintList(toolbarShowNewPostsBtn.getTextColors()); + if(Build.VERSION.SDK_INT=Build.VERSION_CODES.P && !UiUtils.isEMUI()) + m.setGroupDividerEnabled(true); + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){ + inflater.inflate(R.menu.home, menu); + + menu.findItem(R.id.overflow).setActionView(overflowActionView); + announcementsAction = menu.findItem(R.id.announcements_action); + settingsAction = menu.findItem(R.id.settings_action); + + updateOverflowMenu(); + } + + private void updateList(List addItems, Map items) { + if (addItems.size() == 0 || getActivity() == null) return; + for (int i = 0; i < addItems.size(); i++) items.put(View.generateViewId(), addItems.get(i)); + updateOverflowMenu(); + } + + private void updateSwitcherMenu() { + Menu switcherMenu = switcherPopup.getMenu(); + switcherMenu.clear(); + timelinesByMenuItem.clear(); + + for (TimelineDefinition tl : timelines) { + int menuItemId = View.generateViewId(); + timelinesByMenuItem.put(menuItemId, tl); + MenuItem item = switcherMenu.add(0, menuItemId, 0, tl.getTitle(getContext())); + item.setIcon(tl.getIcon().iconRes); + } + + UiUtils.enablePopupMenuIcons(getContext(), switcherPopup); + } + + private boolean onSwitcherItemSelected(MenuItem item) { + int id = item.getItemId(); + + Bundle args = new Bundle(); + args.putString("account", accountID); + + if (id == R.id.menu_back) { + switcher.post(() -> switcherPopup.show()); + return true; + } + + TimelineDefinition tl = timelinesByMenuItem.get(id); + if (tl != null) { + for (int i = 0; i < timelines.length; i++) { + if (timelines[i] == tl) { + navigateTo(i); + return true; + } + } + } + + return false; + } + private void navigateTo(int i) { + navigateTo(i, !reduceMotion); + } + + private void navigateTo(int i, boolean smooth) { + pager.setCurrentItem(i, smooth); + updateSwitcherIcon(i); + } + + @Override + public void showFab() { + if (fragments[pager.getCurrentItem()] instanceof BaseStatusListFragment l) l.showFab(); + } + + @Override + public void hideFab() { + if (fragments[pager.getCurrentItem()] instanceof BaseStatusListFragment l) l.hideFab(); + } + + @Override + public boolean isScrolling() { + return (fragments[pager.getCurrentItem()] instanceof HasFab fabulous) + && fabulous.isScrolling(); + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + if (elevationOnScrollListener != null) elevationOnScrollListener.setViews(getToolbar()); + } + + private void updateSwitcherIcon(int i) { + timelineIcon.setImageResource(timelines[i].getIcon().iconRes); + timelineTitle.setText(timelines[i].getTitle(getContext())); + showFab(); + if (elevationOnScrollListener != null && getCurrentFragment() instanceof IsOnTop f) { + elevationOnScrollListener.handleScroll(getContext(), f.isOnTop()); + } + } + + @Override + public boolean onOptionsItemSelected(MenuItem item){ + Bundle args=new Bundle(); + args.putString("account", accountID); + int id = item.getItemId(); + ListTimeline list; + Hashtag hashtag; + + if (item.getItemId() == R.id.menu_back) { + getToolbar().post(() -> overflowPopup.show()); + return true; + } else if (id == R.id.settings || id == R.id.settings_action) { + Nav.go(getActivity(), SettingsMainFragment.class, args); + } else if (id == R.id.announcements || id == R.id.announcements_action) { + Nav.goForResult(getActivity(), AnnouncementsFragment.class, args, ANNOUNCEMENTS_RESULT, this); + } else if (id == R.id.edit_timelines) { + Nav.go(getActivity(), EditTimelinesFragment.class, args); + } else if ((list = listItems.get(id)) != null) { + args.putString("listID", list.id); + args.putString("listTitle", list.title); + args.putBoolean("listIsExclusive", list.exclusive); + if (list.repliesPolicy != null) args.putInt("repliesPolicy", list.repliesPolicy.ordinal()); + Nav.go(getActivity(), ListTimelineFragment.class, args); + } else if ((hashtag = hashtagsItems.get(id)) != null) { + UiUtils.openHashtagTimeline(getContext(), accountID, hashtag); + } + return true; + } + + @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(); + } + + public void hideNewPostsButton(){ + if(!newPostsBtnShown) + return; + newPostsBtnShown=false; + if(currentNewPostsAnim!=null){ + currentNewPostsAnim.cancel(); + } + timelineTitle.setVisibility(View.VISIBLE); + AnimatorSet set=new AnimatorSet(); + set.playTogether( + ObjectAnimator.ofFloat(timelineTitle, View.ALPHA, 1f), + ObjectAnimator.ofFloat(timelineTitle, View.SCALE_X, 1f), + ObjectAnimator.ofFloat(timelineTitle, View.SCALE_Y, 1f), + ObjectAnimator.ofFloat(toolbarShowNewPostsBtn, View.ALPHA, 0f), + ObjectAnimator.ofFloat(toolbarShowNewPostsBtn, View.SCALE_X, .8f), + ObjectAnimator.ofFloat(toolbarShowNewPostsBtn, View.SCALE_Y, .8f), + ObjectAnimator.ofFloat(collapsedChevron, View.ALPHA, 0f) + ); + set.setDuration(reduceMotion ? 0 : 300); + set.setInterpolator(CubicBezierInterpolator.DEFAULT); + set.addListener(new AnimatorListenerAdapter(){ + @Override + public void onAnimationEnd(Animator animation){ + toolbarShowNewPostsBtn.setVisibility(View.INVISIBLE); + collapsedChevron.setVisibility(View.GONE); + currentNewPostsAnim=null; + } + }); + currentNewPostsAnim=set; + set.start(); + } + + public void showNewPostsButton(){ + if(newPostsBtnShown || pager == null || !timelines[pager.getCurrentItem()].equals(TimelineDefinition.HOME_TIMELINE)) + return; + newPostsBtnShown=true; + if(currentNewPostsAnim!=null){ + currentNewPostsAnim.cancel(); + } + toolbarShowNewPostsBtn.setVisibility(View.VISIBLE); + collapsedChevron.setVisibility(View.VISIBLE); + AnimatorSet set=new AnimatorSet(); + set.playTogether( + ObjectAnimator.ofFloat(timelineTitle, View.ALPHA, 0f), + ObjectAnimator.ofFloat(timelineTitle, View.SCALE_X, .8f), + ObjectAnimator.ofFloat(timelineTitle, View.SCALE_Y, .8f), + ObjectAnimator.ofFloat(toolbarShowNewPostsBtn, View.ALPHA, 1f), + ObjectAnimator.ofFloat(toolbarShowNewPostsBtn, View.SCALE_X, 1f), + ObjectAnimator.ofFloat(toolbarShowNewPostsBtn, View.SCALE_Y, 1f), + ObjectAnimator.ofFloat(collapsedChevron, View.ALPHA, 1f) + ); + set.setDuration(reduceMotion ? 0 : 300); + set.setInterpolator(CubicBezierInterpolator.DEFAULT); + set.addListener(new AnimatorListenerAdapter(){ + @Override + public void onAnimationEnd(Animator animation){ + timelineTitle.setVisibility(View.GONE); + currentNewPostsAnim=null; + } + }); + currentNewPostsAnim=set; + set.start(); + } + + public boolean isNewPostsBtnShown() { + return newPostsBtnShown; + } + + private void onNewPostsBtnClick(View view) { + if(newPostsBtnShown){ + scrollToTop(); + hideNewPostsButton(); + } + } + + @Override + public void onFragmentResult(int reqCode, boolean success, Bundle result){ + if (reqCode == ANNOUNCEMENTS_RESULT && success) { + announcementsBadged = false; + announcements.setVisible(true); + announcementsAction.setVisible(false); + } + } + + private void updateUpdateState(GithubSelfUpdater.UpdateState state){ + if(state!=GithubSelfUpdater.UpdateState.NO_UPDATE && state!=GithubSelfUpdater.UpdateState.CHECKING) { + settingsBadged = true; + settingsAction.setVisible(true); + settings.setVisible(false); + } + } + + @Subscribe + public void onSelfUpdateStateChanged(SelfUpdateStateChangedEvent ev){ + updateUpdateState(ev.state); + } + + @Override + public boolean onBackPressed(){ + if(pager.getCurrentItem() > 0){ + navigateTo(0); + return true; + } + return false; + } + + @Override + public void onDestroyView(){ + super.onDestroyView(); + if (overflowPopup != null) { + overflowPopup.dismiss(); + overflowPopup = null; + } + if (switcherPopup != null) { + switcherPopup.dismiss(); + switcherPopup = null; + } + if(GithubSelfUpdater.needSelfUpdating()){ + E.unregister(this); + } + } + + @Override + protected void onShown() { + super.onShown(); + Object timelines = AccountSessionManager.get(accountID).getLocalPreferences().timelines; + if (timelines != null && timelinesList!= timelines) UiUtils.restartApp(); + } + + @Override + public void onViewStateRestored(Bundle savedInstanceState) { + super.onViewStateRestored(savedInstanceState); + if (savedInstanceState == null) return; + navigateTo(savedInstanceState.getInt("selectedTab"), false); + } + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + outState.putInt("selectedTab", pager.getCurrentItem()); + } + + @Subscribe + public void onHashtagUpdatedEvent(HashtagUpdatedEvent event) { + handleListEvent(hashtagsItems, h -> h.name.equalsIgnoreCase(event.name), event.following, () -> { + Hashtag hashtag = new Hashtag(); + hashtag.name = event.name; + hashtag.following = true; + return hashtag; + }); + } + + @Subscribe + public void onListDeletedEvent(ListDeletedEvent event) { + handleListEvent(listItems, l -> l.id.equals(event.id), false, null); + } + + @Subscribe + public void onListUpdatedCreatedEvent(ListUpdatedCreatedEvent event) { + handleListEvent(listItems, l -> l.id.equals(event.id), true, () -> { + ListTimeline list = new ListTimeline(); + list.id = event.id; + list.title = event.title; + list.repliesPolicy = event.repliesPolicy; + return list; + }); + } + + private void handleListEvent( + Map existingThings, + Predicate matchExisting, + boolean shouldBeInList, + Supplier makeNewThing + ) { + Optional> existingThing = existingThings.entrySet().stream() + .filter(e -> matchExisting.test(e.getValue())).findFirst(); + if (shouldBeInList) { + existingThings.put(existingThing.isPresent() + ? existingThing.get().getKey() : View.generateViewId(), makeNewThing.get()); + updateOverflowMenu(); + } else if (existingThing.isPresent() && !shouldBeInList) { + existingThings.remove(existingThing.get().getKey()); + updateOverflowMenu(); + } + } + + public Collection getHashtags() { + return hashtagsItems.values(); + } + + public Fragment getCurrentFragment() { + return fragments[pager.getCurrentItem()]; + } + + public ImageButton getFab() { + return fab; + } + + @Override + public void onProvideAssistContent(AssistContent assistContent) { + callFragmentToProvideAssistContent(fragments[pager.getCurrentItem()], assistContent); + } + + private class HomePagerAdapter extends RecyclerView.Adapter { + @NonNull + @Override + public SimpleViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + FrameLayout tabView = tabViews[viewType % getItemCount()]; + ViewGroup tabParent = (ViewGroup) tabView.getParent(); + if (tabParent != null) tabParent.removeView(tabView); + tabView.setVisibility(View.VISIBLE); + return new SimpleViewHolder(tabView); + } + + @Override + public void onBindViewHolder(@NonNull SimpleViewHolder holder, int position){} + + @Override + public int getItemCount(){ + return count; + } + + @Override + public int getItemViewType(int position){ + return position; + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTimelineFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTimelineFragment.java index 174aa2335..1613aa42b 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTimelineFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTimelineFragment.java @@ -1,270 +1,84 @@ package org.joinmastodon.android.fragments; -import android.animation.Animator; -import android.animation.AnimatorListenerAdapter; -import android.animation.AnimatorSet; -import android.animation.ObjectAnimator; import android.app.Activity; -import android.content.res.Configuration; +import android.net.Uri; import android.os.Bundle; -import android.text.TextUtils; -import android.view.Gravity; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; import android.view.View; -import android.view.ViewGroup; -import android.view.accessibility.AccessibilityNodeInfo; -import android.view.animation.AnimationUtils; -import android.widget.Button; -import android.widget.FrameLayout; -import android.widget.ImageButton; -import android.widget.ImageView; -import android.widget.LinearLayout; -import android.widget.TextView; -import android.widget.Toolbar; -import com.squareup.otto.Subscribe; +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; -import org.joinmastodon.android.E; -import org.joinmastodon.android.R; -import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.api.requests.markers.SaveMarkers; import org.joinmastodon.android.api.requests.timelines.GetHomeTimeline; -import org.joinmastodon.android.api.requests.timelines.GetListTimeline; -import org.joinmastodon.android.api.requests.timelines.GetPublicTimeline; import org.joinmastodon.android.api.session.AccountSessionManager; -import org.joinmastodon.android.events.SelfUpdateStateChangedEvent; -import org.joinmastodon.android.fragments.settings.SettingsMainFragment; import org.joinmastodon.android.model.CacheablePaginatedResponse; import org.joinmastodon.android.model.FilterContext; -import org.joinmastodon.android.model.FollowList; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.model.TimelineMarkers; import org.joinmastodon.android.ui.displayitems.GapStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.StatusDisplayItem; -import org.joinmastodon.android.ui.utils.DiscoverInfoBannerHelper; -import org.joinmastodon.android.ui.viewcontrollers.HomeTimelineMenuController; -import org.joinmastodon.android.ui.viewcontrollers.ToolbarDropdownMenuController; -import org.joinmastodon.android.ui.views.FixedAspectRatioImageView; -import org.joinmastodon.android.updater.GithubSelfUpdater; -import org.parceler.Parcels; +import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; import java.util.List; +import java.util.Objects; +import java.util.Optional; import java.util.Set; +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; import me.grishka.appkit.api.SimpleCallback; -import me.grishka.appkit.utils.CubicBezierInterpolator; -import me.grishka.appkit.utils.MergeRecyclerAdapter; -import me.grishka.appkit.utils.V; - -public class HomeTimelineFragment extends StatusListFragment implements ToolbarDropdownMenuController.HostFragment{ - private ImageButton fab; - private LinearLayout listsDropdown; - private FixedAspectRatioImageView listsDropdownArrow; - private TextView listsDropdownText; - private Button newPostsBtn; - private View newPostsBtnWrap; - private boolean newPostsBtnShown; - private AnimatorSet currentNewPostsAnim; - private ToolbarDropdownMenuController dropdownController; - private HomeTimelineMenuController dropdownMainMenuController; - private List lists=List.of(); - private ListMode listMode=ListMode.FOLLOWING; - private FollowList currentList; - private MergeRecyclerAdapter mergeAdapter; - private DiscoverInfoBannerHelper localTimelineBannerHelper; +public class HomeTimelineFragment extends StatusListFragment { + private HomeTabFragment parent; private String maxID; private String lastSavedMarkerID; - public HomeTimelineFragment(){ - setListLayoutId(R.layout.fragment_timeline); - } - @Override - public void onCreate(Bundle savedInstanceState){ - super.onCreate(savedInstanceState); - localTimelineBannerHelper=new DiscoverInfoBannerHelper(DiscoverInfoBannerHelper.BannerType.LOCAL_TIMELINE, accountID); + protected boolean wantsComposeButton() { + return true; } @Override public void onAttach(Activity activity){ super.onAttach(activity); - dropdownController=new ToolbarDropdownMenuController(this); - dropdownMainMenuController=new HomeTimelineMenuController(dropdownController, new HomeTimelineMenuController.Callback(){ - @Override - public void onFollowingSelected(){ - if(listMode==ListMode.FOLLOWING) - return; - listMode=ListMode.FOLLOWING; - reload(); - } - - @Override - public void onLocalSelected(){ - if(listMode==ListMode.LOCAL) - return; - listMode=ListMode.LOCAL; - reload(); - } - - @Override - public List getLists(){ - return lists; - } - - @Override - public void onListSelected(FollowList list){ - if(listMode==ListMode.LIST && currentList==list) - return; - listMode=ListMode.LIST; - currentList=list; - reload(); - } - }); - setHasOptionsMenu(true); + if (getParentFragment() instanceof HomeTabFragment home) parent = home; loadData(); - AccountSessionManager.get(accountID).getCacheController().getLists(new Callback<>(){ - @Override - public void onSuccess(List result){ - lists=result; - } - - @Override - public void onError(ErrorResponse error){} - }); } @Override protected void doLoadData(int offset, int count){ - switch(listMode){ - case FOLLOWING -> { - AccountSessionManager.getInstance() - .getAccount(accountID).getCacheController() - .getHomeTimeline(offset>0 ? maxID : null, count, refreshing, new SimpleCallback<>(this){ - @Override - public void onSuccess(CacheablePaginatedResponse> result){ - if(getActivity()==null || listMode!=ListMode.FOLLOWING) - return; - if(refreshing) - list.scrollToPosition(0); - onDataLoaded(result.items, !result.items.isEmpty()); - maxID=result.maxID; - if(result.isFromCache()) - loadNewPosts(); - } - - @Override - public void onError(ErrorResponse error){ - if(listMode!=ListMode.FOLLOWING) - return; - super.onError(error); - } - }); - } - case LOCAL -> { - currentRequest=new GetPublicTimeline(true, false, offset>0 ? maxID : null, null, count, null) - .setCallback(new SimpleCallback<>(this){ - @Override - public void onSuccess(List result){ - if(refreshing) - list.scrollToPosition(0); - maxID=result.isEmpty() ? null : result.get(result.size()-1).id; - AccountSessionManager.get(accountID).filterStatuses(result, FilterContext.PUBLIC); - onDataLoaded(result, !result.isEmpty()); - } - }) - .exec(accountID); - } - case LIST -> { - currentRequest=new GetListTimeline(currentList.id, offset>0 ? maxID : null, null, count, null) - .setCallback(new SimpleCallback<>(this){ - @Override - public void onSuccess(List result){ - if(refreshing) - list.scrollToPosition(0); - maxID=result.isEmpty() ? null : result.get(result.size()-1).id; - AccountSessionManager.get(accountID).filterStatuses(result, FilterContext.HOME); - onDataLoaded(result, !result.isEmpty()); - } - }) - .exec(accountID); - } - } + AccountSessionManager.getInstance() + .getAccount(accountID).getCacheController() + .getHomeTimeline(offset>0 ? maxID : null, count, refreshing, new SimpleCallback<>(this){ + @Override + public void onSuccess(CacheablePaginatedResponse> result){ + if(getActivity()==null) return; + boolean empty=result.items.isEmpty(); + maxID=result.maxID; + AccountSessionManager.get(accountID).filterStatuses(result.items, getFilterContext()); + onDataLoaded(result.items, !empty); + if(result.isFromCache() && GlobalUserPreferences.loadNewPosts) + loadNewPosts(); + } + }); } @Override public void onViewCreated(View view, Bundle savedInstanceState){ super.onViewCreated(view, savedInstanceState); - fab=view.findViewById(R.id.fab); - fab.setOnClickListener(this::onFabClick); - newPostsBtn=view.findViewById(R.id.new_posts_btn); - newPostsBtn.setOnClickListener(this::onNewPostsBtnClick); - newPostsBtnWrap=view.findViewById(R.id.new_posts_btn_wrap); - if(newPostsBtnShown){ - newPostsBtnWrap.setVisibility(View.VISIBLE); - }else{ - newPostsBtnWrap.setVisibility(View.GONE); - newPostsBtnWrap.setScaleX(0.9f); - newPostsBtnWrap.setScaleY(0.9f); - newPostsBtnWrap.setAlpha(0f); - newPostsBtnWrap.setTranslationY(V.dp(-56)); - } - updateToolbarLogo(); list.addOnScrollListener(new RecyclerView.OnScrollListener(){ @Override public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy){ - if(newPostsBtnShown && list.getChildAdapterPosition(list.getChildAt(0))<=getMainAdapterOffset()){ - hideNewPostsButton(); + if(parent!=null && parent.isNewPostsBtnShown() && list.getChildAdapterPosition(list.getChildAt(0))<=getMainAdapterOffset()){ + parent.hideNewPostsButton(); } } }); - - if(GithubSelfUpdater.needSelfUpdating()){ - E.register(this); - updateUpdateState(GithubSelfUpdater.getInstance().getState()); - } - } - - @Override - public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){ - inflater.inflate(R.menu.home, menu); - menu.findItem(R.id.edit_list).setVisible(listMode==ListMode.LIST); - GithubSelfUpdater.UpdateState state=GithubSelfUpdater.UpdateState.NO_UPDATE; - GithubSelfUpdater updater=GithubSelfUpdater.getInstance(); - if(updater!=null) - state=updater.getState(); - if(state!=GithubSelfUpdater.UpdateState.NO_UPDATE && state!=GithubSelfUpdater.UpdateState.CHECKING) - getToolbar().getMenu().findItem(R.id.settings).setIcon(R.drawable.ic_settings_updateready_24px); - } - - @Override - public boolean onOptionsItemSelected(MenuItem item){ - Bundle args=new Bundle(); - args.putString("account", accountID); - int id=item.getItemId(); - if(id==R.id.settings){ - Nav.go(getActivity(), SettingsMainFragment.class, args); - }else if(id==R.id.edit_list){ - args.putParcelable("list", Parcels.wrap(currentList)); - Nav.go(getActivity(), EditListFragment.class, args); - } - return true; - } - - @Override - public void onConfigurationChanged(Configuration newConfig){ - super.onConfigurationChanged(newConfig); - updateToolbarLogo(); } @Override @@ -273,7 +87,7 @@ public class HomeTimelineFragment extends StatusListFragment implements ToolbarD if(!getArguments().getBoolean("noAutoLoad")){ if(!loaded && !dataLoading){ loadData(); - }else if(!dataLoading){ + }else if(!dataLoading && GlobalUserPreferences.loadNewPosts){ loadNewPosts(); } } @@ -282,7 +96,7 @@ public class HomeTimelineFragment extends StatusListFragment implements ToolbarD @Override protected void onHidden(){ super.onHidden(); - if(!data.isEmpty() && listMode==ListMode.FOLLOWING){ + if(!data.isEmpty()){ String topPostID=displayItems.get(Math.max(0, list.getChildAdapterPosition(list.getChildAt(0))-getMainAdapterOffset())).parentID; if(!topPostID.equals(lastSavedMarkerID)){ lastSavedMarkerID=topPostID; @@ -303,44 +117,46 @@ public class HomeTimelineFragment extends StatusListFragment implements ToolbarD } public void onStatusCreated(Status status){ + if(status.reblog!=null) return; prependItems(Collections.singletonList(status), true); } - private void onFabClick(View v){ - Bundle args=new Bundle(); - args.putString("account", accountID); - Nav.go(getActivity(), ComposeFragment.class, args); - } - private void loadNewPosts(){ dataLoading=true; + // we only care about the data that was actually retrieved from the timeline api since + // user-created statuses are probably in the wrong position + List dataFromTimeline=data.stream().filter(s->!s.fromStatusCreated).collect(Collectors.toList()); // The idea here is that we request the timeline such that if there are fewer than `limit` posts, // we'll get the currently topmost post as last in the response. This way we know there's no gap // between the existing and newly loaded parts of the timeline. - String sinceID=data.size()>1 ? data.get(1).id : "1"; - boolean needCache=listMode==ListMode.FOLLOWING; - loadAdditionalPosts(null, null, 20, sinceID, new Callback<>(){ + String sinceID=dataFromTimeline.size()>1 ? dataFromTimeline.get(1).id : "1"; + currentRequest=new GetHomeTimeline(null, null, 20, sinceID, getLocalPrefs().timelineReplyVisibility) + .setCallback(new Callback<>(){ @Override public void onSuccess(List result){ currentRequest=null; dataLoading=false; + refreshDone(); if(result.isEmpty() || getActivity()==null) return; Status last=result.get(result.size()-1); List toAdd; - if(!data.isEmpty() && last.id.equals(data.get(0).id)){ // This part intersects with the existing one - toAdd=result.subList(0, result.size()-1); // Remove the already known last post + if(!dataFromTimeline.isEmpty() && last.id.equals(dataFromTimeline.get(0).id)){ // This part intersects with the existing one + toAdd=new ArrayList<>(result.subList(0, result.size()-1)); // Remove the already known last post }else{ - result.get(result.size()-1).hasGapAfter=true; + last.hasGapAfter=last.id; toAdd=result; } - if(needCache) - AccountSessionManager.get(accountID).filterStatuses(toAdd, FilterContext.HOME); + if(!toAdd.isEmpty()) + AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putHomeTimeline(new ArrayList<>(toAdd), false); + // removing statuses that come up as duplicates (hopefully only posts and boosts that were locally created + // and thus were already prepended to the timeline earlier) + List existingIds=data.stream().map(Status::getID).collect(Collectors.toList()); + toAdd.removeIf(s->existingIds.contains(s.getID())); + AccountSessionManager.get(accountID).filterStatuses(toAdd, getFilterContext()); if(!toAdd.isEmpty()){ prependItems(toAdd, true); - showNewPostsButton(); - if(needCache) - AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putHomeTimeline(toAdd, false); + if(parent != null && GlobalUserPreferences.showNewPostsButton) parent.showNewPostsButton(); } } @@ -348,24 +164,37 @@ public class HomeTimelineFragment extends StatusListFragment implements ToolbarD public void onError(ErrorResponse error){ currentRequest=null; dataLoading=false; + refreshDone(); } - }); + }) + .exec(accountID); + + if (parent.getParentFragment() instanceof HomeFragment homeFragment) { + homeFragment.reloadNotificationsForUnreadCount(); + } } @Override - public void onGapClick(GapStatusDisplayItem.Holder item){ + public void onGapClick(GapStatusDisplayItem.Holder item, boolean downwards){ if(dataLoading) return; - item.getItem().loading=true; - V.setVisibilityAnimated(item.progress, View.VISIBLE); - V.setVisibilityAnimated(item.text, View.GONE); GapStatusDisplayItem gap=item.getItem(); + gap.loading=true; dataLoading=true; - boolean needCache=listMode==ListMode.FOLLOWING; - loadAdditionalPosts(item.getItemID(), null, 20, null, new Callback<>(){ + + String maxID=null; + String minID=null; + if (downwards) { + maxID=item.getItem().getMaxID(); + } else { + int gapPos=displayItems.indexOf(gap); + StatusDisplayItem nextItem=displayItems.get(gapPos + 1); + minID=nextItem.parentID; + } + currentRequest=new GetHomeTimeline(maxID, minID, 20, null, getLocalPrefs().timelineReplyVisibility) + .setCallback(new Callback<>(){ @Override public void onSuccess(List result){ - currentRequest=null; dataLoading=false; if(getActivity()==null) @@ -373,62 +202,108 @@ public class HomeTimelineFragment extends StatusListFragment implements ToolbarD int gapPos=displayItems.indexOf(gap); if(gapPos==-1) return; + AccountSessionManager.get(accountID).filterStatuses(result, getFilterContext()); if(result.isEmpty()){ displayItems.remove(gapPos); adapter.notifyItemRemoved(getMainAdapterOffset()+gapPos); Status gapStatus=getStatusByID(gap.parentID); if(gapStatus!=null){ - gapStatus.hasGapAfter=false; - if(needCache) - AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putHomeTimeline(Collections.singletonList(gapStatus), false); + gapStatus.hasGapAfter=null; + AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putHomeTimeline(Collections.singletonList(gapStatus), false); } }else{ - Set idsBelowGap=new HashSet<>(); - boolean belowGap=false; - int gapPostIndex=0; - for(Status s:data){ - if(belowGap){ - idsBelowGap.add(s.id); - }else if(s.id.equals(gap.parentID)){ - belowGap=true; - s.hasGapAfter=false; - if(needCache) + // TODO: refactor this code. it's too long. incomprehensible, even + if(downwards) { + Set idsBelowGap=new HashSet<>(); + boolean belowGap=false; + int gapPostIndex=0; + for(Status s:data){ + if(belowGap){ + idsBelowGap.add(s.id); + }else if(s.id.equals(gap.parentID)){ + belowGap=true; + s.hasGapAfter=null; AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putHomeTimeline(Collections.singletonList(s), false); - }else{ - gapPostIndex++; + }else{ + gapPostIndex++; + } + } + int endIndex=0; + for(Status s:result){ + endIndex++; + if(idsBelowGap.contains(s.id)) + break; + } + if(endIndex==result.size()){ + Status last=result.get(result.size()-1); + last.hasGapAfter=last.id; + }else{ + result=result.subList(0, endIndex); } - } - int endIndex=0; - for(Status s:result){ - endIndex++; - if(idsBelowGap.contains(s.id)) - break; - } - if(endIndex==result.size()){ - result.get(result.size()-1).hasGapAfter=true; - }else{ - result=result.subList(0, endIndex); - } - if(needCache) AccountSessionManager.get(accountID).filterStatuses(result, FilterContext.HOME); - List targetList=displayItems.subList(gapPos, gapPos+1); - targetList.clear(); - List insertedPosts=data.subList(gapPostIndex+1, gapPostIndex+1); - for(Status s:result){ - if(idsBelowGap.contains(s.id)) - break; - targetList.addAll(buildDisplayItems(s)); - insertedPosts.add(s); - } - if(targetList.isEmpty()){ - // oops. We didn't add new posts, but at least we know there are none. - adapter.notifyItemRemoved(getMainAdapterOffset()+gapPos); - }else{ - adapter.notifyItemChanged(getMainAdapterOffset()+gapPos); - adapter.notifyItemRangeInserted(getMainAdapterOffset()+gapPos+1, targetList.size()-1); - } - if(needCache) + List targetList=displayItems.subList(gapPos, gapPos+1); + targetList.clear(); + List insertedPosts=data.subList(gapPostIndex+1, gapPostIndex+1); + for(Status s:result){ + if(idsBelowGap.contains(s.id)) + break; + targetList.addAll(buildDisplayItems(s)); + insertedPosts.add(s); + } + if(targetList.isEmpty()){ + // oops. We didn't add new posts, but at least we know there are none. + adapter.notifyItemRemoved(getMainAdapterOffset()+gapPos); + }else{ + adapter.notifyItemChanged(getMainAdapterOffset()+gapPos); + adapter.notifyItemRangeInserted(getMainAdapterOffset()+gapPos+1, targetList.size()-1); + } AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putHomeTimeline(insertedPosts, false); + } else { + String aboveGapID = gap.parentID; + int gapPostIndex = 0; + for (;gapPostIndex targetList=displayItems.subList(gapPos, gapPos+1); + if(indexOfGapInResponse gapStatus=data.stream() + .filter(s->Objects.equals(s.id, gap.parentID)) + .findFirst(); + if (gapStatus.isPresent()) { + gapStatus.get().hasGapAfter=null; + AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putHomeTimeline(Collections.singletonList(gapStatus.get()), false); + } + targetList.clear(); + } else { + gap.loading=false; + } + List insertedPosts=data.subList(gapPostIndex+1, gapPostIndex+1); + for(Status s:result){ + targetList.addAll(buildDisplayItems(s)); + insertedPosts.add(s); + } + AccountSessionManager.get(accountID).filterStatuses(insertedPosts, FilterContext.HOME); + if(targetList.isEmpty()){ + // oops. We didn't add new posts, but at least we know there are none. + adapter.notifyItemRemoved(getMainAdapterOffset()+gapPos); + }else{ + adapter.notifyItemChanged(getMainAdapterOffset()+gapPos); + adapter.notifyItemRangeInserted(getMainAdapterOffset()+gapPos+1, targetList.size()-1); + } + list.scrollToPosition(getMainAdapterOffset()+gapPos+targetList.size()); + AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putHomeTimeline(insertedPosts, false); + } } } @@ -445,17 +320,9 @@ public class HomeTimelineFragment extends StatusListFragment implements ToolbarD adapter.notifyItemChanged(gapPos); } } - }); - } + }) + .exec(accountID); - private void loadAdditionalPosts(String maxID, String minID, int limit, String sinceID, Callback> callback){ - MastodonAPIRequest> req=switch(listMode){ - case FOLLOWING -> new GetHomeTimeline(maxID, minID, limit, sinceID); - case LOCAL -> new GetPublicTimeline(true, false, maxID, minID, limit, sinceID); - case LIST -> new GetListTimeline(currentList.id, maxID, minID, limit, sinceID); - }; - currentRequest=req; - req.setCallback(callback).exec(accountID); } @Override @@ -465,205 +332,22 @@ public class HomeTimelineFragment extends StatusListFragment implements ToolbarD currentRequest=null; dataLoading=false; } + if(parent!=null) parent.hideNewPostsButton(); super.onRefresh(); } - private void updateToolbarLogo(){ - listsDropdown=new LinearLayout(getActivity()); - listsDropdown.setOnClickListener(this::onListsDropdownClick); - listsDropdown.setBackgroundResource(R.drawable.bg_button_m3_text); - listsDropdown.setAccessibilityDelegate(new View.AccessibilityDelegate(){ - @Override - public void onInitializeAccessibilityNodeInfo(@NonNull View host, @NonNull AccessibilityNodeInfo info){ - super.onInitializeAccessibilityNodeInfo(host, info); - info.setClassName("android.widget.Spinner"); - } - }); - listsDropdownArrow=new FixedAspectRatioImageView(getActivity()); - listsDropdownArrow.setUseHeight(true); - listsDropdownArrow.setImageResource(R.drawable.ic_arrow_drop_down_24px); - listsDropdownArrow.setScaleType(ImageView.ScaleType.CENTER); - listsDropdownArrow.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO); - listsDropdown.addView(listsDropdownArrow, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT)); - listsDropdownText=new TextView(getActivity()); - listsDropdownText.setTextAppearance(R.style.action_bar_title); - listsDropdownText.setSingleLine(); - listsDropdownText.setEllipsize(TextUtils.TruncateAt.END); - listsDropdownText.setGravity(Gravity.START | Gravity.CENTER_VERTICAL); - listsDropdownText.setPaddingRelative(V.dp(4), 0, V.dp(16), 0); - listsDropdownText.setText(getCurrentListTitle()); - listsDropdownArrow.setImageTintList(listsDropdownText.getTextColors()); - listsDropdown.setBackgroundTintList(listsDropdownText.getTextColors()); - listsDropdown.addView(listsDropdownText, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT)); - - FrameLayout logoWrap=new FrameLayout(getActivity()); - FrameLayout.LayoutParams ddlp=new FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT, Gravity.START); - ddlp.topMargin=ddlp.bottomMargin=V.dp(8); - logoWrap.addView(listsDropdown, ddlp); - - Toolbar toolbar=getToolbar(); - toolbar.addView(logoWrap, new Toolbar.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); - toolbar.setContentInsetsRelative(V.dp(16), 0); - } - - private void showNewPostsButton(){ - if(newPostsBtnShown) - return; - newPostsBtnShown=true; - if(currentNewPostsAnim!=null){ - currentNewPostsAnim.cancel(); - } - newPostsBtnWrap.setVisibility(View.VISIBLE); - AnimatorSet set=new AnimatorSet(); - set.playTogether( - ObjectAnimator.ofFloat(newPostsBtnWrap, View.ALPHA, 1f), - ObjectAnimator.ofFloat(newPostsBtnWrap, View.SCALE_X, 1f), - ObjectAnimator.ofFloat(newPostsBtnWrap, View.SCALE_Y, 1f), - ObjectAnimator.ofFloat(newPostsBtnWrap, View.TRANSLATION_Y, 0f) - ); - set.setDuration(getResources().getInteger(R.integer.m3_sys_motion_duration_medium3)); - set.setInterpolator(AnimationUtils.loadInterpolator(getActivity(), R.interpolator.m3_sys_motion_easing_standard_decelerate)); - set.addListener(new AnimatorListenerAdapter(){ - @Override - public void onAnimationEnd(Animator animation){ - currentNewPostsAnim=null; - } - }); - currentNewPostsAnim=set; - set.start(); - } - - private void hideNewPostsButton(){ - if(!newPostsBtnShown) - return; - newPostsBtnShown=false; - if(currentNewPostsAnim!=null){ - currentNewPostsAnim.cancel(); - } - AnimatorSet set=new AnimatorSet(); - set.playTogether( - ObjectAnimator.ofFloat(newPostsBtnWrap, View.ALPHA, 0f), - ObjectAnimator.ofFloat(newPostsBtnWrap, View.SCALE_X, .9f), - ObjectAnimator.ofFloat(newPostsBtnWrap, View.SCALE_Y, .9f), - ObjectAnimator.ofFloat(newPostsBtnWrap, View.TRANSLATION_Y, V.dp(-56)) - ); - set.setDuration(getResources().getInteger(R.integer.m3_sys_motion_duration_medium3)); - set.setInterpolator(AnimationUtils.loadInterpolator(getActivity(), R.interpolator.m3_sys_motion_easing_standard_accelerate)); - set.addListener(new AnimatorListenerAdapter(){ - @Override - public void onAnimationEnd(Animator animation){ - newPostsBtnWrap.setVisibility(View.GONE); - currentNewPostsAnim=null; - } - }); - currentNewPostsAnim=set; - set.start(); - } - - private void onNewPostsBtnClick(View v){ - if(newPostsBtnShown){ - hideNewPostsButton(); - scrollToTop(); - } - } - - private void onListsDropdownClick(View v){ - listsDropdownArrow.animate().rotation(-180f).setDuration(150).setInterpolator(CubicBezierInterpolator.DEFAULT).start(); - dropdownController.show(dropdownMainMenuController); - AccountSessionManager.get(accountID).getCacheController().reloadLists(new Callback<>(){ - @Override - public void onSuccess(java.util.List result){ - lists=result; - } - - @Override - public void onError(ErrorResponse error){} - }); - } - - @Override - public void onDestroyView(){ - super.onDestroyView(); - if(GithubSelfUpdater.needSelfUpdating()){ - E.unregister(this); - } - } - - private void updateUpdateState(GithubSelfUpdater.UpdateState state){ - if(state!=GithubSelfUpdater.UpdateState.NO_UPDATE && state!=GithubSelfUpdater.UpdateState.CHECKING) - getToolbar().getMenu().findItem(R.id.settings).setIcon(R.drawable.ic_settings_updateready_24px); - } - - @Subscribe - public void onSelfUpdateStateChanged(SelfUpdateStateChangedEvent ev){ - updateUpdateState(ev.state); - } - @Override protected boolean shouldRemoveAccountPostsWhenUnfollowing(){ return true; } @Override - public Toolbar getToolbar(){ - return super.getToolbar(); + protected FilterContext getFilterContext() { + return FilterContext.HOME; } @Override - public void onDropdownWillDismiss(){ - listsDropdownArrow.animate().rotation(0f).setDuration(150).setInterpolator(CubicBezierInterpolator.DEFAULT).start(); - - } - - @Override - public void onDropdownDismissed(){ - - } - - @Override - public void reload(){ - if(currentRequest!=null){ - currentRequest.cancel(); - currentRequest=null; - } - refreshing=true; - showProgress(); - loadData(); - listsDropdownText.setText(getCurrentListTitle()); - invalidateOptionsMenu(); - } - - @Override - protected RecyclerView.Adapter getAdapter(){ - mergeAdapter=new MergeRecyclerAdapter(); - mergeAdapter.addAdapter(super.getAdapter()); - return mergeAdapter; - } - - @Override - protected void onDataLoaded(List d, boolean more){ - if(refreshing){ - if(listMode==ListMode.LOCAL){ - localTimelineBannerHelper.maybeAddBanner(list, mergeAdapter); - localTimelineBannerHelper.onBannerBecameVisible(); - }else{ - localTimelineBannerHelper.removeBanner(mergeAdapter); - } - } - super.onDataLoaded(d, more); - } - - private String getCurrentListTitle(){ - return switch(listMode){ - case FOLLOWING -> getString(R.string.timeline_following); - case LOCAL -> getString(R.string.local_timeline); - case LIST -> currentList.title; - }; - } - - private enum ListMode{ - FOLLOWING, - LOCAL, - LIST + public Uri getWebUri(Uri.Builder base) { + return base.path("/").build(); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/IsOnTop.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/IsOnTop.java new file mode 100644 index 000000000..071e44775 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/IsOnTop.java @@ -0,0 +1,13 @@ +package org.joinmastodon.android.fragments; + +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.RecyclerView; + +public interface IsOnTop { + boolean isOnTop(); + + default boolean isRecyclerViewOnTop(@Nullable RecyclerView list) { + if (list == null) return true; + return !list.canScrollVertically(-1); + } +} 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 550e8332b..fb357d991 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ListTimelineFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ListTimelineFragment.java @@ -1,61 +1,178 @@ package org.joinmastodon.android.fragments; +import android.app.Activity; +import android.net.Uri; import android.os.Bundle; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import androidx.annotation.Nullable; + +import org.joinmastodon.android.E; import org.joinmastodon.android.R; +import org.joinmastodon.android.api.requests.lists.GetList; +import org.joinmastodon.android.api.requests.lists.UpdateList; import org.joinmastodon.android.api.requests.timelines.GetListTimeline; -import org.joinmastodon.android.model.FollowList; +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.Status; -import org.parceler.Parcels; +import org.joinmastodon.android.model.TimelineDefinition; +import org.joinmastodon.android.ui.M3AlertDialogBuilder; +import org.joinmastodon.android.ui.utils.UiUtils; +import org.joinmastodon.android.ui.views.ListEditor; import java.util.List; 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.utils.V; -public class ListTimelineFragment extends StatusListFragment{ - private FollowList followList; +public class ListTimelineFragment extends PinnableStatusListFragment { + private String listID; + private String listTitle; + @Nullable + private ListTimeline.RepliesPolicy repliesPolicy; + private boolean exclusive; - @Override - public void onCreate(Bundle savedInstanceState){ - super.onCreate(savedInstanceState); - followList=Parcels.unwrap(getArguments().getParcelable("list")); - setTitle(followList.title); - setHasOptionsMenu(true); - loadData(); - } + @Override + protected boolean wantsComposeButton() { + return true; + } - @Override - protected void doLoadData(int offset, int count){ - currentRequest=new GetListTimeline(followList.id, offset>0 ? getMaxID() : null, null, count, null) - .setCallback(new SimpleCallback<>(this){ - @Override - public void onSuccess(List result){ - onDataLoaded(result, !result.isEmpty()); - } - }) - .exec(accountID); - } + @Override + public void onAttach(Activity activity){ + super.onAttach(activity); + Bundle args = getArguments(); + listID = args.getString("listID"); + listTitle = args.getString("listTitle"); + exclusive = args.getBoolean("listIsExclusive"); + repliesPolicy = ListTimeline.RepliesPolicy.values()[args.getInt("repliesPolicy", 0)]; - @Override - public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){ - inflater.inflate(R.menu.standalone_list_timeline, menu); - } + setTitle(listTitle); + setHasOptionsMenu(true); - @Override - public boolean onOptionsItemSelected(MenuItem item){ - int id=item.getItemId(); - Bundle args=new Bundle(); - args.putString("account", accountID); - args.putParcelable("list", Parcels.wrap(followList)); - if(id==R.id.members){ - Nav.go(getActivity(), ListMembersFragment.class, args); - }else if(id==R.id.edit_list){ - Nav.go(getActivity(), EditListFragment.class, args); - } - return true; - } + new GetList(listID).setCallback(new Callback<>() { + @Override + public void onSuccess(ListTimeline listTimeline) { + 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; + } + } + + @Override + public void onError(ErrorResponse error) { + error.showToast(getContext()); + } + }); + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + inflater.inflate(R.menu.list, menu); + super.onCreateOptionsMenu(menu, inflater); + UiUtils.enableOptionsMenuIcons(getContext(), menu, R.id.pin); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (super.onOptionsItemSelected(item)) return true; + if (item.getItemId() == R.id.edit) { + ListEditor editor = new ListEditor(getContext()); + editor.applyList(listTitle, exclusive, repliesPolicy); + new M3AlertDialogBuilder(getActivity()) + .setTitle(R.string.sk_edit_list_title) + .setIcon(R.drawable.ic_fluent_people_28_regular) + .setView(editor) + .setPositiveButton(R.string.save, (d, which) -> { + String newTitle = editor.getTitle().trim(); + setTitle(newTitle); + new UpdateList(listID, newTitle, editor.isExclusive(), editor.getRepliesPolicy()).setCallback(new Callback<>() { + @Override + public void onSuccess(ListTimeline list) { + if(getActivity()==null) return; + setTitle(list.title); + listTitle = list.title; + repliesPolicy = list.repliesPolicy; + exclusive = list.exclusive; + E.post(new ListUpdatedCreatedEvent(listID, listTitle, exclusive, repliesPolicy)); + } + + @Override + public void onError(ErrorResponse error) { + setTitle(listTitle); + error.showToast(getContext()); + } + }).exec(accountID); + }) + .setNegativeButton(R.string.cancel, (d, which) -> {}) + .show(); + } else if (item.getItemId() == R.id.delete) { + UiUtils.confirmDeleteList(getActivity(), accountID, listID, listTitle, () -> { + E.post(new ListDeletedEvent(listID)); + Nav.finish(this); + }); + } + return true; + } + + @Override + protected TimelineDefinition makeTimelineDefinition() { + return TimelineDefinition.ofList(listID, listTitle, exclusive); + } + + @Override + protected void doLoadData(int offset, int count) { + currentRequest=new GetListTimeline(listID, getMaxID(), null, count, null, getLocalPrefs().timelineReplyVisibility) + .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 onFabClick(View v){ + Bundle args=new Bundle(); + args.putString("account", accountID); + Nav.go(getActivity(), ComposeFragment.class, args); + } + + @Override + protected void onSetFabBottomInset(int inset) { + ((ViewGroup.MarginLayoutParams) fab.getLayoutParams()).bottomMargin=V.dp(24)+inset; + } + + + @Override + protected FilterContext getFilterContext() { + return FilterContext.HOME; + } + + @Override + public Uri getWebUri(Uri.Builder base) { + return base.path("/lists/" + listID).build(); + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ListsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ListsFragment.java new file mode 100644 index 000000000..8e92bb5ea --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ListsFragment.java @@ -0,0 +1,274 @@ +package org.joinmastodon.android.fragments; + +import android.net.Uri; +import android.os.Bundle; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.CheckBox; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +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.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.ui.DividerItemDecoration; +import org.joinmastodon.android.ui.M3AlertDialogBuilder; +import org.joinmastodon.android.ui.views.ListEditor; +import org.joinmastodon.android.utils.ProvidesAssistContent; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; + +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.utils.BindableViewHolder; +import me.grishka.appkit.views.UsableRecyclerView; + +public class ListsFragment extends MastodonRecyclerFragment implements ScrollableToTop, ProvidesAssistContent.ProvidesWebUri { + private String accountID; + private String profileAccountId; + private final HashMap userInListBefore = new HashMap<>(); + private final HashMap userInList = new HashMap<>(); + private ListsAdapter adapter; + + public ListsFragment() { + super(10); + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + Bundle args = getArguments(); + accountID = args.getString("account"); + setHasOptionsMenu(true); + E.register(this); + + if(args.containsKey("profileAccount")){ + profileAccountId=args.getString("profileAccount"); + String profileDisplayUsername = args.getString("profileDisplayUsername"); + setTitle(getString(R.string.sk_lists_with_user, profileDisplayUsername)); + } else { + setTitle(R.string.sk_your_lists); + } + } + + @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); + list.addItemDecoration(new DividerItemDecoration(getActivity(), R.attr.colorM3OutlineVariant, 0.5f, 56, 16)); + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + inflater.inflate(R.menu.menu_list, menu); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == R.id.create) { + ListEditor editor = new ListEditor(getContext()); + new M3AlertDialogBuilder(getActivity()) + .setTitle(R.string.sk_create_list_title) + .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<>() { + @Override + public void onSuccess(ListTimeline list) { + data.add(0, list); + adapter.notifyItemRangeInserted(0, 1); + E.post(new ListUpdatedCreatedEvent(list.id, list.title, list.exclusive, list.repliesPolicy)); + } + + @Override + public void onError(ErrorResponse error) { + error.showToast(getContext()); + } + }).exec(accountID) + ) + .setNegativeButton(R.string.cancel, (d, which) -> {}) + .show(); + } + return true; + } + + 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); + req.setCallback(new Callback<>() { + @Override + public void onSuccess(Object o) {} + + @Override + public void onError(ErrorResponse error) { + error.showToast(getContext()); + } + }).exec(accountID); + } + + @Override + protected void doLoadData(int offset, int count){ + userInListBefore.clear(); + userInList.clear(); + currentRequest=(profileAccountId != null ? new GetLists(profileAccountId) : new GetLists()) + .setCallback(new SimpleCallback<>(this) { + @Override + public void onSuccess(List lists) { + if(getActivity()==null) return; + for (ListTimeline 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) { + if(getActivity()==null) return; + List newLists = new ArrayList<>(); + for (ListTimeline 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); + } + } + userInList.putAll(userInListBefore); + onDataLoaded(newLists, false); + } + }).exec(accountID); + } + }) + .exec(accountID); + } + + @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)) { + data.remove(i); + adapter.notifyItemRemoved(i); + break; + } + } + } + + @Subscribe + public void onListUpdatedCreatedEvent(ListUpdatedCreatedEvent event) { + for (int i = 0; i < data.size(); i++) { + ListTimeline item = data.get(i); + if (item.id.equals(event.id)) { + item.title = event.title; + item.repliesPolicy = event.repliesPolicy; + item.exclusive = event.exclusive; + adapter.notifyItemChanged(i); + break; + } + } + } + + @Override + protected RecyclerView.Adapter getAdapter() { + return adapter = new ListsAdapter(); + } + + @Override + public void scrollToTop() { + smoothScrollRecyclerViewToTop(list); + } + + @Override + public String getAccountID() { + return accountID; + } + + @Override + public Uri getWebUri(Uri.Builder base) { + return base.path("/lists").build(); + } + + private class ListsAdapter extends RecyclerView.Adapter{ + @NonNull + @Override + public ListViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){ + return new ListViewHolder(); + } + + @Override + public void onBindViewHolder(@NonNull ListViewHolder holder, int position) { + holder.bind(data.get(position)); + } + + @Override + public int getItemCount() { + return data.size(); + } + } + + private class ListViewHolder extends BindableViewHolder implements UsableRecyclerView.Clickable{ + private final TextView title; + private final CheckBox listToggle; + + public ListViewHolder(){ + super(getActivity(), R.layout.item_text, list); + title=findViewById(R.id.title); + listToggle=findViewById(R.id.list_toggle); + } + + @Override + public void onBind(ListTimeline 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 + ), null, null, null); + if (profileAccountId != null) { + Boolean checked = userInList.get(item.id); + listToggle.setVisibility(View.VISIBLE); + listToggle.setChecked(userInList.containsKey(item.id) && checked != null && checked); + listToggle.setOnClickListener(this::onClickToggle); + } else { + listToggle.setVisibility(View.GONE); + } + } + + private void onClickToggle(View view) { + saveListMembership(item.id, listToggle.isChecked()); + } + + @Override + public void onClick() { + Bundle args=new Bundle(); + args.putString("account", accountID); + args.putString("listID", item.id); + args.putString("listTitle", item.title); + args.putBoolean("listIsExclusive", item.exclusive); + if (item.repliesPolicy != null) args.putInt("repliesPolicy", item.repliesPolicy.ordinal()); + Nav.go(getActivity(), ListTimelineFragment.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 7fbda0229..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,6 +2,8 @@ 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; @@ -9,10 +11,13 @@ import org.joinmastodon.android.ui.BetterItemAnimator; import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.utils.ElevationOnScrollListener; +import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.List; import androidx.annotation.CallSuper; +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; import me.grishka.appkit.fragments.BaseRecyclerFragment; import me.grishka.appkit.views.FragmentRootLinearLayout; @@ -36,21 +41,13 @@ public abstract class MastodonRecyclerFragment extends BaseRecyclerFragment extends BaseRecyclerFragment getData() { + return data; + } + + public static void setRefreshLayoutColors(SwipeRefreshLayout l) { + List colors = new ArrayList<>(Arrays.asList( + UiUtils.isDarkTheme() ? R.color.primary_200 : R.color.primary_600, + UiUtils.isDarkTheme() ? R.color.red_primary_200 : R.color.red_primary_600, + UiUtils.isDarkTheme() ? R.color.green_primary_200 : R.color.green_primary_600, + UiUtils.isDarkTheme() ? R.color.blue_primary_200 : R.color.blue_primary_600, + UiUtils.isDarkTheme() ? R.color.purple_200 : R.color.purple_600 + )); + int primary = UiUtils.getThemeColorRes(l.getContext(), + UiUtils.isDarkTheme() ? R.attr.colorPrimary200 : R.attr.colorPrimary600); + if (!colors.contains(primary)) colors.add(0, primary); + int offset = colors.indexOf(primary); + int[] sorted = new int[colors.size()]; + for (int i = 0; i < colors.size(); i++) { + sorted[i] = colors.get((i + offset) % colors.size()); + } + l.setColorSchemeResources(sorted); + int colorBackground=UiUtils.getThemeColor(l.getContext(), R.attr.colorM3Background); + int colorPrimary=UiUtils.getThemeColor(l.getContext(), R.attr.colorM3Primary); + l.setProgressBackgroundColorSchemeColor(UiUtils.alphaBlendColors(colorBackground, colorPrimary, 0.11f)); + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/MastodonToolbarFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/MastodonToolbarFragment.java index 8bc334a33..cb61c0b9d 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/MastodonToolbarFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/MastodonToolbarFragment.java @@ -39,4 +39,9 @@ public abstract class MastodonToolbarFragment extends ToolbarFragment{ toolbar.setNavigationContentDescription(R.string.back); } } + + @Override + protected boolean wantsToolbarMenuIconsTinted() { + return false; // else, badged icons don't work :( + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsFragment.java new file mode 100644 index 000000000..74489f4f7 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsFragment.java @@ -0,0 +1,397 @@ +package org.joinmastodon.android.fragments; + +import android.app.Activity; +import android.app.Fragment; +import android.app.assist.AssistContent; +import android.content.Context; +import android.content.res.Configuration; +import android.os.Build; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.LinearLayout; + +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.accounts.GetFollowRequests; +import org.joinmastodon.android.api.requests.markers.SaveMarkers; +import org.joinmastodon.android.api.requests.notifications.PleromaMarkNotificationsRead; +import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.events.FollowRequestHandledEvent; +import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.model.HeaderPaginationList; +import org.joinmastodon.android.model.PushSubscription; +import org.joinmastodon.android.ui.M3AlertDialogBuilder; +import org.joinmastodon.android.ui.SimpleViewHolder; +import org.joinmastodon.android.ui.tabs.TabLayout; +import org.joinmastodon.android.ui.tabs.TabLayoutMediator; +import org.joinmastodon.android.ui.utils.UiUtils; +import org.joinmastodon.android.utils.ElevationOnScrollListener; +import org.joinmastodon.android.utils.ObjectIdComparator; +import org.joinmastodon.android.utils.ProvidesAssistContent; + +import java.util.Arrays; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; +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.fragments.BaseRecyclerFragment; +import me.grishka.appkit.utils.V; +import me.grishka.appkit.views.FragmentRootLinearLayout; + +public class NotificationsFragment extends MastodonToolbarFragment implements ScrollableToTop, ProvidesAssistContent, HasElevationOnScrollListener, HasAccountID { + + TabLayout tabLayout; + private ViewPager2 pager; + private FrameLayout[] tabViews; + private View tabsDivider; + private TabLayoutMediator tabLayoutMediator; + String unreadMarker, realUnreadMarker; + private MenuItem markAllReadItem, filterItem; + private NotificationsListFragment allNotificationsFragment, mentionsFragment; + private ElevationOnScrollListener elevationOnScrollListener; + + private String accountID; + @Override + public void onCreate(Bundle savedInstanceState){ + super.onCreate(savedInstanceState); + if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N) + setRetainInstance(true); + + accountID=getArguments().getString("account"); + E.register(this); + } + + @Override + public void onDestroy() { + super.onDestroy(); + E.unregister(this); + } + + @Override + public void onAttach(Activity activity){ + super.onAttach(activity); + setHasOptionsMenu(true); + setTitle(R.string.notifications); + } + + @Override + public void onShown() { + super.onShown(); + unreadMarker=realUnreadMarker=AccountSessionManager.get(accountID).getLastKnownNotificationsMarker(); + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){ + inflater.inflate(R.menu.notifications, menu); + menu.findItem(R.id.clear_notifications).setVisible(GlobalUserPreferences.enableDeleteNotifications); + filterItem=menu.findItem(R.id.filter_notifications).setVisible(true); + markAllReadItem=menu.findItem(R.id.mark_all_read); + updateMarkAllReadButton(); + UiUtils.enableOptionsMenuIcons(getActivity(), menu, R.id.follow_requests, R.id.mark_all_read, R.id.filter_notifications); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == R.id.follow_requests) { + Bundle args=new Bundle(); + args.putString("account", accountID); + Nav.go(getActivity(), FollowRequestsListFragment.class, args); + return true; + } else if (item.getItemId() == R.id.clear_notifications) { + UiUtils.confirmDeleteNotification(getActivity(), accountID, null, ()->{ + for (int i = 0; i < tabViews.length; i++) { + getFragmentForPage(i).reload(); + } + }); + return true; + } else if (item.getItemId() == R.id.mark_all_read) { + markAsRead(); + if (getCurrentFragment() instanceof NotificationsListFragment nlf) { + nlf.resetUnreadBackground(); + } + return true; + } else if (item.getItemId() == R.id.filter_notifications) { + Context ctx = getToolbarContext(); + String[] listItems = { + ctx.getString(R.string.notification_type_mentions_and_replies), + ctx.getString(R.string.notification_type_reblog), + ctx.getString(R.string.notification_type_favorite), + ctx.getString(R.string.notification_type_follow), + ctx.getString(R.string.notification_type_poll), + ctx.getString(R.string.sk_notification_type_update), + ctx.getString(R.string.sk_notification_type_posts) + }; + + boolean[] checkedItems = { + getLocalPrefs().notificationFilters.mention, + getLocalPrefs().notificationFilters.reblog, + getLocalPrefs().notificationFilters.favourite, + getLocalPrefs().notificationFilters.follow, + getLocalPrefs().notificationFilters.poll, + getLocalPrefs().notificationFilters.update, + getLocalPrefs().notificationFilters.status, + }; + + M3AlertDialogBuilder dialogBuilder = new M3AlertDialogBuilder(ctx); + dialogBuilder.setTitle(R.string.sk_settings_filters); + dialogBuilder.setMultiChoiceItems(listItems, checkedItems, (dialog, which, isChecked) ->checkedItems[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; + if(ObjectIdComparator.INSTANCE.compare(id, realUnreadMarker)>0){ + new SaveMarkers(null, id).exec(accountID); + if (allNotificationsFragment.isInstanceAkkoma()) { + new PleromaMarkNotificationsRead(id).exec(accountID); + } + AccountSessionManager.get(accountID).setNotificationsMarker(id, true); + realUnreadMarker=id; + updateMarkAllReadButton(); + } + } + + public void updateMarkAllReadButton(){ + markAllReadItem.setVisible(!allNotificationsFragment.getData().isEmpty() && realUnreadMarker!=null && !realUnreadMarker.equals(allNotificationsFragment.getData().get(0).id)); + } + + @Override + public View onCreateContentView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState){ + LinearLayout view=(LinearLayout) inflater.inflate(R.layout.fragment_notifications, container, false); + + tabLayout=view.findViewById(R.id.tabbar); + tabsDivider=view.findViewById(R.id.tabs_divider); + pager=view.findViewById(R.id.pager); + UiUtils.reduceSwipeSensitivity(pager); + + tabViews=new FrameLayout[2]; + for(int i=0;i R.id.notifications_all; + case 1 -> R.id.notifications_mentions; + default -> throw new IllegalStateException("Unexpected value: "+i); + }); + tabView.setVisibility(View.GONE); + view.addView(tabView); // needed so the fragment manager will have somewhere to restore the tab fragment + tabViews[i]=tabView; + } + + tabLayout.setTabTextSize(V.dp(16)); + tabLayout.setTabTextColors(UiUtils.getThemeColor(getActivity(), R.attr.colorM3OnSurfaceVariant), UiUtils.getThemeColor(getActivity(), R.attr.colorM3Primary)); + tabLayout.addOnTabSelectedListener(new TabLayout.OnTabSelectedListener() { + @Override + public void onTabSelected(TabLayout.Tab tab) {} + + @Override + public void onTabUnselected(TabLayout.Tab tab) {} + + @Override + public void onTabReselected(TabLayout.Tab tab) { + scrollToTop(); + } + }); + + pager.setOffscreenPageLimit(4); + pager.setUserInputEnabled(!GlobalUserPreferences.disableSwipe); + pager.setAdapter(new DiscoverPagerAdapter()); + pager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback(){ + @Override + 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); + if(_page instanceof BaseRecyclerFragment page){ + if(!page.loaded && !page.isDataLoading()) + page.loadData(); + } + } + }); + + if(allNotificationsFragment==null){ + Bundle args=new Bundle(); + args.putString("account", accountID); + args.putBoolean("__is_tab", true); + + allNotificationsFragment=new NotificationsListFragment(); + allNotificationsFragment.setArguments(args); + + args=new Bundle(args); + args.putBoolean("onlyMentions", true); + mentionsFragment=new NotificationsListFragment(); + mentionsFragment.setArguments(args); + + getChildFragmentManager().beginTransaction() + .add(R.id.notifications_all, allNotificationsFragment) + .add(R.id.notifications_mentions, mentionsFragment) + .commit(); + } + + tabLayoutMediator=new TabLayoutMediator(tabLayout, pager, new TabLayoutMediator.TabConfigurationStrategy(){ + @Override + public void onConfigureTab(@NonNull TabLayout.Tab tab, int position){ + tab.setText(switch(position){ + case 0 -> R.string.all_notifications; + case 1 -> R.string.mentions; + default -> throw new IllegalStateException("Unexpected value: "+position); + }); + } + }); + tabLayoutMediator.attach(); + + return view; + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + elevationOnScrollListener = new ElevationOnScrollListener((FragmentRootLinearLayout) view, getToolbar(), tabLayout); + elevationOnScrollListener.setDivider(tabsDivider); + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + if (elevationOnScrollListener == null) return; + elevationOnScrollListener.setViews(getToolbar(), tabLayout); + if (getCurrentFragment() instanceof IsOnTop f) { + elevationOnScrollListener.handleScroll(getContext(), f.isOnTop()); + } + } + + @Override + public ElevationOnScrollListener getElevationOnScrollListener() { + return elevationOnScrollListener; + } + + public void refreshFollowRequestsBadge() { + new GetFollowRequests(null, 1).setCallback(new Callback<>() { + @Override + public void onSuccess(HeaderPaginationList accounts) { + if(getActivity()==null) return; + getToolbar().getMenu().findItem(R.id.follow_requests).setVisible(!accounts.isEmpty()); + } + + @Override + public void onError(ErrorResponse errorResponse) {} + }).exec(accountID); + } + + @Subscribe + public void onFollowRequestHandled(FollowRequestHandledEvent ev) { + refreshFollowRequestsBadge(); + } + + @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) + allNotificationsFragment.loadData(); + } + + @Override + protected void updateToolbar(){ + super.updateToolbar(); + getToolbar().setOutlineProvider(null); + getToolbar().setOnClickListener(v->scrollToTop()); + } + + private NotificationsListFragment getFragmentForPage(int page){ + return switch(page){ + case 0 -> allNotificationsFragment; + case 1 -> mentionsFragment; + default -> throw new IllegalStateException("Unexpected value: "+page); + }; + } + + public Fragment getCurrentFragment() { + return getFragmentForPage(pager.getCurrentItem()); + } + + @Override + public void onProvideAssistContent(AssistContent assistContent) { + callFragmentToProvideAssistContent(getFragmentForPage(pager.getCurrentItem()), assistContent); + } + + @Override + public String getAccountID(){ + return accountID; + } + + private class DiscoverPagerAdapter extends RecyclerView.Adapter{ + @NonNull + @Override + public SimpleViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){ + FrameLayout view=tabViews[viewType]; + if (view.getParent() != null) ((ViewGroup)view.getParent()).removeView(view); + view.setVisibility(View.VISIBLE); + view.setLayoutParams(new RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); + return new SimpleViewHolder(view); + } + + @Override + public void onBindViewHolder(@NonNull SimpleViewHolder holder, int position){} + + @Override + public int getItemCount(){ + return 2; + } + + @Override + public int getItemViewType(int position){ + return position; + } + } +} 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 f51dc6842..00f50e51d 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsListFragment.java @@ -4,6 +4,7 @@ import android.app.Activity; import android.graphics.Canvas; import android.graphics.Paint; import android.graphics.Rect; +import android.net.Uri; import android.os.Bundle; import android.text.TextUtils; import android.view.LayoutInflater; @@ -15,48 +16,56 @@ import android.view.View; 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; import org.joinmastodon.android.events.RemoveAccountPostsEvent; +import org.joinmastodon.android.events.StatusCountersUpdatedEvent; import org.joinmastodon.android.model.Notification; import org.joinmastodon.android.model.PaginatedResponse; import org.joinmastodon.android.model.Status; -import org.joinmastodon.android.ui.OutlineProviders; +import org.joinmastodon.android.ui.displayitems.AccountCardStatusDisplayItem; +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.NotificationHeaderStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.StatusDisplayItem; +import org.joinmastodon.android.ui.displayitems.TextStatusDisplayItem; +import org.joinmastodon.android.ui.utils.DiscoverInfoBannerHelper; import org.joinmastodon.android.ui.utils.InsetStatusItemDecoration; import org.joinmastodon.android.ui.utils.UiUtils; -import org.joinmastodon.android.ui.views.NestedRecyclerScrollView; import org.joinmastodon.android.utils.ObjectIdComparator; 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; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; -import me.grishka.appkit.Nav; import me.grishka.appkit.api.SimpleCallback; +import me.grishka.appkit.utils.MergeRecyclerAdapter; public class NotificationsListFragment extends BaseStatusListFragment{ private boolean onlyMentions; private String maxID; - private View tabBar; - private View mentionsTab, allTab; - private View endMark; - private String unreadMarker, realUnreadMarker; - private MenuItem markAllReadItem; private boolean reloadingFromCache; + private DiscoverInfoBannerHelper bannerHelper; + + @Override + protected boolean wantsComposeButton() { + return false; + } @Override public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); - setLayout(R.layout.fragment_notifications); E.register(this); onlyMentions=AccountSessionManager.get(accountID).isNotificationsMentionsOnly(); setHasOptionsMenu(true); @@ -76,19 +85,59 @@ 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; if(n.type==Notification.Type.MENTION || n.type==Notification.Type.STATUS){ titleItem=null; }else{ titleItem=new NotificationHeaderStatusDisplayItem(n.id, this, n, accountID); - if(n.status!=null){ - n.status.card=null; - n.status.spoilerText=null; - } + } + if (n.type == Notification.Type.FOLLOW_REQUEST || n.type == Notification.Type.FOLLOW) { + ArrayList items = new ArrayList<>(); + items.add(titleItem); + items.add(new AccountCardStatusDisplayItem(n.id, this, accountID, n.account, n)); + return items; } if(n.status!=null){ - int flags=titleItem==null ? 0 : (StatusDisplayItem.FLAG_NO_FOOTER | StatusDisplayItem.FLAG_INSET | StatusDisplayItem.FLAG_NO_HEADER); - ArrayList items=StatusDisplayItem.buildItems(this, n.status, accountID, n, knownAccounts, flags); + int flags=titleItem==null ? 0 : (StatusDisplayItem.FLAG_NO_FOOTER | StatusDisplayItem.FLAG_INSET | StatusDisplayItem.FLAG_NO_EMOJI_REACTIONS); // | StatusDisplayItem.FLAG_NO_HEADER); + if (GlobalUserPreferences.spectatorMode) + flags |= StatusDisplayItem.FLAG_NO_FOOTER; + if (!GlobalUserPreferences.showMediaPreview) + flags |= StatusDisplayItem.FLAG_NO_MEDIA_PREVIEW; + ArrayList items=StatusDisplayItem.buildItems(this, n.status, accountID, n, knownAccounts, null, flags); if(titleItem!=null) items.add(0, titleItem); return items; @@ -98,34 +147,54 @@ public class NotificationsListFragment extends BaseStatusListFragment0 ? maxID : null, count, onlyMentions, refreshing && !reloadingFromCache, new SimpleCallback<>(this){ + .getNotifications(offset>0 ? maxID : null, count, onlyMentions, onlyPosts, refreshing && !reloadingFromCache, new SimpleCallback<>(this){ @Override public void onSuccess(PaginatedResponse> result){ if(getActivity()==null) return; - onDataLoaded(result.items.stream().filter(n->n.type!=null).collect(Collectors.toList()), !result.items.isEmpty()); + + 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; - endMark.setVisibility(result.items.isEmpty() ? View.VISIBLE : View.GONE); + onDataLoaded(result.items.stream().filter(n->n.type!=null).collect(Collectors.toList()), !result.items.isEmpty()); + if(bannerHelper!=null) bannerHelper.onBannerBecameVisible(); reloadingFromCache=false; + if (getParentFragment() instanceof NotificationsFragment nf) { + nf.updateMarkAllReadButton(); + } } }); } + @Override + protected void onRelationshipsLoaded(){ + if(getActivity()==null) + return; + for(int i=0;ilist); - scroller.setTakePriorityOverChildViews(true); - list.addItemDecoration(new RecyclerView.ItemDecoration(){ private Paint paint=new Paint(); private Rect tmpRect=new Rect(); @@ -192,15 +233,17 @@ public class NotificationsListFragment extends BaseStatusListFragment holder){ - String itemID=holder.getItemID(); - if(ObjectIdComparator.INSTANCE.compare(itemID, unreadMarker)>0){ - parent.getDecoratedBoundsWithMargins(child, tmpRect); - c.drawRect(tmpRect, paint); + if (getParentFragment() instanceof NotificationsFragment nf) { + if(TextUtils.isEmpty(nf.unreadMarker)) + return; + for(int i=0;i holder){ + String itemID=holder.getItemID(); + if(ObjectIdComparator.INSTANCE.compare(itemID, nf.unreadMarker)>0){ + parent.getDecoratedBoundsWithMargins(child, tmpRect); + c.drawRect(tmpRect, paint); + } } } } @@ -210,9 +253,13 @@ public class NotificationsListFragment extends BaseStatusListFragment getViewsForElevationEffect(){ - ArrayList views=new ArrayList<>(super.getViewsForElevationEffect()); - views.add(tabBar); - return views; + if (getParentFragment() instanceof NotificationsFragment nf) { + ArrayList views=new ArrayList<>(super.getViewsForElevationEffect()); + views.add(nf.tabLayout); + return views; + } else { + return super.getViewsForElevationEffect(); + } } private Notification getNotificationByID(String id){ @@ -232,7 +279,57 @@ 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 - public void onAppendItems(List items){ - super.onAppendItems(items); - if(data.isEmpty() || data.get(0).id.equals(realUnreadMarker)) - return; - for(Notification n:items){ - if(ObjectIdComparator.INSTANCE.compare(n.id, realUnreadMarker)<=0){ - markAsRead(); - break; - } - } + protected RecyclerView.Adapter getAdapter(){ + if (bannerHelper == null) return super.getAdapter(); + MergeRecyclerAdapter adapter=new MergeRecyclerAdapter(); + bannerHelper.maybeAddBanner(list, adapter); + adapter.addAdapter(super.getAdapter()); + return adapter; + } + + @Override + public Uri getWebUri(Uri.Builder base) { + return base.path(isInstanceAkkoma() + ? "/users/" + getSession().self.username + "/interactions" + : "/notifications").build(); } private boolean canRefreshWithoutUpsettingUser(){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/PinnableStatusListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/PinnableStatusListFragment.java new file mode 100644 index 000000000..d1c6fcfbf --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/PinnableStatusListFragment.java @@ -0,0 +1,71 @@ +package org.joinmastodon.android.fragments; + +import android.os.Bundle; +import android.view.HapticFeedbackConstants; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.widget.Toast; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.api.session.AccountLocalPreferences; +import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.model.TimelineDefinition; + +import java.util.ArrayList; +import java.util.List; + +public abstract class PinnableStatusListFragment extends StatusListFragment { + protected List timelines; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + timelines=new ArrayList<>(AccountSessionManager.get(accountID).getLocalPreferences().timelines); + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + super.onCreateOptionsMenu(menu, inflater); + updatePinButton(menu.findItem(R.id.pin)); + } + + protected boolean isPinned() { + return timelines.contains(makeTimelineDefinition()); + } + + protected void updatePinButton(MenuItem pin) { + boolean pinned = isPinned(); + pin.setIcon(pinned ? + R.drawable.ic_fluent_pin_24_filled : + R.drawable.ic_fluent_pin_24_regular); + pin.setTitle(pinned ? R.string.sk_unpin_timeline : R.string.sk_pin_timeline); + } + + protected abstract TimelineDefinition makeTimelineDefinition(); + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == R.id.pin) { + togglePin(item); + return true; + } + return super.onOptionsItemSelected(item); + } + + protected void togglePin(MenuItem pin) { + onPinnedUpdated(true); + getToolbar().performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK); + TimelineDefinition def = makeTimelineDefinition(); + boolean pinned = isPinned(); + if (pinned) timelines.remove(def); + else timelines.add(def); + Toast.makeText(getContext(), pinned ? R.string.sk_unpinned_timeline : R.string.sk_pinned_timeline, Toast.LENGTH_SHORT).show(); + AccountLocalPreferences prefs=AccountSessionManager.get(accountID).getLocalPreferences(); + prefs.timelines=new ArrayList<>(timelines); + prefs.save(); + updatePinButton(pin); + } + + public void onPinnedUpdated(boolean pinned) {} +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/PinnedPostsListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/PinnedPostsListFragment.java index 65dafa5b0..ff6b16780 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/PinnedPostsListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/PinnedPostsListFragment.java @@ -1,10 +1,13 @@ package org.joinmastodon.android.fragments; +import android.net.Uri; import android.os.Bundle; import org.joinmastodon.android.R; import org.joinmastodon.android.api.requests.accounts.GetAccountStatuses; +import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.model.FilterContext; import org.joinmastodon.android.model.Status; import org.parceler.Parcels; @@ -15,6 +18,10 @@ import me.grishka.appkit.api.SimpleCallback; public class PinnedPostsListFragment extends StatusListFragment{ private Account account; + public PinnedPostsListFragment() { + setListLayoutId(R.layout.recycler_fragment_no_refresh); + } + @Override public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); @@ -29,8 +36,20 @@ public class PinnedPostsListFragment extends StatusListFragment{ .setCallback(new SimpleCallback<>(this){ @Override public void onSuccess(List result){ + if(getActivity()==null) return; + AccountSessionManager.get(accountID).filterStatuses(result, getFilterContext()); onDataLoaded(result, false); } }).exec(accountID); } + + @Override + protected FilterContext getFilterContext() { + return FilterContext.ACCOUNT; + } + + @Override + public Uri getWebUri(Uri.Builder base) { + return Uri.parse(account.url); + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileAboutFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileAboutFragment.java index 061e981cc..09390c0e7 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileAboutFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileAboutFragment.java @@ -44,7 +44,7 @@ import me.grishka.appkit.utils.V; import me.grishka.appkit.views.UsableRecyclerView; public class ProfileAboutFragment extends Fragment implements WindowInsetsAwareFragment{ - private static final int MAX_FIELDS=4; + static final int MAX_FIELDS=4; public UsableRecyclerView list; private List fields=Collections.emptyList(); @@ -181,13 +181,13 @@ public class ProfileAboutFragment extends Fragment implements WindowInsetsAwareF private class AboutViewHolder extends BaseViewHolder implements ImageLoaderViewHolder{ private final TextView title; private final LinkedTextView value; - private final ImageView verifiedIcon; +// private final ImageView verifiedIcon; public AboutViewHolder(){ super(R.layout.item_profile_about); title=findViewById(R.id.title); value=findViewById(R.id.value); - verifiedIcon=findViewById(R.id.verified_icon); +// verifiedIcon=findViewById(R.id.verified_icon); } @Override @@ -195,7 +195,7 @@ public class ProfileAboutFragment extends Fragment implements WindowInsetsAwareF super.onBind(item); title.setText(item.parsedName); value.setText(item.parsedValue); - verifiedIcon.setVisibility(item.verifiedAt!=null ? View.VISIBLE : View.GONE); +// verifiedIcon.setVisibility(item.verifiedAt!=null ? View.VISIBLE : View.GONE); } @Override @@ -310,7 +310,7 @@ public class ProfileAboutFragment extends Fragment implements WindowInsetsAwareF public void onSelectedChanged(@Nullable RecyclerView.ViewHolder viewHolder, int actionState){ super.onSelectedChanged(viewHolder, actionState); if(actionState==ItemTouchHelper.ACTION_STATE_DRAG){ - viewHolder.itemView.setTag(R.id.item_touch_helper_previous_elevation, viewHolder.itemView.getElevation()); // prevents the default behavior of changing elevation in onDraw() + viewHolder.itemView.setTag(me.grishka.appkit.R.id.item_touch_helper_previous_elevation, viewHolder.itemView.getElevation()); // prevents the default behavior of changing elevation in onDraw() viewHolder.itemView.animate().translationZ(V.dp(1)).setDuration(200).setInterpolator(CubicBezierInterpolator.DEFAULT).start(); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFeaturedFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFeaturedFragment.java deleted file mode 100644 index 5f915057b..000000000 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFeaturedFragment.java +++ /dev/null @@ -1,204 +0,0 @@ -package org.joinmastodon.android.fragments; - -import android.graphics.Canvas; -import android.graphics.Paint; -import android.os.Bundle; -import android.os.Parcelable; -import android.view.View; - -import org.joinmastodon.android.R; -import org.joinmastodon.android.api.requests.accounts.GetAccountFeaturedHashtags; -import org.joinmastodon.android.api.requests.accounts.GetAccountStatuses; -import org.joinmastodon.android.model.Account; -import org.joinmastodon.android.model.Hashtag; -import org.joinmastodon.android.model.SearchResult; -import org.joinmastodon.android.model.Status; -import org.joinmastodon.android.ui.displayitems.AccountStatusDisplayItem; -import org.joinmastodon.android.ui.displayitems.FooterStatusDisplayItem; -import org.joinmastodon.android.ui.displayitems.HashtagStatusDisplayItem; -import org.joinmastodon.android.ui.displayitems.SectionHeaderStatusDisplayItem; -import org.joinmastodon.android.ui.displayitems.StatusDisplayItem; -import org.joinmastodon.android.ui.utils.UiUtils; -import org.parceler.Parcels; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.stream.Collectors; - -import androidx.recyclerview.widget.RecyclerView; -import me.grishka.appkit.Nav; -import me.grishka.appkit.api.SimpleCallback; - -public class ProfileFeaturedFragment extends BaseStatusListFragment{ - private Account profileAccount; - private List featuredTags; -// private List endorsedAccounts; - private List pinnedStatuses; - private boolean tagsLoaded, statusesLoaded; - - public ProfileFeaturedFragment(){ - setListLayoutId(R.layout.recycler_fragment_no_refresh); - } - - @Override - public void onCreate(Bundle savedInstanceState){ - super.onCreate(savedInstanceState); - profileAccount=Parcels.unwrap(getArguments().getParcelable("profileAccount")); - } - - @Override - protected List buildDisplayItems(SearchResult s){ - ArrayList items=switch(s.type){ - case ACCOUNT -> new ArrayList<>(Collections.singletonList(new AccountStatusDisplayItem(s.id, this, s.account))); - case HASHTAG -> new ArrayList<>(Collections.singletonList(new HashtagStatusDisplayItem(s.id, this, s.hashtag))); - case STATUS -> StatusDisplayItem.buildItems(this, s.status, accountID, s, knownAccounts, false, true); - }; - - if(s.firstInSection){ - items.add(0, new SectionHeaderStatusDisplayItem(this, getString(switch(s.type){ - case ACCOUNT -> R.string.profile_endorsed_accounts; - case HASHTAG -> R.string.hashtags; - case STATUS -> R.string.posts; - }), getString(R.string.view_all), switch(s.type){ - case ACCOUNT -> (Runnable)this::showAllEndorsedAccounts; - case HASHTAG -> (Runnable)this::showAllFeaturedHashtags; - case STATUS -> (Runnable)this::showAllPinnedPosts; - })); - } - - return items; - } - - @Override - protected void addAccountToKnown(SearchResult s){ - Account acc=switch(s.type){ - case ACCOUNT -> s.account; - case STATUS -> s.status.account; - case HASHTAG -> null; - }; - if(acc!=null && !knownAccounts.containsKey(acc.id)) - knownAccounts.put(acc.id, acc); - } - - @Override - public void onItemClick(String id){ - SearchResult res=getResultByID(id); - if(res==null) - return; - switch(res.type){ - case ACCOUNT -> { - Bundle args=new Bundle(); - args.putString("account", accountID); - args.putParcelable("profileAccount", Parcels.wrap(res.account)); - Nav.go(getActivity(), ProfileFragment.class, args); - } - case HASHTAG -> UiUtils.openHashtagTimeline(getActivity(), accountID, res.hashtag); - case STATUS -> { - Status status=res.status.getContentStatus(); - Bundle args=new Bundle(); - args.putString("account", accountID); - args.putParcelable("status", Parcels.wrap(status)); - if(status.inReplyToAccountId!=null && knownAccounts.containsKey(status.inReplyToAccountId)) - args.putParcelable("inReplyToAccount", Parcels.wrap(knownAccounts.get(status.inReplyToAccountId))); - Nav.go(getActivity(), ThreadFragment.class, args); - } - } - } - - @Override - protected void doLoadData(int offset, int count){ - if(!statusesLoaded){ - new GetAccountStatuses(profileAccount.id, null, null, 2, GetAccountStatuses.Filter.PINNED) - .setCallback(new SimpleCallback<>(this){ - @Override - public void onSuccess(List result){ - pinnedStatuses=result; - statusesLoaded=true; - onOneApiRequestCompleted(); - } - }) - .exec(accountID); - } - if(!tagsLoaded){ - new GetAccountFeaturedHashtags(profileAccount.id) - .setCallback(new SimpleCallback<>(this){ - @Override - public void onSuccess(List result){ - featuredTags=result; - tagsLoaded=true; - onOneApiRequestCompleted(); - } - }) - .exec(accountID); - } - } - - @Override - protected void onShown(){ - super.onShown(); - if(!getArguments().getBoolean("noAutoLoad") && !loaded && !dataLoading) - loadData(); - } - - @Override - public void onRefresh(){ - statusesLoaded=false; - tagsLoaded=false; - super.onRefresh(); - } - - private void onOneApiRequestCompleted(){ - if(getActivity()==null) - return; - if(tagsLoaded && statusesLoaded){ - ArrayList results=new ArrayList<>(); - for(int i=0;i sdi && sdi.getItemID().startsWith("post_")){ - super.drawDivider(child, bottomSibling, holder, siblingHolder, parent, c, paint); - } - } - - private void showAllPinnedPosts(){ - Bundle args=new Bundle(); - args.putString("account", accountID); - args.putParcelable("profileAccount", Parcels.wrap(profileAccount)); - Nav.go(getActivity(), PinnedPostsListFragment.class, args); - } - - private void showAllFeaturedHashtags(){ - Bundle args=new Bundle(); - args.putString("account", accountID); - ArrayList tags=featuredTags.stream().map(Parcels::wrap).collect(Collectors.toCollection(ArrayList::new)); - args.putParcelableArrayList("hashtags", tags); - Nav.go(getActivity(), FeaturedHashtagsListFragment.class, args); - } - - private void showAllEndorsedAccounts(){ - - } -} 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 900319fbe..01da12239 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFragment.java @@ -4,77 +4,106 @@ 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.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; import android.content.res.TypedArray; +import android.graphics.Color; import android.graphics.Outline; import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.Drawable; +import android.graphics.drawable.GradientDrawable; 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.style.ImageSpan; +import android.text.TextWatcher; import android.transition.ChangeBounds; import android.transition.Fade; import android.transition.TransitionManager; import android.transition.TransitionSet; +import android.view.Gravity; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; +import android.view.SubMenu; import android.view.View; import android.view.ViewGroup; import android.view.ViewOutlineProvider; import android.view.ViewTreeObserver; import android.view.WindowInsets; +import android.view.inputmethod.InputMethodManager; +import android.view.animation.TranslateAnimation; +import android.widget.Button; import android.widget.EditText; import android.widget.FrameLayout; +import android.widget.ImageButton; import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.ProgressBar; import android.widget.TextView; 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.R; import org.joinmastodon.android.api.requests.accounts.GetAccountByID; import org.joinmastodon.android.api.requests.accounts.GetAccountRelationships; +import org.joinmastodon.android.api.requests.accounts.GetAccountStatuses; import org.joinmastodon.android.api.requests.accounts.GetOwnAccount; import org.joinmastodon.android.api.requests.accounts.SetAccountFollowed; +import org.joinmastodon.android.api.requests.accounts.SetPrivateNote; import org.joinmastodon.android.api.requests.accounts.UpdateAccountCredentials; +import org.joinmastodon.android.api.requests.instance.GetInstance; import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.fragments.account_list.BlockedAccountsListFragment; +import org.joinmastodon.android.fragments.account_list.BlocksListFragment; import org.joinmastodon.android.fragments.account_list.FollowerListFragment; import org.joinmastodon.android.fragments.account_list.FollowingListFragment; +import org.joinmastodon.android.fragments.account_list.MutedAccountsListFragment; +import org.joinmastodon.android.fragments.account_list.MutesListFragment; import org.joinmastodon.android.fragments.report.ReportReasonChoiceFragment; +import org.joinmastodon.android.fragments.settings.SettingsServerFragment; import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.AccountField; import org.joinmastodon.android.model.Attachment; +import org.joinmastodon.android.model.Instance; import org.joinmastodon.android.model.Relationship; +import org.joinmastodon.android.ui.BetterItemAnimator; import org.joinmastodon.android.ui.M3AlertDialogBuilder; 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; import org.joinmastodon.android.ui.text.HtmlParser; -import org.joinmastodon.android.ui.text.ImageSpanThatDoesNotBreakShitForNoGoodReason; import org.joinmastodon.android.ui.utils.SimpleTextWatcher; import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.ui.views.CoverImageView; import org.joinmastodon.android.ui.views.CustomDrawingOrderLinearLayout; +import org.joinmastodon.android.ui.views.LinkedTextView; import org.joinmastodon.android.ui.views.NestedRecyclerScrollView; import org.joinmastodon.android.ui.views.ProgressBarButton; import org.joinmastodon.android.utils.ElevationOnScrollListener; +import org.joinmastodon.android.utils.ProvidesAssistContent; import org.parceler.Parcels; import java.time.LocalDateTime; @@ -85,10 +114,9 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; -import androidx.annotation.NonNull; -import androidx.recyclerview.widget.RecyclerView; -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; -import androidx.viewpager2.widget.ViewPager2; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.ItemTouchHelper; +import androidx.recyclerview.widget.LinearLayoutManager; import me.grishka.appkit.Nav; import me.grishka.appkit.api.Callback; import me.grishka.appkit.api.ErrorResponse; @@ -96,51 +124,60 @@ import me.grishka.appkit.api.SimpleCallback; import me.grishka.appkit.fragments.BaseRecyclerFragment; import me.grishka.appkit.fragments.LoaderFragment; import me.grishka.appkit.fragments.OnBackPressedListener; +import me.grishka.appkit.imageloader.ImageLoaderRecyclerAdapter; +import me.grishka.appkit.imageloader.ImageLoaderViewHolder; +import me.grishka.appkit.imageloader.ListImageLoaderWrapper; +import me.grishka.appkit.imageloader.RecyclerViewDelegate; import me.grishka.appkit.imageloader.ViewImageLoader; +import me.grishka.appkit.imageloader.requests.ImageLoaderRequest; import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest; +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 me.grishka.appkit.views.UsableRecyclerView; -public class ProfileFragment extends LoaderFragment implements OnBackPressedListener, ScrollableToTop{ +public class ProfileFragment extends LoaderFragment implements OnBackPressedListener, ScrollableToTop, HasFab, ProvidesAssistContent.ProvidesWebUri { private static final int AVATAR_RESULT=722; private static final int COVER_RESULT=343; private ImageView avatar; private CoverImageView cover; private View avatarBorder; - private TextView name, username, usernameDomain, bio, followersCount, followersLabel, followingCount, followingLabel; - private ProgressBarButton actionButton; + private TextView name, username, bio, followersCount, followersLabel, followingCount, followingLabel; + private ImageView lockIcon, botIcon; + private ProgressBarButton actionButton, notifyButton; private ViewPager2 pager; private NestedRecyclerScrollView scrollView; - private ProfileFeaturedFragment featuredFragment; - private AccountTimelineFragment timelineFragment; - private ProfileAboutFragment aboutFragment; + private AccountTimelineFragment postsFragment, postsWithRepliesFragment, mediaFragment; + private PinnedPostsListFragment pinnedPostsFragment; private TabLayout tabbar; private SwipeRefreshLayout refreshLayout; private View followersBtn, followingBtn; private EditText nameEdit, bioEdit; - private ProgressBar actionProgress; + 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; - private Account account; + private Account account, remoteAccount; private String accountID; + private String domain; private Relationship relationship; private boolean isOwnProfile; - private ArrayList fields=new ArrayList<>(); + private List fields=new ArrayList<>(); private boolean isInEditMode, editDirty; private Uri editNewAvatar, editNewCover; private String profileAccountID; private boolean refreshing; - private View fab; + private ImageButton fab; private WindowInsets childInsets; private PhotoViewer currentPhotoViewer; private boolean editModeLoading; @@ -151,6 +188,18 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList private MenuItem editSaveMenuItem; private boolean savingEdits; + private int maxFields = ProfileAboutFragment.MAX_FIELDS; + + // from ProfileAboutFragment + public UsableRecyclerView list; + private AboutAdapter adapter; + private ItemTouchHelper dragHelper=new ItemTouchHelper(new ReorderCallback()); + private ListImageLoaderWrapper imgLoader; + + // profile note + private FrameLayout noteWrap; + private EditText noteEdit; + @Override public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); @@ -158,13 +207,21 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList setRetainInstance(true); accountID=getArguments().getString("account"); - if(getArguments().containsKey("profileAccount")){ + domain=AccountSessionManager.getInstance().getAccount(accountID).domain; + if (getArguments().containsKey("remoteAccount")) { + remoteAccount = Parcels.unwrap(getArguments().getParcelable("remoteAccount")); + if(!getArguments().getBoolean("noAutoLoad", false)) + loadData(); + } else if(getArguments().containsKey("profileAccount")){ account=Parcels.unwrap(getArguments().getParcelable("profileAccount")); profileAccountID=account.id; isOwnProfile=AccountSessionManager.getInstance().isSelf(accountID, account); loaded=true; if(!isOwnProfile) loadRelationship(); + else if (isInstanceAkkoma()) { + maxFields = (int) getInstance().get().pleroma.metadata.fieldsLimits.maxFields; + } }else{ profileAccountID=getArguments().getString("profileAccountID"); if(!getArguments().getBoolean("noAutoLoad", false)) @@ -172,6 +229,11 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList } } + private String getPrefilledText() { + return account == null || AccountSessionManager.getInstance().isSelf(accountID, account) + ? null : '@'+account.acct+' '; + } + @Override public void onAttach(Activity activity){ super.onAttach(activity); @@ -186,8 +248,10 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList cover=content.findViewById(R.id.cover); avatarBorder=content.findViewById(R.id.avatar_border); 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); followersCount=content.findViewById(R.id.followers_count); followersLabel=content.findViewById(R.id.followers_label); @@ -196,6 +260,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList followingLabel=content.findViewById(R.id.following_label); followingBtn=content.findViewById(R.id.following_btn); actionButton=content.findViewById(R.id.profile_action_btn); + notifyButton=content.findViewById(R.id.notify_btn); pager=content.findViewById(R.id.pager); scrollView=content.findViewById(R.id.scroller); tabbar=content.findViewById(R.id.tabbar); @@ -205,16 +270,34 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList nameEditWrap=content.findViewById(R.id.name_edit_wrap); bioEditWrap=content.findViewById(R.id.bio_edit_wrap); actionProgress=content.findViewById(R.id.action_progress); + notifyProgress=content.findViewById(R.id.notify_progress); fab=content.findViewById(R.id.fab); followsYouView=content.findViewById(R.id.follows_you); countersLayout=content.findViewById(R.id.profile_counters); tabsDivider=content.findViewById(R.id.tabs_divider); actionButtonWrap=content.findViewById(R.id.profile_action_btn_wrap); scrollableContent=content.findViewById(R.id.scrollable_content); + list=content.findViewById(R.id.metadata); + rolesView=content.findViewById(R.id.roles); + avatarBorder.setOutlineProvider(OutlineProviders.roundedRect(26)); + avatarBorder.setClipToOutline(true); avatar.setOutlineProvider(OutlineProviders.roundedRect(24)); avatar.setClipToOutline(true); + noteEdit=content.findViewById(R.id.note_edit); + noteWrap=content.findViewById(R.id.note_edit_wrap); + + noteEdit.setOnFocusChangeListener((v, hasFocus)->{ + if(hasFocus){ + hideFab(); + noteEdit.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_MULTI_LINE | InputType.TYPE_TEXT_FLAG_CAP_SENTENCES); + }else{ + showFab(); + savePrivateNote(noteEdit.getText().toString()); + } + }); + FrameLayout sizeWrapper=new FrameLayout(getActivity()){ @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec){ @@ -223,13 +306,14 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList } }; - tabViews=new FrameLayout[3]; + tabViews=new FrameLayout[4]; for(int i=0;i R.id.profile_featured; - case 1 -> R.id.profile_timeline; - case 2 -> R.id.profile_about; + case 0 -> R.id.profile_posts; + case 1 -> R.id.profile_posts_with_replies; + case 2 -> R.id.profile_pinned_posts; + case 3 -> R.id.profile_media; default -> throw new IllegalStateException("Unexpected value: "+i); }); tabView.setVisibility(View.GONE); @@ -237,11 +321,14 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList tabViews[i]=tabView; } + UiUtils.reduceSwipeSensitivity(pager); pager.setOffscreenPageLimit(4); + pager.setUserInputEnabled(!GlobalUserPreferences.disableSwipe); pager.setAdapter(new ProfilePagerAdapter()); pager.getLayoutParams().height=getResources().getDisplayMetrics().heightPixels; scrollView.setScrollableChildSupplier(this::getScrollableRecyclerView); + scrollView.getViewTreeObserver().addOnGlobalLayoutListener(this::updateMetadataHeight); sizeWrapper.addView(content); @@ -253,6 +340,19 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList case 2 -> R.string.profile_about; default -> throw new IllegalStateException(); })); + tabLayoutMediator=new TabLayoutMediator(tabbar, pager, new TabLayoutMediator.TabConfigurationStrategy(){ + @Override + public void onConfigureTab(@NonNull TabLayout.Tab tab, int position){ + tab.setText(switch(position){ + case 0 -> R.string.posts; + case 1 -> R.string.posts_and_replies; + case 2 -> R.string.sk_pinned_posts; + case 3 -> R.string.media; + default -> throw new IllegalStateException(); + }); + if (position == 4) tab.view.setVisibility(View.GONE); + } + }); tabbar.addOnTabSelectedListener(new TabLayout.OnTabSelectedListener(){ @Override public void onTabSelected(TabLayout.Tab tab){} @@ -275,15 +375,19 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList }); actionButton.setOnClickListener(this::onActionButtonClick); + actionButton.setOnLongClickListener(this::onActionButtonLongClick); + notifyButton.setOnClickListener(this::onNotifyButtonClick); avatar.setOnClickListener(this::onAvatarClick); cover.setOnClickListener(this::onCoverClick); refreshLayout.setOnRefreshListener(this); fab.setOnClickListener(this::onFabClick); + fab.setOnLongClickListener(v->UiUtils.pickAccountForCompose(getActivity(), accountID, getPrefilledText())); if(savedInstanceState!=null){ - featuredFragment=(ProfileFeaturedFragment) getChildFragmentManager().getFragment(savedInstanceState, "featured"); - timelineFragment=(AccountTimelineFragment) getChildFragmentManager().getFragment(savedInstanceState, "timeline"); - aboutFragment=(ProfileAboutFragment) getChildFragmentManager().getFragment(savedInstanceState, "about"); + postsFragment=(AccountTimelineFragment) getChildFragmentManager().getFragment(savedInstanceState, "posts"); + postsWithRepliesFragment=(AccountTimelineFragment) getChildFragmentManager().getFragment(savedInstanceState, "postsWithReplies"); + mediaFragment=(AccountTimelineFragment) getChildFragmentManager().getFragment(savedInstanceState, "media"); + pinnedPostsFragment=(PinnedPostsListFragment) getChildFragmentManager().getFragment(savedInstanceState, "pinnedPosts"); } if(loaded){ @@ -297,16 +401,49 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList followersBtn.setOnClickListener(this::onFollowersOrFollowingClick); followingBtn.setOnClickListener(this::onFollowersOrFollowingClick); - username.setOnLongClickListener(v->{ - String username=account.acct; - if(!username.contains("@")){ - username+="@"+AccountSessionManager.getInstance().getAccount(accountID).domain; + 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(); } - getActivity().getSystemService(ClipboardManager.class).setPrimaryClip(ClipData.newPlainText(null, "@"+username)); + }); + + content.findViewById(R.id.username_wrap).setOnLongClickListener(v->{ + String usernameString=account.acct; + if(!usernameString.contains("@")){ + usernameString+="@"+domain; + } + getActivity().getSystemService(ClipboardManager.class).setPrimaryClip(ClipData.newPlainText(null, "@"+usernameString)); UiUtils.maybeShowTextCopiedToast(getActivity()); return true; }); + // from ProfileAboutFragment + list.setItemAnimator(new BetterItemAnimator()); + list.setDrawSelectorOnTop(true); + list.setLayoutManager(new LinearLayoutManager(getActivity())); + imgLoader=new ListImageLoaderWrapper(getActivity(), list, new RecyclerViewDelegate(list), null); + list.setAdapter(adapter=new AboutAdapter()); + list.setClipToPadding(false); + scrollableContent.setDrawingOrderCallback((count, pos)->{ // The header is the first child, draw it last to overlap everything for the photo viewer transition to look nice if(pos==count-1) @@ -323,36 +460,88 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList nameEdit.addTextChangedListener(new SimpleTextWatcher(e->editDirty=true)); bioEdit.addTextChangedListener(new SimpleTextWatcher(e->editDirty=true)); - usernameDomain.setOnClickListener(v->new DecentralizationExplainerSheet(getActivity(), accountID, account).show()); - return sizeWrapper; } + private void showPrivateNote(){ + noteWrap.setVisibility(View.VISIBLE); + noteEdit.setText(relationship.note); + } + + private void hidePrivateNote(){ + noteWrap.setVisibility(View.GONE); + noteEdit.setText(null); + } + + private void savePrivateNote(String note){ + if(note!=null && note.equals(relationship.note)){ + updateRelationship(); + invalidateOptionsMenu(); + return; + } + new SetPrivateNote(profileAccountID, note).setCallback(new Callback<>() { + @Override + public void onSuccess(Relationship result) { + updateRelationship(result); + invalidateOptionsMenu(); + } + + @Override + public void onError(ErrorResponse error) { + error.showToast(getContext()); + } + }).exec(accountID); + } + + private void onAccountLoaded(Account result) { + account=result; + isOwnProfile=AccountSessionManager.getInstance().isSelf(accountID, account); + bindHeaderView(); + dataLoaded(); + if(!tabLayoutMediator.isAttached()) + tabLayoutMediator.attach(); + if(!isOwnProfile) + loadRelationship(); + else + AccountSessionManager.getInstance().updateAccountInfo(accountID, account); + if(refreshing){ + refreshing=false; + refreshLayout.setRefreshing(false); + if(postsFragment.loaded) + postsFragment.onRefresh(); + if(postsWithRepliesFragment.loaded) + postsWithRepliesFragment.onRefresh(); + if(pinnedPostsFragment.loaded) + pinnedPostsFragment.onRefresh(); + if(mediaFragment.loaded) + mediaFragment.onRefresh(); + } + V.setVisibilityAnimated(fab, View.VISIBLE); + } + @Override protected void doLoadData(){ + if (remoteAccount != null) { + UiUtils.lookupAccountHandle(getContext(), accountID, remoteAccount.getFullyQualifiedName(), (c, args) -> { + if (getContext() == null) return; + if (args == null || !args.containsKey("profileAccount")) { + Toast.makeText(getContext(), getContext().getString( + R.string.sk_error_loading_profile, domain + ), Toast.LENGTH_SHORT).show(); + Nav.finish(this); + return; + } + onAccountLoaded(Parcels.unwrap(args.getParcelable("profileAccount"))); + }); + return; + } + currentRequest=new GetAccountByID(profileAccountID) .setCallback(new SimpleCallback<>(this){ @Override public void onSuccess(Account result){ - account=result; - isOwnProfile=AccountSessionManager.getInstance().isSelf(accountID, account); - bindHeaderView(); - dataLoaded(); - if(!tabLayoutMediator.isAttached()) - tabLayoutMediator.attach(); - if(!isOwnProfile) - loadRelationship(); - else - AccountSessionManager.getInstance().updateAccountInfo(accountID, account); - if(refreshing){ - refreshing=false; - refreshLayout.setRefreshing(false); - if(timelineFragment.loaded) - timelineFragment.onRefresh(); - if(featuredFragment.loaded) - featuredFragment.onRefresh(); - } - V.setVisibilityAnimated(fab, View.VISIBLE); + if(getActivity()==null) return; + onAccountLoaded(result); } }) .exec(accountID); @@ -360,6 +549,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; @@ -374,28 +568,21 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList args.putString("account", accountID); args.putParcelable("profileAccount", Parcels.wrap(account)); args.putBoolean("__is_tab", true); - args.putBoolean("noAutoLoad", true); - if(featuredFragment==null){ - featuredFragment=new ProfileFeaturedFragment(); - featuredFragment.setArguments(args); + if(postsFragment==null){ + postsFragment=AccountTimelineFragment.newInstance(accountID, account, GetAccountStatuses.Filter.DEFAULT, true); } - if(timelineFragment==null){ - timelineFragment=AccountTimelineFragment.newInstance(accountID, account, true); + if(postsWithRepliesFragment==null){ + postsWithRepliesFragment=AccountTimelineFragment.newInstance(accountID, account, GetAccountStatuses.Filter.INCLUDE_REPLIES, false); } - if(aboutFragment==null){ - aboutFragment=new ProfileAboutFragment(); - aboutFragment.setFields(fields); + if(mediaFragment==null){ + mediaFragment=AccountTimelineFragment.newInstance(accountID, account, GetAccountStatuses.Filter.MEDIA, false); } + if(pinnedPostsFragment==null){ + pinnedPostsFragment=new PinnedPostsListFragment(); + pinnedPostsFragment.setArguments(args); + } + setFields(fields); pager.getAdapter().notifyDataSetChanged(); - pager.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener(){ - @Override - public boolean onPreDraw(){ - pager.getViewTreeObserver().removeOnPreDrawListener(this); - pager.setCurrentItem(1, false); - tabbar.selectTab(tabbar.getTabAt(1)); - return true; - } - }); super.dataLoaded(); } @@ -439,6 +626,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList scrollView.getViewTreeObserver().removeOnPreDrawListener(this); tabBarIsAtTop=!scrollView.canScrollVertically(1) && scrollView.getHeight()>0; + if (UiUtils.isTrueBlackTheme()) tabBarIsAtTop=false; tabsColorBackground.setAlpha(tabBarIsAtTop ? 20 : 0); tabbar.setTranslationZ(tabBarIsAtTop ? V.dp(3) : 0); tabsDivider.setAlpha(tabBarIsAtTop ? 0 : 1); @@ -451,14 +639,23 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList @Override public void onSaveInstanceState(Bundle outState){ super.onSaveInstanceState(outState); - if(featuredFragment==null) + if(postsFragment==null) return; - if(featuredFragment.isAdded()) - getChildFragmentManager().putFragment(outState, "featured", featuredFragment); - if(timelineFragment.isAdded()) - getChildFragmentManager().putFragment(outState, "timeline", timelineFragment); - if(aboutFragment.isAdded()) - getChildFragmentManager().putFragment(outState, "about", aboutFragment); + if(postsFragment.isAdded()) + getChildFragmentManager().putFragment(outState, "posts", postsFragment); + if(postsWithRepliesFragment.isAdded()) + getChildFragmentManager().putFragment(outState, "postsWithReplies", postsWithRepliesFragment); + if(mediaFragment.isAdded()) + getChildFragmentManager().putFragment(outState, "media", mediaFragment); + if(pinnedPostsFragment.isAdded()) + getChildFragmentManager().putFragment(outState, "pinnedPosts", pinnedPostsFragment); + } + + @Override + public void onHidden(){ + if (relationship != null && !noteEdit.getText().toString().equals(relationship.note)){ + savePrivateNote(noteEdit.getText().toString()); + } } @Override @@ -484,42 +681,60 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList } private void applyChildWindowInsets(){ - if(timelineFragment!=null && timelineFragment.isAdded() && childInsets!=null){ - timelineFragment.onApplyWindowInsets(childInsets); - featuredFragment.onApplyWindowInsets(childInsets); + if(postsFragment!=null && postsFragment.isAdded() && childInsets!=null){ + postsFragment.onApplyWindowInsets(childInsets); + postsWithRepliesFragment.onApplyWindowInsets(childInsets); + pinnedPostsFragment.onApplyWindowInsets(childInsets); + mediaFragment.onApplyWindowInsets(childInsets); } } + @SuppressLint("SetTextI18n") private void bindHeaderView(){ - setTitle(account.displayName); + setTitle(account.getDisplayName()); setSubtitle(getResources().getQuantityString(R.plurals.x_posts, (int)(account.statusesCount%1000), account.statusesCount)); - ViewImageLoader.load(avatar, null, new UrlImageLoaderRequest(GlobalUserPreferences.playGifs ? account.avatar : account.avatarStatic, V.dp(100), V.dp(100))); + ViewImageLoader.load(avatar, null, new UrlImageLoaderRequest( + TextUtils.isEmpty(account.avatar) ? getSession().getDefaultAvatarUrl() : + GlobalUserPreferences.playGifs ? account.avatar : account.avatarStatic, + V.dp(100), V.dp(100))); ViewImageLoader.load(cover, null, new UrlImageLoaderRequest(GlobalUserPreferences.playGifs ? account.header : account.headerStatic, 1000, 1000)); - SpannableStringBuilder ssb=new SpannableStringBuilder(account.displayName); + SpannableStringBuilder ssb=new SpannableStringBuilder(account.getDisplayName()); if(AccountSessionManager.get(accountID).getLocalPreferences().customEmojiInNames) HtmlParser.parseCustomEmoji(ssb, account.emojis); name.setText(ssb); setTitle(ssb); + if (account.roles != null && !account.roles.isEmpty()) { + rolesView.setVisibility(View.VISIBLE); + rolesView.removeAllViews(); + name.setPadding(0, 0, V.dp(12), 0); + for (Account.Role role : account.roles) { + TextView roleText = new TextView(getActivity(), null, 0, R.style.role_label); + roleText.setText(role.name); + roleText.setGravity(Gravity.CENTER_VERTICAL); + if (!TextUtils.isEmpty(role.color) && role.color.startsWith("#")) try { + GradientDrawable bg = (GradientDrawable) roleText.getBackground().mutate(); + bg.setStroke(V.dp(1), Color.parseColor(role.color)); + } catch (Exception ignored) {} + rolesView.addView(roleText); + } + } + boolean isSelf=AccountSessionManager.getInstance().isSelf(accountID, account); - if(account.locked){ - ssb=new SpannableStringBuilder(account.username); - ssb.append(" "); - Drawable lock=username.getResources().getDrawable(R.drawable.ic_lock_fill1_20px, getActivity().getTheme()).mutate(); - lock.setBounds(0, 0, lock.getIntrinsicWidth(), lock.getIntrinsicHeight()); - lock.setTint(username.getCurrentTextColor()); - ssb.append(getString(R.string.manually_approves_followers), new ImageSpanThatDoesNotBreakShitForNoGoodReason(lock, ImageSpan.ALIGN_BOTTOM), 0); - username.setText(ssb); - }else{ - username.setText(account.username); - } - String domain=account.getDomain(); - if(TextUtils.isEmpty(domain)) - domain=AccountSessionManager.get(accountID).domain; - usernameDomain.setText(domain); + String acct = ((isSelf || account.isRemote) + ? account.getFullyQualifiedName() + : account.acct); - CharSequence parsedBio=HtmlParser.parse(account.note, account.emojis, Collections.emptyList(), Collections.emptyList(), accountID, account); + username.setText('@'+acct); + + lockIcon.setVisibility(account.locked ? View.VISIBLE : View.GONE); + lockIcon.setImageTintList(ColorStateList.valueOf(username.getCurrentTextColor())); + + botIcon.setVisibility(account.bot ? View.VISIBLE : View.GONE); + botIcon.setImageTintList(ColorStateList.valueOf(username.getCurrentTextColor())); + + CharSequence parsedBio=HtmlParser.parse(account.note, account.emojis, Collections.emptyList(), Collections.emptyList(), accountID); if(TextUtils.isEmpty(parsedBio)){ bio.setVisibility(View.GONE); }else{ @@ -531,9 +746,15 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList 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) + countersLayout.findViewById(R.id.profile_counters_separator).setVisibility(View.GONE); + UiUtils.loadCustomEmojiInTextView(name); UiUtils.loadCustomEmojiInTextView(bio); + notifyButton.setVisibility(View.GONE); if(AccountSessionManager.getInstance().isSelf(accountID, account)){ actionButton.setText(R.string.edit_profile); TypedArray ta=actionButton.getContext().obtainStyledAttributes(R.style.Widget_Mastodon_M3_Button_Tonal, new int[]{android.R.attr.background}); @@ -548,10 +769,12 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList fields.clear(); - AccountField joined=new AccountField(); - joined.parsedName=joined.name=getString(R.string.profile_joined); - joined.parsedValue=joined.value=DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).format(LocalDateTime.ofInstant(account.createdAt, ZoneId.systemDefault())); - fields.add(joined); + if (account.createdAt != null) { + AccountField joined=new AccountField(); + joined.parsedName=joined.name=getString(R.string.profile_joined); + joined.parsedValue=joined.value=DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).format(LocalDateTime.ofInstant(account.createdAt, ZoneId.systemDefault())); + fields.add(joined); + } for(AccountField field:account.fields){ field.parsedValue=ssb=HtmlParser.parse(field.value, account.emojis, Collections.emptyList(), Collections.emptyList(), accountID, account); @@ -570,9 +793,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList fields.add(field); } - if(aboutFragment!=null){ - aboutFragment.setFields(fields); - } + setFields(fields); } private void updateToolbar(){ @@ -583,11 +804,16 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList } } + @Override + protected boolean wantsToolbarMenuIconsTinted() { + return false; + } + @Override public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){ if(isOwnProfile && isInEditMode){ editSaveMenuItem=menu.add(0, R.id.save, 0, R.string.save_changes); - editSaveMenuItem.setIcon(R.drawable.ic_save_24px); + editSaveMenuItem.setIcon(R.drawable.ic_fluent_save_24_regular); editSaveMenuItem.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS); editSaveMenuItem.setVisible(!isActionButtonInView()); return; @@ -595,22 +821,54 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList if(relationship==null && !isOwnProfile) return; inflater.inflate(isOwnProfile ? R.menu.profile_own : R.menu.profile, menu); - menu.findItem(R.id.share).setTitle(R.string.share_user); - if(isOwnProfile) - return; + 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; + 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 + )); + } - menu.findItem(R.id.mute).setTitle(getString(relationship.muting ? R.string.unmute_user : R.string.mute_user, account.displayName)); - menu.findItem(R.id.block).setTitle(getString(relationship.blocking ? R.string.unblock_user : R.string.block_user, account.displayName)); - menu.findItem(R.id.report).setTitle(getString(R.string.report_user, account.displayName)); - if(relationship.following) - menu.findItem(R.id.hide_boosts).setTitle(getString(relationship.showingReblogs ? R.string.hide_boosts_from_user : R.string.show_boosts_from_user, account.displayName)); - else - menu.findItem(R.id.hide_boosts).setVisible(false); - if(!account.isLocal()) - menu.findItem(R.id.block_domain).setTitle(getString(relationship.domainBlocking ? R.string.unblock_domain : R.string.block_domain, account.getDomain())); - else - menu.findItem(R.id.block_domain).setVisible(false); - menu.findItem(R.id.add_to_list).setVisible(relationship.following); + if(isOwnProfile) { + if (isInstancePixelfed()) menu.findItem(R.id.scheduled).setVisible(false); + 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_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())); + menu.findItem(R.id.manage_user_lists).setVisible(relationship.following); + menu.findItem(R.id.soft_block).setVisible(relationship.followedBy && !relationship.following); + MenuItem hideBoosts=menu.findItem(R.id.hide_boosts); + if (relationship.following) { + hideBoosts.setTitle(getString(relationship.showingReblogs ? R.string.hide_boosts_from_user : R.string.show_boosts_from_user, account.getShortUsername())); + hideBoosts.setIcon(relationship.showingReblogs ? R.drawable.ic_fluent_arrow_repeat_all_off_24_regular : R.drawable.ic_fluent_arrow_repeat_all_24_regular); + UiUtils.insetPopupMenuIcon(getContext(), hideBoosts); + hideBoosts.setVisible(true); + } else { + hideBoosts.setVisible(false); + } + MenuItem blockDomain=menu.findItem(R.id.block_domain); + if(!account.isLocal()){ + blockDomain.setTitle(getString(relationship.domainBlocking ? R.string.unblock_domain : R.string.block_domain, account.getDomain())); + blockDomain.setVisible(true); + }else{ + blockDomain.setVisible(false); + } + 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 @@ -622,9 +880,11 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList intent.putExtra(Intent.EXTRA_TEXT, account.url); startActivity(Intent.createChooser(intent, item.getTitle())); }else if(id==R.id.mute){ - confirmToggleMuted(); + UiUtils.confirmToggleMuteUser(getActivity(), accountID, account, relationship.muting, this::updateRelationship); }else if(id==R.id.block){ - confirmToggleBlocked(); + UiUtils.confirmToggleBlockUser(getActivity(), accountID, account, relationship.blocking, this::updateRelationship); + }else if(id==R.id.soft_block){ + UiUtils.confirmSoftBlockUser(getActivity(), accountID, account, this::updateRelationship); }else if(id==R.id.report){ Bundle args=new Bundle(); args.putString("account", accountID); @@ -634,12 +894,12 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList }else if(id==R.id.open_in_browser){ UiUtils.launchWebBrowser(getActivity(), account.url); }else if(id==R.id.block_domain){ - UiUtils.confirmToggleBlockDomain(getActivity(), accountID, account, relationship.domainBlocking, ()->{ + UiUtils.confirmToggleBlockDomain(getActivity(), accountID, account.getDomain(), relationship.domainBlocking, ()->{ relationship.domainBlocking=!relationship.domainBlocking; updateRelationship(); - }, this::updateRelationship); + }); }else if(id==R.id.hide_boosts){ - new SetAccountFollowed(account.id, true, !relationship.showingReblogs) + new SetAccountFollowed(account.id, true, !relationship.showingReblogs, relationship.notifying) .setCallback(new Callback<>(){ @Override public void onSuccess(Relationship result){ @@ -661,9 +921,60 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList Bundle args=new Bundle(); args.putString("account", accountID); Nav.go(getActivity(), FavoritedStatusListFragment.class, args); + }else if(id==R.id.manage_user_lists){ + final Bundle args=new Bundle(); + args.putString("account", accountID); + if (!isOwnProfile) { + args.putString("profileAccount", profileAccountID); + args.putString("profileDisplayUsername", account.getDisplayUsername()); + } + Nav.go(getActivity(), ListsFragment.class, args); + }else if(id==R.id.muted_accounts){ + final Bundle args=new Bundle(); + args.putString("account", accountID); + args.putParcelable("targetAccount", Parcels.wrap(account)); + Nav.go(getActivity(), MutedAccountsListFragment.class, args); + }else if(id==R.id.blocked_accounts){ + final Bundle args=new Bundle(); + args.putString("account", accountID); + args.putParcelable("targetAccount", Parcels.wrap(account)); + Nav.go(getActivity(), BlockedAccountsListFragment.class, args); + }else if(id==R.id.followed_hashtags){ + Bundle args=new Bundle(); + args.putString("account", accountID); + Nav.go(getActivity(), FollowedHashtagsFragment.class, args); + }else if(id==R.id.scheduled){ + Bundle args=new Bundle(); + args.putString("account", accountID); + Nav.go(getActivity(), ScheduledStatusListFragment.class, args); }else if(id==R.id.save){ if(isInEditMode) saveAndExitEditMode(); + }else if(id==R.id.edit_note){ + if(noteWrap.getVisibility()==View.GONE){ + showPrivateNote(); + UiUtils.beginLayoutTransition(scrollableContent); + noteEdit.requestFocus(); + noteEdit.postDelayed(()->{ + InputMethodManager imm=getActivity().getSystemService(InputMethodManager.class); + imm.showSoftInput(noteEdit, 0); + }, 100); + }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()) + .setMessage(getContext().getString(R.string.sk_private_note_confirm_delete, account.getDisplayUsername())) + .setPositiveButton(R.string.delete, (dlg, btn)->savePrivateNote(null)) + .setNegativeButton(R.string.cancel, null) + .show(); + } + invalidateOptionsMenu(); }else if(id==R.id.add_to_list){ Bundle args=new Bundle(); args.putString("account", accountID); @@ -693,11 +1004,40 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList } private void updateRelationship(){ + if(getActivity()==null) return; + if(relationship.note!=null && !relationship.note.isEmpty()) showPrivateNote(); + else hidePrivateNote(); invalidateOptionsMenu(); actionButton.setVisibility(View.VISIBLE); + notifyButton.setVisibility(relationship.following ? View.VISIBLE : View.GONE); UiUtils.setRelationshipToActionButtonM3(relationship, actionButton); actionProgress.setIndeterminateTintList(actionButton.getTextColors()); + notifyProgress.setIndeterminateTintList(notifyButton.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); + UiUtils.beginLayoutTransition(scrollableContent); + } + + public ImageButton getFab() { + return fab; + } + + @Override + public void showFab() { + if (getFragmentForPage(pager.getCurrentItem()) instanceof HasFab fabulous) fabulous.showFab(); + } + + @Override + public void hideFab() { + if (getFragmentForPage(pager.getCurrentItem()) instanceof HasFab fabulous) fabulous.hideFab(); + } + + @Override + public boolean isScrolling() { + return getFragmentForPage(pager.getCurrentItem()) instanceof HasFab fabulous + && fabulous.isScrolling(); } private void onScrollChanged(View v, int scrollX, int scrollY, int oldScrollX, int oldScrollY){ @@ -718,6 +1058,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList boolean newTabBarIsAtTop=!scrollView.canScrollVertically(1); if(newTabBarIsAtTop!=tabBarIsAtTop){ + if(UiUtils.isTrueBlackTheme()) newTabBarIsAtTop=false; tabBarIsAtTop=newTabBarIsAtTop; if(tabBarIsAtTop){ @@ -757,15 +1098,17 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList private Fragment getFragmentForPage(int page){ return switch(page){ - case 0 -> featuredFragment; - case 1 -> timelineFragment; - case 2 -> aboutFragment; + case 0 -> postsFragment; + case 1 -> postsWithRepliesFragment; + case 2 -> pinnedPostsFragment; + case 3 -> mediaFragment; default -> throw new IllegalStateException(); }; } private RecyclerView getScrollableRecyclerView(){ - return getFragmentForPage(pager.getCurrentItem()).getView().findViewById(R.id.list); + return isInEditMode ? list : + getFragmentForPage(pager.getCurrentItem()).getView().findViewById(R.id.list); } private void onActionButtonClick(View v){ @@ -779,6 +1122,31 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList } } + private boolean onActionButtonLongClick(View v) { + if (isOwnProfile || AccountSessionManager.getInstance().getLoggedInAccounts().size() < 2) return false; + UiUtils.pickAccount(getActivity(), accountID, R.string.sk_follow_as, R.drawable.ic_fluent_person_add_28_regular, session -> { + UiUtils.lookupAccount(getActivity(), account, session.getID(), accountID, acc -> { + if (acc == null) return; + new SetAccountFollowed(acc.id, true, true).setCallback(new Callback<>() { + @Override + public void onSuccess(Relationship relationship) { + Toast.makeText( + getActivity(), + getString(R.string.sk_followed_as, session.self.getShortUsername()), + Toast.LENGTH_SHORT + ).show(); + } + + @Override + public void onError(ErrorResponse error) { + error.showToast(getActivity()); + } + }).exec(session.getID()); + }); + }, null); + return true; + } + private void setActionProgressVisible(boolean visible){ actionButton.setTextVisible(!visible); actionProgress.setVisibility(visible ? View.VISIBLE : View.GONE); @@ -787,6 +1155,14 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList actionButton.setClickable(!visible); } + private void setNotifyProgressVisible(boolean visible){ + notifyButton.setTextVisible(!visible); + notifyProgress.setVisibility(visible ? View.VISIBLE : View.GONE); + if(visible) + notifyProgress.setIndeterminateTintList(notifyButton.getTextColors()); + notifyButton.setClickable(!visible); + } + private void loadAccountInfoAndEnterEditMode(){ if(editModeLoading) return; @@ -815,22 +1191,31 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList .exec(accountID); } + private void updateMetadataHeight() { + ViewGroup.LayoutParams params = list.getLayoutParams(); + int desiredHeight = isInEditMode ? scrollView.getHeight() : ViewGroup.LayoutParams.WRAP_CONTENT; + if (params.height == desiredHeight) return; + params.height = desiredHeight; + list.requestLayout(); + } + private void enterEditMode(Account account){ if(isInEditMode) throw new IllegalStateException(); isInEditMode=true; + adapter.notifyDataSetChanged(); + dragHelper.attachToRecyclerView(list); + editDirty=false; invalidateOptionsMenu(); - pager.setUserInputEnabled(false); actionButton.setText(R.string.save_changes); - pager.setCurrentItem(2); - for(int i=0;i<3;i++){ - tabbar.getTabAt(i).view.setEnabled(false); - } - Drawable overlay=getResources().getDrawable(R.drawable.edit_avatar_overlay).mutate(); + pager.setVisibility(View.GONE); + tabbar.setVisibility(View.GONE); + Drawable overlay=getResources().getDrawable(R.drawable.edit_avatar_overlay, getActivity().getTheme()).mutate(); avatar.setForeground(overlay); + updateMetadataHeight(); Toolbar toolbar=getToolbar(); - Drawable close=getToolbarContext().getDrawable(R.drawable.ic_baseline_close_24).mutate(); + Drawable close=getToolbarContext().getDrawable(R.drawable.ic_fluent_dismiss_24_regular).mutate(); close.setTint(UiUtils.getThemeColor(getToolbarContext(), R.attr.colorM3OnSurfaceVariant)); toolbar.setNavigationIcon(close); toolbar.setNavigationContentDescription(R.string.discard); @@ -844,7 +1229,8 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList ); name.setVisibility(View.GONE); - username.setVisibility(View.GONE); + rolesView.setVisibility(View.GONE); + usernameWrap.setVisibility(View.GONE); bio.setVisibility(View.GONE); countersLayout.setVisibility(View.GONE); @@ -854,10 +1240,12 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList bioEditWrap.setVisibility(View.VISIBLE); bioEdit.setText(account.source.note); - aboutFragment.enterEditMode(account.source.fields); refreshLayout.setEnabled(false); editDirty=false; V.setVisibilityAnimated(fab, View.GONE); + + fields = account.source.fields; + adapter.notifyDataSetChanged(); } private void exitEditMode(){ @@ -867,15 +1255,11 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList invalidateOptionsMenu(); actionButton.setText(R.string.edit_profile); - for(int i=0;i<3;i++){ - tabbar.getTabAt(i).view.setEnabled(true); - } - pager.setUserInputEnabled(true); avatar.setForeground(null); Toolbar toolbar=getToolbar(); if(canGoBack()){ - Drawable back=getToolbarContext().getDrawable(R.drawable.ic_arrow_back).mutate(); + Drawable back=getToolbarContext().getDrawable(R.drawable.ic_fluent_arrow_left_24_regular).mutate(); back.setTint(UiUtils.getThemeColor(getToolbarContext(), R.attr.colorM3OnSurfaceVariant)); toolbar.setNavigationIcon(back); toolbar.setNavigationContentDescription(0); @@ -894,11 +1278,18 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList nameEditWrap.setVisibility(View.GONE); bioEditWrap.setVisibility(View.GONE); name.setVisibility(View.VISIBLE); - username.setVisibility(View.VISIBLE); + rolesView.setVisibility(View.VISIBLE); + usernameWrap.setVisibility(View.VISIBLE); bio.setVisibility(View.VISIBLE); countersLayout.setVisibility(View.VISIBLE); refreshLayout.setEnabled(true); + pager.setVisibility(View.VISIBLE); + tabbar.setVisibility(View.VISIBLE); + updateMetadataHeight(); + InputMethodManager imm=getActivity().getSystemService(InputMethodManager.class); + imm.hideSoftInputFromWindow(content.getWindowToken(), 0); + V.setVisibilityAnimated(fab, View.VISIBLE); bindHeaderView(); V.setVisibilityAnimated(fab, View.VISIBLE); } @@ -908,13 +1299,14 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList throw new IllegalStateException(); setActionProgressVisible(true); savingEdits=true; - new UpdateAccountCredentials(nameEdit.getText().toString(), bioEdit.getText().toString(), editNewAvatar, editNewCover, aboutFragment.getFields()) + new UpdateAccountCredentials(nameEdit.getText().toString(), bioEdit.getText().toString(), editNewAvatar, editNewCover, fields) .setCallback(new Callback<>(){ @Override public void onSuccess(Account result){ savingEdits=false; account=result; AccountSessionManager.getInstance().updateAccountInfo(accountID, account); + if(getActivity()==null) return; exitEditMode(); setActionProgressVisible(false); } @@ -929,14 +1321,6 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList .exec(accountID); } - private void confirmToggleMuted(){ - UiUtils.confirmToggleMuteUser(getActivity(), accountID, account, relationship.muting, this::updateRelationship); - } - - private void confirmToggleBlocked(){ - UiUtils.confirmToggleBlockUser(getActivity(), accountID, account, relationship.blocking, this::updateRelationship); - } - private void updateRelationship(Relationship r){ relationship=r; updateRelationship(); @@ -944,10 +1328,13 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList @Override public boolean onBackPressed(){ + if(noteEdit.hasFocus()) { + savePrivateNote(noteEdit.getText().toString()); + } if(isInEditMode){ if(savingEdits) return true; - if(editDirty || aboutFragment.isEditDirty()){ + if(editDirty){ new M3AlertDialogBuilder(getActivity()) .setTitle(R.string.discard_changes) .setPositiveButton(R.string.discard, (dlg, btn)->exitEditMode()) @@ -971,6 +1358,10 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList return Collections.singletonList(att); } + private void onNotifyButtonClick(View v) { + UiUtils.performToggleAccountNotifications(getActivity(), account, accountID, relationship, actionButton, this::setNotifyProgressVisible, this::updateRelationship); + } + private void onAvatarClick(View v){ if(isInEditMode){ startImagePicker(AVATAR_RESULT); @@ -979,7 +1370,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList if(ava==null) return; int radius=V.dp(25); - currentPhotoViewer=new PhotoViewer(getActivity(), createFakeAttachments(account.avatar, ava), 0, + currentPhotoViewer=new PhotoViewer(getActivity(), createFakeAttachments(TextUtils.isEmpty(account.avatar) ? getSession().getDefaultAvatarUrl() : account.avatar, ava), 0, null, accountID, new SingleImagePhotoViewerListener(avatar, avatarBorder, new int[]{radius, radius, radius, radius}, this, ()->currentPhotoViewer=null, ()->ava, null, null)); } } @@ -999,9 +1390,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList private void onFabClick(View v){ Bundle args=new Bundle(); args.putString("account", accountID); - if(!AccountSessionManager.getInstance().isSelf(accountID, account)){ - args.putString("prefilledText", '@'+account.acct+' '); - } + if(getPrefilledText() != null) args.putString("prefilledText", getPrefilledText()); Nav.go(getActivity(), ComposeFragment.class, args); } @@ -1084,7 +1473,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList @Override public int getItemCount(){ - return loaded ? 3 : 0; + return loaded ? tabViews.length : 0; } @Override @@ -1092,4 +1481,256 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList return position; } } + + @Override + public void onProvideAssistContent(AssistContent assistContent) { + callFragmentToProvideAssistContent(getFragmentForPage(pager.getCurrentItem()), assistContent); + } + + @Override + public String getAccountID() { + return accountID; + } + + @Override + public Uri getWebUri(Uri.Builder base) { + return Uri.parse(account.url); + } + + // from ProfileAboutFragment + public void setFields(List fields){ + this.fields=fields; + if(isInEditMode){ + isInEditMode=false; +// dragHelper.attachToRecyclerView(null); + } + if(adapter!=null) + adapter.notifyDataSetChanged(); + } + + private class AboutAdapter extends UsableRecyclerView.Adapter implements ImageLoaderRecyclerAdapter { + public AboutAdapter(){ + super(imgLoader); + } + + @NonNull + @Override + public BaseViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){ + return switch(viewType){ + case 0 -> new AboutViewHolder(); + case 1 -> new EditableAboutViewHolder(); + case 2 -> new AddRowViewHolder(); + default -> throw new IllegalStateException("Unexpected value: "+viewType); + }; + } + + @Override + public void onBindViewHolder(BaseViewHolder holder, int position){ + if(position { + public BaseViewHolder(int layout){ + super(getActivity(), layout, list); + } + + @Override + public void onBind(AccountField item){ + } + } + + private class AboutViewHolder extends BaseViewHolder implements ImageLoaderViewHolder{ + private final TextView title; + private final LinkedTextView value; + + public AboutViewHolder(){ + super(R.layout.item_profile_about); + title=findViewById(R.id.title); + value=findViewById(R.id.value); + } + + @Override + public void onBind(AccountField item){ + super.onBind(item); + title.setText(item.parsedName); + value.setText(item.parsedValue); + if(item.verifiedAt!=null){ + int textColor=UiUtils.getThemeColor(getContext(), R.attr.colorM3Success); + value.setTextColor(textColor); + value.setLinkTextColor(textColor); + Drawable check=getResources().getDrawable(R.drawable.ic_fluent_checkmark_starburst_20_regular, getActivity().getTheme()).mutate(); + check.setTint(textColor); + value.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, check, null); + }else{ + value.setTextColor(UiUtils.getThemeColor(getActivity(), android.R.attr.textColorPrimary)); + value.setLinkTextColor(UiUtils.getThemeColor(getActivity(), android.R.attr.colorAccent)); + value.setCompoundDrawables(null, null, null, null); + } + } + + @Override + 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(); + } + + @Override + public void clearImage(int index){ + setImage(index, null); + } + } + + private class EditableAboutViewHolder extends BaseViewHolder { + private final EditText title; + private final EditText value; + private boolean ignoreTextChange; + + public EditableAboutViewHolder(){ + super(R.layout.onboarding_profile_field); + title=findViewById(R.id.title); + value=findViewById(R.id.content); + findViewById(R.id.dragger_thingy).setOnLongClickListener(v->{ + dragHelper.startDrag(this); + return true; + }); + title.addTextChangedListener(new SimpleTextWatcher(e->{ + item.name=e.toString(); + if(!ignoreTextChange) + editDirty=true; + })); + value.addTextChangedListener(new SimpleTextWatcher(e->{ + item.value=e.toString(); + if(!ignoreTextChange) + editDirty=true; + })); + findViewById(R.id.delete).setOnClickListener(this::onRemoveRowClick); + } + + @Override + public void onBind(AccountField item){ + super.onBind(item); + ignoreTextChange=true; + title.setText(item.name); + value.setText(item.value); + ignoreTextChange=false; + } + + private void onRemoveRowClick(View v){ + int pos=getAbsoluteAdapterPosition(); + fields.remove(pos); + adapter.notifyItemRemoved(pos); + for(int i=0;itoPosition;i--) { + Collections.swap(fields, i, i-1); + } + } + adapter.notifyItemMoved(fromPosition, toPosition); + ((BindableViewHolder)viewHolder).rebind(); + ((BindableViewHolder)target).rebind(); + return true; + } + + @Override + public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction){ + + } + + @Override + public void onSelectedChanged(@Nullable RecyclerView.ViewHolder viewHolder, int actionState){ + super.onSelectedChanged(viewHolder, actionState); + if(actionState==ItemTouchHelper.ACTION_STATE_DRAG){ + viewHolder.itemView.setTag(me.grishka.appkit.R.id.item_touch_helper_previous_elevation, viewHolder.itemView.getElevation()); // prevents the default behavior of changing elevation in onDraw() + viewHolder.itemView.animate().translationZ(V.dp(1)).setDuration(200).setInterpolator(CubicBezierInterpolator.DEFAULT).start(); + } + } + + @Override + public void clearView(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder){ + super.clearView(recyclerView, viewHolder); + viewHolder.itemView.animate().translationZ(0).setDuration(100).setInterpolator(CubicBezierInterpolator.DEFAULT).start(); + } + + @Override + public boolean isLongPressDragEnabled(){ + return false; + } + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ScheduledStatusListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ScheduledStatusListFragment.java new file mode 100644 index 000000000..a8001f59b --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ScheduledStatusListFragment.java @@ -0,0 +1,220 @@ +package org.joinmastodon.android.fragments; + +import android.app.Activity; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.view.View; +import android.view.ViewGroup; +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.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; +import org.joinmastodon.android.ui.displayitems.StatusDisplayItem; +import org.joinmastodon.android.ui.utils.UiUtils; +import org.parceler.Parcels; + +import java.util.Collections; +import java.util.List; + +import me.grishka.appkit.Nav; +import me.grishka.appkit.api.SimpleCallback; +import me.grishka.appkit.utils.V; + +public class ScheduledStatusListFragment extends BaseStatusListFragment { + private String nextMaxID; + private static final int SCHEDULED_STATUS_LIST_OPENED = 161; + + @Override + protected boolean wantsComposeButton() { + return true; + } + + @Override + public void onCreate(Bundle savedInstanceState){ + super.onCreate(savedInstanceState); + E.register(this); + } + + @Override + public void onDestroy(){ + super.onDestroy(); + E.unregister(this); + } + + + @Override + public void onAttach(Activity activity){ + super.onAttach(activity); + setTitle(R.string.sk_unsent_posts); + loadData(); + } + + @Override + public void onFabClick(View v) { + Bundle args=new Bundle(); + args.putString("account", accountID); + args.putSerializable("scheduledAt", CreateStatus.getDraftInstant()); + Nav.go(getActivity(), ComposeFragment.class, args); + } + + @Override + public boolean onFabLongClick(View v) { + Bundle args=new Bundle(); + args.putString("account", accountID); + args.putSerializable("scheduledAt", CreateStatus.getDraftInstant()); + return UiUtils.pickAccountForCompose(getActivity(), accountID, args); + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + if (getArguments().getBoolean("hide_fab", false)) fab.setVisibility(View.GONE); + } + + @Override + protected List buildDisplayItems(ScheduledStatus s) { + return StatusDisplayItem.buildItems(this, s.toStatus(), accountID, s, knownAccounts, null, + StatusDisplayItem.FLAG_NO_EMOJI_REACTIONS | + StatusDisplayItem.FLAG_NO_FOOTER | + StatusDisplayItem.FLAG_NO_TRANSLATE); + } + + @Override + protected void addAccountToKnown(ScheduledStatus s) {} + + @Override + public void onItemClick(String id) { + final Bundle args=new Bundle(); + args.putString("account", accountID); + ScheduledStatus scheduledStatus = getStatusByID(id); + Status status = scheduledStatus.toStatus(); + args.putParcelable("scheduledStatus", Parcels.wrap(scheduledStatus)); + args.putParcelable("editStatus", Parcels.wrap(status)); + args.putString("sourceText", status.text); + args.putString("sourceSpoiler", status.spoilerText); + args.putBoolean("redraftStatus", true); + args.putString("sourceContentType", scheduledStatus.params.contentType != null ? + scheduledStatus.params.contentType.name() : null); + setResult(true, null); + + // closing this scheduled status list if another status list is opened from compose fragment + Nav.goForResult(getActivity(), ComposeFragment.class, args, SCHEDULED_STATUS_LIST_OPENED, this); + } + + @Override + public void onFragmentResult(int reqCode, boolean success, Bundle result) { + if (reqCode == SCHEDULED_STATUS_LIST_OPENED && success && getActivity() != null) { + Nav.finish(this); + } + } + + @Override + protected void onShown(){ + super.onShown(); + // because, for some reason, when navigating back from compose fragment, + // match_parent would otherwise be incorrect (leaving a gap for the keyboard + // where there is none) + list.post(list::requestLayout); + } + + @Override + protected void doLoadData(int offset, int count){ + currentRequest=new GetScheduledStatuses(offset==0 ? null : nextMaxID, count) + .setCallback(new SimpleCallback<>(this){ + @Override + public void onSuccess(HeaderPaginationList result){ + if(result.nextPageUri!=null) + nextMaxID=result.nextPageUri.getQueryParameter("max_id"); + else + nextMaxID=null; + if(getActivity()==null) return; + onDataLoaded(result, nextMaxID!=null); + } + }) + .exec(accountID); + } + + // copied from StatusListFragment.java + @Subscribe + public void onScheduledStatusDeleted(ScheduledStatusDeletedEvent ev){ + if(!ev.accountID.equals(accountID)) return; + ScheduledStatus status=getStatusByID(ev.id); + if(status==null) return; + removeStatus(status); + } + + // copied from StatusListFragment.java + @Subscribe + public void onScheduledStatusCreated(ScheduledStatusCreatedEvent ev){ + if(!ev.accountID.equals(accountID)) return; + prependItems(Collections.singletonList(ev.scheduledStatus), true); + scrollToTop(); + } + + // copied from StatusListFragment.java + protected void removeStatus(ScheduledStatus status){ + data.remove(status); + preloadedData.remove(status); + int index=-1; + for(int i=0;i=29 && insets.getTappableElementInsets().bottom==0){ + ((ViewGroup.MarginLayoutParams) fab.getLayoutParams()).bottomMargin=V.dp(16)+insets.getSystemWindowInsetBottom(); + }else{ + ((ViewGroup.MarginLayoutParams) fab.getLayoutParams()).bottomMargin=V.dp(16); + } + } + super.onApplyWindowInsets(insets); + } + + @Override + public Uri getWebUri(Uri.Builder base) { + // TODO: adapt when frontends finally implement a scheduled posts list + return null; + } +} 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 d6a28ae6c..fb10263dc 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ScrollableToTop.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ScrollableToTop.java @@ -3,9 +3,12 @@ package org.joinmastodon.android.fragments; import android.view.ViewTreeObserver; import androidx.recyclerview.widget.RecyclerView; -import me.grishka.appkit.utils.V; + +import org.joinmastodon.android.ui.utils.UiUtils; public interface ScrollableToTop{ +// boolean isScrolledToTop(); + void scrollToTop(); /** @@ -21,7 +24,7 @@ public interface ScrollableToTop{ @Override public boolean onPreDraw(){ list.getViewTreeObserver().removeOnPreDrawListener(this); - list.scrollBy(0, V.dp(300)); + list.scrollBy(0, UiUtils.SCROLL_TO_TOP_DELTA); list.smoothScrollToPosition(0); return true; } 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 099fe02cf..819f58a77 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/StatusEditHistoryFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/StatusEditHistoryFragment.java @@ -1,35 +1,42 @@ 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.R; import org.joinmastodon.android.api.requests.statuses.GetStatusEditHistory; +import org.joinmastodon.android.model.FilterContext; import org.joinmastodon.android.model.Status; +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; +import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.EnumSet; +import java.util.LinkedList; import java.util.List; import java.util.Objects; import java.util.stream.Collectors; import me.grishka.appkit.api.SimpleCallback; +import name.fraser.neil.plaintext.diff_match_patch; public class StatusEditHistoryFragment extends StatusListFragment{ - private String id; - + private String id, url; @Override public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); id=getArguments().getString("id"); + url=getArguments().getString("url"); loadData(); } @@ -45,6 +52,7 @@ public class StatusEditHistoryFragment extends StatusListFragment{ .setCallback(new SimpleCallback<>(this){ @Override public void onSuccess(List result){ + if(getActivity()==null) return; Collections.sort(result, Comparator.comparing((Status s)->s.createdAt).reversed()); onDataLoaded(result, false); } @@ -54,7 +62,7 @@ public class StatusEditHistoryFragment extends StatusListFragment{ @Override protected List buildDisplayItems(Status s){ - List items=StatusDisplayItem.buildItems(this, s, accountID, s, knownAccounts, true, false); + List items=new ArrayList<>(); int idx=data.indexOf(s); if(idx>=0){ String date=UiUtils.DATE_TIME_FORMATTER.format(s.createdAt.atZone(ZoneId.systemDefault())); @@ -79,8 +87,11 @@ public class StatusEditHistoryFragment extends StatusListFragment{ EnumSet changes=EnumSet.noneOf(StatusEditChangeType.class); Status prev=data.get(idx+1); - if(!Objects.equals(s.content, prev.content)){ + // if only formatting was changed, don't even try to create a diff text + if(!Objects.equals(HtmlParser.text(s.content), HtmlParser.text(prev.content))){ changes.add(StatusEditChangeType.TEXT_CHANGED); + //update status content to display a diffs + s.content=createDiffText(prev.content, s.content); } if(!Objects.equals(s.spoilerText, prev.spoilerText)){ if(s.spoilerText==null){ @@ -139,19 +150,50 @@ public class StatusEditHistoryFragment extends StatusListFragment{ action=getString(R.string.edit_multiple_changed); } } - items.add(0, new ReblogOrReplyLineStatusDisplayItem(s.id, this, action+" · "+date, Collections.emptyList(), 0)); + String sep = getString(R.string.sk_separator); + items.add(0, new ReblogOrReplyLineStatusDisplayItem(s.id, this, action+" "+sep+" "+date, Collections.emptyList(), 0, null, null, s)); + items.add(1, new DummyStatusDisplayItem(s.id, this)); } + items.addAll(StatusDisplayItem.buildItems(this, s, accountID, s, knownAccounts, null, StatusDisplayItem.FLAG_NO_FOOTER|StatusDisplayItem.FLAG_INSET|StatusDisplayItem.FLAG_NO_EMOJI_REACTIONS)); return items; } - @Override - public void onViewCreated(View view, Bundle savedInstanceState){ - super.onViewCreated(view, savedInstanceState); - list.addItemDecoration(new InsetStatusItemDecoration(this)); - } - @Override public boolean isItemEnabled(String id){ return false; } + + @Override + protected FilterContext getFilterContext() { + return null; + } + + @Override + public Uri getWebUri(Uri.Builder base) { + return Uri.parse(url); + } + + private String createDiffText(String original, String modified) { + diff_match_patch dmp=new diff_match_patch(); + LinkedList diffs=dmp.diff_main(original, modified); + dmp.diff_cleanupSemantic(diffs); + + StringBuilder stringBuilder=new StringBuilder(); + for(diff_match_patch.Diff diff : diffs){ + switch(diff.operation){ + case DELETE->{ + stringBuilder.append(""); + stringBuilder.append(diff.text); + stringBuilder.append(""); + } + case INSERT->{ + stringBuilder.append(""); + stringBuilder.append(diff.text); + stringBuilder.append(""); + } + default->stringBuilder.append(diff.text); + } + } + return stringBuilder.toString(); + } } 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 de915e4ce..c9f65ac5d 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/StatusListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/StatusListFragment.java @@ -1,41 +1,77 @@ package org.joinmastodon.android.fragments; +import static org.joinmastodon.android.api.session.AccountLocalPreferences.ShowEmojiReactions.ONLY_OPENED; + +import android.content.res.Configuration; import android.os.Bundle; import com.squareup.otto.Subscribe; import org.joinmastodon.android.E; +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; import org.joinmastodon.android.events.RemoveAccountPostsEvent; import org.joinmastodon.android.events.StatusCountersUpdatedEvent; import org.joinmastodon.android.events.StatusCreatedEvent; import org.joinmastodon.android.events.StatusDeletedEvent; import org.joinmastodon.android.events.StatusUpdatedEvent; +import org.joinmastodon.android.model.FilterContext; 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; +import java.util.Iterator; import java.util.List; +import java.util.function.BiPredicate; +import java.util.function.Consumer; +import java.util.function.Predicate; +import java.util.function.Supplier; import java.util.stream.Collectors; import java.util.stream.Stream; import androidx.recyclerview.widget.RecyclerView; import me.grishka.appkit.Nav; -public abstract class StatusListFragment extends BaseStatusListFragment{ +public abstract class StatusListFragment extends BaseStatusListFragment { protected EventListener eventListener=new EventListener(); protected List buildDisplayItems(Status s){ - return StatusDisplayItem.buildItems(this, s, accountID, s, knownAccounts, false, true); + boolean isMainThreadStatus = this instanceof ThreadFragment t && s.id.equals(t.mainStatus.id); + int flags = 0; + AccountLocalPreferences lp=getLocalPrefs(); + if(GlobalUserPreferences.spectatorMode) + flags |= StatusDisplayItem.FLAG_NO_FOOTER; + if(!lp.emojiReactionsEnabled || lp.showEmojiReactions==ONLY_OPENED) + flags |= StatusDisplayItem.FLAG_NO_EMOJI_REACTIONS; + if(GlobalUserPreferences.translateButtonOpenedOnly) + flags |= StatusDisplayItem.FLAG_NO_TRANSLATE; + if(!GlobalUserPreferences.showMediaPreview) + flags |= StatusDisplayItem.FLAG_NO_MEDIA_PREVIEW; + return StatusDisplayItem.buildItems(this, s, accountID, s, knownAccounts, getFilterContext(), isMainThreadStatus ? 0 : flags); } + protected abstract FilterContext getFilterContext(); + @Override protected void addAccountToKnown(Status s){ if(!knownAccounts.containsKey(s.account.id)) knownAccounts.put(s.account.id, s.account); + if(s.reblog!=null && !knownAccounts.containsKey(s.reblog.account.id)) + knownAccounts.put(s.reblog.account.id, s.reblog.account); } @Override @@ -53,8 +89,20 @@ public abstract class StatusListFragment extends BaseStatusListFragment{ @Override public void onItemClick(String id){ Status status=getContentStatusByID(id); - if(status==null) + 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); args.putParcelable("status", Parcels.wrap(status.clone())); @@ -118,12 +166,12 @@ public abstract class StatusListFragment extends BaseStatusListFragment{ } } - protected Status getContentStatusByID(String id){ + public Status getContentStatusByID(String id){ Status s=getStatusByID(id); return s==null ? null : s.getContentStatus(); } - protected Status getStatusByID(String id){ + public Status getStatusByID(String id){ for(Status s:data){ if(s.id.equals(id)){ return s; @@ -150,25 +198,79 @@ public abstract class StatusListFragment extends BaseStatusListFragment{ } } + private boolean removeStatusDisplayItems(String parentID, int firstIndex, int ancestorFirstIndex, int ancestorLastIndex){ + // did we find an ancestor that is also the status' neighbor? + if(ancestorFirstIndex>=0 && ancestorLastIndex==firstIndex-1){ + // update ancestor to have no descendant anymore + displayItems.subList(ancestorFirstIndex, ancestorLastIndex+1).forEach(i->i.hasDescendantNeighbor=false); + adapter.notifyItemRangeChanged(ancestorFirstIndex, ancestorLastIndex-ancestorFirstIndex+1); + } + + if(firstIndex==-1) return false; + int lastIndex=firstIndex; + while(lastIndex isToBeRemovedReblog=item->item!=null && item.reblog!=null + && item.reblog.id.equals(status.reblog.id) + && asm.isSelf(accountID, item.account); + final BiPredicate> isToBeRemovedContent=(parentId, contentIdSupplier)-> + parentId.equals(status.id) || contentIdSupplier.get().equals(status.id); + + int ancestorFirstIndex=-1, ancestorLastIndex=-1; for(int i=0;i> removeStatusFromData=(list)->{ + Iterator it=list.iterator(); + while(it.hasNext()){ + Status s=it.next(); + if(unReblogging + ? isToBeRemovedReblog.test(s) + : isToBeRemovedContent.test(s.id, s::getContentStatusID)){ + it.remove(); + cache.deleteStatus(s.id); + } + } + }; + removeStatusFromData.accept(data); + removeStatusFromData.accept(preloadedData); + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + if (getParentFragment() instanceof HomeTabFragment home) home.updateToolbarLogo(); } public class EventListener{ @@ -177,7 +279,8 @@ public abstract class StatusListFragment extends BaseStatusListFragment{ public void onStatusCountersUpdated(StatusCountersUpdatedEvent ev){ for(Status s:data){ if(s.getContentStatus().id.equals(ev.id)){ - s.update(ev); + s.getContentStatus().update(ev); + AccountSessionManager.get(accountID).getCacheController().updateStatus(s); for(int i=0;i{ } } for(Status s:preloadedData){ - if(s.id.equals(ev.id)){ - s.update(ev); + if(s.getContentStatus().id.equals(ev.id)){ + s.getContentStatus().update(ev); + AccountSessionManager.get(accountID).getCacheController().updateStatus(s); + } + } + } + + @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{ removeStatus(status); } + @Subscribe + public void onReblogDeleted(ReblogDeletedEvent ev){ + AccountSessionManager asm=AccountSessionManager.getInstance(); + if(!ev.accountID.equals(accountID)) + return; + for(Status item : data){ + boolean itemIsOwnReblog=item.reblog!=null + && item.getContentStatusID().equals(ev.statusID) + && asm.isSelf(accountID, item.account); + if(itemIsOwnReblog){ + removeStatus(item); + break; + } + } + } + @Subscribe public void onStatusCreated(StatusCreatedEvent ev){ if(!ev.accountID.equals(accountID)) @@ -225,6 +391,7 @@ public abstract class StatusListFragment extends BaseStatusListFragment{ Status contentStatus=status.getContentStatus(); if(contentStatus.poll!=null && contentStatus.poll.id.equals(ev.poll.id)){ updatePoll(status.id, contentStatus, ev.poll); + AccountSessionManager.get(accountID).getCacheController().updateStatus(contentStatus); } } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ThreadFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ThreadFragment.java index 8dfc9194f..6bd70b5cd 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ThreadFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ThreadFragment.java @@ -1,6 +1,6 @@ package org.joinmastodon.android.fragments; -import android.content.res.ColorStateList; +import android.net.Uri; import android.os.Bundle; import android.text.TextUtils; import android.view.View; @@ -11,27 +11,54 @@ import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + import org.joinmastodon.android.GlobalUserPreferences; +import com.squareup.otto.Subscribe; + +import org.joinmastodon.android.E; +import org.joinmastodon.android.GlobalUserPreferences; +import org.joinmastodon.android.GlobalUserPreferences.AutoRevealMode; import org.joinmastodon.android.R; +import org.joinmastodon.android.api.requests.statuses.GetStatusByID; import org.joinmastodon.android.api.requests.statuses.GetStatusContext; import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.events.StatusCountersUpdatedEvent; +import org.joinmastodon.android.events.StatusMuteChangedEvent; +import org.joinmastodon.android.events.StatusUpdatedEvent; import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.FilterContext; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.model.StatusContext; import org.joinmastodon.android.ui.OutlineProviders; +import org.joinmastodon.android.ui.BetterItemAnimator; 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.ReblogOrReplyLineStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.SpoilerStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.StatusDisplayItem; import org.joinmastodon.android.ui.displayitems.TextStatusDisplayItem; +import org.joinmastodon.android.ui.displayitems.WarningFilteredStatusDisplayItem; import org.joinmastodon.android.ui.text.HtmlParser; import org.joinmastodon.android.ui.utils.UiUtils; +import org.joinmastodon.android.utils.ProvidesAssistContent; import org.parceler.Parcels; +import java.util.ArrayDeque; +import java.util.ArrayList; import java.util.Collections; +import java.util.Deque; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; +import me.grishka.appkit.api.Callback; +import me.grishka.appkit.api.ErrorResponse; import androidx.recyclerview.widget.RecyclerView; import me.grishka.appkit.Nav; import me.grishka.appkit.api.SimpleCallback; @@ -41,9 +68,11 @@ import me.grishka.appkit.utils.MergeRecyclerAdapter; import me.grishka.appkit.utils.SingleViewRecyclerAdapter; import me.grishka.appkit.utils.V; -public class ThreadFragment extends StatusListFragment{ - private Status mainStatus; - private ImageView endMark; +public class ThreadFragment extends StatusListFragment implements ProvidesAssistContent { + protected Status mainStatus, updatedStatus, replyTo; + private final HashMap ancestryMap = new HashMap<>(); + private StatusContext result; + protected boolean contextInitiallyRendered, transitionFinished, preview; private FrameLayout replyContainer; private LinearLayout replyButton; private ImageView replyButtonAva; @@ -55,22 +84,74 @@ public class ThreadFragment extends StatusListFragment{ 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")); + refreshing=contextInitiallyRendered=getArguments().getBoolean("refresh", false); if(inReplyToAccount!=null) knownAccounts.put(inReplyToAccount.id, inReplyToAccount); data.add(mainStatus); onAppendItems(Collections.singletonList(mainStatus)); - if(AccountSessionManager.get(accountID).getLocalPreferences().customEmojiInNames) - setTitle(HtmlParser.parseCustomEmoji(getString(R.string.post_from_user, mainStatus.account.displayName), mainStatus.account.emojis)); - else - setTitle(getString(R.string.post_from_user, mainStatus.account.displayName)); + preview=mainStatus.preview; + 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 buildDisplayItems(Status s){ List items=super.buildDisplayItems(s); - if(s.id.equals(mainStatus.id)){ - for(StatusDisplayItem item:items){ + // "what the fuck is a deque"? yes + // (it's just so the last-added item automatically comes first when looping over it) + Deque deleteTheseItems = new ArrayDeque<>(); + + // modifying hidden filtered items if status is displayed as a warning + List itemsToModify = + (items.get(0) instanceof WarningFilteredStatusDisplayItem warning) + ? warning.filteredItems + : items; + + for(int i = 0; i < itemsToModify.size(); i++){ + StatusDisplayItem item = itemsToModify.get(i); + NeighborAncestryInfo ancestryInfo = ancestryMap.get(s.id); + if (ancestryInfo != null) { + item.setAncestryInfo( + ancestryInfo.descendantNeighbor != null, + ancestryInfo.ancestoringNeighbor != null, + s.id.equals(mainStatus.id), + Optional.ofNullable(ancestryInfo.ancestoringNeighbor) + .map(ancestor -> ancestor.id.equals(mainStatus.id)) + .orElse(false) + ); + } + + if (item instanceof ReblogOrReplyLineStatusDisplayItem && + (!item.isDirectDescendant && item.hasAncestoringNeighbor)) { + deleteTheseItems.add(i); + } + + if(s.id.equals(mainStatus.id)){ if(item instanceof TextStatusDisplayItem text) text.textSelectable=true; else if(item instanceof FooterStatusDisplayItem footer) @@ -82,49 +163,237 @@ public class ThreadFragment extends StatusListFragment{ } } } - items.add(items.size()-1, new ExtendedFooterStatusDisplayItem(s.id, this, s.getContentStatus())); + } + + for (int deleteThisItem : deleteTheseItems) itemsToModify.remove(deleteThisItem); + if(s.id.equals(mainStatus.id)) { + items.add(new ExtendedFooterStatusDisplayItem(s.id, this, accountID, s.getContentStatus())); } return items; } + @Override + public void onTransitionFinished() { + transitionFinished = true; + maybeApplyContext(); + } + @Override protected void doLoadData(int offset, int count){ - currentRequest=new GetStatusContext(mainStatus.id) + if(preview && replyTo==null){ + result=new StatusContext(); + result.descendants=Collections.emptyList(); + result.ancestors=Collections.emptyList(); + return; + } + if(refreshing && !preview) loadMainStatus(); + currentRequest=new GetStatusContext(preview ? replyTo.id : mainStatus.id) .setCallback(new SimpleCallback<>(this){ @Override public void onSuccess(StatusContext result){ - if(getActivity()==null) - return; - if(refreshing){ - data.clear(); - displayItems.clear(); - data.add(mainStatus); - onAppendItems(Collections.singletonList(mainStatus)); + if(preview){ + result.descendants=Collections.emptyList(); + result.ancestors.add(replyTo); } - filterStatuses(result.descendants); - filterStatuses(result.ancestors); - if(footerProgress!=null) - footerProgress.setVisibility(View.GONE); - data.addAll(result.descendants); - int prevCount=displayItems.size(); - onAppendItems(result.descendants); - int count=displayItems.size(); - if(!refreshing) - adapter.notifyItemRangeInserted(prevCount, count-prevCount); - prependItems(result.ancestors, !refreshing); - dataLoaded(); - if(refreshing){ - refreshDone(); - adapter.notifyDataSetChanged(); - } - list.scrollToPosition(displayItems.size()-count); + ThreadFragment.this.result = result; + maybeApplyContext(); } }) .exec(accountID); } + private void loadMainStatus() { + new GetStatusByID(mainStatus.id) + .setCallback(new Callback<>() { + @Override + public void onSuccess(Status status) { + if (getContext() == null || status == null) return; + updatedStatus = status; + // for the case that the context has already loaded (and the animation has + // already finished), falling back to applying it ourselves: + maybeApplyMainStatus(); + } + + @Override + public void onError(ErrorResponse error) {} + }).exec(accountID); + } + + private void restoreStatusStates(List newData, Map oldData) { + for (Status s : newData) { + if (s == mainStatus) continue; + Status oldStatus = oldData == null ? null : oldData.get(s.id); + // restore previous spoiler/filter revealed states when refreshing + if (oldStatus != null) { + s.spoilerRevealed = oldStatus.spoilerRevealed; + s.sensitiveRevealed = oldStatus.sensitiveRevealed; + s.filterRevealed = oldStatus.filterRevealed; + } + if (GlobalUserPreferences.autoRevealEqualSpoilers != AutoRevealMode.NEVER && + s.spoilerText != null && + s.spoilerText.equals(mainStatus.spoilerText)) { + if (GlobalUserPreferences.autoRevealEqualSpoilers == AutoRevealMode.DISCUSSIONS || Objects.equals(mainStatus.account.id, s.account.id)) { + s.spoilerRevealed = mainStatus.spoilerRevealed; + } + } + } + + } + protected void maybeApplyContext() { + if (!transitionFinished || result == null || getContext() == null) return; + Map oldData = null; + if(refreshing){ + oldData = new HashMap<>(data.size()); + for (Status s : data) oldData.put(s.id, s); + data.clear(); + ancestryMap.clear(); + displayItems.clear(); + data.add(mainStatus); + onAppendItems(Collections.singletonList(mainStatus)); + } + + // TODO: figure out how this code works + if (isInstanceAkkoma()) sortStatusContext(mainStatus, result); + + filterStatuses(result.descendants); + filterStatuses(result.ancestors); + restoreStatusStates(result.descendants, oldData); + restoreStatusStates(result.ancestors, oldData); + + for (NeighborAncestryInfo i : mapNeighborhoodAncestry(mainStatus, result)) { + ancestryMap.put(i.status.id, i); + } + + if(footerProgress!=null) + footerProgress.setVisibility(View.GONE); + data.addAll(result.descendants); + + int prevCount=displayItems.size(); + onAppendItems(result.descendants); + + int count=displayItems.size(); + if(!refreshing) + adapter.notifyItemRangeInserted(prevCount, count-prevCount); + int prependedCount = prependItems(result.ancestors, !refreshing); + if (prependedCount > 0 && displayItems.get(prependedCount) instanceof ReblogOrReplyLineStatusDisplayItem) { + displayItems.remove(prependedCount); + adapter.notifyItemRemoved(prependedCount); + count--; + } + + dataLoaded(); + if(refreshing){ + refreshDone(); + adapter.notifyDataSetChanged(); + } + list.scrollToPosition(displayItems.size()-count); + + // no animation is going to happen, so proceeding to apply right now + if (data.size() == 1) { + contextInitiallyRendered = true; + // for the case that the main status has already finished loading + maybeApplyMainStatus(); + } + + result = null; + } + protected Object maybeApplyMainStatus() { + if (updatedStatus == null || !contextInitiallyRendered) return null; + + // restore revealed states for main status because it gets updated after doLoadData + updatedStatus.filterRevealed = mainStatus.filterRevealed; + updatedStatus.spoilerRevealed = mainStatus.spoilerRevealed; + updatedStatus.sensitiveRevealed = mainStatus.sensitiveRevealed; + + // returning fired event object to facilitate testing + Object event; + if (updatedStatus.editedAt != null && + (mainStatus.editedAt == null || + updatedStatus.editedAt.isAfter(mainStatus.editedAt))) { + event = new StatusUpdatedEvent(updatedStatus); + } else { + event = new StatusCountersUpdatedEvent(updatedStatus); + } + + mainStatus = updatedStatus; + updatedStatus = null; + E.post(event); + return event; + } + + public static List mapNeighborhoodAncestry(Status mainStatus, StatusContext context) { + List ancestry = new ArrayList<>(); + + List statuses = new ArrayList<>(context.ancestors); + statuses.add(mainStatus); + statuses.addAll(context.descendants); + + int count = statuses.size(); + for (int index = 0; index < count; index++) { + Status current = statuses.get(index); + ancestry.add(new NeighborAncestryInfo( + current, + // descendant neighbor + Optional + .ofNullable(count > index + 1 ? statuses.get(index + 1) : null) + .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 -> current.id.equals(ancestorsDescendant.id)) + .orElse(false)) + .map(a -> a.status) + .orElse(null) + )); + } + + return ancestry; + } + + public static void sortStatusContext(Status mainStatus, StatusContext context) { + List threadIds=new ArrayList<>(); + threadIds.add(mainStatus.id); + for(Status s:context.descendants){ + if(threadIds.contains(s.inReplyToId)){ + threadIds.add(s.id); + } + } + threadIds.add(mainStatus.inReplyToId); + for(int i=context.ancestors.size()-1; i >= 0; i--){ + Status s=context.ancestors.get(i); + if(s.inReplyToId != null && threadIds.contains(s.id)){ + threadIds.add(s.inReplyToId); + } + } + + context.ancestors=context.ancestors.stream().filter(s -> threadIds.contains(s.id)).collect(Collectors.toList()); + context.descendants=getDescendantsOrdered(mainStatus.id, + context.descendants.stream() + .filter(s -> threadIds.contains(s.id)) + .collect(Collectors.toList())); + } + + private static List getDescendantsOrdered(String id, List statuses){ + List out=new ArrayList<>(); + for(Status s:getDirectDescendants(id, statuses)){ + out.add(s); + getDirectDescendants(s.id, statuses).forEach(d ->{ + out.add(d); + out.addAll(getDescendantsOrdered(d.id, statuses)); + }); + } + return out; + } + + private static List getDirectDescendants(String id, List statuses){ + return statuses.stream() + .filter(s -> id.equals(s.inReplyToId)) + .collect(Collectors.toList()); + } + private void filterStatuses(List statuses){ - AccountSessionManager.get(accountID).filterStatuses(statuses, FilterContext.THREAD); + AccountSessionManager.get(accountID).filterStatuses(statuses, getFilterContext()); } @Override @@ -157,38 +426,128 @@ public class ThreadFragment extends StatusListFragment{ showContent(); if(!loaded) footerProgress.setVisibility(View.VISIBLE); + + list.setItemAnimator(new BetterItemAnimator() { + @Override + public void onAnimationFinished(@NonNull RecyclerView.ViewHolder viewHolder) { + super.onAnimationFinished(viewHolder); + contextInitiallyRendered = true; + // for the case that both requests are already done (and thus won't apply it) + maybeApplyMainStatus(); + } + }); } protected void onStatusCreated(Status status){ - if(status.inReplyToId!=null && getStatusByID(status.inReplyToId)!=null){ - onAppendItems(Collections.singletonList(status)); - data.add(status); + if (status.inReplyToId == null) return; + Status repliedToStatus = getStatusByID(status.inReplyToId); + if (repliedToStatus == null) return; + NeighborAncestryInfo ancestry = ancestryMap.get(repliedToStatus.id); + + int nextDisplayItemsIndex = -1, indexOfPreviousDisplayItem = -1; + + if (ancestry != null) for (int i = 0; i < displayItems.size(); i++) { + StatusDisplayItem item = displayItems.get(i); + if (repliedToStatus.id.equals(item.parentID)) { + // saving the replied-to status' display items index to eventually reach the last one + indexOfPreviousDisplayItem = i; + item.hasDescendantNeighbor = true; + } else if (indexOfPreviousDisplayItem >= 0 && nextDisplayItemsIndex == -1) { + // previous display item was the replied-to status' display items + nextDisplayItemsIndex = i; + // nothing left to do if there's no other reply to that status + if (ancestry.descendantNeighbor == null) break; + } + if (ancestry.descendantNeighbor != null && item.parentID.equals(ancestry.descendantNeighbor.id)) { + // existing reply shall no longer have the replied-to status as its neighbor + item.hasAncestoringNeighbor = false; + } } + + // fall back to inserting the item at the end + nextDisplayItemsIndex = nextDisplayItemsIndex >= 0 ? nextDisplayItemsIndex : displayItems.size(); + int nextDataIndex = data.indexOf(repliedToStatus) + 1; + + // if replied-to status already has another reply... + if (ancestry != null && ancestry.descendantNeighbor != null) { + // update the reply's ancestry to remove its ancestoring neighbor (as we did above) + ancestryMap.get(ancestry.descendantNeighbor.id).ancestoringNeighbor = null; + // make sure the existing reply has a reply line + if (nextDataIndex < data.size() && + !(displayItems.get(nextDisplayItemsIndex) instanceof ReblogOrReplyLineStatusDisplayItem)) { + Status nextStatus = data.get(nextDataIndex); + if (!nextStatus.account.id.equals(repliedToStatus.account.id)) { + // create reply line manually since we're not building that status' items + displayItems.add(nextDisplayItemsIndex, StatusDisplayItem.buildReplyLine( + this, nextStatus, accountID, nextStatus, repliedToStatus.account, false + )); + } + } + } + + // update replied-to status' ancestry + if (ancestry != null) ancestry.descendantNeighbor = status; + + // add ancestry for newly created status before building its display items + ancestryMap.put(status.id, new NeighborAncestryInfo(status, null, repliedToStatus)); + displayItems.addAll(nextDisplayItemsIndex, buildDisplayItems(status)); + data.add(nextDataIndex, status); + adapter.notifyDataSetChanged(); + } + + public Status getMainStatus(){ + return mainStatus; } @Override public boolean isItemEnabled(String id){ - return !id.equals(mainStatus.id); + return !id.equals(mainStatus.id) || !mainStatus.filterRevealed; } @Override - protected RecyclerView.Adapter getAdapter(){ - MergeRecyclerAdapter a=new MergeRecyclerAdapter(); - a.addAdapter(super.getAdapter()); - - endMark=new ImageView(getActivity()); - endMark.setScaleType(ImageView.ScaleType.CENTER); - endMark.setImageTintList(ColorStateList.valueOf(UiUtils.getThemeColor(getActivity(), R.attr.colorM3OutlineVariant))); - endMark.setLayoutParams(new RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, V.dp(25))); - endMark.setImageResource(R.drawable.thread_end_mark); - a.addAdapter(new SingleViewRecyclerAdapter(endMark)); - - return a; + public boolean wantsLightStatusBar(){ + return !UiUtils.isDarkTheme(); } @Override - protected boolean needDividerForExtraItem(View child, View bottomSibling, RecyclerView.ViewHolder holder, RecyclerView.ViewHolder siblingHolder){ - return bottomSibling==endMark; + public boolean wantsLightNavigationBar(){ + return !UiUtils.isDarkTheme(); + } + + + @Override + protected FilterContext getFilterContext() { + return FilterContext.THREAD; + } + + @Override + public Uri getWebUri(Uri.Builder base) { + return Uri.parse(mainStatus.url); + } + + protected static class NeighborAncestryInfo { + protected Status status, descendantNeighbor, ancestoringNeighbor; + + protected NeighborAncestryInfo(@NonNull Status status, Status descendantNeighbor, Status ancestoringNeighbor) { + this.status = status; + this.descendantNeighbor = descendantNeighbor; + this.ancestoringNeighbor = ancestoringNeighbor; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + NeighborAncestryInfo that = (NeighborAncestryInfo) o; + return status.equals(that.status) + && Objects.equals(descendantNeighbor, that.descendantNeighbor) + && Objects.equals(ancestoringNeighbor, that.ancestoringNeighbor); + } + + @Override + public int hashCode() { + return Objects.hash(status, descendantNeighbor, ancestoringNeighbor); + } } @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/AccountRelatedAccountListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/AccountRelatedAccountListFragment.java index 8277a5b41..43ad6ffa4 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/AccountRelatedAccountListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/AccountRelatedAccountListFragment.java @@ -1,17 +1,68 @@ 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.MastodonAPIRequest; +import org.joinmastodon.android.api.requests.accounts.GetAccountByHandle; +import org.joinmastodon.android.api.session.AccountSession; +import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.model.Account; import org.parceler.Parcels; -public abstract class AccountRelatedAccountListFragment extends PaginatedAccountListFragment{ +import java.util.Optional; + +public abstract class AccountRelatedAccountListFragment extends PaginatedAccountListFragment { protected Account account; + protected String initialSubtitle = ""; @Override public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); account=Parcels.unwrap(getArguments().getParcelable("targetAccount")); + if (getArguments().containsKey("remoteAccount")) { + remoteInfo = Parcels.unwrap(getArguments().getParcelable("remoteAccount")); + } setTitle("@"+account.acct); } + + @Override + public Uri getWebUri(Uri.Builder base) { + return base.path(isInstanceAkkoma() + ? "/users/" + account.id + : '@' + account.acct).build(); + } + + @Override + public String getRemoteDomain() { + return account.getDomainFromURL(); + } + + @Override + public Account getCurrentInfo() { + return doneWithHomeInstance && remoteInfo != null ? remoteInfo : account; + } + + @Override + protected MastodonAPIRequest loadRemoteInfo() { + return new GetAccountByHandle(account.acct); + } + + @Override + protected AccountSession getRemoteSession() { + return Optional.ofNullable(remoteInfo) + .map(AccountSessionManager.getInstance()::tryGetAccount) + .orElse(null); + } + + @Override + protected void onRemoteLoadingFailed() { + super.onRemoteLoadingFailed(); + String prefix = initialSubtitle == null ? "" : + initialSubtitle + " " + getContext().getString(R.string.sk_separator) + " "; + String str = prefix + + getContext().getString(R.string.sk_no_remote_info_hint, getSession().domain); + setSubtitle(str); + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/AccountSearchFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/AccountSearchFragment.java index 001768e16..ce3579a8b 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/AccountSearchFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/AccountSearchFragment.java @@ -1,5 +1,6 @@ package org.joinmastodon.android.fragments.account_list; +import android.net.Uri; import android.os.Bundle; import android.text.TextUtils; import android.view.View; @@ -106,4 +107,9 @@ public class AccountSearchFragment extends BaseAccountListFragment{ if(!TextUtils.isEmpty(currentQuery)) loadData(); } + + @Override + public Uri getWebUri(Uri.Builder base) { + return null; + } } 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 65d6f5f39..bb89f5205 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 @@ -1,5 +1,6 @@ package org.joinmastodon.android.fragments.account_list; +import android.app.assist.AssistContent; import android.content.res.Configuration; import android.os.Build; import android.os.Bundle; @@ -13,9 +14,9 @@ import org.joinmastodon.android.api.requests.accounts.GetAccountRelationships; import org.joinmastodon.android.fragments.MastodonRecyclerFragment; import org.joinmastodon.android.model.Relationship; import org.joinmastodon.android.model.viewmodel.AccountViewModel; -import org.joinmastodon.android.ui.DividerItemDecoration; import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.ui.viewholders.AccountViewHolder; +import org.joinmastodon.android.utils.ProvidesAssistContent; import java.util.ArrayList; import java.util.HashMap; @@ -34,7 +35,7 @@ import me.grishka.appkit.imageloader.requests.ImageLoaderRequest; import me.grishka.appkit.utils.V; import me.grishka.appkit.views.UsableRecyclerView; -public abstract class BaseAccountListFragment extends MastodonRecyclerFragment{ +public abstract class BaseAccountListFragment extends MastodonRecyclerFragment implements ProvidesAssistContent.ProvidesWebUri { protected HashMap relationships=new HashMap<>(); protected String accountID; protected ArrayList> relationshipsRequests=new ArrayList<>(); @@ -85,6 +86,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/BlocksListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/BlocksListFragment.java new file mode 100644 index 000000000..f13f6e359 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/BlocksListFragment.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.GetAccountBlocks; +import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.ui.viewholders.AccountViewHolder; + +public class BlocksListFragment extends AccountRelatedAccountListFragment{ + + @Override + public void onCreate(Bundle savedInstanceState){ + super.onCreate(savedInstanceState); + setTitle(R.string.mo_blocked_accounts); + } + + @Override + public HeaderPaginationRequest 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/FollowerListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/FollowerListFragment.java index 1a7b3c8e2..46452cf62 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/FollowerListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/FollowerListFragment.java @@ -1,5 +1,6 @@ package org.joinmastodon.android.fragments.account_list; +import android.net.Uri; import android.os.Bundle; import org.joinmastodon.android.R; @@ -12,11 +13,17 @@ public class FollowerListFragment extends AccountRelatedAccountListFragment{ @Override public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); - setSubtitle(getResources().getQuantityString(R.plurals.x_followers, (int)(account.followersCount%1000), account.followersCount)); + setSubtitle(initialSubtitle = getResources().getQuantityString(R.plurals.x_followers, (int)(account.followersCount%1000), account.followersCount)); } @Override public HeaderPaginationRequest onCreateRequest(String maxID, int count){ - return new GetAccountFollowers(account.id, maxID, count); + return new GetAccountFollowers(getCurrentInfo().id, maxID, count); + } + + @Override + public Uri getWebUri(Uri.Builder base) { + return super.getWebUri(base).buildUpon() + .appendPath(isInstanceAkkoma() ? "#followers" : "/followers").build(); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/FollowingListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/FollowingListFragment.java index 83351e751..0e1c2bacb 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/FollowingListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/FollowingListFragment.java @@ -1,5 +1,6 @@ package org.joinmastodon.android.fragments.account_list; +import android.net.Uri; import android.os.Bundle; import org.joinmastodon.android.R; @@ -12,11 +13,17 @@ public class FollowingListFragment extends AccountRelatedAccountListFragment{ @Override public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); - setSubtitle(getResources().getQuantityString(R.plurals.x_following, (int)(account.followingCount%1000), account.followingCount)); + setSubtitle(initialSubtitle = getResources().getQuantityString(R.plurals.x_following, (int)(account.followingCount%1000), account.followingCount)); } @Override public HeaderPaginationRequest onCreateRequest(String maxID, int count){ - return new GetAccountFollowing(account.id, maxID, count); + return new GetAccountFollowing(getCurrentInfo().id, maxID, count); + } + + @Override + public Uri getWebUri(Uri.Builder base) { + return super.getWebUri(base).buildUpon() + .appendPath(isInstanceAkkoma() ? "#followees" : "/following").build(); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/MutedAccountsListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/MutedAccountsListFragment.java new file mode 100644 index 000000000..22dff9921 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/MutedAccountsListFragment.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 MutedAccountsListFragment extends AccountRelatedAccountListFragment{ + + @Override + public void onCreate(Bundle savedInstanceState){ + super.onCreate(savedInstanceState); + setTitle(R.string.sk_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/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/account_list/PaginatedAccountListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/PaginatedAccountListFragment.java index 1bf665fb7..6f293b821 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/PaginatedAccountListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/PaginatedAccountListFragment.java @@ -1,33 +1,179 @@ package org.joinmastodon.android.fragments.account_list; +import android.os.Bundle; +import android.view.View; + +import org.joinmastodon.android.GlobalUserPreferences; +import org.joinmastodon.android.api.MastodonAPIRequest; import org.joinmastodon.android.api.requests.HeaderPaginationRequest; +import org.joinmastodon.android.api.session.AccountSession; import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.HeaderPaginationList; import org.joinmastodon.android.model.viewmodel.AccountViewModel; +import java.util.Collection; +import java.util.Collections; +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; -public abstract class PaginatedAccountListFragment extends BaseAccountListFragment{ +public abstract class PaginatedAccountListFragment extends BaseAccountListFragment{ private String nextMaxID; + private MastodonAPIRequest remoteInfoRequest; + protected boolean doneWithHomeInstance, remoteRequestFailed, startedRemoteLoading, remoteDisabled; + protected int localOffset; + protected T remoteInfo; public abstract HeaderPaginationRequest onCreateRequest(String maxID, int count); + protected abstract MastodonAPIRequest loadRemoteInfo(); + public abstract T getCurrentInfo(); + public abstract String getRemoteDomain(); + + @Override + public void onViewCreated(View view, Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + // already have remote info (e.g. from arguments), so no need to fetch it again + if (remoteInfo != null) { + onRemoteInfoLoaded(remoteInfo); + return; + } + + remoteDisabled = !GlobalUserPreferences.allowRemoteLoading + || getSession().domain.equals(getRemoteDomain()); + if (!remoteDisabled) { + remoteInfoRequest = loadRemoteInfo().setCallback(new Callback<>() { + @Override + public void onSuccess(T result) { + if (getContext() == null) return; + onRemoteInfoLoaded(result); + } + + @Override + public void onError(ErrorResponse error) { + if (getContext() == null) return; + onRemoteLoadingFailed(); + } + }); + remoteInfoRequest.execRemote(getRemoteDomain(), getRemoteSession()); + } + } + + /** + * override to provide an ideal account session (e.g. if you're logged into the author's remote + * account) to make the remote request from. if null is provided, will try to get any session + * on the remote domain, or tries the request without authentication. + */ + protected AccountSession getRemoteSession() { + return null; + } + + protected void onRemoteInfoLoaded(T info) { + this.remoteInfo = info; + this.remoteInfoRequest = null; + maybeStartLoadingRemote(); + } + + protected void onRemoteLoadingFailed() { + this.remoteRequestFailed = true; + this.remoteInfo = null; + this.remoteInfoRequest = null; + if (doneWithHomeInstance) dataLoaded(); + } + + @Override + public void dataLoaded() { + super.dataLoaded(); + footerProgress.setVisibility(View.GONE); + } + + private void maybeStartLoadingRemote() { + if (startedRemoteLoading || remoteDisabled) return; + if (!remoteRequestFailed) { + if (data.size() == 0) showProgress(); + else footerProgress.setVisibility(View.VISIBLE); + } + if (doneWithHomeInstance && remoteInfo != null) { + startedRemoteLoading = true; + loadData(localOffset, itemsPerPage * 2); + } + } + + @Override + public void onRefresh() { + localOffset = 0; + doneWithHomeInstance = false; + startedRemoteLoading = false; + super.onRefresh(); + } + + @Override + public void loadData(int offset, int count) { + // always subtract the amount loaded through the home instance once loading from remote + // since loadData gets called with data.size() (data includes both local and remote) + if (doneWithHomeInstance) offset -= localOffset; + super.loadData(offset, count); + } + @Override protected void doLoadData(int offset, int count){ - currentRequest=onCreateRequest(offset==0 ? null : nextMaxID, count) + MastodonAPIRequest request = onCreateRequest(offset==0 ? null : nextMaxID, count) .setCallback(new SimpleCallback<>(this){ @Override public void onSuccess(HeaderPaginationList result){ + boolean justRefreshed = !doneWithHomeInstance && offset == 0; + Collection d = justRefreshed ? List.of() : data; + if(result.nextPageUri!=null) nextMaxID=result.nextPageUri.getQueryParameter("max_id"); else nextMaxID=null; - onDataLoaded(result.stream().map(a->new AccountViewModel(a, accountID)).collect(Collectors.toList()), nextMaxID!=null); + if(getActivity()==null) return; + List items = result.stream() + .filter(a -> d.size() > 1000 || d.stream() + .noneMatch(i -> i.account.url.equals(a.url))) + .peek(account ->{ + if (account.getDomainFromURL().equals(getRemoteDomain())) + account.acct=account.getFullyQualifiedName(); + }) + .map(a->new AccountViewModel(a, accountID)) + .collect(Collectors.toList()); + + boolean hasMore = nextMaxID != null; + + if (!hasMore && !doneWithHomeInstance) { + // only runs last time data was fetched from the home instance + localOffset = d.size() + items.size(); + doneWithHomeInstance = true; + } + + onDataLoaded(items, hasMore); + if (doneWithHomeInstance) maybeStartLoadingRemote(); } - }) - .exec(accountID); + + @Override + public void onError(ErrorResponse error) { + if (doneWithHomeInstance) { + onRemoteLoadingFailed(); + onDataLoaded(Collections.emptyList(), false); + return; + } + super.onError(error); + } + }); + + if (doneWithHomeInstance && remoteInfo == null) return; // we are waiting + if (doneWithHomeInstance && remoteInfo != null) { + request.execRemote(getRemoteDomain(), getRemoteSession()); + } else { + request.exec(accountID); + } + currentRequest = request; } @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/StatusEmojiReactionsListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/StatusEmojiReactionsListFragment.java new file mode 100644 index 000000000..665d70fea --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/StatusEmojiReactionsListFragment.java @@ -0,0 +1,97 @@ +package org.joinmastodon.android.fragments.account_list; + +import android.net.Uri; +import android.os.Bundle; +import android.text.SpannableStringBuilder; +import android.view.View; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.api.requests.statuses.PleromaGetStatusReactions; +import org.joinmastodon.android.model.Emoji; +import org.joinmastodon.android.model.EmojiReaction; +import org.joinmastodon.android.model.viewmodel.AccountViewModel; +import org.joinmastodon.android.ui.text.HtmlParser; +import org.joinmastodon.android.ui.utils.UiUtils; + +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +import me.grishka.appkit.api.ErrorResponse; +import me.grishka.appkit.api.SimpleCallback; + +public class StatusEmojiReactionsListFragment extends BaseAccountListFragment { + private String id; + private String emojiName; + private String url; + private int count; + + @Override + public void onCreate(Bundle savedInstanceState){ + super.onCreate(savedInstanceState); + id = getArguments().getString("statusID"); + emojiName = getArguments().getString("emoji"); + url = getArguments().getString("url"); + count = getArguments().getInt("count"); + + SpannableStringBuilder title = new SpannableStringBuilder(getResources().getQuantityString(R.plurals.sk_users_reacted_with, count, + count, url == null ? emojiName : ":"+emojiName+":")); + if (url != null) { + Emoji emoji = new Emoji(); + emoji.shortcode = emojiName; + emoji.url = url; + HtmlParser.parseCustomEmoji(title, Collections.singletonList(emoji)); + } + setTitle(title); + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + if (url != null) { + UiUtils.loadCustomEmojiInTextView(toolbarTitleView); + } + } + + @Override + public void dataLoaded() { + super.dataLoaded(); + footerProgress.setVisibility(View.GONE); + } + + @Override + protected void doLoadData(int offset, int count){ + currentRequest = new PleromaGetStatusReactions(id, emojiName) + .setCallback(new SimpleCallback<>(StatusEmojiReactionsListFragment.this){ + @Override + public void onSuccess(List result) { + if (getActivity() == null) + return; + + List items = result.get(0).accounts.stream() + .map(a -> new AccountViewModel(a, accountID)) + .collect(Collectors.toList()); + + onDataLoaded(items); + } + + @Override + public void onError(ErrorResponse error) { + super.onError(error); + } + }) + .exec(accountID); + } + + @Override + public void onResume(){ + super.onResume(); + if(!loaded && !dataLoading) + loadData(); + } + + @Override + public Uri getWebUri(Uri.Builder base) { + return null; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/StatusFavoritesListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/StatusFavoritesListFragment.java index f62e40ac5..20d0c2acf 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/StatusFavoritesListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/StatusFavoritesListFragment.java @@ -1,21 +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.statuses.GetStatusFavorites; import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.model.Status; public class StatusFavoritesListFragment extends StatusRelatedAccountListFragment{ @Override public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); + updateTitle(status); + } + + @Override + protected void updateTitle(Status status) { setTitle(getResources().getQuantityString(R.plurals.x_favorites, (int)(status.favouritesCount%1000), status.favouritesCount)); } @Override public HeaderPaginationRequest onCreateRequest(String maxID, int count){ - return new GetStatusFavorites(status.id, maxID, count); + return new GetStatusFavorites(getCurrentInfo().id, maxID, count); + } + + @Override + public Uri getWebUri(Uri.Builder base) { + Uri statusUri = super.getWebUri(base); + return isInstanceAkkoma() + ? statusUri + : statusUri.buildUpon().appendPath("favourites").build(); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/StatusReblogsListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/StatusReblogsListFragment.java index 6d494e198..3c5a5e228 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/StatusReblogsListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/StatusReblogsListFragment.java @@ -1,21 +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.statuses.GetStatusReblogs; import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.model.Status; public class StatusReblogsListFragment extends StatusRelatedAccountListFragment{ @Override public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); + updateTitle(status); + } + + @Override + protected void updateTitle(Status status) { setTitle(getResources().getQuantityString(R.plurals.x_reblogs, (int)(status.reblogsCount%1000), status.reblogsCount)); } @Override public HeaderPaginationRequest onCreateRequest(String maxID, int count){ - return new GetStatusReblogs(status.id, maxID, count); + return new GetStatusReblogs(getCurrentInfo().id, maxID, count); + } + + @Override + public Uri getWebUri(Uri.Builder base) { + Uri statusUri = super.getWebUri(base); + return isInstanceAkkoma() + ? statusUri + : statusUri.buildUpon().appendPath("reblogs").build(); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/StatusRelatedAccountListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/StatusRelatedAccountListFragment.java index 302ad75a6..aeee1fdc9 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/StatusRelatedAccountListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/StatusRelatedAccountListFragment.java @@ -1,17 +1,77 @@ 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.MastodonAPIRequest; +import org.joinmastodon.android.api.requests.statuses.GetStatusByID; +import org.joinmastodon.android.api.session.AccountSession; +import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.model.Status; import org.parceler.Parcels; -public abstract class StatusRelatedAccountListFragment extends PaginatedAccountListFragment{ +import java.util.Optional; + +public abstract class StatusRelatedAccountListFragment extends PaginatedAccountListFragment { protected Status status; + protected abstract void updateTitle(Status status); + + protected MastodonAPIRequest loadRemoteInfo() { + String[] parts = status.url.split("/"); + if (parts.length == 0) return null; + return new GetStatusByID(parts[parts.length - 1]); + } + @Override public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); status=Parcels.unwrap(getArguments().getParcelable("status")); } + @Override + protected boolean hasSubtitle(){ + return remoteRequestFailed; + } + + @Override + public Uri getWebUri(Uri.Builder base) { + return base + .encodedPath(isInstanceAkkoma() + ? "/notice/" + status.id + : '@' + status.account.acct + '/' + status.id) + .build(); + } + + @Override + public String getRemoteDomain() { + return Uri.parse(status.url).getHost(); + } + + @Override + public Status getCurrentInfo() { + return doneWithHomeInstance && remoteInfo != null ? remoteInfo : status; + } + + @Override + protected AccountSession getRemoteSession() { + return Optional.ofNullable(remoteInfo) + .map(s -> s.account) + .map(AccountSessionManager.getInstance()::tryGetAccount) + .orElse(null); + } + + @Override + protected void onRemoteInfoLoaded(Status info) { + super.onRemoteInfoLoaded(info); + updateTitle(remoteInfo); + } + + @Override + protected void onRemoteLoadingFailed() { + super.onRemoteLoadingFailed(); + setSubtitle(getContext().getString(R.string.sk_no_remote_info_hint, getSession().domain)); + updateToolbar(); + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/BubbleTimelineFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/BubbleTimelineFragment.java new file mode 100644 index 000000000..e0cc248f6 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/BubbleTimelineFragment.java @@ -0,0 +1,68 @@ +package org.joinmastodon.android.fragments.discover; + +import android.net.Uri; +import android.os.Bundle; + +import androidx.recyclerview.widget.RecyclerView; + +import org.joinmastodon.android.api.requests.timelines.GetBubbleTimeline; +import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.fragments.StatusListFragment; +import org.joinmastodon.android.model.FilterContext; +import org.joinmastodon.android.model.Status; +import org.joinmastodon.android.ui.utils.DiscoverInfoBannerHelper; + +import java.util.List; + +import me.grishka.appkit.api.SimpleCallback; +import me.grishka.appkit.utils.MergeRecyclerAdapter; + +public class BubbleTimelineFragment extends StatusListFragment { + private DiscoverInfoBannerHelper bannerHelper; + + @Override + public void onCreate(Bundle savedInstanceState){ + super.onCreate(savedInstanceState); + bannerHelper=new DiscoverInfoBannerHelper(DiscoverInfoBannerHelper.BannerType.BUBBLE_TIMELINE, accountID); + } + + + @Override + protected boolean wantsComposeButton() { + return true; + } + + @Override + protected void doLoadData(int offset, int count){ + currentRequest=new GetBubbleTimeline(getMaxID(), count, getLocalPrefs().timelineReplyVisibility) + .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); + bannerHelper.onBannerBecameVisible(); + } + }) + .exec(accountID); + } + + @Override + protected RecyclerView.Adapter getAdapter(){ + MergeRecyclerAdapter adapter=new MergeRecyclerAdapter(); + bannerHelper.maybeAddBanner(list, adapter); + adapter.addAdapter(super.getAdapter()); + return adapter; + } + + @Override + protected FilterContext getFilterContext() { + return FilterContext.PUBLIC; + } + + @Override + public Uri getWebUri(Uri.Builder base) { + return isInstanceAkkoma() ? base.path("/main/bubble").build() : null; + } +} 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 048012928..8c0192343 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 @@ -1,39 +1,88 @@ package org.joinmastodon.android.fragments.discover; +import android.graphics.Rect; +import android.graphics.drawable.Animatable; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.os.Build; import android.os.Bundle; +import android.text.SpannableStringBuilder; +import android.text.TextUtils; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.ProgressBar; +import android.widget.TextView; +import org.joinmastodon.android.R; +import org.joinmastodon.android.api.requests.accounts.GetAccountRelationships; import org.joinmastodon.android.api.requests.accounts.GetFollowSuggestions; +import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.fragments.IsOnTop; +import org.joinmastodon.android.fragments.MastodonRecyclerFragment; +import org.joinmastodon.android.fragments.ProfileFragment; import org.joinmastodon.android.fragments.ScrollableToTop; -import org.joinmastodon.android.fragments.account_list.BaseAccountListFragment; +import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.FollowSuggestion; -import org.joinmastodon.android.model.viewmodel.AccountViewModel; -import org.joinmastodon.android.ui.utils.DiscoverInfoBannerHelper; +import org.joinmastodon.android.model.Relationship; +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.ProgressBarButton; +import org.joinmastodon.android.utils.ProvidesAssistContent; +import org.parceler.Parcels; +import java.util.Collections; import java.util.List; +import java.util.Map; +import java.util.function.Function; 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; import me.grishka.appkit.api.SimpleCallback; -import me.grishka.appkit.utils.MergeRecyclerAdapter; +import me.grishka.appkit.imageloader.ImageLoaderRecyclerAdapter; +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.BindableViewHolder; +import me.grishka.appkit.utils.V; +import me.grishka.appkit.views.UsableRecyclerView; -public class DiscoverAccountsFragment extends BaseAccountListFragment implements ScrollableToTop{ - private DiscoverInfoBannerHelper bannerHelper; +public class DiscoverAccountsFragment extends MastodonRecyclerFragment implements ScrollableToTop, IsOnTop, ProvidesAssistContent.ProvidesWebUri { + private String accountID; + private Map relationships=Collections.emptyMap(); + private GetAccountRelationships relationshipsRequest; + + public DiscoverAccountsFragment(){ + super(20); + } @Override public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); - bannerHelper=new DiscoverInfoBannerHelper(DiscoverInfoBannerHelper.BannerType.ACCOUNTS, accountID); + accountID=getArguments().getString("account"); + if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N) + setRetainInstance(true); } @Override protected void doLoadData(int offset, int count){ + if(relationshipsRequest!=null){ + relationshipsRequest.cancel(); + relationshipsRequest=null; + } currentRequest=new GetFollowSuggestions(count) .setCallback(new SimpleCallback<>(this){ @Override public void onSuccess(List result){ - List accounts=result.stream().map(fs->new AccountViewModel(fs.account, accountID)).collect(Collectors.toList()); - onDataLoaded(accounts, false); - bannerHelper.onBannerBecameVisible(); + if(getActivity()==null) return; + onDataLoaded(result.stream().map(fs->new AccountWrapper(fs.account)).collect(Collectors.toList()), false); + loadRelationships(); } }) .exec(accountID); @@ -41,14 +90,253 @@ public class DiscoverAccountsFragment extends BaseAccountListFragment implements @Override protected RecyclerView.Adapter getAdapter(){ - MergeRecyclerAdapter adapter=new MergeRecyclerAdapter(); - bannerHelper.maybeAddBanner(list, adapter); - adapter.addAdapter(super.getAdapter()); - return adapter; + return new AccountsAdapter(); + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState){ + super.onViewCreated(view, savedInstanceState); + list.addItemDecoration(new RecyclerView.ItemDecoration(){ + @Override + public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state){ + outRect.bottom=outRect.left=outRect.right=V.dp(16); + if(parent.getChildAdapterPosition(view)==0) + outRect.top=V.dp(16); + } + }); + ((UsableRecyclerView)list).setDrawSelectorOnTop(true); + } + + private void loadRelationships(){ + relationships=Collections.emptyMap(); + relationshipsRequest=new GetAccountRelationships(data.stream().map(fs->fs.account.id).collect(Collectors.toList())); + relationshipsRequest.setCallback(new Callback<>(){ + @Override + public void onSuccess(List result){ + relationshipsRequest=null; + relationships=result.stream().collect(Collectors.toMap(rel->rel.id, Function.identity())); + if(getActivity()==null) return; + if(list==null) + return; + for(int i=0;i implements ImageLoaderRecyclerAdapter{ + + public AccountsAdapter(){ + super(imgLoader); + } + + @Override + public void onBindViewHolder(AccountViewHolder holder, int position){ + holder.bind(data.get(position)); + super.onBindViewHolder(holder, position); + } + + @NonNull + @Override + public AccountViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){ + return new AccountViewHolder(); + } + + @Override + public int getItemCount(){ + return data.size(); + } + + @Override + public int getImageCountForItem(int position){ + return 2+data.get(position).emojiHelper.getImageCount(); + } + + @Override + public ImageLoaderRequest getImageRequest(int position, int image){ + AccountWrapper item=data.get(position); + if(image==0) + return item.avaRequest; + else if(image==1) + return item.coverRequest; + else + return item.emojiHelper.getImageRequest(image-2); + } + } + + private class AccountViewHolder extends BindableViewHolder implements ImageLoaderViewHolder, UsableRecyclerView.DisableableClickable{ + private final ImageView cover, avatar; + private final TextView name, username, bio, followersCount, followingCount, postsCount, followersLabel, followingLabel, postsLabel; + private final ProgressBarButton actionButton; + private final ProgressBar actionProgress; + private final View actionWrap; + + private Relationship relationship; + + public AccountViewHolder(){ + super(getActivity(), R.layout.item_discover_account, list); + cover=findViewById(R.id.cover); + avatar=findViewById(R.id.avatar); + name=findViewById(R.id.name); + username=findViewById(R.id.username); + bio=findViewById(R.id.bio); + followersCount=findViewById(R.id.followers_count); + followersLabel=findViewById(R.id.followers_label); + followingCount=findViewById(R.id.following_count); + followingLabel=findViewById(R.id.following_label); + postsCount=findViewById(R.id.posts_count); + postsLabel=findViewById(R.id.posts_label); + actionButton=findViewById(R.id.action_btn); + actionProgress=findViewById(R.id.action_progress); + actionWrap=findViewById(R.id.action_btn_wrap); + + avatar.setOutlineProvider(OutlineProviders.roundedRect(15)); + avatar.setClipToOutline(true); + View border=findViewById(R.id.avatar_border); + border.setOutlineProvider(OutlineProviders.roundedRect(17)); + border.setClipToOutline(true); + cover.setOutlineProvider(OutlineProviders.roundedRect(9)); + cover.setClipToOutline(true); + itemView.setOutlineProvider(OutlineProviders.roundedRect(12)); + itemView.setClipToOutline(true); + actionButton.setOnClickListener(this::onActionButtonClick); + itemView.setOnClickListener(v->this.onClick()); + } + + @Override + public boolean isEnabled(){ + return false; + } + + @Override + public void onBind(AccountWrapper item){ + name.setText(item.parsedName); + username.setText('@'+item.account.acct); + bio.setText(item.parsedBio); + followersCount.setText(UiUtils.abbreviateNumber(item.account.followersCount)); + followingCount.setText(UiUtils.abbreviateNumber(item.account.followingCount)); + postsCount.setText(UiUtils.abbreviateNumber(item.account.statusesCount)); + followersLabel.setText(getResources().getQuantityString(R.plurals.followers, (int)Math.min(999, item.account.followersCount))); + followingLabel.setText(getResources().getQuantityString(R.plurals.following, (int)Math.min(999, item.account.followingCount))); + postsLabel.setText(getResources().getQuantityString(R.plurals.sk_posts_count_label, (int)(item.account.statusesCount%1000), item.account.statusesCount)); + followersCount.setVisibility(item.account.followersCount < 0 ? View.GONE : View.VISIBLE); + followersLabel.setVisibility(item.account.followersCount < 0 ? View.GONE : View.VISIBLE); + followingCount.setVisibility(item.account.followingCount < 0 ? View.GONE : View.VISIBLE); + followingLabel.setVisibility(item.account.followingCount < 0 ? View.GONE : View.VISIBLE); + relationship=relationships.get(item.account.id); + UiUtils.setExtraTextInfo(getContext(), null, true, false, false, item.account); + + if(relationship==null){ + actionWrap.setVisibility(View.GONE); + }else{ + actionWrap.setVisibility(View.VISIBLE); + UiUtils.setRelationshipToActionButtonM3(relationship, actionButton); + } + } + + @Override + public void setImage(int index, Drawable image){ + if(index==0){ + avatar.setImageDrawable(image); + }else if(index==1){ + cover.setImageDrawable(image); + }else{ + item.emojiHelper.setImageDrawable(index-2, image); + name.invalidate(); + bio.invalidate(); + } + if(image instanceof Animatable a && !a.isRunning()) + a.start(); + } + + @Override + public void clearImage(int index){ + setImage(index, null); + } + + @Override + public void onClick(){ + Bundle args=new Bundle(); + args.putString("account", accountID); + args.putParcelable("profileAccount", Parcels.wrap(item.account)); + Nav.go(getActivity(), ProfileFragment.class, args); + } + + private void onActionButtonClick(View v){ + itemView.setHasTransientState(true); + UiUtils.performAccountAction(getActivity(), item.account, accountID, relationship, actionButton, this::setActionProgressVisible, rel->{ + itemView.setHasTransientState(false); + relationships.put(item.account.id, rel); + rebind(); + }); + } + + private void setActionProgressVisible(boolean visible){ + actionButton.setTextVisible(!visible); + actionProgress.setVisibility(visible ? View.VISIBLE : View.GONE); + if(visible) + actionProgress.setIndeterminateTintList(actionButton.getTextColors()); + actionButton.setClickable(!visible); + } + } + + protected class AccountWrapper{ + public Account account; + public ImageLoaderRequest avaRequest, coverRequest; + public CustomEmojiHelper emojiHelper=new CustomEmojiHelper(); + public CharSequence parsedName, parsedBio; + + public AccountWrapper(Account account){ + this.account=account; + avaRequest=new UrlImageLoaderRequest( + TextUtils.isEmpty(account.avatar) ? AccountSessionManager.getInstance().getAccount(accountID).getDefaultAvatarUrl() : account.avatar, + V.dp(50), V.dp(50)); + if(!TextUtils.isEmpty(account.header)) + coverRequest=new UrlImageLoaderRequest(account.header, 1000, 1000); + parsedBio=HtmlParser.parse(account.note, account.emojis, Collections.emptyList(), Collections.emptyList(), accountID); + if(account.emojis.isEmpty()){ + parsedName= account.getDisplayName(); + }else{ + parsedName=HtmlParser.parseCustomEmoji(account.getDisplayName(), account.emojis); + emojiHelper.setText(new SpannableStringBuilder(parsedName).append(parsedBio)); + } + } + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverFragment.java index ca9d05b01..b09cadc34 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverFragment.java @@ -12,8 +12,12 @@ import android.widget.ImageButton; import android.widget.LinearLayout; import android.widget.TextView; +import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.R; +import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.fragments.IsOnTop; import org.joinmastodon.android.fragments.ScrollableToTop; +import org.joinmastodon.android.model.Instance; import org.joinmastodon.android.model.SearchResult; import org.joinmastodon.android.ui.OutlineProviders; import org.joinmastodon.android.ui.SimpleViewHolder; @@ -31,7 +35,7 @@ import me.grishka.appkit.fragments.BaseRecyclerFragment; import me.grishka.appkit.fragments.OnBackPressedListener; import me.grishka.appkit.utils.V; -public class DiscoverFragment extends AppKitFragment implements ScrollableToTop, OnBackPressedListener{ +public class DiscoverFragment extends AppKitFragment implements ScrollableToTop, OnBackPressedListener, IsOnTop { private static final int QUERY_RESULT=937; private TabLayout tabLayout; @@ -53,6 +57,8 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop, private String accountID; private String currentQuery; + private boolean disableDiscover; + @Override public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); @@ -74,8 +80,8 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop, for(int i=0;i R.id.discover_posts; - case 1 -> R.id.discover_hashtags; + case 0 -> R.id.discover_hashtags; + case 1 -> R.id.discover_posts; case 2 -> R.id.discover_news; case 3 -> R.id.discover_users; default -> throw new IllegalStateException("Unexpected value: "+i); @@ -93,8 +99,6 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop, pager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback(){ @Override public void onPageSelected(int position){ - if(position==0) - return; Fragment _page=getFragmentForPage(position); if(_page instanceof BaseRecyclerFragment page){ if(!page.loaded && !page.isDataLoading()) @@ -103,7 +107,7 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop, } }); - if(postsFragment==null){ + if(hashtagsFragment==null){ Bundle args=new Bundle(); args.putString("account", accountID); args.putBoolean("__is_tab", true); @@ -121,8 +125,8 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop, accountsFragment.setArguments(args); getChildFragmentManager().beginTransaction() - .add(R.id.discover_posts, postsFragment) .add(R.id.discover_hashtags, hashtagsFragment) + .add(R.id.discover_posts, postsFragment) .add(R.id.discover_news, newsFragment) .add(R.id.discover_users, accountsFragment) .commit(); @@ -132,8 +136,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.posts; - case 1 -> R.string.hashtags; + case 0 -> R.string.hashtags; + case 1 -> R.string.posts; case 2 -> R.string.news; case 3 -> R.string.for_you; default -> throw new IllegalStateException("Unexpected value: "+position); @@ -154,6 +158,7 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop, } }); + disableDiscover=AccountSessionManager.get(accountID).getInstance().map(Instance::isAkkoma).orElse(false); searchView=view.findViewById(R.id.search_fragment); if(searchFragment==null){ searchFragment=new SearchFragment(); @@ -165,11 +170,13 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop, searchBack=view.findViewById(R.id.search_back); searchText=view.findViewById(R.id.search_text); - searchBack.setEnabled(searchActive); searchBack.setImportantForAccessibility(searchActive ? View.IMPORTANT_FOR_ACCESSIBILITY_YES : View.IMPORTANT_FOR_ACCESSIBILITY_NO); - searchBack.setOnClickListener(v->exitSearch()); - if(searchActive){ - searchBack.setImageResource(R.drawable.ic_arrow_back); + searchBack.setOnClickListener(v->{ + if(searchActive) exitSearch(); else openSearch(); + }); + if(searchActive) searchBack.setImageResource(R.drawable.ic_fluent_arrow_left_24_regular); + else searchBack.setEnabled(false); + if(searchActive || disableDiscover){ pager.setVisibility(View.GONE); tabLayout.setVisibility(View.GONE); searchView.setVisibility(View.VISIBLE); @@ -178,22 +185,35 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop, View searchWrap=view.findViewById(R.id.search_wrap); searchWrap.setOutlineProvider(OutlineProviders.roundedRect(28)); searchWrap.setClipToOutline(true); - searchText.setOnClickListener(v->{ - Bundle args=new Bundle(); - args.putString("account", accountID); - if(!TextUtils.isEmpty(currentQuery)){ - args.putString("query", currentQuery); - } - Nav.goForResult(getActivity(), SearchQueryFragment.class, args, QUERY_RESULT, DiscoverFragment.this); - }); + searchText.setOnClickListener(v->openSearch()); tabsDivider=view.findViewById(R.id.tabs_divider); return view; } + @Override + public boolean isOnTop() { + return searchActive ? searchFragment.isOnTop() + : ((IsOnTop)getFragmentForPage(pager.getCurrentItem())).isOnTop(); + } + + public void openSearch() { + Bundle args=new Bundle(); + args.putString("account", accountID); + if(!TextUtils.isEmpty(currentQuery)){ + args.putString("query", currentQuery); + } + Nav.goForResult(getActivity(), SearchQueryFragment.class, args, QUERY_RESULT, DiscoverFragment.this); + } + @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(); @@ -201,8 +221,8 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop, } public void loadData(){ - if(postsFragment!=null && !postsFragment.loaded && !postsFragment.dataLoading) - postsFragment.loadData(); + if(hashtagsFragment!=null && !hashtagsFragment.loaded && !hashtagsFragment.dataLoading) + hashtagsFragment.loadData(); } private void enterSearch(){ @@ -211,7 +231,7 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop, pager.setVisibility(View.GONE); tabLayout.setVisibility(View.GONE); searchView.setVisibility(View.VISIBLE); - searchBack.setImageResource(R.drawable.ic_arrow_back); + searchBack.setImageResource(R.drawable.ic_fluent_arrow_left_24_regular); searchBack.setEnabled(true); searchBack.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_YES); tabsDivider.setVisibility(View.GONE); @@ -222,21 +242,24 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop, if(!searchActive) return; searchActive=false; + searchText.setText(R.string.sk_search_fediverse); + searchBack.setImageResource(R.drawable.ic_fluent_search_24_regular); + searchBack.setEnabled(false); + searchBack.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO); + currentQuery=null; + searchFragment.clear(); + + if(disableDiscover) return; pager.setVisibility(View.VISIBLE); tabLayout.setVisibility(View.VISIBLE); searchView.setVisibility(View.GONE); - searchText.setText(R.string.search_mastodon); - searchBack.setImageResource(R.drawable.ic_search_24px); - searchBack.setEnabled(false); - searchBack.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO); tabsDivider.setVisibility(View.VISIBLE); - currentQuery=null; } private Fragment getFragmentForPage(int page){ return switch(page){ - case 0 -> postsFragment; - case 1 -> hashtagsFragment; + case 0 -> hashtagsFragment; + case 1 -> postsFragment; case 2 -> newsFragment; case 3 -> accountsFragment; default -> throw new IllegalStateException("Unexpected value: "+page); 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 7c0917f57..b4a8d0950 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,8 +2,8 @@ package org.joinmastodon.android.fragments.discover; import android.graphics.Rect; import android.graphics.drawable.Drawable; +import android.os.Build; import android.os.Bundle; -import android.text.TextUtils; import android.view.View; import android.view.ViewGroup; import android.widget.ImageView; @@ -11,17 +11,16 @@ 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; -import org.joinmastodon.android.ui.DividerItemDecoration; 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 java.util.ArrayList; -import java.util.Collections; import java.util.List; import java.util.stream.Collectors; @@ -32,18 +31,16 @@ import me.grishka.appkit.api.SimpleCallback; import me.grishka.appkit.fragments.BaseRecyclerFragment; import me.grishka.appkit.imageloader.ImageLoaderRecyclerAdapter; import me.grishka.appkit.imageloader.ImageLoaderViewHolder; -import me.grishka.appkit.imageloader.ListImageLoaderAdapter; import me.grishka.appkit.imageloader.ListImageLoaderWrapper; import me.grishka.appkit.imageloader.RecyclerViewDelegate; import me.grishka.appkit.imageloader.requests.ImageLoaderRequest; -import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest; import me.grishka.appkit.utils.BindableViewHolder; import me.grishka.appkit.utils.MergeRecyclerAdapter; 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{ private String accountID; private DiscoverInfoBannerHelper bannerHelper; private MergeRecyclerAdapter mergeAdapter; @@ -60,6 +57,8 @@ public class DiscoverNewsFragment extends BaseRecyclerFragment im super.onCreate(savedInstanceState); accountID=getArguments().getString("account"); bannerHelper=new DiscoverInfoBannerHelper(DiscoverInfoBannerHelper.BannerType.TRENDING_LINKS, accountID); + if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N) + setRetainInstance(true); } @Override @@ -111,6 +110,11 @@ public class DiscoverNewsFragment extends BaseRecyclerFragment im smoothScrollRecyclerViewToTop(list); } + @Override + public boolean isOnTop(){ + return isRecyclerViewOnTop(list); + } + private class LinksAdapter extends UsableRecyclerView.Adapter implements ImageLoaderRecyclerAdapter{ private final List data; 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 22148f9fb..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 @@ -1,5 +1,6 @@ package org.joinmastodon.android.fragments.discover; +import android.net.Uri; import android.os.Bundle; import org.joinmastodon.android.api.requests.trends.GetTrendingStatuses; @@ -31,8 +32,9 @@ public class DiscoverPostsFragment extends StatusListFragment{ .setCallback(new SimpleCallback<>(this){ @Override public void onSuccess(List result){ + if(getActivity()==null) return; realOffset+=result.size(); - AccountSessionManager.get(accountID).filterStatuses(result, FilterContext.PUBLIC); + AccountSessionManager.get(accountID).filterStatuses(result, getFilterContext()); onDataLoaded(result, !result.isEmpty()); bannerHelper.onBannerBecameVisible(); } @@ -46,4 +48,14 @@ public class DiscoverPostsFragment extends StatusListFragment{ adapter.addAdapter(super.getAdapter()); return adapter; } + + @Override + protected FilterContext getFilterContext() { + return FilterContext.PUBLIC; + } + + @Override + public Uri getWebUri(Uri.Builder base){ + return isInstanceAkkoma() ? null : base.path("/explore/posts").build(); + } } 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 new file mode 100644 index 000000000..eb74bd278 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/FederatedTimelineFragment.java @@ -0,0 +1,63 @@ +package org.joinmastodon.android.fragments.discover; + +import android.net.Uri; +import android.os.Bundle; + +import org.joinmastodon.android.api.requests.timelines.GetPublicTimeline; +import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.fragments.StatusListFragment; +import org.joinmastodon.android.model.FilterContext; +import org.joinmastodon.android.model.Status; +import org.joinmastodon.android.ui.utils.DiscoverInfoBannerHelper; + +import java.util.List; + +import androidx.recyclerview.widget.RecyclerView; +import me.grishka.appkit.api.SimpleCallback; +import me.grishka.appkit.utils.MergeRecyclerAdapter; + +public class FederatedTimelineFragment extends StatusListFragment{ + private DiscoverInfoBannerHelper bannerHelper; + + private String maxID; + + @Override + public void onCreate(Bundle savedInstanceState){ + super.onCreate(savedInstanceState); + bannerHelper=new DiscoverInfoBannerHelper(DiscoverInfoBannerHelper.BannerType.FEDERATED_TIMELINE, accountID); + } + + @Override + protected void doLoadData(int offset, int count){ + currentRequest=new GetPublicTimeline(false, false, getMaxID(), null, count, null, getLocalPrefs().timelineReplyVisibility) + .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); + bannerHelper.onBannerBecameVisible(); + } + }) + .exec(accountID); + } + + @Override + protected RecyclerView.Adapter getAdapter(){ + MergeRecyclerAdapter adapter=new MergeRecyclerAdapter(); + bannerHelper.maybeAddBanner(list, adapter); + adapter.addAdapter(super.getAdapter()); + return adapter; + } + + @Override + protected FilterContext getFilterContext() { + return FilterContext.PUBLIC; + } + + @Override + public Uri getWebUri(Uri.Builder base) { + return base.path(isInstanceAkkoma() ? "/main/all" : "/public").build(); + } +} 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 new file mode 100644 index 000000000..d6975f9af --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/LocalTimelineFragment.java @@ -0,0 +1,63 @@ +package org.joinmastodon.android.fragments.discover; + +import android.net.Uri; +import android.os.Bundle; + +import org.joinmastodon.android.api.requests.timelines.GetPublicTimeline; +import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.fragments.StatusListFragment; +import org.joinmastodon.android.model.FilterContext; +import org.joinmastodon.android.model.Status; +import org.joinmastodon.android.ui.utils.DiscoverInfoBannerHelper; + +import java.util.List; + +import androidx.recyclerview.widget.RecyclerView; +import me.grishka.appkit.api.SimpleCallback; +import me.grishka.appkit.utils.MergeRecyclerAdapter; + +public class LocalTimelineFragment extends StatusListFragment{ + private DiscoverInfoBannerHelper bannerHelper; + + private String maxID; + + @Override + public void onCreate(Bundle savedInstanceState){ + super.onCreate(savedInstanceState); + bannerHelper=new DiscoverInfoBannerHelper(DiscoverInfoBannerHelper.BannerType.LOCAL_TIMELINE, accountID); + } + + @Override + protected void doLoadData(int offset, int count){ + currentRequest=new GetPublicTimeline(true, false, getMaxID(), null, count, null, getLocalPrefs().timelineReplyVisibility) + .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); + bannerHelper.onBannerBecameVisible(); + } + }) + .exec(accountID); + } + + @Override + protected RecyclerView.Adapter getAdapter(){ + MergeRecyclerAdapter adapter=new MergeRecyclerAdapter(); + bannerHelper.maybeAddBanner(list, adapter); + adapter.addAdapter(super.getAdapter()); + return adapter; + } + + @Override + protected FilterContext getFilterContext() { + return FilterContext.PUBLIC; + } + + @Override + public Uri getWebUri(Uri.Builder base){ + return base.path(isInstanceAkkoma() ? "/main/public" : "/public/local").build(); + } +} 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 59c47a903..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 @@ -1,10 +1,13 @@ package org.joinmastodon.android.fragments.discover; import android.app.Activity; +import android.net.Uri; import android.os.Build; import android.os.Bundle; +import android.view.View; import android.view.inputmethod.InputMethodManager; +import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.R; import org.joinmastodon.android.api.requests.search.GetSearchResults; import org.joinmastodon.android.api.session.AccountSessionManager; @@ -12,6 +15,7 @@ import org.joinmastodon.android.fragments.BaseStatusListFragment; import org.joinmastodon.android.fragments.ProfileFragment; import org.joinmastodon.android.fragments.ThreadFragment; import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.model.FilterContext; import org.joinmastodon.android.model.Hashtag; import org.joinmastodon.android.model.SearchResult; import org.joinmastodon.android.model.SearchResults; @@ -33,6 +37,7 @@ 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.utils.V; public class SearchFragment extends BaseStatusListFragment{ private String currentQuery; @@ -50,7 +55,7 @@ public class SearchFragment extends BaseStatusListFragment{ super.onCreate(savedInstanceState); if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N) setRetainInstance(true); - setEmptyText(R.string.no_search_results); + setEmptyText(R.string.sk_recent_searches_placeholder); loadData(); } @@ -65,7 +70,7 @@ public class SearchFragment extends BaseStatusListFragment{ return switch(s.type){ case ACCOUNT -> Collections.singletonList(new AccountStatusDisplayItem(s.id, this, s.account)); case HASHTAG -> Collections.singletonList(new HashtagStatusDisplayItem(s.id, this, s.hashtag)); - case STATUS -> StatusDisplayItem.buildItems(this, s.status, accountID, s, knownAccounts, false, true); + case STATUS -> StatusDisplayItem.buildItems(this, s.status, accountID, s, knownAccounts, FilterContext.PUBLIC, 0); }; } @@ -165,6 +170,7 @@ public class SearchFragment extends BaseStatusListFragment{ list.scrollToPosition(0); } }) + .setTimeout(180000) // 3 minutes (searches can take a long time) .exec(accountID); } @@ -180,13 +186,16 @@ public class SearchFragment extends BaseStatusListFragment{ } public void setQuery(String q, SearchResult.Type filter){ - if(q.isBlank()) + if(q.isBlank()) { + setEmptyText(R.string.sk_recent_searches_placeholder); return; + } if(currentRequest!=null){ currentRequest.cancel(); currentRequest=null; } currentQuery=q; + setEmptyText(R.string.no_search_results); if(filter==null) currentFilter=EnumSet.allOf(SearchResult.Type.class); else @@ -228,6 +237,21 @@ public class SearchFragment extends BaseStatusListFragment{ } } + public void clear() { + data.clear(); + preloadedData.clear(); + adapter.notifyDataSetChanged(); + V.setVisibilityAnimated(content, View.GONE); + } + + @Override + public Uri getWebUri(Uri.Builder base) { + Uri.Builder searchUri = base.path("/search"); + return isInstanceAkkoma() + ? searchUri.appendQueryParameter("query", currentQuery).build() + : searchUri.build(); + } + @FunctionalInterface public interface ProgressVisibilityListener{ void onProgressVisibilityChanged(boolean visible); 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 2712ee978..d3876d66b 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 @@ -4,12 +4,12 @@ import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.AnimatorSet; import android.animation.ObjectAnimator; +import android.app.Activity; import android.app.Fragment; 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; @@ -22,19 +22,22 @@ import android.view.animation.AnimationUtils; import android.view.inputmethod.InputMethodManager; import android.widget.Button; import android.widget.TextView; +import android.widget.Toast; import android.widget.Toolbar; -import org.joinmastodon.android.MainActivity; +import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.R; import org.joinmastodon.android.api.requests.search.GetSearchResults; import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.fragments.MastodonRecyclerFragment; +import org.joinmastodon.android.fragments.ProfileFragment; import org.joinmastodon.android.model.Relationship; import org.joinmastodon.android.model.SearchResult; import org.joinmastodon.android.model.SearchResults; import org.joinmastodon.android.model.viewmodel.ListItem; import org.joinmastodon.android.model.viewmodel.SearchResultViewModel; import org.joinmastodon.android.ui.DividerItemDecoration; +import org.joinmastodon.android.ui.M3AlertDialogBuilder; import org.joinmastodon.android.ui.SearchViewHelper; import org.joinmastodon.android.ui.adapters.GenericListItemsAdapter; import org.joinmastodon.android.ui.utils.HideableSingleViewRecyclerAdapter; @@ -91,11 +94,11 @@ public class SearchQueryFragment extends MastodonRecyclerFragment(R.string.search_open_url, 0, R.drawable.ic_link_24px, this::onOpenURLClick); - goToHashtagItem=new ListItem<>("", null, R.drawable.ic_tag_24px, this::onGoToHashtagClick); - goToAccountItem=new ListItem<>("", null, R.drawable.ic_person_24px, this::onGoToAccountClick); - goToStatusSearchItem=new ListItem<>("", null, R.drawable.ic_search_24px, this::onGoToStatusSearchClick); - goToAccountSearchItem=new ListItem<>("", null, R.drawable.ic_group_24px, this::onGoToAccountSearchClick); + openUrlItem=new ListItem<>(R.string.search_open_url, 0, R.drawable.ic_fluent_link_24_regular, this::onOpenURLClick); + goToHashtagItem=new ListItem<>("", null, R.drawable.ic_fluent_number_symbol_24_regular, this::onGoToHashtagClick); + goToAccountItem=new ListItem<>("", null, R.drawable.ic_fluent_person_24_regular, this::onGoToAccountClick); + goToStatusSearchItem=new ListItem<>("", null, R.drawable.ic_fluent_search_24_regular, this::onGoToStatusSearchClick); + goToAccountSearchItem=new ListItem<>("", null, R.drawable.ic_fluent_people_24_regular, this::onGoToAccountSearchClick); currentQuery=getArguments().getString("query"); dataLoaded(); @@ -124,7 +127,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); @@ -159,14 +166,14 @@ public class SearchQueryFragment extends MastodonRecyclerFragment{ + UiUtils.openHashtagTimeline(getActivity(), accountID, res.hashtag); + AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putRecentSearch(res); + }); } private boolean isInRecentMode(){ return TextUtils.isEmpty(currentQuery); } + private void wrapSuicideDialog(Runnable r){ + if(!GlobalUserPreferences.showSuicideHelp || currentQuery==null){ + r.run(); + return; + } + + String[] terms=getContext().getString(R.string.sk_suicide_search_terms).toLowerCase().split(","); + String query=currentQuery.trim().toLowerCase(); + boolean termMatches=false; + for(String term : terms){ + if(query.contains(term)){ + termMatches=true; + break; + } + } + + if(!termMatches){ + r.run(); + return; + } + + String url=getContext().getString(R.string.sk_suicide_helplines_url); + new M3AlertDialogBuilder(getActivity()) + .setTitle(R.string.sk_search_suicide_title) + .setMessage(R.string.sk_search_suicide_message) + .setNegativeButton(R.string.sk_do_not_show_again, (dialog, which)->{ + GlobalUserPreferences.showSuicideHelp = false; + GlobalUserPreferences.save(); + r.run(); + }) + .setNeutralButton(R.string.sk_search_suicide_hotlines, (dialog, which)->UiUtils.launchWebBrowser(getContext(), url)) + .setPositiveButton(R.string.ok, (dialog, which)->r.run()) + .setOnDismissListener((dialog)->{}) + .show(); + } + private void onSearchViewEnter(){ if(TextUtils.isEmpty(currentQuery) || currentQuery.trim().isEmpty()) return; - deliverResult(currentQuery, null); + wrapSuicideDialog(()->deliverResult(currentQuery, null)); } private void onOpenURLClick(ListItem item_){ - ((MainActivity)getActivity()).handleURL(Uri.parse(searchViewHelper.getQuery()), accountID); + UiUtils.openURL(getContext(), accountID, searchViewHelper.getQuery(), false); } private void onGoToHashtagClick(ListItem item_){ - String q=searchViewHelper.getQuery(); - if(q.startsWith("#")) - q=q.substring(1); - UiUtils.openHashtagTimeline(getActivity(), accountID, q); + wrapSuicideDialog(()->{ + String q=searchViewHelper.getQuery(); + if(q.startsWith("#")) + q=q.substring(1); + UiUtils.openHashtagTimeline(getActivity(), accountID, q); + }); } private void onGoToAccountClick(ListItem item_){ @@ -408,15 +454,21 @@ public class SearchQueryFragment extends MastodonRecyclerFragment { + if (!args.containsKey("profileAccount")) { + Toast.makeText(getContext(), R.string.no_search_results, Toast.LENGTH_SHORT).show(); + return; + } + Nav.go((Activity) getContext(), ProfileFragment.class, args); + }).ifPresent(progress -> progress.wrapProgress((Activity) getContext(), R.string.loading, true)); } private void onGoToStatusSearchClick(ListItem item_){ - deliverResult(searchViewHelper.getQuery(), SearchResult.Type.STATUS); + wrapSuicideDialog(()->deliverResult(searchViewHelper.getQuery(), SearchResult.Type.STATUS)); } private void onGoToAccountSearchClick(ListItem item_){ - deliverResult(searchViewHelper.getQuery(), SearchResult.Type.ACCOUNT); + wrapSuicideDialog(()->deliverResult(searchViewHelper.getQuery(), SearchResult.Type.ACCOUNT)); } private void onClearRecentClick(){ @@ -429,6 +481,8 @@ public class SearchQueryFragment extends MastodonRecyclerFragment implements ScrollableToTop{ +public class TrendingHashtagsFragment extends BaseRecyclerFragment implements ScrollableToTop, IsOnTop{ private String accountID; public TrendingHashtagsFragment(){ @@ -34,6 +34,8 @@ public class TrendingHashtagsFragment extends BaseRecyclerFragment impl public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); accountID=getArguments().getString("account"); + if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N) + setRetainInstance(true); } @Override @@ -58,6 +60,11 @@ public class TrendingHashtagsFragment extends BaseRecyclerFragment impl smoothScrollRecyclerViewToTop(list); } + @Override + public boolean isOnTop(){ + return isRecyclerViewOnTop(list); + } + 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 c3d36b9f2..75325ad4a 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.sheets.AccountSwitcherSheet; +import org.joinmastodon.android.ui.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 new file mode 100644 index 000000000..54f35591f --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/CustomWelcomeFragment.java @@ -0,0 +1,244 @@ +package org.joinmastodon.android.fragments.onboarding; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.os.Build; +import android.os.Bundle; +import android.text.Editable; +import android.text.TextWatcher; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.RadioButton; +import android.widget.Space; +import android.widget.TextView; +import android.widget.Toolbar; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.model.Instance; +import org.joinmastodon.android.model.catalog.CatalogInstance; +import org.joinmastodon.android.ui.BetterItemAnimator; +import org.joinmastodon.android.ui.utils.UiUtils; + +import java.util.ArrayList; +import java.util.Objects; + +import me.grishka.appkit.FragmentStackActivity; +import me.grishka.appkit.utils.BindableViewHolder; +import me.grishka.appkit.utils.MergeRecyclerAdapter; +import me.grishka.appkit.utils.SingleViewRecyclerAdapter; +import me.grishka.appkit.utils.V; +import me.grishka.appkit.views.UsableRecyclerView; + +public class CustomWelcomeFragment extends InstanceCatalogFragment { + private View headerView; + + public CustomWelcomeFragment() { + super(R.layout.fragment_welcome_custom, 1); + } + + @Override + public void onAttach(Context context){ + super.onAttach(context); + setRefreshEnabled(false); + } + + @Override + public void onCreate(Bundle savedInstanceState){ + super.onCreate(savedInstanceState); + dataLoaded(); + } + + @Override + protected void onUpdateToolbar(){ + super.onUpdateToolbar(); + + if (!canGoBack()) { + ImageView toolbarLogo=new ImageView(getActivity()); + toolbarLogo.setScaleType(ImageView.ScaleType.CENTER); + toolbarLogo.setImageResource(R.drawable.logo); + toolbarLogo.setImageTintList(ColorStateList.valueOf(UiUtils.getThemeColor(getActivity(), android.R.attr.textColorPrimary))); + + FrameLayout logoWrap=new FrameLayout(getActivity()); + FrameLayout.LayoutParams logoParams=new FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.CENTER); + logoParams.setMargins(0, V.dp(2), 0, 0); + logoWrap.addView(toolbarLogo, logoParams); + + getToolbar().addView(logoWrap, new Toolbar.LayoutParams(Gravity.CENTER)); + } else { + setTitle(R.string.add_account); + } + } + + @Override + protected void proceedWithAuthOrSignup(Instance instance) { + AccountSessionManager.getInstance().authenticate(getActivity(), instance); + } + + @Override + protected void updateFilteredList(){ + String query=getCurrentSearchQuery(); + boolean addFakeInstance=query.length()>0 && query.matches("^\\S+\\.[^\\.]+$"); + if(addFakeInstance){ + fakeInstance.domain=fakeInstance.normalizedDomain=query; + fakeInstance.description=getString(R.string.loading_instance); + if(filteredData.size()>0 && filteredData.get(0)==fakeInstance){ + if(list.findViewHolderForAdapterPosition(1) instanceof InstanceViewHolder ivh){ + ivh.rebind(); + } + } + if(filteredData.isEmpty()){ + filteredData.add(fakeInstance); + adapter.notifyItemInserted(0); + } + } + ArrayList prevData=new ArrayList<>(filteredData); + filteredData.clear(); + if(query.length()>0){ + boolean foundExactMatch=false; + for(CatalogInstance inst:data){ + if(inst.normalizedDomain.contains(query)){ + filteredData.add(inst); + if(inst.normalizedDomain.equals(query)) + foundExactMatch=true; + } + } + if(!foundExactMatch && addFakeInstance) { + filteredData.add(0, fakeInstance); + adapter.notifyItemChanged(0); + } + } + UiUtils.updateList(prevData, filteredData, list, adapter, Objects::equals); + for(int i=0;i getAdapter(){ + headerView=getActivity().getLayoutInflater().inflate(R.layout.header_welcome_custom, list, false); + searchEdit=headerView.findViewById(R.id.search_edit); + searchEdit.setOnEditorActionListener(this::onSearchEnterPressed); + + headerView.findViewById(R.id.more).setVisibility(View.GONE); + headerView.findViewById(R.id.visibility).setVisibility(View.GONE); + 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.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); + + searchEdit.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){ + nextButton.setEnabled(false); + chosenInstance = null; + searchEdit.removeCallbacks(searchDebouncer); + searchEdit.postDelayed(searchDebouncer, 300); + } + + @Override + public void afterTextChanged(Editable s){} + }); + + mergeAdapter=new MergeRecyclerAdapter(); + mergeAdapter.addAdapter(new SingleViewRecyclerAdapter(headerView)); + mergeAdapter.addAdapter(adapter=new InstancesAdapter()); + View spacer = new Space(getActivity()); + spacer.setMinimumHeight(V.dp(8)); + mergeAdapter.addAdapter(new SingleViewRecyclerAdapter(spacer)); + return mergeAdapter; + } + + private class InstancesAdapter extends UsableRecyclerView.Adapter { + public InstancesAdapter(){ + super(imgLoader); + } + + @NonNull + @Override + public InstanceViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){ + return new InstanceViewHolder(); + } + + @Override + public void onBindViewHolder(InstanceViewHolder holder, int position){ + holder.bind(filteredData.get(position)); + chosenInstance = filteredData.get(position); + if (chosenInstance != fakeInstance) nextButton.setEnabled(true); + super.onBindViewHolder(holder, position); + } + + @Override + public int getItemCount(){ + return filteredData.size(); + } + + @Override + public int getItemViewType(int position){ + return -1; + } + } + + private class InstanceViewHolder extends BindableViewHolder implements UsableRecyclerView.Clickable{ + private final TextView title, description, userCount, lang; + + public InstanceViewHolder(){ + super(getActivity(), R.layout.item_instance_custom, list); + title=findViewById(R.id.title); + description=findViewById(R.id.description); + userCount=findViewById(R.id.user_count); + lang=findViewById(R.id.lang); + if(Build.VERSION.SDK_INT{ +abstract class InstanceCatalogFragment extends MastodonRecyclerFragment { protected RecyclerView.Adapter adapter; protected MergeRecyclerAdapter mergeAdapter; protected CatalogInstance chosenInstance; @@ -76,13 +76,13 @@ abstract class InstanceCatalogFragment extends BaseRecyclerFragment sortInstances(List result){ @@ -128,9 +128,16 @@ abstract class InstanceCatalogFragment extends BaseRecyclerFragment0 ? parts[parts.length-1] : ""; + } + protected String normalizeInstanceDomain(String _domain){ if(TextUtils.isEmpty(_domain)) return null; + String[] parts=_domain.split("@"); + _domain=parts[parts.length - 1]; if(_domain.contains(":")){ try{ _domain=Uri.parse(_domain).getAuthority(); @@ -208,7 +215,7 @@ abstract class InstanceCatalogFragment extends BaseRecyclerFragmentonButtonClick()); @@ -77,12 +93,16 @@ public class InstanceRulesFragment extends ToolbarFragment{ @Override public void onViewCreated(View view, Bundle savedInstanceState){ super.onViewCreated(view, savedInstanceState); +// setStatusBarColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3Background)); +// view.setBackgroundColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3Background)); list.addOnScrollListener(onScrollListener=new ElevationOnScrollListener((FragmentRootLinearLayout) view, buttonBar, getToolbar())); } @Override protected void onUpdateToolbar(){ super.onUpdateToolbar(); + getToolbar().setBackgroundResource(R.drawable.bg_onboarding_panel); + getToolbar().setElevation(0); if(onScrollListener!=null){ onScrollListener.setViews(buttonBar, getToolbar()); } @@ -107,6 +127,60 @@ public class InstanceRulesFragment extends ToolbarFragment{ @Override public void onApplyWindowInsets(WindowInsets insets){ - super.onApplyWindowInsets(UiUtils.applyBottomInsetToFixedView(buttonBar, insets)); + 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(), insets.getSystemWindowInsetBottom())); + }else{ + super.onApplyWindowInsets(insets.replaceSystemWindowInsets(insets.getSystemWindowInsetLeft(), insets.getSystemWindowInsetTop(), insets.getSystemWindowInsetRight(), insets.getSystemWindowInsetBottom())); + } + } + + @Override + public void onProvideAssistContent(AssistContent assistContent) { + assistContent.setWebUri(new Uri.Builder() + .scheme("https") + .authority(instance.normalizedUri) + .path("/about") + .build()); + } + + private class ItemsAdapter extends RecyclerView.Adapter{ + + @NonNull + @Override + public ItemViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){ + return new ItemViewHolder(); + } + + @Override + public void onBindViewHolder(@NonNull ItemViewHolder holder, int position){ + holder.bind(instance.rules.get(position)); + } + + @Override + public int getItemCount(){ + return instance.rules.size(); + } + } + + private class ItemViewHolder extends BindableViewHolder{ + private final TextView text, number; + + public ItemViewHolder(){ + super(getActivity(), R.layout.item_server_rule, list); + text=findViewById(R.id.text); + number=findViewById(R.id.number); + } + + @SuppressLint("DefaultLocale") + @Override + public void onBind(Instance.Rule item){ + if(item.parsedText==null){ + item.parsedText=HtmlParser.parseLinks(item.text); + } + text.setText(item.parsedText); + number.setText(String.format("%d", getAbsoluteAdapterPosition())); + } } } 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 ea8741356..10cdab1f5 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 @@ -1,6 +1,7 @@ package org.joinmastodon.android.fragments.onboarding; import android.app.ProgressDialog; +import android.net.Uri; import android.os.Bundle; import android.view.View; import android.view.WindowInsets; @@ -55,7 +56,7 @@ public class OnboardingFollowSuggestionsFragment extends BaseAccountListFragment buttonBar=view.findViewById(R.id.button_bar); 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 @@ -160,9 +161,9 @@ 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 @@ -171,4 +172,9 @@ public class OnboardingFollowSuggestionsFragment extends BaseAccountListFragment holder.setStyle(AccountViewHolder.AccessoryType.BUTTON, true); holder.avatar.setOutlineProvider(OutlineProviders.roundedRect(8)); } + + @Override + public Uri getWebUri(Uri.Builder base){ + return null; + } } 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 619801a8a..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 @@ -3,7 +3,6 @@ package org.joinmastodon.android.fragments.onboarding; import android.app.Activity; import android.content.Intent; import android.net.Uri; -import android.os.Build; import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/report/ReportAddPostsChoiceFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/report/ReportAddPostsChoiceFragment.java index 85f9c698c..13e074e84 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/report/ReportAddPostsChoiceFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/report/ReportAddPostsChoiceFragment.java @@ -1,9 +1,8 @@ package org.joinmastodon.android.fragments.report; import android.app.Activity; -import android.graphics.Canvas; -import android.graphics.Paint; import android.graphics.Rect; +import android.net.Uri; import android.os.Bundle; import android.view.View; import android.view.WindowInsets; @@ -19,10 +18,12 @@ import org.joinmastodon.android.api.requests.accounts.GetAccountStatuses; import org.joinmastodon.android.events.FinishReportFragmentsEvent; import org.joinmastodon.android.fragments.StatusListFragment; import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.model.FilterContext; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.ui.OutlineProviders; import org.joinmastodon.android.ui.displayitems.AudioStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.CheckableHeaderStatusDisplayItem; +import org.joinmastodon.android.ui.displayitems.DummyStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.LinkCardStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.MediaGridStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.StatusDisplayItem; @@ -81,10 +82,11 @@ public class ReportAddPostsChoiceFragment extends StatusListFragment{ @Override protected void doLoadData(int offset, int count){ - currentRequest=new GetAccountStatuses(reportAccount.id, offset>0 ? getMaxID() : null, null, count, GetAccountStatuses.Filter.OWN_POSTS_AND_REPLIES) + currentRequest=new GetAccountStatuses(reportAccount.id, getMaxID(), null, count, GetAccountStatuses.Filter.OWN_POSTS_AND_REPLIES) .setCallback(new SimpleCallback<>(this){ @Override public void onSuccess(List result){ + if(getActivity()==null) return; for(Status s:result){ s.sensitive=true; } @@ -94,15 +96,14 @@ public class ReportAddPostsChoiceFragment extends StatusListFragment{ .exec(accountID); } - @Override - public void onItemClick(String id){ + public void onToggleItem(String id){ if(selectedIDs.contains(id)) selectedIDs.remove(id); else selectedIDs.add(id); CheckableHeaderStatusDisplayItem.Holder holder=findHolderOfType(id, CheckableHeaderStatusDisplayItem.Holder.class); - if(holder!=null) - holder.rebind(); + if(holder!=null) holder.rebind(); + else notifyItemChanged(id, CheckableHeaderStatusDisplayItem.class); } @Override @@ -118,13 +119,20 @@ public class ReportAddPostsChoiceFragment extends StatusListFragment{ RecyclerView.ViewHolder holder=parent.getChildViewHolder(view); if(holder.getAbsoluteAdapterPosition()==0 || holder instanceof CheckableHeaderStatusDisplayItem.Holder) return; - outRect.left=V.dp(40); + boolean isRTL=parent.getLayoutDirection()==View.LAYOUT_DIRECTION_RTL; + if(isRTL) outRect.right=V.dp(40); + else outRect.left=V.dp(40); if(holder instanceof AudioStatusDisplayItem.Holder){ outRect.bottom=V.dp(16); }else if(holder instanceof LinkCardStatusDisplayItem.Holder || holder instanceof MediaGridStatusDisplayItem.Holder){ - outRect.bottom=V.dp(16); - outRect.left+=V.dp(16); - outRect.right=V.dp(16); + outRect.bottom=V.dp(8); + if(isRTL){ + outRect.right+=V.dp(16); + outRect.left=V.dp(16); + }else{ + outRect.left+=V.dp(16); + outRect.right=V.dp(16); + } } } }); @@ -152,9 +160,6 @@ public class ReportAddPostsChoiceFragment extends StatusListFragment{ return adapter; } - protected void drawDivider(View child, View bottomSibling, RecyclerView.ViewHolder holder, RecyclerView.ViewHolder siblingHolder, RecyclerView parent, Canvas c, Paint paint){ - } - private void onButtonClick(View v){ Bundle args=new Bundle(); args.putString("account", accountID); @@ -198,7 +203,14 @@ public class ReportAddPostsChoiceFragment extends StatusListFragment{ @Override protected List buildDisplayItems(Status s){ - return StatusDisplayItem.buildItems(this, s, accountID, s, knownAccounts, StatusDisplayItem.FLAG_INSET | StatusDisplayItem.FLAG_NO_FOOTER | StatusDisplayItem.FLAG_CHECKABLE | StatusDisplayItem.FLAG_MEDIA_FORCE_HIDDEN); + List items=StatusDisplayItem.buildItems(this, s, accountID, s, knownAccounts, getFilterContext(), StatusDisplayItem.FLAG_NO_FOOTER | StatusDisplayItem.FLAG_CHECKABLE | StatusDisplayItem.FLAG_MEDIA_FORCE_HIDDEN); + items.add(new DummyStatusDisplayItem(s.getID(), this)); + return items; + } + + @Override + protected FilterContext getFilterContext(){ + return null; } @Override @@ -218,4 +230,9 @@ public class ReportAddPostsChoiceFragment extends StatusListFragment{ private boolean isChecked(CheckableHeaderStatusDisplayItem.Holder holder){ return selectedIDs.contains(holder.getItem().parentID); } + + @Override + public Uri getWebUri(Uri.Builder base){ + return null; + } } 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 522be853a..9966253f7 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 @@ -16,6 +16,7 @@ import android.widget.TextView; 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.reports.SendReport; import org.joinmastodon.android.api.session.AccountSessionManager; @@ -99,6 +100,7 @@ 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/report/ReportDoneFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/report/ReportDoneFragment.java index 64c13d87d..1b7e9f01c 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/report/ReportDoneFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/report/ReportDoneFragment.java @@ -34,7 +34,6 @@ import me.grishka.appkit.api.Callback; import me.grishka.appkit.api.ErrorResponse; 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 ReportDoneFragment extends MastodonToolbarFragment{ @@ -131,9 +130,9 @@ public class ReportDoneFragment extends MastodonToolbarFragment{ unfollowTitle.setText(getString(R.string.unfollow_user, '@'+reportAccount.acct)); muteTitle.setText(getString(R.string.mute_user, '@'+reportAccount.acct)); blockTitle.setText(getString(R.string.block_user, '@'+reportAccount.acct)); - setIconToButton(R.drawable.ic_person_remove_20px, unfollowTitle); - setIconToButton(R.drawable.ic_block_20px, blockTitle); - setIconToButton(R.drawable.ic_volume_off_20px, muteTitle); + setIconToButton(R.drawable.ic_fluent_person_delete_20_filled, unfollowTitle); + setIconToButton(R.drawable.ic_fluent_person_prohibited_20_filled, blockTitle); + setIconToButton(R.drawable.ic_fluent_speaker_0_20_filled, muteTitle); unfollowBtn.setOnClickListener(v->onUnfollowClick()); muteBtn.setOnClickListener(v->onMuteClick()); @@ -184,7 +183,7 @@ public class ReportDoneFragment extends MastodonToolbarFragment{ E.post(new RemoveAccountPostsEvent(accountID, reportAccount.id, true)); unfollowTitle.setTextColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3OnSecondaryContainer)); unfollowTitle.setText(getString(R.string.unfollowed_user, '@'+reportAccount.acct)); - setIconToButton(R.drawable.ic_check_24px, unfollowTitle); + setIconToButton(R.drawable.ic_fluent_checkmark_24_regular, unfollowTitle); unfollowBtn.setBackgroundResource(R.drawable.bg_button_m3_tonal); unfollowBtn.setClickable(false); unfollowBtn.setFocusable(false); @@ -203,7 +202,7 @@ public class ReportDoneFragment extends MastodonToolbarFragment{ UiUtils.confirmToggleMuteUser(getActivity(), accountID, reportAccount, false, rel->{ muteTitle.setTextColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3OnSecondaryContainer)); muteTitle.setText(getString(R.string.muted_user, '@'+reportAccount.acct)); - setIconToButton(R.drawable.ic_check_24px, muteTitle); + setIconToButton(R.drawable.ic_fluent_checkmark_24_regular, muteTitle); muteBtn.setBackgroundResource(R.drawable.bg_button_m3_tonal); muteBtn.setClickable(false); muteBtn.setFocusable(false); @@ -214,7 +213,7 @@ public class ReportDoneFragment extends MastodonToolbarFragment{ UiUtils.confirmToggleBlockUser(getActivity(), accountID, reportAccount, false, rel->{ blockTitle.setTextColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3OnSecondaryContainer)); blockTitle.setText(getString(R.string.blocked_user, '@'+reportAccount.acct)); - setIconToButton(R.drawable.ic_check_24px, blockTitle); + setIconToButton(R.drawable.ic_fluent_checkmark_24_regular, blockTitle); blockBtn.setBackgroundResource(R.drawable.bg_button_m3_tonal); blockBtn.setClickable(false); blockBtn.setFocusable(false); @@ -227,7 +226,7 @@ public class ReportDoneFragment extends MastodonToolbarFragment{ @Override protected int getNavigationIconDrawableResource(){ - return R.drawable.ic_baseline_close_24; + return R.drawable.ic_fluent_dismiss_24_regular; } @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/report/ReportReasonChoiceFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/report/ReportReasonChoiceFragment.java index 681e619a6..ede23f10a 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/report/ReportReasonChoiceFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/report/ReportReasonChoiceFragment.java @@ -4,6 +4,7 @@ import android.app.Activity; import android.graphics.Canvas; import android.graphics.Paint; import android.graphics.Rect; +import android.net.Uri; import android.os.Bundle; import android.util.Log; import android.view.LayoutInflater; @@ -21,6 +22,7 @@ import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.events.FinishReportFragmentsEvent; import org.joinmastodon.android.fragments.StatusListFragment; import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.model.FilterContext; import org.joinmastodon.android.model.Instance; import org.joinmastodon.android.model.Relationship; import org.joinmastodon.android.model.ReportReason; @@ -81,7 +83,7 @@ public class ReportReasonChoiceFragment extends StatusListFragment{ reportStatus=Parcels.unwrap(getArguments().getParcelable("status")); if(reportStatus!=null){ Status hiddenStatus=reportStatus.clone(); - hiddenStatus.spoilerText=getString(R.string.post_hidden); + if(hiddenStatus.spoilerText==null) hiddenStatus.spoilerText=getString(R.string.post_hidden); onDataLoaded(Collections.singletonList(hiddenStatus)); setTitle(R.string.report_title_post); }else{ @@ -166,17 +168,6 @@ public class ReportReasonChoiceFragment extends StatusListFragment{ ((UsableRecyclerView)list).setIncludeMarginsInItemHitbox(false); if(reportStatus!=null){ - list.addItemDecoration(new RecyclerView.ItemDecoration(){ - @Override - public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state){ - RecyclerView.ViewHolder holder=parent.getChildViewHolder(view); - if(holder instanceof LinkCardStatusDisplayItem.Holder || holder instanceof MediaGridStatusDisplayItem.Holder){ - outRect.left=V.dp(16); - outRect.right=V.dp(16); - } - } - }); - list.addItemDecoration(new RecyclerView.ItemDecoration(){ private Paint paint=new Paint(Paint.ANTI_ALIAS_FLAG); { @@ -213,18 +204,6 @@ public class ReportReasonChoiceFragment extends StatusListFragment{ float off=paint.getStrokeWidth()/2f; c.drawRoundRect(V.dp(16)-off, top-off, parent.getWidth()-V.dp(16)+off, bottom+off, V.dp(12), V.dp(12), paint); } - - @Override - public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state){ - RecyclerView.ViewHolder holder=parent.getChildViewHolder(view); - if(holder instanceof StatusDisplayItem.Holder){ - outRect.left=outRect.right=V.dp(16); - } - int index=holder.getAbsoluteAdapterPosition()-mergeAdapter.getPositionForAdapter(adapter); - if(index==displayItems.size()){ - outRect.top=V.dp(32); - } - } }); } } @@ -241,19 +220,12 @@ public class ReportReasonChoiceFragment extends StatusListFragment{ @Override protected List buildDisplayItems(Status s){ - return StatusDisplayItem.buildItems(this, s, accountID, s, knownAccounts, StatusDisplayItem.FLAG_INSET | StatusDisplayItem.FLAG_NO_FOOTER); + return StatusDisplayItem.buildItems(this, s, accountID, s, knownAccounts, getFilterContext(), StatusDisplayItem.FLAG_INSET | StatusDisplayItem.FLAG_NO_FOOTER); } @Override - protected void onModifyItemViewHolder(BindableViewHolder holder){ - if((Object)holder instanceof MediaGridStatusDisplayItem.Holder h){ - View layout=h.getLayout(); - layout.setOutlineProvider(OutlineProviders.roundedRect(8)); - layout.setClipToOutline(true); - View overlay=h.getSensitiveOverlay(); - overlay.setOutlineProvider(OutlineProviders.roundedRect(8)); - overlay.setClipToOutline(true); - } + protected FilterContext getFilterContext(){ + return null; } @Override @@ -262,4 +234,9 @@ public class ReportReasonChoiceFragment extends StatusListFragment{ if(id.equals(reportAccount.id)) relationship=rel; } + + @Override + public Uri getWebUri(Uri.Builder base){ + return null; + } } 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 0207d4b7e..8f0643841 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 @@ -49,7 +49,7 @@ public abstract class BaseSettingsFragment extends MastodonRecyclerFragmentvh instanceof SimpleListItemViewHolder ivh && ivh.getItem().dividerAfter)); + list.addItemDecoration(new DividerItemDecoration(getActivity(), R.attr.colorM3OutlineVariant, 1, 0, 0, vh->vh instanceof ListItemViewHolder ivh && ivh.getItem().dividerAfter)); list.setItemAnimator(new BetterItemAnimator()); } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/EditFilterFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/EditFilterFragment.java index bee767c19..6aa1edea0 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/EditFilterFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/EditFilterFragment.java @@ -68,6 +68,10 @@ public class EditFilterFragment extends BaseSettingsFragment 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), diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/FilterWordsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/FilterWordsFragment.java index fb689ef6b..d6e854242 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/FilterWordsFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/FilterWordsFragment.java @@ -92,7 +92,7 @@ public class FilterWordsFragment extends BaseSettingsFragment imp public void onViewCreated(View view, Bundle savedInstanceState){ super.onViewCreated(view, savedInstanceState); fab=view.findViewById(R.id.fab); - fab.setImageResource(R.drawable.ic_add_24px); + fab.setImageResource(R.drawable.ic_fluent_add_24_regular); fab.setContentDescription(getString(R.string.add_muted_word)); fab.setOnClickListener(v->onFabClick()); } 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 1143f6700..6bd3a77b8 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 @@ -5,20 +5,42 @@ import android.content.ClipData; import android.content.ClipboardManager; import android.os.Build; import android.os.Bundle; +import android.util.Log; import android.view.Gravity; import android.view.ViewGroup; import android.widget.TextView; import android.widget.Toast; import org.joinmastodon.android.BuildConfig; +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.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.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.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.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.format.FormatStyle; +import java.util.ArrayList; +import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import androidx.recyclerview.widget.RecyclerView; @@ -27,25 +49,53 @@ import me.grishka.appkit.utils.MergeRecyclerAdapter; import me.grishka.appkit.utils.SingleViewRecyclerAdapter; import me.grishka.appkit.utils.V; -public class SettingsAboutAppFragment extends BaseSettingsFragment{ - private ListItem mediaCacheItem; +public class SettingsAboutAppFragment extends BaseSettingsFragment implements HasAccountID{ + private static final String TAG="SettingsAboutAppFragment"; + 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; @Override public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); - setTitle(getString(R.string.about_app, getString(R.string.app_name))); - AccountSession s=AccountSessionManager.get(accountID); - onDataLoaded(List.of( - new ListItem<>(R.string.settings_even_more, 0, i->UiUtils.launchWebBrowser(getActivity(), "https://"+s.domain+"/auth/edit")), - new ListItem<>(R.string.settings_contribute, 0, i->UiUtils.launchWebBrowser(getActivity(), getString(R.string.github_url))), - new ListItem<>(R.string.settings_tos, 0, i->UiUtils.launchWebBrowser(getActivity(), "https://"+s.domain+"/terms")), - new ListItem<>(R.string.settings_privacy_policy, 0, i->UiUtils.launchWebBrowser(getActivity(), getString(R.string.privacy_policy_url)), 0, true), - mediaCacheItem=new ListItem<>(R.string.settings_clear_cache, 0, this::onClearMediaCacheClick) + 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.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), + 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()){ + items.add(enablePreReleasesItem=new CheckableListItem<>(R.string.sk_updater_enable_pre_releases, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.enablePreReleases, i->toggleCheckableItem(enablePreReleasesItem))); + } + + copyCrashLogItem.isEnabled=crashLogFile.exists(); + onDataLoaded(items); updateMediaCacheItem(); } + @Override + protected void onHidden(){ + super.onHidden(); + GlobalUserPreferences.enablePreReleases=enablePreReleasesItem!=null && enablePreReleasesItem.checked; + GlobalUserPreferences.save(); + if(timelineCacheCleared) getActivity().recreate(); + } + @Override protected void doLoadData(int offset, int count){} @@ -55,12 +105,11 @@ 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.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){ @@ -85,10 +134,40 @@ public class SettingsAboutAppFragment extends BaseSettingsFragment{ }); } + private void onClearTimelineCacheClick(ListItem item){ + session.getCacheController().putHomeTimeline(List.of(), true); + Toast.makeText(getContext(), R.string.sk_timeline_cache_cleared, Toast.LENGTH_SHORT).show(); + 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 updateMediaCacheItem(){ long size=ImageCache.getInstance(getActivity()).getDiskCache().size(); mediaCacheItem.subtitle=UiUtils.formatFileSize(getActivity(), size, false); mediaCacheItem.isEnabled=size>0; rebindItem(mediaCacheItem); } + + @Override + public String getAccountID(){ + return accountID; + } + + private void onCopyCrashLog(ListItem item){ + if(!crashLogFile.exists()) return; + try(InputStream is=new FileInputStream(crashLogFile)){ + BufferedReader reader=new BufferedReader(new InputStreamReader(is)); + StringBuilder sb=new StringBuilder(); + String line; + while ((line=reader.readLine())!=null) sb.append(line).append("\n"); + UiUtils.copyText(list, sb.toString()); + } catch(IOException e){ + Log.e(TAG, "Error reading crash log", e); + } + } } 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 0852f6e2f..c934ffa24 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 @@ -2,59 +2,115 @@ package org.joinmastodon.android.fragments.settings; import android.os.Bundle; +import androidx.annotation.StringRes; + import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.R; +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.HasAccountID; import org.joinmastodon.android.model.Preferences; 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.viewcontrollers.ComposeLanguageAlertViewController; +import org.joinmastodon.android.utils.MastodonLanguage; +import java.util.ArrayList; import java.util.List; -import java.util.Locale; +import java.util.stream.IntStream; +import java.util.stream.Stream; -public class SettingsBehaviorFragment extends BaseSettingsFragment{ +public class SettingsBehaviorFragment extends BaseSettingsFragment implements HasAccountID{ private ListItem languageItem; private CheckableListItem altTextItem, playGifsItem, customTabsItem, confirmUnfollowItem, confirmBoostItem, confirmDeleteItem; - private Locale postLanguage; + 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; + + // MOSHIDON + private CheckableListItem mentionRebloggerAutomaticallyItem, hapticFeedbackItem, showPostsWithoutAltItem; + @Override public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); setTitle(R.string.settings_behavior); AccountSession s=AccountSessionManager.get(accountID); - if(s.preferences!=null && s.preferences.postingDefaultLanguage!=null){ - postLanguage=Locale.forLanguageTag(s.preferences.postingDefaultLanguage); - } + AccountLocalPreferences lp=getLocalPrefs(); + languageResolver = s.getInstance().map(MastodonLanguage.LanguageResolver::new).orElse(null); + postLanguage=s.preferences==null || s.preferences.postingDefaultLanguage==null ? null : + languageResolver.from(s.preferences.postingDefaultLanguage).orElse(null); - onDataLoaded(List.of( - languageItem=new ListItem<>(getString(R.string.default_post_language), postLanguage!=null ? postLanguage.getDisplayName(Locale.getDefault()) : null, R.drawable.ic_language_24px, this::onDefaultLanguageClick), - altTextItem=new CheckableListItem<>(R.string.settings_alt_text_reminders, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.altTextReminders, R.drawable.ic_alt_24px, this::toggleCheckableItem), - playGifsItem=new CheckableListItem<>(R.string.settings_gif, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.playGifs, R.drawable.ic_animation_24px, this::toggleCheckableItem), - customTabsItem=new CheckableListItem<>(R.string.settings_custom_tabs, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.useCustomTabs, R.drawable.ic_open_in_browser_24px, this::toggleCheckableItem), - confirmUnfollowItem=new CheckableListItem<>(R.string.settings_confirm_unfollow, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.confirmUnfollow, R.drawable.ic_person_remove_24px, this::toggleCheckableItem), - confirmBoostItem=new CheckableListItem<>(R.string.settings_confirm_boost, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.confirmBoost, R.drawable.ic_repeat_24px, this::toggleCheckableItem), - confirmDeleteItem=new CheckableListItem<>(R.string.settings_confirm_delete_post, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.confirmDeletePost, R.drawable.ic_delete_24px, this::toggleCheckableItem) + List> items = new ArrayList<>(List.of( + languageItem=new ListItem<>(getString(R.string.default_post_language), postLanguage!=null ? postLanguage.getDisplayName(getContext()) : null, R.drawable.ic_fluent_local_language_24_regular, this::onDefaultLanguageClick), + 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)), + 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)), + 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)), + 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), 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)) )); + + if(isInstanceAkkoma()) items.add( + replyVisibilityItem=new ListItem<>(R.string.sk_settings_reply_visibility, getReplyVisibilityString(), R.drawable.ic_fluent_chat_24_regular, this::onReplyVisibilityClick) + ); + + loadNewPostsItem.checkedChangeListener=checked->onLoadNewPostsClick(); + seeNewPostsBtnItem.isEnabled=loadNewPostsItem.checked; + + onDataLoaded(items); + } + + private @StringRes int getPrefixWithRepliesString(){ + return switch(GlobalUserPreferences.prefixReplies){ + case NEVER -> R.string.sk_settings_prefix_replies_never; + case ALWAYS -> R.string.sk_settings_prefix_replies_always; + case TO_OTHERS -> R.string.sk_settings_prefix_replies_to_others; + }; + } + + private @StringRes int getReplyVisibilityString(){ + AccountLocalPreferences lp=getLocalPrefs(); + if (lp.timelineReplyVisibility==null) return R.string.sk_settings_reply_visibility_all; + return switch(lp.timelineReplyVisibility){ + case "following" -> R.string.sk_settings_reply_visibility_following; + case "self" -> R.string.sk_settings_reply_visibility_self; + default -> R.string.sk_settings_reply_visibility_all; + }; } @Override protected void doLoadData(int offset, int count){} private void onDefaultLanguageClick(ListItem item){ - ComposeLanguageAlertViewController vc=new ComposeLanguageAlertViewController(getActivity(), null, new ComposeLanguageAlertViewController.SelectedOption(-1, postLanguage), null); + if (languageResolver == null) return; + ComposeLanguageAlertViewController vc=new ComposeLanguageAlertViewController(getActivity(), null, new ComposeLanguageAlertViewController.SelectedOption(postLanguage), null, languageResolver); new M3AlertDialogBuilder(getActivity()) .setTitle(R.string.default_post_language) .setView(vc.getView()) .setPositiveButton(R.string.ok, (dlg, which)->{ ComposeLanguageAlertViewController.SelectedOption opt=vc.getSelectedOption(); - if(!opt.locale.equals(postLanguage)){ + if(!opt.language.equals(postLanguage)){ newPostLanguage=opt; - languageItem.subtitle=newPostLanguage.locale.getDisplayLanguage(Locale.getDefault()); + postLanguage=newPostLanguage.language; + languageItem.subtitle=newPostLanguage.language.getDefaultName(); rebindItem(languageItem); } }) @@ -62,22 +118,90 @@ public class SettingsBehaviorFragment extends BaseSettingsFragment{ .show(); } + private void onPrefixRepliesClick(ListItem item){ + int selected=GlobalUserPreferences.prefixReplies.ordinal(); + int[] newSelected={selected}; + new M3AlertDialogBuilder(getActivity()) + .setTitle(R.string.sk_settings_prefix_reply_cw_with_re) + .setSingleChoiceItems((String[]) IntStream.of(R.string.sk_settings_prefix_replies_never, R.string.sk_settings_prefix_replies_always, R.string.sk_settings_prefix_replies_to_others).mapToObj(this::getString).toArray(String[]::new), + selected, (dlg, which)->newSelected[0]=which) + .setPositiveButton(R.string.ok, (dlg, which)->{ + GlobalUserPreferences.prefixReplies=GlobalUserPreferences.PrefixRepliesMode.values()[newSelected[0]]; + prefixRepliesItem.subtitleRes=getPrefixWithRepliesString(); + rebindItem(prefixRepliesItem); + }) + .setNegativeButton(R.string.cancel, null) + .show(); + } + + private void onReplyVisibilityClick(ListItem item){ + AccountLocalPreferences lp=getLocalPrefs(); + int selected=lp.timelineReplyVisibility==null ? 2 : switch(lp.timelineReplyVisibility){ + case "following" -> 0; + case "self" -> 1; + default -> 2; + }; + int[] newSelected={selected}; + new M3AlertDialogBuilder(getActivity()) + .setTitle(R.string.sk_settings_prefix_reply_cw_with_re) + .setSingleChoiceItems((String[]) IntStream.of(R.string.sk_settings_reply_visibility_following, R.string.sk_settings_reply_visibility_self, R.string.sk_settings_reply_visibility_all).mapToObj(this::getString).toArray(String[]::new), + selected, (dlg, which)->newSelected[0]=which) + .setPositiveButton(R.string.ok, (dlg, which)->{ + lp.timelineReplyVisibility=switch(newSelected[0]){ + case 0 -> "following"; + case 1 -> "self"; + default -> null; + }; + replyVisibilityItem.subtitleRes=getReplyVisibilityString(); + rebindItem(replyVisibilityItem); + }) + .setNegativeButton(R.string.cancel, null) + .show(); + } + + private void onLoadNewPostsClick(){ + toggleCheckableItem(loadNewPostsItem); + seeNewPostsBtnItem.checked=loadNewPostsItem.checked; + seeNewPostsBtnItem.isEnabled=loadNewPostsItem.checked; + rebindItem(seeNewPostsBtnItem); + } + @Override protected void onHidden(){ super.onHidden(); GlobalUserPreferences.playGifs=playGifsItem.checked; + GlobalUserPreferences.overlayMedia=overlayMediaItem.checked; GlobalUserPreferences.useCustomTabs=customTabsItem.checked; GlobalUserPreferences.altTextReminders=altTextItem.checked; - GlobalUserPreferences.confirmUnfollow=customTabsItem.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 + || lp.showReplies!=showRepliesItem.checked; + lp.showBoosts=showBoostsItem.checked; + lp.showReplies=showRepliesItem.checked; + lp.save(); if(newPostLanguage!=null){ AccountSession s=AccountSessionManager.get(accountID); if(s.preferences==null) s.preferences=new Preferences(); - s.preferences.postingDefaultLanguage=newPostLanguage.locale.toLanguageTag(); + s.preferences.postingDefaultLanguage=newPostLanguage.language.getLanguage(); s.savePreferencesLater(); } + if(restartPlease) getActivity().recreate(); + } + + @Override + public String getAccountID(){ + return 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 f50c91886..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 @@ -1,27 +1,37 @@ package org.joinmastodon.android.fragments.settings; import android.app.Activity; +import android.app.AlertDialog; 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; import android.widget.ImageView; +import androidx.annotation.StringRes; + 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.session.AccountLocalPreferences; +import org.joinmastodon.android.api.session.AccountLocalPreferences.ColorPreference; import org.joinmastodon.android.api.session.AccountSession; import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.events.StatusDisplaySettingsChangedEvent; 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.views.TextInputFrameLayout; +import java.util.Arrays; import java.util.List; +import java.util.function.Consumer; +import java.util.stream.Collectors; import java.util.stream.IntStream; import me.grishka.appkit.FragmentStackActivity; @@ -29,21 +39,57 @@ import me.grishka.appkit.FragmentStackActivity; public class SettingsDisplayFragment extends BaseSettingsFragment{ private ImageView themeTransitionWindowView; private ListItem themeItem; - private CheckableListItem showCWsItem, hideSensitiveMediaItem, interactionCountsItem, emojiInNamesItem; + private CheckableListItem revealCWsItem, hideSensitiveMediaItem, interactionCountsItem, emojiInNamesItem; + + // MEGALODON + private CheckableListItem trueBlackModeItem, marqueeItem, disableSwipeItem, reduceMotionItem, altIndicatorItem, noAltIndicatorItem, collapsePostsItem, spectatorModeItem, hideFabItem, translateOpenedItem, disablePillItem, showNavigationLabelsItem, likeIconItem, underlinedLinksItem; + private ListItem colorItem, publishTextItem, autoRevealCWsItem; + private CheckableListItem pronounsInUserListingsItem, pronounsInTimelinesItem, pronounsInThreadsItem; + + // MOSHIDON + private CheckableListItem enableDoubleTapToSwipeItem, relocatePublishButtonItem, showPostDividersItem, enableDoubleTapToSearchItem, showMediaPreviewItem; + + private AccountLocalPreferences lp; @Override public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); setTitle(R.string.settings_display); AccountSession s=AccountSessionManager.get(accountID); - AccountLocalPreferences lp=s.getLocalPreferences(); + lp=s.getLocalPreferences(); onDataLoaded(List.of( - themeItem=new ListItem<>(R.string.settings_theme, getAppearanceValue(), R.drawable.ic_dark_mode_24px, this::onAppearanceClick), - showCWsItem=new CheckableListItem<>(R.string.settings_show_cws, 0, CheckableListItem.Style.SWITCH, lp.showCWs, R.drawable.ic_warning_24px, this::toggleCheckableItem), - hideSensitiveMediaItem=new CheckableListItem<>(R.string.settings_hide_sensitive_media, 0, CheckableListItem.Style.SWITCH, lp.hideSensitiveMedia, R.drawable.ic_no_adult_content_24px, this::toggleCheckableItem), - interactionCountsItem=new CheckableListItem<>(R.string.settings_show_interaction_counts, 0, CheckableListItem.Style.SWITCH, lp.showInteractionCounts, R.drawable.ic_social_leaderboard_24px, this::toggleCheckableItem), - emojiInNamesItem=new CheckableListItem<>(R.string.settings_show_emoji_in_names, 0, CheckableListItem.Style.SWITCH, lp.customEmojiInNames, R.drawable.ic_emoticon_24px, this::toggleCheckableItem) + 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, 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)), + 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, 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)), + spectatorModeItem=new CheckableListItem<>(R.string.sk_settings_hide_interaction, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.spectatorMode, R.drawable.ic_fluent_star_off_24_regular, i->toggleCheckableItem(spectatorModeItem)), + hideFabItem=new CheckableListItem<>(R.string.sk_settings_hide_fab, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.autoHideFab, R.drawable.ic_fluent_edit_24_regular, i->toggleCheckableItem(hideFabItem)), + 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)), + pronounsInThreadsItem=new CheckableListItem<>(R.string.sk_settings_display_pronouns_in_threads, 0, CheckableListItem.Style.CHECKBOX, GlobalUserPreferences.displayPronounsInThreads, 0, i->toggleCheckableItem(pronounsInThreadsItem)), + pronounsInUserListingsItem=new CheckableListItem<>(R.string.sk_settings_display_pronouns_in_user_listings, 0, CheckableListItem.Style.CHECKBOX, GlobalUserPreferences.displayPronounsInUserListings, 0, i->toggleCheckableItem(pronounsInUserListingsItem)) )); + trueBlackModeItem.checkedChangeListener=checked->onTrueBlackModeClick(); } @Override @@ -62,17 +108,45 @@ public class SettingsDisplayFragment extends BaseSettingsFragment{ @Override protected void onHidden(){ super.onHidden(); - AccountSession s=AccountSessionManager.get(accountID); - AccountLocalPreferences lp=s.getLocalPreferences(); - lp.showCWs=showCWsItem.checked; + + 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; lp.hideSensitiveMedia=hideSensitiveMediaItem.checked; lp.showInteractionCounts=interactionCountsItem.checked; lp.customEmojiInNames=emojiInNamesItem.checked; lp.save(); - E.post(new StatusDisplaySettingsChangedEvent(accountID)); + 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; + GlobalUserPreferences.spectatorMode=spectatorModeItem.checked; + GlobalUserPreferences.autoHideFab=hideFabItem.checked; + 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)); } - private int getAppearanceValue(){ + private @StringRes int getAppearanceValue(){ return switch(GlobalUserPreferences.theme){ case AUTO -> R.string.theme_auto; case LIGHT -> R.string.theme_light; @@ -80,6 +154,34 @@ public class SettingsDisplayFragment extends BaseSettingsFragment{ }; } + private String getColorPaletteValue(){ + ColorPreference color=AccountSessionManager.get(accountID).getLocalPreferences().color; + return color==null + ? getString(R.string.sk_settings_color_palette_default, getString(GlobalUserPreferences.color.getName())) + : getString(color.getName()); + } + + private String getPublishButtonText() { + return TextUtils.isEmpty(AccountSessionManager.get(accountID).getLocalPreferences().publishButtonText) + ? getContext().getString(R.string.publish) + : AccountSessionManager.get(accountID).getLocalPreferences().publishButtonText; + } + + private @StringRes int getAutoRevealSpoilersText() { + return switch(GlobalUserPreferences.autoRevealEqualSpoilers){ + case THREADS -> R.string.sk_settings_auto_reveal_author; + case DISCUSSIONS -> R.string.sk_settings_auto_reveal_anyone; + default -> R.string.sk_settings_auto_reveal_nobody; + }; + } + + private void onTrueBlackModeClick(){ + toggleCheckableItem(trueBlackModeItem); + boolean prev=GlobalUserPreferences.trueBlackTheme; + GlobalUserPreferences.trueBlackTheme=trueBlackModeItem.checked; + maybeApplyNewThemeRightNow(null, null, prev); + } + private void onAppearanceClick(ListItem item_){ int selected=switch(GlobalUserPreferences.theme){ case LIGHT -> 0; @@ -104,19 +206,100 @@ public class SettingsDisplayFragment extends BaseSettingsFragment{ GlobalUserPreferences.save(); themeItem.subtitleRes=getAppearanceValue(); rebindItem(themeItem); - maybeApplyNewThemeRightNow(prev); + maybeApplyNewThemeRightNow(prev, null, null); } }) .setNegativeButton(R.string.cancel, null) .show(); } - private void maybeApplyNewThemeRightNow(GlobalUserPreferences.ThemePreference prev){ - boolean isCurrentDark=prev==GlobalUserPreferences.ThemePreference.DARK || - (prev==GlobalUserPreferences.ThemePreference.AUTO && Build.VERSION.SDK_INT>=30 && getResources().getConfiguration().isNightModeActive()); + private void onColorClick(ListItem item_){ + boolean multiple=AccountSessionManager.getInstance().getLoggedInAccounts().size() > 1; + int indexOffset=multiple ? 1 : 0; + int selected=lp.color==null ? 0 : lp.color.ordinal() + indexOffset; + int[] newSelected={selected}; + List items=Arrays.stream(ColorPreference.values()).map(ColorPreference::getName).map(this::getString).collect(Collectors.toList()); + if(multiple) + items.add(0, getString(R.string.sk_settings_color_palette_default, items.get(GlobalUserPreferences.color.ordinal()))); + + Consumer save=(asDefault)->{ + boolean defaultSelected=multiple && newSelected[0]==0; + ColorPreference pref=defaultSelected ? null : ColorPreference.values()[newSelected[0]-indexOffset]; + if(pref!=lp.color){ + ColorPreference prev=lp.color; + lp.color=asDefault ? null : pref; + lp.save(); + if((asDefault || !multiple) && pref!=null){ + GlobalUserPreferences.color=pref; + GlobalUserPreferences.save(); + } + colorItem.subtitle=getColorPaletteValue(); + rebindItem(colorItem); + if(prev==null && pref!=null) restartActivityToApplyNewTheme(); + else maybeApplyNewThemeRightNow(null, prev, null); + } + }; + + AlertDialog.Builder alert=new M3AlertDialogBuilder(getActivity()) + .setTitle(R.string.sk_settings_color_palette) + .setSingleChoiceItems(items.stream().toArray(String[]::new), + selected, (dlg, item)->newSelected[0]=item) + .setPositiveButton(R.string.ok, (dlg, item)->save.accept(false)) + .setNegativeButton(R.string.cancel, null); + if(multiple) alert.setNeutralButton(R.string.sk_set_as_default, (dlg, item)->save.accept(true)); + alert.show(); + } + + private void onPublishTextClick(ListItem item_){ + TextInputFrameLayout input = new TextInputFrameLayout( + getContext(), + getString(R.string.publish), + TextUtils.isEmpty(lp.publishButtonText) ? "" : lp.publishButtonText.trim() + ); + new M3AlertDialogBuilder(getContext()).setTitle(R.string.sk_settings_publish_button_text_title).setView(input) + .setPositiveButton(R.string.save, (d, which) -> { + lp.publishButtonText=input.getEditText().getText().toString().trim(); + lp.save(); + publishTextItem.subtitle=getPublishButtonText(); + rebindItem(publishTextItem); + }) + .setNeutralButton(R.string.clear, (d, which) -> { + lp.publishButtonText=null; + lp.save(); + publishTextItem.subtitle=getPublishButtonText(); + rebindItem(publishTextItem); + }) + .setNegativeButton(R.string.cancel, (d, which) -> {}) + .show(); + } + + private void onAutoRevealSpoilersClick(ListItem item_){ + int selected=GlobalUserPreferences.autoRevealEqualSpoilers.ordinal(); + int[] newSelected={selected}; + new M3AlertDialogBuilder(getActivity()) + .setTitle(R.string.sk_settings_auto_reveal_equal_spoilers) + .setSingleChoiceItems((String[])IntStream.of(R.string.sk_settings_auto_reveal_nobody, R.string.sk_settings_auto_reveal_author, R.string.sk_settings_auto_reveal_anyone).mapToObj(this::getString).toArray(String[]::new), + selected, (dlg, item)->newSelected[0]=item) + .setPositiveButton(R.string.ok, (dlg, item)->{ + GlobalUserPreferences.autoRevealEqualSpoilers=GlobalUserPreferences.AutoRevealMode.values()[newSelected[0]]; + autoRevealCWsItem.subtitleRes=getAutoRevealSpoilersText(); + rebindItem(autoRevealCWsItem); + }) + .setNegativeButton(R.string.cancel, null) + .show(); + } + + private void maybeApplyNewThemeRightNow(GlobalUserPreferences.ThemePreference prevTheme, ColorPreference prevColor, Boolean prevTrueBlack){ + if(prevTheme==null) prevTheme=GlobalUserPreferences.theme; + if(prevTrueBlack==null) prevTrueBlack=GlobalUserPreferences.trueBlackTheme; + if(prevColor==null) prevColor=lp.getCurrentColor(); + + boolean isCurrentDark=prevTheme==GlobalUserPreferences.ThemePreference.DARK || + (prevTheme==GlobalUserPreferences.ThemePreference.AUTO && Build.VERSION.SDK_INT>=30 && getResources().getConfiguration().isNightModeActive()); boolean isNewDark=GlobalUserPreferences.theme==GlobalUserPreferences.ThemePreference.DARK || (GlobalUserPreferences.theme==GlobalUserPreferences.ThemePreference.AUTO && Build.VERSION.SDK_INT>=30 && getResources().getConfiguration().isNightModeActive()); - if(isCurrentDark!=isNewDark){ + boolean isNewBlack=GlobalUserPreferences.trueBlackTheme; + if(isCurrentDark!=isNewDark || prevColor!=lp.getCurrentColor() || (isNewDark && prevTrueBlack!=isNewBlack)){ restartActivityToApplyNewTheme(); } } 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 be9ba8a6a..b2a40c953 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 @@ -55,7 +55,7 @@ public class SettingsFiltersFragment extends BaseSettingsFragment{ MergeRecyclerAdapter adapter=new MergeRecyclerAdapter(); adapter.addAdapter(super.getAdapter()); adapter.addAdapter(new GenericListItemsAdapter<>(Collections.singletonList( - new ListItem(R.string.settings_add_filter, 0, R.drawable.ic_add_24px, this::onAddFilterClick) + new ListItem(R.string.settings_add_filter, 0, R.drawable.ic_fluent_add_24_regular, this::onAddFilterClick) ))); return adapter; } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsInstanceFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsInstanceFragment.java new file mode 100644 index 000000000..6dd90718a --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsInstanceFragment.java @@ -0,0 +1,157 @@ +package org.joinmastodon.android.fragments.settings; + +import android.os.Bundle; + +import androidx.annotation.StringRes; + +import org.joinmastodon.android.E; +import org.joinmastodon.android.R; +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.StatusDisplaySettingsChangedEvent; +import org.joinmastodon.android.fragments.HasAccountID; +import org.joinmastodon.android.model.ContentType; +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.utils.UiUtils; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import me.grishka.appkit.Nav; + +public class SettingsInstanceFragment extends BaseSettingsFragment implements HasAccountID{ + private CheckableListItem contentTypesItem, emojiReactionsItem, localOnlyItem, glitchModeItem; + private ListItem defaultContentTypeItem, showEmojiReactionsItem; + private AccountLocalPreferences lp; + + @Override + public void onCreate(Bundle savedInstanceState){ + super.onCreate(savedInstanceState); + setTitle(R.string.sk_settings_instance); + AccountSession s=AccountSessionManager.get(accountID); + lp=s.getLocalPreferences(); + onDataLoaded(List.of( + new ListItem<>(AccountSessionManager.get(accountID).domain, getString(R.string.settings_server_explanation), R.drawable.ic_fluent_server_24_regular, this::onServerClick), + new ListItem<>(R.string.sk_settings_profile, 0, R.drawable.ic_fluent_open_24_regular, i->UiUtils.launchWebBrowser(getActivity(), "https://"+s.domain+"/settings/profile")), + new ListItem<>(R.string.sk_settings_posting, 0, R.drawable.ic_fluent_open_24_regular, i->UiUtils.launchWebBrowser(getActivity(), "https://"+s.domain+"/settings/preferences/other")), + new ListItem<>(R.string.sk_settings_auth, 0, R.drawable.ic_fluent_open_24_regular, i->UiUtils.launchWebBrowser(getActivity(), "https://"+s.domain+"/auth/edit"), 0, true), + contentTypesItem=new CheckableListItem<>(R.string.sk_settings_content_types, R.string.sk_settings_content_types_explanation, CheckableListItem.Style.SWITCH, lp.contentTypesEnabled, R.drawable.ic_fluent_text_edit_style_24_regular, i->onContentTypeClick()), + defaultContentTypeItem=new ListItem<>(R.string.sk_settings_default_content_type, lp.defaultContentType.getName(), R.drawable.ic_fluent_text_bold_24_regular, this::onDefaultContentTypeClick, 0, true), + emojiReactionsItem=new CheckableListItem<>(R.string.sk_settings_emoji_reactions, R.string.sk_settings_emoji_reactions_explanation, CheckableListItem.Style.SWITCH, lp.emojiReactionsEnabled, R.drawable.ic_fluent_emoji_laugh_24_regular, i->onEmojiReactionsClick()), + showEmojiReactionsItem=new ListItem<>(R.string.sk_settings_show_emoji_reactions, getShowEmojiReactionsString(), R.drawable.ic_fluent_emoji_24_regular, this::onShowEmojiReactionsClick, 0, true), + localOnlyItem=new CheckableListItem<>(R.string.sk_settings_support_local_only, R.string.sk_settings_local_only_explanation, CheckableListItem.Style.SWITCH, lp.localOnlySupported, R.drawable.ic_fluent_eye_24_regular, i->onLocalOnlyClick()), + glitchModeItem=new CheckableListItem<>(R.string.sk_settings_glitch_instance, R.string.sk_settings_glitch_mode_explanation, CheckableListItem.Style.SWITCH, lp.glitchInstance, R.drawable.ic_fluent_eye_24_filled, i->toggleCheckableItem(glitchModeItem)) + )); + contentTypesItem.checkedChangeListener=checked->onContentTypeClick(); + defaultContentTypeItem.isEnabled=contentTypesItem.checked; + emojiReactionsItem.checkedChangeListener=checked->onEmojiReactionsClick(); + showEmojiReactionsItem.isEnabled=emojiReactionsItem.checked; + localOnlyItem.checkedChangeListener=checked->onLocalOnlyClick(); + glitchModeItem.isEnabled=localOnlyItem.checked; + } + + @Override + protected void doLoadData(int offset, int count){} + + @Override + protected void onHidden(){ + super.onHidden(); + lp.contentTypesEnabled=contentTypesItem.checked; + lp.emojiReactionsEnabled=emojiReactionsItem.checked; + lp.localOnlySupported=localOnlyItem.checked; + lp.glitchInstance=glitchModeItem.checked; + lp.save(); + E.post(new StatusDisplaySettingsChangedEvent(accountID)); + } + + private void onServerClick(ListItem item){ + Bundle args=new Bundle(); + args.putString("account", accountID); + Nav.go(getActivity(), SettingsServerFragment.class, args); + } + + private void onContentTypeClick(){ + toggleCheckableItem(contentTypesItem); + defaultContentTypeItem.isEnabled=contentTypesItem.checked; + resetDefaultContentType(); + rebindItem(defaultContentTypeItem); + } + + private void resetDefaultContentType(){ + lp.defaultContentType=defaultContentTypeItem.isEnabled + ? ContentType.PLAIN : ContentType.UNSPECIFIED; + defaultContentTypeItem.subtitleRes=lp.defaultContentType.getName(); + } + + private void onDefaultContentTypeClick(ListItem item_){ + List supportedContentTypes=Arrays.stream(ContentType.values()) + .filter(t->t.supportedByInstance(getInstance().orElse(null))) + .collect(Collectors.toList()); + int selected=supportedContentTypes.indexOf(lp.defaultContentType); + int[] newSelected={selected}; + String[] names=supportedContentTypes.stream() + .map(ContentType::getName) + .map(this::getString) + .toArray(String[]::new); + + new M3AlertDialogBuilder(getActivity()) + .setTitle(R.string.sk_settings_default_content_type) + .setSingleChoiceItems(names, + selected, (dlg, item)->newSelected[0]=item) + .setPositiveButton(R.string.ok, (dlg, item)->{ + ContentType type=supportedContentTypes.get(newSelected[0]); + lp.defaultContentType=type; + defaultContentTypeItem.subtitleRes=type.getName(); + rebindItem(defaultContentTypeItem); + }) + .setNegativeButton(R.string.cancel, null) + .show(); + } + + private void onShowEmojiReactionsClick(ListItem item_){ + int selected=lp.showEmojiReactions.ordinal(); + int[] newSelected={selected}; + new M3AlertDialogBuilder(getActivity()) + .setTitle(R.string.sk_settings_show_emoji_reactions) + .setSingleChoiceItems((String[]) IntStream.of(R.string.sk_settings_show_emoji_reactions_hide_empty, R.string.sk_settings_show_emoji_reactions_only_opened, R.string.sk_settings_show_emoji_reactions_always).mapToObj(this::getString).toArray(String[]::new), + selected, (dlg, item)->newSelected[0]=item) + .setPositiveButton(R.string.ok, (dlg, item)->{ + lp.showEmojiReactions=AccountLocalPreferences.ShowEmojiReactions.values()[newSelected[0]]; + showEmojiReactionsItem.subtitleRes=getShowEmojiReactionsString(); + rebindItem(showEmojiReactionsItem); + }) + .setNegativeButton(R.string.cancel, null) + .show(); + } + + private @StringRes int getShowEmojiReactionsString(){ + return switch(lp.showEmojiReactions){ + case HIDE_EMPTY -> R.string.sk_settings_show_emoji_reactions_hide_empty; + case ONLY_OPENED -> R.string.sk_settings_show_emoji_reactions_only_opened; + case ALWAYS -> R.string.sk_settings_show_emoji_reactions_always; + }; + } + + private void onEmojiReactionsClick(){ + toggleCheckableItem(emojiReactionsItem); + showEmojiReactionsItem.isEnabled=emojiReactionsItem.checked; + rebindItem(showEmojiReactionsItem); + } + + private void onLocalOnlyClick(){ + toggleCheckableItem(localOnlyItem); + glitchModeItem.checked=localOnlyItem.checked && !isInstanceAkkoma(); + glitchModeItem.isEnabled=localOnlyItem.checked; + rebindItem(glitchModeItem); + } + + @Override + public String getAccountID(){ + return accountID; + } +} 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 bb5a806d7..c126c3986 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,5 +1,6 @@ package org.joinmastodon.android.fragments.settings; +import android.content.Intent; import android.os.Bundle; import android.view.View; import android.widget.Button; @@ -15,8 +16,9 @@ import org.joinmastodon.android.R; import org.joinmastodon.android.api.session.AccountSession; 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.sheets.AccountSwitcherSheet; +import org.joinmastodon.android.ui.AccountSwitcherSheet; import org.joinmastodon.android.ui.M3AlertDialogBuilder; import org.joinmastodon.android.ui.utils.HideableSingleViewRecyclerAdapter; import org.joinmastodon.android.ui.utils.UiUtils; @@ -29,6 +31,7 @@ import me.grishka.appkit.Nav; import me.grishka.appkit.utils.MergeRecyclerAdapter; public class SettingsMainFragment extends BaseSettingsFragment{ + private AccountSession account; private boolean loggedOut; private HideableSingleViewRecyclerAdapter bannerAdapter; private Button updateButton1, updateButton2; @@ -47,22 +50,27 @@ public class SettingsMainFragment extends BaseSettingsFragment{ @Override public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); + account=AccountSessionManager.get(accountID); setTitle(R.string.settings); - setSubtitle(AccountSessionManager.get(accountID).getFullUsername()); + setSubtitle(account.getFullUsername()); onDataLoaded(List.of( - new ListItem<>(R.string.settings_behavior, 0, R.drawable.ic_settings_24px, this::onBehaviorClick), - new ListItem<>(R.string.settings_display, 0, R.drawable.ic_style_24px, this::onDisplayClick), - new ListItem<>(R.string.settings_privacy, 0, R.drawable.ic_privacy_tip_24px, this::onPrivacyClick), - new ListItem<>(R.string.settings_filters, 0, R.drawable.ic_filter_alt_24px, this::onFiltersClick), - new ListItem<>(R.string.settings_notifications, 0, R.drawable.ic_notifications_24px, this::onNotificationsClick), - new ListItem<>(AccountSessionManager.get(accountID).domain, getString(R.string.settings_server_explanation), R.drawable.ic_dns_24px, this::onServerClick), - new ListItem<>(getString(R.string.about_app, getString(R.string.app_name)), null, R.drawable.ic_info_24px, this::onAboutClick, null, 0, true), - new ListItem<>(R.string.manage_accounts, 0, R.drawable.ic_switch_account_24px, this::onManageAccountsClick), - new ListItem<>(R.string.log_out, 0, R.drawable.ic_logout_24px, this::onLogOutClick, R.attr.colorM3Error, false) + new ListItem<>(R.string.settings_behavior, 0, R.drawable.ic_fluent_settings_24_regular, this::onBehaviorClick), + new ListItem<>(R.string.settings_display, 0, R.drawable.ic_fluent_color_24_regular, this::onDisplayClick), + 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.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) )); + Instance instance=AccountSessionManager.getInstance().getInstanceInfo(account.domain); + if(!instance.isAkkoma()){ + data.add(3, new ListItem<>(R.string.settings_filters, 0, R.drawable.ic_fluent_filter_24_regular, this::onFiltersClick)); + } + if(BuildConfig.DEBUG || BuildConfig.BUILD_TYPE.equals("appcenterPrivateBeta")){ - data.add(0, new ListItem<>("Debug settings", null, R.drawable.ic_settings_24px, i->Nav.go(getActivity(), SettingsDebugFragment.class, makeFragmentArgs()), null, 0, true)); + data.add(0, new ListItem<>("Debug settings", null, R.drawable.ic_fluent_wrench_screwdriver_24_regular, i->Nav.go(getActivity(), SettingsDebugFragment.class, makeFragmentArgs()), null, 0, true)); } AccountSession session=AccountSessionManager.get(accountID); @@ -84,7 +92,7 @@ public class SettingsMainFragment extends BaseSettingsFragment{ protected void onHidden(){ super.onHidden(); if(!loggedOut) - AccountSessionManager.get(accountID).savePreferencesIfPending(); + account.savePreferencesIfPending(); } @Override @@ -101,7 +109,7 @@ public class SettingsMainFragment extends BaseSettingsFragment{ updateButton2.setOnClickListener(this::onUpdateButtonClick); bannerTitle.setText(R.string.app_update_ready); - bannerIcon.setImageResource(R.drawable.ic_apk_install_24px); + bannerIcon.setImageResource(R.drawable.ic_fluent_phone_update_24_regular); MergeRecyclerAdapter adapter=new MergeRecyclerAdapter(); adapter.addAdapter(bannerAdapter); @@ -143,8 +151,8 @@ public class SettingsMainFragment extends BaseSettingsFragment{ Nav.go(getActivity(), SettingsNotificationsFragment.class, makeFragmentArgs()); } - private void onServerClick(ListItem item_){ - Nav.go(getActivity(), SettingsServerFragment.class, makeFragmentArgs()); + private void onInstanceClick(ListItem item_){ + Nav.go(getActivity(), SettingsInstanceFragment.class, makeFragmentArgs()); } private void onAboutClick(ListItem item_){ @@ -152,16 +160,17 @@ public class SettingsMainFragment extends BaseSettingsFragment{ } private void onManageAccountsClick(ListItem item){ - new AccountSwitcherSheet(getActivity(), null).setOnLoggedOutCallback(()->loggedOut=true).show(); + new AccountSwitcherSheet(getActivity(), null).show(); } 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)->AccountSessionManager.get(accountID).logOut(getActivity(), ()->{ + .setPositiveButton(R.string.log_out, (dialog, which)->account.logOut(getActivity(), ()->{ loggedOut=true; - ((MainActivity)getActivity()).restartHomeFragment(); + ((MainActivity)getActivity()).restartActivity(); })) .setNegativeButton(R.string.cancel, null) .show(); 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 deced5307..edaf6b6d6 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,5 +1,7 @@ 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; @@ -10,10 +12,13 @@ import android.provider.Settings; import android.view.View; import android.widget.Button; import android.widget.ImageView; +import android.widget.RelativeLayout; import android.widget.TextView; +import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.R; import org.joinmastodon.android.api.PushSubscriptionManager; +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.model.PushSubscription; @@ -22,14 +27,17 @@ import org.joinmastodon.android.model.viewmodel.ListItem; import org.joinmastodon.android.ui.M3AlertDialogBuilder; import org.joinmastodon.android.ui.utils.HideableSingleViewRecyclerAdapter; import org.joinmastodon.android.ui.utils.UiUtils; +import org.unifiedpush.android.connector.UnifiedPush; import java.time.Instant; +import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.stream.Stream; import androidx.recyclerview.widget.RecyclerView; import me.grishka.appkit.utils.MergeRecyclerAdapter; +import me.grishka.appkit.utils.V; public class SettingsNotificationsFragment extends BaseSettingsFragment{ private PushSubscription pushSubscription; @@ -47,26 +55,53 @@ public class SettingsNotificationsFragment extends BaseSettingsFragment{ private boolean needUpdateNotificationSettings; private boolean notificationsAllowed=true; + // MEGALODON + private boolean useUnifiedPush = false; + private CheckableListItem uniformIconItem, deleteItem, onlyLatestItem, unifiedPushItem; + private CheckableListItem postsItem, updateItem; + + // MOSHIDON + private CheckableListItem swapBookmarkWithReblogItem; + + private AccountLocalPreferences lp; + @Override public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); setTitle(R.string.settings_notifications); + lp=AccountSessionManager.get(accountID).getLocalPreferences(); getPushSubscription(); + useUnifiedPush=!getDistributor(getContext()).isEmpty(); onDataLoaded(List.of( - pauseItem=new CheckableListItem<>(getString(R.string.pause_all_notifications), getPauseItemSubtitle(), CheckableListItem.Style.SWITCH, false, R.drawable.ic_notifications_paused_24px, i->onPauseNotificationsClick(false)), - policyItem=new ListItem<>(R.string.settings_notifications_policy, 0, R.drawable.ic_group_24px, this::onNotificationsPolicyClick), + 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)), + policyItem=new ListItem<>(R.string.settings_notifications_policy, 0, R.drawable.ic_fluent_people_24_regular, this::onNotificationsPolicyClick, 0, true), - mentionsItem=new CheckableListItem<>(R.string.notification_type_mentions_and_replies, 0, CheckableListItem.Style.CHECKBOX, pushSubscription.alerts.mention, this::toggleCheckableItem), - boostsItem=new CheckableListItem<>(R.string.notification_type_reblog, 0, CheckableListItem.Style.CHECKBOX, pushSubscription.alerts.reblog, this::toggleCheckableItem), - favoritesItem=new CheckableListItem<>(R.string.notification_type_favorite, 0, CheckableListItem.Style.CHECKBOX, pushSubscription.alerts.favourite, this::toggleCheckableItem), - followersItem=new CheckableListItem<>(R.string.notification_type_follow, 0, CheckableListItem.Style.CHECKBOX, pushSubscription.alerts.follow, this::toggleCheckableItem), - pollsItem=new CheckableListItem<>(R.string.notification_type_poll, 0, CheckableListItem.Style.CHECKBOX, pushSubscription.alerts.poll, this::toggleCheckableItem) + mentionsItem=new CheckableListItem<>(R.string.notification_type_mentions_and_replies, 0, CheckableListItem.Style.CHECKBOX, pushSubscription.alerts.mention, R.drawable.ic_fluent_mention_24_regular, i->toggleCheckableItem(mentionsItem)), + boostsItem=new CheckableListItem<>(R.string.notification_type_reblog, 0, CheckableListItem.Style.CHECKBOX, pushSubscription.alerts.reblog, R.drawable.ic_fluent_arrow_repeat_all_24_regular, i->toggleCheckableItem(boostsItem)), + favoritesItem=new CheckableListItem<>(R.string.notification_type_favorite, 0, CheckableListItem.Style.CHECKBOX, pushSubscription.alerts.favourite, R.drawable.ic_fluent_star_24_regular, i->toggleCheckableItem(favoritesItem)), + followersItem=new CheckableListItem<>(R.string.notification_type_follow, 0, CheckableListItem.Style.CHECKBOX, pushSubscription.alerts.follow, R.drawable.ic_fluent_person_add_24_regular, i->toggleCheckableItem(followersItem)), + pollsItem=new CheckableListItem<>(R.string.notification_type_poll, 0, CheckableListItem.Style.CHECKBOX, pushSubscription.alerts.poll, R.drawable.ic_fluent_poll_24_regular, i->toggleCheckableItem(pollsItem)), + 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, 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) )); - typeItems=List.of(mentionsItem, boostsItem, favoritesItem, followersItem, pollsItem); + //only enable when distributors, who can receive notifications, are available + unifiedPushItem.isEnabled=!UnifiedPush.getDistributors(getContext(), new ArrayList<>()).isEmpty(); + if (!unifiedPushItem.isEnabled) { + unifiedPushItem.subtitleRes=R.string.sk_settings_unifiedpush_no_distributor_body; + } + + typeItems=List.of(mentionsItem, boostsItem, favoritesItem, followersItem, pollsItem, updateItem, postsItem); pauseItem.checkedChangeListener=checked->onPauseNotificationsClick(true); + unifiedPushItem.checkedChangeListener=checked->onUnifiedPushClick(); updatePolicyItem(null); updatePauseItem(); } @@ -83,12 +118,20 @@ public class SettingsNotificationsFragment extends BaseSettingsFragment{ || favoritesItem.checked!=ps.alerts.favourite || followersItem.checked!=ps.alerts.follow || 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(); if(needUpdateNotificationSettings && PushSubscriptionManager.arePushNotificationsAvailable()){ ps.alerts.mention=mentionsItem.checked; ps.alerts.reblog=boostsItem.checked; ps.alerts.favourite=favoritesItem.checked; ps.alerts.follow=followersItem.checked; ps.alerts.poll=pollsItem.checked; + ps.alerts.status=postsItem.checked; + ps.alerts.update=updateItem.checked; AccountSessionManager.getInstance().getAccount(accountID).getPushSubscriptionManager().updatePushSettings(pushSubscription); } } @@ -268,13 +311,13 @@ public class SettingsNotificationsFragment extends BaseSettingsFragment{ long pauseTime=AccountSessionManager.get(accountID).getLocalPreferences().getNotificationsPauseEndTime(); if(!areNotificationsAllowed()){ bannerAdapter.setVisible(true); - bannerIcon.setImageResource(R.drawable.ic_app_badging_24px); + bannerIcon.setImageResource(R.drawable.ic_fluent_alert_badge_24_regular); bannerText.setText(R.string.notifications_disabled_in_system); bannerButton.setText(R.string.open_system_notification_settings); bannerButton.setOnClickListener(v->openSystemNotificationSettings()); }else if(pauseTime>System.currentTimeMillis()){ bannerAdapter.setVisible(true); - bannerIcon.setImageResource(R.drawable.ic_notifications_paused_24px); + bannerIcon.setImageResource(R.drawable.ic_fluent_alert_snooze_24_regular); bannerText.setText(getString(R.string.pause_notifications_banner, UiUtils.formatRelativeTimestampAsMinutesAgo(getActivity(), Instant.ofEpochMilli(pauseTime), false))); bannerButton.setText(R.string.resume_notifications_now); bannerButton.setOnClickListener(v->resumePausedNotifications()); @@ -282,4 +325,42 @@ public class SettingsNotificationsFragment extends BaseSettingsFragment{ bannerAdapter.setVisible(false); } } -} + + private void onUnifiedPushClick(){ + if(getDistributor(getContext()).isEmpty()){ + List distributors = UnifiedPush.getDistributors(getContext(), new ArrayList<>()); + showUnifiedPushRegisterDialog(distributors); + return; + } + + for (AccountSession accountSession : AccountSessionManager.getInstance().getLoggedInAccounts()) { + UnifiedPush.unregisterApp( + getContext(), + accountSession.getID() + ); + + //re-register to fcm + accountSession.getPushSubscriptionManager().registerAccountForPush(getPushSubscription()); + } + unifiedPushItem.toggle(); + rebindItem(unifiedPushItem); + } + + private void showUnifiedPushRegisterDialog(List distributors){ + new M3AlertDialogBuilder(getContext()).setTitle(R.string.sk_settings_unifiedpush_choose).setItems(distributors.toArray(String[]::new), + (dialog, which)->{ + String userDistrib = distributors.get(which); + UnifiedPush.saveDistributor(getContext(), userDistrib); + for (AccountSession accountSession : AccountSessionManager.getInstance().getLoggedInAccounts()){ + UnifiedPush.registerApp( + getContext(), + accountSession.getID(), + new ArrayList<>(), + getContext().getPackageName() + ); + } + unifiedPushItem.toggle(); + rebindItem(unifiedPushItem); + }).setOnCancelListener(d->rebindItem(unifiedPushItem)).show(); + } +} \ 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 27ce4f1d9..e4e097057 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 @@ -2,40 +2,106 @@ package org.joinmastodon.android.fragments.settings; 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; import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.model.Instance; +import org.joinmastodon.android.model.StatusPrivacy; import org.joinmastodon.android.model.viewmodel.CheckableListItem; +import org.joinmastodon.android.model.viewmodel.ListItem; +import org.joinmastodon.android.ui.M3AlertDialogBuilder; +import java.util.ArrayList; import java.util.List; public class SettingsPrivacyFragment extends BaseSettingsFragment{ - private CheckableListItem discoverableItem, indexableItem; + private CheckableListItem discoverableItem, indexableItem, lockedItem; + private ListItem privacyItem; + private StatusPrivacy privacy=null; + private Instance instance; + + //MOSHIDON + private CheckableListItem unlistedRepliesItem; + @Override public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); setTitle(R.string.settings_privacy); - Account self=AccountSessionManager.get(accountID).self; + AccountSession session=AccountSessionManager.get(accountID); + Account self=session.self; + instance=AccountSessionManager.getInstance().getInstanceInfo(session.domain); + privacy=self.source.privacy; onDataLoaded(List.of( - discoverableItem=new CheckableListItem<>(R.string.settings_discoverable, 0, CheckableListItem.Style.SWITCH, self.discoverable, R.drawable.ic_thumbs_up_down_24px, this::toggleCheckableItem), - indexableItem=new CheckableListItem<>(R.string.settings_indexable, 0, CheckableListItem.Style.SWITCH, self.source.indexable!=null ? self.source.indexable : true, R.drawable.ic_search_24px, this::toggleCheckableItem) + 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), 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)) )); - if(self.source.indexable==null) - indexableItem.isEnabled=false; + + if(!instance.isAkkoma()){ + data.addAll(List.of( + discoverableItem=new CheckableListItem<>(R.string.settings_discoverable, 0, CheckableListItem.Style.SWITCH, self.discoverable, R.drawable.ic_fluent_thumb_like_dislike_24_regular, i->toggleCheckableItem(discoverableItem)), + indexableItem=new CheckableListItem<>(R.string.settings_indexable, 0, CheckableListItem.Style.SWITCH, self.source.indexable!=null ? self.source.indexable : true, R.drawable.ic_fluent_search_24_regular, i->toggleCheckableItem(indexableItem)) + )); + if(self.source.indexable==null) + indexableItem.isEnabled=false; + } } @Override protected void doLoadData(int offset, int count){} + private @StringRes int getPrivacyString(StatusPrivacy p){ + if(p==null) return R.string.visibility_public; + return switch(p){ + case PUBLIC -> R.string.visibility_public; + case UNLISTED -> R.string.sk_visibility_unlisted; + case PRIVATE -> R.string.visibility_followers_only; + case DIRECT -> R.string.visibility_private; + case LOCAL -> R.string.sk_local_only; + }; + } + + private void onPrivacyClick(ListItem item_){ + Account self=AccountSessionManager.get(accountID).self; + List options=new ArrayList<>(List.of(StatusPrivacy.PUBLIC, StatusPrivacy.UNLISTED, StatusPrivacy.PRIVATE, StatusPrivacy.DIRECT)); + if(instance.isAkkoma()) options.add(StatusPrivacy.LOCAL); + int selected=options.indexOf(self.source.privacy); + int[] newSelected={selected}; + new M3AlertDialogBuilder(getActivity()) + .setTitle(R.string.sk_settings_default_visibility) + .setSingleChoiceItems(options.stream().map(this::getPrivacyString).map(this::getString).toArray(String[]::new), + selected, (dlg, item)->newSelected[0]=item) + .setPositiveButton(R.string.ok, (dlg, item)->{ + privacy=options.get(newSelected[0]); + privacyItem.subtitleRes=getPrivacyString(privacy); + rebindItem(privacyItem); + }) + .setNegativeButton(R.string.cancel, null) + .show(); + } + @Override public void onPause(){ super.onPause(); - Account self=AccountSessionManager.get(accountID).self; - if(self.discoverable!=discoverableItem.checked || (self.source.indexable!=null && self.source.indexable!=indexableItem.checked)){ - self.discoverable=discoverableItem.checked; - self.source.indexable=indexableItem.checked; - AccountSessionManager.get(accountID).savePreferencesLater(); + GlobalUserPreferences.defaultToUnlistedReplies=unlistedRepliesItem.checked; + GlobalUserPreferences.save(); + AccountSession s=AccountSessionManager.get(accountID); + Account self=s.self; + boolean savePlease=self.locked!=lockedItem.checked + || self.source.privacy!=privacy + || (discoverableItem!=null && self.discoverable!=discoverableItem.checked) + || (indexableItem!=null && self.source.indexable!=null && self.source.indexable!=indexableItem.checked); + if(savePlease){ + if(discoverableItem!=null) self.discoverable=discoverableItem.checked; + if(indexableItem!=null) self.source.indexable=indexableItem.checked; + self.locked=lockedItem.checked; + s.preferences.postingDefaultVisibility=privacy; + s.savePreferencesLater(); } } } 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 cd464289b..a655368e1 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 @@ -8,6 +8,7 @@ import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.text.TextUtils; +import android.util.Log; import android.view.Gravity; import android.view.LayoutInflater; import android.view.View; @@ -34,6 +35,7 @@ import org.joinmastodon.android.ui.viewholders.AccountViewHolder; import org.joinmastodon.android.ui.viewholders.SimpleListItemViewHolder; import org.joinmastodon.android.ui.views.FixedAspectRatioImageView; import org.joinmastodon.android.utils.ViewImageLoaderHolderTarget; +import org.parceler.Parcels; import java.io.BufferedReader; import java.io.IOException; @@ -42,6 +44,8 @@ import java.util.HashMap; import java.util.Locale; import java.util.Map; +import me.grishka.appkit.api.Callback; +import me.grishka.appkit.api.ErrorResponse; import me.grishka.appkit.api.SimpleCallback; import me.grishka.appkit.fragments.LoaderFragment; import me.grishka.appkit.imageloader.ViewImageLoader; @@ -60,7 +64,7 @@ public class SettingsServerAboutFragment extends LoaderFragment{ public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); accountID=getArguments().getString("account"); - instance=AccountSessionManager.getInstance().getInstanceInfo(AccountSessionManager.get(accountID).domain); + instance=Parcels.unwrap(getArguments().getParcelable("instance")); loadData(); } @@ -124,6 +128,8 @@ public class SettingsServerAboutFragment extends LoaderFragment{ hlp.leftMargin=hlp.rightMargin=V.dp(16); scrollingLayout.addView(heading, hlp); + // if a remote instance is shown, the account is remote and need to be loaded accordingly when shown + instance.contactAccount.isRemote=!AccountSessionManager.get(accountID).domain.equals(instance.normalizedUri); AccountViewModel model=new AccountViewModel(instance.contactAccount, accountID); AccountViewHolder holder=new AccountViewHolder(this, scrollingLayout, null); holder.setStyle(AccountViewHolder.AccessoryType.NONE, false); @@ -139,7 +145,7 @@ public class SettingsServerAboutFragment extends LoaderFragment{ if(!TextUtils.isEmpty(instance.email)){ needDivider=true; SimpleListItemViewHolder holder=new SimpleListItemViewHolder(getActivity(), scrollingLayout); - ListItem item=new ListItem<>(R.string.send_email_to_server_admin, 0, R.drawable.ic_mail_24px, i->{}); + ListItem item=new ListItem<>(R.string.send_email_to_server_admin, 0, R.drawable.ic_fluent_mail_24_regular, i->{}); holder.bind(item); holder.itemView.setBackground(UiUtils.getThemeDrawable(getActivity(), android.R.attr.selectableItemBackground)); holder.itemView.setOnClickListener(v->openAdminEmail()); @@ -160,7 +166,7 @@ public class SettingsServerAboutFragment extends LoaderFragment{ @Override protected void doLoadData(){ new GetInstanceExtendedDescription() - .setCallback(new SimpleCallback<>(this){ + .setCallback(new Callback<>(){ @Override public void onSuccess(GetInstanceExtendedDescription.Response result){ MastodonAPIController.runInBackground(()->{ @@ -196,8 +202,14 @@ public class SettingsServerAboutFragment extends LoaderFragment{ }); }); } + + @Override + public void onError(ErrorResponse error){ + // probably an akkoma instance where this isn't implemented + dataLoaded(); + } }) - .exec(accountID); + .execRemote(instance.normalizedUri); } @Override 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 0d27e27b0..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,29 +1,38 @@ 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; import org.joinmastodon.android.ui.tabs.TabLayoutMediator; import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.ui.views.NestedRecyclerScrollView; +import org.parceler.Parcels; 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; @@ -44,11 +53,17 @@ public class SettingsServerFragment extends AppKitFragment{ public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); accountID=getArguments().getString("account"); - setTitle(AccountSessionManager.get(accountID).domain); + instance=getArguments().containsKey("instance") + ? Parcels.unwrap(getArguments().getParcelable("instance")) + : AccountSessionManager.getOptional(accountID) + .map(i->AccountSessionManager.getInstance().getInstanceInfo(i.domain)) + .orElseThrow(); + setTitle(instance.title); Bundle args=new Bundle(); args.putString("account", accountID); args.putBoolean("__is_tab", true); + args.putParcelable("instance", Parcels.wrap(instance)); aboutFragment=new SettingsServerAboutFragment(); aboutFragment.setArguments(args); rulesFragment=new SettingsServerRulesFragment(); @@ -122,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/fragments/settings/SettingsServerRulesFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsServerRulesFragment.java index 198fca6a1..c466c9441 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsServerRulesFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsServerRulesFragment.java @@ -1,19 +1,20 @@ package org.joinmastodon.android.fragments.settings; import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.View; -import org.joinmastodon.android.R; -import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.api.requests.instance.GetInstance; import org.joinmastodon.android.fragments.MastodonRecyclerFragment; import org.joinmastodon.android.model.Instance; import org.joinmastodon.android.ui.adapters.InstanceRulesAdapter; +import org.parceler.Parcels; import androidx.recyclerview.widget.RecyclerView; +import me.grishka.appkit.api.Callback; +import me.grishka.appkit.api.ErrorResponse; public class SettingsServerRulesFragment extends MastodonRecyclerFragment{ private String accountID; + private String domain; public SettingsServerRulesFragment(){ super(20); @@ -23,24 +24,32 @@ public class SettingsServerRulesFragment extends MastodonRecyclerFragment(){ + @Override + public void onSuccess(Instance instance){ + onDataLoaded(instance.rules); + } + + @Override + public void onError(ErrorResponse error){ + error.showToast(getContext()); + } + }).execRemote(domain); + } @Override protected RecyclerView.Adapter getAdapter(){ return new InstanceRulesAdapter(data); } - @Override - protected View onCreateFooterView(LayoutInflater inflater){ - return inflater.inflate(R.layout.load_more_with_end_mark, null); - } - public RecyclerView getList(){ return list; } diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/Account.java b/mastodon/src/main/java/org/joinmastodon/android/model/Account.java index b12a1dae5..8c1574f7e 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/Account.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Account.java @@ -1,41 +1,44 @@ package org.joinmastodon.android.model; +import android.net.Uri; import android.text.TextUtils; +import androidx.annotation.Nullable; + import org.joinmastodon.android.api.ObjectValidationException; -import org.joinmastodon.android.api.RequiredField; import org.parceler.Parcel; import java.time.Instant; import java.time.LocalDate; +import java.util.Collections; import java.util.List; /** * Represents a user of Mastodon and their associated profile. */ @Parcel -public class Account extends BaseModel{ +public class Account extends BaseModel implements Searchable{ // Base attributes /** * The account id */ - @RequiredField +// @RequiredField public String id; /** * The username of the account, not including domain. */ - @RequiredField +// @RequiredField public String username; /** * The Webfinger account URI. Equal to username for local users, or username@domain for remote users. */ - @RequiredField +// @RequiredField public String acct; /** * The location of the user's profile page. */ - @RequiredField +// @RequiredField public String url; // Display attributes @@ -43,17 +46,17 @@ public class Account extends BaseModel{ /** * The profile's display name. */ - @RequiredField +// @RequiredField public String displayName; /** * The profile's bio / description. */ - @RequiredField +// @RequiredField public String note; /** * An image icon that is shown next to statuses and in the profile. */ - @RequiredField +// @RequiredField public String avatar; /** * A static version of the avatar. Equal to avatar if its value is a static image; different if avatar is an animated GIF. @@ -62,7 +65,6 @@ public class Account extends BaseModel{ /** * An image banner that is shown above the profile and in profile cards. */ - @RequiredField public String header; /** * A static version of the header. Equal to header if its value is a static image; different if header is an animated GIF. @@ -86,7 +88,7 @@ public class Account extends BaseModel{ /** * When the account was created. */ - @RequiredField +// @RequiredField public Instant createdAt; /** * When the most recent status was posted. @@ -134,6 +136,21 @@ public class Account extends BaseModel{ public Instant muteExpiresAt; public boolean noindex; + public List roles; + + public @Nullable String fqn; // akkoma has this, mastodon't + + @Override + public String getQuery() { + return url; + } + + @Parcel + public static class Role { + public String name; + /** #rrggbb */ + public String color; + } @Override public void postprocess() throws ObjectValidationException{ @@ -141,15 +158,26 @@ public class Account extends BaseModel{ if(fields!=null){ for(AccountField f:fields) f.postprocess(); + }else{ + fields=Collections.emptyList(); } if(emojis!=null){ for(Emoji e:emojis) e.postprocess(); + }else{ + emojis=Collections.emptyList(); } if(moved!=null) moved.postprocess(); + if(fqn==null) fqn=getFullyQualifiedName(); + if(id==null) id=""; + if(username==null) username=""; if(TextUtils.isEmpty(displayName)) - displayName=username; + displayName=!TextUtils.isEmpty(username) ? username : ""; + if(acct==null) acct=""; + if(url==null) url=""; + if(note==null) note=""; + if(avatar==null) avatar=""; } public boolean isLocal(){ @@ -161,10 +189,28 @@ public class Account extends BaseModel{ return parts.length==1 ? null : parts[1]; } + public String getDomainFromURL() { + return Uri.parse(url).getHost(); + } + public String getDisplayUsername(){ return '@'+acct; } + public String getShortUsername() { + return '@'+acct.split("@")[0]; + } + + public String getFullyQualifiedName() { + if (TextUtils.isEmpty(acct)) + return ""; + return fqn != null ? fqn : acct.split("@")[0] + "@" + getDomainFromURL(); + } + + public String getDisplayName(){ + return '\u2068'+displayName+'\u2069'; + } + @Override public String toString(){ return "Account{"+ diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/AkkomaTranslation.java b/mastodon/src/main/java/org/joinmastodon/android/model/AkkomaTranslation.java new file mode 100644 index 000000000..fd024a654 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/AkkomaTranslation.java @@ -0,0 +1,14 @@ +package org.joinmastodon.android.model; + +public class AkkomaTranslation extends BaseModel{ + public String text; + public String detectedLanguage; + + public Translation toTranslation() { + Translation translation=new Translation(); + translation.content=text; + translation.detectedSourceLanguage=detectedLanguage; + translation.provider="Akkoma"; + return translation; + } +} 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..4168e6505 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/AltTextFilter.java @@ -0,0 +1,19 @@ +package org.joinmastodon.android.model; + +import org.jsoup.internal.StringUtil; + +import java.util.EnumSet; + +public class AltTextFilter extends LegacyFilter { + + public AltTextFilter(FilterAction filterAction, FilterContext firstContext, FilterContext... restContexts) { + this.filterAction = filterAction; + isRemote = false; + context = EnumSet.of(firstContext, restContexts); + } + + @Override + public boolean matches(Status status) { + return status.getContentStatus().mediaAttachments.stream().map(attachment -> attachment.description).anyMatch(StringUtil::isBlank); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/Announcement.java b/mastodon/src/main/java/org/joinmastodon/android/model/Announcement.java new file mode 100644 index 000000000..9c769ed03 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Announcement.java @@ -0,0 +1,65 @@ +package org.joinmastodon.android.model; + +import org.joinmastodon.android.api.ObjectValidationException; +import org.joinmastodon.android.api.RequiredField; +import org.parceler.Parcel; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +@Parcel +public class Announcement extends BaseModel implements DisplayItemsParent { + @RequiredField + public String id; + @RequiredField + public String content; + public Instant startsAt; + public Instant endsAt; + public boolean published; + public boolean allDay; + public Instant publishedAt; + public Instant updatedAt; + public boolean read; + public List emojis; + public List reactions; + public List mentions; + public List tags; + + @Override + public String toString() { + return "Announcement{" + + "id='" + id + '\'' + + ", content='" + content + '\'' + + ", startsAt=" + startsAt + + ", endsAt=" + endsAt + + ", published=" + published + + ", allDay=" + allDay + + ", publishedAt=" + publishedAt + + ", updatedAt=" + updatedAt + + ", read=" + read + + ", emojis=" + emojis + + ", mentions=" + mentions + + ", tags=" + tags + + '}'; + } + + @Override + public void postprocess() throws ObjectValidationException{ + super.postprocess(); + if(reactions==null) reactions=new ArrayList<>(); + } + + public Status toStatus() { + Status s=Status.ofFake(id, content, publishedAt); + s.createdAt=startsAt != null ? startsAt : publishedAt; + s.reactions=reactions; + if(updatedAt != null) s.editedAt=updatedAt; + return s; + } + + @Override + public String getID() { + return id; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/Attachment.java b/mastodon/src/main/java/org/joinmastodon/android/model/Attachment.java index d3ef9d9e1..6fbcc6712 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/Attachment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Attachment.java @@ -3,6 +3,7 @@ package org.joinmastodon.android.model; import android.graphics.Bitmap; import android.graphics.PointF; import android.graphics.drawable.Drawable; +import android.media.MediaMetadataRetriever; import com.google.gson.annotations.SerializedName; @@ -16,7 +17,7 @@ import org.parceler.ParcelProperty; @Parcel public class Attachment extends BaseModel{ - @RequiredField +// @RequiredField public String id; @RequiredField public Type type; @@ -85,6 +86,13 @@ public class Attachment extends BaseModel{ return 0; } + public boolean hasSound() { + MediaMetadataRetriever retriever = new MediaMetadataRetriever(); + retriever.setDataSource(url); + String hasAudioStr = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO); + return "yes".equals(hasAudioStr); + } + @Override public void postprocess() throws ObjectValidationException{ super.postprocess(); @@ -93,6 +101,12 @@ public class Attachment extends BaseModel{ if(placeholder!=null) blurhashPlaceholder=new BlurHashDrawable(placeholder, getWidth(), getHeight()); } + + if (id == null) { + // akkoma servers doesn't provide IDs for attachments, + // but IDs are needed by the AudioPlayerService + id = "" + this.hashCode(); + } } @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/BaseModel.java b/mastodon/src/main/java/org/joinmastodon/android/model/BaseModel.java index b2ec8898f..fc67a71fc 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/BaseModel.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/BaseModel.java @@ -11,6 +11,14 @@ import androidx.annotation.CallSuper; import androidx.annotation.NonNull; public abstract class BaseModel implements Cloneable{ + + /** + * indicates the profile has been fetched from a foreign instance. + * + * @see org.joinmastodon.android.api.MastodonAPIRequest#execRemote + */ + public transient boolean isRemote; + @CallSuper public void postprocess() throws ObjectValidationException{ try{ 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 7eb0ebddf..0f197ab33 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/Card.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Card.java @@ -2,6 +2,7 @@ package org.joinmastodon.android.model; import android.graphics.Bitmap; import android.graphics.drawable.Drawable; +import android.net.Uri; import com.google.gson.annotations.SerializedName; @@ -51,6 +52,13 @@ public class Card extends BaseModel{ } } + public boolean isHashtagUrl(String statusUrl){ + Uri parsedUrl=Uri.parse(url); + Uri parsedStatusUrl=Uri.parse(statusUrl); + if(parsedUrl.getHost()==null || parsedUrl.getPath()==null || parsedStatusUrl.getHost()==null) return false; + return title.equals("Akkoma") && parsedUrl.getHost().equals(parsedStatusUrl.getHost()) && parsedUrl.getPath().startsWith("/tag/"); + } + @Override public String toString(){ return "Card{"+ diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/ContentType.java b/mastodon/src/main/java/org/joinmastodon/android/model/ContentType.java new file mode 100644 index 000000000..79957bfcb --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/ContentType.java @@ -0,0 +1,39 @@ +package org.joinmastodon.android.model; + +import android.view.Menu; + +import androidx.annotation.Nullable; + +import com.google.gson.annotations.SerializedName; + +import org.joinmastodon.android.R; + +public enum ContentType { + @SerializedName("text/plain") + PLAIN, + @SerializedName("text/html") + HTML, + @SerializedName("text/markdown") + MARKDOWN, + @SerializedName("text/bbcode") + BBCODE, // akkoma + @SerializedName("text/x.misskeymarkdown") + MISSKEY_MARKDOWN, // akkoma/*key + @SerializedName("") + UNSPECIFIED; + + public int getName() { + return switch(this) { + case PLAIN -> R.string.sk_content_type_plain; + case HTML -> R.string.sk_content_type_html; + case MARKDOWN -> R.string.sk_content_type_markdown; + case BBCODE -> R.string.sk_content_type_bbcode; + case MISSKEY_MARKDOWN -> R.string.sk_content_type_mfm; + case UNSPECIFIED -> R.string.sk_content_type_unspecified; + }; + } + + public boolean supportedByInstance(Instance i) { + return i.isAkkoma() || (this!=BBCODE && this!=MISSKEY_MARKDOWN); + } +} 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/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/Emoji.java b/mastodon/src/main/java/org/joinmastodon/android/model/Emoji.java index 88ccc19de..3ade9bec7 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/Emoji.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Emoji.java @@ -33,6 +33,20 @@ public class Emoji extends BaseModel{ */ public String category; + public Emoji() {} + + public Emoji(String shortcode, String url, String staticUrl) { + this.shortcode = shortcode.replaceAll(":", ""); + this.url = url; + this.staticUrl = staticUrl; + } + + public String getUrl(boolean playGifs){ + String idealUrl=playGifs ? url : staticUrl; + if(idealUrl==null) return url==null ? staticUrl : url; + return idealUrl; + } + @Override public String toString(){ return "Emoji{"+ diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/EmojiCategory.java b/mastodon/src/main/java/org/joinmastodon/android/model/EmojiCategory.java index 902a60eb2..95eaadf28 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/EmojiCategory.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/EmojiCategory.java @@ -1,5 +1,6 @@ package org.joinmastodon.android.model; +import java.util.ArrayList; import java.util.List; public class EmojiCategory{ @@ -10,4 +11,8 @@ public class EmojiCategory{ this.title=title; this.emojis=emojis; } + public EmojiCategory(EmojiCategory category){ + this.title = category.title; + this.emojis = new ArrayList<>(category.emojis); + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/EmojiReaction.java b/mastodon/src/main/java/org/joinmastodon/android/model/EmojiReaction.java new file mode 100644 index 000000000..6007dfdb3 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/EmojiReaction.java @@ -0,0 +1,63 @@ +package org.joinmastodon.android.model; + +import org.joinmastodon.android.GlobalUserPreferences; +import org.parceler.Parcel; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import me.grishka.appkit.imageloader.requests.ImageLoaderRequest; +import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest; +import me.grishka.appkit.utils.V; + +@Parcel +public class EmojiReaction { + public List accounts; + public List accountIds; + public int count; + public boolean me; + public String name; + public String url; + public String staticUrl; + + public transient ImageLoaderRequest request; + + public String getUrl(boolean playGifs){ + String idealUrl=playGifs ? url : staticUrl; + if(idealUrl==null) return url==null ? staticUrl : url; + return idealUrl; + } + + public static EmojiReaction of(Emoji info, Account me){ + EmojiReaction reaction=new EmojiReaction(); + reaction.me=true; + reaction.count=1; + reaction.name=info.shortcode; + reaction.url=info.url; + 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)); + return reaction; + } + + public static EmojiReaction of(String emoji, Account me){ + EmojiReaction reaction=new EmojiReaction(); + reaction.me=true; + reaction.count=1; + reaction.name=emoji; + reaction.accounts=new ArrayList<>(Collections.singleton(me)); + reaction.accountIds=new ArrayList<>(Collections.singleton(me.id)); + return reaction; + } + + public void add(Account self){ + if(accounts==null) accounts=new ArrayList<>(); + if(accountIds==null) accountIds=new ArrayList<>(); + count++; + me=true; + accounts.add(self); + accountIds.add(self.id); + } +} 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/FilterResult.java b/mastodon/src/main/java/org/joinmastodon/android/model/FilterResult.java index 59c70e6dc..e6d1599be 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/FilterResult.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/FilterResult.java @@ -1,21 +1,20 @@ package org.joinmastodon.android.model; import org.joinmastodon.android.api.ObjectValidationException; -import org.joinmastodon.android.api.RequiredField; import org.parceler.Parcel; import java.util.List; @Parcel -public class FilterResult extends BaseModel{ - @RequiredField - public Filter filter; +public class FilterResult extends BaseModel { + public LegacyFilter filter; - public List keywordMatches; + public List keywordMatches; - @Override - public void postprocess() throws ObjectValidationException{ - super.postprocess(); - filter.postprocess(); - } + @Override + public void postprocess() throws ObjectValidationException { + super.postprocess(); + if(filter!=null) filter.postprocess(); + if(keywordMatches==null) keywordMatches=List.of(); + } } 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 66f91104a..d5f923533 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/Hashtag.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Hashtag.java @@ -11,15 +11,16 @@ public class Hashtag extends BaseModel implements DisplayItemsParent{ public String name; @RequiredField public String url; + public boolean following; public List history; public int statusesCount; - public boolean following; @Override public String toString(){ return "Hashtag{"+ "name='"+name+'\''+ ", url='"+url+'\''+ + ", following="+following+ ", history="+history+ ", statusesCount="+statusesCount+ ", following="+following+ 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 b4d523420..ed91e59cf 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/Instance.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Instance.java @@ -11,6 +11,7 @@ import java.net.IDN; import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Optional; @Parcel public class Instance extends BaseModel{ @@ -27,7 +28,7 @@ public class Instance extends BaseModel{ /** * Admin-defined description of the Mastodon site. */ - @RequiredField +// @RequiredField public String description; /** * A shorter description defined by the admin. @@ -37,7 +38,7 @@ public class Instance extends BaseModel{ /** * An email that may be contacted for any inquiries. */ - @RequiredField +// @RequiredField public String email; /** * The version of Mastodon installed on the instance. @@ -82,6 +83,15 @@ public class Instance extends BaseModel{ // non-standard field in some Mastodon forks public int maxTootChars; + public V2 v2; + + public Pleroma pleroma; + + public PleromaPollLimits pollLimits; + + /** like uri, but always without scheme and trailing slash */ + public transient String normalizedUri; + @Override public void postprocess() throws ObjectValidationException{ super.postprocess(); @@ -91,6 +101,10 @@ public class Instance extends BaseModel{ rules=Collections.emptyList(); if(shortDescription==null) shortDescription=""; + // akkoma says uri is "https://example.social" while just "example.social" on mastodon + normalizedUri = uri + .replaceFirst("^https://", "") + .replaceFirst("/$", ""); } @Override @@ -130,6 +144,34 @@ public class Instance extends BaseModel{ return ci; } + public boolean isAkkoma() { + return pleroma != null; + } + + public boolean isPixelfed() { + return version.contains("compatible; Pixelfed"); + } + + public boolean hasFeature(Feature feature) { + Optional> pleromaFeatures = Optional.ofNullable(pleroma) + .map(p -> p.metadata) + .map(m -> m.features); + + return switch (feature) { + case BUBBLE_TIMELINE -> pleromaFeatures + .map(f -> f.contains("bubble_timeline")) + .orElse(false); + case MACHINE_TRANSLATION -> pleromaFeatures + .map(f -> f.contains("akkoma:machine_translation")) + .orElse(false); + }; + } + + public enum Feature { + BUBBLE_TIMELINE, + MACHINE_TRANSLATION + } + @Parcel public static class Rule{ public String id; @@ -176,4 +218,46 @@ public class Instance extends BaseModel{ public int minExpiration; public int maxExpiration; } + + @Parcel + public static class V2 extends BaseModel { + public V2.Configuration configuration; + + @Parcel + public static class Configuration { + public TranslationConfiguration translation; + } + + @Parcel + public static class TranslationConfiguration{ + public boolean enabled; + } + } + + @Parcel + public static class Pleroma extends BaseModel { + public Pleroma.Metadata metadata; + + @Parcel + public static class Metadata { + public List features; + public Pleroma.Metadata.FieldsLimits fieldsLimits; + + @Parcel + public static class FieldsLimits { + public long maxFields; + public long maxRemoteFields; + public long nameLength; + public long valueLength; + } + } + } + + @Parcel + public static class PleromaPollLimits { + public long maxExpiration; + public long maxOptionChars; + public long maxOptions; + public long minExpiration; + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/LegacyFilter.java b/mastodon/src/main/java/org/joinmastodon/android/model/LegacyFilter.java index 51980add9..aeeda7f0f 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/LegacyFilter.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/LegacyFilter.java @@ -6,24 +6,32 @@ import com.google.gson.annotations.SerializedName; import org.joinmastodon.android.api.ObjectValidationException; import org.joinmastodon.android.api.RequiredField; +import org.parceler.Parcel; import java.time.Instant; +import java.util.ArrayList; import java.util.EnumSet; import java.util.List; import java.util.regex.Pattern; +@Parcel public class LegacyFilter extends BaseModel{ - @RequiredField public String id; - @RequiredField public String phrase; + public String title; public transient EnumSet context=EnumSet.noneOf(FilterContext.class); public Instant expiresAt; public boolean irreversible; public boolean wholeWord; @SerializedName("context") - private List _context; + protected List _context; + + public FilterAction filterAction; + + public List keywords=new ArrayList<>(); + + public List statuses=new ArrayList<>(); private transient Pattern pattern; @@ -36,6 +44,10 @@ public class LegacyFilter extends BaseModel{ if(c!=null) context.add(c); } + for(FilterKeyword keyword:keywords) + keyword.postprocess(); + for(FilterStatus status:statuses) + status.postprocess(); } public boolean matches(CharSequence text){ @@ -47,6 +59,7 @@ public class LegacyFilter extends BaseModel{ else pattern=Pattern.compile(Pattern.quote(phrase), Pattern.CASE_INSENSITIVE); } + if (title == null) title = phrase; return pattern.matcher(text).find(); } @@ -62,12 +75,15 @@ public class LegacyFilter extends BaseModel{ public String toString(){ return "Filter{"+ "id='"+id+'\''+ + ", title='"+title+'\''+ ", phrase='"+phrase+'\''+ ", context="+context+ ", expiresAt="+expiresAt+ ", irreversible="+irreversible+ ", wholeWord="+wholeWord+ + ", filterAction="+filterAction+ + ", keywords="+keywords+ + ", statuses="+statuses+ '}'; } - } diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/ListTimeline.java b/mastodon/src/main/java/org/joinmastodon/android/model/ListTimeline.java new file mode 100644 index 000000000..4bd06e935 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/ListTimeline.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 ListTimeline 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/Marker.java b/mastodon/src/main/java/org/joinmastodon/android/model/Marker.java index 1bab926a7..636d91155 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/Marker.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Marker.java @@ -1,5 +1,7 @@ package org.joinmastodon.android.model; +import com.google.gson.annotations.SerializedName; + import org.joinmastodon.android.api.AllFieldsAreRequired; import java.time.Instant; @@ -18,4 +20,11 @@ public class Marker extends BaseModel{ ", updatedAt="+updatedAt+ '}'; } + + public enum Type { + @SerializedName("home") + HOME, + @SerializedName("notifications") + NOTIFICATIONS + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/Markers.java b/mastodon/src/main/java/org/joinmastodon/android/model/Markers.java new file mode 100644 index 000000000..6c07cdce1 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Markers.java @@ -0,0 +1,14 @@ +package org.joinmastodon.android.model; + +public class Markers { + public Marker notifications; + public Marker home; + + @Override + public String toString() { + return "Markers{" + + "notifications=" + notifications + + ", home=" + home + + '}'; + } +} 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 30a4ccc9e..3353eea9c 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/Notification.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Notification.java @@ -18,8 +18,10 @@ public class Notification extends BaseModel implements DisplayItemsParent{ public Instant createdAt; @RequiredField public Account account; - public Status status; + public Report report; + public String emoji; + public String emojiUrl; @Override public void postprocess() throws ObjectValidationException{ @@ -53,6 +55,23 @@ public class Notification extends BaseModel implements DisplayItemsParent{ @SerializedName("poll") POLL, @SerializedName("status") - STATUS + STATUS, + @SerializedName("update") + UPDATE, + @SerializedName("reaction") + REACTION, + @SerializedName("pleroma:emoji_reaction") + PLEROMA_EMOJI_REACTION, + @SerializedName("admin.sign_up") + SIGN_UP, + @SerializedName("admin.report") + REPORT + } + + @Parcel + public static class Report { + public String id; + public String comment; + public Account targetAccount; } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/NotificationAction.java b/mastodon/src/main/java/org/joinmastodon/android/model/NotificationAction.java new file mode 100644 index 000000000..52b936243 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/NotificationAction.java @@ -0,0 +1,10 @@ +package org.joinmastodon.android.model; + +public enum NotificationAction { + FAVORITE, + BOOST, + UNBOOST, + BOOKMARK, + REPLY, + FOLLOW_BACK +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/Poll.java b/mastodon/src/main/java/org/joinmastodon/android/model/Poll.java index 89d9c8fea..cd06a24c1 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/Poll.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Poll.java @@ -18,11 +18,11 @@ public class Poll extends BaseModel{ public int votersCount; public int votesCount; public boolean voted; - @RequiredField +// @RequiredField public List ownVotes; @RequiredField public List