Compare commits

...

377 Commits

Author SHA1 Message Date
kyori19 dbcde68479
Merge remote-tracking branch 'tuskyapp/develop'
# Conflicts:
#	.gitignore
#	README.md
#	app/build.gradle
#	app/src/green/res/mipmap-hdpi/ic_launcher.png
#	app/src/green/res/mipmap-mdpi/ic_launcher.png
#	app/src/green/res/mipmap-xhdpi/ic_launcher.png
#	app/src/green/res/mipmap-xxhdpi/ic_launcher.png
#	app/src/green/res/mipmap-xxxhdpi/ic_launcher.png
#	app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt
#	app/src/main/java/com/keylesspalace/tusky/MainActivity.kt
#	app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt
#	app/src/main/java/com/keylesspalace/tusky/TabData.kt
#	app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java
#	app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java
#	app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt
#	app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt
#	app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt
#	app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt
#	app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt
#	app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt
#	app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesActivity.kt
#	app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt
#	app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt
#	app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt
#	app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt
#	app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt
#	app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt
#	app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt
#	app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt
#	app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt
#	app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt
#	app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt
#	app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt
#	app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt
#	app/src/main/java/com/keylesspalace/tusky/entity/NewStatus.kt
#	app/src/main/java/com/keylesspalace/tusky/entity/Status.kt
#	app/src/main/java/com/keylesspalace/tusky/entity/TimelineAccount.kt
#	app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java
#	app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt
#	app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt
#	app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt
#	app/src/main/java/com/keylesspalace/tusky/util/StatusParsingHelper.kt
#	app/src/main/res/layout/activity_about.xml
#	app/src/main/res/layout/activity_main.xml
#	app/src/main/res/layout/item_status.xml
#	app/src/main/res/layout/item_status_notification.xml
#	app/src/main/res/values-ar/strings.xml
#	app/src/main/res/values-be/strings.xml
#	app/src/main/res/values-bn-rBD/strings.xml
#	app/src/main/res/values-ca/strings.xml
#	app/src/main/res/values-cy/strings.xml
#	app/src/main/res/values-de/strings.xml
#	app/src/main/res/values-es/strings.xml
#	app/src/main/res/values-eu/strings.xml
#	app/src/main/res/values-fa/strings.xml
#	app/src/main/res/values-fr/strings.xml
#	app/src/main/res/values-gd/strings.xml
#	app/src/main/res/values-gl/strings.xml
#	app/src/main/res/values-hu/strings.xml
#	app/src/main/res/values-in/strings.xml
#	app/src/main/res/values-is/strings.xml
#	app/src/main/res/values-it/strings.xml
#	app/src/main/res/values-ja/strings.xml
#	app/src/main/res/values-lv/strings.xml
#	app/src/main/res/values-nb-rNO/strings.xml
#	app/src/main/res/values-night/theme_colors.xml
#	app/src/main/res/values-oc/strings.xml
#	app/src/main/res/values-pl/strings.xml
#	app/src/main/res/values-pt-rBR/strings.xml
#	app/src/main/res/values-ru/strings.xml
#	app/src/main/res/values-sa/strings.xml
#	app/src/main/res/values-sv/strings.xml
#	app/src/main/res/values-tr/strings.xml
#	app/src/main/res/values-uk/strings.xml
#	app/src/main/res/values-vi/strings.xml
#	app/src/main/res/values-zh-rCN/strings.xml
#	app/src/main/res/values/attrs.xml
#	app/src/main/res/values/styles.xml
#	app/src/main/res/values/theme_colors.xml
#	app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt
#	app/src/test/java/com/keylesspalace/tusky/FilterV1Test.kt
#	app/src/test/java/com/keylesspalace/tusky/components/timeline/StatusMocker.kt
#	app/src/test/java/com/keylesspalace/tusky/db/TimelineDaoTest.kt
#	app/src/test/java/com/keylesspalace/tusky/usecase/TimelineCasesTest.kt
#	app/src/test/java/com/keylesspalace/tusky/util/RickRollTest.kt
#	assets/tusky_banner.xcf
#	fastlane/metadata/android/ca/changelogs/58.txt
#	fastlane/metadata/android/ca/full_description.txt
#	fastlane/metadata/android/de/changelogs/58.txt
#	fastlane/metadata/android/de/changelogs/61.txt
#	fastlane/metadata/android/de/changelogs/67.txt
#	fastlane/metadata/android/de/changelogs/68.txt
#	fastlane/metadata/android/de/changelogs/70.txt
#	fastlane/metadata/android/de/changelogs/72.txt
#	fastlane/metadata/android/de/changelogs/74.txt
#	fastlane/metadata/android/de/changelogs/77.txt
#	fastlane/metadata/android/de/changelogs/80.txt
#	fastlane/metadata/android/de/changelogs/82.txt
#	fastlane/metadata/android/de/changelogs/83.txt
#	fastlane/metadata/android/de/changelogs/87.txt
#	fastlane/metadata/android/de/changelogs/89.txt
#	fastlane/metadata/android/de/changelogs/94.txt
#	fastlane/metadata/android/de/full_description.txt
#	fastlane/metadata/android/de/short_description.txt
#	fastlane/metadata/android/fa/changelogs/58.txt
#	fastlane/metadata/android/it/changelogs/58.txt
#	gradle.properties
#	gradle/libs.versions.toml
#	instance-build.gradle
2023-05-28 13:33:05 +09:00
Nik Clayton d5fdcc9578
Prepare 22.0 beta 6 (versionCode 108) (#3687) 2023-05-24 22:05:25 +02:00
Nik Clayton 57e79b3f00
Save and restore the user's reading position more frequently (#3685)
The previous code gets the user's reading position *once*, when the
viewmodel is created, and uses that whenever it needs to be restored.

This is a problem. Suppose the user is a few days behind on their
notifications, and opens the app.

The reading position is restored, as expected. They scroll up to start
reading newer notifications.

Then they change their notification filters. This causes the
notifications list to change, and when it does their reading position
is set back to what it was when they first switched to the Notifications
tab.

Fix this by:

NotificationsFragment:
- Save the reading position whenever the user stops scrolling.

NotificationsViewModel:
- Use the saved reading position whenever the list of notifications
  can change, not just when the view model is created.
2023-05-23 20:07:39 +02:00
Danial Behzadi abf15ccfde Translated using Weblate (Persian)
Currently translated at 100.0% (25 of 25 strings)

Translation: Tusky/Tusky description
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky-app/fa/
2023-05-23 20:06:27 +02:00
Deleted User b7060291b4 Translated using Weblate (German)
Currently translated at 100.0% (24 of 24 strings)

Translation: Tusky/Tusky description
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky-app/de/
2023-05-23 20:06:27 +02:00
Mārtiņš Bruņenieks 16eabc9d3c Translated using Weblate (Latvian)
Currently translated at 92.5% (556 of 601 strings)

Co-authored-by: Mārtiņš Bruņenieks <martinsb@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/lv/
Translation: Tusky/Tusky
2023-05-22 17:22:46 +02:00
Gordot Forrot 01c7e2c860 Translated using Weblate (Spanish)
Currently translated at 100.0% (601 of 601 strings)

Co-authored-by: Gordot Forrot <grdofrro@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/es/
Translation: Tusky/Tusky
2023-05-22 17:22:46 +02:00
Andrés Blasco Arnáiz cd211fa157 Translated using Weblate (Spanish)
Currently translated at 100.0% (601 of 601 strings)

Co-authored-by: Andrés Blasco Arnáiz <andresbarnaiz@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/es/
Translation: Tusky/Tusky
2023-05-22 17:22:46 +02:00
Nik Clayton 867232ce88
Prepare 22.0 beta 5 (versionCode 107) (#3678) 2023-05-20 15:32:45 +02:00
Deleted User 931a09eab7 Translated using Weblate (German)
Currently translated at 100.0% (601 of 601 strings)

Co-authored-by: Deleted User <noreply+279@weblate.org>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/de/
Translation: Tusky/Tusky
2023-05-20 10:18:17 +02:00
Hồ Nhất Duy 7343d3261c Translated using Weblate (Vietnamese)
Currently translated at 100.0% (601 of 601 strings)

Co-authored-by: Hồ Nhất Duy <mastoduy@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/vi/
Translation: Tusky/Tusky
2023-05-20 10:18:17 +02:00
Nik Clayton 76a3b4062c
Merge pull request #3675 from nailyk-weblate/weblate-tusky-tusky-app
Translations update from Weblate
2023-05-19 14:38:19 +02:00
Konrad Pozniak 7a832836d4
Downgrade apng library to 2.23.0 (#3676)
Fixes https://github.com/tuskyapp/Tusky/issues/3631
2023-05-19 13:30:57 +02:00
Hồ Nhất Duy d267633689 Translated using Weblate (Vietnamese)
Currently translated at 100.0% (24 of 24 strings)

Translation: Tusky/Tusky description
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky-app/vi/
2023-05-19 11:17:33 +00:00
Ihor Hordiichuk c4f18dbefa Translated using Weblate (Ukrainian)
Currently translated at 100.0% (24 of 24 strings)

Translation: Tusky/Tusky description
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky-app/uk/
2023-05-19 11:17:33 +00:00
Danial Behzadi 0469bd535e Translated using Weblate (Persian)
Currently translated at 100.0% (24 of 24 strings)

Translation: Tusky/Tusky description
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky-app/fa/
2023-05-19 11:17:33 +00:00
Nik Clayton d644f6e6f3
Save the a copy of the notification marker ID locally (#3672)
Not all servers support the marker API. If they don't the user will
potentially get duplicate Android notifications.

To resolve this, store a copy of the notification marker ID locally as
well. Defer to the remote marker if it exists (and is newer than the
local marker).

Fixes https://github.com/tuskyapp/Tusky/issues/3671
2023-05-18 23:23:42 +02:00
Nik Clayton f69c2646ca
Merge pull request #3669 from nailyk-weblate/weblate-tusky-tusky
Translations update from Weblate
2023-05-18 15:07:50 +02:00
Quentí 7e6beb5b1f Translated using Weblate (Occitan)
Currently translated at 100.0% (601 of 601 strings)

Co-authored-by: Quentí <quentinantonin@free.fr>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/oc/
Translation: Tusky/Tusky
2023-05-17 20:35:54 +00:00
Konrad Pozniak a0286d9e5c
delete "head of development" section from readme (#3636) 2023-05-17 20:35:01 +02:00
Nik Clayton 64878b4bb5
Merge pull request #3665 from nailyk-weblate/weblate-tusky-tusky
Translations update from Weblate
2023-05-17 18:31:10 +02:00
Deleted User 4723321764 Translated using Weblate (German)
Currently translated at 100.0% (601 of 601 strings)

Co-authored-by: Deleted User <noreply+278@weblate.org>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/de/
Translation: Tusky/Tusky
2023-05-16 17:35:54 +00:00
Nik Clayton 252d1054ce
Prepare v22.0 beta 4 release (#3664) 2023-05-16 19:31:12 +02:00
Nik Clayton 158f9b83fb
updateMarkersWithAuth(): Require DOMAIN header (#3660)
Otherwise markers are updated for the wrong account.

Fixes https://github.com/tuskyapp/Tusky/issues/3658
2023-05-16 18:24:38 +02:00
Nik Clayton 92ba53a8c3
Compare notification IDs by length as well as value (#3657) 2023-05-15 23:11:14 +02:00
Nik Clayton 36ebb0e16c
Prepare v22.0 beta 3 release (#3656) 2023-05-15 14:19:54 +02:00
Nik Clayton 44a66cddc7
Merge pull request #3655 from nailyk-weblate/weblate-tusky-tusky
Translations update from Weblate
2023-05-14 18:30:46 +02:00
Nik Clayton da045ef88e
Merge pull request #3654 from nailyk-weblate/weblate-tusky-tusky-app
Translations update from Weblate
2023-05-14 17:43:08 +02:00
Hồ Nhất Duy 6749016d9c Translated using Weblate (Vietnamese)
Currently translated at 100.0% (601 of 601 strings)

Co-authored-by: Hồ Nhất Duy <mastoduy@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/vi/
Translation: Tusky/Tusky
2023-05-14 15:35:54 +00:00
Hồ Nhất Duy 12512fd70a Translated using Weblate (Vietnamese)
Currently translated at 100.0% (22 of 22 strings)

Translation: Tusky/Tusky description
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky-app/vi/
2023-05-14 13:35:54 +00:00
Nik Clayton 81b15e72f3
Only fetch and display a given notification once (#3626)
When fetching:

- Maintain a marker with the position of the newest fetched notification
- Use the marker to determine which notifications to fetch
- Fetch notifications with min_id to ensure that none are lost
- Update the marker as necessary
- Perform a one-time immediate fetch of notifications on startup

When creating notifications:

- Identify each notification with tag=${MastodonNotificationId}, id=${account.id}
- Remove activeNotifications field, it's no longer necessary
- Use the tag/id tuple to reliably identify existing notifications and avoid creating duplicates
- Cancelling notifications for an account must iterate over all the notifications, and individually remove the notifications that exist for that account.
- Limit notifications to a maximum of 40 (excluding summary notifications)
- Remove notifications (oldest first) to get under this limit
- Rate limit notification creation to 1 per second, so the OS won't drop them

Adjust the summary notification:

- Ensure the summary notification and the child notifications have the same group key
- Dismiss the summary notification if there is only one child notification

NotificationClearBroadcastReceiver is no longer needed, so remove it, and the need for deletePendingIntent.

Fixes #3625, #3539
2023-05-13 16:00:28 +02:00
Konrad Pozniak 74a00c0591
Make links in bios of follow request and follow notifications work (#3646)
* make links in bios of follow request and follow notifications work
* use ClickableSpanTextView to display account notes
2023-05-13 15:32:56 +02:00
Nik Clayton ad9e57cb10
Merge pull request #3644 from nailyk-weblate/weblate-tusky-tusky-app
Translations update from Weblate
2023-05-13 11:45:10 +02:00
Nik Clayton 7481e1930a
Merge pull request #3645 from nailyk-weblate/weblate-tusky-tusky
Translations update from Weblate
2023-05-13 11:44:41 +02:00
Ihor Hordiichuk 3ecd92b068 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (22 of 22 strings)

Translation: Tusky/Tusky description
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky-app/uk/
2023-05-13 09:31:44 +00:00
Ihor Hordiichuk 82ff7e0b5c Translated using Weblate (Ukrainian)
Currently translated at 100.0% (601 of 601 strings)

Co-authored-by: Ihor Hordiichuk <igor_ck@outlook.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/uk/
Translation: Tusky/Tusky
2023-05-13 02:35:54 +00:00
puf 7c294268de Translated using Weblate (Welsh)
Currently translated at 100.0% (601 of 601 strings)

Co-authored-by: puf <puffinux@tutanota.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/cy/
Translation: Tusky/Tusky
2023-05-13 02:35:54 +00:00
Nik Clayton 54397e7342
Merge pull request #3640 from nailyk-weblate/weblate-tusky-tusky
Translations update from Weblate
2023-05-11 13:54:59 +02:00
Nik Clayton c4b3b9c53f
Merge pull request #3574 from nailyk-weblate/weblate-tusky-tusky
Translations update from Weblate
2023-05-11 13:33:07 +02:00
Nik Clayton baac55b6f6
Update app/src/main/res/values-fr/strings.xml 2023-05-11 13:23:08 +02:00
Nik Clayton 69ce3af1b1
Add missing "one" plural string for FR / hint_describe_for_visually_impaired 2023-05-11 13:21:09 +02:00
Deleted User 6df8ccdc60 Translated using Weblate (German)
Currently translated at 100.0% (601 of 601 strings)

Co-authored-by: Deleted User <noreply+277@weblate.org>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/de/
Translation: Tusky/Tusky
2023-05-11 11:20:19 +00:00
Nik Clayton 7f8f04460c
Add entry for "or" to locales_config.xml and donottranslate.xml (#3584) 2023-05-11 12:54:51 +02:00
Nik Clayton b12e772a0b
FilterResult.status_matches is a List<String>? (#3634)
See https://github.com/mastodon/documentation/pull/1214
2023-05-09 17:56:48 +02:00
Deleted User 718a7b125d Translated using Weblate (German)
Currently translated at 100.0% (601 of 601 strings)

Co-authored-by: Deleted User <noreply+276@weblate.org>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/de/
Translation: Tusky/Tusky
2023-05-09 15:44:10 +00:00
Paul Sanz 95fd4918c5 Translated using Weblate (Spanish)
Currently translated at 95.1% (572 of 601 strings)

Co-authored-by: Paul Sanz <registro@polkillas.net>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/es/
Translation: Tusky/Tusky
2023-05-09 15:44:10 +00:00
Hakan e4f766d447 Translated using Weblate (Turkish)
Currently translated at 100.0% (601 of 601 strings)

Co-authored-by: Hakan <hakanutku@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/tr/
Translation: Tusky/Tusky
2023-05-09 15:44:10 +00:00
GunChleoc 0fe45c5238 Translated using Weblate (Gaelic)
Currently translated at 100.0% (601 of 601 strings)

Co-authored-by: GunChleoc <fios@foramnagaidhlig.net>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/gd/
Translation: Tusky/Tusky
2023-05-09 15:44:10 +00:00
TAKAHASHI Shuuji d9b1abc90e Translated using Weblate (Japanese)
Currently translated at 92.5% (556 of 601 strings)

Co-authored-by: TAKAHASHI Shuuji <shuuji3@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/ja/
Translation: Tusky/Tusky
2023-05-09 15:44:10 +00:00
xzFantom 18a224c53b Translated using Weblate (Belarusian)
Currently translated at 98.0% (589 of 601 strings)

Translated using Weblate (Belarusian)

Currently translated at 95.0% (571 of 601 strings)

Co-authored-by: xzFantom <xzfantom@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/be/
Translation: Tusky/Tusky
2023-05-09 15:44:10 +00:00
Subham Jena be8be43bc4 Translated using Weblate (Odia)
Currently translated at 2.3% (14 of 601 strings)

Co-authored-by: Subham Jena <subhamjena8465@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/or/
Translation: Tusky/Tusky
2023-05-09 15:44:10 +00:00
codl 22b1a2c1e0 Translated using Weblate (French)
Currently translated at 100.0% (601 of 601 strings)

Translated using Weblate (French)

Currently translated at 97.8% (588 of 601 strings)

Translated using Weblate (French)

Currently translated at 89.5% (538 of 601 strings)

Translated using Weblate (French)

Currently translated at 79.5% (478 of 601 strings)

Co-authored-by: codl <codl@codl.fr>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/fr/
Translation: Tusky/Tusky
2023-05-09 15:44:10 +00:00
Deleted User f7aaeedabf Translated using Weblate (German)
Currently translated at 100.0% (601 of 601 strings)

Co-authored-by: Deleted User <noreply+272@weblate.org>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/de/
Translation: Tusky/Tusky
2023-05-09 15:44:10 +00:00
Connyduck 05850ec216 Added translation using Weblate (Odia)
Co-authored-by: Connyduck <weblate@connyduck.at>
2023-05-09 15:44:10 +00:00
Nik Clayton 367240a612
Save the user's reading position in the home timeline (#3614)
- Add a field to AccountEntity to hold the reading position
- Provide a method to save in the viewmodel to save the position
- Save the position when TimelineFragment pauses

Does not restore the position yet.
2023-05-08 13:57:17 +02:00
Weblate 0c02dd18bf
Translations update from Weblate (#3630)
* Translated using Weblate (French)

Currently translated at 100.0% (22 of 22 strings)

Translation: Tusky/Tusky description
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky-app/fr/

* Translated using Weblate (German)

Currently translated at 100.0% (22 of 22 strings)

Translation: Tusky/Tusky description
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky-app/de/

* Translated using Weblate (Persian)

Currently translated at 100.0% (22 of 22 strings)

Translation: Tusky/Tusky description
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky-app/fa/

---------

Co-authored-by: codl <codl@codl.fr>
Co-authored-by: Deleted User <noreply+275@weblate.org>
Co-authored-by: Danial Behzadi <dani.behzi@ubuntu.com>
2023-05-08 13:32:17 +02:00
Konrad Pozniak d2747811be
Fix crash in ViewThreadFragment.removeItem (#3622) 2023-05-05 12:03:42 +02:00
Nik Clayton 80774f35f8
Prepare v22.0 beta 2 release (#3611)
- Bump versionCode and versionName
- Create new fastlane changelog
- Update CHANGELOG.md
2023-05-04 16:01:00 +02:00
Nik Clayton 7ce5048752
Show the requester's bio in a "wants to follow you" notification (#3284) 2023-05-04 16:00:30 +02:00
UlrichKu 1040358f3c
Ensure text field has focus when reporting statuses 2023-05-04 14:53:27 +02:00
Nik Clayton c154aaa17d
Ignore LogConditional issues (#3615)
Logs are stripped in release builds, this is an unnecessary lint
2023-05-04 14:50:21 +02:00
Nik Clayton a7001eecb8
Use the tab's contentDescription as the toolbar title (#3609)
Previous code had an IndexOutOfBoundsException where tab.position could be larger than the tabs array.
2023-05-04 13:19:44 +02:00
Nik Clayton 4da758c1f7
Check for non-empty pages when falling back (#3603)
Requesting a non-existent notification ID will cause the fall-back requests to succeed but return empty lists which stops pagination.

Check for this, and in the worst case, fall back to returning the most recent notifications.
2023-05-04 13:19:28 +02:00
Konrad Pozniak 62b91664e9
Allow multiline polls (this time for real) (#3608) 2023-05-03 09:05:05 +02:00
Konrad Pozniak 5abd3dc9fe
fix reporting (#3607) 2023-05-02 18:20:02 +02:00
Weblate a04c6177a5
Translated using Weblate (Galician) (#3586)
Currently translated at 100.0% (21 of 21 strings)

Translation: Tusky/Tusky description
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky-app/gl/

Co-authored-by: XoseM <xosem@disroot.org>
2023-05-02 12:45:39 +02:00
UlrichKu 5cac17b7a9
#3430: (fix) do not hide the anchor of the FAB (#3561)
On Account preferences > Filters

With the list hidden on an empty view the FAB is erroneouly placed on the top left of the screen.

(Introduced with #3430)
2023-04-30 21:51:36 +02:00
Nik Clayton 0a39ee7ded
Force a layout pass to ensure spans are measured correctly (#3600)
Fixes https://github.com/tuskyapp/Tusky/issues/3596
2023-04-30 21:50:52 +02:00
Konrad Pozniak 11cf93fa55
Remove unused class PairedList (#3602) 2023-04-30 21:49:05 +02:00
Nik Clayton ccab33dd0f
Add additional margin after the "X <performed an action>" view (#3597)
Add additional 6dp margin to the top of the username view, to provide more space after the "X <performed an action>" view, consistent with item_follow.xml.
2023-04-30 14:28:01 +02:00
Nik Clayton 03031e5716
Set initialLoadSize = pageSize (#3598)
The pageSize is large enough that there's no need for the default 3 x pageSize initialLoadSize value, and this improves performance.

Fixes https://github.com/tuskyapp/Tusky/issues/3578
2023-04-29 19:34:29 +02:00
Nik Clayton 152318ce85
Show filter title instead of filter keywords (#3589)
Avoid showing the user the things they have filtered on when showing a filter placeholder in a timeline.
2023-04-29 18:56:27 +02:00
Nik Clayton 5c28cba57d
Show 0/1/1+ for replies, even if show stats is off (#3590)
Fixes https://github.com/tuskyapp/Tusky/issues/3588
2023-04-29 16:49:04 +02:00
Konrad Pozniak a5578cf765
Check view is non-null before scrolling, fix crash in NotificationsFragment (#3594)
The posted message runs at the end of the message queue, by which time the view may no longer exist.
2023-04-29 16:39:49 +02:00
Nik Clayton 2f512705e8
Fix IndexOutOfBoundsException in onPause (#3581)
Use `getOrNull` instead of `get`, which was occasionally throwing IndexOutOfBoundsException.
2023-04-27 12:59:32 +02:00
Nik Clayton d3a36be9e1
Merge pull request #3575 from nailyk-weblate/weblate-tusky-tusky-app
Translations update from Weblate
2023-04-27 10:37:25 +02:00
Hồ Nhất Duy 0ecda75acd Translated using Weblate (Vietnamese)
Currently translated at 100.0% (21 of 21 strings)

Translation: Tusky/Tusky description
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky-app/vi/
2023-04-25 19:16:21 +00:00
Danial Behzadi eb502ef11b Translated using Weblate (Persian)
Currently translated at 100.0% (21 of 21 strings)

Translation: Tusky/Tusky description
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky-app/fa/
2023-04-25 19:16:21 +00:00
Deleted User 22845aa796 Translated using Weblate (German)
Currently translated at 100.0% (21 of 21 strings)

Translation: Tusky/Tusky description
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky-app/de/
2023-04-25 19:16:21 +00:00
Nik Clayton ae1f62d317
Prepare v22.0 beta 1 release (#3567)
- Bump versionCode and versionName
- Create new fastlane changelog
- Add more detailed changelog to CHANGELOG.md
2023-04-25 13:00:08 +02:00
Weblate 30b890a4e9
Translations update from Weblate (#3524) 2023-04-24 15:49:29 +02:00
Nik Clayton 23c3c3b492
Fix translation merge conflicts (#3563) 2023-04-24 15:27:54 +02:00
Nik Clayton 4754388f99
Add padding below status content (#3559)
Fixes #3550
2023-04-24 14:58:49 +02:00
Nik Clayton f1b3faf85f
Show the follower's bio/note in a "followed you" notification (#3281)
This makes the notification view for a follow request contain more info about the new follower, and makes the layout (of their name / username) consistent with other notifications that show names/usernames.
2023-04-24 12:09:34 +02:00
Konrad Pozniak af2727b633
Remove shrinker rules for okhttp (#3560)
Now included automatically in OkHttp.
2023-04-24 12:08:03 +02:00
UlrichKu 24d7ef7ccb
Always publish image alt text
Previous code would discard the image alt-text if the user finished writing the text before the image had finished uploading.

This code ensures the text is set after the image has completed uploading.
2023-04-24 11:48:40 +02:00
Nik Clayton 168be9223d
Disable lint checks for unused resource IDs (#3557)
* Disable lint checks for unused resource IDs

The check doesn't catch some instances where resources are used through viewbinding, and has too many false positives to be useful.

* Regenerate lint baseline

Delete the existing file, then regenerated with `.\gradlew lintBlueDebug -Dlint.baselines.continue=true`
2023-04-24 09:20:03 +02:00
UlrichKu 8de5613b47
3509: Ensure filter edit dialog is scrollable (#3510) 2023-04-23 22:39:19 +02:00
Nik Clayton ce92c52fc1
Merge pull request #3556 from tuskyapp/renovate/okhttp
fix(deps): update okhttp to v4.11.0
2023-04-23 22:09:05 +02:00
Nik Clayton 73be497bbe
Merge pull request #3513 from Lakoja/fix-relationship-crash
Do not crash on empty relationship response
2023-04-23 18:36:13 +02:00
renovate[bot] 683bdeaeab
fix(deps): update okhttp to v4.11.0 2023-04-23 02:39:55 +00:00
Nik Clayton a3d254e486
Merge pull request #3554 from tuskyapp/renovate/gradle-8.x
chore(deps): update dependency gradle to v8.1.1
2023-04-21 21:53:41 +02:00
Nik Clayton df44bfbf7f
Merge pull request #3521 from nikclayton/patch-4
Disable wildcard imports for Java and Kotlin files
2023-04-21 21:43:31 +02:00
Nik Clayton b57db0589d
Merge pull request #3514 from Lakoja/fix-edgy-crashes
Do not crash on/avoid index out of bounds
2023-04-21 19:52:45 +02:00
renovate[bot] 3d67e6a9ff
chore(deps): update dependency gradle to v8.1.1 2023-04-21 13:48:29 +00:00
renovate[bot] 3cb9acfae8
fix(deps): update dependency androidx.core:core-ktx to v1.10.0 (#3515)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-04-20 19:51:01 +02:00
renovate[bot] 3557a314bc
fix(deps): update dependency org.robolectric:robolectric to v4.10 (#3522)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-04-20 19:50:51 +02:00
UlrichKu b14549e041
3503: SwipeRefreshLayout must be higher level (#3504)
* 3503: SwipeRefreshLayout must be higher level

* 3503: Fix notifications view a bit

* 3503: Wrap recycler views and message views in the swipe-to-refresh if all are present
2023-04-20 19:36:29 +02:00
renovate[bot] 1988c36c4d
chore(deps): update dependency gradle to v8.1 (#3528)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-04-20 19:13:37 +02:00
renovate[bot] cdbf7a2843
fix(deps): update dependency androidx.activity:activity-ktx to v1.7.1 (#3547)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-04-20 19:11:24 +02:00
renovate[bot] 8570e61eb7
fix(deps): update dependency androidx.core:core-splashscreen to v1.0.1 (#3548)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-04-20 19:11:13 +02:00
renovate[bot] 290b91aaf6
fix(deps): update dependency androidx.fragment:fragment-ktx to v1.5.7 (#3549)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-04-20 19:10:23 +02:00
Nik Clayton ca9b171868
Disable wildcard imports for Java and Kotlin files
Out-of-the-box Android Studio will perform wildcard imports if the import list is >= 3. This is a lint failure.

Adjust the default to 999 so that, in practice, wildcard imports never happen.
2023-04-10 14:26:10 +02:00
Konrad Pozniak 16e1ab20f6
Merge pull request #3502 from nailyk-weblate/weblate-tusky-tusky-app
Translations update from Weblate
2023-04-08 17:28:28 +02:00
UlrichKu 040268e2d3
3492: Correctly shorten name in drawer and notifications (#3495)
* #3492: Correctly shorten name in drawer and notifications

* Trigger linter again

* 3492: Use a flat ContraintLayout for everything
2023-04-08 16:55:32 +02:00
Ali Rohman c9dc2f5e91 Translated using Weblate (Indonesian)
Currently translated at 100.0% (20 of 20 strings)

Translation: Tusky/Tusky description
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky-app/id/
2023-04-08 14:45:14 +00:00
Konrad Pozniak 5f00e3fd35
Merge pull request #3506 from nailyk-weblate/weblate-tusky-tusky
Translations update from Weblate
2023-04-08 12:21:53 +02:00
renovate[bot] 85afdeed2d
chore(deps): update dependency org.jetbrains.kotlin.android to v1.8.20 (#3500)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-04-08 11:15:15 +02:00
XoseM 3f680872b5 Translated using Weblate (Galician)
Currently translated at 100.0% (603 of 603 strings)

Co-authored-by: XoseM <xosem@disroot.org>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/gl/
Translation: Tusky/Tusky
2023-04-08 09:04:58 +00:00
Hồ Nhất Duy 33144c28fa Translated using Weblate (Vietnamese)
Currently translated at 100.0% (603 of 603 strings)

Co-authored-by: Hồ Nhất Duy <mastoduy@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/vi/
Translation: Tusky/Tusky
2023-04-08 09:04:58 +00:00
Ihor Hordiichuk 3264b9706b Translated using Weblate (Ukrainian)
Currently translated at 100.0% (603 of 603 strings)

Co-authored-by: Ihor Hordiichuk <igor_ck@outlook.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/uk/
Translation: Tusky/Tusky
2023-04-08 09:04:58 +00:00
Eric d9cb87d522 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (603 of 603 strings)

Co-authored-by: Eric <alchemillatruth@purelymail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/zh_Hans/
Translation: Tusky/Tusky
2023-04-08 09:04:58 +00:00
Danial Behzadi bddf4481a7 Translated using Weblate (Persian)
Currently translated at 100.0% (603 of 603 strings)

Co-authored-by: Danial Behzadi <dani.behzi@ubuntu.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/fa/
Translation: Tusky/Tusky
2023-04-08 09:04:58 +00:00
Lakoja bca98d2f48 Do not crash on/avoid index out of bounds 2023-04-05 15:48:40 +02:00
Lakoja eb52c8ca58 Do not crash on empty relationship response 2023-04-05 10:54:03 +02:00
UlrichKu cf50d0563f
3416: Call the list activity when list list empty (#3426)
* 3416: Call the list activity when empty; show failure on loading

* 3416: Revert include grouping

* 3416: Remove faulty include after merge

* 3416: Added a list loading progress

* 3416: Add proper padding to progress

* 3416: Show a text if there are no lists, do not change dialog title

* 3416: Center things in layout

* 3416: Appease linter (?)

* 3416: Do not hide manage lists button

* 3416: Use ThemeUtils
2023-03-30 21:23:08 +02:00
UlrichKu eee1414aff
#2528: Also clear notifications on refresh in notifications timeline (#3498) 2023-03-30 19:38:44 +02:00
UlrichKu 8958c50de7
#3178: Increase contrast for separator lines in dark themes (#3497) 2023-03-30 19:31:23 +02:00
renovate[bot] c4c171f4a3
fix(deps): update dependency app.cash.turbine:turbine to v0.12.3 (#3496)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-03-30 19:31:08 +02:00
UlrichKu 23381d45d7
3430: Make list refresh/retry consistent (#3474)
* 3430: Make list refresh/retry consistent

* 3430: Add swipe-to-refresh and use states in filter lists
2023-03-30 19:29:42 +02:00
renovate[bot] 57ed98f305
fix(deps): update emoji2 to v1.3.0 (#3484)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-03-30 19:24:05 +02:00
Conny Duck fcd997f36c Merge remote-tracking branch 'weblate/develop' into develop
# Conflicts:
#	app/src/main/res/values-es/strings.xml
#	app/src/main/res/values-gl/strings.xml
2023-03-30 19:00:34 +02:00
renovate[bot] 5b6389d4ae
fix(deps): update dependency androidx.activity:activity-ktx to v1.7.0 (#3483)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-03-30 18:52:53 +02:00
Konrad Pozniak 60af582750
Merge pull request #3490 from nailyk-weblate/weblate-tusky-tusky
Translations update from Weblate
2023-03-30 18:52:39 +02:00
Konrad Pozniak 24dd68c996
distinguish between different error types in ScheduledStatusActivity (#3487) 2023-03-30 18:52:24 +02:00
Ali Rohman 4f34c2effa Translated using Weblate (Indonesian)
Currently translated at 40.3% (242 of 600 strings)

Co-authored-by: Ali Rohman <laymoth@pm.me>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/id/
Translation: Tusky/Tusky
2023-03-30 16:42:49 +00:00
Quentí 8d122989da Translated using Weblate (Occitan)
Currently translated at 98.5% (591 of 600 strings)

Co-authored-by: Quentí <quentinantonin@free.fr>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/oc/
Translation: Tusky/Tusky
2023-03-30 16:42:49 +00:00
Deleted User 6f1e5ba744 Translated using Weblate (German)
Currently translated at 100.0% (600 of 600 strings)

Co-authored-by: Deleted User <noreply+268@weblate.org>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/de/
Translation: Tusky/Tusky
2023-03-30 16:42:49 +00:00
XoseM 8c5f1209ab Translated using Weblate (Galician)
Currently translated at 100.0% (600 of 600 strings)

Translated using Weblate (Galician)

Currently translated at 94.0% (564 of 600 strings)

Co-authored-by: XoseM <xosem@disroot.org>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/gl/
Translation: Tusky/Tusky
2023-03-30 16:42:49 +00:00
Hồ Nhất Duy 33cac594ca Translated using Weblate (Vietnamese)
Currently translated at 100.0% (600 of 600 strings)

Co-authored-by: Hồ Nhất Duy <mastoduy@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/vi/
Translation: Tusky/Tusky
2023-03-30 16:42:49 +00:00
Eric 8b38f5b75b Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (600 of 600 strings)

Co-authored-by: Eric <alchemillatruth@purelymail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/zh_Hans/
Translation: Tusky/Tusky
2023-03-30 16:42:49 +00:00
Danial Behzadi 00d1d4aeb5 Translated using Weblate (Persian)
Currently translated at 100.0% (600 of 600 strings)

Co-authored-by: Danial Behzadi <dani.behzi@ubuntu.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/fa/
Translation: Tusky/Tusky
2023-03-30 16:42:49 +00:00
Paul Sanz 92642f73e8 Translated using Weblate (Spanish)
Currently translated at 95.5% (573 of 600 strings)

Translated using Weblate (Spanish)

Currently translated at 95.1% (571 of 600 strings)

Co-authored-by: Paul Sanz <registro@polkillas.net>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/es/
Translation: Tusky/Tusky
2023-03-30 16:42:49 +00:00
XoseM f76c354aff Translated using Weblate (Galician)
Currently translated at 94.0% (564 of 600 strings)

Co-authored-by: XoseM <xosem@disroot.org>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/gl/
Translation: Tusky/Tusky
2023-03-28 03:46:14 +00:00
Hồ Nhất Duy 99f5980f8e Translated using Weblate (Vietnamese)
Currently translated at 100.0% (600 of 600 strings)

Co-authored-by: Hồ Nhất Duy <mastoduy@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/vi/
Translation: Tusky/Tusky
2023-03-28 03:46:14 +00:00
Eric 6db5b788b6 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (600 of 600 strings)

Co-authored-by: Eric <alchemillatruth@purelymail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/zh_Hans/
Translation: Tusky/Tusky
2023-03-28 03:46:14 +00:00
Danial Behzadi ddbd859d45 Translated using Weblate (Persian)
Currently translated at 100.0% (600 of 600 strings)

Co-authored-by: Danial Behzadi <dani.behzi@ubuntu.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/fa/
Translation: Tusky/Tusky
2023-03-28 03:46:14 +00:00
Paul Sanz bfab84ce89 Translated using Weblate (Spanish)
Currently translated at 95.1% (571 of 600 strings)

Co-authored-by: Paul Sanz <registro@polkillas.net>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/es/
Translation: Tusky/Tusky
2023-03-28 03:46:14 +00:00
UlrichKu 9e66ccf4a6
3434: Make description dialog (text field) more usable (#3458)
* 3434: Make description dialog (text field) more usable

* 3434: Close dialog on back button

* 3434: Use a TextInputLayout

* 3434: Adapt German plurals text

* 3434: Remove unused id

* 3434: Disable counter officially
2023-03-24 18:21:56 +01:00
renovate[bot] de5b72e472
fix(deps): update androidx-work to v2.8.1 (#3482)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-03-24 18:21:05 +01:00
Konrad Pozniak 08efb0ecae
Merge pull request #3466 from nailyk-weblate/weblate-tusky-tusky
Translations update from Weblate
2023-03-24 18:07:57 +01:00
renovate[bot] 057d38bc5f
fix(deps): update androidx-room to v2.5.1 (#3481)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-03-24 16:58:14 +01:00
UlrichKu aa5703fd5d
3435: Have loading progress visible (shares with recycler view and moved to top) (#3453) 2023-03-24 16:57:21 +01:00
Eric 8ade624e69 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (598 of 598 strings)

Co-authored-by: Eric <alchemillatruth@purelymail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/zh_Hans/
Translation: Tusky/Tusky
2023-03-24 15:49:05 +00:00
xzFantom 8f67b344c3 Translated using Weblate (Belarusian)
Currently translated at 94.1% (562 of 597 strings)

Co-authored-by: xzFantom <xzfantom@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/be/
Translation: Tusky/Tusky
2023-03-24 15:49:05 +00:00
Ihor Hordiichuk 3d4a6e7898 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (598 of 598 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (598 of 598 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (597 of 597 strings)

Co-authored-by: Ihor Hordiichuk <igor_ck@outlook.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/uk/
Translation: Tusky/Tusky
2023-03-24 15:49:05 +00:00
Gera, Zoltan 11c218012d Translated using Weblate (Hungarian)
Currently translated at 100.0% (597 of 597 strings)

Co-authored-by: Gera, Zoltan <gerazo@manioka.hu>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/hu/
Translation: Tusky/Tusky
2023-03-24 15:49:05 +00:00
Hồ Nhất Duy a98b450d50 Translated using Weblate (Vietnamese)
Currently translated at 100.0% (598 of 598 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (598 of 598 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (597 of 597 strings)

Co-authored-by: Hồ Nhất Duy <mastoduy@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/vi/
Translation: Tusky/Tusky
2023-03-24 15:49:05 +00:00
Sveinn í Felli 51300bae42 Translated using Weblate (Icelandic)
Currently translated at 100.0% (597 of 597 strings)

Translated using Weblate (Icelandic)

Currently translated at 95.8% (572 of 597 strings)

Co-authored-by: Sveinn í Felli <sv1@fellsnet.is>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/is/
Translation: Tusky/Tusky
2023-03-24 15:49:05 +00:00
Danial Behzadi e5b5c6e24e Translated using Weblate (Persian)
Currently translated at 100.0% (598 of 598 strings)

Translated using Weblate (Persian)

Currently translated at 100.0% (597 of 597 strings)

Co-authored-by: Danial Behzadi <dani.behzi@ubuntu.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/fa/
Translation: Tusky/Tusky
2023-03-24 15:49:05 +00:00
UlrichKu b70e4be305
3469: Do not jump in the list on the (second) refresh (#3471) 2023-03-24 16:26:50 +01:00
renovate[bot] 444a1c0e48
fix(deps): update dependency androidx.lifecycle:lifecycle-reactivestreams-ktx to v2.6.1 (#3479)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-03-22 22:01:21 +01:00
renovate[bot] 2e138dbbe3
fix(deps): update dependency androidx.fragment:fragment-ktx to v1.5.6 (#3478)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-03-22 22:01:12 +01:00
Konrad Pozniak 787f88b801
remove Rx from AccountViewModel and ReportViewModel (#3463) 2023-03-22 22:00:03 +01:00
UlrichKu 8c519af611
3408: Only support iconics (get rid of "discouraged api") (#3476)
* 3408: Only support iconics (get rid of "discouraged api")

* 3408: Remove unused import
2023-03-21 22:19:34 +01:00
renovate[bot] 7f7afb3e46
fix(deps): update dependency androidx.recyclerview:recyclerview to v1.3.0 (#3424)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-03-21 20:08:43 +01:00
UlrichKu 182df2bfae
3408 home help message (#3415)
* 3408: First draft of help message on empty home timeline

* 3408: Move image spanning to utils; tweak gui a bit (looks like status)

* 3408: Use proper R again; appease linter

* 3408: Add doc; remove narrow comment

* 3408: null is default

* 3408: Add German text

* 3408: Stack refresh animation on top of help message (reorder)
2023-03-21 19:44:35 +01:00
UlrichKu 9484a8b2b9
3457: Allow multi-line poll options (#3459) 2023-03-21 19:34:18 +01:00
UlrichKu 0c36b369dd
3159: Correctly refresh timestamps (#3456)
* 3159: Correctly refresh timestamp (of all elements) and refresh all on display options change

* Remove unnecessary import

* 3159: Remove unnecessary semicolon

* 3159: Remove todo question
2023-03-21 19:01:33 +01:00
Konrad Pozniak 3d71b6e69a
add lint to ci checks (#3462)
* add lint to ci checks

* update lint baseline
2023-03-21 18:56:11 +01:00
UlrichKu d754df8f07
3472: Give polls a default duration (#3473) 2023-03-21 18:52:38 +01:00
Konrad Pozniak 3fc1892c7d
Merge pull request #3455 from nailyk-weblate/weblate-tusky-tusky
Translations update from Weblate
2023-03-18 11:04:51 +01:00
Nik Clayton 81f725667e
Show better errors when loading notifications fails (#3448)
* Show better errors with notification loading fails

The errors are returned as a JSON object, parse it, and show the error
message it contains.

Handle the cases where there might be no error message, or the JSON may be
malformed.

Add tests.

Fixes #3445

* Lint
2023-03-18 10:25:41 +01:00
Eric af762db529 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (597 of 597 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (597 of 597 strings)

Co-authored-by: Eric <alchemillatruth@purelymail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/zh_Hans/
Translation: Tusky/Tusky
2023-03-18 09:16:59 +00:00
Danial Behzadi b9e2695597 Translated using Weblate (Persian)
Currently translated at 100.0% (597 of 597 strings)

Co-authored-by: Danial Behzadi <dani.behzi@ubuntu.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/fa/
Translation: Tusky/Tusky
2023-03-18 09:16:59 +00:00
Hồ Nhất Duy b12b06a8c2 Translated using Weblate (Vietnamese)
Currently translated at 100.0% (596 of 596 strings)

Co-authored-by: Hồ Nhất Duy <mastoduy@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/vi/
Translation: Tusky/Tusky
2023-03-18 09:16:59 +00:00
Mārtiņš Bruņenieks 2b13b96372 Translated using Weblate (Latvian)
Currently translated at 91.1% (543 of 596 strings)

Translated using Weblate (Latvian)

Currently translated at 90.7% (541 of 596 strings)

Co-authored-by: Mārtiņš Bruņenieks <martinsb@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/lv/
Translation: Tusky/Tusky
2023-03-18 09:16:59 +00:00
Rhoslyn Prys 5c7bd41109 Translated using Weblate (Welsh)
Currently translated at 100.0% (596 of 596 strings)

Co-authored-by: Rhoslyn Prys <post@meddal.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/cy/
Translation: Tusky/Tusky
2023-03-18 09:16:59 +00:00
renovate[bot] 84188ed10f
fix(deps): update glide to v4.15.1 (#3449)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-03-18 10:12:07 +01:00
Konrad Pozniak 321d17f5de
Remove Rx from EventHub and TimelineCases (#3446)
* remove Rx from EventHub and TimelineCases

* fix tests

* fix AccountViewModel.unblockDomain

* remove debug logging
2023-03-18 10:11:47 +01:00
Konrad Pozniak 66eadabd44
update release process (#3439) 2023-03-18 09:51:21 +01:00
Nik Clayton c36b243745
Show reblog/favourite confirmations as menus not dialogs (#3418)
* Show reblog/favourite confirmations as menus not dialogs

The previous code used dialogs and displayed the text of the status when
reblogging or favouriting.

This didn't work when the post just contained images, and other material
from the status (content warning, polls) was not shown either.

Fix this by displaying a popup menu instead. The status remains visible so
the user can clearly see what they're acting on.

In addition, this lays the groundwork for supporting a long-press menu
in the future to allow the user to reblog/favourite from a different
account.

Fixes https://github.com/tuskyapp/Tusky/issues/3308

* Revert the change that puts the menu immediately over the icon

Although this behavious is consistent with how the option menu works, I
decided that the risk of someone inadvertently double-tapping in the same
location, and the first tap opens the menu and the second tap confirms the
action was too great.

So now the menu appears either above or below the icon depending on space,
and the user has to tap in two slightly different spaces.

This is also consistent with the previous behaviour, where it's highly
unlikely that the confirm button on the dialog would have been directly
under the user's finger if they double-tapped.
2023-03-18 09:37:55 +01:00
Nik Clayton 61720c3472
Meet accessibility guidelines for clickable spans (#3382)
Clickable spans in textviews do not normally meet the Android accessibility
guidelines of a minimum 48dp square touch target.

This can't be fixed with a `TouchDelegate`, as the span is not a separate
view to which the delegate can be attached.

Add `ClickableSpanTextView`. If used instead of a `TextView`, any spans
from `ClickableSpan` will have their touchable area extended to meet the
48dp minimum.

The touchable area is still bounded by the size of the view.

If two spans are closer together than 48dp then the closest span to the
touch wins.

Co-authored-by: Konrad Pozniak <connyduck@users.noreply.github.com>
2023-03-18 08:57:39 +01:00
Grigorii Ioffe 75e7b9f1a5
Show toot stat inline (#3413)
* Show toot stat inline

* Correct elements position

* Format stats and show it according to setting

* inline toot statistics setting

* Code formatting

* Use kotlin functions

* Change the statistics setting description

* Use capital letters for all variants

* increase the statistics margin

* Merge fixes

* Code review fixes

* move setReblogsCount and setFavouritedCount to StatusViewHolder

* code cleaning

* code cleaning

* import lexicographical order

---------

Co-authored-by: Grigorii Ioffe <zikasaks@gmail.com>
Co-authored-by: grigoriiioffe <zikasaks@icloud.com>
2023-03-18 08:57:26 +01:00
UlrichKu 9087d0ecdd
Fix doubled line from PR 3188 (support mastodon filter api) (#3460) 2023-03-18 08:05:39 +01:00
Konrad Pozniak 3bb92d51bf
remove Rx from TabPreferenceActivity (#3444) 2023-03-13 13:16:58 +01:00
Konrad Pozniak bab178166d
update AndroidX lifecycle to 2.6.0, fix deprecation (#3443) 2023-03-13 13:16:49 +01:00
Konrad Pozniak d839f18267
update ktlint plugin to 11.3.1, format code (#3442) 2023-03-13 13:16:39 +01:00
Konrad Pozniak 774d79d666
Merge pull request #3423 from nailyk-weblate/weblate-tusky-tusky-app
Translations update from Weblate
2023-03-13 10:53:49 +01:00
Konrad Pozniak 24c4ea13cf
Merge pull request #3411 from nailyk-weblate/weblate-tusky-tusky
Translations update from Weblate
2023-03-13 10:53:37 +01:00
Manuel 6a3d0b574f Translated using Weblate (Italian)
Currently translated at 55.0% (11 of 20 strings)

Translation: Tusky/Tusky description
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky-app/it/
2023-03-13 09:45:18 +00:00
Nik Clayton 3ff8f73246
Enforce lint cleanliness when building (#3363)
* Enforce lint cleanliness when building

The volume of lintable issues is growing. To stem the tide:

1. Add `app/lint-baseline.xml`, which contains the current set of lint issues.
   Any issue appearing here will not cause the build to fail.

2. Move lint configuration settings in to `lint.xml`.

3. Update the lint configuration so that any issue (i.e., any issue not
   in lint-baseline.xml) causes a build failure.

4. Add the lint tasks as depedency when assembling an APK, to ensure the
   lint checks are run.

* lint: Convert launcher images to webp

* Move lint.xml location

* Don't lint when assembling

* Update baseline
2023-03-13 10:23:42 +01:00
Nik Clayton 6dfdaec425
Ignore "@instance..." part of username when computing status length (#3392)
* Move compose.* tests to own namespace

* Ignore "@instance..." part of username when computing status length

In a status with a mention ("@foo@example.org") only the "@foo" part should
be included in the calculated status length. It wasn't, so the app was
prevening people from posting statuses that should have been allowed.

Fix this.

- Lift the length calculation code in to a separate static function (easier
  and faster to test)
- Add a `MentionSpan` type, to reuse existing code for detecting mentions
- Fix a bug in `FakeSpannable.getSpans()` (it was returning the outer type,
  not the wrapped inner span)
- Add additional fast tests

The tests made sense under the `components.compose.ComposeActivity` package,
so I also created that and moved the existing ComposeActivity tests there.

Fixes https://github.com/tuskyapp/Tusky/issues/3339

* Static import assertEquals
2023-03-13 10:22:33 +01:00
renovate[bot] f309c7750f
fix(deps): update dependency org.mockito:mockito-inline to v5.2.0 (#3428)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-03-13 10:20:08 +01:00
Nik Clayton 81116f2df3
Update Robolectric to 4.9.2 (#3422) 2023-03-13 10:18:41 +01:00
Nik Clayton 70dced795c
Don't display error message if user cancels picking an image (#3427)
* Don't display error message if user cancels picking an image

* Update app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt
2023-03-13 10:17:41 +01:00
Deleted User ab986a4ca0 Translated using Weblate (German)
Currently translated at 100.0% (596 of 596 strings)

Co-authored-by: Deleted User <noreply+267@weblate.org>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/de/
Translation: Tusky/Tusky
2023-03-13 09:15:23 +00:00
Deleted User ae3cd32846 Translated using Weblate (German)
Currently translated at 100.0% (596 of 596 strings)

Co-authored-by: Deleted User <noreply+266@weblate.org>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/de/
Translation: Tusky/Tusky
2023-03-13 09:15:23 +00:00
ButterflyOfFire d61c630e03 Translated using Weblate (Arabic)
Currently translated at 93.2% (556 of 596 strings)

Co-authored-by: ButterflyOfFire <butterflyoffire@protonmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/ar/
Translation: Tusky/Tusky
2023-03-13 09:15:23 +00:00
Quentí cac75be82a Translated using Weblate (Occitan)
Currently translated at 99.1% (591 of 596 strings)

Co-authored-by: Quentí <quentinantonin@free.fr>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/oc/
Translation: Tusky/Tusky
2023-03-13 09:15:23 +00:00
Mārtiņš Bruņenieks 5afdf9bdb6 Translated using Weblate (Latvian)
Currently translated at 93.2% (540 of 579 strings)

Co-authored-by: Mārtiņš Bruņenieks <martinsb@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/lv/
Translation: Tusky/Tusky
2023-03-13 09:15:23 +00:00
Ihor Hordiichuk 48a9d20c11 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (596 of 596 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (579 of 579 strings)

Co-authored-by: Ihor Hordiichuk <igor_ck@outlook.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/uk/
Translation: Tusky/Tusky
2023-03-13 09:15:23 +00:00
Deleted User 0324bbc0c0 Translated using Weblate (German)
Currently translated at 100.0% (579 of 579 strings)

Co-authored-by: Deleted User <noreply+265@weblate.org>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/de/
Translation: Tusky/Tusky
2023-03-13 09:15:23 +00:00
puf a1bff13444 Translated using Weblate (Welsh)
Currently translated at 100.0% (567 of 567 strings)

Co-authored-by: puf <puffinux@tutanota.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/cy/
Translation: Tusky/Tusky
2023-03-13 09:15:23 +00:00
Rhoslyn Prys 1c26aa4ea7 Translated using Weblate (Welsh)
Currently translated at 100.0% (567 of 567 strings)

Co-authored-by: Rhoslyn Prys <post@meddal.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/cy/
Translation: Tusky/Tusky
2023-03-13 09:15:23 +00:00
Ricard Torres ca5e08a6f3 Translated using Weblate (Catalan)
Currently translated at 96.1% (573 of 596 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (567 of 567 strings)

Co-authored-by: Ricard Torres <ricard@ricard.dev>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/ca/
Translation: Tusky/Tusky
2023-03-13 09:15:23 +00:00
Manuel 89f827a866 Translated using Weblate (Italian)
Currently translated at 100.0% (567 of 567 strings)

Co-authored-by: Manuel <mannivuwiki@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/it/
Translation: Tusky/Tusky
2023-03-13 09:15:23 +00:00
Gera, Zoltan a88e4f45cb Translated using Weblate (Hungarian)
Currently translated at 100.0% (567 of 567 strings)

Co-authored-by: Gera, Zoltan <gerazo@manioka.hu>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/hu/
Translation: Tusky/Tusky
2023-03-13 09:15:23 +00:00
Hồ Nhất Duy a294b4ea24 Translated using Weblate (Vietnamese)
Currently translated at 100.0% (596 of 596 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (579 of 579 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (567 of 567 strings)

Co-authored-by: Hồ Nhất Duy <mastoduy@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/vi/
Translation: Tusky/Tusky
2023-03-13 09:15:23 +00:00
Eric 635d762add Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (596 of 596 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (579 of 579 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (567 of 567 strings)

Co-authored-by: Eric <alchemillatruth@purelymail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/zh_Hans/
Translation: Tusky/Tusky
2023-03-13 09:15:23 +00:00
Danial Behzadi 4b78b41df9 Translated using Weblate (Persian)
Currently translated at 100.0% (596 of 596 strings)

Translated using Weblate (Persian)

Currently translated at 100.0% (579 of 579 strings)

Translated using Weblate (Persian)

Currently translated at 100.0% (567 of 567 strings)

Co-authored-by: Danial Behzadi <dani.behzi@ubuntu.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/fa/
Translation: Tusky/Tusky
2023-03-13 09:15:23 +00:00
Deleted User 3582540e42 Translated using Weblate (German)
Currently translated at 100.0% (567 of 567 strings)

Co-authored-by: Deleted User <noreply+264@weblate.org>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/de/
Translation: Tusky/Tusky
2023-03-13 09:15:23 +00:00
Nik Clayton 9ec9d35100
Return the actual error body as a string (#3440)
The previous code returned the text representation of the error body type, which resulted in errors appearing in the UI as:

```
okhttp3.ResponseBody$Companion$asResponseBody$1@...
```

This code actually converts the *body* of the error response to a string, so the error is displayed correctly.
2023-03-13 09:58:15 +01:00
Levi Bard 51be2c9374
Fixup DAO function (#3441) 2023-03-13 09:57:59 +01:00
Conny Duck 7e5458e312 update version code for release pipeline testing 2023-03-12 19:12:34 +01:00
Conny Duck b13fe64cc6 update the version code for release pipeline testing 2023-03-11 15:32:15 +01:00
Levi Bard ff8dd37855
Support the mastodon 4 filter api (#3188)
* Replace "warn"-filtered posts in timelines and thread view with placeholders

* Adapt hashtag muting interface

* Rework filter UI

* Add icon for account preferences

* Clean up UI

* WIP: Use chips instead of a list. Adjust padding

* Scroll the filter edit activity

Nested scrolling views (e.g., an activity that scrolls with an embedded list
that also scrolls) can be difficult UI.

Since the list of contexts is fixed, replace it with a fixed collection of
switches, so there's no need to scroll the list.

Since the list of actions is only two (warn, hide), and are mutually
exclusive, replace the spinner with two radio buttons.

Use the accent colour and title styles on the different heading titles in
the layout, to match the presentation in Preferences.

Add an explicit "Cancel" button.

The layout is a straightforward LinearLayout, so use that instead of
ConstraintLayout, and remove some unncessary IDs.

Update EditFilterActivity to handle the new layout.

* Cleanup

* Add more information to the filter list view

* First pass on code review comments

* Add view model to filters activity

* Add view model to edit filters activity

* Only use the status wrapper for filtered statuses

* Relint

---------

Co-authored-by: Nik Clayton <nik@ngo.org.uk>
2023-03-11 13:12:50 +01:00
Nik Clayton b9be125c95
Show the difference between edited statuses (#3314)
* Show the difference between edited statuses

Diff each status against the previous version, comparing the different
HTML as XML to produce a structured diff.

Mark new content with `<ins>`, deleted content with `<del>`.

Convert these to styled spans in `ViewEditsAdapter`.

* Update diffx to 1.1.1

Fixes issue with diffs splitting on accented characters

* Style edited strings with Android spans

Don't use HTML spans and try and format them, create real Android spans.

Do this with a custom tag handler that can add custom spans that set the
text paint appropriately.

* Lint

* Move colors in to theme_colors.xml

* Draw a roundrect for the backoround, add start/end padding

Make the background slightlysofter by drawing it as a roundrect.

Make the spans easier to understand by padding the start/end of each one with
the width of a " " character. This is visual only, the underlying text is not
changed.

* Catch exceptions when parsing XML

* Move sorting in to Dispatchers.Default coroutine

* Scope the loader type

* Remove alpha
2023-03-10 20:30:55 +01:00
Goooler 43ea59ab2f
Replace DefaultTextWatcher with extensions in core-ktx (#3401)
* Replace DefaultTextWatcher with extensions in core-ktx

* Fix positiveButton.isEnabled

* editable!! for highlightSpans

* Fix style

* Put noteWatcher back
2023-03-10 20:27:24 +01:00
Goooler f1d46766eb
Use Sequences on joinToString (#3400)
* Use more Sequences to reduce collection processing

https://kotlinlang.org/docs/sequences.html

* Use joinToString

* Fix style

* Revert "Use more Sequences to reduce collection processing"

This reverts commit acf8071d9e62af1366b40dc6cb0ce43b4b355ec2.

* Fix
2023-03-10 20:25:56 +01:00
Levi Bard f71aa55bbe
Remove stale pre-edit statuses from the thread view. (#3377)
Fixes #3366
2023-03-10 20:24:41 +01:00
Nik Clayton 4d401c7878
Convert NotificationsFragment and related code to Kotlin, use the Paging library (#3159)
* Unmodified output from "Convert Java to Kotlin" on NotificationsFragment.java

* Bare minimum changes to get this to compile and run

- Use `lateinit` for `eventhub`, `adapter`, `preferences`, and `scrolllistener`
- Removed override for accountManager, it can be used from the superclass
- Add `?.` where non-nullity could not (yet) be guaranteed
- Remove `?` from type lists where non-nullity is guaranteed
- Explicitly convert lists to mutable where necessary
- Delete unused function `findReplyPosition`

* Remove all unnecessary non-null (!!) assertions

The previous change meant some values are no longer nullable. Remove the
non-null assertions.

* Lint ListStatusAccessibilityDelegate call

- Remove redundant constructor
- Move block outside of `()`

* Use `let` when handling compose button visibility on scroll

* Replace a `requireNonNull` with `!!`

* Remove redundant return values

* Remove or rename unused lambda parameters

* Remove unnecessary type parameters

* Remove unnecessary null checks

* Replace cascading-if statement with `when`

* Simplify calculation of `topId`

* Use more appropriate list properties and methods

- Access the last value with `.last()`
- Access the last index with `.lastIndex`
- Replace logical-chain with `asRightOrNull` and `?.`
- `.isNotEmpty()`, not `!...isEmpty()`

* Inline unnecessary variable

* Use PrefKeys constants instead of bare strings

* Use `requireContext()` instead of `context!!`

* Replace deprecated `onActivityCreated()` with `onViewCreated()`

* Remove unnecessary variable setting

* Replace `size == 0` check with `isEmpty()`

* Format with ktlint, no functionality changes

* Convert NotifcationsAdapter to Kotlin

Does not compile, this is the unchanged output of the "Convert to Kotlin"
function

* Minimum changes to get NotificationsAdapter to compile

* Remove unnecessary visibility modifiers

* Use `isNotEmpty()`

* Remove unused lambda parameters

* Convert cascading-if to `when`

* Simplifiy assignment op

* Use explicit argument names with `copy()`

* Use `.firstOrNull()` instead of `if`

* Mark as lateinit to avoid unnecessary null checks

* Format with ktlint, whitespace changes only

* Bare minimum necessary to demonstrate paging in notifications

Create `NotificationsPagingSource`. This uses a new `notifications2()` API
call, which will exist until all the code has been adapted. Instead of
using placeholders,

Create `NotificationsPagingAdapter` (will replace `NotificationsAdapater`)
to consume this data.

Expose the paging source view a new `NotificationsViewModel` `flow`, and
submit new pages to the adapter as they are available in
`NotificationsFragment`.

Comment out any other code in `NotificationsFragment` that deals with
loading data from the network. This will be updated as necessary, either
here, or in the view model.

Lots of functionality is missing, including:

- Different views for different notification types
- Starting at the remembered notification position
- Interacting with notifications
- Adjusting the UI state to match the loading state

These will be added incrementally.

* Migrate StatusNotificationViewHolder impl. to NotificationsPagingAdapter

With this change `NotificationsPagingAdapter` shows notifications about a
status correctly.

- Introduce a `ViewHolder` abstract class that all Notification view holders
  derive from. Modify the fallback view holder to use this.

- Implement `StatusNotificationViewHolder`. Much of the code is from the
  existing implementation in the `NotificationAdapater`.

- The original code split the code that binds values to views between the
  adapter's `bindViewHolder` method and the view holder's methods.

  In this code, all of the binding code is in the view holder, in a `bind`
  method. This is called by the adapter's `bindViewHolder` method. This keeps
  all the binding logic in the view holder, where it belongs.

- The new `StatusNotificationViewHolder` uses view binding to access its views
  instead of `findViewById`.

- Logically, information about whether to show sensitive media, or open
  content warnings should be part of the `StatusDisplayOptions`. So add those
  as fields, and populate them appropriately.

  This affects code outside notification handling, which will be adjusted
  later.

* Note some TODOs to complete before the PR is finished

* Extract StatusNotificationViewHolder to a new file

* Add TODO for NotificationViewData.Concrete

* Convert the adapter to take NotificationViewData.Concrete

* Add a view holder for regular status notifications

* Migrate Follow and FollowRequest notifications

* Migrate report notifications

* Convert onViewThread to use the adapter data

* Convert onViewMedia to use the adapter data

* Convert onMore to use the adapter data

* Convert onReply to use the adapter data

* Convert NotificationViewData to Kotlin

* Re-implement the reblog functionality

- Move reblogging in to the view model
- Update the UI via the adapter's `snapshot()` and `notifyItemChanged()`
  methods

* Re-implement the favourite functionality

Same approach as reblog

* Re-implement the bookmark functionality

Same approach as reblog

* Add TODO re StatusActionListener interface

* Add TODO re event handling

* Re-implementing the voting functionality

* Re-implement viewing hidden content

- Hidden media
- Content behind a content warning

* Add a TODO re pinning

* Re-implement "Show more" / "Show less"

* Delete unused updateStatus() function

* Comment out the scroll listener for the moment

* Re-implement applying filters to notifications

Introduce `NotificationsRepository`, to provide access to the notifications
stream.

When changing the filters the flow is as follows:

- User clicks "Apply" in the fragment.

- Fragment calls `viewModel.accept()` with a `UiAction.ApplyFilter` (new
  class).

- View model maintains a private flow of incoming UI actions. The new action
  is emitted to that flow.

- In view model, `notificationFilter` waits for `.ApplyFilter` actions, and
  ensures the filter is saved, then emits it.

- In view model, `pagingDataFlow` waits for new items from
  `notificationsFilter` and fetches the notifications from the repository in
  response. The repository provides `Notification`, so the model maps them to
  `NotificationViewData.Concrete` for display by the adapter.

- In view model the UI state also waits for new items from
  `notificationsFilter` and emits a new `UiState` every time the filter is
  changed.

When opening the fragment for the first time:

- All of the above machinery, but `notificationFilter` also fetches the filter
  from the active account and emits that first. This triggers the first fetch
  and the first update of `uiState`.

Also:

- Add TODOs for functionality that is not implemented yet

- Delete a lot of dead code from NotificationsFragment

* Include important preference values in `uiState`

Listen to the flow of eventHub events, filtered to preference changes that
are relevant to the notification view.

When preferences change (or when the view model starts), fetch the current
values, and include them in `uiState`.

Remove preference handling from `NotificationsFragment`, and just use
the values from `uiState`.

Adjust how the `useAbsoluteTime` preference is handled. The previous code
loaded new content (via a diffutil) in to the adapter, which would trigger
a re-binding of the timestamp.

As the adapter content is immutable, the new code simply triggers a
re-binding of the views that are currently visible on screen.

* Update UI in response to different load states

Notifications can be loaded at the top and bottom of the timeline. Add a
new layout to show the progress of these loads, and any errors that can
occur.

Catch network errors in `NotificationsPagingSource` and convert to
`LoadState.Error`.

Add a header/footer to the notifications list to show the load state.

Collect the load state from the adapter, use this to drive the visibility
of different views.

* Save and restore the last read notification ID

Use this when fetching notifications, to centre the list around the
notification that was last read.

* Call notifyItemRangeChanged with the correct parameters

* Don't try and save list position if there are no items in the list

* Show/hide the "Nothing to see" view appropriately

* Update comments

* Handle the case where the notification key no longer exists

* Re-implement support for showMediaPreview and other settings

* Re-implement "hide FAB when scrolling" preference

* Delete dead code

* Delete Notifications Adapater and Placeholder types

* Remove NotificationViewData.Concrete subclass

Now there's no Placeholder, everything is a NotificationViewData.

* Improve how notification pages are loaded if the first notification is missing or filtered

* Re-implement clear notifications, show errors

* s/default/from/

* Add missing headers

* Don't process bookmarking via EventHub

- Initiating a bookmark is triggered by the fragment sending a
  StatusUiAction.Bookmark
- View model receives this, makes API call, waits for response, emits either
  a success or failure state
- Fragment collects success/failure states, updates the UI accordingly

* Don't process favourites via EventHub

* Don't process reblog via EventHub

* Don't process poll votes with EventHub

This removes EventHub from the fragment

* Respond to follow requests via the view model

* Docs and cleanup

* Typo and editing pass

* Minor edits for clarity

* Remove newline in diagram

* Reorder sequence diagram

* s/authorize/accept/

* s/pagingDataFlow/pagingData/

* Add brief KDoc

* Try and fetch a full first page of notifications

* Call the API method `notifications` again

* Log UI errors at the point of handling

* Remove unused variable

* Replace String.format() with interpolation

* Convert NotificationViewData to data class

* Rename copy() to make(), to avoid confusion with default copy() method

* Lint

* Update app/src/main/res/layout/simple_list_item_1.xml

* Update app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingAdapter.kt

* Update app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt

* Update app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.kt

* Update app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.kt

* Initial NotificationsViewModel tests

* Add missing import

* More tests, some cleanup

* Comments, re-order some code

* Set StateRestorationPolicy.PREVENT_WHEN_EMPTY

* Mark clearNotifications() as "suspend"

* Catch exceptions from clearNotifications and emit

* Update TODOs with explanations

* Ensure initial fetch uses a null ID

* Stop/start collecting pagingData based on the lifecycle

* Don't hide the list while refreshing

* Refresh notifications on mutes and blocks

* Update tests now clearNotifications is a suspend fun

* Add "Refresh" menu to NotificationsFragment

* Use account.name over account.displayName

* Update app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.kt

Co-authored-by: Konrad Pozniak <connyduck@users.noreply.github.com>

* Mark layoutmanager as lateinit

* Mark layoutmanager as lateinit

* Refactor generating UI text

* Add Copyright header

* Correctly apply notification filters

* Show follow request header in notifications

* Wait for follow request actions to complete, so the reqeuest is sent

* Remove duplicate copyright header

* Revert copyright change in unmodified file

* Null check response body

* Move NotificationsFragment to component.notifications

* Use viewlifecycleowner.lifecyclescope

* Show notification filter as a dialog rather than a popup window

The popup window:

- Is inconsistent UI
- Requires a custom layout
- Didn't play nicely with viewbinding

* Refresh adapter on block/mute

* Scroll up slightly when new content is loaded

* Restore progressbar

* Lint

* Update app/src/main/res/layout/simple_list_item_1.xml

---------

Co-authored-by: Konrad Pozniak <connyduck@users.noreply.github.com>
2023-03-10 20:12:33 +01:00
renovate[bot] ce6a350267
chore(deps): update dependency gradle to v8.0.2 (#3372)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-03-05 16:56:19 +01:00
Konrad Pozniak 4c8f63b3a6
Merge pull request #3407 from nailyk-weblate/weblate-tusky-tusky
Translations update from Weblate
2023-03-02 19:26:37 +01:00
Nik Clayton 7261fa0b18
By explicit about the `firstStrong` text direction on UGC (#3328)
`activity_account` sets the root text direction to `anyRtl`.

This is OK for UI elements, but can be a problem for user generated content
(UGC) that may contain bidirectional text.

Fix this by explicitly restoring the default behaviour, `firstStrong`, on
views in `activity_account` that can show UGC.

Fixes https://github.com/tuskyapp/Tusky/issues/3294
2023-03-02 18:30:26 +01:00
Nik Clayton a792d2c0d6
Enable parallel GC for Gradle builds (#3404)
* Enable parallel GC for Gradle builds

Per https://developer.android.com/studio/build/optimize-your-build#experiment-with-the-jvm-parallel-garbage-collector

I benchmarked this, and p75 incremental build time dropped from 33s to 30s.

https://github.com/gradle/gradle/issues/19750 means that if `org.gradle.jvmargs` is set any unchanged default values are lost, so include those too.

* Update gradle.properties
2023-03-01 21:07:57 +01:00
Goooler ca29ee2b0b
Use more orEmpty extensions (#3399)
https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.text/or-empty.html
https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.collections/or-empty.html
2023-03-01 21:06:55 +01:00
Ihor Hordiichuk 7fa0c51599 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (567 of 567 strings)

Co-authored-by: Ihor Hordiichuk <igor_ck@outlook.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/uk/
Translation: Tusky/Tusky
2023-03-01 20:00:31 +00:00
Sveinn í Felli 4afca08ac8 Translated using Weblate (Icelandic)
Currently translated at 100.0% (566 of 566 strings)

Co-authored-by: Sveinn í Felli <sv1@fellsnet.is>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/is/
Translation: Tusky/Tusky
2023-03-01 20:00:31 +00:00
Paul Sanz eb2a8abc23 Translated using Weblate (Spanish)
Currently translated at 100.0% (566 of 566 strings)

Co-authored-by: Paul Sanz <registro@polkillas.net>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/es/
Translation: Tusky/Tusky
2023-03-01 20:00:31 +00:00
Konrad Pozniak ed188783de
include card and collapsed state in instant expanded change (#3394) 2023-03-01 20:00:56 +01:00
Konrad Pozniak 816dc0cbbc
make sure all timeline database operations run on a background thread (#3391) 2023-03-01 20:00:19 +01:00
Konrad Pozniak b8c77a795c
prevent adding multiple tabs of the same type (#3390)
* prevent adding multiple tabs of the same type

* use Objects.hash
2023-03-01 19:59:40 +01:00
Nik Clayton 1b6108ca94
Add "Refresh" accessibility menu (#3121)
* Add "Refresh" accessibility menu to TimelineFragment

Per https://developer.android.com/reference/androidx/swiperefreshlayout/widget/SwipeRefreshLayout
the layout does not provide accessibility events, and a menu item should be
provided as an alternative method for refreshing the content.

In `TimelineFragment`:
- Implement the `MenuProvider` interface so it can populate the action bar
  menu in activities that host the fragment
- Create a "Refresh" menu item, and refresh the state when it is selected

`MainActivity` has to change how the menu is created, so that fragments
can add items to it.

In `MainActivity`:
- Call `setSupportActionBar` so `mainToolbar` participates in menus
- Implement the `MenuProvider` interface, and move menu creation there
- Set the title via supportActionBar

* Never show the refresh item as a menubar action

Per guidelines in https://developer.android.com/develop/ui/views/touch-and-input/swipe/add-swipe-interface#AddRefreshAction

* Add "Refresh" menu item for AccountMediaFragment

Also, fix the colour of the refresh progress indicator

* Implement "Refresh" for AnnouncementsActivity

* Add "Refresh" menu for ConversationsFragment

* Keep the tabs adapter over the life of the viewpager

Make `tabs` `var` instead of `val` in `MainPagerAdapter` so it can be updated
when tabs change.

Then detach the `tabLayoutMediator`, update the tabs, and call
`notifyItemRangeChanged` in `setupTabs()`.

This fixes a bug (not sure if it's this code, or in ViewPager2) where
assigning a new adapter to the view pager seemed to result in a leak of one
or more fragments. This wasn't user-visible, but it's a leak, and it becomes
user-visible when fragments want to display menus.

This also fixes two other bugs:

1. Be on the left-most tab. Scroll down a bit. Then modify the tabs at
   "Account preferences > tabs", but keep the left-most tab as-is.

   Then go back to MainActivity. Your reading position in the left-most
   tab has been jumped to the top.

2. Be on any non-left-most tab. Then modify the tab list by reordering tabs
   (adding/removing tabs is also OK).

   Then go back to MainActivity. Your tab selection has been overridden,
   and the left-most tab has been selected.

Because the fragments are not destroyed unnecessarily your reading position
is retained. And it remembers the tab you had selected, and as long as that
tab is still present you will be returned to it, even if it's changed
position in the list.

Fixes https://github.com/tuskyapp/Tusky/issues/3251

* Add "Refresh" menu for ScheduledStatusActivity

* Lint

* Add "Refresh" menu for SearchFragment / SearchActivity

* Explicitly set the searchview width

Using "collapseActionView" requires the user to press "Back" twice to exit
the activity, which is not acceptable.

* Move toolbar handling in to ViewThreadActivity

Previous code had the toolbar in the fragment's layout. Refactor to make
consistent with other activities, and move the toolbar in to the activity
layout.

Implement MenuProvider in ViewThreadFragment to adjust the menu in the
activity.

* Add "Refresh" menu to ViewThreadFragment

* Implement "Refresh" for ViewEditsFragment

* Lint

* Add "Refresh" menu to ReportStatusesFragment

* Add "Refresh" menu to NotificationsFragment

* Rename menu resource files

Be consistent with the layout resource files, which have an activity/fragment
prefix, then the lower_snake_case name of the activity or fragment it's for.

* Only enable refresh menu if swiptorefresh is enabled

Some timelines don't have swipetorefresh enabled (e.g., those shown on
AccountActivity). In those cases don't add the refresh menu, rely on the
hosting activity to provide it.

Update AccountActivity to provide the refresh menu item.
2023-03-01 19:58:18 +01:00
Konrad Pozniak f9588b48e2
Merge pull request #3403 from nailyk-weblate/weblate-tusky-tusky
Translations update from Weblate
2023-03-01 19:45:18 +01:00
Mārtiņš Bruņenieks 29fc449f04 Translated using Weblate (Latvian)
Currently translated at 95.0% (538 of 566 strings)

Co-authored-by: Mārtiņš Bruņenieks <martinsb@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/lv/
Translation: Tusky/Tusky
2023-02-28 21:35:54 +00:00
renovate[bot] e98925b426
chore(deps): update dependency com.android.application to v7.4.2 (#3395)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-02-28 22:05:32 +01:00
renovate[bot] e46e2720a4
Update dependency androidx.arch.core:core-testing to v2.2.0 (#3378)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-02-28 21:35:31 +01:00
Nik Clayton ddc4d76c41
Lazily load activities in Robolectric tests (#3397)
Not all Robolectric tests interact with activities, so lazy loading the activity can speed them up.

I benchmarked the impact with gradle-profiler. Each test run performed 6 warmup runs, and then 10 benchmarked runs with and without this change.

Without this change (all values rounded to nearest second):
Mean: 151
Median: 149
P75: 157
StdDev: 10

With this change:
Mean: 127
Median: 125
P75: 130
StdDev: 7

So a ~ 18% reduction in median and P75 times,
2023-02-28 21:28:05 +01:00
UlrichKu b4d10c9613
3204: Add an account based preference store (#3205)
* 3204: Add an account based preference store

* 3204: (related) reformat a bit, add todo

* 3204: Use the preference data store for all three account settings

* 3204: Move event handling to account settings handler

* 3204: Correct includes

* 3204: Appease linter

* 3204: Appease linter again

* 3204: Add an account based preference store

* 3204: Use the preference data store for all three account settings

* 3204: Move event handling to account settings handler

* 3204: Correct includes

* 3204: Add general "preference upgrade loop stepper"; use it for removing obsolete account settings (in shared)

* 3204: Add missing spaces

* 3204: Key is non-nullable

* 3204: Upgrade to new settings migration code

* 3204: Remove (commented) DI code
2023-02-27 14:07:28 +01:00
Konrad Pozniak daa67632df
Merge pull request #3381 from nailyk-weblate/weblate-tusky-tusky
Translations update from Weblate
2023-02-27 13:11:04 +01:00
Konrad Pozniak 49b253922a
Merge pull request #3369 from nailyk-weblate/weblate-tusky-tusky-app
Translations update from Weblate
2023-02-27 13:10:37 +01:00
Ricard Torres 7751b88a57 Translated using Weblate (Catalan)
Currently translated at 100.0% (566 of 566 strings)

Co-authored-by: Ricard Torres <ricard@ricard.dev>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/ca/
Translation: Tusky/Tusky
2023-02-27 12:03:21 +00:00
XoseM 46d00a3108 Translated using Weblate (Galician)
Currently translated at 100.0% (566 of 566 strings)

Co-authored-by: XoseM <xosem@disroot.org>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/gl/
Translation: Tusky/Tusky
2023-02-27 12:03:21 +00:00
Garutmaan Garuda 81f3474583 Translated using Weblate (Sanskrit)
Currently translated at 88.8% (503 of 566 strings)

Co-authored-by: Garutmaan Garuda <garutmaangaruda@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/sa/
Translation: Tusky/Tusky
2023-02-27 12:03:21 +00:00
Quentí 03640c8ce7 Translated using Weblate (Occitan)
Currently translated at 100.0% (566 of 566 strings)

Co-authored-by: Quentí <quentinantonin@free.fr>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/oc/
Translation: Tusky/Tusky
2023-02-27 12:03:21 +00:00
UlrichKu 6b9db02c08
3311 load more more prominent (#3376)
* 3311: Add a zig-zag line to top and bottom of "load more"

* 3311: Remove unneeded extensions

* 3311: Use a simple gradient instead of zigzag line

* 3311: Use a simple gradient drawable (remove custom view)

* 3311: Remove gradient lines
2023-02-27 11:33:11 +01:00
Danial Behzadi efb2c031a3 Translated using Weblate (Persian)
Currently translated at 100.0% (20 of 20 strings)

Translation: Tusky/Tusky description
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky-app/fa/
2023-02-27 10:25:55 +00:00
Nik Clayton 10f983c953
Ignore clicks outside the start/end of a line (#3380)
* Ignore clicks outside the start/end of a line

`LinkMovementMethod` has a bug in its calculation of the clickable width of a span on a line. If the span is the last thing on the line the clickable area extends to the end of the view. So the user can tap what appears to be whitespace and open a link.

Previous code tried to fix this by adding a zero-width space after the link so that `LinkMovementMethod` wouldn't consider it empty. However the ZWS was selected by copy/paste operations, resulting in junk results if users tried to copy the link.

Fix this by subclassing `LinkMovementMethod` and fixing the click detection code to ignore clicks that are outside the bounds of the line that was clicked on.

Remove the code that adds the ZWS.

Fixes https://github.com/tuskyapp/Tusky/issues/1567

* Assume arguments are all non-null

* Use `object` for singleton

* getInstance as a one-liner
2023-02-27 08:54:51 +01:00
Konrad Pozniak 9340e7a6f4
update Glide to 4.15.0 (#3384) 2023-02-27 08:54:26 +01:00
renovate[bot] b3f173b2b0
fix(deps): update dependency org.mockito:mockito-inline to v5 (#3373)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-02-27 08:54:07 +01:00
Goooler 000681c702
Add extra proguard rules for OkHttp (#3350)
* Add extra proguard rules for OkHttp

339732e3a1/okhttp/src/jvmMain/resources/META-INF/proguard/okhttp3.pro (L11-L14)

* Update proguard-rules.pro
2023-02-25 21:40:13 +01:00
Goooler 2da7bc5bc8
Use TypedArray.use to obtain attrs (#3349)
17346638ff/core/core-ktx/src/main/java/androidx/core/content/res/TypedArray.kt (L227-L236)
2023-02-25 21:30:52 +01:00
renovate[bot] 92bd2153e9
Update dependency org.mockito:mockito-inline to v4.11.0 (#3365)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-02-25 21:28:09 +01:00
fruyek d4eb744241
Perform unicode text isolation on user names in post edit history view (#3342) 2023-02-25 21:27:54 +01:00
Levi Bard 2e189a17dc
When looking up fediverse urls, verify that account results returned match the input query. (#3341)
Fixes #2804
2023-02-25 21:27:26 +01:00
Nik Clayton fda8c80949
Use an explicit SCHEMA_VERSION instead of BuildConfig.VERSION_CODE (#3324)
* Use an explicit SCHEMA_VERSION instead of BuildConfig.VERSION_CODE

Every nightly release has a new BuildConfig.VERSION_CODE, so the previous
code would not do the right thing.

Require the schema version to be explicitly set. While I'm here, provide
a clear set of guidelines as to what has to happen when the schema changes.

* Improve documentation links
2023-02-25 21:22:49 +01:00
UlrichKu 4ab305f3dc
2528: Do not remove notifications on general resume (#3312)
* 2528: Do not remove notifications on general resume

* 2528: Have notification removal in the right onResume
2023-02-25 21:18:03 +01:00
Levi Bard f2b07196e6
Improve language list prioritization. (#3293)
Partially addresses #3277
2023-02-25 21:15:21 +01:00
Nik Clayton 4a0251800d
Fix lifecycle handling bug (#3319)
Fragments can go `onCreate` -> `onCreateView` -> `onViewCreated` -> `onDestroyView` without transitioning through `onStart`.

The previous code assumed `onStart` was always called. 

Se https://itnext.io/an-update-to-the-fragmentviewbindingdelegate-the-bug-weve-inherited-from-autoclearedvalue-7fc0a89fcae1
2023-02-25 21:06:22 +01:00
Goooler c6f7ecdb5b
Gradle 8.0.1 (#3338)
https://docs.gradle.org/8.0/release-notes.html
2023-02-25 20:59:39 +01:00
renovate[bot] 9eec1ab5c0
Update emoji2 to v1.2.0 (#3368)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-02-25 20:38:14 +01:00
renovate[bot] a0ee3072f0
Update dependency org.mockito.kotlin:mockito-kotlin to v4.1.0 (#3364)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-02-25 20:37:40 +01:00
renovate[bot] 77dbf3c1a9
Update dependency com.google.android.material:material to v1.8.0 (#3361)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-02-25 20:36:18 +01:00
Eric Frohnhoefer 9e0ff78fb4
Fix media controller UI not showing during audio playback (#3286)
* Update ViewVideoFragment.kt

Testing
 - Open audio attachment: https://solarpunk.moe/@vv/109562659215759090
 - Ensure media control UI and alt text is shown once playback starts

Fixes #3261

* Fix commit issue

* Fix spacing
2023-02-23 19:41:16 +01:00
Nik Clayton 70092c8de2
Make "Up" and "Overflow" menu icons more visible in AccountProfile (#3272)
* Make "Up" and "Overflow" menu icons more visible in AccountProfile

The toolbar in AccountProfile is transparent, so any profile image the user
has chosen is shown under it.

This makes the "Up" and "Overflow" menu icons also have transparent
backgrouns.

Consequently, they can be hard to spot, or possibly invisible, on backgrounds
that are very dark or very light.

Fix this by compositing the icons in a LayerDrawable, with a circular
background identical to the surface colour. This ensures they stand out
against the background image, and blend in when the user scrolls.

* Get and reuse the background drawable

* Apply a smidgen of transparency
2023-02-23 19:30:27 +01:00
renovate[bot] 60fd9cf0e7
Update dependency com.google.code.gson:gson to v2.10.1 (#3362)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-02-23 17:34:04 +01:00
renovate[bot] 142fe4b743
Update dependency io.reactivex.rxjava3:rxandroid to v3.0.2 (#3348)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-02-21 20:17:45 +01:00
Konrad Pozniak d85704d60d
Merge pull request #3346 from nailyk-weblate/weblate-tusky-tusky
Translations update from Weblate
2023-02-21 20:14:19 +01:00
renovate[bot] 62c7d63131
Update dependency com.github.CanHub:Android-Image-Cropper to v4.3.2 (#3347)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-02-21 19:43:51 +01:00
renovate[bot] b35cc1fd6b
Update dependency com.github.penfeizhou.android.animation:glide-plugin to v2.24.0 (#3358)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-02-21 19:41:53 +01:00
renovate[bot] fc71e398d5
Update dependency com.github.UnifiedPush:android-connector to v2.1.1 (#3357)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-02-21 19:39:49 +01:00
renovate[bot] 01eefd94a0
Update dependency androidx.browser:browser to v1.5.0 (#3356)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-02-21 19:39:39 +01:00
renovate[bot] b62fc9bf07
Update dagger to v2.45 (#3355)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-02-21 19:37:44 +01:00
renovate[bot] 53f7afd9ee
Update androidx-work to v2.8.0 (#3354)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-02-21 19:37:33 +01:00
renovate[bot] aa14013adc
Update dependency io.reactivex.rxjava3:rxjava to v3.1.6 (#3353)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-02-21 19:37:07 +01:00
Danial Behzadi 837a91e12c Translated using Weblate (Persian)
Currently translated at 100.0% (566 of 566 strings)

Co-authored-by: Danial Behzadi <dani.behzi@ubuntu.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/fa/
Translation: Tusky/Tusky
2023-02-21 18:37:00 +00:00
Deleted User 766dab2173 Translated using Weblate (German)
Currently translated at 100.0% (566 of 566 strings)

Co-authored-by: Deleted User <noreply+263@weblate.org>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/de/
Translation: Tusky/Tusky
2023-02-21 18:37:00 +00:00
Mārtiņš Bruņenieks 5e3d6a8f97 Translated using Weblate (Latvian)
Currently translated at 95.0% (536 of 564 strings)

Co-authored-by: Mārtiņš Bruņenieks <martinsb@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/lv/
Translation: Tusky/Tusky
2023-02-21 18:37:00 +00:00
XoseM d82200569e Translated using Weblate (Galician)
Currently translated at 100.0% (564 of 564 strings)

Co-authored-by: XoseM <xosem@disroot.org>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/gl/
Translation: Tusky/Tusky
2023-02-21 18:37:00 +00:00
Garutmaan Garuda 6923a4e06d Translated using Weblate (Sanskrit)
Currently translated at 85.1% (480 of 564 strings)

Co-authored-by: Garutmaan Garuda <garutmaangaruda@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/sa/
Translation: Tusky/Tusky
2023-02-21 18:37:00 +00:00
Hồ Nhất Duy 78a6bde25a Translated using Weblate (Vietnamese)
Currently translated at 100.0% (566 of 566 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (564 of 564 strings)

Co-authored-by: Hồ Nhất Duy <mastoduy@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/vi/
Translation: Tusky/Tusky
2023-02-21 18:37:00 +00:00
Ihor Hordiichuk 2b92fabdc0 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (566 of 566 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (564 of 564 strings)

Co-authored-by: Ihor Hordiichuk <igor_ck@outlook.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/uk/
Translation: Tusky/Tusky
2023-02-21 18:37:00 +00:00
Sveinn í Felli e6c5f61da6 Translated using Weblate (Icelandic)
Currently translated at 100.0% (564 of 564 strings)

Co-authored-by: Sveinn í Felli <sv1@fellsnet.is>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/is/
Translation: Tusky/Tusky
2023-02-21 18:37:00 +00:00
Eric afebee1f27 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (566 of 566 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (564 of 564 strings)

Co-authored-by: Eric <alchemillatruth@purelymail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/zh_Hans/
Translation: Tusky/Tusky
2023-02-21 18:37:00 +00:00
Manuel b0df135b99 Translated using Weblate (Italian)
Currently translated at 99.2% (560 of 564 strings)

Co-authored-by: Manuel <mannivuwiki@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/it/
Translation: Tusky/Tusky
2023-02-21 18:37:00 +00:00
Gera, Zoltan 94a4926259 Translated using Weblate (Hungarian)
Currently translated at 100.0% (564 of 564 strings)

Co-authored-by: Gera, Zoltan <gerazo@manioka.hu>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/hu/
Translation: Tusky/Tusky
2023-02-21 18:37:00 +00:00
Paul Sanz 6a3cc8cbd2 Translated using Weblate (Spanish)
Currently translated at 100.0% (564 of 564 strings)

Co-authored-by: Paul Sanz <registro@polkillas.net>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/es/
Translation: Tusky/Tusky
2023-02-21 18:37:00 +00:00
Enzo Martín Segovia 3a151b7660 Translated using Weblate (Spanish)
Currently translated at 100.0% (564 of 564 strings)

Co-authored-by: Enzo Martín Segovia <enzo_seg@outlook.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/es/
Translation: Tusky/Tusky
2023-02-21 18:37:00 +00:00
Deleted User 37440b333e Translated using Weblate (German)
Currently translated at 100.0% (564 of 564 strings)

Co-authored-by: Deleted User <noreply+259@weblate.org>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/de/
Translation: Tusky/Tusky
2023-02-21 18:37:00 +00:00
puf fe2cc4a8e5 Translated using Weblate (Welsh)
Currently translated at 100.0% (564 of 564 strings)

Co-authored-by: puf <puffinux@tutanota.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/cy/
Translation: Tusky/Tusky
2023-02-21 18:37:00 +00:00
Newidyn dd89fe53c4 Translated using Weblate (Welsh)
Currently translated at 100.0% (564 of 564 strings)

Co-authored-by: Newidyn <grugallt@protonmail.ch>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/cy/
Translation: Tusky/Tusky
2023-02-21 18:37:00 +00:00
Ricard Torres b5e33e5730 Translated using Weblate (Catalan)
Currently translated at 100.0% (564 of 564 strings)

Co-authored-by: Ricard Torres <ricard@ricard.dev>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/ca/
Translation: Tusky/Tusky
2023-02-21 18:37:00 +00:00
ButterflyOfFire 640a6faaae Translated using Weblate (French)
Currently translated at 84.3% (476 of 564 strings)

Translated using Weblate (Arabic)

Currently translated at 97.6% (551 of 564 strings)

Co-authored-by: ButterflyOfFire <butterflyoffire@protonmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/ar/
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/fr/
Translation: Tusky/Tusky
2023-02-21 18:37:00 +00:00
Nik Clayton 2974265c4a
Use the adapter position when responding to clicks on followed tags (#3334)
This ensures that the position is valid w.r.t. to the backing array.

Fixes https://github.com/tuskyapp/Tusky/issues/3333
2023-02-20 20:36:37 +01:00
renovate[bot] 8f3869d42e
Update dependency androidx.exifinterface:exifinterface to v1.3.6 (#3344)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-02-20 20:36:22 +01:00
renovate[bot] ac87482e7a
Update dependency androidx.appcompat:appcompat to v1.6.1 (#3343)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-02-20 20:36:11 +01:00
Nik Clayton 15fb8ad43f
Update avatar size/placement for follow requests (#3280)
Use the same icon size/placement as other notifications.

Fixes https://github.com/tuskyapp/Tusky/issues/3279
2023-02-20 20:30:32 +01:00
Goooler 6cc79c8d75
Use unsafeLazy to simplify thread unsafe lazy initializations (#3276) 2023-02-20 20:14:54 +01:00
Nik Clayton 27f6976295
Add FAB to follow new hashtags from FollowedTagsActivity (#3275)
- Add a FAB for user interaction (hide on scroll if appropriate)
- Show a dialog to collect the new hashtag
- Autocomplete hashtags the same as when composing a status
2023-02-20 20:14:16 +01:00
Levi Bard 41d493e72a
Allow viewing of the account header image. (#3274)
Fixes #3254
2023-02-20 20:06:50 +01:00
Goooler cfea5700b0
Code cleanups (#3264)
* Kotlin 1.8.10

https://github.com/JetBrains/kotlin/releases/tag/v1.8.10

* Migrate onActivityCreated to onViewCreated

* More final modifiers

* Java Cleanups

* Kotlin cleanups

* More final modifiers

* Const value TOOLBAR_HIDE_DELAY_MS

* Revert
2023-02-20 19:58:37 +01:00
renovate[bot] 0baf3fe467
Add renovate.json (#3263)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-02-20 19:55:09 +01:00
Nik Clayton 52c98749e6
Fetch list title from second values in arguments (#3327)
Previous code was:

```
for (i in tabs.indices) {
    // ...
    if (tabs[i].id == LIST) {
        tab.contentDescription = tabs[i].arguments[1]
    } else {
        tab.setContentDescription(tabs[i].text)
    }
    // ...
```

When I converted it over, `i` was replaced with `position`, but I misread `tab.contentDescription = tabs[i].arguments[1]` as `tab.contentDescription = tabs[i].arguments[i]`.

Put the `1` back.
2023-02-16 20:43:44 +01:00
mcclure ab364712fe
Previous attempt to fix git sha on non-git build broke git build. (#3322)
This uses unusual code provided by Mikhail Lopatkin of Gradle Inc:
https://github.com/gradle/gradle/issues/23914#issuecomment-1431909019
It wraps the git sha function in a "value source". This allows it to interact correctly with configuration caching.
Because the code is longer than before, it is now broken out into its own file getGitSha.gradle.
2023-02-16 20:20:52 +01:00
Conny Duck b760ebe004 update tusky_banner.xcf 2023-02-15 20:05:45 +01:00
Nik Clayton 196ebdb386
Keep the tabs adapter over the life of the viewpager (#3255)
Make `tabs` `var` instead of `val` in `MainPagerAdapter` so it can be updated
when tabs change.

Then detach the `tabLayoutMediator`, update the tabs, and call
`notifyItemRangeChanged` in `setupTabs()`.

This fixes a bug (not sure if it's this code, or in ViewPager2) where
assigning a new adapter to the view pager seemed to result in a leak of one
or more fragments. This wasn't user-visible, but it's a leak, and it becomes
user-visible when fragments want to display menus.

This also fixes two other bugs:

1. Be on the left-most tab. Scroll down a bit. Then modify the tabs at
   "Account preferences > tabs", but keep the left-most tab as-is.

   Then go back to MainActivity. Your reading position in the left-most
   tab has been jumped to the top.

2. Be on any non-left-most tab. Then modify the tab list by reordering tabs
   (adding/removing tabs is also OK).

   Then go back to MainActivity. Your tab selection has been overridden,
   and the left-most tab has been selected.

Because the fragments are not destroyed unnecessarily your reading position
is retained. And it remembers the tab you had selected, and as long as that
tab is still present you will be returned to it, even if it's changed
position in the list.

Fixes https://github.com/tuskyapp/Tusky/issues/3251
2023-02-15 19:17:59 +01:00
Konrad Pozniak 53d4a02bdc
Merge pull request #3265 from nailyk-weblate/weblate-tusky-tusky-app
Translations update from Weblate
2023-02-14 22:09:30 +01:00
Konrad Pozniak ff5792db44
Merge pull request #3318 from nailyk-weblate/weblate-tusky-tusky
Translations update from Weblate
2023-02-14 21:37:43 +01:00
XoseM d85f5078a5 Translated using Weblate (Galician)
Currently translated at 100.0% (20 of 20 strings)

Translation: Tusky/Tusky description
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky-app/gl/
2023-02-14 20:31:25 +00:00
Danial Behzadi 5ba42792af Translated using Weblate (Persian)
Currently translated at 90.0% (18 of 20 strings)

Translation: Tusky/Tusky description
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky-app/fa/
2023-02-14 20:31:25 +00:00
Gera, Zoltan 2253878f8b Translated using Weblate (Hungarian)
Currently translated at 100.0% (20 of 20 strings)

Translation: Tusky/Tusky description
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky-app/hu/
2023-02-14 20:31:25 +00:00
Deleted User 5102f1cee2 Translated using Weblate (German)
Currently translated at 100.0% (20 of 20 strings)

Translation: Tusky/Tusky description
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky-app/de/
2023-02-14 20:31:25 +00:00
Ricard Torres bd19e81d89 Translated using Weblate (Spanish)
Currently translated at 100.0% (20 of 20 strings)

Translation: Tusky/Tusky description
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky-app/es/

Translated using Weblate (Catalan)

Currently translated at 100.0% (20 of 20 strings)

Translation: Tusky/Tusky description
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky-app/ca/
2023-02-14 20:31:25 +00:00
Levi Bard 395e21c956
Add support for updating media description and focus point when editing statuses (#3215)
* Add support for updating media description and focus point when editing statuses

* Don't publish description/focus point updates via the standard api when editing a published post
2023-02-14 21:13:38 +01:00
Konrad Pozniak d34586b7c8
Update Readme (#3304)
* Update Readme

* master -> main
2023-02-14 21:09:16 +01:00
Konrad Pozniak 80dbf6a491
Update Contributing.md (#3303) 2023-02-14 21:09:06 +01:00
XoseM 7c1a0ff5b3 Translated using Weblate (Galician)
Currently translated at 100.0% (560 of 560 strings)

Co-authored-by: XoseM <xosem@disroot.org>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/gl/
Translation: Tusky/Tusky
2023-02-14 20:07:20 +00:00
Hồ Nhất Duy 10c230332b Translated using Weblate (Vietnamese)
Currently translated at 100.0% (560 of 560 strings)

Co-authored-by: Hồ Nhất Duy <mastoduy@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/vi/
Translation: Tusky/Tusky
2023-02-14 20:07:20 +00:00
Aleksandr Yakovlev aa685abe31 Translated using Weblate (Russian)
Currently translated at 77.3% (433 of 560 strings)

Co-authored-by: Aleksandr Yakovlev <keloero@oreolek.me>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/ru/
Translation: Tusky/Tusky
2023-02-14 20:07:20 +00:00
Eduardo 2dee16ac02 Translated using Weblate (Portuguese (Brazil))
Currently translated at 96.0% (538 of 560 strings)

Co-authored-by: Eduardo <edu200399lim@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/pt_BR/
Translation: Tusky/Tusky
2023-02-14 20:07:20 +00:00
Danial Behzadi 8ece903401 Translated using Weblate (Persian)
Currently translated at 100.0% (564 of 564 strings)

Translated using Weblate (Persian)

Currently translated at 100.0% (560 of 560 strings)

Co-authored-by: Danial Behzadi <dani.behzi@ubuntu.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/fa/
Translation: Tusky/Tusky
2023-02-14 20:07:20 +00:00
Deleted User 969e4e9f1a Translated using Weblate (German)
Currently translated at 100.0% (560 of 560 strings)

Co-authored-by: Deleted User <noreply+256@weblate.org>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/de/
Translation: Tusky/Tusky
2023-02-14 20:07:20 +00:00
mcclure 4dc7919ec0
Fix sporadic failure in non-git build (using patch by @nikclayton) (#3307) 2023-02-14 20:15:42 +01:00
David Edwards 98e5363692
Add trending tags (#3149)
* Add initial feature for viewing trending graphs. Currently only views hash tag trends.

Contains API additions, tab additions and a set of trending components.

* Add clickable system through a LinkListener. Duplicates a little code from SFragment.

* Add accessibility description.

* The background for the graph should match the background for black theme too.

* Add error handling through a state flow system using existing code as an example.

* Graphing: Use a primary and a secondary line. Remove under line fill. Apply line thickness. Dotted end of line.

* Trending changes: New layout for trending: Cell. Use ViewBinding. Add padding to RecyclerView to stop the FAB from hiding content. Multiple bugs in GraphView resolved. Wide (landscape, for example) will show 4 columns, portrait will show 2. Remove unused base holder class. ViewModel invalidate scoping changed. Some renaming to variables made. For uses and accounts, use longs. These could be big numbers eventually. TagViewHolder renamed to TrendingTagViewHolder.

* Trending changes: Remove old layout. Update cell textsizes and use proper string. Remove bad comment.

* Trending changes: Refresh the main drawer when the tabs are edited. This will allow the trending item to toggle.

* Trending changes: Add a trending activity to be able to view the trending data from the main drawer.

* Trending changes: The title text should be changed to Trending Hashtags.

* Trending changes: Add meta color to draw axis etc. Draw the date boundaries on the graph. Remove dates from each cell and place them in the list as a header. Graphs should be proportional to the highest historical value. Add a new interface to control whether the FAB should be visible (important when switching tabs, the state is lost). Add header to the adapter and viewdata structures. Add QOL extensions for getting the dates from history.

* Trending changes: Refresh FAB through the main activity and FabFragment interface. Trending has no FAB.

* Trending changes: Make graph proportional to the highest usage value. Fixes to the graph ratio calculations.

* Trending changes: KtLintFix

* Trending changes: Remove accidental build gradle change. Remove trending cases. Remove unused progress. Set drawer button addition explicitly to false, leaving the code there for future issue #3010. Remove unnecessary arguments for intent. Remove media preview preferences, there is nothing to preview. No padding between hashtag symbol and text. Do not ellipsize hashtags.

* Trending changes: Use bottomsheet slide in animation helper for opening the hashtag intent. Remove explicit layout height from the XML and apply it to the view holder itself. The height was not being respected in XML.

* Use some platform standards for styling

- Align on an 8dp grid
- Use android:attr for paddingStart and paddingEnd
- Use textAppearanceListItem variants
- Adjust constraints to handle different size containers

* Correct lineWidth calculations

Previous code didn't convert the value to pixels, so it was always displaying
as a hairline stroke, irrespective of the value in the layout file.

While I'm here, rename from lineThickness to lineWidth, to be consistent
with parameters like strokeWidth.

* Does not need to inherit from FabFragment

* Rename to TrendingAdapter

"Paging" in the adapter name is a misnomer here

* Clean up comments, use full class name as tag

* Simplify TrendingViewModel

- Remove unncessary properties
- Fetch tags and map in invalidate()
- emptyList() instead of listOf() for clarity

* Remove line dividers, use X-axis to separate content

Experiment with UI -- instead of dividers between each item, draw an explicit
x-axis for each chart, and add a little more vertical padding, to see if that
provides a cleaner separation between the content

* Adjust date format

- Show day and year
- Use platform attributes for size

* Locale-aware format of numbers

Format numbers < 100,000 by inserting locale-aware separators. Numbers larger
are scaled and have K, M, G, ... etc suffix appended.

* Prevent a crash if viewData is empty

Don't access viewData without first checking if it's empty. This can be the
case if the server returned an empty list for some reason, or the data has
been filtered.

* Filter out tags the user has filtered from their home timeline

Invalidate the list if the user's preferences change, as that may indicate
they've changed their filters.

* Experiment with alternative layout

* Set chart height to 160dp to align to an 8dp grid

* Draw ticks that are 5% the height of the x-axis

* Legend adjustments

- Use tuskyblue for the ticks
- Wrap legend components in a layout so they can have a dedicated background
- Use a 60% transparent background for the legend to retain legibility
  if lines go under it

* Bezier curves, shorter cell height

* More tweaks

- List tags in order of popularity, most popular first
- Make it clear that uses/accounts in the legend are totals, not current
- Show current values at end of the chart

* Hide FAB

* Fix crash, it's not always hosted in an ActionButtonActivity

* Arrange totals vertically in landscape layout

* Always add the Trending drawer menu if it's not a tab

* Revert unrelated whitespace changes

* One more whitespace revert

---------

Co-authored-by: Nik Clayton <nik@ngo.org.uk>
2023-02-14 19:52:11 +01:00
mcclure 8d2ef28028
Small "Building" section in CONTRIBUTING.md. (#3298)
Covers minimum required Android Studio version, and the difference between "blue" and "green".
2023-02-11 13:16:04 +01:00
Conny Duck f6141e3309 revert en-gb string update 2023-02-11 13:07:14 +01:00
Konrad Pozniak 0562a651bf
Merge pull request #3302 from nailyk-weblate/weblate-tusky-tusky
Translations update from Weblate
2023-02-11 13:01:48 +01:00
Paul Sanz a1effeb6d0 Translated using Weblate (Spanish)
Currently translated at 100.0% (560 of 560 strings)

Co-authored-by: Paul Sanz <registro@polkillas.net>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/es/
Translation: Tusky/Tusky
2023-02-11 11:55:18 +00:00
Konrad Pozniak cc0b5ae1a2
Merge pull request #3296 from mcclure/live-without-git-2
Fix non-git build
2023-02-10 22:01:27 +01:00
Konrad Pozniak a6b5e6e0c2
Merge pull request #3262 from nailyk-weblate/weblate-tusky-tusky
Translations update from Weblate
2023-02-10 21:52:25 +01:00
mcc b3db7b91af Increase git hash from 7 back to 8 characters. Clearer comments 2023-02-10 15:33:53 -05:00
mcc fe8ed9f331 Fix merge error 2023-02-10 15:30:12 -05:00
mcc 7f55204998 Fix non-git build, broken by gradle modernization (PR#3171) 2023-02-10 15:22:32 -05:00
Mārtiņš Bruņenieks 94fb2c7487 Translated using Weblate (Latvian)
Currently translated at 94.6% (530 of 560 strings)

Co-authored-by: Mārtiņš Bruņenieks <martinsb@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/lv/
Translation: Tusky/Tusky
2023-02-09 12:35:55 +00:00
Eric bec01cad2c Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (560 of 560 strings)

Co-authored-by: Eric <alchemillatruth@purelymail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/zh_Hans/
Translation: Tusky/Tusky
2023-02-09 12:35:55 +00:00
Gera, Zoltan a6c8c50b02 Translated using Weblate (Hungarian)
Currently translated at 100.0% (560 of 560 strings)

Co-authored-by: Gera, Zoltan <gerazo@manioka.hu>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/hu/
Translation: Tusky/Tusky
2023-02-09 12:35:55 +00:00
Ralf Thees df56e3bdb8 Translated using Weblate (German)
Currently translated at 100.0% (560 of 560 strings)

Co-authored-by: Ralf Thees <ralf@herrthees.de>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/de/
Translation: Tusky/Tusky
2023-02-09 12:35:55 +00:00
Deleted User 174c503642 Translated using Weblate (German)
Currently translated at 100.0% (560 of 560 strings)

Co-authored-by: Deleted User <noreply+253@weblate.org>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/de/
Translation: Tusky/Tusky
2023-02-09 12:35:55 +00:00
Deleted User 3092c9b773 Translated using Weblate (German)
Currently translated at 100.0% (560 of 560 strings)

Co-authored-by: Deleted User <noreply+252@weblate.org>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/de/
Translation: Tusky/Tusky
2023-02-09 12:35:54 +00:00
puf b1d44ce192 Translated using Weblate (English (United Kingdom))
Currently translated at 7.8% (44 of 560 strings)

Translated using Weblate (Welsh)

Currently translated at 100.0% (560 of 560 strings)

Co-authored-by: puf <puffinux@tutanota.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/cy/
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/en_GB/
Translation: Tusky/Tusky
2023-02-09 12:35:54 +00:00
GunChleoc aa6de31a08 Translated using Weblate (Gaelic)
Currently translated at 100.0% (560 of 560 strings)

Co-authored-by: GunChleoc <fios@foramnagaidhlig.net>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/gd/
Translation: Tusky/Tusky
2023-02-09 12:35:54 +00:00
Lin 17893f5564 Translated using Weblate (Chinese (Traditional))
Currently translated at 87.1% (488 of 560 strings)

Co-authored-by: Lin <012akt97@ouo.4wrd.cc>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/zh_Hant/
Translation: Tusky/Tusky
2023-02-09 12:35:54 +00:00
Manuel da21741e98 Translated using Weblate (Italian)
Currently translated at 100.0% (560 of 560 strings)

Translated using Weblate (Italian)

Currently translated at 95.8% (537 of 560 strings)

Co-authored-by: Manuel <mannivuwiki@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/it/
Translation: Tusky/Tusky
2023-02-09 12:35:54 +00:00
Ricard Torres 433ae40659 Translated using Weblate (Catalan)
Currently translated at 100.0% (560 of 560 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (560 of 560 strings)

Translated using Weblate (Catalan)

Currently translated at 90.7% (508 of 560 strings)

Translated using Weblate (Catalan)

Currently translated at 76.7% (430 of 560 strings)

Co-authored-by: Ricard Torres <ricard@ricard.dev>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/ca/
Translation: Tusky/Tusky
2023-02-09 12:35:54 +00:00
Konrad Pozniak 15ff6191ae
Clean up Account adapters (#3202)
* make BlocksAdapter use viewbinding

* remove LoadingFooterViewHolder

* cleanup code

* move accountlist to component packes

* make FollowRequestsHeaderAdapter use viewbinding

* add license to MutesAdapter

* move accountlist to component packages

* use ConstraintLayout in item_blocked_user.xml

* support the bot badge everywhere

* cleanup code

* cleanup xml files

* ktlint

* ktlint
2023-02-04 20:29:13 +01:00
Konrad Pozniak 006f0de05c
Upgrade AndroidX dependencies (#3169)
* upgrade AndroidX dependencies

* use new @Upsert in InstanceDao

* fix crash because of new Room nullchecks

* make TimelineStatusEntity.reblogAccount a val as well
2023-02-04 20:22:29 +01:00
Nik Clayton 16df2bfe87
Be consistent about using sentence-case in notification setting titles (#3187) 2023-02-04 20:19:23 +01:00
Nik Clayton a1494ecc68
Perform preference schema upgrades at startup (#3186)
* Perform preference schema upgrades at startup

Over time it can be desirable to change how preferences are interpreted.

Preferences might be removed, or renamed. Or the default value for a
preference might be changed.

When this happens it's important that users upgrading from one version to
the next (or jumping from one version to several versions ahead) get a
consistent experience. In particular:

- Preferences that no longer exist should be deleted
- Preferences that have been renamed should have the old preference values
  copied over
- If the user used the default value for the preference, and the default has
  changed, the previous default value should be explicitly set as their
  value for the preference

To support this, store a SCHEMA_VERSION as a preference. This is not exposed
to the user, and corresponds to the app's VERSION_CODE.

If the version code does not match the schema version then this is a newer
version of the app with older preferences that may need to be changed.

Those changes will be implemented in `upgradeSharedPreferences`.

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (552 of 552 strings)

Co-authored-by: Gera, Zoltan <gerazo@manioka.hu>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/hu/
Translation: Tusky/Tusky

* Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (552 of 552 strings)

Co-authored-by: Eric <alchemillatruth@purelymail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/zh_Hans/
Translation: Tusky/Tusky

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (552 of 552 strings)

Co-authored-by: Ihor Hordiichuk <igor_ck@outlook.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/uk/
Translation: Tusky/Tusky

* Translated using Weblate (Vietnamese)

Currently translated at 100.0% (552 of 552 strings)

Co-authored-by: Hồ Nhất Duy <mastoduy@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/vi/
Translation: Tusky/Tusky

* Translated using Weblate (Belarusian)

Currently translated at 100.0% (552 of 552 strings)

Co-authored-by: Mikalai <mikalai.hryb@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/be/
Translation: Tusky/Tusky

* Translated using Weblate (Belarusian)

Currently translated at 100.0% (552 of 552 strings)

Co-authored-by: Andrej Zabavin <andre.zabavin@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/be/
Translation: Tusky/Tusky

* Translated using Weblate (Belarusian)

Currently translated at 100.0% (552 of 552 strings)

Co-authored-by: Mikalai <mikalai.hryb@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/be/
Translation: Tusky/Tusky

* Translated using Weblate (Belarusian)

Currently translated at 100.0% (552 of 552 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (552 of 552 strings)

Co-authored-by: xzFantom <xzfantom@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/be/
Translation: Tusky/Tusky

* Translated using Weblate (Japanese)

Currently translated at 91.3% (504 of 552 strings)

Co-authored-by: TAKAHASHI Shuuji <shuuji3@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/ja/
Translation: Tusky/Tusky

* Translated using Weblate (Icelandic)

Currently translated at 100.0% (552 of 552 strings)

Co-authored-by: Sveinn í Felli <sv1@fellsnet.is>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/is/
Translation: Tusky/Tusky

* Translated using Weblate (Belarusian)

Currently translated at 100.0% (552 of 552 strings)

Co-authored-by: Mikalai <mikalai.hryb@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/be/
Translation: Tusky/Tusky

* Lint

---------

Co-authored-by: Gera, Zoltan <gerazo@manioka.hu>
Co-authored-by: Eric <alchemillatruth@purelymail.com>
Co-authored-by: Ihor Hordiichuk <igor_ck@outlook.com>
Co-authored-by: Hồ Nhất Duy <mastoduy@gmail.com>
Co-authored-by: Mikalai <mikalai.hryb@gmail.com>
Co-authored-by: Andrej Zabavin <andre.zabavin@gmail.com>
Co-authored-by: xzFantom <xzfantom@gmail.com>
Co-authored-by: TAKAHASHI Shuuji <shuuji3@gmail.com>
Co-authored-by: Sveinn í Felli <sv1@fellsnet.is>
2023-02-04 20:19:01 +01:00
Goooler 3592318dc1
Modernize a bit (#3171)
* Remove redundant ignore file

* Add .gitattributes

* Generate new wrapper

* Apply plugins in `plugins`

* Adopt new dsl

* Enable stable config cache

* Ignore all build folders

* Enable build scan

* Disable buildFeatures flags by default

* Migrate to nonTransitive R class

* Tweak flags

* Bump AGP to 7.4.0

* Bump deps

* Run `ktlintFormat`

* Add an icon for IDEA to display

* Revert "Bump deps"

This reverts commit bc0d5b69d59f70289d5d5c4887a85e6af23cc662.

* Revert "Enable build scan"

This reverts commit 1568e5e84f1ee51064b3f426b1da0cf35fb67856.

* Remove com.android.library

* Enable Gradle cache

* Enable room incremental build

* Cleanups

* Cleanups

* Add .editorconfig

* Defer clean task

* Migrate `flavorDimensions`

* Merge instance-build.gradle into app's build.gradle

* Declare compileOptions & kotlinOptions

* Bump jvmTarget to 17

* Fix conflicts

* Xmx4g

* Rename output apks

* Revert "Bump jvmTarget to 17"

This reverts commit e4d1543bda65b6d2979ae0712bceee33fa8298a6.
2023-02-04 19:58:53 +01:00
Conny Duck be935655a5 Release 100 2023-02-03 20:10:46 +01:00
Konrad Pozniak 35bbee2b7f
Merge pull request #3258 from nailyk-weblate/weblate-tusky-tusky
Translations update from Weblate
2023-02-03 19:54:35 +01:00
Konrad Pozniak 823c00b02f
Merge pull request #3257 from nailyk-weblate/weblate-tusky-tusky-app
Translations update from Weblate
2023-02-03 19:54:22 +01:00
Connyduck 5d1864452e Translated using Weblate (German)
Currently translated at 96.6% (541 of 560 strings)

Co-authored-by: Connyduck <weblate@connyduck.at>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/de/
Translation: Tusky/Tusky
2023-02-03 18:48:53 +00:00
Connyduck 2461deaa44 Translated using Weblate (German)
Currently translated at 100.0% (20 of 20 strings)

Translation: Tusky/Tusky description
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky-app/de/
2023-02-03 18:48:32 +00:00
puf fb7590e046 Translated using Weblate (Welsh)
Currently translated at 100.0% (20 of 20 strings)

Translation: Tusky/Tusky description
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky-app/cy/
2023-02-03 18:48:32 +00:00
Konrad Pozniak aa1c5be1cb
Merge pull request #3256 from nailyk-weblate/weblate-tusky-tusky
Translations update from Weblate
2023-02-03 19:46:26 +01:00
Mārtiņš Bruņenieks 27b8e78a22 Translated using Weblate (Latvian)
Currently translated at 94.2% (528 of 560 strings)

Co-authored-by: Mārtiņš Bruņenieks <martinsb@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/lv/
Translation: Tusky/Tusky
2023-02-03 18:22:27 +00:00
Connyduck 3d6b5a8f8f Translated using Weblate (German)
Currently translated at 96.6% (541 of 560 strings)

Co-authored-by: Connyduck <weblate@connyduck.at>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/de/
Translation: Tusky/Tusky
2023-02-03 18:22:26 +00:00
puf 4054386566 Translated using Weblate (Welsh)
Currently translated at 100.0% (560 of 560 strings)

Co-authored-by: puf <puffinux@tutanota.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/cy/
Translation: Tusky/Tusky
2023-02-03 18:22:26 +00:00
Konrad Pozniak ff79919d2a
prevent the app from getting into an invalid state when old shortcuts are used (#3252) 2023-02-03 19:21:33 +01:00
Konrad Pozniak 07a4e97e9b
fix crash in ViewThreadFragment (#3250) 2023-02-03 19:21:21 +01:00
Nik Clayton fac62a638e
Set swipe scale factor to 2 (#3249)
Verified by enabling "Pointer Location" in the developer options, and then experimenting to see which angles trigger opens of the nav. drawer, and in a stock "Tabbed Activity" generated in Android Studio (New > Activity > Tabbed Activity) that uses ViewPager2.
2023-02-03 19:21:04 +01:00
Konrad Pozniak 6249dba467
improve avatars next to tabs (#3242) 2023-01-31 19:30:50 +01:00
Konrad Pozniak ebb9d22db4 Merge remote-tracking branch 'weblate/develop' into develop
# Conflicts:
#	app/src/main/res/values-sv/strings.xml
2023-01-31 19:30:29 +01:00
Konrad Pozniak ed06fffc90
Merge pull request #3247 from nailyk-weblate/weblate-tusky-tusky
Translations update from Weblate
2023-01-31 19:27:36 +01:00
Konrad Pozniak f4cfbecc81
Merge pull request #3245 from nailyk-weblate/weblate-tusky-tusky-app
Translations update from Weblate
2023-01-31 19:27:21 +01:00
Mārtiņš Bruņenieks 9d1651c254 Translated using Weblate (Latvian)
Currently translated at 91.9% (515 of 560 strings)

Co-authored-by: Mārtiņš Bruņenieks <martinsb@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/lv/
Translation: Tusky/Tusky
2023-01-31 18:22:07 +00:00
Elias Mårtenson d1c9f4bb5d Translated using Weblate (Swedish)
Currently translated at 99.1% (555 of 560 strings)

Translated using Weblate (Swedish)

Currently translated at 98.0% (549 of 560 strings)

Co-authored-by: Elias Mårtenson <elias@dhsdevelopments.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/sv/
Translation: Tusky/Tusky
2023-01-31 18:22:07 +00:00
Eduardo d24357eb4c Translated using Weblate (Portuguese (Brazil))
Currently translated at 95.7% (536 of 560 strings)

Co-authored-by: Eduardo <edu200399lim@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/pt_BR/
Translation: Tusky/Tusky
2023-01-31 18:22:07 +00:00
Przemysław Wilde 577d19aff1 Translated using Weblate (Polish)
Currently translated at 99.8% (559 of 560 strings)

Co-authored-by: Przemysław Wilde <przemyslawwilde@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/pl/
Translation: Tusky/Tusky
2023-01-31 18:22:07 +00:00
Grzegorz Cichocki 4ddab53802 Translated using Weblate (Polish)
Currently translated at 99.8% (559 of 560 strings)

Co-authored-by: Grzegorz Cichocki <grzegorz.cichocki@pollub.edu.pl>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/pl/
Translation: Tusky/Tusky
2023-01-31 18:22:07 +00:00
Quentí 5ee51e32ed Translated using Weblate (Occitan)
Currently translated at 100.0% (560 of 560 strings)

Co-authored-by: Quentí <quentinantonin@free.fr>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/oc/
Translation: Tusky/Tusky
2023-01-31 18:22:06 +00:00
puf f2b3d570d9 Translated using Weblate (Welsh)
Currently translated at 100.0% (560 of 560 strings)

Translated using Weblate (Welsh)

Currently translated at 100.0% (560 of 560 strings)

Co-authored-by: puf <puffinux@tutanota.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/cy/
Translation: Tusky/Tusky
2023-01-31 18:22:06 +00:00
ButterflyOfFire 18218ad956 Translated using Weblate (French)
Currently translated at 85.1% (477 of 560 strings)

Translated using Weblate (Arabic)

Currently translated at 98.2% (550 of 560 strings)

Co-authored-by: ButterflyOfFire <butterflyoffire@protonmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/ar/
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/fr/
Translation: Tusky/Tusky
2023-01-31 18:22:06 +00:00
Aitor Salaberria 30a5991fa0 Translated using Weblate (Basque)
Currently translated at 83.0% (465 of 560 strings)

Translated using Weblate (Spanish)

Currently translated at 94.1% (527 of 560 strings)

Translated using Weblate (Basque)

Currently translated at 78.5% (440 of 560 strings)

Translated using Weblate (Basque)

Currently translated at 78.5% (440 of 560 strings)

Translated using Weblate (Basque)

Currently translated at 78.5% (440 of 560 strings)

Translated using Weblate (Basque)

Currently translated at 78.5% (440 of 560 strings)

Co-authored-by: Aitor Salaberria <trslbrr@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/es/
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/eu/
Translation: Tusky/Tusky
2023-01-31 18:22:06 +00:00
Elias Mårtenson 4916625542 Translated using Weblate (Swedish)
Currently translated at 98.0% (549 of 560 strings)

Co-authored-by: Elias Mårtenson <elias@dhsdevelopments.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/sv/
Translation: Tusky/Tusky
2023-01-31 04:35:56 +00:00
Eduardo df9d11c868 Translated using Weblate (Portuguese (Brazil))
Currently translated at 95.7% (536 of 560 strings)

Co-authored-by: Eduardo <edu200399lim@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/pt_BR/
Translation: Tusky/Tusky
2023-01-31 04:35:56 +00:00
Przemysław Wilde 5e39957e8c Translated using Weblate (Polish)
Currently translated at 99.8% (559 of 560 strings)

Co-authored-by: Przemysław Wilde <przemyslawwilde@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/pl/
Translation: Tusky/Tusky
2023-01-31 04:35:56 +00:00
Grzegorz Cichocki 4621f49cec Translated using Weblate (Polish)
Currently translated at 99.8% (559 of 560 strings)

Co-authored-by: Grzegorz Cichocki <grzegorz.cichocki@pollub.edu.pl>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/pl/
Translation: Tusky/Tusky
2023-01-31 04:35:55 +00:00
Quentí c0a8439f1b Translated using Weblate (Occitan)
Currently translated at 100.0% (560 of 560 strings)

Co-authored-by: Quentí <quentinantonin@free.fr>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/oc/
Translation: Tusky/Tusky
2023-01-31 04:35:55 +00:00
puf 434f65dcb3 Translated using Weblate (Welsh)
Currently translated at 100.0% (560 of 560 strings)

Co-authored-by: puf <puffinux@tutanota.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/cy/
Translation: Tusky/Tusky
2023-01-31 04:35:55 +00:00
ButterflyOfFire c71fc92d2f Translated using Weblate (French)
Currently translated at 85.1% (477 of 560 strings)

Translated using Weblate (Arabic)

Currently translated at 98.2% (550 of 560 strings)

Co-authored-by: ButterflyOfFire <butterflyoffire@protonmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/ar/
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/fr/
Translation: Tusky/Tusky
2023-01-31 04:35:55 +00:00
Aitor Salaberria 568966d694 Translated using Weblate (Basque)
Currently translated at 83.0% (465 of 560 strings)

Translated using Weblate (Spanish)

Currently translated at 94.1% (527 of 560 strings)

Translated using Weblate (Basque)

Currently translated at 78.5% (440 of 560 strings)

Translated using Weblate (Basque)

Currently translated at 78.5% (440 of 560 strings)

Translated using Weblate (Basque)

Currently translated at 78.5% (440 of 560 strings)

Translated using Weblate (Basque)

Currently translated at 78.5% (440 of 560 strings)

Co-authored-by: Aitor Salaberria <trslbrr@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/es/
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/eu/
Translation: Tusky/Tusky
2023-01-31 04:35:55 +00:00
puf a4c3545d60 Translated using Weblate (Welsh)
Currently translated at 100.0% (20 of 20 strings)

Translation: Tusky/Tusky description
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky-app/cy/
2023-01-31 04:35:54 +00:00
Eduardo f0ec7ace5e Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (20 of 20 strings)

Translation: Tusky/Tusky description
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky-app/pt_BR/
2023-01-31 04:35:54 +00:00
Grzegorz Cichocki e7a5e4a558 Translated using Weblate (Polish)
Currently translated at 100.0% (20 of 20 strings)

Translation: Tusky/Tusky description
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky-app/pl/
2023-01-31 04:35:54 +00:00
520 changed files with 28911 additions and 6861 deletions

17
.editorconfig Normal file
View File

@ -0,0 +1,17 @@
root = true
[*]
charset = utf-8
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
# Disable wildcard imports
[*.{java, kt}]
ij_kotlin_name_count_to_use_star_import = 999
ij_kotlin_name_count_to_use_star_import_for_members = 999
ij_java_class_count_to_use_import_on_demand = 999
[*.{yml,yaml}]
indent_size = 2

4
.gitattributes vendored Normal file
View File

@ -0,0 +1,4 @@
* text=auto eol=lf
*.bat text eol=crlf
*.jar binary

3
.gitignore vendored
View File

@ -3,10 +3,11 @@
/local.properties
/.idea
.DS_Store
/build
build
/captures
.externalNativeBuild
app/release
app-release.apk
app/green
app/blue
app/src/main/gen

BIN
.idea/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

98
CHANGELOG.md Normal file
View File

@ -0,0 +1,98 @@
# Tusky changelog
## Unreleased or Tusky Nightly
### New features and other improvements
### Significant bug fixes
## v22.0 beta 6
### Significant bug fixes
- **Save reading position in the Notifications tab more frequently**, [PR#3685](https://github.com/tuskyapp/Tusky/pull/3685)
## v22.0 beta 5
## Significant bug fixes
- **Rolled back APNG library to fix broken animated emojis**, [PR#3676](https://github.com/tuskyapp/Tusky/pull/3676)
- **Save local copy of notification marker in case server does not support the API**, [PR#3672](https://github.com/tuskyapp/Tusky/pull/3672)
## v22.0 beta 4
### Significant bug fixes
- **Fixed repeated fetch of notifications if configured with multiple accounts**, [PR#3660](https://github.com/tuskyapp/Tusky/pull/3660)
## v22.0 beta 3
### Significant bug fixes
- **Fixed crash when viewing a thread**, [PR#3622](https://github.com/tuskyapp/Tusky/pull/3622)
- **Fixed crash processing Mastodon filters**, [PR#3634](https://github.com/tuskyapp/Tusky/pull/3634)
- **Links in bios of follow/follow request notifications are clickable**, [PR#3646](https://github.com/tuskyapp/Tusky/pull/3646)
- **Android Notifications updates**, [PR#3636](https://github.com/tuskyapp/Tusky/pull/3626)
- Android notification for a Mastodon notification should only be shown once
- Android notifications are grouped by Mastodon notification type (follow, mention, boost, etc)
- Potential for missing notifications has been removed
## v22.0 beta 2
### Significant bug fixes
- **Improved notification loading speed**, [PR#3598](https://github.com/tuskyapp/Tusky/pull/3598)
- **Restore showing 0/1/1+ for replies**, [PR#3590](https://github.com/tuskyapp/Tusky/pull/3590)
- **Show filter titles, not filter keywords, on filtered posts**, [PR#3589](https://github.com/tuskyapp/Tusky/pull/3589)
- **Fixed a bug where opening a status could open an unrelated link**, [PR#3600](https://github.com/tuskyapp/Tusky/pull/3600)
- **Show "Add" button in correct place when there are no filters**, [PR#3561](https://github.com/tuskyapp/Tusky/pull/3561)
- **Fixed assorted crashes**
## v22.0 beta 1
### New features and other improvements
- **View trending hashtags**, [PR#3149](https://github.com/tuskyapp/Tusky/pull/3149) by [@knossos](https://fosstodon.org/@knossos)
- View trending hashtags from the side menu, or by adding them to a new tab.
- **Edit image description and focus point**, [PR#3215](https://github.com/tuskyapp/Tusky/pull/3215) by [@Tak](https://mastodon.gamedev.place/@Tak)
- Edit image descriptions and focus points when editing posts.
- **View profile banner images**, [PR#3274](https://github.com/tuskyapp/Tusky/pull/3274) by [@Tak](https://mastodon.gamedev.place/@Tak)
- Tap the banner image on any profile to view it full size, save, share, etc.
- **Follow new hashtags**, [PR#3275](https://github.com/tuskyapp/Tusky/pull/3275) by [@nikclayton](https://mastodon.social/@nikclayton)
- Follow new hashtags from the "Followed hashtags" screen.
- **Better ordering when selecting languages**, [PR#3293](https://github.com/tuskyapp/Tusky/pull/3293) by [@Tak](https://mastodon.gamedev.place/@Tak)
- Tusky will prioritise the language of the post being replied to, your default posting language, configured Tusky languages, and configured system languages when ordering the list of languages to post in.
- **"Load more" break is more prominent**, [PR#3376](https://github.com/tuskyapp/Tusky/pull/3376) by [@lakoja](https://freiburg.social/@lakoja)
- Adjusted the design so the "Load more" break in a timeline is more obvious.
- **Add "Refresh" menu**, [PR#3121](https://github.com/tuskyapp/Tusky/pull/3121) by [@nikclayton](https://mastodon.social/@nikclayton)
- Tusky timelines can now be refreshed from a menu as well as swiping, making this accessible to assistive devices.
- **Notifications timeline improvements**, [PR#3159](https://github.com/tuskyapp/Tusky/pull/3159) by [@nikclayton](https://mastodon.social/@nikclayton)
- Notifications no longer need to "Load more", they are loaded automatically as you scroll.
- Errors when interacting with notifications are displayed to the user, with a "Retry" option.
- **Show the difference between versions of a post**, [PR#3314](https://github.com/tuskyapp/Tusky/pull/3314) by [@nikclayton](https://mastodon.social/@nikclayton)
- Viewing the edits to a post highlights the differences (text that was added or deleted) between the different versions.
- **Support Mastodon v4 filters**, [PR#3188](https://github.com/tuskyapp/Tusky/pull/3188) by [@Tak](https://mastodon.gamedev.place/@Tak)
- Mastodon v4 introduced additional [filtering controls](https://docs.joinmastodon.org/user/moderating/#filters).
- **Option to show post statistics in the timeline**, [PR#3413](https://github.com/tuskyapp/Tusky/pull/3413)
- Tusky can now (optionally) show the number of replies, reposts, and favourites a post has received, in the timeline.
- **Expanded tappable area for links, hashtags, and mentions in a post**, [PR#3382](https://github.com/tuskyapp/Tusky/pull/3382) by [@nikclayton](https://mastodon.social/@nikclayton)
- Links, hashtags, and mentions in a post now react to taps that are a little above, below, or to the side of the tappable text, making them more accessible.
### Significant bug fixes
- **Remember selected tab and position**, [PR#3255](https://github.com/tuskyapp/Tusky/pull/3255) by [@nikclayton](https://mastodon.social/@nikclayton)
- Changing your tab settings (adding, removing, re-ordering) remembers your reading position in those tabs.
- **Show player controls during audio playback**, [PR#3286](https://github.com/tuskyapp/Tusky/pull/3286) by [@EricFrohnhoefer](https://mastodon.social/@EricFrohnhoefer)
- A regression from v21.0 where the media player controls could not be used.
- **Keep notifications until read**, [PR#3312](https://github.com/tuskyapp/Tusky/pull/3312) by [@lakoja](https://freiburg.social/@lakoja)
- Opening Tusky would dismiss all active Tusky Android notifications.
- **Fix copying URLs at the end of a post**, [PR#3380](https://github.com/tuskyapp/Tusky/pull/3380) by [@nikclayton](https://mastodon.social/@nikclayton)
- Copying a URL from the end of a post could include an extra Unicode whitespace character, making the URL unusable as is.
- **Correctly display mixed RTL and LTR text in profiles**, [PR#3328](https://github.com/tuskyapp/Tusky/pull/3328) by [@nikclayton](https://mastodon.social/@nikclayton)
- Profile text that contained a mix of right-to-left and left-to-right writing directions would display incorrectly.
- **Stop showing duplicates of edited posts in threads**, [PR#3377](https://github.com/tuskyapp/Tusky/pull/3377) by [@Tak](https://mastodon.gamedev.place/@Tak)
- Editing a post in thread view would show the old and new version of the post in the thread.
- **Correct post length calculation**, [PR#3392](https://github.com/tuskyapp/Tusky/pull/3392) by [@nikclayton](https://mastodon.social/@nikclayton)
- In a post that mentioned a user (e.g., `@tusky@mastodon.social`) Tusky was incorrectly including the `@mastodon.social` part when calculating the post's length, leading to incorrect "This post is too long" errors.
- **Always publish image captions**, [PR#3421](https://github.com/tuskyapp/Tusky/pull/3421) by [@lakoja](https://freiburg.social/@lakoja)
- Finishing editing an image caption before the image had finished loading would lose the caption.

View File

@ -1,51 +1,53 @@
# Contributing
## Getting Started
1. Fork the repository on the GitHub page by clicking the Fork button. This makes a fork of the project under your GitHub account.
2. Clone your fork to your machine. ```git clone https://github.com/<Your_Username>/Tusky```
3. Create a new branch named after your change. ```git checkout -b your-change-name``` (```checkout``` switches to a branch, ```-b``` specifies that the branch is a new one)
Thanks for your interest in contributing to Tusky! Here are some informations to help you get started.
## Making Changes
If you have any questions, don't hesitate to open an issue or join our [development chat on Matrix](https://riot.im/app/#/room/#Tusky:matrix.org).
### Text
All English text that will be visible to users should be put in ```app/src/main/res/values/strings.xml```. Any text that is missing in a translation will fall back to the version in this file. Be aware that anything added to this file will need to be translated, so be very concise with wording and try to add as few things as possible. Look for existing strings to use first. If there is untranslatable text that you don't want to keep as a string constant in a Java class, you can use the string resource file ```app/src/main/res/values/donottranslate.xml```.
## Contributing translations
### Translation
Translations are done through our [Weblate](https://weblate.tusky.app/projects/tusky/tusky/).
To add a new language, click on the 'Start a new translation' button on at the bottom of the page.
Translations are managed on our [Weblate](https://weblate.tusky.app/projects/tusky/tusky/). You can create an account and translate texts through the interface, no coding knowledge required.
To add a new language, click on the 'Start a new translation' button on at the bottom of the page.
- Use gender-neutral language
- Address users informally (e.g. in German "du" and never "Sie")
## Contributing code
### Prerequisites
You should have a general understanding of Android development and Git.
### Architecture
We try to follow the [Guide to app architecture](https://developer.android.com/topic/architecture).
### Kotlin
This project is in the process of migrating to Kotlin, all new code must be written in Kotlin.
Tusky was originally written in Java, but is in the process of migrating to Kotlin. All new code must be written in Kotlin.
We try to follow the [Kotlin Style Guide](https://developer.android.com/kotlin/style-guide) and make format the code according to the default [ktlint codestyle](https://github.com/pinterest/ktlint).
You can check the codestyle by running `./gradlew ktlintCheck`.
### Java
Existing code in Java should follow the [Android Style Guide](https://source.android.com/source/code-style), which is what Android uses for their own source code. ```@Nullable``` and ```@NotNull``` annotations are really helpful for Kotlin interoperability. Please don't submit new features written in Java.
### Text
All English text that will be visible to users must be put in `app/src/main/res/values/strings.xml` so it is translateable into other languages.
Try to keep texts friendly and concise.
If there is untranslatable text that you don't want to keep as a string constant in Kotlin code, you can use the string resource file `app/src/main/res/values/donottranslate.xml`.
### Viewbinding
We use [Viewbinding](https://developer.android.com/topic/libraries/view-binding) to reference views. No contribution using another mechanism will be accepted.
There are useful extensions in `src/main/java/com/keylesspalace/tusky/util/ViewExtensions.kt` that make working with viewbinding easier.
### Visuals
There are three themes in the app, so any visual changes should be checked with each of them to ensure they look appropriate no matter which theme is selected. Usually, you can use existing color attributes like ```?attr/colorPrimary``` and ```?attr/textColorSecondary```.
There are three themes in the app, so any visual changes should be checked with each of them to ensure they look appropriate no matter which theme is selected. Usually, you can use existing color attributes like `?attr/colorPrimary` and `?attr/textColorSecondary`.
All icons are from the Material iconset, find new icons [here](https://fonts.google.com/icons) (Google fonts) or [here](https://fonts.google.com/icons) (community contributions).
### Saving
Any time you get a good chunk of work done it's good to make a commit. You can either uses Android Studio's built-in UI for doing this or running the commands:
```
git add .
git commit -m "Describe the changes in this commit here."
```
### Accessibility
We try to make Tusky as accessible as possible for as many people as possible. Please make sure that all touch targets are at least 48dpx48dp in size, Text has sufficient contrast and images or icons have a image description. See [this guide](https://developer.android.com/guide/topics/ui/accessibility/apps) for more information.
## Submitting Your Changes
1. Make sure your branch is up-to-date with the ```develop``` branch. Run:
```
git fetch
git rebase origin/develop
```
It may refuse to start the rebase if there's changes that haven't been committed, so make sure you've added and committed everything. If there were changes on develop to any of the parts of files you worked on, a conflict will arise when you rebase. [Resolving a merge conflict](https://help.github.com/articles/resolving-a-merge-conflict-using-the-command-line) is a good guide to help with this. After committing the resolution, you can run ```git rebase --continue``` to finish the rebase. If you want to cancel, like if you make some mistake in resolving the conflict, you can always do ```git rebase --abort```.
### Supported servers
Tusky is primarily a Mastodon client and aims to always support the newest Mastodon version. Other platforms implementing the Mastodon Api, e.g. Akkoma, GoToSocial or Pixelfed should also work with Tusky but no special effort is made to support their quirks or additional features.
2. Push your local branch to your fork on GitHub by running ```git push origin your-change-name```.
3. Then, go to the original project page and make a pull request. Select your fork/branch and use ```develop``` as the base branch.
4. Wait for feedback on your pull request and be ready to make some changes
## Troubleshooting / FAQ
If you have any questions, don't hesitate to open an issue or contact [Tusky@mastodon.social](https://mastodon.social/@Tusky). Please also ask before you start implementing a new big feature.
- Tusky should be built with the newest version of Android Studio
- Tusky comes with two sets of build variants, "blue" and "green", which can be installed simultaneously and are distinguished by the colors of their icons. Green is intended for local development and testing, whereas blue is for releases.
## Resources
- [Mastodon Api documentation](https://docs.joinmastodon.org/api/)

View File

@ -16,7 +16,7 @@ Tusky is a beautiful Android client for [Mastodon](https://github.com/mastodon/m
- Multi-Account support
- Dark, light and black themes with the possibility to auto-switch based on the time of day
- Drafts - compose posts and save them for later
- Choose between different emoji styles
- Choose between different emoji styles
- Optimized for all screen sizes
- Completely open-source - no non-free dependencies like Google services

View File

@ -7,20 +7,21 @@ This approach of having ~500 user on the nightly releases and ~5000 users on the
## Beta
- Make sure all new features are well tested by Nightly users and all issues addressed as good as possible. Check GitHub issues, Google Play crash reports, messages on `@Tusky@mastodon.social`, emails on `tusky@connyduck.at`, #Tusky hashtag.
- Merge the latest Weblate translations (Weblate -> Repository maintenance -> commit all changes, then merge the automatic PRs by @nailyk-weblate on GitHub)
- Merge the latest Weblate translations (Weblate -> Repository maintenance -> commit all changes, then merge the automatic PRs by @nailyk-weblate on GitHub)
- Check all the translations (Android Studio shows warnings on problems). Sometimes translators add faulty translations that would crash Tusky in this language, e.g. wrong number of formatting parameters. In this case it is usually easiest to just delete the string. [Example cleanup](https://github.com/tuskyapp/Tusky/commit/feaea70af418c77178985144a2d01a8e97725dfd).
- Update `versionCode` and `versionName` in `app/build.gradle`
- Add a new short changelog under `fastlane/metadata/android/en-US/changelogs`. Use the next versionCode as the filename. This is so translators on Weblate have the duration of the beta to translate the changelog and F-Droid users will see it in their language on the release. If another beta is released, the changelogs have to be renamed. Note that changelogs shouldn't be over 500 characters or F-Droid will truncate them.
- Build the app as apk and as app bundle.
- Do a quick check to make sure the build doesn't crash. Also install it over the last release to make sure the database migrations are correct.
- Merge `develop` into `main`
- Create a new [GitHub release](https://github.com/tuskyapp/Tusky/releases).
- Tag the head of `main`.
- Create an exhaustive changelog by going through all commits since the last release.
- Attach the apk, adb and mapping.txt files to the release
- Mark the release as being a pre-release.
- Bitrise will automatically build and upload the release to the Internal Testing track on Google Play.
- Do a quick check to make sure the build doesn't crash, e.g. by enrolling yourself into the test track.
- In case there are any problems, delete the GitHub release, fix the problems and start again
- Download the build as apk from Google Play (App Bundle Explorer -> chose the release -> Downloads -> Signed, universal APK). Attach it to the GitHub Release.
- Create a new Open Testing release on Google Play. Reuse the build from the Internal Testing track.
- Create a merge request at F-Droid. [Example](https://gitlab.com/fdroid/fdroiddata/-/merge_requests/11218) (F-Droid automatically picks up new release tags, but not beta ones. This could probably be changed somehow.)
- Upload the release to the Open Testing track on Google Play.
- Announce the release
## Full release
@ -28,15 +29,16 @@ This approach of having ~500 user on the nightly releases and ~5000 users on the
- Make sure all new features are well tested by beta users and all issues addressed as good as possible. Check GitHub issues, Google Play crash reports, messages on `@Tusky@mastodon.social`, #Tusky hashtag.
- Merge the latest Weblate translations (Weblate -> Repository maintenance -> commit all changes, then merge the automatic PRs by @nailyk-weblate on GitHub)
- Update `versionCode` and `versionName` in `app/build.gradle`
- Build the app as apk and as app bundle.
- Do a quick check to make sure the build doesn't crash. Also install it over the last release to make sure the database migrations are correct.
- Merge `develop` into `main`
- Create a new [GitHub release](https://github.com/tuskyapp/Tusky/releases).
- Tag the head of `main`.
- Resuse the changelog from the beta release, or create a new one if this is only a minor release.
- Attach the apk, adb and mapping.txt files to the release
- Reuse the changelog from the beta release, or create a new one if this is only a minor release.
- (F-Droid will automatically detect and build the release)
- Upload the release to the Production track on Google Play.
- Bitrise will automatically build and upload the release to the Internal Testing track on Google Play.
- Do a quick check to make sure the build doesn't crash, e.g. by enrolling yourself into the test track.
- In case there are any problems, delete the GitHub release, fix the problems and start again
- Download the build as apk from Google Play (App Bundle Explorer -> chose the release -> Downloads -> Signed, universal APK). Attach it to the GitHub Release.
- Create a new full release on Google Play. Reuse the build from the Internal Testing track.
- update the download link on the homepage ([repo](https://github.com/tuskyapp/tuskyapp.github.io))
- Announce the release

2
app/.gitignore vendored
View File

@ -1,2 +0,0 @@
/build
app-release.apk

View File

@ -1,30 +1,33 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
apply plugin: 'kotlin-parcelize'
apply from: "../instance-build.gradle"
def getGitSha = {
def stdout = new ByteArrayOutputStream()
try {
exec {
commandLine 'git', 'rev-parse', '--short', 'HEAD'
standardOutput = stdout
}
} catch (Exception e) {
return "unknown"
}
return stdout.toString().trim()
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.kapt)
alias(libs.plugins.kotlin.parcelize)
}
apply from: 'getGitSha.gradle'
final def gitSha = ext.getGitSha()
// The app name
final def APP_NAME = "Yuito"
// The application id. Must be unique, e.g. based on your domain
final def APP_ID = "net.accelf.yuito"
// url of a custom app logo. Recommended size at least 600x600. Keep empty to use the Tusky elephant friend.
final def CUSTOM_LOGO_URL = ""
// e.g. mastodon.social. Keep empty to not suggest any instance on the signup screen
final def CUSTOM_INSTANCE = ""
// link to your support account. Will be linked on the about page when not empty.
final def SUPPORT_ACCOUNT_URL = "https://odakyu.app/@ars42525"
android {
compileSdkVersion 33
compileSdk 33
namespace "com.keylesspalace.tusky"
defaultConfig {
applicationId 'net.accelf.yuito'
namespace 'com.keylesspalace.tusky'
minSdkVersion 23
targetSdkVersion 33
applicationId APP_ID
namespace "com.keylesspalace.tusky"
minSdk 23
targetSdk 33
versionCode 55
versionName '4.5.2'
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@ -35,12 +38,6 @@ android {
buildConfigField("String", "CUSTOM_LOGO_URL", "\"$CUSTOM_LOGO_URL\"")
buildConfigField("String", "CUSTOM_INSTANCE", "\"$CUSTOM_INSTANCE\"")
buildConfigField("String", "SUPPORT_ACCOUNT_URL", "\"$SUPPORT_ACCOUNT_URL\"")
kapt {
arguments {
arg("room.schemaLocation", "$projectDir/schemas")
}
}
}
signingConfigs {
debug {
@ -58,20 +55,26 @@ android {
}
}
flavorDimensions "color"
flavorDimensions += "color"
productFlavors {
blue {}
green {
resValue "string", "app_name", APP_NAME + " Test"
applicationIdSuffix ".test"
versionNameSuffix "-" + getGitSha()
versionNameSuffix "-" + gitSha
}
}
lintOptions {
disable 'MissingTranslation'
lint {
lintConfig file("lint.xml")
// Regenerate by deleting app/lint-baseline.xml, then run:
// ./gradlew lintBlueDebug
baseline = file("lint-baseline.xml")
}
buildFeatures {
buildConfig true
resValues true
viewBinding true
}
testOptions {
@ -81,17 +84,19 @@ android {
}
unitTests.all {
systemProperty 'robolectric.logging.enabled', 'true'
systemProperty 'robolectric.lazyload', 'ON'
}
}
sourceSets {
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
}
packagingOptions {
// Exclude unneeded files added by libraries
exclude 'LICENSE_OFL'
exclude 'LICENSE_UNICODE'
}
// Exclude unneeded files added by libraries
packagingOptions.resources.excludes += [
'LICENSE_OFL',
'LICENSE_UNICODE',
]
bundle {
language {
// bundle all languages in every apk so the dynamic language switching works
@ -102,14 +107,34 @@ android {
includeInApk false
includeInBundle false
}
// Can remove this once https://issuetracker.google.com/issues/260059413 is fixed.
// https://kotlinlang.org/docs/gradle-configure-project.html#gradle-java-toolchains-support
compileOptions {
sourceCompatibility JavaVersion.VERSION_11
targetCompatibility JavaVersion.VERSION_11
}
applicationVariants.configureEach { variant ->
variant.outputs.configureEach {
outputFileName = "Tusky_${variant.versionName}_${variant.versionCode}_${gitSha}_" +
"${variant.flavorName}_${buildType.name}.apk"
}
}
}
repositories {
maven {
url 'https://maven.accelf.net/'
kapt {
arguments {
arg("room.schemaLocation", "$projectDir/schemas")
arg("room.incremental", "true")
}
}
configurations {
// JNI-only libraries don't play nicely with Robolectric
// see https://github.com/tuskyapp/Tusky/pull/3367
testImplementation.exclude group: "org.conscrypt", module: "conscrypt-android"
testRuntime.exclude group: "org.conscrypt", module: "conscrypt-android"
}
// library versions are in PROJECT_ROOT/gradle/libs.versions.toml
dependencies {
implementation libs.kotlinx.coroutines.android
@ -145,11 +170,7 @@ dependencies {
implementation libs.photoview
implementation libs.bundles.material.drawer
implementation libs.material.typeface, {
artifact {
type = "aar"
}
}
implementation libs.material.typeface
implementation libs.image.cropper
@ -158,6 +179,8 @@ dependencies {
implementation libs.bouncycastle
implementation libs.unified.push
implementation libs.bundles.xmldiff
testImplementation libs.androidx.test.junit
testImplementation libs.robolectric
testImplementation libs.bundles.mockito
@ -165,6 +188,8 @@ dependencies {
testImplementation libs.androidx.core.testing
testImplementation libs.kotlinx.coroutines.test
testImplementation libs.androidx.work.testing
testImplementation libs.truth
testImplementation libs.turbine
androidTestImplementation libs.espresso.core
androidTestImplementation libs.androidx.room.testing

27
app/getGitSha.gradle Normal file
View File

@ -0,0 +1,27 @@
import org.gradle.api.provider.ValueSourceParameters
import javax.inject.Inject
// Must wrap this in a ValueSource in order to get well-defined fail behavior without confusing Gradle on repeat builds.
abstract class GitShaValueSource implements ValueSource<String, ValueSourceParameters.None> {
@Inject abstract ExecOperations getExecOperations()
@Override String obtain() {
try {
def output = new ByteArrayOutputStream()
execOperations.exec {
it.commandLine 'git', 'rev-parse', '--short=8', 'HEAD'
it.standardOutput = output
}
return output.toString().trim()
} catch (GradleException ignore) {
// Git executable unavailable, or we are not building in a git repo. Fall through:
}
return "unknown"
}
}
// Export closure
ext.getGitSha = {
providers.of(GitShaValueSource) {}.get()
}

7673
app/lint-baseline.xml Normal file

File diff suppressed because one or more lines are too long

41
app/lint.xml Normal file
View File

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
~ Copyright 2023 Tusky Contributors
~
~ This file is a part of Tusky.
~
~ This program is free software; you can redistribute it and/or modify it under the terms of the
~ GNU General Public License as published by the Free Software Foundation; either version 3 of the
~ License, or (at your option) any later version.
~
~ Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
~ the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
~ Public License for more details.
~
~ You should have received a copy of the GNU General Public License along with Tusky; if not,
~ see <http://www.gnu.org/licenses>.
-->
<lint>
<!-- Missing translations are OK -->
<issue id="MissingTranslation" severity="ignore" />
<!-- Duplicate strings are OK. This can happen when e.g., "favourite" appears as both
a noun and a verb -->
<issue id="DuplicateStrings" severity="ignore" />
<!-- Resource IDs used in viewbinding are incorrectly reported as unused,
https://issuetracker.google.com/issues/204797401.
Disable these for the time being. -->
<issue id="UnusedIds" severity="ignore" />
<!-- Logs are stripped in release builds. -->
<issue id="LogConditional" severity="ignore" />
<!-- Ensure we are warned about errors in the baseline -->
<issue id="LintBaseline" severity="warning" />
<!-- Mark all other lint issues as errors -->
<issue id="all" severity="error" />
</lint>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -123,7 +123,7 @@
<activity android:name=".EditProfileActivity" />
<activity android:name=".components.preference.PreferencesActivity" />
<activity android:name=".StatusListActivity" />
<activity android:name=".AccountListActivity" />
<activity android:name=".components.accountlist.AccountListActivity" />
<activity android:name=".AboutActivity" />
<activity android:name=".TabPreferenceActivity" />
<activity
@ -143,7 +143,8 @@
</activity>
<activity android:name=".ListsActivity" />
<activity android:name=".LicenseActivity" />
<activity android:name=".FiltersActivity" />
<activity android:name=".components.filters.FiltersActivity" />
<activity android:name=".components.trending.TrendingActivity" />
<activity android:name=".components.followedtags.FollowedTagsActivity" />
<activity
android:name=".components.report.ReportActivity"
@ -152,9 +153,9 @@
<activity android:name=".components.scheduled.ScheduledStatusActivity" />
<activity android:name=".components.announcements.AnnouncementsActivity" />
<activity android:name=".components.drafts.DraftsActivity" />
<activity android:name="com.keylesspalace.tusky.components.filters.EditFilterActivity"
android:windowSoftInputMode="adjustResize" />
<receiver android:name=".receiver.NotificationClearBroadcastReceiver"
android:exported="false" />
<receiver
android:name=".receiver.SendStatusBroadcastReceiver"
android:enabled="true"

View File

@ -61,7 +61,6 @@ class AboutActivity : BottomSheetActivity(), Injectable {
}
private fun TextView.setClickableTextWithoutUnderlines(@StringRes textId: Int) {
val text = SpannableString(context.getText(textId))
Linkify.addLinks(text, Linkify.WEB_URLS)

View File

@ -41,6 +41,7 @@ import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.loadAvatar
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.unsafeLazy
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel
import com.keylesspalace.tusky.viewmodel.State
@ -63,10 +64,10 @@ class AccountsInListFragment : DialogFragment(), Injectable {
private val adapter = Adapter()
private val searchAdapter = SearchAdapter()
private val radius by lazy { resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp) }
private val pm by lazy { PreferenceManager.getDefaultSharedPreferences(requireContext()) }
private val animateAvatar by lazy { pm.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false) }
private val animateEmojis by lazy { pm.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) }
private val radius by unsafeLazy { resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp) }
private val pm by unsafeLazy { PreferenceManager.getDefaultSharedPreferences(requireContext()) }
private val animateAvatar by unsafeLazy { pm.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false) }
private val animateEmojis by unsafeLazy { pm.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -113,7 +114,7 @@ class AccountsInListFragment : DialogFragment(), Injectable {
binding.searchView.isSubmitButtonEnabled = true
binding.searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean {
viewModel.search(query ?: "")
viewModel.search(query.orEmpty())
return true
}
@ -152,12 +153,14 @@ class AccountsInListFragment : DialogFragment(), Injectable {
if (error is IOException) {
binding.messageView.setup(
R.drawable.elephant_offline,
R.string.error_network, retryAction
R.string.error_network,
retryAction
)
} else {
binding.messageView.setup(
R.drawable.elephant_error,
R.string.error_generic, retryAction
R.string.error_generic,
retryAction
)
}
}

View File

@ -79,7 +79,7 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
/* set the taskdescription programmatically, the theme would turn it blue */
String appName = getString(R.string.app_name);
Bitmap appIcon = BitmapFactory.decodeResource(getResources(), R.mipmap.ic_launcher);
int recentsBackgroundColor = MaterialColors.getColor(this, R.attr.colorSurface, Color.BLACK);
int recentsBackgroundColor = MaterialColors.getColor(this, com.google.android.material.R.attr.colorSurface, Color.BLACK);
setTaskDescription(new ActivityManager.TaskDescription(appName, appIcon, recentsBackgroundColor));
@ -239,8 +239,6 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
}
if (permissionsToRequest.isEmpty()) {
int[] permissionsAlreadyGranted = new int[permissions.length];
for (int i = 0; i < permissionsAlreadyGranted.length; ++i)
permissionsAlreadyGranted[i] = PackageManager.PERMISSION_GRANTED;
requester.onRequestPermissionsResult(permissions, permissionsAlreadyGranted);
return;
}

View File

@ -93,8 +93,11 @@ abstract class BottomSheetActivity : BaseActivity() {
if (statuses.isNotEmpty()) {
viewThread(statuses[0].id, statuses[0].url)
return@subscribe
} else if (accounts.isNotEmpty()) {
viewAccount(accounts[0].id)
}
accounts.firstOrNull { it.url == url }?.let { account ->
// Some servers return (unrelated) accounts for url searches (#2804)
// Verify that the account's url matches the query
viewAccount(account.id)
return@subscribe
}
@ -181,5 +184,5 @@ abstract class BottomSheetActivity : BaseActivity() {
enum class PostLookupFallbackBehavior {
OPEN_IN_BROWSER,
DISPLAY_ERROR,
DISPLAY_ERROR
}

View File

@ -34,6 +34,7 @@ import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.load.resource.bitmap.FitCenter
import com.bumptech.glide.load.resource.bitmap.RoundedCorners
import com.canhub.cropper.CropImage
import com.canhub.cropper.CropImageContract
import com.canhub.cropper.options
import com.google.android.material.snackbar.Snackbar
@ -80,14 +81,18 @@ class EditProfileActivity : BaseActivity(), Injectable {
}
private val cropImage = registerForActivityResult(CropImageContract()) { result ->
if (result.isSuccessful) {
if (result.uriContent == viewModel.getAvatarUri()) {
viewModel.newAvatarPicked()
} else {
viewModel.newHeaderPicked()
}
if (result is CropImage.CancelledResult) {
return@registerForActivityResult
}
if (!result.isSuccessful) {
return@registerForActivityResult onPickFailure(result.error)
}
if (result.uriContent == viewModel.getAvatarUri()) {
viewModel.newAvatarPicked()
} else {
onPickFailure(result.error)
viewModel.newHeaderPicked()
}
}
@ -131,12 +136,11 @@ class EditProfileActivity : BaseActivity(), Injectable {
is Success -> {
val me = profileRes.data
if (me != null) {
binding.displayNameEditText.setText(me.intentionallyUseDisplayName)
binding.noteEditText.setText(me.source?.note)
binding.lockedCheckBox.isChecked = me.locked
accountFieldEditAdapter.setFields(me.source?.fields ?: emptyList())
accountFieldEditAdapter.setFields(me.source?.fields.orEmpty())
binding.addFieldButton.isVisible =
(me.source?.fields?.size ?: 0) < maxAccountFields

View File

@ -1,183 +0,0 @@
package com.keylesspalace.tusky
import android.os.Bundle
import android.text.format.DateUtils
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.Toast
import androidx.lifecycle.lifecycleScope
import at.connyduck.calladapter.networkresult.fold
import at.connyduck.calladapter.networkresult.getOrElse
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.databinding.ActivityFiltersBinding
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.view.getSecondsForDurationIndex
import com.keylesspalace.tusky.view.setupEditDialogForFilter
import com.keylesspalace.tusky.view.showAddFilterDialog
import kotlinx.coroutines.launch
import java.io.IOException
import javax.inject.Inject
class FiltersActivity : BaseActivity() {
@Inject
lateinit var api: MastodonApi
@Inject
lateinit var eventHub: EventHub
private val binding by viewBinding(ActivityFiltersBinding::inflate)
private lateinit var context: String
private lateinit var filters: MutableList<Filter>
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
setSupportActionBar(binding.includedToolbar.toolbar)
supportActionBar?.run {
// Back button
setDisplayHomeAsUpEnabled(true)
setDisplayShowHomeEnabled(true)
}
binding.addFilterButton.setOnClickListener {
showAddFilterDialog(this)
}
title = intent?.getStringExtra(FILTERS_TITLE)
context = intent?.getStringExtra(FILTERS_CONTEXT)!!
loadFilters()
}
fun updateFilter(id: String, phrase: String, filterContext: List<String>, irreversible: Boolean, wholeWord: Boolean, expiresInSeconds: Int?, itemIndex: Int) {
lifecycleScope.launch {
api.updateFilter(id, phrase, filterContext, irreversible, wholeWord, expiresInSeconds).fold(
{ updatedFilter ->
if (updatedFilter.context.contains(context)) {
filters[itemIndex] = updatedFilter
} else {
filters.removeAt(itemIndex)
}
refreshFilterDisplay()
eventHub.dispatch(PreferenceChangedEvent(context))
},
{
Toast.makeText(this@FiltersActivity, "Error updating filter '$phrase'", Toast.LENGTH_SHORT).show()
}
)
}
}
fun deleteFilter(itemIndex: Int) {
val filter = filters[itemIndex]
if (filter.context.size == 1) {
lifecycleScope.launch {
// This is the only context for this filter; delete it
api.deleteFilter(filters[itemIndex].id).fold(
{
filters.removeAt(itemIndex)
refreshFilterDisplay()
eventHub.dispatch(PreferenceChangedEvent(context))
},
{
Toast.makeText(this@FiltersActivity, "Error deleting filter '${filters[itemIndex].phrase}'", Toast.LENGTH_SHORT).show()
}
)
}
} else {
// Keep the filter, but remove it from this context
val oldFilter = filters[itemIndex]
val newFilter = Filter(
oldFilter.id, oldFilter.phrase, oldFilter.context.filter { c -> c != context },
oldFilter.expiresAt, oldFilter.irreversible, oldFilter.wholeWord
)
updateFilter(
newFilter.id, newFilter.phrase, newFilter.context, newFilter.irreversible, newFilter.wholeWord,
getSecondsForDurationIndex(-1, this, oldFilter.expiresAt), itemIndex
)
}
}
fun createFilter(phrase: String, wholeWord: Boolean, expiresInSeconds: Int? = null) {
lifecycleScope.launch {
api.createFilter(phrase, listOf(context), false, wholeWord, expiresInSeconds).fold(
{ filter ->
filters.add(filter)
refreshFilterDisplay()
eventHub.dispatch(PreferenceChangedEvent(context))
},
{
Toast.makeText(this@FiltersActivity, "Error creating filter '$phrase'", Toast.LENGTH_SHORT).show()
}
)
}
}
private fun refreshFilterDisplay() {
binding.filtersView.adapter = ArrayAdapter(
this,
android.R.layout.simple_list_item_1,
filters.map { filter ->
if (filter.expiresAt == null) {
filter.phrase
} else {
getString(
R.string.filter_expiration_format,
filter.phrase,
DateUtils.getRelativeTimeSpanString(
filter.expiresAt.time,
System.currentTimeMillis(),
DateUtils.MINUTE_IN_MILLIS,
DateUtils.FORMAT_ABBREV_RELATIVE
)
)
}
}
)
binding.filtersView.onItemClickListener = AdapterView.OnItemClickListener { _, _, position, _ -> setupEditDialogForFilter(this, filters[position], position) }
}
private fun loadFilters() {
binding.filterMessageView.hide()
binding.filtersView.hide()
binding.addFilterButton.hide()
binding.filterProgressBar.show()
lifecycleScope.launch {
val newFilters = api.getFilters().getOrElse {
binding.filterProgressBar.hide()
binding.filterMessageView.show()
if (it is IOException) {
binding.filterMessageView.setup(
R.drawable.elephant_offline,
R.string.error_network
) { loadFilters() }
} else {
binding.filterMessageView.setup(
R.drawable.elephant_error,
R.string.error_generic
) { loadFilters() }
}
return@launch
}
filters = newFilters.filter { it.context.contains(context) }.toMutableList()
refreshFilterDisplay()
binding.filtersView.show()
binding.addFilterButton.show()
binding.filterProgressBar.hide()
}
}
companion object {
const val FILTERS_CONTEXT = "filters_context"
const val FILTERS_TITLE = "filters_title"
}
}

View File

@ -45,7 +45,6 @@ class LicenseActivity : BaseActivity() {
}
private fun loadFileIntoTextView(@RawRes fileId: Int, textView: TextView) {
val sb = StringBuilder()
val br = BufferedReader(InputStreamReader(resources.openRawResource(fileId)))

View File

@ -31,6 +31,7 @@ import android.widget.TextView
import androidx.activity.viewModels
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
import androidx.core.widget.doOnTextChanged
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.DividerItemDecoration
@ -45,7 +46,6 @@ import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.MastoList
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.onTextChanged
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible
@ -101,6 +101,9 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
DividerItemDecoration(this, DividerItemDecoration.VERTICAL)
)
binding.swipeRefreshLayout.setOnRefreshListener { viewModel.retryLoading() }
binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
lifecycleScope.launch {
viewModel.state.collect(this@ListsActivity::update)
}
@ -113,7 +116,6 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
lifecycleScope.launch {
viewModel.events.collect { event ->
@Suppress("WHEN_ENUM_CAN_BE_NULL_IN_JAVA")
when (event) {
Event.CREATE_ERROR -> showMessage(R.string.error_create_list)
Event.RENAME_ERROR -> showMessage(R.string.error_rename_list)
@ -135,8 +137,11 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
val dialog = AlertDialog.Builder(this)
.setView(layout)
.setPositiveButton(
if (list == null) R.string.action_create_list
else R.string.action_rename_list
if (list == null) {
R.string.action_create_list
} else {
R.string.action_rename_list
}
) { _, _ ->
onPickedDialogName(editText.text, list?.id)
}
@ -144,8 +149,8 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
.show()
val positiveButton = dialog.getButton(Dialog.BUTTON_POSITIVE)
editText.onTextChanged { s, _, _, _ ->
positiveButton.isEnabled = s.isNotBlank()
editText.doOnTextChanged { s, _, _, _ ->
positiveButton.isEnabled = s?.isNotBlank() == true
}
editText.setText(list?.title)
editText.text?.let { editText.setSelection(it.length) }
@ -164,6 +169,7 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
private fun update(state: ListsViewModel.State) {
adapter.submitList(state.lists)
binding.progressBar.visible(state.loadingState == LOADING)
binding.swipeRefreshLayout.isRefreshing = state.loadingState == LOADING
when (state.loadingState) {
INITIAL, LOADING -> binding.messageView.hide()
ERROR_NETWORK -> {
@ -182,7 +188,8 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
if (state.lists.isEmpty()) {
binding.messageView.show()
binding.messageView.setup(
R.drawable.elephant_friend_empty, R.string.message_empty,
R.drawable.elephant_friend_empty,
R.string.message_empty,
null
)
} else {
@ -193,7 +200,9 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
private fun showMessage(@StringRes messageId: Int) {
Snackbar.make(
binding.listsRecycler, messageId, Snackbar.LENGTH_SHORT
binding.listsRecycler,
messageId,
Snackbar.LENGTH_SHORT
).show()
}

View File

@ -30,11 +30,13 @@ import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.graphics.drawable.InsetDrawable
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.text.TextUtils
import android.util.Log
import android.util.TypedValue
import android.view.KeyEvent
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.WindowManager
@ -42,20 +44,19 @@ import android.widget.ImageView
import androidx.activity.OnBackPressedCallback
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.content.res.AppCompatResources
import androidx.appcompat.view.menu.MenuBuilder
import androidx.appcompat.widget.PopupMenu
import androidx.appcompat.widget.ThemeUtils
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.view.GravityCompat
import androidx.lifecycle.Lifecycle
import androidx.core.view.MenuProvider
import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceManager
import androidx.viewpager2.widget.MarginPageTransformer
import at.connyduck.calladapter.networkresult.fold
import autodispose2.androidx.lifecycle.autoDispose
import com.bumptech.glide.Glide
import com.bumptech.glide.RequestManager
import com.bumptech.glide.load.resource.bitmap.RoundedCorners
@ -68,18 +69,19 @@ import com.google.android.material.tabs.TabLayout.OnTabSelectedListener
import com.google.android.material.tabs.TabLayoutMediator
import com.keylesspalace.tusky.appstore.AnnouncementReadEvent
import com.keylesspalace.tusky.appstore.CacheUpdater
import com.keylesspalace.tusky.appstore.Event
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.MainTabsChangedEvent
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.appstore.ProfileEditedEvent
import com.keylesspalace.tusky.components.account.AccountActivity
import com.keylesspalace.tusky.components.accountlist.AccountListActivity
import com.keylesspalace.tusky.components.announcements.AnnouncementsActivity
import com.keylesspalace.tusky.components.compose.ComposeActivity
import com.keylesspalace.tusky.components.compose.ComposeActivity.Companion.canHandleMimeType
import com.keylesspalace.tusky.components.drafts.DraftsActivity
import com.keylesspalace.tusky.components.login.LoginActivity
import com.keylesspalace.tusky.components.notifications.NotificationHelper
import com.keylesspalace.tusky.components.notifications.NotificationsFragment
import com.keylesspalace.tusky.components.notifications.disableAllNotifications
import com.keylesspalace.tusky.components.notifications.enablePushNotificationsWithFallback
import com.keylesspalace.tusky.components.notifications.showMigrationNoticeIfNecessary
@ -87,15 +89,16 @@ import com.keylesspalace.tusky.components.preference.PreferencesActivity
import com.keylesspalace.tusky.components.scheduled.ScheduledStatusActivity
import com.keylesspalace.tusky.components.search.SearchActivity
import com.keylesspalace.tusky.components.timeline.TimelineFragment
import com.keylesspalace.tusky.components.trending.TrendingActivity
import com.keylesspalace.tusky.databinding.ActivityMainBinding
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.DraftsAlert
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.fragment.NotificationsFragment
import com.keylesspalace.tusky.interfaces.AccountSelectionListener
import com.keylesspalace.tusky.interfaces.ActionButtonActivity
import com.keylesspalace.tusky.interfaces.FabFragment
import com.keylesspalace.tusky.interfaces.ReselectableFragment
import com.keylesspalace.tusky.interfaces.ResettableFragment
import com.keylesspalace.tusky.pager.MainPagerAdapter
@ -109,6 +112,7 @@ import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.reduceSwipeSensitivity
import com.keylesspalace.tusky.util.setDrawableTint
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.unsafeLazy
import com.keylesspalace.tusky.util.updateShortcut
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible
@ -143,16 +147,15 @@ import com.mikepenz.materialdrawer.widget.AccountHeaderView
import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
import de.c1710.filemojicompat_ui.helpers.EMOJI_PREFERENCE
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import kotlinx.coroutines.job
import kotlinx.coroutines.launch
import net.accelf.yuito.FooterDrawerItem
import net.accelf.yuito.QuickTootViewModel
import net.accelf.yuito.streaming.StreamingManager
import javax.inject.Inject
class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInjector {
class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInjector, MenuProvider {
@Inject
lateinit var androidInjector: DispatchingAndroidInjector<Any>
@ -188,7 +191,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
private var unreadAnnouncementsCount = 0
private val preferences by lazy { PreferenceManager.getDefaultSharedPreferences(this) }
private val preferences by unsafeLazy { PreferenceManager.getDefaultSharedPreferences(this) }
private lateinit var glide: RequestManager
@ -197,6 +200,12 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
// We need to know if the emoji pack has been changed
private var selectedEmojiPack: String? = null
/** Mediate between binding.viewPager and the chosen tab layout */
private var tabLayoutMediator: TabLayoutMediator? = null
/** Adapter for the different timeline tabs */
private lateinit var tabAdapter: MainPagerAdapter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -231,7 +240,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
} else {
// No account was provided, show the chooser
showAccountChooserDialog(
getString(R.string.action_share_as), true,
getString(R.string.action_share_as),
true,
object : AccountSelectionListener {
override fun onAccountSelected(account: AccountEntity) {
val requestedId = account.id
@ -263,6 +273,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
}
window.statusBarColor = Color.TRANSPARENT // don't draw a status bar, the DrawerLayout and the MaterialDrawerLayout have their own
setContentView(binding.root)
setSupportActionBar(binding.mainToolbar)
glide = Glide.with(this)
@ -274,21 +285,15 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
loadDrawerAvatar(activeAccount.profilePictureUrl, true)
binding.mainToolbar.menu.add(R.string.action_search).apply {
setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM)
icon = IconicsDrawable(this@MainActivity, GoogleMaterial.Icon.gmd_search).apply {
sizeDp = 20
colorInt = MaterialColors.getColor(binding.mainToolbar, android.R.attr.textColorPrimary)
}
setOnMenuItemClickListener {
startActivity(SearchActivity.getIntent(this@MainActivity))
true
}
}
addMenuProvider(this)
binding.viewPager.reduceSwipeSensitivity()
setupDrawer(savedInstanceState, addSearchButton = hideTopToolbar)
setupDrawer(
savedInstanceState,
addSearchButton = hideTopToolbar,
addTrendingButton = !accountManager.activeAccount!!.tabPreferences.hasTab(TRENDING)
)
/* Fetch user info while we're doing other things. This has to be done after setting up the
* drawer, though, because its callback touches the header in the drawer. */
@ -296,19 +301,40 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
fetchAnnouncements()
streamingManager.setup(lifecycleScope.coroutineContext.job) { active ->
if (active) {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
// Initialise the tab adapter and set to viewpager. Fragments appear to be leaked if the
// adapter changes over the life of the viewPager (the adapter, not its contents), so set
// the initial list of tabs to empty, and set the full list later in setupTabs(). See
// https://github.com/tuskyapp/Tusky/issues/3251 for details.
tabAdapter = MainPagerAdapter(emptyList(), this)
binding.viewPager.adapter = tabAdapter
setupTabs(showNotificationTab)
if (PreferenceManager.getDefaultSharedPreferences(this).getBoolean(PrefKeys.VIEW_PAGER_OFF_SCREEN_LIMIT, false)) {
binding.viewPager.offscreenPageLimit = 9
}
eventHub.events
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
.subscribe { event: Event? ->
lifecycleScope.launch {
eventHub.events.collect { event ->
when (event) {
is ProfileEditedEvent -> onFetchUserInfoSuccess(event.newProfileData)
is MainTabsChangedEvent -> setupTabs(false)
is MainTabsChangedEvent -> {
refreshMainDrawerItems(
addSearchButton = hideTopToolbar,
addTrendingButton = !event.newTabs.hasTab(TRENDING)
)
setupTabs(false)
}
is AnnouncementReadEvent -> {
unreadAnnouncementsCount--
updateAnnouncementsBadge()
@ -316,6 +342,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
}
binding.viewQuickToot.handleEvent(event)
}
}
Schedulers.io().scheduleDirect {
// Flush old media that was cached for sharing
@ -355,14 +382,28 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
draftsAlert.observeInContext(this, true)
}
override fun onPause() {
super.onPause()
streamingManager.pause()
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.activity_main, menu)
menu.findItem(R.id.action_search)?.apply {
icon = IconicsDrawable(this@MainActivity, GoogleMaterial.Icon.gmd_search).apply {
sizeDp = 20
colorInt = MaterialColors.getColor(binding.mainToolbar, android.R.attr.textColorPrimary)
}
}
}
override fun onMenuItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_search -> {
startActivity(SearchActivity.getIntent(this@MainActivity))
true
}
else -> super.onOptionsItemSelected(item)
}
}
override fun onResume() {
super.onResume()
NotificationHelper.clearNotificationsForActiveAccount(this, accountManager)
val currentEmojiPack = preferences.getString(EMOJI_PREFERENCE, "")
if (currentEmojiPack != selectedEmojiPack) {
Log.d(
@ -373,7 +414,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
selectedEmojiPack = currentEmojiPack
recreate()
}
streamingManager.resume()
}
override fun onStart() {
@ -382,20 +422,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
if (binding.mainDrawerLayout.isOpen) {
binding.mainDrawerLayout.closeDrawer(GravityCompat.START, false)
}
keepScreenOn()
}
private fun keepScreenOn() {
if (streamingManager.active) {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
override fun onStop() {
super.onStop()
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
@ -417,7 +443,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
// FIXME: blackberry keyONE raises SHIFT key event even CTRL IS PRESSED
when (keyCode) {
KeyEvent.KEYCODE_N -> {
// open compose activity by pressing SHIFT + N (or CTRL + N)
val composeIntent = Intent(applicationContext, ComposeActivity::class.java)
startActivity(composeIntent)
@ -449,8 +474,11 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
finish()
}
private fun setupDrawer(savedInstanceState: Bundle?, addSearchButton: Boolean) {
private fun setupDrawer(
savedInstanceState: Bundle?,
addSearchButton: Boolean,
addTrendingButton: Boolean
) {
val drawerOpenClickListener = View.OnClickListener { binding.mainDrawerLayout.open() }
binding.mainToolbar.setNavigationOnClickListener(drawerOpenClickListener)
@ -475,6 +503,9 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
closeDrawerOnProfileListClick = true
}
header.currentProfileName.maxLines = 1
header.currentProfileName.ellipsize = TextUtils.TruncateAt.END
header.accountHeaderBackground.setColorFilter(getColor(R.color.headerBackgroundFilter))
header.accountHeaderBackground.setBackgroundColor(MaterialColors.getColor(header, R.attr.colorBackgroundAccent))
val animateAvatars = preferences.getBoolean("animateGifAvatars", false)
@ -507,6 +538,14 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
})
binding.mainDrawer.apply {
refreshMainDrawerItems(addSearchButton, addTrendingButton)
setSavedInstance(savedInstanceState)
}
}
private fun refreshMainDrawerItems(addSearchButton: Boolean, addTrendingButton: Boolean) {
binding.mainDrawer.apply {
itemAdapter.clear()
tintStatusBar = true
addItems(
primaryDrawerItem {
@ -572,8 +611,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
startActivityWithSlideInAnimation(AnnouncementsActivity.newIntent(context))
}
badgeStyle = BadgeStyle().apply {
textColor = ColorHolder.fromColor(MaterialColors.getColor(binding.mainDrawer, R.attr.colorOnPrimary))
color = ColorHolder.fromColor(MaterialColors.getColor(binding.mainDrawer, R.attr.colorPrimary))
textColor = ColorHolder.fromColor(MaterialColors.getColor(binding.mainDrawer, com.google.android.material.R.attr.colorOnPrimary))
color = ColorHolder.fromColor(MaterialColors.getColor(binding.mainDrawer, com.google.android.material.R.attr.colorPrimary))
}
},
DividerDrawerItem(),
@ -626,7 +665,18 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
)
}
setSavedInstance(savedInstanceState)
if (addTrendingButton) {
binding.mainDrawer.addItemsAtPosition(
5,
primaryDrawerItem {
nameRes = R.string.title_public_trending_hashtags
iconicsIcon = GoogleMaterial.Icon.gmd_trending_up
onClick = {
startActivityWithSlideInAnimation(TrendingActivity.getIntent(context))
}
}
)
}
}
if (BuildConfig.DEBUG) {
@ -692,8 +742,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
}
private fun setupTabs(selectNotificationTab: Boolean) {
val activeTabLayout = if (preferences.getString("mainNavPosition", "top") == "bottom") {
val actionBarSize = getDimension(this, R.attr.actionBarSize)
val activeTabLayout = if (preferences.getString(PrefKeys.MAIN_NAV_POSITION, "top") == "bottom") {
val actionBarSize = getDimension(this, androidx.appcompat.R.attr.actionBarSize)
val fabMargin = resources.getDimensionPixelSize(R.dimen.fabMargin)
(binding.composeButton.layoutParams as CoordinatorLayout.LayoutParams).bottomMargin = actionBarSize + fabMargin
binding.topNav.hide()
@ -705,22 +755,31 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
binding.tabLayout
}
val tabs = accountManager.activeAccount!!.tabPreferences.toMutableList()
// Save the previous tab so it can be restored later
val previousTab = tabAdapter.tabs.getOrNull(binding.viewPager.currentItem)
val adapter = MainPagerAdapter(tabs, this)
binding.viewPager.adapter = adapter
TabLayoutMediator(activeTabLayout, binding.viewPager) { _: TabLayout.Tab?, _: Int -> }.attach()
activeTabLayout.removeAllTabs()
val popups = ArrayList<PopupMenu>()
for (i in tabs.indices) {
val tab = activeTabLayout.newTab()
.setIcon(tabs[i].icon)
if (tabs[i].id == LIST) {
tab.contentDescription = tabs[i].arguments[1]
} else {
tab.setContentDescription(tabs[i].text)
val tabs = accountManager.activeAccount!!.tabPreferences.toMutableList()
val popups = MutableList<PopupMenu?>(tabs.size) { null }
// Detach any existing mediator before changing tab contents and attaching a new mediator
tabLayoutMediator?.detach()
tabAdapter.tabs = tabs
tabAdapter.notifyItemRangeChanged(0, tabs.size)
tabLayoutMediator = TabLayoutMediator(activeTabLayout, binding.viewPager, true) {
tab: TabLayout.Tab, position: Int ->
val data = tabs[position]
tab.icon = AppCompatResources.getDrawable(this@MainActivity, data.icon)
tab.contentDescription = when (data.id) {
LIST -> data.arguments[1]
else -> getString(data.text)
}
if (popups[position] != null) {
return@TabLayoutMediator
}
activeTabLayout.addTab(tab)
val popup = PopupMenu(this, tab.view)
popup.menuInflater.inflate(R.menu.view_tab_action, popup.menu)
@ -729,19 +788,19 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
if (popup.menu is MenuBuilder) {
val menuBuilder = popup.menu as MenuBuilder
if (tabs[i].id in arrayOf(HOME, LOCAL, FEDERATED, HASHTAG, LIST)) {
if (data.id in arrayOf(HOME, LOCAL, FEDERATED, HASHTAG, LIST)) {
menuBuilder.findItem(R.id.tabReset).isVisible = true
}
if (tabs[i].id == LIST) {
if (data.id == LIST) {
menuBuilder.findItem(R.id.tabEditList).isVisible = true
}
if (tabs[i].id in arrayOf(HOME, LOCAL, FEDERATED, LIST)) {
if (data.id in arrayOf(HOME, LOCAL, FEDERATED, LIST)) {
menuBuilder.findItem(R.id.tabToggleStreaming).apply {
isVisible = true
isChecked = tabs[i].enableStreaming
isChecked = data.enableStreaming
}
}
if (tabs[i].id == NOTIFICATIONS) {
if (data.id == NOTIFICATIONS) {
menuBuilder.findItem(R.id.tabToggleNotificationsFilter).isVisible = true
}
@ -750,15 +809,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
val iconMarginPx = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 8f, resources.displayMetrics).toInt()
if (item.icon != null) {
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.LOLLIPOP) {
item.icon = InsetDrawable(item.icon, iconMarginPx, 0, iconMarginPx, 0)
} else {
item.icon = object : InsetDrawable(item.icon, iconMarginPx, 0, iconMarginPx, 0) {
override fun getIntrinsicWidth(): Int {
return intrinsicHeight + iconMarginPx + iconMarginPx
}
}
}
item.icon = InsetDrawable(item.icon, iconMarginPx, 0, iconMarginPx, 0)
setDrawableTint(this, item.icon!!, android.R.attr.textColorPrimary)
}
}
@ -766,12 +817,14 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
}
popup.setOnMenuItemClickListener { item ->
val fragment = adapter.getFragment(tab.position)
val fragment = tabAdapter.getFragment(tab.position)
when (item.itemId) {
R.id.tabJumpToTop -> {
if (fragment is ReselectableFragment) {
(fragment as ReselectableFragment).onReselect()
}
refreshComposeButtonState(tabAdapter, tab.position)
}
R.id.tabReset -> {
if (fragment is ResettableFragment) {
@ -780,8 +833,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
}
R.id.tabEditList -> {
AccountsInListFragment.newInstance(
tabs[i].arguments.getOrNull(0).orEmpty(),
tabs[i].arguments.getOrNull(1).orEmpty()
data.arguments.getOrNull(0).orEmpty(),
data.arguments.getOrNull(1).orEmpty()
).show(supportFragmentManager, null)
}
R.id.tabToggleStreaming -> {
@ -790,17 +843,10 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
item.isChecked = current
tintCheckIcon(item)
keepScreenOn()
tabs[i] = tabs[i].copy(enableStreaming = current)
tabs[position] = data.copy(enableStreaming = current)
accountManager.activeAccount?.let {
Single.fromCallable {
it.tabPreferences = tabs
accountManager.saveAccount(it)
}
.subscribeOn(Schedulers.io())
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
.subscribe()
it.tabPreferences = tabs
accountManager.saveAccount(it)
}
}
}
@ -812,22 +858,28 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
!prefs.getBoolean("showNotificationsFilter", true)
)
.apply()
eventHub.dispatch(PreferenceChangedEvent("showNotificationsFilter"))
lifecycleScope.launch {
eventHub.dispatch(PreferenceChangedEvent("showNotificationsFilter"))
}
}
}
}
false
}
popups.add(popup)
popups[position] = popup
}.also { it.attach() }
if (tabs[i].id == NOTIFICATIONS) {
notificationTabPosition = i
if (selectNotificationTab) {
tab.select()
}
}
}
// Selected tab is either
// - Notification tab (if appropriate)
// - The previously selected tab (if it hasn't been removed)
// - Left-most tab
val position = if (selectNotificationTab) {
tabs.indexOfFirst { it.id == NOTIFICATIONS }
} else {
previousTab?.let { tabs.indexOfFirst { it == previousTab } }
}.takeIf { it != -1 } ?: 0
binding.viewPager.setCurrentItem(position, false)
val pageMargin = resources.getDimensionPixelSize(R.dimen.tab_page_margin)
binding.viewPager.setPageTransformer(MarginPageTransformer(pageMargin))
@ -841,31 +893,41 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
onTabSelectedListener = object : OnTabSelectedListener {
override fun onTabSelected(tab: TabLayout.Tab) {
if (tab.position == notificationTabPosition) {
NotificationHelper.clearNotificationsForActiveAccount(this@MainActivity, accountManager)
}
binding.mainToolbar.title = tab.contentDescription
binding.mainToolbar.title = tabs[tab.position].title(this@MainActivity)
refreshComposeButtonState(tabAdapter, tab.position)
}
override fun onTabUnselected(tab: TabLayout.Tab) {}
override fun onTabReselected(tab: TabLayout.Tab) {
popups[tab.position].show()
popups[tab.position]?.show()
}
}.also {
activeTabLayout.addOnTabSelectedListener(it)
}
val activeTabPosition = if (selectNotificationTab) notificationTabPosition else 0
binding.mainToolbar.title = tabs[activeTabPosition].title(this@MainActivity)
supportActionBar?.title = tabs[activeTabPosition].title(this@MainActivity)
binding.mainToolbar.setOnClickListener {
(adapter.getFragment(activeTabLayout.selectedTabPosition) as? ReselectableFragment)?.onReselect()
(tabAdapter.getFragment(activeTabLayout.selectedTabPosition) as? ReselectableFragment)?.onReselect()
}
updateProfiles()
}
keepScreenOn()
private fun refreshComposeButtonState(adapter: MainPagerAdapter, tabPosition: Int) {
adapter.getFragment(tabPosition)?.also { fragment ->
if (fragment is FabFragment) {
if (fragment.isFabVisible()) {
binding.composeButton.show()
} else {
binding.composeButton.hide()
}
} else {
binding.composeButton.show()
}
}
}
private fun handleProfileClick(profile: IProfile, current: Boolean): Boolean {
@ -969,7 +1031,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
}
private fun loadDrawerAvatar(avatarUrl: String, showPlaceholder: Boolean) {
val hideTopToolbar = preferences.getBoolean(PrefKeys.HIDE_TOP_TOOLBAR, false)
val animateAvatars = preferences.getBoolean("animateGifAvatars", false)
@ -997,7 +1058,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
.into(avatarView)
}
} else {
binding.bottomNavAvatar.hide()
binding.topNavAvatar.hide()
@ -1104,16 +1164,17 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
private fun updateProfiles() {
val animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
val profiles: MutableList<IProfile> = accountManager.getAllAccountsOrderedByActive().map { acc ->
ProfileDrawerItem().apply {
isSelected = acc.isActive
nameText = acc.displayName.emojify(acc.emojis, header, animateEmojis)
iconUrl = acc.profilePictureUrl
isNameShown = true
identifier = acc.id
descriptionText = acc.fullName
}
}.toMutableList()
val profiles: MutableList<IProfile> =
accountManager.getAllAccountsOrderedByActive().map { acc ->
ProfileDrawerItem().apply {
isSelected = acc.isActive
nameText = acc.displayName.emojify(acc.emojis, header, animateEmojis)
iconUrl = acc.profilePictureUrl
isNameShown = true
identifier = acc.id
descriptionText = acc.fullName
}
}.toMutableList()
// reuse the already existing "add account" item
for (profile in header.profiles.orEmpty()) {
@ -1127,7 +1188,9 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
header.setActiveProfile(accountManager.activeAccount!!.id)
binding.mainToolbar.subtitle = if (accountManager.shouldDisplaySelfUsername(this)) {
accountManager.activeAccount!!.fullName
} else null
} else {
null
}
}
override fun getActionButton() = binding.composeButton

View File

@ -23,11 +23,8 @@ import android.view.Menu
import android.view.MenuItem
import androidx.activity.viewModels
import androidx.fragment.app.commit
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import at.connyduck.calladapter.networkresult.fold
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider
import autodispose2.autoDispose
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
@ -36,12 +33,13 @@ import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel.K
import com.keylesspalace.tusky.databinding.ActivityStatuslistBinding
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.FilterV1
import com.keylesspalace.tusky.util.viewBinding
import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import kotlinx.coroutines.launch
import net.accelf.yuito.QuickTootViewModel
import retrofit2.HttpException
import javax.inject.Inject
class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
@ -65,6 +63,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
private var unmuteTagItem: MenuItem? = null
/** The filter muting hashtag, null if unknown or hashtag is not filtered */
private var mutedFilterV1: FilterV1? = null
private var mutedFilter: Filter? = null
override fun onCreate(savedInstanceState: Bundle?) {
@ -104,10 +103,9 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
binding.viewQuickToot.attachViewModel(quickTootViewModel, this)
eventHub.events
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(AndroidLifecycleScopeProvider.from(this, Lifecycle.Event.ON_DESTROY))
.subscribe(binding.viewQuickToot::handleEvent)
lifecycleScope.launch {
eventHub.events.collect(binding.viewQuickToot::handleEvent)
}
binding.floatingBtn.setOnClickListener(binding.viewQuickToot::onFABClicked)
}
@ -193,49 +191,89 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
lifecycleScope.launch {
mastodonApi.getFilters().fold(
{ filters ->
for (filter in filters) {
if ((tag == filter.phrase) and filter.context.contains(Filter.HOME)) {
Log.d(TAG, "Tag $hashtag is filtered")
muteTagItem?.isVisible = false
unmuteTagItem?.isVisible = true
mutedFilter = filter
return@fold
mutedFilter = filters.firstOrNull { filter ->
filter.context.contains(Filter.Kind.HOME.kind) && filter.keywords.any {
it.keyword == tag
}
}
Log.d(TAG, "Tag $hashtag is not filtered")
mutedFilter = null
muteTagItem?.isEnabled = true
muteTagItem?.isVisible = true
muteTagItem?.isVisible = true
updateTagMuteState(mutedFilter != null)
},
{ throwable ->
Log.e(TAG, "Error getting filters: $throwable")
if (throwable is HttpException && throwable.code() == 404) {
mastodonApi.getFiltersV1().fold(
{ filters ->
mutedFilterV1 = filters.firstOrNull { filter ->
tag == filter.phrase && filter.context.contains(FilterV1.HOME)
}
updateTagMuteState(mutedFilterV1 != null)
},
{ throwable ->
Log.e(TAG, "Error getting filters: $throwable")
}
)
} else {
Log.e(TAG, "Error getting filters: $throwable")
}
}
)
}
}
private fun updateTagMuteState(muted: Boolean) {
if (muted) {
muteTagItem?.isVisible = false
muteTagItem?.isEnabled = false
unmuteTagItem?.isVisible = true
} else {
unmuteTagItem?.isVisible = false
muteTagItem?.isEnabled = true
muteTagItem?.isVisible = true
}
}
private fun muteTag(): Boolean {
val tag = hashtag ?: return true
lifecycleScope.launch {
mastodonApi.createFilter(
tag,
listOf(Filter.HOME),
irreversible = false,
wholeWord = true,
title = "#$tag",
context = listOf(FilterV1.HOME),
filterAction = Filter.Action.WARN.action,
expiresInSeconds = null
).fold(
{ filter ->
mutedFilter = filter
muteTagItem?.isVisible = false
unmuteTagItem?.isVisible = true
eventHub.dispatch(PreferenceChangedEvent(filter.context[0]))
if (mastodonApi.addFilterKeyword(filterId = filter.id, keyword = tag, wholeWord = true).isSuccess) {
mutedFilter = filter
updateTagMuteState(true)
eventHub.dispatch(PreferenceChangedEvent(filter.context[0]))
} else {
Snackbar.make(binding.root, getString(R.string.error_muting_hashtag_format, tag), Snackbar.LENGTH_SHORT).show()
Log.e(TAG, "Failed to mute #$tag")
}
},
{
Snackbar.make(binding.root, getString(R.string.error_muting_hashtag_format, tag), Snackbar.LENGTH_SHORT).show()
Log.e(TAG, "Failed to mute #$tag", it)
{ throwable ->
if (throwable is HttpException && throwable.code() == 404) {
mastodonApi.createFilterV1(
tag,
listOf(FilterV1.HOME),
irreversible = false,
wholeWord = true,
expiresInSeconds = null
).fold(
{ filter ->
mutedFilterV1 = filter
updateTagMuteState(true)
eventHub.dispatch(PreferenceChangedEvent(filter.context[0]))
},
{ throwable ->
Snackbar.make(binding.root, getString(R.string.error_muting_hashtag_format, tag), Snackbar.LENGTH_SHORT).show()
Log.e(TAG, "Failed to mute #$tag", throwable)
}
)
} else {
Snackbar.make(binding.root, getString(R.string.error_muting_hashtag_format, tag), Snackbar.LENGTH_SHORT).show()
Log.e(TAG, "Failed to mute #$tag", throwable)
}
}
)
}
@ -244,19 +282,49 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
}
private fun unmuteTag(): Boolean {
val filter = mutedFilter ?: return true
lifecycleScope.launch {
mastodonApi.deleteFilter(filter.id).fold(
val tag = hashtag
val result = if (mutedFilter != null) {
val filter = mutedFilter!!
if (filter.context.size > 1) {
// This filter exists in multiple contexts, just remove the home context
mastodonApi.updateFilter(
id = filter.id,
context = filter.context.filter { it != Filter.Kind.HOME.kind }
)
} else {
mastodonApi.deleteFilter(filter.id)
}
} else if (mutedFilterV1 != null) {
mutedFilterV1?.let { filter ->
if (filter.context.size > 1) {
// This filter exists in multiple contexts, just remove the home context
mastodonApi.updateFilterV1(
id = filter.id,
phrase = filter.phrase,
context = filter.context.filter { it != FilterV1.HOME },
irreversible = null,
wholeWord = null,
expiresInSeconds = null
)
} else {
mastodonApi.deleteFilterV1(filter.id)
}
}
} else {
null
}
result?.fold(
{
muteTagItem?.isVisible = true
unmuteTagItem?.isVisible = false
eventHub.dispatch(PreferenceChangedEvent(filter.context[0]))
updateTagMuteState(false)
eventHub.dispatch(PreferenceChangedEvent(Filter.Kind.HOME.kind))
mutedFilterV1 = null
mutedFilter = null
},
{
Snackbar.make(binding.root, getString(R.string.error_unmuting_hashtag_format, filter.phrase), Snackbar.LENGTH_SHORT).show()
Log.e(TAG, "Failed to unmute #${filter.phrase}", it)
{ throwable ->
Snackbar.make(binding.root, getString(R.string.error_unmuting_hashtag_format, tag), Snackbar.LENGTH_SHORT).show()
Log.e(TAG, "Failed to unmute #$tag", throwable)
}
)
}

View File

@ -20,9 +20,11 @@ import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.fragment.app.Fragment
import com.keylesspalace.tusky.components.conversation.ConversationsFragment
import com.keylesspalace.tusky.components.notifications.NotificationsFragment
import com.keylesspalace.tusky.components.timeline.TimelineFragment
import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel
import com.keylesspalace.tusky.fragment.NotificationsFragment
import com.keylesspalace.tusky.components.trending.TrendingFragment
import java.util.Objects
/** this would be a good case for a sealed class, but that does not work nice with Room */
@ -31,6 +33,7 @@ const val NOTIFICATIONS = "Notifications"
const val LOCAL = "Local"
const val FEDERATED = "Federated"
const val DIRECT = "Direct"
const val TRENDING = "Trending"
const val HASHTAG = "Hashtag"
const val LIST = "List"
@ -44,60 +47,82 @@ data class TabData(
val arguments: List<String> = emptyList(),
val title: (Context) -> String = { context -> context.getString(text) },
val enableStreaming: Boolean = false,
)
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as TabData
if (id != other.id) return false
if (arguments != other.arguments) return false
return true
}
override fun hashCode() = Objects.hash(id, arguments)
}
fun List<TabData>.hasTab(id: String): Boolean = this.find { it.id == id } != null
fun createTabDataFromId(id: String, arguments: List<String> = emptyList()): TabData {
val enableStreaming = id.endsWith(STREAMING)
return when (if (enableStreaming) id.slice(IntRange(0, id.length - 4)) else id) {
HOME -> TabData(
HOME,
R.string.title_home,
R.drawable.ic_home_24dp,
{ TimelineFragment.newInstance(TimelineViewModel.Kind.HOME, enableStreaming = enableStreaming) },
enableStreaming = enableStreaming
id = HOME,
text = R.string.title_home,
icon = R.drawable.ic_home_24dp,
fragment = { TimelineFragment.newInstance(TimelineViewModel.Kind.HOME, enableStreaming = enableStreaming) },
enableStreaming = enableStreaming,
)
NOTIFICATIONS -> TabData(
NOTIFICATIONS,
R.string.title_notifications,
R.drawable.ic_notifications_24dp,
{ NotificationsFragment.newInstance() }
id = NOTIFICATIONS,
text = R.string.title_notifications,
icon = R.drawable.ic_notifications_24dp,
fragment = { NotificationsFragment.newInstance() }
)
LOCAL -> TabData(
LOCAL,
R.string.title_public_local,
R.drawable.ic_local_24dp,
{ TimelineFragment.newInstance(TimelineViewModel.Kind.PUBLIC_LOCAL, enableStreaming = enableStreaming) },
enableStreaming = enableStreaming
id = LOCAL,
text = R.string.title_public_local,
icon = R.drawable.ic_local_24dp,
fragment = { TimelineFragment.newInstance(TimelineViewModel.Kind.PUBLIC_LOCAL, enableStreaming = enableStreaming) },
enableStreaming = enableStreaming,
)
FEDERATED -> TabData(
FEDERATED,
R.string.title_public_federated,
R.drawable.ic_public_24dp,
{ TimelineFragment.newInstance(TimelineViewModel.Kind.PUBLIC_FEDERATED, enableStreaming = enableStreaming) },
enableStreaming = enableStreaming
id = FEDERATED,
text = R.string.title_public_federated,
icon = R.drawable.ic_public_24dp,
fragment = { TimelineFragment.newInstance(TimelineViewModel.Kind.PUBLIC_FEDERATED, enableStreaming = enableStreaming) },
enableStreaming = enableStreaming,
)
DIRECT -> TabData(
DIRECT,
R.string.title_direct_messages,
R.drawable.ic_reblog_direct_24dp,
{ ConversationsFragment.newInstance() }
id = DIRECT,
text = R.string.title_direct_messages,
icon = R.drawable.ic_reblog_direct_24dp,
fragment = { ConversationsFragment.newInstance() }
)
TRENDING -> TabData(
id = TRENDING,
text = R.string.title_public_trending_hashtags,
icon = R.drawable.ic_trending_up_24px,
fragment = { TrendingFragment.newInstance() }
)
HASHTAG -> TabData(
HASHTAG,
R.string.hashtags,
R.drawable.ic_hashtag,
{ args -> TimelineFragment.newHashtagInstance(args) },
arguments,
{ context -> arguments.joinToString(separator = " ") { context.getString(R.string.title_tag, it) } }
id = HASHTAG,
text = R.string.hashtags,
icon = R.drawable.ic_hashtag,
fragment = { args -> TimelineFragment.newHashtagInstance(args) },
arguments = arguments,
title = { context -> arguments.joinToString(separator = " ") { context.getString(R.string.title_tag, it) } }
)
LIST -> TabData(
LIST,
R.string.list,
R.drawable.ic_list,
{ args -> TimelineFragment.newInstance(TimelineViewModel.Kind.LIST, args.getOrNull(0).orEmpty(), true, enableStreaming) },
arguments,
{ arguments.getOrNull(1).orEmpty() },
enableStreaming
id = LIST,
text = R.string.list,
icon = R.drawable.ic_list,
fragment = { args -> TimelineFragment.newInstance(TimelineViewModel.Kind.LIST, args.getOrNull(0).orEmpty()) },
arguments = arguments,
title = { arguments.getOrNull(1).orEmpty() },
enableStreaming = enableStreaming,
)
else -> throw IllegalArgumentException("unknown tab type")
}

View File

@ -15,16 +15,21 @@
package com.keylesspalace.tusky
import android.content.Intent
import android.graphics.Color
import android.os.Bundle
import android.util.Log
import android.view.Gravity
import android.view.View
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.TextView
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.AppCompatEditText
import androidx.core.view.updatePadding
import androidx.lifecycle.Lifecycle
import androidx.core.widget.doOnTextChanged
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.ItemTouchHelper
@ -33,8 +38,7 @@ import androidx.recyclerview.widget.RecyclerView
import androidx.transition.TransitionManager
import at.connyduck.calladapter.networkresult.fold
import at.connyduck.sparkbutton.helpers.Utils
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from
import autodispose2.autoDispose
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.transition.MaterialArcMotion
import com.google.android.material.transition.MaterialContainerTransform
import com.keylesspalace.tusky.adapter.ItemInteractionListener
@ -45,11 +49,16 @@ import com.keylesspalace.tusky.appstore.MainTabsChangedEvent
import com.keylesspalace.tusky.databinding.ActivityTabPreferenceBinding
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.onTextChanged
import com.keylesspalace.tusky.util.getDimension
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.unsafeLazy
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.util.regex.Pattern
import javax.inject.Inject
@ -58,6 +67,7 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
@Inject
lateinit var mastodonApi: MastodonApi
@Inject
lateinit var eventHub: EventHub
@ -70,9 +80,9 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
private var tabsChanged = false
private val selectedItemElevation by lazy { resources.getDimension(R.dimen.selected_drag_item_elevation) }
private val selectedItemElevation by unsafeLazy { resources.getDimension(R.dimen.selected_drag_item_elevation) }
private val hashtagRegex by lazy { Pattern.compile("([\\w_]*[\\p{Alpha}_][\\w_]*)", Pattern.CASE_INSENSITIVE) }
private val hashtagRegex by unsafeLazy { Pattern.compile("([\\w_]*[\\p{Alpha}_][\\w_]*)", Pattern.CASE_INSENSITIVE) }
private val onFabDismissedCallback = object : OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
@ -160,7 +170,6 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
}
override fun onTabAdded(tab: TabData) {
if (currentTabs.size >= MAX_TAB_COUNT) {
return
}
@ -222,7 +231,6 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
}
private fun showAddHashtagDialog(tab: TabData? = null, tabPosition: Int = 0) {
val frameLayout = FrameLayout(this)
val padding = Utils.dpToPx(this, 8)
frameLayout.updatePadding(left = padding, right = padding)
@ -254,7 +262,7 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
}
.create()
editText.onTextChanged { s, _, _, _ ->
editText.doOnTextChanged { s, _, _, _ ->
dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = validateHashtag(s)
}
@ -265,19 +273,30 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
private fun showSelectListDialog() {
val adapter = ListSelectionAdapter(this)
lifecycleScope.launch {
mastodonApi.getLists().fold(
{ lists ->
adapter.addAll(lists)
},
{ throwable ->
Log.e("TabPreferenceActivity", "failed to load lists", throwable)
}
)
}
AlertDialog.Builder(this)
val statusLayout = LinearLayout(this)
statusLayout.gravity = Gravity.CENTER
val progress = ProgressBar(this)
val preferredPadding = getDimension(this, androidx.appcompat.R.attr.dialogPreferredPadding)
progress.setPadding(preferredPadding, 0, preferredPadding, 0)
progress.visible(false)
val noListsText = TextView(this)
noListsText.setPadding(preferredPadding, 0, preferredPadding, 0)
noListsText.text = getText(R.string.select_list_empty)
noListsText.visible(false)
statusLayout.addView(progress)
statusLayout.addView(noListsText)
val dialogBuilder = AlertDialog.Builder(this)
.setTitle(R.string.select_list_title)
.setNeutralButton(R.string.select_list_manage) { _, _ ->
val listIntent = Intent(applicationContext, ListsActivity::class.java)
startActivity(listIntent)
}
.setNegativeButton(android.R.string.cancel, null)
.setView(statusLayout)
.setAdapter(adapter) { _, position ->
val list = adapter.getItem(position)
val newTab = createTabDataFromId(LIST, listOf(list!!.id, list.title))
@ -286,7 +305,40 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
updateAvailableTabs()
saveTabs()
}
.show()
val showProgressBarJob = getProgressBarJob(progress, 500)
showProgressBarJob.start()
val dialog = dialogBuilder.show()
lifecycleScope.launch {
mastodonApi.getLists().fold(
{ lists ->
showProgressBarJob.cancel()
adapter.addAll(lists)
if (lists.isEmpty()) {
noListsText.show()
}
},
{ throwable ->
dialog.hide()
Log.e("TabPreferenceActivity", "failed to load lists", throwable)
Snackbar.make(binding.root, R.string.error_list_load, Snackbar.LENGTH_LONG).show()
}
)
}
}
private fun getProgressBarJob(progressView: View, delayMs: Long) = this.lifecycleScope.launch(
start = CoroutineStart.LAZY
) {
try {
delay(delayMs)
progressView.show()
awaitCancellation()
} finally {
progressView.hide()
}
}
private fun validateHashtag(input: CharSequence?): Boolean {
@ -317,6 +369,10 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
if (!currentTabs.contains(directMessagesTab)) {
addableTabs.add(directMessagesTab)
}
val trendingTab = createTabDataFromId(TRENDING)
if (!currentTabs.contains(trendingTab)) {
addableTabs.add(trendingTab)
}
addableTabs.add(createTabDataFromId(HASHTAG))
addableTabs.add(createTabDataFromId(LIST))
@ -337,13 +393,10 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
private fun saveTabs() {
accountManager.activeAccount?.let {
Single.fromCallable {
lifecycleScope.launch(Dispatchers.IO) {
it.tabPreferences = currentTabs
accountManager.saveAccount(it)
}
.subscribeOn(Schedulers.io())
.autoDispose(from(this, Lifecycle.Event.ON_DESTROY))
.subscribe()
}
tabsChanged = true
}
@ -351,7 +404,9 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
override fun onPause() {
super.onPause()
if (tabsChanged) {
eventHub.dispatch(MainTabsChangedEvent(currentTabs))
lifecycleScope.launch {
eventHub.dispatch(MainTabsChangedEvent(currentTabs))
}
}
}

View File

@ -16,12 +16,14 @@
package com.keylesspalace.tusky
import android.app.Application
import android.content.SharedPreferences
import android.util.Log
import androidx.preference.PreferenceManager
import androidx.work.WorkManager
import autodispose2.AutoDisposePlugins
import com.keylesspalace.tusky.components.notifications.NotificationWorkerFactory
import com.keylesspalace.tusky.di.AppInjector
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.settings.SCHEMA_VERSION
import com.keylesspalace.tusky.util.APP_THEME_DEFAULT
import com.keylesspalace.tusky.util.LocaleManager
import com.keylesspalace.tusky.util.setAppNightMode
@ -36,7 +38,6 @@ import java.security.Security
import javax.inject.Inject
class TuskyApplication : Application(), HasAndroidInjector {
@Inject
lateinit var androidInjector: DispatchingAndroidInjector<Any>
@ -46,6 +47,9 @@ class TuskyApplication : Application(), HasAndroidInjector {
@Inject
lateinit var localeManager: LocaleManager
@Inject
lateinit var sharedPreferences: SharedPreferences
override fun onCreate() {
// Uncomment me to get StrictMode violation logs
// if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
@ -65,7 +69,11 @@ class TuskyApplication : Application(), HasAndroidInjector {
AppInjector.init(this)
val preferences = PreferenceManager.getDefaultSharedPreferences(this)
// Migrate shared preference keys and defaults from version to version.
val oldVersion = sharedPreferences.getInt(PrefKeys.SCHEMA_VERSION, 0)
if (oldVersion != SCHEMA_VERSION) {
upgradeSharedPreferences(oldVersion, SCHEMA_VERSION)
}
// In this case, we want to have the emoji preferences merged with the other ones
// Copied from PreferenceManager.getDefaultSharedPreferenceName
@ -73,7 +81,7 @@ class TuskyApplication : Application(), HasAndroidInjector {
EmojiPackHelper.init(this, DefaultEmojiPackList.get(this), allowPackImports = false)
// init night mode
val theme = preferences.getString("appTheme", APP_THEME_DEFAULT)
val theme = sharedPreferences.getString("appTheme", APP_THEME_DEFAULT)
setAppNightMode(theme)
localeManager.setLocale()
@ -91,4 +99,24 @@ class TuskyApplication : Application(), HasAndroidInjector {
}
override fun androidInjector() = androidInjector
private fun upgradeSharedPreferences(oldVersion: Int, newVersion: Int) {
Log.d(TAG, "Upgrading shared preferences: $oldVersion -> $newVersion")
val editor = sharedPreferences.edit()
if (oldVersion < 2023022701) {
// These preferences are (now) handled in AccountPreferenceHandler. Remove them from shared for clarity.
editor.remove(PrefKeys.ALWAYS_OPEN_SPOILER)
editor.remove(PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA)
editor.remove(PrefKeys.MEDIA_PREVIEW_ENABLED)
}
editor.putInt(PrefKeys.SCHEMA_VERSION, newVersion)
editor.apply()
}
companion object {
private const val TAG = "TuskyApplication"
}
}

View File

@ -306,8 +306,9 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
isCreating = false
invalidateOptionsMenu()
binding.progressBarShare.visibility = View.GONE
if (result)
if (result) {
shareFile(file, "image/png")
}
},
{ error ->
isCreating = false

View File

@ -15,10 +15,9 @@
package com.keylesspalace.tusky.adapter
import android.text.Editable
import android.text.TextWatcher
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.widget.doAfterTextChanged
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.databinding.ItemEditFieldBinding
import com.keylesspalace.tusky.entity.StringField
@ -81,25 +80,13 @@ class AccountFieldEditAdapter : RecyclerView.Adapter<BindingHolder<ItemEditField
holder.binding.accountFieldValueTextLayout.counterMaxLength = it
}
holder.binding.accountFieldNameText.addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(newText: Editable) {
fieldData[holder.bindingAdapterPosition].first = newText.toString()
}
holder.binding.accountFieldNameText.doAfterTextChanged { newText ->
fieldData[holder.bindingAdapterPosition].first = newText.toString()
}
override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {}
})
holder.binding.accountFieldValueText.addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(newText: Editable) {
fieldData[holder.bindingAdapterPosition].second = newText.toString()
}
override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {}
})
holder.binding.accountFieldValueText.doAfterTextChanged { newText ->
fieldData[holder.bindingAdapterPosition].second = newText.toString()
}
}
class MutableStringPair(var first: String, var second: String)

View File

@ -1,82 +0,0 @@
/* Copyright 2017 Andrew Dawson
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.adapter
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.interfaces.AccountActionListener
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.loadAvatar
/** Displays a list of blocked accounts. */
class BlocksAdapter(
accountActionListener: AccountActionListener,
animateAvatar: Boolean,
animateEmojis: Boolean,
showBotOverlay: Boolean,
) : AccountAdapter<BlocksAdapter.BlockedUserViewHolder>(
accountActionListener,
animateAvatar,
animateEmojis,
showBotOverlay
) {
override fun createAccountViewHolder(parent: ViewGroup): BlockedUserViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_blocked_user, parent, false)
return BlockedUserViewHolder(view)
}
override fun onBindAccountViewHolder(viewHolder: BlockedUserViewHolder, position: Int) {
viewHolder.setupWithAccount(accountList[position], animateAvatar, animateEmojis)
viewHolder.setupActionListener(accountActionListener)
}
class BlockedUserViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val avatar: ImageView = itemView.findViewById(R.id.blocked_user_avatar)
private val username: TextView = itemView.findViewById(R.id.blocked_user_username)
private val displayName: TextView = itemView.findViewById(R.id.blocked_user_display_name)
private val unblock: ImageButton = itemView.findViewById(R.id.blocked_user_unblock)
private var id: String? = null
fun setupWithAccount(account: TimelineAccount, animateAvatar: Boolean, animateEmojis: Boolean) {
id = account.id
val emojifiedName = account.name.emojify(account.emojis, displayName, animateEmojis)
displayName.text = emojifiedName
val format = username.context.getString(R.string.post_username_format)
val formattedUsername = String.format(format, account.username)
username.text = formattedUsername
val avatarRadius = avatar.context.resources
.getDimensionPixelSize(R.dimen.avatar_radius_48dp)
loadAvatar(account.avatar, avatar, avatarRadius, animateAvatar)
}
fun setupActionListener(listener: AccountActionListener) {
unblock.setOnClickListener {
val position = bindingAdapterPosition
if (position != RecyclerView.NO_POSITION) {
listener.onBlock(false, id, position)
}
}
itemView.setOnClickListener { listener.onViewAccount(id) }
}
}
}

View File

@ -21,18 +21,47 @@ import android.text.Spanned
import android.text.style.StyleSpan
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.notifications.NotificationsPagingAdapter
import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.interfaces.AccountActionListener
import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.loadAvatar
import com.keylesspalace.tusky.util.parseAsMastodonHtml
import com.keylesspalace.tusky.util.setClickableText
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.unicodeWrap
import com.keylesspalace.tusky.util.visible
import com.keylesspalace.tusky.viewdata.NotificationViewData
class FollowRequestViewHolder(
private val binding: ItemFollowRequestBinding,
private val accountActionListener: AccountActionListener,
private val linkListener: LinkListener,
private val showHeader: Boolean
) : RecyclerView.ViewHolder(binding.root) {
) : NotificationsPagingAdapter.ViewHolder, RecyclerView.ViewHolder(binding.root) {
override fun bind(
viewData: NotificationViewData,
payloads: List<*>?,
statusDisplayOptions: StatusDisplayOptions
) {
// Skip updates with payloads. That indicates a timestamp update, and
// this view does not have timestamps.
if (!payloads.isNullOrEmpty()) return
setupWithAccount(
viewData.account,
statusDisplayOptions.animateAvatars,
statusDisplayOptions.animateEmojis,
statusDisplayOptions.showBotOverlay
)
setupActionListener(accountActionListener, viewData.account.id)
}
fun setupWithAccount(
account: TimelineAccount,
@ -41,20 +70,41 @@ class FollowRequestViewHolder(
showBotOverlay: Boolean
) {
val wrappedName = account.name.unicodeWrap()
val emojifiedName: CharSequence = wrappedName.emojify(account.emojis, itemView, animateEmojis)
val emojifiedName: CharSequence = wrappedName.emojify(
account.emojis,
itemView,
animateEmojis
)
binding.displayNameTextView.text = emojifiedName
if (showHeader) {
val wholeMessage: String = itemView.context.getString(R.string.notification_follow_request_format, wrappedName)
val wholeMessage: String = itemView.context.getString(
R.string.notification_follow_request_format,
wrappedName
)
binding.notificationTextView.text = SpannableStringBuilder(wholeMessage).apply {
setSpan(StyleSpan(Typeface.BOLD), 0, wrappedName.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
setSpan(
StyleSpan(Typeface.BOLD),
0,
wrappedName.length,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
}.emojify(account.emojis, itemView, animateEmojis)
}
binding.notificationTextView.visible(showHeader)
val format = itemView.context.getString(R.string.post_username_format)
val formattedUsername = String.format(format, account.username)
val formattedUsername = itemView.context.getString(R.string.post_username_format, account.username)
binding.usernameTextView.text = formattedUsername
if (account.note.isEmpty()) {
binding.accountNote.hide()
} else {
binding.accountNote.show()
val emojifiedNote = account.note.parseAsMastodonHtml()
.emojify(account.emojis, binding.accountNote, animateEmojis)
setClickableText(binding.accountNote, emojifiedNote, emptyList(), null, linkListener)
}
val avatarRadius = binding.avatar.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp)
loadAvatar(account.avatar, binding.avatar, avatarRadius, animateAvatar)
binding.avatarBadge.visible(showBotOverlay && account.bot)
}
fun setupActionListener(listener: AccountActionListener, accountId: String) {

View File

@ -26,7 +26,6 @@ import com.keylesspalace.tusky.entity.MastoList
class ListSelectionAdapter(context: Context) : ArrayAdapter<MastoList>(context, R.layout.item_picker_list) {
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val binding = if (convertView == null) {
ItemPickerListBinding.inflate(LayoutInflater.from(context), parent, false)
} else {

View File

@ -1,714 +0,0 @@
/* Copyright 2021 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.adapter;
import android.content.Context;
import android.graphics.Color;
import android.graphics.PorterDuff;
import android.graphics.Typeface;
import android.graphics.drawable.Drawable;
import android.text.InputFilter;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.style.StyleSpan;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.ColorRes;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.core.content.ContextCompat;
import androidx.recyclerview.widget.RecyclerView;
import com.bumptech.glide.Glide;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding;
import com.keylesspalace.tusky.databinding.ItemReportNotificationBinding;
import com.keylesspalace.tusky.databinding.ViewQuoteInlineBinding;
import com.keylesspalace.tusky.entity.Emoji;
import com.keylesspalace.tusky.entity.Notification;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.entity.TimelineAccount;
import com.keylesspalace.tusky.interfaces.AccountActionListener;
import com.keylesspalace.tusky.interfaces.LinkListener;
import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.util.AbsoluteTimeFormatter;
import com.keylesspalace.tusky.util.CardViewMode;
import com.keylesspalace.tusky.util.CustomEmojiHelper;
import com.keylesspalace.tusky.util.ImageLoadingHelper;
import com.keylesspalace.tusky.util.LinkHelper;
import com.keylesspalace.tusky.util.SmartLengthInputFilter;
import com.keylesspalace.tusky.util.StatusDisplayOptions;
import com.keylesspalace.tusky.util.StringUtils;
import com.keylesspalace.tusky.util.TimestampUtils;
import com.keylesspalace.tusky.viewdata.NotificationViewData;
import com.keylesspalace.tusky.viewdata.StatusViewData;
import net.accelf.yuito.QuoteInlineHelper;
import java.util.Date;
import java.util.List;
import at.connyduck.sparkbutton.helpers.Utils;
public class NotificationsAdapter extends RecyclerView.Adapter {
public interface AdapterDataSource<T> {
int getItemCount();
T getItemAt(int pos);
}
private static final int VIEW_TYPE_STATUS = 0;
private static final int VIEW_TYPE_STATUS_NOTIFICATION = 1;
private static final int VIEW_TYPE_FOLLOW = 2;
private static final int VIEW_TYPE_FOLLOW_REQUEST = 3;
private static final int VIEW_TYPE_PLACEHOLDER = 4;
private static final int VIEW_TYPE_REPORT = 5;
private static final int VIEW_TYPE_UNKNOWN = 6;
private static final InputFilter[] COLLAPSE_INPUT_FILTER = new InputFilter[]{SmartLengthInputFilter.INSTANCE};
private static final InputFilter[] NO_INPUT_FILTER = new InputFilter[0];
private String accountId;
private StatusDisplayOptions statusDisplayOptions;
private StatusActionListener statusListener;
private NotificationActionListener notificationActionListener;
private AccountActionListener accountActionListener;
private AdapterDataSource<NotificationViewData> dataSource;
private final AbsoluteTimeFormatter absoluteTimeFormatter = new AbsoluteTimeFormatter();
public NotificationsAdapter(String accountId,
AdapterDataSource<NotificationViewData> dataSource,
StatusDisplayOptions statusDisplayOptions,
StatusActionListener statusListener,
NotificationActionListener notificationActionListener,
AccountActionListener accountActionListener) {
this.accountId = accountId;
this.dataSource = dataSource;
this.statusDisplayOptions = statusDisplayOptions;
this.statusListener = statusListener;
this.notificationActionListener = notificationActionListener;
this.accountActionListener = accountActionListener;
}
@NonNull
@Override
public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
LayoutInflater inflater = LayoutInflater.from(parent.getContext());
switch (viewType) {
case VIEW_TYPE_STATUS: {
View view = inflater
.inflate(R.layout.item_status, parent, false);
return new StatusViewHolder(view);
}
case VIEW_TYPE_STATUS_NOTIFICATION: {
View view = inflater
.inflate(R.layout.item_status_notification, parent, false);
return new StatusNotificationViewHolder(view, statusDisplayOptions, absoluteTimeFormatter);
}
case VIEW_TYPE_FOLLOW: {
View view = inflater
.inflate(R.layout.item_follow, parent, false);
return new FollowViewHolder(view, statusDisplayOptions);
}
case VIEW_TYPE_FOLLOW_REQUEST: {
ItemFollowRequestBinding binding = ItemFollowRequestBinding.inflate(inflater, parent, false);
return new FollowRequestViewHolder(binding, true);
}
case VIEW_TYPE_PLACEHOLDER: {
View view = inflater
.inflate(R.layout.item_status_placeholder, parent, false);
return new PlaceholderViewHolder(view);
}
case VIEW_TYPE_REPORT: {
ItemReportNotificationBinding binding = ItemReportNotificationBinding.inflate(inflater, parent, false);
return new ReportNotificationViewHolder(binding);
}
default:
case VIEW_TYPE_UNKNOWN: {
View view = new View(parent.getContext());
view.setLayoutParams(
new ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
Utils.dpToPx(parent.getContext(), 24)
)
);
return new RecyclerView.ViewHolder(view) {
};
}
}
}
@Override
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) {
bindViewHolder(viewHolder, position, null);
}
@Override
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position, @NonNull List payloads) {
bindViewHolder(viewHolder, position, payloads);
}
private void bindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position, @Nullable List payloads) {
Object payloadForHolder = payloads != null && !payloads.isEmpty() ? payloads.get(0) : null;
if (position < this.dataSource.getItemCount()) {
NotificationViewData notification = dataSource.getItemAt(position);
if (notification instanceof NotificationViewData.Placeholder) {
if (payloadForHolder == null) {
NotificationViewData.Placeholder placeholder = ((NotificationViewData.Placeholder) notification);
PlaceholderViewHolder holder = (PlaceholderViewHolder) viewHolder;
holder.setup(statusListener, placeholder.isLoading());
}
return;
}
NotificationViewData.Concrete concreteNotification =
(NotificationViewData.Concrete) notification;
switch (viewHolder.getItemViewType()) {
case VIEW_TYPE_STATUS: {
StatusViewHolder holder = (StatusViewHolder) viewHolder;
StatusViewData.Concrete status = concreteNotification.getStatusViewData();
if (status == null) {
/* in some very rare cases servers sends null status even though they should not,
* we have to handle it somehow */
holder.showStatusContent(false);
} else {
if (payloads == null) {
holder.showStatusContent(true);
}
holder.setupWithStatus(status, statusListener, statusDisplayOptions, payloadForHolder);
}
if (concreteNotification.getType() == Notification.Type.POLL) {
holder.setPollInfo(accountId.equals(concreteNotification.getAccount().getId()));
} else {
holder.hideStatusInfo();
}
break;
}
case VIEW_TYPE_STATUS_NOTIFICATION: {
StatusNotificationViewHolder holder = (StatusNotificationViewHolder) viewHolder;
StatusViewData.Concrete statusViewData = concreteNotification.getStatusViewData();
if (payloadForHolder == null) {
if (statusViewData == null) {
/* in some very rare cases servers sends null status even though they should not,
* we have to handle it somehow */
holder.showNotificationContent(false);
} else {
holder.showNotificationContent(true);
Status status = statusViewData.getActionable();
holder.setDisplayName(status.getAccount().getName(), status.getAccount().getEmojis());
holder.setUsername(status.getAccount().getUsername());
holder.setCreatedAt(status.getCreatedAt());
if (concreteNotification.getType() == Notification.Type.STATUS ||
concreteNotification.getType() == Notification.Type.UPDATE) {
holder.setAvatar(status.getAccount().getAvatar(), status.getAccount().getBot());
} else {
holder.setAvatars(status.getAccount().getAvatar(),
concreteNotification.getAccount().getAvatar());
}
}
holder.setMessage(concreteNotification, statusListener);
holder.setupButtons(notificationActionListener,
concreteNotification.getAccount().getId(),
concreteNotification.getId());
} else {
if (payloadForHolder instanceof List)
for (Object item : (List) payloadForHolder) {
if (StatusBaseViewHolder.Key.KEY_CREATED.equals(item) && statusViewData != null) {
holder.setCreatedAt(statusViewData.getStatus().getActionableStatus().getCreatedAt());
}
}
}
break;
}
case VIEW_TYPE_FOLLOW: {
if (payloadForHolder == null) {
FollowViewHolder holder = (FollowViewHolder) viewHolder;
holder.setMessage(concreteNotification.getAccount(), concreteNotification.getType() == Notification.Type.SIGN_UP);
holder.setupButtons(notificationActionListener, concreteNotification.getAccount().getId());
}
break;
}
case VIEW_TYPE_FOLLOW_REQUEST: {
if (payloadForHolder == null) {
FollowRequestViewHolder holder = (FollowRequestViewHolder) viewHolder;
holder.setupWithAccount(concreteNotification.getAccount(), statusDisplayOptions.animateAvatars(), statusDisplayOptions.animateEmojis(), statusDisplayOptions.showBotOverlay());
holder.setupActionListener(accountActionListener, concreteNotification.getAccount().getId());
}
break;
}
case VIEW_TYPE_REPORT: {
if (payloadForHolder == null) {
ReportNotificationViewHolder holder = (ReportNotificationViewHolder) viewHolder;
holder.setupWithReport(concreteNotification.getAccount(), concreteNotification.getReport(), statusDisplayOptions.animateAvatars(), statusDisplayOptions.animateEmojis());
holder.setupActionListener(notificationActionListener, concreteNotification.getReport().getTargetAccount().getId(), concreteNotification.getAccount().getId(), concreteNotification.getReport().getId());
}
}
default:
}
}
}
@Override
public int getItemCount() {
return dataSource.getItemCount();
}
public void setMediaPreviewEnabled(boolean mediaPreviewEnabled) {
this.statusDisplayOptions = statusDisplayOptions.copy(
statusDisplayOptions.animateAvatars(),
mediaPreviewEnabled,
statusDisplayOptions.useAbsoluteTime(),
statusDisplayOptions.showBotOverlay(),
statusDisplayOptions.useBlurhash(),
CardViewMode.NONE,
statusDisplayOptions.confirmReblogs(),
statusDisplayOptions.confirmFavourites(),
statusDisplayOptions.hideStats(),
statusDisplayOptions.animateEmojis(),
statusDisplayOptions.quoteEnabled()
);
}
public boolean isMediaPreviewEnabled() {
return this.statusDisplayOptions.mediaPreviewEnabled();
}
@Override
public int getItemViewType(int position) {
NotificationViewData notification = dataSource.getItemAt(position);
if (notification instanceof NotificationViewData.Concrete) {
NotificationViewData.Concrete concrete = ((NotificationViewData.Concrete) notification);
switch (concrete.getType()) {
case MENTION:
case POLL: {
return VIEW_TYPE_STATUS;
}
case STATUS:
case FAVOURITE:
case REBLOG:
case UPDATE: {
return VIEW_TYPE_STATUS_NOTIFICATION;
}
case FOLLOW:
case SIGN_UP: {
return VIEW_TYPE_FOLLOW;
}
case FOLLOW_REQUEST: {
return VIEW_TYPE_FOLLOW_REQUEST;
}
case REPORT: {
return VIEW_TYPE_REPORT;
}
default: {
return VIEW_TYPE_UNKNOWN;
}
}
} else if (notification instanceof NotificationViewData.Placeholder) {
return VIEW_TYPE_PLACEHOLDER;
} else {
throw new AssertionError("Unknown notification type");
}
}
public interface NotificationActionListener {
void onViewAccount(String id);
void onViewStatusForNotificationId(String notificationId);
void onViewReport(String reportId);
void onExpandedChange(boolean expanded, int position);
/**
* Called when the status {@link android.widget.ToggleButton} responsible for collapsing long
* status content is interacted with.
*
* @param isCollapsed Whether the status content is shown in a collapsed state or fully.
* @param position The position of the status in the list.
*/
void onNotificationContentCollapsedChange(boolean isCollapsed, int position);
}
private static class FollowViewHolder extends RecyclerView.ViewHolder {
private TextView message;
private TextView usernameView;
private TextView displayNameView;
private ImageView avatar;
private StatusDisplayOptions statusDisplayOptions;
FollowViewHolder(View itemView, StatusDisplayOptions statusDisplayOptions) {
super(itemView);
message = itemView.findViewById(R.id.notification_text);
usernameView = itemView.findViewById(R.id.notification_username);
displayNameView = itemView.findViewById(R.id.notification_display_name);
avatar = itemView.findViewById(R.id.notification_avatar);
this.statusDisplayOptions = statusDisplayOptions;
}
void setMessage(TimelineAccount account, Boolean isSignUp) {
Context context = message.getContext();
String format = context.getString(isSignUp ? R.string.notification_sign_up_format : R.string.notification_follow_format);
String wrappedDisplayName = StringUtils.unicodeWrap(account.getName());
String wholeMessage = String.format(format, wrappedDisplayName);
CharSequence emojifiedMessage = CustomEmojiHelper.emojify(
wholeMessage, account.getEmojis(), message, statusDisplayOptions.animateEmojis()
);
message.setText(emojifiedMessage);
String username = context.getString(R.string.post_username_format, account.getUsername());
usernameView.setText(username);
CharSequence emojifiedDisplayName = CustomEmojiHelper.emojify(
wrappedDisplayName, account.getEmojis(), usernameView, statusDisplayOptions.animateEmojis()
);
displayNameView.setText(emojifiedDisplayName);
int avatarRadius = avatar.getContext().getResources()
.getDimensionPixelSize(R.dimen.avatar_radius_42dp);
ImageLoadingHelper.loadAvatar(account.getAvatar(), avatar, avatarRadius,
statusDisplayOptions.animateAvatars());
}
void setupButtons(final NotificationActionListener listener, final String accountId) {
itemView.setOnClickListener(v -> listener.onViewAccount(accountId));
}
}
private static class StatusNotificationViewHolder extends RecyclerView.ViewHolder
implements View.OnClickListener {
private final TextView message;
private final View statusNameBar;
private final TextView displayName;
private final TextView username;
private final TextView timestampInfo;
private final TextView statusContent;
private final ImageView statusAvatar;
private final ImageView notificationAvatar;
private final TextView contentWarningDescriptionTextView;
private final Button contentWarningButton;
private final Button contentCollapseButton; // TODO: This code SHOULD be based on StatusBaseViewHolder
private ConstraintLayout quoteContainer;
private StatusDisplayOptions statusDisplayOptions;
private final AbsoluteTimeFormatter absoluteTimeFormatter;
private String accountId;
private String notificationId;
private NotificationActionListener notificationActionListener;
private StatusViewData.Concrete statusViewData;
private int avatarRadius48dp;
private int avatarRadius36dp;
private int avatarRadius24dp;
StatusNotificationViewHolder(
View itemView,
StatusDisplayOptions statusDisplayOptions,
AbsoluteTimeFormatter absoluteTimeFormatter
) {
super(itemView);
message = itemView.findViewById(R.id.notification_top_text);
statusNameBar = itemView.findViewById(R.id.status_name_bar);
displayName = itemView.findViewById(R.id.status_display_name);
username = itemView.findViewById(R.id.status_username);
timestampInfo = itemView.findViewById(R.id.status_meta_info);
statusContent = itemView.findViewById(R.id.notification_content);
statusAvatar = itemView.findViewById(R.id.notification_status_avatar);
notificationAvatar = itemView.findViewById(R.id.notification_notification_avatar);
contentWarningDescriptionTextView = itemView.findViewById(R.id.notification_content_warning_description);
contentWarningButton = itemView.findViewById(R.id.notification_content_warning_button);
contentCollapseButton = itemView.findViewById(R.id.button_toggle_notification_content);
this.statusDisplayOptions = statusDisplayOptions;
this.absoluteTimeFormatter = absoluteTimeFormatter;
quoteContainer = itemView.findViewById(R.id.status_quote_inline_container);
int darkerFilter = Color.rgb(123, 123, 123);
statusAvatar.setColorFilter(darkerFilter, PorterDuff.Mode.MULTIPLY);
notificationAvatar.setColorFilter(darkerFilter, PorterDuff.Mode.MULTIPLY);
itemView.setOnClickListener(this);
message.setOnClickListener(this);
statusContent.setOnClickListener(this);
this.avatarRadius48dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_48dp);
this.avatarRadius36dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_36dp);
this.avatarRadius24dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_24dp);
}
private void showNotificationContent(boolean show) {
statusNameBar.setVisibility(show ? View.VISIBLE : View.GONE);
contentWarningDescriptionTextView.setVisibility(show ? View.VISIBLE : View.GONE);
contentWarningButton.setVisibility(show ? View.VISIBLE : View.GONE);
statusContent.setVisibility(show ? View.VISIBLE : View.GONE);
statusAvatar.setVisibility(show ? View.VISIBLE : View.GONE);
notificationAvatar.setVisibility(show ? View.VISIBLE : View.GONE);
}
private void setDisplayName(String name, List<Emoji> emojis) {
CharSequence emojifiedName = CustomEmojiHelper.emojify(name, emojis, displayName, statusDisplayOptions.animateEmojis());
displayName.setText(emojifiedName);
}
private void setUsername(String name) {
Context context = username.getContext();
String format = context.getString(R.string.post_username_format);
String usernameText = String.format(format, name);
username.setText(usernameText);
}
protected void setCreatedAt(@Nullable Date createdAt) {
if (statusDisplayOptions.useAbsoluteTime()) {
timestampInfo.setText(absoluteTimeFormatter.format(createdAt, true));
} else {
// This is the visible timestampInfo.
String readout;
/* This one is for screen-readers. Frequently, they would mispronounce timestamps like "17m"
* as 17 meters instead of minutes. */
CharSequence readoutAloud;
if (createdAt != null) {
long then = createdAt.getTime();
long now = new Date().getTime();
readout = TimestampUtils.getRelativeTimeSpanString(timestampInfo.getContext(), then, now);
readoutAloud = android.text.format.DateUtils.getRelativeTimeSpanString(then, now,
android.text.format.DateUtils.SECOND_IN_MILLIS,
android.text.format.DateUtils.FORMAT_ABBREV_RELATIVE);
} else {
// unknown minutes~
readout = "?m";
readoutAloud = "? minutes";
}
timestampInfo.setText(readout);
timestampInfo.setContentDescription(readoutAloud);
}
}
Drawable getIconWithColor(Context context, @DrawableRes int drawable, @ColorRes int color) {
Drawable icon = ContextCompat.getDrawable(context, drawable);
if (icon != null) {
icon.setColorFilter(context.getColor(color), PorterDuff.Mode.SRC_ATOP);
}
return icon;
}
void setMessage(NotificationViewData.Concrete notificationViewData, LinkListener listener) {
this.statusViewData = notificationViewData.getStatusViewData();
String displayName = StringUtils.unicodeWrap(notificationViewData.getAccount().getName());
Notification.Type type = notificationViewData.getType();
Context context = message.getContext();
String format;
Drawable icon;
switch (type) {
default:
case FAVOURITE: {
icon = getIconWithColor(context, R.drawable.ic_star_24dp, R.color.tusky_orange);
format = context.getString(R.string.notification_favourite_format);
break;
}
case REBLOG: {
icon = getIconWithColor(context, R.drawable.ic_repeat_24dp, R.color.tusky_blue);
format = context.getString(R.string.notification_reblog_format);
break;
}
case STATUS: {
icon = getIconWithColor(context, R.drawable.ic_home_24dp, R.color.tusky_blue);
format = context.getString(R.string.notification_subscription_format);
break;
}
case UPDATE: {
icon = getIconWithColor(context, R.drawable.ic_edit_24dp, R.color.tusky_blue);
format = context.getString(R.string.notification_update_format);
break;
}
}
message.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null);
String wholeMessage = String.format(format, displayName);
final SpannableStringBuilder str = new SpannableStringBuilder(wholeMessage);
int displayNameIndex = format.indexOf("%s");
str.setSpan(
new StyleSpan(Typeface.BOLD),
displayNameIndex,
displayNameIndex + displayName.length(),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
);
CharSequence emojifiedText = CustomEmojiHelper.emojify(
str, notificationViewData.getAccount().getEmojis(), message, statusDisplayOptions.animateEmojis()
);
message.setText(emojifiedText);
if (statusViewData != null) {
boolean hasSpoiler = !TextUtils.isEmpty(statusViewData.getStatus().getSpoilerText());
contentWarningDescriptionTextView.setVisibility(hasSpoiler ? View.VISIBLE : View.GONE);
contentWarningButton.setVisibility(hasSpoiler ? View.VISIBLE : View.GONE);
if (statusViewData.isExpanded()) {
contentWarningButton.setText(R.string.post_content_warning_show_less);
} else {
contentWarningButton.setText(R.string.post_content_warning_show_more);
}
contentWarningButton.setOnClickListener(view -> {
if (getBindingAdapterPosition() != RecyclerView.NO_POSITION) {
notificationActionListener.onExpandedChange(!statusViewData.isExpanded(), getBindingAdapterPosition());
}
statusContent.setVisibility(statusViewData.isExpanded() ? View.GONE : View.VISIBLE);
});
setupContentAndSpoiler(listener);
}
}
void setupButtons(final NotificationActionListener listener, final String accountId,
final String notificationId) {
this.notificationActionListener = listener;
this.accountId = accountId;
this.notificationId = notificationId;
}
void setAvatar(@Nullable String statusAvatarUrl, boolean isBot) {
statusAvatar.setPaddingRelative(0, 0, 0, 0);
ImageLoadingHelper.loadAvatar(statusAvatarUrl,
statusAvatar, avatarRadius48dp, statusDisplayOptions.animateAvatars());
if (statusDisplayOptions.showBotOverlay() && isBot) {
notificationAvatar.setVisibility(View.VISIBLE);
Glide.with(notificationAvatar)
.load(ContextCompat.getDrawable(notificationAvatar.getContext(), R.drawable.bot_badge))
.into(notificationAvatar);
} else {
notificationAvatar.setVisibility(View.GONE);
}
}
void setAvatars(@Nullable String statusAvatarUrl, @Nullable String notificationAvatarUrl) {
int padding = Utils.dpToPx(statusAvatar.getContext(), 12);
statusAvatar.setPaddingRelative(0, 0, padding, padding);
ImageLoadingHelper.loadAvatar(statusAvatarUrl,
statusAvatar, avatarRadius36dp, statusDisplayOptions.animateAvatars());
notificationAvatar.setVisibility(View.VISIBLE);
ImageLoadingHelper.loadAvatar(notificationAvatarUrl, notificationAvatar,
avatarRadius24dp, statusDisplayOptions.animateAvatars());
}
private void setQuoteContainer(StatusViewData.Concrete quote, final LinkListener listener, StatusDisplayOptions statusDisplayOptions) {
if (quote != null) {
quoteContainer.setVisibility(View.VISIBLE);
ViewQuoteInlineBinding binding = ViewQuoteInlineBinding.bind(quoteContainer);
new QuoteInlineHelper(binding, listener,
quoteContainer.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_24dp),
statusDisplayOptions)
.setupQuoteContainer(quote);
} else {
quoteContainer.setVisibility(View.GONE);
}
}
@Override
public void onClick(View v) {
switch (v.getId()) {
case R.id.notification_container:
case R.id.notification_content:
if (notificationActionListener != null)
notificationActionListener.onViewStatusForNotificationId(notificationId);
break;
case R.id.notification_top_text:
if (notificationActionListener != null)
notificationActionListener.onViewAccount(accountId);
break;
}
}
private void setupContentAndSpoiler(final LinkListener listener) {
boolean shouldShowContentIfSpoiler = statusViewData.isExpanded();
boolean hasSpoiler = !TextUtils.isEmpty(statusViewData.getStatus(). getSpoilerText());
if (!shouldShowContentIfSpoiler && hasSpoiler) {
statusContent.setVisibility(View.GONE);
} else {
statusContent.setVisibility(View.VISIBLE);
}
Spanned content = statusViewData.getContent();
List<Emoji> emojis = statusViewData.getActionable().getEmojis();
if (statusViewData.isCollapsible() && (statusViewData.isExpanded() || !hasSpoiler)) {
contentCollapseButton.setOnClickListener(view -> {
int position = getBindingAdapterPosition();
if (position != RecyclerView.NO_POSITION && notificationActionListener != null) {
notificationActionListener.onNotificationContentCollapsedChange(!statusViewData.isCollapsed(), position);
}
});
contentCollapseButton.setVisibility(View.VISIBLE);
if (statusViewData.isCollapsed()) {
contentCollapseButton.setText(R.string.post_content_warning_show_more);
statusContent.setFilters(COLLAPSE_INPUT_FILTER);
} else {
contentCollapseButton.setText(R.string.post_content_warning_show_less);
statusContent.setFilters(NO_INPUT_FILTER);
}
} else {
contentCollapseButton.setVisibility(View.GONE);
statusContent.setFilters(NO_INPUT_FILTER);
}
CharSequence emojifiedText = CustomEmojiHelper.emojify(
content, emojis, statusContent, statusDisplayOptions.animateEmojis()
);
LinkHelper.setClickableText(statusContent, emojifiedText, statusViewData.getActionable().getMentions(), statusViewData.getActionable().getTags(), listener);
CharSequence emojifiedContentWarning;
if (statusViewData.getSpoilerText() != null) {
emojifiedContentWarning = CustomEmojiHelper.emojify(
statusViewData.getSpoilerText(),
statusViewData.getActionable().getEmojis(),
contentWarningDescriptionTextView,
statusDisplayOptions.animateEmojis()
);
} else {
emojifiedContentWarning = "";
}
contentWarningDescriptionTextView.setText(emojifiedContentWarning);
setQuoteContainer(statusViewData.getQuoteViewData(), listener, statusDisplayOptions);
}
}
}

View File

@ -75,7 +75,6 @@ class PollAdapter : RecyclerView.Adapter<BindingHolder<ItemPollBinding>>() {
override fun getItemCount() = pollOptions.size
override fun onBindViewHolder(holder: BindingHolder<ItemPollBinding>, position: Int) {
val option = pollOptions[position]
val resultTextView = holder.binding.statusPollOptionResult

View File

@ -20,28 +20,76 @@ import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import at.connyduck.sparkbutton.helpers.Utils
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.NotificationsAdapter.NotificationActionListener
import com.keylesspalace.tusky.components.notifications.NotificationActionListener
import com.keylesspalace.tusky.components.notifications.NotificationsPagingAdapter
import com.keylesspalace.tusky.databinding.ItemReportNotificationBinding
import com.keylesspalace.tusky.entity.Report
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.getRelativeTimeSpanString
import com.keylesspalace.tusky.util.loadAvatar
import com.keylesspalace.tusky.util.unicodeWrap
import com.keylesspalace.tusky.viewdata.NotificationViewData
import java.util.Date
class ReportNotificationViewHolder(
private val binding: ItemReportNotificationBinding,
) : RecyclerView.ViewHolder(binding.root) {
private val notificationActionListener: NotificationActionListener
) : NotificationsPagingAdapter.ViewHolder, RecyclerView.ViewHolder(binding.root) {
fun setupWithReport(reporter: TimelineAccount, report: Report, animateAvatar: Boolean, animateEmojis: Boolean) {
val reporterName = reporter.name.unicodeWrap().emojify(reporter.emojis, itemView, animateEmojis)
val reporteeName = report.targetAccount.name.unicodeWrap().emojify(report.targetAccount.emojis, itemView, animateEmojis)
val icon = ContextCompat.getDrawable(itemView.context, R.drawable.ic_flag_24dp)
override fun bind(
viewData: NotificationViewData,
payloads: List<*>?,
statusDisplayOptions: StatusDisplayOptions
) {
// Skip updates with payloads. That indicates a timestamp update, and
// this view does not have timestamps.
if (!payloads.isNullOrEmpty()) return
setupWithReport(
viewData.account,
viewData.report!!,
statusDisplayOptions.animateAvatars,
statusDisplayOptions.animateEmojis
)
setupActionListener(
notificationActionListener,
viewData.report.targetAccount.id,
viewData.account.id,
viewData.report.id
)
}
private fun setupWithReport(
reporter: TimelineAccount,
report: Report,
animateAvatar: Boolean,
animateEmojis: Boolean
) {
val reporterName = reporter.name.unicodeWrap().emojify(
reporter.emojis,
binding.root,
animateEmojis
)
val reporteeName = report.targetAccount.name.unicodeWrap().emojify(
report.targetAccount.emojis,
itemView,
animateEmojis
)
val icon = ContextCompat.getDrawable(binding.root.context, R.drawable.ic_flag_24dp)
binding.notificationTopText.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null)
binding.notificationTopText.text = itemView.context.getString(R.string.notification_header_report_format, reporterName, reporteeName)
binding.notificationSummary.text = itemView.context.getString(R.string.notification_summary_report_format, getRelativeTimeSpanString(itemView.context, report.createdAt.time, Date().time), report.status_ids?.size ?: 0)
binding.notificationTopText.text = itemView.context.getString(
R.string.notification_header_report_format,
reporterName,
reporteeName
)
binding.notificationSummary.text = itemView.context.getString(
R.string.notification_summary_report_format,
getRelativeTimeSpanString(itemView.context, report.createdAt.time, Date().time),
report.status_ids?.size ?: 0
)
binding.notificationCategory.text = getTranslatedCategory(itemView.context, report.category)
// Fancy avatar inset
@ -52,17 +100,22 @@ class ReportNotificationViewHolder(
report.targetAccount.avatar,
binding.notificationReporteeAvatar,
itemView.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp),
animateAvatar,
animateAvatar
)
loadAvatar(
reporter.avatar,
binding.notificationReporterAvatar,
itemView.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_24dp),
animateAvatar,
animateAvatar
)
}
fun setupActionListener(listener: NotificationActionListener, reporteeId: String, reporterId: String, reportId: String) {
private fun setupActionListener(
listener: NotificationActionListener,
reporteeId: String,
reporterId: String,
reportId: String
) {
binding.notificationReporteeAvatar.setOnClickListener {
val position = bindingAdapterPosition
if (position != RecyclerView.NO_POSITION) {

View File

@ -13,6 +13,7 @@ import android.text.TextUtils;
import android.text.format.DateUtils;
import android.text.style.DynamicDrawableSpan;
import android.text.style.ImageSpan;
import android.view.Menu;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
@ -25,12 +26,11 @@ import android.widget.Toast;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.appcompat.widget.PopupMenu;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.core.content.ContextCompat;
import androidx.core.text.HtmlCompat;
import androidx.core.view.ViewKt;
import androidx.recyclerview.widget.DefaultItemAnimator;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
@ -50,6 +50,8 @@ import com.keylesspalace.tusky.entity.Attachment.Focus;
import com.keylesspalace.tusky.entity.Attachment.MetaData;
import com.keylesspalace.tusky.entity.Card;
import com.keylesspalace.tusky.entity.Emoji;
import com.keylesspalace.tusky.entity.Filter;
import com.keylesspalace.tusky.entity.FilterResult;
import com.keylesspalace.tusky.entity.HashTag;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.interfaces.StatusActionListener;
@ -59,6 +61,7 @@ import com.keylesspalace.tusky.util.CardViewMode;
import com.keylesspalace.tusky.util.CustomEmojiHelper;
import com.keylesspalace.tusky.util.ImageLoadingHelper;
import com.keylesspalace.tusky.util.LinkHelper;
import com.keylesspalace.tusky.util.NumberUtils;
import com.keylesspalace.tusky.util.StatusDisplayOptions;
import com.keylesspalace.tusky.util.TimestampUtils;
import com.keylesspalace.tusky.util.TouchDelegateHelper;
@ -84,49 +87,55 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
public static final String KEY_CREATED = "created";
}
private TextView displayName;
private TextView username;
private ImageButton replyButton;
private TextView replyCountLabel;
private SparkButton reblogButton;
private SparkButton favouriteButton;
private ImageButton quoteButton;
private SparkButton bookmarkButton;
private ImageButton moreButton;
private ConstraintLayout mediaContainer;
protected MediaPreviewLayout mediaPreview;
private TextView sensitiveMediaWarning;
private View sensitiveMediaShow;
protected TextView[] mediaLabels;
protected CharSequence[] mediaDescriptions;
private MaterialButton contentWarningButton;
private ImageView avatarInset;
private final String TAG = "StatusBaseViewHolder";
public ImageView avatar;
public TextView metaInfo;
public TextView content;
public TextView contentWarningDescription;
private final TextView displayName;
private final TextView username;
private final ImageButton replyButton;
private final TextView replyCountLabel;
private final SparkButton reblogButton;
private final SparkButton favouriteButton;
private final ImageButton quoteButton;
private final SparkButton bookmarkButton;
private final ImageButton moreButton;
private final ConstraintLayout mediaContainer;
protected final MediaPreviewLayout mediaPreview;
private final TextView sensitiveMediaWarning;
private final View sensitiveMediaShow;
protected final TextView[] mediaLabels;
protected final CharSequence[] mediaDescriptions;
private final MaterialButton contentWarningButton;
private final ImageView avatarInset;
private ConstraintLayout quoteContainer;
private final ConstraintLayout quoteContainer;
private RecyclerView pollOptions;
private TextView pollDescription;
private Button pollButton;
public final ImageView avatar;
public final TextView metaInfo;
public final TextView content;
public final TextView contentWarningDescription;
private LinearLayout cardView;
private LinearLayout cardInfo;
private ShapeableImageView cardImage;
private TextView cardTitle;
private TextView cardDescription;
private TextView cardUrl;
private PollAdapter pollAdapter;
private final RecyclerView pollOptions;
private final TextView pollDescription;
private final Button pollButton;
private final LinearLayout cardView;
private final LinearLayout cardInfo;
private final ShapeableImageView cardImage;
private final TextView cardTitle;
private final TextView cardDescription;
private final TextView cardUrl;
private final PollAdapter pollAdapter;
protected LinearLayout filteredPlaceholder;
protected TextView filteredPlaceholderLabel;
protected Button filteredPlaceholderShowButton;
protected ConstraintLayout statusContainer;
private final NumberFormat numberFormat = NumberFormat.getNumberInstance();
private final AbsoluteTimeFormatter absoluteTimeFormatter = new AbsoluteTimeFormatter();
protected int avatarRadius48dp;
private int avatarRadius36dp;
private int avatarRadius24dp;
protected final int avatarRadius48dp;
private final int avatarRadius36dp;
private final int avatarRadius24dp;
private final Drawable mediaPreviewUnloaded;
@ -175,6 +184,11 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
cardDescription = itemView.findViewById(R.id.card_description);
cardUrl = itemView.findViewById(R.id.card_link);
filteredPlaceholder = itemView.findViewById(R.id.status_filtered_placeholder);
filteredPlaceholderLabel = itemView.findViewById(R.id.status_filter_label);
filteredPlaceholderShowButton = itemView.findViewById(R.id.status_filter_show_anyway);
statusContainer = itemView.findViewById(R.id.status_container);
pollAdapter = new PollAdapter();
pollOptions.setAdapter(pollAdapter);
pollOptions.setLayoutManager(new LinearLayoutManager(pollOptions.getContext()));
@ -206,16 +220,17 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
contentWarningButton.performClick();
}
protected void setSpoilerAndContent(boolean expanded,
@NonNull Spanned content,
@Nullable String spoilerText,
@Nullable List<Status.Mention> mentions,
@Nullable List<HashTag> tags,
@NonNull List<Emoji> emojis,
@Nullable PollViewData poll,
protected void setSpoilerAndContent(@NonNull StatusViewData.Concrete status,
@NonNull StatusDisplayOptions statusDisplayOptions,
final StatusActionListener listener) {
Status actionable = status.getActionable();
String spoilerText = status.getSpoilerText();
List<Emoji> emojis = actionable.getEmojis();
boolean sensitive = !TextUtils.isEmpty(spoilerText);
boolean expanded = status.isExpanded();
if (sensitive) {
CharSequence emojiSpoiler = CustomEmojiHelper.emojify(
spoilerText, emojis, contentWarningDescription, statusDisplayOptions.animateEmojis()
@ -224,20 +239,12 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
contentWarningDescription.setVisibility(View.VISIBLE);
contentWarningButton.setVisibility(View.VISIBLE);
setContentWarningButtonText(expanded);
contentWarningButton.setOnClickListener(view -> {
contentWarningDescription.invalidate();
if (getBindingAdapterPosition() != RecyclerView.NO_POSITION) {
listener.onExpandedChange(!expanded, getBindingAdapterPosition());
}
setContentWarningButtonText(!expanded);
this.setTextVisible(sensitive, !expanded, content, mentions, tags, emojis, poll, statusDisplayOptions, listener);
});
this.setTextVisible(sensitive, expanded, content, mentions, tags, emojis, poll, statusDisplayOptions, listener);
contentWarningButton.setOnClickListener(view -> toggleExpandedState(true, !expanded, status, statusDisplayOptions, listener));
this.setTextVisible(true, expanded, status, statusDisplayOptions, listener);
} else {
contentWarningDescription.setVisibility(View.GONE);
contentWarningButton.setVisibility(View.GONE);
this.setTextVisible(sensitive, true, content, mentions, tags, emojis, poll, statusDisplayOptions, listener);
this.setTextVisible(false, true, status, statusDisplayOptions, listener);
}
}
@ -249,20 +256,42 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
}
}
protected void toggleExpandedState(boolean sensitive,
boolean expanded,
@NonNull final StatusViewData.Concrete status,
@NonNull final StatusDisplayOptions statusDisplayOptions,
@NonNull final StatusActionListener listener) {
contentWarningDescription.invalidate();
int adapterPosition = getBindingAdapterPosition();
if (adapterPosition != RecyclerView.NO_POSITION) {
listener.onExpandedChange(expanded, adapterPosition);
}
setContentWarningButtonText(expanded);
this.setTextVisible(sensitive, expanded, status, statusDisplayOptions, listener);
setupCard(status, expanded, statusDisplayOptions.cardViewMode(), statusDisplayOptions, listener);
}
private void setTextVisible(boolean sensitive,
boolean expanded,
Spanned content,
List<Status.Mention> mentions,
List<HashTag> tags,
List<Emoji> emojis,
@Nullable PollViewData poll,
StatusDisplayOptions statusDisplayOptions,
@NonNull final StatusViewData.Concrete status,
@NonNull final StatusDisplayOptions statusDisplayOptions,
final StatusActionListener listener) {
Status actionable = status.getActionable();
Spanned content = status.getContent();
List<Status.Mention> mentions = actionable.getMentions();
List<HashTag> tags =actionable.getTags();
List<Emoji> emojis = actionable.getEmojis();
PollViewData poll = PollViewDataKt.toViewData(actionable.getPoll());
if (expanded) {
CharSequence emojifiedText = CustomEmojiHelper.emojify(content, emojis, this.content, statusDisplayOptions.animateEmojis());
LinkHelper.setClickableText(this.content, emojifiedText, mentions, tags, listener);
for (int i = 0; i < mediaLabels.length; ++i) {
updateMediaLabel(i, sensitive, expanded);
updateMediaLabel(i, sensitive, true);
}
if (poll != null) {
setupPoll(poll, emojis, statusDisplayOptions, listener);
@ -287,7 +316,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
}
private void setAvatar(String url,
@Nullable String rebloggedUrl,
@Nullable String rebloggedUrl,
boolean isBot,
StatusDisplayOptions statusDisplayOptions) {
@ -298,8 +327,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
if (statusDisplayOptions.showBotOverlay() && isBot) {
avatarInset.setVisibility(View.VISIBLE);
Glide.with(avatarInset)
// passing the drawable id directly into .load() ignores night mode https://github.com/bumptech/glide/issues/4692
.load(ContextCompat.getDrawable(avatarInset.getContext(), R.drawable.bot_badge))
.load(R.drawable.bot_badge)
.into(avatarInset);
} else {
avatarInset.setVisibility(View.GONE);
@ -356,8 +384,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
} else {
long then = createdAt.getTime();
long now = System.currentTimeMillis();
String readout = TimestampUtils.getRelativeTimeSpanString(metaInfo.getContext(), then, now);
timestampText = readout;
timestampText = TimestampUtils.getRelativeTimeSpanString(metaInfo.getContext(), then, now);
}
}
@ -444,11 +471,18 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
}
private void setReplyCount(int repliesCount) {
protected void setReplyCount(int repliesCount, boolean fullStats) {
// This label only exists in the non-detailed view (to match the web ui)
if (replyCountLabel != null) {
replyCountLabel.setText((repliesCount > 1 ? replyCountLabel.getContext().getString(R.string.status_count_one_plus) : Integer.toString(repliesCount)));
if (replyCountLabel == null) return;
if (fullStats) {
replyCountLabel.setText(NumberUtils.shortNumber(repliesCount));
return;
}
// Show "0", "1", or "1+" for replies otherwise, so the user knows if there is a thread
// that they can click through to read.
replyCountLabel.setText((repliesCount > 1 ? replyCountLabel.getContext().getString(R.string.status_count_one_plus) : Integer.toString(repliesCount)));
}
private void setReblogged(boolean reblogged) {
@ -727,7 +761,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
int position = getBindingAdapterPosition();
if (position != RecyclerView.NO_POSITION) {
if (statusDisplayOptions.confirmReblogs()) {
showConfirmReblogDialog(listener, statusContent, buttonState, position);
showConfirmReblog(listener, buttonState, position);
return false;
} else {
listener.onReblog(!buttonState, position);
@ -739,12 +773,13 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
});
}
favouriteButton.setEventListener((button, buttonState) -> {
// return true to play animation
int position = getBindingAdapterPosition();
if (position != RecyclerView.NO_POSITION) {
if (statusDisplayOptions.confirmFavourites()) {
showConfirmFavouriteDialog(listener, statusContent, buttonState, position);
showConfirmFavourite(listener, buttonState, position);
return false;
} else {
listener.onFavourite(!buttonState, position);
@ -801,38 +836,46 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
itemView.setOnClickListener(viewThreadListener);
}
private void showConfirmReblogDialog(StatusActionListener listener,
String statusContent,
boolean buttonState,
int position) {
int okButtonTextId = buttonState ? R.string.action_unreblog : R.string.action_reblog;
new AlertDialog.Builder(reblogButton.getContext())
.setMessage(statusContent)
.setPositiveButton(okButtonTextId, (__, ___) -> {
listener.onReblog(!buttonState, position);
if (!buttonState) {
// Play animation only when it's reblog, not unreblog
reblogButton.playAnimation();
}
})
.show();
private void showConfirmReblog(StatusActionListener listener,
boolean buttonState,
int position) {
PopupMenu popup = new PopupMenu(itemView.getContext(), reblogButton);
popup.inflate(R.menu.status_reblog);
Menu menu = popup.getMenu();
if (buttonState) {
menu.findItem(R.id.menu_action_reblog).setVisible(false);
} else {
menu.findItem(R.id.menu_action_unreblog).setVisible(false);
}
popup.setOnMenuItemClickListener(item -> {
listener.onReblog(!buttonState, position);
if(!buttonState) {
reblogButton.playAnimation();
}
return true;
});
popup.show();
}
private void showConfirmFavouriteDialog(StatusActionListener listener,
String statusContent,
boolean buttonState,
int position) {
int okButtonTextId = buttonState ? R.string.action_unfavourite : R.string.action_favourite;
new AlertDialog.Builder(favouriteButton.getContext())
.setMessage(statusContent)
.setPositiveButton(okButtonTextId, (__, ___) -> {
listener.onFavourite(!buttonState, position);
if (!buttonState) {
// Play animation only when it's favourite, not unfavourite
favouriteButton.playAnimation();
}
})
.show();
private void showConfirmFavourite(StatusActionListener listener,
boolean buttonState,
int position) {
PopupMenu popup = new PopupMenu(itemView.getContext(), favouriteButton);
popup.inflate(R.menu.status_favourite);
Menu menu = popup.getMenu();
if (buttonState) {
menu.findItem(R.id.menu_action_favourite).setVisible(false);
} else {
menu.findItem(R.id.menu_action_unfavourite).setVisible(false);
}
popup.setOnMenuItemClickListener(item -> {
listener.onFavourite(!buttonState, position);
if(!buttonState) {
favouriteButton.playAnimation();
}
return true;
});
popup.show();
}
public void setupWithStatus(StatusViewData.Concrete status, final StatusActionListener listener,
@ -850,7 +893,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
setUsername(status.getUsername());
setMetaData(status, statusDisplayOptions, listener);
setIsReply(actionable.getInReplyToId() != null);
setReplyCount(actionable.getRepliesCount());
setReplyCount(actionable.getRepliesCount(), statusDisplayOptions.showStatsInline());
setAvatar(actionable.getAccount().getAvatar(), status.getRebloggedAvatar(),
actionable.getAccount().getBot(), statusDisplayOptions);
setReblogged(actionable.getReblogged());
@ -876,19 +919,16 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
hideSensitiveMediaWarning();
}
if (cardView != null) {
setupCard(status, statusDisplayOptions.cardViewMode(), statusDisplayOptions, listener);
}
setupCard(status, status.isExpanded(), statusDisplayOptions.cardViewMode(), statusDisplayOptions, listener);
setupButtons(listener, actionable.getAccount().getId(), status.getContent().toString(),
actionable.isNotestock(), statusDisplayOptions);
setRebloggingEnabled(actionable.rebloggingAllowed(), actionable.getVisibility());
setQuoteEnabled(actionable.rebloggingAllowed() && !actionable.isNotestock(), actionable.getVisibility());
setSpoilerAndContent(status.isExpanded(), status.getContent(), status.getSpoilerText(),
actionable.getMentions(), actionable.getTags(), actionable.getEmojis(),
PollViewDataKt.toViewData(actionable.getPoll()), statusDisplayOptions,
listener);
setSpoilerAndContent(status, statusDisplayOptions, listener);
setupFilterPlaceholder(status, listener, statusDisplayOptions);
setDescriptionForStatus(status, statusDisplayOptions);
@ -909,6 +949,30 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
}
}
private void setupFilterPlaceholder(StatusViewData.Concrete status, StatusActionListener listener, StatusDisplayOptions displayOptions) {
if (status.getFilterAction() != Filter.Action.WARN) {
showFilteredPlaceholder(false);
return;
}
showFilteredPlaceholder(true);
Filter matchedFilter = null;
for (FilterResult result : status.getActionable().getFiltered()) {
Filter filter = result.getFilter();
if (filter.getAction() == Filter.Action.WARN) {
matchedFilter = filter;
break;
}
}
filteredPlaceholderLabel.setText(itemView.getContext().getString(R.string.status_filter_placeholder_label_format, matchedFilter.getTitle()));
filteredPlaceholderShowButton.setOnClickListener(view -> {
listener.clearWarningAction(getBindingAdapterPosition());
});
}
protected static boolean hasPreviewableAttachment(List<Attachment> attachments) {
for (Attachment attachment : attachments) {
if (attachment.getType() == Attachment.Type.AUDIO || attachment.getType() == Attachment.Type.UNKNOWN) {
@ -1143,20 +1207,27 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
}
protected void setupCard(
StatusViewData.Concrete status,
CardViewMode cardViewMode,
StatusDisplayOptions statusDisplayOptions,
final StatusViewData.Concrete status,
boolean expanded,
final CardViewMode cardViewMode,
final StatusDisplayOptions statusDisplayOptions,
final StatusActionListener listener
) {
if (cardView == null) {
return;
}
final Status actionable = status.getActionable();
final Card card = actionable.getCard();
if (cardViewMode != CardViewMode.NONE &&
actionable.getAttachments().size() == 0 &&
actionable.getPoll() == null &&
card != null &&
!TextUtils.isEmpty(card.getUrl()) &&
(!actionable.getSensitive() || status.isExpanded()) &&
(!status.isCollapsible() || !status.isCollapsed())) {
actionable.getAttachments().size() == 0 &&
actionable.getPoll() == null &&
card != null &&
!TextUtils.isEmpty(card.getUrl()) &&
(!actionable.getSensitive() || expanded) &&
(!status.isCollapsible() || !status.isCollapsed())) {
cardView.setVisibility(View.VISIBLE);
cardTitle.setText(card.getTitle());
if (TextUtils.isEmpty(card.getDescription()) && TextUtils.isEmpty(card.getAuthorName())) {
@ -1249,7 +1320,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
cardImage.setScaleType(ImageView.ScaleType.CENTER);
Glide.with(cardImage.getContext())
.load(ContextCompat.getDrawable(cardImage.getContext(), R.drawable.card_image_placeholder))
.load(R.drawable.card_image_placeholder)
.into(cardImage);
}
@ -1288,4 +1359,13 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
bookmarkButton.setVisibility(visibility);
moreButton.setVisibility(visibility);
}
public void showFilteredPlaceholder(boolean show) {
if (statusContainer != null) {
statusContainer.setVisibility(show ? View.GONE : View.VISIBLE);
}
if (filteredPlaceholder != null) {
filteredPlaceholder.setVisibility(show ? View.VISIBLE : View.GONE);
}
}
}

View File

@ -160,7 +160,7 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder {
status;
super.setupWithStatus(uncollapsedStatus, listener, statusDisplayOptions, payloads);
setupCard(uncollapsedStatus, CardViewMode.FULL_WIDTH, statusDisplayOptions, listener); // Always show card for detailed status
setupCard(uncollapsedStatus, status.isExpanded(), CardViewMode.FULL_WIDTH, statusDisplayOptions, listener); // Always show card for detailed status
if (payloads == null) {
Status actionable = uncollapsedStatus.getActionable();

View File

@ -28,9 +28,11 @@ import androidx.recyclerview.widget.RecyclerView;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.entity.Emoji;
import com.keylesspalace.tusky.entity.Filter;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.util.CustomEmojiHelper;
import com.keylesspalace.tusky.util.NumberUtils;
import com.keylesspalace.tusky.util.SmartLengthInputFilter;
import com.keylesspalace.tusky.util.StatusDisplayOptions;
import com.keylesspalace.tusky.util.StringUtils;
@ -44,13 +46,17 @@ public class StatusViewHolder extends StatusBaseViewHolder {
private static final InputFilter[] COLLAPSE_INPUT_FILTER = new InputFilter[]{SmartLengthInputFilter.INSTANCE};
private static final InputFilter[] NO_INPUT_FILTER = new InputFilter[0];
private TextView statusInfo;
private Button contentCollapseButton;
private final TextView statusInfo;
private final Button contentCollapseButton;
private final TextView favouritedCountLabel;
private final TextView reblogsCountLabel;
public StatusViewHolder(View itemView) {
super(itemView);
statusInfo = itemView.findViewById(R.id.status_info);
contentCollapseButton = itemView.findViewById(R.id.button_toggle_content);
favouritedCountLabel = itemView.findViewById(R.id.status_favourites_count);
reblogsCountLabel = itemView.findViewById(R.id.status_insets);
}
@Override
@ -60,10 +66,13 @@ public class StatusViewHolder extends StatusBaseViewHolder {
@Nullable Object payloads) {
if (payloads == null) {
setupCollapsedState(status, listener);
boolean sensitive = !TextUtils.isEmpty(status.getActionable().getSpoilerText());
boolean expanded = status.isExpanded();
setupCollapsedState(sensitive, expanded, status, listener);
Status reblogging = status.getRebloggingStatus();
if (reblogging == null) {
if (reblogging == null || status.getFilterAction() == Filter.Action.WARN) {
hideStatusInfo();
} else {
String rebloggedByDisplayName = reblogging.getAccount().getName();
@ -73,8 +82,13 @@ public class StatusViewHolder extends StatusBaseViewHolder {
}
}
super.setupWithStatus(status, listener, statusDisplayOptions, payloads);
reblogsCountLabel.setVisibility(statusDisplayOptions.showStatsInline() ? View.VISIBLE : View.INVISIBLE);
favouritedCountLabel.setVisibility(statusDisplayOptions.showStatsInline() ? View.VISIBLE : View.INVISIBLE);
setFavouritedCount(status.getActionable().getFavouritesCount());
setReblogsCount(status.getActionable().getReblogsCount());
super.setupWithStatus(status, listener, statusDisplayOptions, payloads);
}
private void setRebloggedByDisplayName(final CharSequence name,
@ -91,7 +105,7 @@ public class StatusViewHolder extends StatusBaseViewHolder {
}
// don't use this on the same ViewHolder as setRebloggedByDisplayName, will cause recycling issues as paddings are changed
void setPollInfo(final boolean ownPoll) {
protected void setPollInfo(final boolean ownPoll) {
statusInfo.setText(ownPoll ? R.string.poll_ended_created : R.string.poll_ended_voted);
statusInfo.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_poll_24dp, 0, 0, 0);
statusInfo.setCompoundDrawablePadding(Utils.dpToPx(statusInfo.getContext(), 10));
@ -99,13 +113,24 @@ public class StatusViewHolder extends StatusBaseViewHolder {
statusInfo.setVisibility(View.VISIBLE);
}
void hideStatusInfo() {
protected void setReblogsCount(int reblogsCount) {
reblogsCountLabel.setText(NumberUtils.shortNumber(reblogsCount));
}
protected void setFavouritedCount(int favouritedCount) {
favouritedCountLabel.setText(NumberUtils.shortNumber(favouritedCount));
}
protected void hideStatusInfo() {
statusInfo.setVisibility(View.GONE);
}
private void setupCollapsedState(final StatusViewData.Concrete status, final StatusActionListener listener) {
private void setupCollapsedState(boolean sensitive,
boolean expanded,
final StatusViewData.Concrete status,
final StatusActionListener listener) {
/* input filter for TextViews have to be set before text */
if (status.isCollapsible() && (status.isExpanded() || TextUtils.isEmpty(status.getSpoilerText()))) {
if (status.isCollapsible() && (!sensitive || expanded)) {
contentCollapseButton.setOnClickListener(view -> {
int position = getBindingAdapterPosition();
if (position != RecyclerView.NO_POSITION)
@ -130,4 +155,16 @@ public class StatusViewHolder extends StatusBaseViewHolder {
super.showStatusContent(show);
contentCollapseButton.setVisibility(show ? View.VISIBLE : View.GONE);
}
@Override
protected void toggleExpandedState(boolean sensitive,
boolean expanded,
@NonNull StatusViewData.Concrete status,
@NonNull StatusDisplayOptions statusDisplayOptions,
@NonNull final StatusActionListener listener) {
setupCollapsedState(sensitive, expanded, status, listener);
super.toggleExpandedState(sensitive, expanded, status, statusDisplayOptions, listener);
}
}

View File

@ -0,0 +1,41 @@
/* Copyright 2023 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.adapter
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemTrendingDateBinding
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
class TrendingDateViewHolder(
private val binding: ItemTrendingDateBinding
) : RecyclerView.ViewHolder(binding.root) {
private val dateFormat = SimpleDateFormat("EEE dd MMM yyyy", Locale.getDefault()).apply {
this.timeZone = TimeZone.getDefault()
}
fun setup(start: Date, end: Date) {
binding.dates.text = itemView.context.getString(
R.string.date_range,
dateFormat.format(start),
dateFormat.format(end)
)
}
}

View File

@ -0,0 +1,94 @@
/* Copyright 2023 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.adapter
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemTrendingCellBinding
import com.keylesspalace.tusky.entity.TrendingTagHistory
import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.viewdata.TrendingViewData
import java.text.NumberFormat
import kotlin.math.ln
import kotlin.math.pow
class TrendingTagViewHolder(
private val binding: ItemTrendingCellBinding
) : RecyclerView.ViewHolder(binding.root) {
fun setup(
tagViewData: TrendingViewData.Tag,
maxTrendingValue: Long,
trendingListener: LinkListener
) {
val reversedHistory = tagViewData.tag.history.reversed()
setGraph(reversedHistory, maxTrendingValue)
setTag(tagViewData.tag.name)
val totalUsage = tagViewData.tag.history.sumOf { it.uses.toLongOrNull() ?: 0 }
binding.totalUsage.text = formatNumber(totalUsage)
val totalAccounts = tagViewData.tag.history.sumOf { it.accounts.toLongOrNull() ?: 0 }
binding.totalAccounts.text = formatNumber(totalAccounts)
binding.currentUsage.text = reversedHistory.last().uses
binding.currentAccounts.text = reversedHistory.last().accounts
itemView.setOnClickListener {
trendingListener.onViewTag(tagViewData.tag.name)
}
setAccessibility(totalAccounts, tagViewData.tag.name)
}
private fun setGraph(history: List<TrendingTagHistory>, maxTrendingValue: Long) {
binding.graph.maxTrendingValue = maxTrendingValue
binding.graph.primaryLineData = history
.mapNotNull { it.uses.toLongOrNull() }
binding.graph.secondaryLineData = history
.mapNotNull { it.accounts.toLongOrNull() }
}
private fun setTag(tag: String) {
binding.tag.text = binding.root.context.getString(R.string.title_tag, tag)
}
private fun setAccessibility(totalAccounts: Long, tag: String) {
itemView.contentDescription =
itemView.context.getString(R.string.accessibility_talking_about_tag, totalAccounts, tag)
}
companion object {
private val numberFormatter: NumberFormat = NumberFormat.getInstance()
private val ln_1k = ln(1000.0)
/**
* Format numbers according to the current locale. Numbers < min have
* separators (',', '.', etc) inserted according to the locale.
*
* Numbers > min are scaled down to that by multiples of 1,000, and
* a suffix appropriate to the scaling is appended.
*/
private fun formatNumber(num: Long, min: Int = 100000): String {
if (num < min) return numberFormatter.format(num)
val exp = (ln(num.toDouble()) / ln_1k).toInt()
// TODO: is the choice of suffixes here locale-agnostic?
return String.format("%.1f %c", num / 1000.0.pow(exp.toDouble()), "KMGTPE"[exp - 1])
}
}
}

View File

@ -3,45 +3,51 @@ package com.keylesspalace.tusky.appstore
import com.google.gson.Gson
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import io.reactivex.rxjava3.disposables.Disposable
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import javax.inject.Inject
class CacheUpdater @Inject constructor(
eventHub: EventHub,
private val accountManager: AccountManager,
accountManager: AccountManager,
appDatabase: AppDatabase,
gson: Gson
) {
private val disposable: Disposable
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
init {
val timelineDao = appDatabase.timelineDao()
disposable = eventHub.events.subscribe { event ->
val accountId = accountManager.activeAccount?.id ?: return@subscribe
when (event) {
is FavoriteEvent ->
timelineDao.setFavourited(accountId, event.statusId, event.favourite)
is ReblogEvent ->
timelineDao.setReblogged(accountId, event.statusId, event.reblog)
is BookmarkEvent ->
timelineDao.setBookmarked(accountId, event.statusId, event.bookmark)
is UnfollowEvent ->
timelineDao.removeAllByUser(accountId, event.accountId)
is StatusDeletedEvent ->
timelineDao.delete(accountId, event.statusId)
is PollVoteEvent -> {
val pollString = gson.toJson(event.poll)
timelineDao.setVoted(accountId, event.statusId, pollString)
scope.launch {
eventHub.events.collect { event ->
val accountId = accountManager.activeAccount?.id ?: return@collect
when (event) {
is FavoriteEvent ->
timelineDao.setFavourited(accountId, event.statusId, event.favourite)
is ReblogEvent ->
timelineDao.setReblogged(accountId, event.statusId, event.reblog)
is BookmarkEvent ->
timelineDao.setBookmarked(accountId, event.statusId, event.bookmark)
is UnfollowEvent ->
timelineDao.removeAllByUser(accountId, event.accountId)
is StatusDeletedEvent ->
timelineDao.delete(accountId, event.statusId)
is PollVoteEvent -> {
val pollString = gson.toJson(event.poll)
timelineDao.setVoted(accountId, event.statusId, pollString)
}
is PinEvent ->
timelineDao.setPinned(accountId, event.statusId, event.pinned)
}
is PinEvent ->
timelineDao.setPinned(accountId, event.statusId, event.pinned)
}
}
}
fun stop() {
this.disposable.dispose()
this.scope.cancel()
}
}

View File

@ -6,22 +6,23 @@ import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Status
import net.accelf.yuito.streaming.Subscription
data class FavoriteEvent(val statusId: String, val favourite: Boolean) : Dispatchable
data class ReblogEvent(val statusId: String, val reblog: Boolean) : Dispatchable
data class BookmarkEvent(val statusId: String, val bookmark: Boolean) : Dispatchable
data class MuteConversationEvent(val statusId: String, val mute: Boolean) : Dispatchable
data class UnfollowEvent(val accountId: String) : Dispatchable
data class BlockEvent(val accountId: String) : Dispatchable
data class MuteEvent(val accountId: String) : Dispatchable
data class StatusDeletedEvent(val statusId: String) : Dispatchable
data class StatusComposedEvent(val status: Status) : Dispatchable
data class StatusScheduledEvent(val status: Status) : Dispatchable
data class ProfileEditedEvent(val newProfileData: Account) : Dispatchable
data class PreferenceChangedEvent(val preferenceKey: String) : Dispatchable
data class MainTabsChangedEvent(val newTabs: List<TabData>) : Dispatchable
data class PollVoteEvent(val statusId: String, val poll: Poll) : Dispatchable
data class DomainMuteEvent(val instance: String) : Dispatchable
data class AnnouncementReadEvent(val announcementId: String) : Dispatchable
data class PinEvent(val statusId: String, val pinned: Boolean) : Dispatchable
data class QuickReplyEvent(val status: Status) : Dispatchable
data class StreamUpdateEvent(val status: Status, val subscription: Subscription) : Dispatchable
data class FavoriteEvent(val statusId: String, val favourite: Boolean) : Event
data class ReblogEvent(val statusId: String, val reblog: Boolean) : Event
data class BookmarkEvent(val statusId: String, val bookmark: Boolean) : Event
data class MuteConversationEvent(val statusId: String, val mute: Boolean) : Event
data class UnfollowEvent(val accountId: String) : Event
data class BlockEvent(val accountId: String) : Event
data class MuteEvent(val accountId: String) : Event
data class StatusDeletedEvent(val statusId: String) : Event
data class StatusComposedEvent(val status: Status) : Event
data class StatusScheduledEvent(val status: Status) : Event
data class StatusEditedEvent(val originalId: String, val status: Status) : Event
data class ProfileEditedEvent(val newProfileData: Account) : Event
data class PreferenceChangedEvent(val preferenceKey: String) : Event
data class MainTabsChangedEvent(val newTabs: List<TabData>) : Event
data class PollVoteEvent(val statusId: String, val poll: Poll) : Event
data class DomainMuteEvent(val instance: String) : Event
data class AnnouncementReadEvent(val announcementId: String) : Event
data class PinEvent(val statusId: String, val pinned: Boolean) : Event
data class QuickReplyEvent(val status: Status) : Event
data class StreamUpdateEvent(val status: Status, val subscription: Subscription) : Event

View File

@ -1,20 +1,19 @@
package com.keylesspalace.tusky.appstore
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.subjects.PublishSubject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import javax.inject.Inject
import javax.inject.Singleton
interface Event
interface Dispatchable : Event
@Singleton
class EventHub @Inject constructor() {
private val eventsSubject = PublishSubject.create<Event>()
val events: Observable<Event> = eventsSubject
private val sharedEventFlow: MutableSharedFlow<Event> = MutableSharedFlow()
val events: Flow<Event> = sharedEventFlow
fun dispatch(event: Dispatchable) {
eventsSubject.onNext(event)
suspend fun dispatch(event: Event) {
sharedEventFlow.emit(event)
}
}

View File

@ -22,9 +22,11 @@ import android.content.Context
import android.content.Intent
import android.content.res.ColorStateList
import android.graphics.Color
import android.graphics.drawable.LayerDrawable
import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
@ -32,12 +34,15 @@ import androidx.activity.viewModels
import androidx.annotation.ColorInt
import androidx.annotation.Px
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.app.ActivityOptionsCompat
import androidx.core.view.MenuProvider
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsCompat.Type.systemBars
import androidx.core.view.updatePadding
import androidx.core.widget.doAfterTextChanged
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.viewpager2.widget.MarginPageTransformer
@ -50,13 +55,13 @@ import com.google.android.material.shape.ShapeAppearanceModel
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import com.keylesspalace.tusky.AccountListActivity
import com.keylesspalace.tusky.BottomSheetActivity
import com.keylesspalace.tusky.EditProfileActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.StatusListActivity
import com.keylesspalace.tusky.ViewMediaActivity
import com.keylesspalace.tusky.components.account.list.ListsForAccountFragment
import com.keylesspalace.tusky.components.accountlist.AccountListActivity
import com.keylesspalace.tusky.components.compose.ComposeActivity
import com.keylesspalace.tusky.components.report.ReportActivity
import com.keylesspalace.tusky.databinding.ActivityAccountBinding
@ -70,7 +75,6 @@ import com.keylesspalace.tusky.interfaces.ActionButtonActivity
import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.interfaces.ReselectableFragment
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.DefaultTextWatcher
import com.keylesspalace.tusky.util.Error
import com.keylesspalace.tusky.util.Loading
import com.keylesspalace.tusky.util.Success
@ -82,9 +86,14 @@ import com.keylesspalace.tusky.util.parseAsMastodonHtml
import com.keylesspalace.tusky.util.reduceSwipeSensitivity
import com.keylesspalace.tusky.util.setClickableText
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.unsafeLazy
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible
import com.keylesspalace.tusky.view.showMuteAccountDialog
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizeDp
import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
import java.text.NumberFormat
@ -94,13 +103,14 @@ import java.util.Locale
import javax.inject.Inject
import kotlin.math.abs
class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInjector, LinkListener {
class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider, HasAndroidInjector, LinkListener {
@Inject
lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector<Any>
@Inject
lateinit var viewModelFactory: ViewModelFactory
@Inject
lateinit var draftsAlert: DraftsAlert
@ -110,7 +120,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
private lateinit var accountFieldAdapter: AccountFieldAdapter
private val preferences by lazy { PreferenceManager.getDefaultSharedPreferences(this) }
private val preferences by unsafeLazy { PreferenceManager.getDefaultSharedPreferences(this) }
private var followState: FollowState = FollowState.NOT_FOLLOWING
private var blocking: Boolean = false
@ -150,11 +160,14 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
private lateinit var adapter: AccountPagerAdapter
private var noteWatcher: TextWatcher? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
loadResources()
makeNotificationBarTransparent()
setContentView(binding.root)
addMenuProvider(this)
// Obtain information to fill out the profile.
viewModel.setAccountInfo(intent.getStringExtra(KEY_ACCOUNT_ID)!!)
@ -183,9 +196,9 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
* Load colors and dimensions from resources
*/
private fun loadResources() {
toolbarColor = MaterialColors.getColor(this, R.attr.colorSurface, Color.BLACK)
toolbarColor = MaterialColors.getColor(this, com.google.android.material.R.attr.colorSurface, Color.BLACK)
statusBarColorTransparent = getColor(R.color.transparent_statusbar_background)
statusBarColorOpaque = MaterialColors.getColor(this, R.attr.colorPrimaryDark, Color.BLACK)
statusBarColorOpaque = MaterialColors.getColor(this, androidx.appcompat.R.attr.colorPrimaryDark, Color.BLACK)
avatarSize = resources.getDimension(R.dimen.account_activity_avatar_size)
titleVisibleHeight = resources.getDimensionPixelSize(R.dimen.account_activity_scroll_title_visible_height)
}
@ -303,6 +316,23 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
toolbarBackground.fillColor = ColorStateList.valueOf(Color.TRANSPARENT)
binding.accountToolbar.background = toolbarBackground
// Provide a non-transparent background to the navigation and overflow icons to ensure
// they remain visible over whatever the profile background image might be.
val backgroundCircle = AppCompatResources.getDrawable(this, R.drawable.background_circle)!!
backgroundCircle.alpha = 210 // Any lower than this and the backgrounds interfere
binding.accountToolbar.navigationIcon = LayerDrawable(
arrayOf(
backgroundCircle,
binding.accountToolbar.navigationIcon
)
)
binding.accountToolbar.overflowIcon = LayerDrawable(
arrayOf(
backgroundCircle,
binding.accountToolbar.overflowIcon
)
)
binding.accountHeaderInfoContainer.background = MaterialShapeDrawable.createWithElevationOverlay(this, appBarElevation)
val avatarBackground = MaterialShapeDrawable.createWithElevationOverlay(this, appBarElevation).apply {
@ -318,7 +348,6 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
binding.accountAppBarLayout.addOnOffsetChangedListener(object : AppBarLayout.OnOffsetChangedListener {
override fun onOffsetChanged(appBarLayout: AppBarLayout, verticalOffset: Int) {
if (verticalOffset == oldOffset) {
return
}
@ -399,14 +428,16 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
draftsAlert.observeInContext(this, true)
}
private fun onRefresh() {
viewModel.refresh()
adapter.refreshContent()
}
/**
* Setup swipe to refresh layout
*/
private fun setupRefreshLayout() {
binding.swipeToRefreshLayout.setOnRefreshListener {
viewModel.refresh()
adapter.refreshContent()
}
binding.swipeToRefreshLayout.setOnRefreshListener { onRefresh() }
viewModel.isRefreshing.observe(
this
) { isRefreshing ->
@ -439,8 +470,8 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
val emojifiedNote = account.note.parseAsMastodonHtml().emojify(account.emojis, binding.accountNoteTextView, animateEmojis)
setClickableText(binding.accountNoteTextView, emojifiedNote, emptyList(), null, this)
accountFieldAdapter.fields = account.fields ?: emptyList()
accountFieldAdapter.emojis = account.emojis ?: emptyList()
accountFieldAdapter.fields = account.fields.orEmpty()
accountFieldAdapter.emojis = account.emojis.orEmpty()
accountFieldAdapter.notifyDataSetChanged()
binding.accountLockedImageView.visible(account.locked)
@ -493,18 +524,23 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
.centerCrop()
.into(binding.accountHeaderImageView)
binding.accountAvatarImageView.setOnClickListener { avatarView ->
val intent =
ViewMediaActivity.newSingleImageIntent(avatarView.context, account.avatar)
avatarView.transitionName = account.avatar
val options = ActivityOptionsCompat.makeSceneTransitionAnimation(this, avatarView, account.avatar)
startActivity(intent, options.toBundle())
binding.accountAvatarImageView.setOnClickListener { view ->
viewImage(view, account.avatar)
}
binding.accountHeaderImageView.setOnClickListener { view ->
viewImage(view, account.header)
}
}
}
private fun viewImage(view: View, uri: String) {
view.transitionName = uri
startActivity(
ViewMediaActivity.newSingleImageIntent(view.context, uri),
ActivityOptionsCompat.makeSceneTransitionAnimation(this, view, uri).toBundle()
)
}
/**
* Update toolbar views for loaded account
*/
@ -619,10 +655,11 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
binding.accountSubscribeButton.setOnClickListener {
viewModel.changeSubscribingState()
}
if (relation.notifying != null)
if (relation.notifying != null) {
subscribing = relation.notifying
else if (relation.subscribing != null)
} else if (relation.subscribing != null) {
subscribing = relation.subscribing
}
}
// remove the listener so it doesn't fire on non-user changes
@ -631,15 +668,11 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
binding.accountNoteTextInputLayout.visible(relation.note != null)
binding.accountNoteTextInputLayout.editText?.setText(relation.note)
binding.accountNoteTextInputLayout.editText?.addTextChangedListener(noteWatcher)
updateButtons()
}
private val noteWatcher = object : DefaultTextWatcher() {
override fun afterTextChanged(s: Editable) {
noteWatcher = binding.accountNoteTextInputLayout.editText?.doAfterTextChanged { s ->
viewModel.noteChanged(s.toString())
}
updateButtons()
}
private fun updateFollowButton() {
@ -704,7 +737,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.account_toolbar, menu)
val openAsItem = menu.findItem(R.id.action_open_as)
@ -716,7 +749,6 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
}
if (!viewModel.isSelf) {
val block = menu.findItem(R.id.action_block)
block.title = if (blocking) {
getString(R.string.action_unblock)
@ -769,7 +801,12 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
menu.removeItem(R.id.action_add_or_remove_from_list)
}
return super.onCreateOptionsMenu(menu)
menu.findItem(R.id.action_search)?.apply {
icon = IconicsDrawable(this@AccountActivity, GoogleMaterial.Icon.gmd_search).apply {
sizeDp = 20
colorInt = MaterialColors.getColor(binding.collapsingToolbar, android.R.attr.textColorPrimary)
}
}
}
private fun showFollowRequestPendingDialog() {
@ -857,7 +894,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
viewUrl(url, text = text)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
override fun onMenuItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_open_in_web -> {
// If the account isn't loaded yet, eat the input.
@ -869,7 +906,8 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
R.id.action_open_as -> {
loadedAccount?.let { loadedAccount ->
showAccountChooserDialog(
item.title, false,
item.title,
false,
object : AccountSelectionListener {
override fun onAccountSelected(account: AccountEntity) {
openAsAccount(loadedAccount.url, account)
@ -922,6 +960,11 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
viewModel.changeShowReblogsState()
return true
}
R.id.action_refresh -> {
binding.swipeToRefreshLayout.isRefreshing = true
onRefresh()
return true
}
R.id.action_report -> {
loadedAccount?.let { loadedAccount ->
startActivity(ReportActivity.getIntent(this, viewModel.accountId, loadedAccount.username))
@ -929,23 +972,25 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
return true
}
}
return super.onOptionsItemSelected(item)
return false
}
override fun getActionButton(): FloatingActionButton? {
return if (!blocking) {
binding.accountFloatingActionButton
} else null
} else {
null
}
}
private fun getFullUsername(account: Account): String {
if (account.isRemote()) {
return "@" + account.username
return if (account.isRemote()) {
"@" + account.username
} else {
val localUsername = account.localUsername
// Note: !! here will crash if this pane is ever shown to a logged-out user. With AccountActivity this is believed to be impossible.
val domain = accountManager.activeAccount!!.domain
return "@$localUsername@$domain"
"@$localUsername@$domain"
}
}

View File

@ -2,7 +2,9 @@ package com.keylesspalace.tusky.components.account
import android.util.Log
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.connyduck.calladapter.networkresult.fold
import com.keylesspalace.tusky.appstore.BlockEvent
import com.keylesspalace.tusky.appstore.DomainMuteEvent
import com.keylesspalace.tusky.appstore.EventHub
@ -16,22 +18,17 @@ import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.Error
import com.keylesspalace.tusky.util.Loading
import com.keylesspalace.tusky.util.Resource
import com.keylesspalace.tusky.util.RxAwareViewModel
import com.keylesspalace.tusky.util.Success
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.Disposable
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import java.util.concurrent.TimeUnit
import javax.inject.Inject
class AccountViewModel @Inject constructor(
private val mastodonApi: MastodonApi,
private val eventHub: EventHub,
private val accountManager: AccountManager
) : RxAwareViewModel() {
) : ViewModel() {
val accountData = MutableLiveData<Resource<Account>>()
val relationshipData = MutableLiveData<Resource<Relationship>>()
@ -44,15 +41,16 @@ class AccountViewModel @Inject constructor(
lateinit var accountId: String
var isSelf = false
private var noteDisposable: Disposable? = null
private var noteUpdateJob: Job? = null
init {
eventHub.events
.subscribe { event ->
viewModelScope.launch {
eventHub.events.collect { event ->
if (event is ProfileEditedEvent && event.newProfileData.id == accountData.value?.data?.id) {
accountData.postValue(Success(event.newProfileData))
}
}.autoDispose()
}
}
}
private fun obtainAccount(reload: Boolean = false) {
@ -60,40 +58,41 @@ class AccountViewModel @Inject constructor(
isDataLoading = true
accountData.postValue(Loading())
mastodonApi.account(accountId)
.subscribe(
{ account ->
accountData.postValue(Success(account))
isDataLoading = false
isRefreshing.postValue(false)
},
{ t ->
Log.w(TAG, "failed obtaining account", t)
accountData.postValue(Error())
isDataLoading = false
isRefreshing.postValue(false)
}
)
.autoDispose()
viewModelScope.launch {
mastodonApi.account(accountId)
.fold(
{ account ->
accountData.postValue(Success(account))
isDataLoading = false
isRefreshing.postValue(false)
},
{ t ->
Log.w(TAG, "failed obtaining account", t)
accountData.postValue(Error(cause = t))
isDataLoading = false
isRefreshing.postValue(false)
}
)
}
}
}
private fun obtainRelationship(reload: Boolean = false) {
if (relationshipData.value == null || reload) {
relationshipData.postValue(Loading())
mastodonApi.relationships(listOf(accountId))
.subscribe(
{ relationships ->
relationshipData.postValue(Success(relationships[0]))
},
{ t ->
Log.w(TAG, "failed obtaining relationships", t)
relationshipData.postValue(Error())
}
)
.autoDispose()
viewModelScope.launch {
mastodonApi.relationships(listOf(accountId))
.fold(
{ relationships ->
relationshipData.postValue(if (relationships.isNotEmpty()) Success(relationships[0]) else Error())
},
{ t ->
Log.w(TAG, "failed obtaining relationships", t)
relationshipData.postValue(Error(cause = t))
}
)
}
}
}
@ -134,42 +133,30 @@ class AccountViewModel @Inject constructor(
}
fun blockDomain(instance: String) {
mastodonApi.blockDomain(instance).enqueue(object : Callback<Any> {
override fun onResponse(call: Call<Any>, response: Response<Any>) {
if (response.isSuccessful) {
eventHub.dispatch(DomainMuteEvent(instance))
val relation = relationshipData.value?.data
if (relation != null) {
relationshipData.postValue(Success(relation.copy(blockingDomain = true)))
}
} else {
Log.e(TAG, "Error muting %s".format(instance))
viewModelScope.launch {
mastodonApi.blockDomain(instance).fold({
eventHub.dispatch(DomainMuteEvent(instance))
val relation = relationshipData.value?.data
if (relation != null) {
relationshipData.postValue(Success(relation.copy(blockingDomain = true)))
}
}
override fun onFailure(call: Call<Any>, t: Throwable) {
Log.e(TAG, "Error muting %s".format(instance), t)
}
})
}, { e ->
Log.e(TAG, "Error muting $instance", e)
})
}
}
fun unblockDomain(instance: String) {
mastodonApi.unblockDomain(instance).enqueue(object : Callback<Any> {
override fun onResponse(call: Call<Any>, response: Response<Any>) {
if (response.isSuccessful) {
val relation = relationshipData.value?.data
if (relation != null) {
relationshipData.postValue(Success(relation.copy(blockingDomain = false)))
}
} else {
Log.e(TAG, "Error unmuting %s".format(instance))
viewModelScope.launch {
mastodonApi.unblockDomain(instance).fold({
val relation = relationshipData.value?.data
if (relation != null) {
relationshipData.postValue(Success(relation.copy(blockingDomain = false)))
}
}
override fun onFailure(call: Call<Any>, t: Throwable) {
Log.e(TAG, "Error unmuting %s".format(instance), t)
}
})
}, { e ->
Log.e(TAG, "Error unmuting $instance", e)
})
}
}
fun changeShowReblogsState() {
@ -209,84 +196,88 @@ class AccountViewModel @Inject constructor(
RelationShipAction.MUTE -> relation.copy(muting = true)
RelationShipAction.UNMUTE -> relation.copy(muting = false)
RelationShipAction.SUBSCRIBE -> {
if (isMastodon)
if (isMastodon) {
relation.copy(notifying = true)
else relation.copy(subscribing = true)
} else {
relation.copy(subscribing = true)
}
}
RelationShipAction.UNSUBSCRIBE -> {
if (isMastodon)
if (isMastodon) {
relation.copy(notifying = false)
else relation.copy(subscribing = false)
} else {
relation.copy(subscribing = false)
}
}
}
relationshipData.postValue(Loading(newRelation))
}
try {
val relationship = when (relationshipAction) {
RelationShipAction.FOLLOW -> mastodonApi.followAccount(
accountId,
showReblogs = parameter ?: true
)
RelationShipAction.UNFOLLOW -> mastodonApi.unfollowAccount(accountId)
RelationShipAction.BLOCK -> mastodonApi.blockAccount(accountId)
RelationShipAction.UNBLOCK -> mastodonApi.unblockAccount(accountId)
RelationShipAction.MUTE -> mastodonApi.muteAccount(
accountId,
parameter ?: true,
duration
)
RelationShipAction.UNMUTE -> mastodonApi.unmuteAccount(accountId)
RelationShipAction.SUBSCRIBE -> {
if (isMastodon)
mastodonApi.followAccount(accountId, notify = true)
else mastodonApi.subscribeAccount(accountId)
}
RelationShipAction.UNSUBSCRIBE -> {
if (isMastodon)
mastodonApi.followAccount(accountId, notify = false)
else mastodonApi.unsubscribeAccount(accountId)
val relationshipCall = when (relationshipAction) {
RelationShipAction.FOLLOW -> mastodonApi.followAccount(
accountId,
showReblogs = parameter ?: true
)
RelationShipAction.UNFOLLOW -> mastodonApi.unfollowAccount(accountId)
RelationShipAction.BLOCK -> mastodonApi.blockAccount(accountId)
RelationShipAction.UNBLOCK -> mastodonApi.unblockAccount(accountId)
RelationShipAction.MUTE -> mastodonApi.muteAccount(
accountId,
parameter ?: true,
duration
)
RelationShipAction.UNMUTE -> mastodonApi.unmuteAccount(accountId)
RelationShipAction.SUBSCRIBE -> {
if (isMastodon) {
mastodonApi.followAccount(accountId, notify = true)
} else {
mastodonApi.subscribeAccount(accountId)
}
}
relationshipData.postValue(Success(relationship))
when (relationshipAction) {
RelationShipAction.UNFOLLOW -> eventHub.dispatch(UnfollowEvent(accountId))
RelationShipAction.BLOCK -> eventHub.dispatch(BlockEvent(accountId))
RelationShipAction.MUTE -> eventHub.dispatch(MuteEvent(accountId))
else -> {
RelationShipAction.UNSUBSCRIBE -> {
if (isMastodon) {
mastodonApi.followAccount(accountId, notify = false)
} else {
mastodonApi.unsubscribeAccount(accountId)
}
}
} catch (_: Throwable) {
relationshipData.postValue(Error(relation))
}
relationshipCall.fold(
{ relationship ->
relationshipData.postValue(Success(relationship))
when (relationshipAction) {
RelationShipAction.UNFOLLOW -> eventHub.dispatch(UnfollowEvent(accountId))
RelationShipAction.BLOCK -> eventHub.dispatch(BlockEvent(accountId))
RelationShipAction.MUTE -> eventHub.dispatch(MuteEvent(accountId))
else -> { }
}
},
{ t ->
Log.w(TAG, "failed loading relationship", t)
relationshipData.postValue(Error(relation, cause = t))
}
)
}
fun noteChanged(newNote: String) {
noteSaved.postValue(false)
noteDisposable?.dispose()
noteDisposable = Single.timer(1500, TimeUnit.MILLISECONDS)
.flatMap {
mastodonApi.updateAccountNote(accountId, newNote)
}
.doOnSuccess {
noteSaved.postValue(true)
}
.delay(4, TimeUnit.SECONDS)
.subscribe(
{
noteSaved.postValue(false)
},
{
Log.e(TAG, "Error updating note", it)
}
)
}
override fun onCleared() {
super.onCleared()
noteDisposable?.dispose()
noteUpdateJob?.cancel()
noteUpdateJob = viewModelScope.launch {
delay(1500)
mastodonApi.updateAccountNote(accountId, newNote)
.fold(
{
noteSaved.postValue(true)
delay(4000)
noteSaved.postValue(false)
},
{ t ->
Log.w(TAG, "Error updating note", t)
}
)
}
}
fun refresh() {
@ -294,12 +285,14 @@ class AccountViewModel @Inject constructor(
}
private fun reload(isReload: Boolean = false) {
if (isDataLoading)
if (isDataLoading) {
return
}
accountId.let {
obtainAccount(isReload)
if (!isSelf)
if (!isSelf) {
obtainRelationship(isReload)
}
}
}

View File

@ -65,7 +65,7 @@ class ListsForAccountFragment : DialogFragment(), Injectable {
dialog?.apply {
window?.setLayout(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.MATCH_PARENT
)
}
}
@ -172,7 +172,7 @@ class ListsForAccountFragment : DialogFragment(), Injectable {
ListAdapter<AccountListState, BindingHolder<ItemAddOrRemoveFromListBinding>>(Differ) {
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int,
viewType: Int
): BindingHolder<ItemAddOrRemoveFromListBinding> {
val binding =
ItemAddOrRemoveFromListBinding.inflate(LayoutInflater.from(parent.context), parent, false)

View File

@ -35,23 +35,23 @@ import javax.inject.Inject
data class AccountListState(
val list: MastoList,
val includesAccount: Boolean,
val includesAccount: Boolean
)
data class ActionError(
val error: Throwable,
val type: Type,
val listId: String,
val listId: String
) : Throwable(error) {
enum class Type {
ADD,
REMOVE,
REMOVE
}
}
@OptIn(ExperimentalCoroutinesApi::class)
class ListsForAccountViewModel @Inject constructor(
private val mastodonApi: MastodonApi,
private val mastodonApi: MastodonApi
) : ViewModel() {
private lateinit var accountId: String
@ -75,14 +75,14 @@ class ListsForAccountViewModel @Inject constructor(
runCatching {
val (all, includes) = listOf(
async { mastodonApi.getLists() },
async { mastodonApi.getListsIncludesAccount(accountId) },
async { mastodonApi.getListsIncludesAccount(accountId) }
).awaitAll()
_states.emit(
all.getOrThrow().map { list ->
AccountListState(
list = list,
includesAccount = includes.getOrThrow().any { it.id == list.id },
includesAccount = includes.getOrThrow().any { it.id == list.id }
)
}
)

View File

@ -16,15 +16,21 @@
package com.keylesspalace.tusky.components.account.media
import android.os.Bundle
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import androidx.core.app.ActivityOptionsCompat
import androidx.core.view.MenuProvider
import androidx.core.view.ViewCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.paging.LoadState
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.GridLayoutManager
import com.google.android.material.color.MaterialColors
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.ViewMediaActivity
import com.keylesspalace.tusky.databinding.FragmentTimelineBinding
@ -39,20 +45,22 @@ import com.keylesspalace.tusky.util.openLink
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.viewdata.AttachmentViewData
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizeDp
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import java.io.IOException
import javax.inject.Inject
/**
* Created by charlag on 26/10/2017.
*
* Fragment with multiple columns of media previews for the specified account.
*/
class AccountMediaFragment :
Fragment(R.layout.fragment_timeline),
RefreshableFragment,
MenuProvider,
Injectable {
@Inject
@ -73,6 +81,7 @@ class AccountMediaFragment :
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
val alwaysShowSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia
@ -95,6 +104,8 @@ class AccountMediaFragment :
binding.recyclerView.adapter = adapter
binding.swipeRefreshLayout.isEnabled = false
binding.swipeRefreshLayout.setOnRefreshListener { refreshContent() }
binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
binding.statusView.visibility = View.GONE
@ -108,6 +119,10 @@ class AccountMediaFragment :
binding.statusView.hide()
binding.progressBar.hide()
if (loadState.refresh != LoadState.Loading && loadState.source.refresh != LoadState.Loading) {
binding.swipeRefreshLayout.isRefreshing = false
}
if (adapter.itemCount == 0) {
when (loadState.refresh) {
is LoadState.NotLoading -> {
@ -133,6 +148,27 @@ class AccountMediaFragment :
}
}
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.fragment_account_media, menu)
menu.findItem(R.id.action_refresh)?.apply {
icon = IconicsDrawable(requireContext(), GoogleMaterial.Icon.gmd_refresh).apply {
sizeDp = 20
colorInt = MaterialColors.getColor(binding.root, android.R.attr.textColorPrimary)
}
}
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
return when (menuItem.itemId) {
R.id.action_refresh -> {
binding.swipeRefreshLayout.isRefreshing = true
refreshContent()
true
}
else -> false
}
}
private fun onAttachmentClick(selected: AttachmentViewData, view: View) {
if (!selected.isRevealed) {
viewModel.revealAttachment(selected)

View File

@ -40,7 +40,7 @@ class AccountMediaGridAdapter(
}
) {
private val baseItemBackgroundColor = MaterialColors.getColor(context, R.attr.colorSurface, Color.BLACK)
private val baseItemBackgroundColor = MaterialColors.getColor(context, com.google.android.material.R.attr.colorSurface, Color.BLACK)
private val videoIndicator = AppCompatResources.getDrawable(context, R.drawable.ic_play_indicator)
private val mediaHiddenDrawable = AppCompatResources.getDrawable(context, R.drawable.ic_hide_media_24dp)

View File

@ -26,7 +26,6 @@ class AccountMediaPagingSource(
override fun getRefreshKey(state: PagingState<String, AttachmentViewData>): String? = null
override suspend fun load(params: LoadParams<String>): LoadResult<String, AttachmentViewData> {
return if (params is LoadParams.Refresh) {
val list = viewModel.attachmentData.toList()
LoadResult.Page(list, null, list.lastOrNull()?.statusId)

View File

@ -34,7 +34,6 @@ class AccountMediaRemoteMediator(
loadType: LoadType,
state: PagingState<String, AttachmentViewData>
): MediatorResult {
try {
val statusResponse = when (loadType) {
LoadType.REFRESH -> {

View File

@ -25,7 +25,7 @@ import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.viewdata.AttachmentViewData
import javax.inject.Inject
class AccountMediaViewModel @Inject constructor (
class AccountMediaViewModel @Inject constructor(
api: MastodonApi
) : ViewModel() {

View File

@ -13,18 +13,20 @@
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky
package com.keylesspalace.tusky.components.accountlist
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.fragment.app.commit
import com.keylesspalace.tusky.BottomSheetActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ActivityAccountListBinding
import com.keylesspalace.tusky.fragment.AccountListFragment
import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
import javax.inject.Inject
class AccountListActivity : BaseActivity(), HasAndroidInjector {
class AccountListActivity : BottomSheetActivity(), HasAndroidInjector {
@Inject
lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector<Any>
@ -63,10 +65,9 @@ class AccountListActivity : BaseActivity(), HasAndroidInjector {
setDisplayShowHomeEnabled(true)
}
supportFragmentManager
.beginTransaction()
.replace(R.id.fragment_container, AccountListFragment.newInstance(type, id, accountLocked))
.commit()
supportFragmentManager.commit {
replace(R.id.fragment_container, AccountListFragment.newInstance(type, id, accountLocked))
}
}
override fun androidInjector() = dispatchingAndroidInjector
@ -76,8 +77,6 @@ class AccountListActivity : BaseActivity(), HasAndroidInjector {
private const val EXTRA_ID = "id"
private const val EXTRA_ACCOUNT_LOCKED = "acc_locked"
@JvmStatic
@JvmOverloads
fun newIntent(context: Context, type: Type, id: String? = null, accountLocked: Boolean = false): Intent {
return Intent(context, AccountListActivity::class.java).apply {
putExtra(EXTRA_TYPE, type)

View File

@ -13,7 +13,7 @@
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.fragment
package com.keylesspalace.tusky.components.accountlist
import android.os.Bundle
import android.util.Log
@ -27,25 +27,30 @@ import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.SimpleItemAnimator
import at.connyduck.calladapter.networkresult.fold
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from
import autodispose2.autoDispose
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.AccountListActivity.Type
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.BottomSheetActivity
import com.keylesspalace.tusky.PostLookupFallbackBehavior
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.AccountAdapter
import com.keylesspalace.tusky.adapter.BlocksAdapter
import com.keylesspalace.tusky.adapter.FollowAdapter
import com.keylesspalace.tusky.adapter.FollowRequestsAdapter
import com.keylesspalace.tusky.adapter.FollowRequestsHeaderAdapter
import com.keylesspalace.tusky.adapter.MutesAdapter
import com.keylesspalace.tusky.StatusListActivity
import com.keylesspalace.tusky.components.account.AccountActivity
import com.keylesspalace.tusky.components.accountlist.AccountListActivity.Type
import com.keylesspalace.tusky.components.accountlist.adapter.AccountAdapter
import com.keylesspalace.tusky.components.accountlist.adapter.BlocksAdapter
import com.keylesspalace.tusky.components.accountlist.adapter.FollowAdapter
import com.keylesspalace.tusky.components.accountlist.adapter.FollowRequestsAdapter
import com.keylesspalace.tusky.components.accountlist.adapter.FollowRequestsHeaderAdapter
import com.keylesspalace.tusky.components.accountlist.adapter.MutesAdapter
import com.keylesspalace.tusky.databinding.FragmentAccountListBinding
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.Relationship
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.interfaces.AccountActionListener
import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.HttpHeaderLink
@ -59,10 +64,15 @@ import retrofit2.Response
import java.io.IOException
import javax.inject.Inject
class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountActionListener, Injectable {
class AccountListFragment :
Fragment(R.layout.fragment_account_list),
AccountActionListener,
LinkListener,
Injectable {
@Inject
lateinit var api: MastodonApi
@Inject
lateinit var accountManager: AccountManager
@ -83,15 +93,15 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.recyclerView.setHasFixedSize(true)
val layoutManager = LinearLayoutManager(view.context)
binding.recyclerView.layoutManager = layoutManager
(binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
binding.recyclerView.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL))
binding.swipeRefreshLayout.setOnRefreshListener { fetchAccounts() }
binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
val pm = PreferenceManager.getDefaultSharedPreferences(view.context)
val animateAvatar = pm.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false)
val animateEmojis = pm.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
@ -101,8 +111,11 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct
Type.BLOCKS -> BlocksAdapter(this, animateAvatar, animateEmojis, showBotOverlay)
Type.MUTES -> MutesAdapter(this, animateAvatar, animateEmojis, showBotOverlay)
Type.FOLLOW_REQUESTS -> {
val headerAdapter = FollowRequestsHeaderAdapter(accountManager.activeAccount!!.domain, arguments?.getBoolean(ARG_ACCOUNT_LOCKED) == true)
val followRequestsAdapter = FollowRequestsAdapter(this, animateAvatar, animateEmojis, showBotOverlay)
val headerAdapter = FollowRequestsHeaderAdapter(
instanceName = accountManager.activeAccount!!.domain,
accountLocked = arguments?.getBoolean(ARG_ACCOUNT_LOCKED) == true
)
val followRequestsAdapter = FollowRequestsAdapter(this, this, animateAvatar, animateEmojis, showBotOverlay)
binding.recyclerView.adapter = ConcatAdapter(headerAdapter, followRequestsAdapter)
followRequestsAdapter
}
@ -126,6 +139,11 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct
fetchAccounts()
}
override fun onViewTag(tag: String) {
(activity as BaseActivity?)
?.startActivityWithSlideInAnimation(StatusListActivity.newHashtagIntent(requireContext(), tag))
}
override fun onViewAccount(id: String) {
(activity as BaseActivity?)?.let {
val intent = AccountActivity.getIntent(it, id)
@ -133,6 +151,10 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct
}
}
override fun onViewUrl(url: String, text: String) {
(activity as BottomSheetActivity?)?.viewUrl(url, PostLookupFallbackBehavior.OPEN_IN_BROWSER, text)
}
override fun onMute(mute: Boolean, id: String, position: Int, notifications: Boolean) {
viewLifecycleOwner.lifecycleScope.launch {
try {
@ -225,7 +247,6 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct
accountId: String,
position: Int
) {
if (accept) {
api.authorizeFollowRequest(accountId)
} else {
@ -285,6 +306,7 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct
return
}
fetching = true
binding.swipeRefreshLayout.isRefreshing = true
if (fromId != null) {
binding.recyclerView.post { adapter.setBottomLoading(true) }
@ -293,6 +315,7 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct
viewLifecycleOwner.lifecycleScope.launch {
try {
val response = getFetchCallByListType(fromId)
if (!response.isSuccessful) {
onFetchAccountsFailure(Exception(response.message()))
return@launch
@ -315,6 +338,7 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct
private fun onFetchAccountsSuccess(accounts: List<TimelineAccount>, linkHeader: String?) {
adapter.setBottomLoading(false)
binding.swipeRefreshLayout.isRefreshing = false
val links = HttpHeaderLink.parse(linkHeader)
val next = HttpHeaderLink.findByRelationType(links, "next")
@ -347,12 +371,12 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct
}
private fun fetchRelationships(ids: List<String>) {
api.relationships(ids)
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(from(this))
.subscribe(::onFetchRelationshipsSuccess) {
onFetchRelationshipsFailure(ids)
}
lifecycleScope.launch {
api.relationships(ids)
.fold(::onFetchRelationshipsSuccess) { throwable ->
Log.e(TAG, "Fetch failure for relationships of accounts: $ids", throwable)
}
}
}
private fun onFetchRelationshipsSuccess(relationships: List<Relationship>) {
@ -362,12 +386,9 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct
mutesAdapter.updateMutingNotificationsMap(mutingNotificationsMap)
}
private fun onFetchRelationshipsFailure(ids: List<String>) {
Log.e(TAG, "Fetch failure for relationships of accounts: $ids")
}
private fun onFetchAccountsFailure(throwable: Throwable) {
fetching = false
binding.swipeRefreshLayout.isRefreshing = false
Log.e(TAG, "Fetch failure", throwable)
if (adapter.itemCount == 0) {

View File

@ -12,24 +12,26 @@
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.adapter
package com.keylesspalace.tusky.components.accountlist.adapter
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemFooterBinding
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.interfaces.AccountActionListener
import com.keylesspalace.tusky.util.BindingHolder
import com.keylesspalace.tusky.util.removeDuplicates
/** Generic adapter with bottom loading indicator. */
abstract class AccountAdapter<AVH : RecyclerView.ViewHolder> internal constructor(
var accountActionListener: AccountActionListener,
protected val accountActionListener: AccountActionListener,
protected val animateAvatar: Boolean,
protected val animateEmojis: Boolean,
protected val showBotOverlay: Boolean
) : RecyclerView.Adapter<RecyclerView.ViewHolder?>() {
var accountList = mutableListOf<TimelineAccount>()
protected var accountList: MutableList<TimelineAccount> = mutableListOf()
private var bottomLoading: Boolean = false
override fun getItemCount(): Int {
@ -59,11 +61,10 @@ abstract class AccountAdapter<AVH : RecyclerView.ViewHolder> internal constructo
}
private fun createFooterViewHolder(
parent: ViewGroup,
parent: ViewGroup
): RecyclerView.ViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_footer, parent, false)
return LoadingFooterViewHolder(view)
val binding = ItemFooterBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return BindingHolder(binding)
}
override fun getItemViewType(position: Int): Int {

View File

@ -0,0 +1,68 @@
/* Copyright 2017 Andrew Dawson
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.accountlist.adapter
import android.view.LayoutInflater
import android.view.ViewGroup
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemBlockedUserBinding
import com.keylesspalace.tusky.interfaces.AccountActionListener
import com.keylesspalace.tusky.util.BindingHolder
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.loadAvatar
import com.keylesspalace.tusky.util.visible
/** Displays a list of blocked accounts. */
class BlocksAdapter(
accountActionListener: AccountActionListener,
animateAvatar: Boolean,
animateEmojis: Boolean,
showBotOverlay: Boolean
) : AccountAdapter<BindingHolder<ItemBlockedUserBinding>>(
accountActionListener = accountActionListener,
animateAvatar = animateAvatar,
animateEmojis = animateEmojis,
showBotOverlay = showBotOverlay
) {
override fun createAccountViewHolder(parent: ViewGroup): BindingHolder<ItemBlockedUserBinding> {
val binding = ItemBlockedUserBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return BindingHolder(binding)
}
override fun onBindAccountViewHolder(viewHolder: BindingHolder<ItemBlockedUserBinding>, position: Int) {
val account = accountList[position]
val binding = viewHolder.binding
val context = binding.root.context
val emojifiedName = account.name.emojify(account.emojis, binding.blockedUserDisplayName, animateEmojis)
binding.blockedUserDisplayName.text = emojifiedName
val formattedUsername = context.getString(R.string.post_username_format, account.username)
binding.blockedUserUsername.text = formattedUsername
val avatarRadius = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp)
loadAvatar(account.avatar, binding.blockedUserAvatar, avatarRadius, animateAvatar)
binding.blockedUserBotBadge.visible(showBotOverlay && account.bot)
binding.blockedUserUnblock.setOnClickListener {
accountActionListener.onBlock(false, account.id, position)
}
binding.root.setOnClickListener {
accountActionListener.onViewAccount(account.id)
}
}
}

View File

@ -12,10 +12,12 @@
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.adapter
package com.keylesspalace.tusky.components.accountlist.adapter
import android.view.LayoutInflater
import android.view.ViewGroup
import com.keylesspalace.tusky.adapter.AccountViewHolder
import com.keylesspalace.tusky.databinding.ItemAccountBinding
import com.keylesspalace.tusky.interfaces.AccountActionListener
@ -26,17 +28,14 @@ class FollowAdapter(
animateEmojis: Boolean,
showBotOverlay: Boolean
) : AccountAdapter<AccountViewHolder>(
accountActionListener,
animateAvatar,
animateEmojis,
showBotOverlay
accountActionListener = accountActionListener,
animateAvatar = animateAvatar,
animateEmojis = animateEmojis,
showBotOverlay = showBotOverlay
) {
override fun createAccountViewHolder(parent: ViewGroup): AccountViewHolder {
val binding = ItemAccountBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
val binding = ItemAccountBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return AccountViewHolder(binding)
}

View File

@ -12,29 +12,51 @@
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.adapter
package com.keylesspalace.tusky.components.accountlist.adapter
import android.view.LayoutInflater
import android.view.ViewGroup
import com.keylesspalace.tusky.adapter.FollowRequestViewHolder
import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding
import com.keylesspalace.tusky.interfaces.AccountActionListener
import com.keylesspalace.tusky.interfaces.LinkListener
/** Displays a list of follow requests with accept/reject buttons. */
class FollowRequestsAdapter(
accountActionListener: AccountActionListener,
private val linkListener: LinkListener,
animateAvatar: Boolean,
animateEmojis: Boolean,
showBotOverlay: Boolean
) : AccountAdapter<FollowRequestViewHolder>(accountActionListener, animateAvatar, animateEmojis, showBotOverlay) {
) : AccountAdapter<FollowRequestViewHolder>(
accountActionListener = accountActionListener,
animateAvatar = animateAvatar,
animateEmojis = animateEmojis,
showBotOverlay = showBotOverlay
) {
override fun createAccountViewHolder(parent: ViewGroup): FollowRequestViewHolder {
val binding = ItemFollowRequestBinding.inflate(
LayoutInflater.from(parent.context), parent, false
LayoutInflater.from(parent.context),
parent,
false
)
return FollowRequestViewHolder(
binding,
accountActionListener,
linkListener,
showHeader = false
)
return FollowRequestViewHolder(binding, false)
}
override fun onBindAccountViewHolder(viewHolder: FollowRequestViewHolder, position: Int) {
viewHolder.setupWithAccount(accountList[position], animateAvatar, animateEmojis, showBotOverlay)
viewHolder.setupWithAccount(
account = accountList[position],
animateAvatar = animateAvatar,
animateEmojis = animateEmojis,
showBotOverlay = showBotOverlay
)
viewHolder.setupActionListener(accountActionListener, accountList[position].id)
}
}

View File

@ -13,27 +13,28 @@
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.adapter
package com.keylesspalace.tusky.components.accountlist.adapter
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemFollowRequestsHeaderBinding
import com.keylesspalace.tusky.util.BindingHolder
class FollowRequestsHeaderAdapter(private val instanceName: String, private val accountLocked: Boolean) : RecyclerView.Adapter<HeaderViewHolder>() {
class FollowRequestsHeaderAdapter(
private val instanceName: String,
private val accountLocked: Boolean
) : RecyclerView.Adapter<BindingHolder<ItemFollowRequestsHeaderBinding>>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HeaderViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_follow_requests_header, parent, false) as TextView
return HeaderViewHolder(view)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemFollowRequestsHeaderBinding> {
val binding = ItemFollowRequestsHeaderBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return BindingHolder(binding)
}
override fun onBindViewHolder(viewHolder: HeaderViewHolder, position: Int) {
viewHolder.textView.text = viewHolder.textView.context.getString(R.string.follow_requests_info, instanceName)
override fun onBindViewHolder(viewHolder: BindingHolder<ItemFollowRequestsHeaderBinding>, position: Int) {
viewHolder.binding.root.text = viewHolder.binding.root.context.getString(R.string.follow_requests_info, instanceName)
}
override fun getItemCount() = if (accountLocked) 0 else 1
}
class HeaderViewHolder(var textView: TextView) : RecyclerView.ViewHolder(textView)

View File

@ -1,4 +1,19 @@
package com.keylesspalace.tusky.adapter
/* Copyright 2023 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.accountlist.adapter
import android.view.LayoutInflater
import android.view.ViewGroup
@ -9,22 +24,21 @@ import com.keylesspalace.tusky.interfaces.AccountActionListener
import com.keylesspalace.tusky.util.BindingHolder
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.loadAvatar
import com.keylesspalace.tusky.util.visible
/**
* Displays a list of muted accounts with mute/unmute account and mute/unmute notifications
* buttons.
* */
/** Displays a list of muted accounts with mute/unmute account button and mute/unmute notifications switch */
class MutesAdapter(
accountActionListener: AccountActionListener,
animateAvatar: Boolean,
animateEmojis: Boolean,
showBotOverlay: Boolean
) : AccountAdapter<BindingHolder<ItemMutedUserBinding>>(
accountActionListener,
animateAvatar,
animateEmojis,
showBotOverlay
accountActionListener = accountActionListener,
animateAvatar = animateAvatar,
animateEmojis = animateEmojis,
showBotOverlay = showBotOverlay
) {
private val mutingNotificationsMap = HashMap<String, Boolean>()
override fun createAccountViewHolder(parent: ViewGroup): BindingHolder<ItemMutedUserBinding> {
@ -48,6 +62,8 @@ class MutesAdapter(
val avatarRadius = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp)
loadAvatar(account.avatar, binding.mutedUserAvatar, avatarRadius, animateAvatar)
binding.mutedUserBotBadge.visible(showBotOverlay && account.bot)
val unmuteString = context.getString(R.string.action_unmute_desc, formattedUsername)
binding.mutedUserUnmute.contentDescription = unmuteString
ViewCompat.setTooltipText(binding.mutedUserUnmute, unmuteString)

View File

@ -80,7 +80,7 @@ class AnnouncementAdapter(
item.reactions.forEachIndexed { i, reaction ->
(
chips.getChildAt(i)?.takeUnless { it.id == R.id.addReactionChip } as Chip?
?: Chip(ContextThemeWrapper(chips.context, R.style.Widget_MaterialComponents_Chip_Choice)).apply {
?: Chip(ContextThemeWrapper(chips.context, com.google.android.material.R.style.Widget_MaterialComponents_Chip_Choice)).apply {
isCheckable = true
checkedIcon = null
chips.addView(this, i)

View File

@ -19,12 +19,17 @@ import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.os.Bundle
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.widget.PopupWindow
import androidx.activity.viewModels
import androidx.core.view.MenuProvider
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.color.MaterialColors
import com.keylesspalace.tusky.BottomSheetActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.StatusListActivity
@ -39,11 +44,21 @@ import com.keylesspalace.tusky.util.Loading
import com.keylesspalace.tusky.util.Success
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.unsafeLazy
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.view.EmojiPicker
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizeDp
import javax.inject.Inject
class AnnouncementsActivity : BottomSheetActivity(), AnnouncementActionListener, OnEmojiSelectedListener, Injectable {
class AnnouncementsActivity :
BottomSheetActivity(),
AnnouncementActionListener,
OnEmojiSelectedListener,
MenuProvider,
Injectable {
@Inject
lateinit var viewModelFactory: ViewModelFactory
@ -54,8 +69,8 @@ class AnnouncementsActivity : BottomSheetActivity(), AnnouncementActionListener,
private lateinit var adapter: AnnouncementAdapter
private val picker by lazy { EmojiPicker(this) }
private val pickerDialog by lazy {
private val picker by unsafeLazy { EmojiPicker(this) }
private val pickerDialog by unsafeLazy {
PopupWindow(this)
.apply {
contentView = picker
@ -70,6 +85,7 @@ class AnnouncementsActivity : BottomSheetActivity(), AnnouncementActionListener,
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
addMenuProvider(this)
setSupportActionBar(binding.includedToolbar.toolbar)
supportActionBar?.apply {
@ -129,6 +145,27 @@ class AnnouncementsActivity : BottomSheetActivity(), AnnouncementActionListener,
binding.progressBar.show()
}
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.activity_announcements, menu)
menu.findItem(R.id.action_search)?.apply {
icon = IconicsDrawable(this@AnnouncementsActivity, GoogleMaterial.Icon.gmd_search).apply {
sizeDp = 20
colorInt = MaterialColors.getColor(binding.includedToolbar.toolbar, android.R.attr.textColorPrimary)
}
}
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
return when (menuItem.itemId) {
R.id.action_refresh -> {
binding.swipeRefreshLayout.isRefreshing = true
refreshAnnouncements()
true
}
else -> false
}
}
private fun refreshAnnouncements() {
viewModel.load()
binding.swipeRefreshLayout.isRefreshing = true

View File

@ -107,8 +107,7 @@ class AnnouncementsViewModel @Inject constructor(
} else {
listOf(
*announcement.reactions.toTypedArray(),
emojis.value!!.find { emoji -> emoji.shortcode == name }
!!.run {
emojis.value!!.find { emoji -> emoji.shortcode == name }!!.run {
Announcement.Reaction(
name,
1,

View File

@ -32,6 +32,8 @@ import android.os.Build
import android.os.Bundle
import android.os.Parcelable
import android.provider.MediaStore
import android.text.Spanned
import android.text.style.URLSpan
import android.util.Log
import android.view.KeyEvent
import android.view.MenuItem
@ -52,11 +54,13 @@ import androidx.appcompat.app.AlertDialog
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.core.content.res.use
import androidx.core.view.ContentInfoCompat
import androidx.core.view.OnReceiveContentListener
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.core.widget.doAfterTextChanged
import androidx.core.widget.doOnTextChanged
import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.LinearLayoutManager
@ -92,18 +96,18 @@ import com.keylesspalace.tusky.entity.NewPoll
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.APP_THEME_DEFAULT
import com.keylesspalace.tusky.util.MentionSpan
import com.keylesspalace.tusky.util.PickMediaFiles
import com.keylesspalace.tusky.util.afterTextChanged
import com.keylesspalace.tusky.util.getInitialLanguage
import com.keylesspalace.tusky.util.getInitialLanguages
import com.keylesspalace.tusky.util.getLocaleList
import com.keylesspalace.tusky.util.getMediaSize
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.highlightSpans
import com.keylesspalace.tusky.util.loadAvatar
import com.keylesspalace.tusky.util.modernLanguageCode
import com.keylesspalace.tusky.util.onTextChanged
import com.keylesspalace.tusky.util.setDrawableTint
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.unsafeLazy
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible
import com.mikepenz.iconics.IconicsDrawable
@ -145,7 +149,7 @@ class ComposeActivity :
private var photoUploadUri: Uri? = null
private val preferences by lazy { PreferenceManager.getDefaultSharedPreferences(this) }
private val preferences by unsafeLazy { PreferenceManager.getDefaultSharedPreferences(this) }
@VisibleForTesting
var maximumTootCharacters = InstanceInfoRepository.DEFAULT_CHARACTER_LIMIT
@ -228,13 +232,13 @@ class ComposeActivity :
val mediaAdapter = MediaPreviewAdapter(
this,
onAddCaption = { item ->
CaptionDialog.newInstance(item.localId, item.description, item.uri)
.show(supportFragmentManager, "caption_dialog")
CaptionDialog.newInstance(item.localId, item.description, item.uri).show(supportFragmentManager, "caption_dialog")
},
onAddFocus = { item ->
makeFocusDialog(item.focus, item.uri) { newFocus ->
viewModel.updateFocus(item.localId, newFocus)
}
// TODO this is inconsistent to CaptionDialog (device rotation)?
},
onEditImage = this::editImageInQueue,
onRemove = this::removeMediaFromQueue
@ -273,7 +277,7 @@ class ComposeActivity :
binding.composeScheduleView.setDateTime(composeOptions?.scheduledAt)
}
setupLanguageSpinner(getInitialLanguage(composeOptions?.language, accountManager.activeAccount))
setupLanguageSpinner(getInitialLanguages(composeOptions?.language, accountManager.activeAccount))
setupComposeField(preferences, viewModel.startingText)
setupDefaultTagViews(preferences)
setupContentWarningField(composeOptions?.contentWarning)
@ -420,7 +424,7 @@ class ComposeActivity :
if (startingContentWarning != null) {
binding.composeContentWarningField.setText(startingContentWarning)
}
binding.composeContentWarningField.onTextChanged { _, _, _, _ -> updateVisibleCharactersLeft() }
binding.composeContentWarningField.doOnTextChanged { _, _, _, _ -> updateVisibleCharactersLeft() }
}
private fun setupComposeField(preferences: SharedPreferences, startingText: String?) {
@ -443,8 +447,8 @@ class ComposeActivity :
val mentionColour = binding.composeEditField.linkTextColors.defaultColor
highlightSpans(binding.composeEditField.text, mentionColour)
binding.composeEditField.afterTextChanged { editable ->
highlightSpans(editable, mentionColour)
binding.composeEditField.doAfterTextChanged { editable ->
highlightSpans(editable!!, mentionColour)
updateVisibleCharactersLeft()
}
@ -535,7 +539,9 @@ class ComposeActivity :
preferences.edit()
.putBoolean(PREF_USE_DEFAULT_TAG, isChecked)
.apply()
eventHub.dispatch(PreferenceChangedEvent(PREF_USE_DEFAULT_TAG))
lifecycleScope.launch {
eventHub.dispatch(PreferenceChangedEvent(PREF_USE_DEFAULT_TAG))
}
}
binding.editTextDefaultText.setText(preferences.getString(PREF_DEFAULT_TAG, ""))
@ -543,7 +549,9 @@ class ComposeActivity :
preferences.edit()
.putString(PREF_DEFAULT_TAG, it.toString())
.apply()
eventHub.dispatch(PreferenceChangedEvent(PREF_DEFAULT_TAG))
lifecycleScope.launch {
eventHub.dispatch(PreferenceChangedEvent(PREF_DEFAULT_TAG))
}
}
}
@ -613,7 +621,7 @@ class ComposeActivity :
)
}
private fun setupLanguageSpinner(initialLanguage: String) {
private fun setupLanguageSpinner(initialLanguages: List<String>) {
binding.composePostLanguageButton.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) {
viewModel.postLanguage = (parent.adapter.getItem(position) as Locale).modernLanguageCode
@ -624,7 +632,7 @@ class ComposeActivity :
}
}
binding.composePostLanguageButton.apply {
adapter = LocaleAdapter(context, android.R.layout.simple_spinner_dropdown_item, getLocaleList(initialLanguage))
adapter = LocaleAdapter(context, android.R.layout.simple_spinner_dropdown_item, getLocaleList(initialLanguages))
setSelection(0)
}
}
@ -640,10 +648,10 @@ class ComposeActivity :
}
private fun setupAvatar(activeAccount: AccountEntity) {
val actionBarSizeAttr = intArrayOf(R.attr.actionBarSize)
val a = obtainStyledAttributes(null, actionBarSizeAttr)
val avatarSize = a.getDimensionPixelSize(0, 1)
a.recycle()
val actionBarSizeAttr = intArrayOf(androidx.appcompat.R.attr.actionBarSize)
val avatarSize = obtainStyledAttributes(null, actionBarSizeAttr).use { a ->
a.getDimensionPixelSize(0, 1)
}
val animateAvatars = preferences.getBoolean("animateGifAvatars", false)
loadAvatar(
@ -768,7 +776,7 @@ class ComposeActivity :
var oneMediaWithoutDescription = false
for (media in viewModel.media.value) {
if (media.description == null || media.description.isEmpty()) {
if (media.description.isNullOrEmpty()) {
oneMediaWithoutDescription = true
break
}
@ -879,25 +887,26 @@ class ComposeActivity :
}
private fun onMediaPick() {
addMediaBehavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() {
override fun onStateChanged(bottomSheet: View, newState: Int) {
// Wait until bottom sheet is not collapsed and show next screen after
if (newState == BottomSheetBehavior.STATE_COLLAPSED) {
addMediaBehavior.removeBottomSheetCallback(this)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && ContextCompat.checkSelfPermission(this@ComposeActivity, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(
this@ComposeActivity,
arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE),
PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE
)
} else {
pickMediaFile.launch(true)
addMediaBehavior.addBottomSheetCallback(
object : BottomSheetBehavior.BottomSheetCallback() {
override fun onStateChanged(bottomSheet: View, newState: Int) {
// Wait until bottom sheet is not collapsed and show next screen after
if (newState == BottomSheetBehavior.STATE_COLLAPSED) {
addMediaBehavior.removeBottomSheetCallback(this)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && ContextCompat.checkSelfPermission(this@ComposeActivity, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(
this@ComposeActivity,
arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE),
PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE
)
} else {
pickMediaFile.launch(true)
}
}
}
}
override fun onSlide(bottomSheet: View, slideOffset: Float) {}
}
override fun onSlide(bottomSheet: View, slideOffset: Float) {}
}
)
addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
}
@ -953,23 +962,13 @@ class ComposeActivity :
@VisibleForTesting
fun calculateTextLength(): Int {
var offset = 0
val urlSpans = binding.composeEditField.urls
if (urlSpans != null) {
for (span in urlSpans) {
// it's expected that this will be negative
// when the url length is less than the reserved character count
offset += (span.url.length - charactersReservedPerUrl)
}
}
var length = binding.composeEditField.length() - offset
if (binding.checkboxUseDefaultText.isChecked) {
length += 1 + binding.editTextDefaultText.length()
}
if (viewModel.showContentWarning.value) {
length += binding.composeContentWarningField.length()
}
return length
return statusLength(
binding.composeEditField.text,
binding.composeContentWarningField.text,
binding.checkboxUseDefaultText.isChecked,
binding.editTextDefaultText.text,
charactersReservedPerUrl
)
}
@VisibleForTesting
@ -1054,7 +1053,8 @@ class ComposeActivity :
pickMediaFile.launch(true)
} else {
Snackbar.make(
binding.activityCompose, R.string.error_media_upload_permission,
binding.activityCompose,
R.string.error_media_upload_permission,
Snackbar.LENGTH_SHORT
).apply {
setAction(R.string.action_retry) { onMediaPick() }
@ -1088,9 +1088,13 @@ class ComposeActivity :
private fun enableButton(button: ImageButton, clickable: Boolean, colorActive: Boolean) {
button.isEnabled = clickable
setDrawableTint(
this, button.drawable,
if (colorActive) android.R.attr.textColorTertiary
else R.attr.textColorDisabled
this,
button.drawable,
if (colorActive) {
android.R.attr.textColorTertiary
} else {
R.attr.textColorDisabled
}
)
}
@ -1098,8 +1102,11 @@ class ComposeActivity :
binding.addPollTextActionTextView.isEnabled = enable
val textColor = MaterialColors.getColor(
binding.addPollTextActionTextView,
if (enable) android.R.attr.textColorTertiary
else R.attr.textColorDisabled
if (enable) {
android.R.attr.textColorTertiary
} else {
R.attr.textColorDisabled
}
)
binding.addPollTextActionTextView.setTextColor(textColor)
binding.addPollTextActionTextView.compoundDrawablesRelative[0].colorFilter = PorterDuffColorFilter(textColor, PorterDuff.Mode.SRC_IN)
@ -1275,8 +1282,11 @@ class ComposeActivity :
lifecycleScope.launch {
val dialog = if (viewModel.shouldShowSaveDraftDialog()) {
ProgressDialog.show(
this@ComposeActivity, null,
getString(R.string.saving_draft), true, false
this@ComposeActivity,
null,
getString(R.string.saving_draft),
true,
false
)
} else {
null
@ -1337,11 +1347,7 @@ class ComposeActivity :
}
override fun onUpdateDescription(localId: Int, description: String) {
lifecycleScope.launch {
if (!viewModel.updateDescription(localId, description)) {
Toast.makeText(this@ComposeActivity, R.string.error_failed_set_caption, Toast.LENGTH_SHORT).show()
}
}
viewModel.updateDescription(localId, description)
}
/**
@ -1438,5 +1444,57 @@ class ComposeActivity :
fun canHandleMimeType(mimeType: String?): Boolean {
return mimeType != null && (mimeType.startsWith("image/") || mimeType.startsWith("video/") || mimeType.startsWith("audio/") || mimeType == "text/plain")
}
/**
* Calculate the effective status length.
*
* Some text is counted differently:
*
* In the status body:
*
* - URLs always count for [urlLength] characters irrespective of their actual length
* (https://docs.joinmastodon.org/user/posting/#links)
* - Mentions ("@user@some.instance") only count the "@user" part
* (https://docs.joinmastodon.org/user/posting/#mentions)
* - Hashtags are always treated as their actual length, including the "#"
* (https://docs.joinmastodon.org/user/posting/#hashtags)
*
* Content warning text is always treated as its full length, URLs and other entities
* are not treated differently.
*
* @param body status body text
* @param contentWarning optional content warning text
* @param urlLength the number of characters attributed to URLs
* @return the effective status length
*/
@JvmStatic
fun statusLength(body: Spanned, contentWarning: Spanned?, useDefault: Boolean, default: Spanned, urlLength: Int): Int {
var length = body.length - body.getSpans(0, body.length, URLSpan::class.java)
.fold(0) { acc, span ->
// Accumulate a count of characters to be *ignored* in the final length
acc + when (span) {
is MentionSpan -> {
// Ignore everything from the second "@" (if present)
span.url.length - (
span.url.indexOf("@", 1).takeIf { it >= 0 }
?: span.url.length
)
}
else -> {
// Expected to be negative if the URL length < maxUrlLength
span.url.length - urlLength
}
}
}
// Content warning text is treated as is, URLs or mentions there are not special
contentWarning?.let { length += it.length }
if (useDefault) {
length += 1 + default.length
}
return length
}
}
}

View File

@ -46,7 +46,6 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.updateAndGet
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
@ -141,7 +140,7 @@ class ComposeViewModel @Inject constructor(
): QueuedMedia {
var stashMediaItem: QueuedMedia? = null
media.updateAndGet { mediaValue ->
media.update { mediaList ->
val mediaItem = QueuedMedia(
localId = mediaUploader.getNewLocalMediaId(),
uri = uri,
@ -155,11 +154,11 @@ class ComposeViewModel @Inject constructor(
if (replaceItem != null) {
mediaUploader.cancelUploadScope(replaceItem.localId)
mediaValue.map {
mediaList.map {
if (it.localId == replaceItem.localId) mediaItem else it
}
} else { // Append
mediaValue + mediaItem
mediaList + mediaItem
}
}
val mediaItem = stashMediaItem!! // stashMediaItem is always non-null and uncaptured at this point, but Kotlin doesn't know that
@ -180,13 +179,13 @@ class ComposeViewModel @Inject constructor(
state = if (event.processed) { QueuedMedia.State.PROCESSED } else { QueuedMedia.State.UNPROCESSED }
)
is UploadEvent.ErrorEvent -> {
media.update { mediaValue -> mediaValue.filter { it.localId != mediaItem.localId } }
media.update { mediaList -> mediaList.filter { it.localId != mediaItem.localId } }
uploadError.emit(event.error)
return@collect
}
}
media.update { mediaValue ->
mediaValue.map { mediaItem ->
media.update { mediaList ->
mediaList.map { mediaItem ->
if (mediaItem.localId == newMediaItem.localId) {
newMediaItem
} else {
@ -200,7 +199,7 @@ class ComposeViewModel @Inject constructor(
}
private fun addUploadedMedia(id: String, type: QueuedMedia.Type, uri: Uri, description: String?, focus: Attachment.Focus?) {
media.update { mediaValue ->
media.update { mediaList ->
val mediaItem = QueuedMedia(
localId = mediaUploader.getNewLocalMediaId(),
uri = uri,
@ -212,13 +211,13 @@ class ComposeViewModel @Inject constructor(
focus = focus,
state = QueuedMedia.State.PUBLISHED
)
mediaValue + mediaItem
mediaList + mediaItem
}
}
fun removeMediaFromQueue(item: QueuedMedia) {
mediaUploader.cancelUploadScope(item.localId)
media.update { mediaValue -> mediaValue.filter { it.localId != item.localId } }
media.update { mediaList -> mediaList.filter { it.localId != item.localId } }
}
fun toggleMarkSensitive() {
@ -285,7 +284,7 @@ class ComposeViewModel @Inject constructor(
failedToSendAlert = false,
scheduledAt = scheduledAt.value,
language = postLanguage,
statusId = originalStatusId,
statusId = originalStatusId
)
}
@ -297,7 +296,6 @@ class ComposeViewModel @Inject constructor(
content: String,
spoilerText: String
) {
if (!scheduledTootId.isNullOrEmpty()) {
api.deleteScheduledStatus(scheduledTootId!!)
}
@ -335,10 +333,9 @@ class ComposeViewModel @Inject constructor(
serviceClient.sendToot(tootToSend)
}
// Updates a QueuedMedia item arbitrarily, then sends description and focus to server
private suspend fun updateMediaItem(localId: Int, mutator: (QueuedMedia) -> QueuedMedia): Boolean {
val newMediaList = media.updateAndGet { mediaValue ->
mediaValue.map { mediaItem ->
private fun updateMediaItem(localId: Int, mutator: (QueuedMedia) -> QueuedMedia) {
media.update { mediaList ->
mediaList.map { mediaItem ->
if (mediaItem.localId == localId) {
mutator(mediaItem)
} else {
@ -346,30 +343,16 @@ class ComposeViewModel @Inject constructor(
}
}
}
val updatedItem = newMediaList.find { it.localId == localId }
if (updatedItem?.id != null) {
val focus = updatedItem.focus
val focusString = if (focus != null) "${focus.x},${focus.y}" else null
return api.updateMedia(updatedItem.id, updatedItem.description, focusString)
.fold({
true
}, { throwable ->
Log.w(TAG, "failed to update media", throwable)
false
})
}
return true
}
suspend fun updateDescription(localId: Int, description: String): Boolean {
return updateMediaItem(localId) { mediaItem ->
fun updateDescription(localId: Int, description: String) {
updateMediaItem(localId) { mediaItem ->
mediaItem.copy(description = description)
}
}
suspend fun updateFocus(localId: Int, focus: Attachment.Focus): Boolean {
return updateMediaItem(localId) { mediaItem ->
fun updateFocus(localId: Int, focus: Attachment.Focus) {
updateMediaItem(localId) { mediaItem ->
mediaItem.copy(focus = focus)
}
}
@ -414,7 +397,6 @@ class ComposeViewModel @Inject constructor(
}
fun setup(composeOptions: ComposeActivity.ComposeOptions?, useCache: Boolean) {
if (setupComplete) {
return
}
@ -453,14 +435,16 @@ class ComposeViewModel @Inject constructor(
pickMedia(attachment.uri, attachment.description, attachment.focus)
}
}
} else composeOptions?.mediaAttachments?.forEach { a ->
// when coming from redraft or ScheduledTootActivity
val mediaType = when (a.type) {
Attachment.Type.VIDEO, Attachment.Type.GIFV -> QueuedMedia.Type.VIDEO
Attachment.Type.UNKNOWN, Attachment.Type.IMAGE -> QueuedMedia.Type.IMAGE
Attachment.Type.AUDIO -> QueuedMedia.Type.AUDIO
} else {
composeOptions?.mediaAttachments?.forEach { a ->
// when coming from redraft or ScheduledTootActivity
val mediaType = when (a.type) {
Attachment.Type.VIDEO, Attachment.Type.GIFV -> QueuedMedia.Type.VIDEO
Attachment.Type.UNKNOWN, Attachment.Type.IMAGE -> QueuedMedia.Type.IMAGE
Attachment.Type.AUDIO -> QueuedMedia.Type.AUDIO
}
addUploadedMedia(a.id, mediaType, a.url.toUri(), a.description, a.meta?.focus)
}
addUploadedMedia(a.id, mediaType, a.url.toUri(), a.description, a.meta?.focus)
}
draftId = composeOptions?.draftId ?: 0

View File

@ -41,7 +41,6 @@ fun downsizeImage(
contentResolver: ContentResolver,
tempFile: File
): Boolean {
val decodeBoundsInputStream = try {
contentResolver.openInputStream(uri)
} catch (e: FileNotFoundException) {

View File

@ -48,11 +48,12 @@ class MediaPreviewAdapter(
val addFocusId = 2
val editImageId = 3
val removeId = 4
if (item.state != ComposeActivity.QueuedMedia.State.PUBLISHED) {
// Already-published items can't have their metadata edited
popup.menu.add(0, addCaptionId, 0, R.string.action_set_caption)
if (item.type == ComposeActivity.QueuedMedia.Type.IMAGE) {
popup.menu.add(0, addFocusId, 0, R.string.action_set_focus)
popup.menu.add(0, addCaptionId, 0, R.string.action_set_caption)
if (item.type == ComposeActivity.QueuedMedia.Type.IMAGE) {
popup.menu.add(0, addFocusId, 0, R.string.action_set_focus)
if (item.state != ComposeActivity.QueuedMedia.State.PUBLISHED) {
// Already-published items can't be edited
popup.menu.add(0, editImageId, 0, R.string.action_edit_image)
}
}
@ -89,10 +90,11 @@ class MediaPreviewAdapter(
val imageView = holder.progressImageView
val focus = item.focus
if (focus != null)
if (focus != null) {
imageView.setFocalPoint(focus)
else
} else {
imageView.removeFocalPoint() // Probably unnecessary since we have no UI for removal once added.
}
var glide = Glide.with(holder.itemView.context)
.load(item.uri)
@ -100,8 +102,9 @@ class MediaPreviewAdapter(
.dontAnimate()
.centerInside()
if (focus != null)
if (focus != null) {
glide = glide.addListener(imageView)
}
glide.into(imageView)
}

View File

@ -159,7 +159,6 @@ class MediaUploader @Inject constructor(
try {
when (inUri.scheme) {
ContentResolver.SCHEME_CONTENT -> {
mimeType = contentResolver.getType(uri)
val suffix = "." + MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType ?: "tmp")
@ -278,7 +277,8 @@ class MediaUploader @Inject constructor(
var lastProgress = -1
val fileBody = ProgressRequestBody(
stream!!, media.mediaSize,
stream!!,
media.mediaSize,
mimeType.toMediaTypeOrNull()!!
) { percentage ->
if (percentage != lastProgress) {

View File

@ -35,7 +35,6 @@ fun showAddPollDialog(
maxDuration: Int,
onUpdatePoll: (NewPoll) -> Unit
) {
val binding = DialogAddPollBinding.inflate(LayoutInflater.from(context))
val dialog = AlertDialog.Builder(context)
@ -63,7 +62,7 @@ fun showAddPollDialog(
var durations = context.resources.getIntArray(R.array.poll_duration_values).toList()
val durationLabels = context.resources.getStringArray(R.array.poll_duration_names).filterIndexed { index, _ -> durations[index] in minDuration..maxDuration }
binding.pollDurationSpinner.adapter = ArrayAdapter(context, android.R.layout.simple_spinner_item, durationLabels).apply {
setDropDownViewResource(R.layout.support_simple_spinner_dropdown_item)
setDropDownViewResource(androidx.appcompat.R.layout.support_simple_spinner_dropdown_item)
}
durations = durations.filter { it in minDuration..maxDuration }
@ -76,8 +75,10 @@ fun showAddPollDialog(
}
}
val DAY_SECONDS = 60 * 60 * 24
val desiredDuration = poll?.expiresIn ?: DAY_SECONDS
val pollDurationId = durations.indexOfLast {
it <= (poll?.expiresIn ?: 0)
it <= desiredDuration
}
binding.pollDurationSpinner.setSelection(pollDurationId)

View File

@ -19,11 +19,11 @@ import android.text.InputFilter
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.widget.doOnTextChanged
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemAddPollOptionBinding
import com.keylesspalace.tusky.util.BindingHolder
import com.keylesspalace.tusky.util.onTextChanged
import com.keylesspalace.tusky.util.visible
class AddPollOptionsAdapter(
@ -46,7 +46,7 @@ class AddPollOptionsAdapter(
val holder = BindingHolder(binding)
binding.optionEditText.filters = arrayOf(InputFilter.LengthFilter(maxOptionLength))
binding.optionEditText.onTextChanged { s, _, _, _ ->
binding.optionEditText.doOnTextChanged { s, _, _, _ ->
val pos = holder.bindingAdapterPosition
if (pos != RecyclerView.NO_POSITION) {
options[pos] = s.toString()

View File

@ -21,75 +21,55 @@ import android.graphics.drawable.Drawable
import android.net.Uri
import android.os.Bundle
import android.text.InputFilter
import android.text.InputType
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import android.widget.EditText
import android.widget.LinearLayout
import androidx.appcompat.app.AlertDialog
import androidx.core.os.bundleOf
import androidx.fragment.app.DialogFragment
import at.connyduck.sparkbutton.helpers.Utils
import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition
import com.github.chrisbanes.photoview.PhotoView
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.DialogImageDescriptionBinding
// https://github.com/tootsuite/mastodon/blob/c6904c0d3766a2ea8a81ab025c127169ecb51373/app/models/media_attachment.rb#L32
private const val MEDIA_DESCRIPTION_CHARACTER_LIMIT = 1500
class CaptionDialog : DialogFragment() {
private lateinit var listener: Listener
private lateinit var input: EditText
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val context = requireContext()
val dialogLayout = LinearLayout(context)
val padding = Utils.dpToPx(context, 8)
dialogLayout.setPadding(padding, padding, padding, padding)
dialogLayout.orientation = LinearLayout.VERTICAL
val imageView = PhotoView(context).apply {
maximumScale = 6f
}
val binding = DialogImageDescriptionBinding.inflate(layoutInflater)
val margin = Utils.dpToPx(context, 4)
dialogLayout.addView(imageView)
(imageView.layoutParams as LinearLayout.LayoutParams).weight = 1f
imageView.layoutParams.height = 0
(imageView.layoutParams as LinearLayout.LayoutParams).setMargins(0, margin, 0, 0)
input = binding.imageDescriptionText
val imageView = binding.imageDescriptionView
imageView.maximumScale = 6f
input = EditText(context)
input.hint = resources.getQuantityString(
R.plurals.hint_describe_for_visually_impaired,
MEDIA_DESCRIPTION_CHARACTER_LIMIT, MEDIA_DESCRIPTION_CHARACTER_LIMIT
MEDIA_DESCRIPTION_CHARACTER_LIMIT,
MEDIA_DESCRIPTION_CHARACTER_LIMIT
)
dialogLayout.addView(input)
(input.layoutParams as LinearLayout.LayoutParams).setMargins(margin, margin, margin, margin)
input.setLines(2)
input.inputType = (
InputType.TYPE_CLASS_TEXT
or InputType.TYPE_TEXT_FLAG_MULTI_LINE
or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
)
input.filters = arrayOf(InputFilter.LengthFilter(MEDIA_DESCRIPTION_CHARACTER_LIMIT))
input.setText(arguments?.getString(EXISTING_DESCRIPTION_ARG))
val localId = arguments?.getInt(LOCAL_ID_ARG) ?: error("Missing localId")
val dialog = AlertDialog.Builder(context)
.setView(dialogLayout)
.setView(binding.root)
.setPositiveButton(android.R.string.ok) { _, _ ->
listener.onUpdateDescription(localId, input.text.toString())
}
.setNegativeButton(android.R.string.cancel, null)
.create()
isCancelable = false
isCancelable = true
val window = dialog.window
window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
@ -105,7 +85,7 @@ class CaptionDialog : DialogFragment() {
override fun onResourceReady(
resource: Drawable,
transition: Transition<in Drawable>?,
transition: Transition<in Drawable>?
) {
imageView.setImageDrawable(resource)
}
@ -122,7 +102,7 @@ class CaptionDialog : DialogFragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
savedInstanceState: Bundle?
): View? {
savedInstanceState?.getString(DESCRIPTION_KEY)?.let {
input.setText(it)
@ -143,12 +123,12 @@ class CaptionDialog : DialogFragment() {
fun newInstance(
localId: Int,
existingDescription: String?,
previewUri: Uri,
previewUri: Uri
) = CaptionDialog().apply {
arguments = bundleOf(
LOCAL_ID_ARG to localId,
EXISTING_DESCRIPTION_ARG to existingDescription,
PREVIEW_URI_ARG to previewUri,
PREVIEW_URI_ARG to previewUri
)
}

View File

@ -21,7 +21,6 @@ import android.graphics.drawable.Drawable
import android.net.Uri
import android.view.WindowManager
import android.widget.FrameLayout
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
@ -31,7 +30,6 @@ import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.target.Target
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.DialogFocusBinding
import com.keylesspalace.tusky.entity.Attachment.Focus
import kotlinx.coroutines.launch
@ -39,7 +37,7 @@ import kotlinx.coroutines.launch
fun <T> T.makeFocusDialog(
existingFocus: Focus?,
previewUri: Uri,
onUpdateFocus: suspend (Focus) -> Boolean
onUpdateFocus: suspend (Focus) -> Unit
) where T : Activity, T : LifecycleOwner {
val focus = existingFocus ?: Focus(0.0f, 0.0f) // Default to center
@ -79,9 +77,7 @@ fun <T> T.makeFocusDialog(
val okListener = { dialog: DialogInterface, _: Int ->
lifecycleScope.launch {
if (!onUpdateFocus(dialogBinding.focusIndicator.getFocus())) {
showFailedFocusMessage()
}
onUpdateFocus(dialogBinding.focusIndicator.getFocus())
}
dialog.dismiss()
}
@ -99,7 +95,3 @@ fun <T> T.makeFocusDialog(
dialog.show()
}
private fun Activity.showFailedFocusMessage() {
Toast.makeText(this, R.string.error_failed_set_focus, Toast.LENGTH_SHORT).show()
}

View File

@ -27,14 +27,16 @@ class FocusIndicatorView
fun setImageSize(width: Int, height: Int) {
this.imageSize = Point(width, height)
if (focus != null)
if (focus != null) {
invalidate()
}
}
fun setFocus(focus: Attachment.Focus) {
this.focus = focus
if (imageSize != null)
if (imageSize != null) {
invalidate()
}
}
// Assumes setFocus called first
@ -46,8 +48,9 @@ class FocusIndicatorView
// so base it on the view width/height whenever the first access occurs.
private fun getCircleRadius(): Float {
val circleRadius = this.circleRadius
if (circleRadius != null)
if (circleRadius != null) {
return circleRadius
}
val newCircleRadius = min(this.width, this.height).toFloat() / 4.0f
this.circleRadius = newCircleRadius
return newCircleRadius
@ -67,12 +70,11 @@ class FocusIndicatorView
@SuppressLint("ClickableViewAccessibility") // Android Studio wants us to implement PerformClick for accessibility, but that unfortunately cannot be made meaningful for this widget.
override fun onTouchEvent(event: MotionEvent): Boolean {
if (event.actionMasked == MotionEvent.ACTION_CANCEL)
if (event.actionMasked == MotionEvent.ACTION_CANCEL) {
return false
}
val imageSize = this.imageSize
if (imageSize == null)
return false
val imageSize = this.imageSize ?: return false
// Convert touch xy to point inside image
focus = Attachment.Focus(axisToFocus(event.x, imageSize.x, this.width), -axisToFocus(event.y, imageSize.y, this.height))

View File

@ -48,7 +48,6 @@ class TootButton
fun setStatusVisibility(visibility: Status.Visibility) {
if (!smallStyle) {
icon = when (visibility) {
Status.Visibility.PUBLIC -> {
setText(R.string.action_send_public)

View File

@ -64,9 +64,10 @@ data class ConversationAccountEntity(
localUsername = localUsername,
username = username,
displayName = displayName,
note = "",
url = "",
avatar = avatar,
emojis = emojis,
emojis = emojis
)
}
}
@ -96,7 +97,7 @@ data class ConversationStatusEntity(
val collapsed: Boolean,
val muted: Boolean,
val poll: Poll?,
val language: String?,
val language: String?
) {
fun toViewData(): StatusViewData.Concrete {
@ -130,6 +131,7 @@ data class ConversationStatusEntity(
poll = poll,
card = null,
language = language,
filtered = null,
quote = null,
),
isExpanded = expanded,
@ -146,7 +148,7 @@ fun TimelineAccount.toEntity() =
username = username,
displayName = name,
avatar = avatar,
emojis = emojis ?: emptyList()
emojis = emojis.orEmpty()
)
fun Status.toEntity(
@ -178,7 +180,7 @@ fun Status.toEntity(
collapsed = contentCollapsed,
muted = muted ?: false,
poll = poll,
language = language,
language = language
)
fun Conversation.toEntity(

View File

@ -87,6 +87,6 @@ fun StatusViewData.Concrete.toConversationStatusEntity(
collapsed = collapsed,
muted = muted,
poll = poll,
language = status.language,
language = status.language
)
}

View File

@ -36,7 +36,6 @@ import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.util.ImageLoadingHelper;
import com.keylesspalace.tusky.util.SmartLengthInputFilter;
import com.keylesspalace.tusky.util.StatusDisplayOptions;
import com.keylesspalace.tusky.viewdata.PollViewDataKt;
import com.keylesspalace.tusky.viewdata.StatusViewData;
import java.util.List;
@ -110,9 +109,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
setupButtons(listener, account.getId(), statusViewData.getContent().toString(),
false, statusDisplayOptions);
setSpoilerAndContent(statusViewData.isExpanded(), statusViewData.getContent(), status.getSpoilerText(),
status.getMentions(), status.getTags(), status.getEmojis(),
PollViewDataKt.toViewData(status.getPoll()), statusDisplayOptions, listener);
setSpoilerAndContent(statusViewData, statusDisplayOptions, listener);
setConversationName(conversation.getAccounts());

View File

@ -17,13 +17,18 @@ package com.keylesspalace.tusky.components.conversation
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.PopupMenu
import androidx.core.view.MenuProvider
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.paging.LoadState
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.DividerItemDecoration
@ -31,7 +36,7 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.SimpleItemAnimator
import at.connyduck.sparkbutton.helpers.Utils
import autodispose2.androidx.lifecycle.autoDispose
import com.google.android.material.color.MaterialColors
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.StatusListActivity
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
@ -53,7 +58,10 @@ import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.viewdata.AttachmentViewData
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizeDp
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
@ -62,7 +70,12 @@ import javax.inject.Inject
import kotlin.time.DurationUnit
import kotlin.time.toDuration
class ConversationsFragment : SFragment(), StatusActionListener, Injectable, ReselectableFragment {
class ConversationsFragment :
SFragment(),
StatusActionListener,
Injectable,
ReselectableFragment,
MenuProvider {
@Inject
lateinit var viewModelFactory: ViewModelFactory
@ -83,6 +96,8 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
val preferences = PreferenceManager.getDefaultSharedPreferences(view.context)
val statusDisplayOptions = StatusDisplayOptions(
@ -96,7 +111,10 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
confirmFavourites = preferences.getBoolean("confirmFavourites", false),
hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false),
animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false),
quoteEnabled = accountManager.activeAccount?.domain in CAN_USE_QUOTE_ID,
showStatsInline = preferences.getBoolean(PrefKeys.SHOW_STATS_INLINE, false),
showSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia,
openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler,
quoteEnabled = accountManager.activeAccount?.domain in CAN_USE_QUOTE_ID,
)
adapter = ConversationAdapter(statusDisplayOptions, this)
@ -125,9 +143,13 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
binding.statusView.show()
if ((loadState.refresh as LoadState.Error).error is IOException) {
binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network, null)
binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) {
refreshContent()
}
} else {
binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic, null)
binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic) {
refreshContent()
}
}
}
is LoadState.Loading -> {
@ -173,22 +195,48 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
}
}
lifecycleScope.launchWhenResumed {
val useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false)
while (!useAbsoluteTime) {
adapter.notifyItemRangeChanged(0, adapter.itemCount, listOf(StatusBaseViewHolder.Key.KEY_CREATED))
delay(1.toDuration(DurationUnit.MINUTES))
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
val useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false)
while (!useAbsoluteTime) {
adapter.notifyItemRangeChanged(
0,
adapter.itemCount,
listOf(StatusBaseViewHolder.Key.KEY_CREATED)
)
delay(1.toDuration(DurationUnit.MINUTES))
}
}
}
eventHub.events
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
.subscribe { event ->
lifecycleScope.launch {
eventHub.events.collect { event ->
if (event is PreferenceChangedEvent) {
onPreferenceChanged(event.preferenceKey)
}
}
}
}
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.fragment_conversations, menu)
menu.findItem(R.id.action_refresh)?.apply {
icon = IconicsDrawable(requireContext(), GoogleMaterial.Icon.gmd_refresh).apply {
sizeDp = 20
colorInt = MaterialColors.getColor(binding.root, android.R.attr.textColorPrimary)
}
}
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
return when (menuItem.itemId) {
R.id.action_refresh -> {
binding.swipeRefreshLayout.isRefreshing = true
refreshContent()
true
}
else -> false
}
}
private fun setupRecyclerView() {
@ -202,10 +250,12 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
binding.recyclerView.adapter = adapter.withLoadStateFooter(ConversationLoadStateAdapter(adapter::retry))
}
private fun refreshContent() {
adapter.refresh()
}
private fun initSwipeToRefresh() {
binding.swipeRefreshLayout.setOnRefreshListener {
adapter.refresh()
}
binding.swipeRefreshLayout.setOnRefreshListener { refreshContent() }
binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
}
@ -317,6 +367,9 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
}
}
override fun clearWarningAction(position: Int) {
}
override fun onReselect() {
if (isAdded) {
binding.recyclerView.layoutManager?.scrollToPosition(0)

View File

@ -15,7 +15,7 @@ import retrofit2.HttpException
class ConversationsRemoteMediator(
private val api: MastodonApi,
private val db: AppDatabase,
accountManager: AccountManager,
accountManager: AccountManager
) : RemoteMediator<Int, ConversationEntity>() {
private var nextKey: String? = null
@ -28,7 +28,6 @@ class ConversationsRemoteMediator(
loadType: LoadType,
state: PagingState<Int, ConversationEntity>
): MediatorResult {
if (loadType == LoadType.PREPEND) {
return MediatorResult.Success(endOfPaginationReached = true)
}
@ -47,7 +46,6 @@ class ConversationsRemoteMediator(
}
db.withTransaction {
if (loadType == LoadType.REFRESH) {
db.conversationDao().deleteForAccount(activeAccount.id)
}

View File

@ -23,6 +23,7 @@ import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.cachedIn
import androidx.paging.map
import at.connyduck.calladapter.networkresult.fold
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.network.MastodonApi
@ -30,7 +31,6 @@ import com.keylesspalace.tusky.usecase.TimelineCases
import com.keylesspalace.tusky.util.EmptyPagingSource
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.await
import javax.inject.Inject
class ConversationsViewModel @Inject constructor(
@ -61,51 +61,47 @@ class ConversationsViewModel @Inject constructor(
fun favourite(favourite: Boolean, conversation: ConversationViewData) {
viewModelScope.launch {
try {
timelineCases.favourite(conversation.lastStatus.id, favourite).await()
timelineCases.favourite(conversation.lastStatus.id, favourite).fold({
val newConversation = conversation.toEntity(
accountId = accountManager.activeAccount!!.id,
favourited = favourite
)
saveConversationToDb(newConversation)
} catch (e: Exception) {
}, { e ->
Log.w(TAG, "failed to favourite status", e)
}
})
}
}
fun bookmark(bookmark: Boolean, conversation: ConversationViewData) {
viewModelScope.launch {
try {
timelineCases.bookmark(conversation.lastStatus.id, bookmark).await()
timelineCases.bookmark(conversation.lastStatus.id, bookmark).fold({
val newConversation = conversation.toEntity(
accountId = accountManager.activeAccount!!.id,
bookmarked = bookmark
)
saveConversationToDb(newConversation)
} catch (e: Exception) {
}, { e ->
Log.w(TAG, "failed to bookmark status", e)
}
})
}
}
fun voteInPoll(choices: List<Int>, conversation: ConversationViewData) {
viewModelScope.launch {
try {
val poll = timelineCases.voteInPoll(conversation.lastStatus.id, conversation.lastStatus.status.poll?.id!!, choices).await()
val newConversation = conversation.toEntity(
accountId = accountManager.activeAccount!!.id,
poll = poll
)
timelineCases.voteInPoll(conversation.lastStatus.id, conversation.lastStatus.status.poll?.id!!, choices)
.fold({ poll ->
val newConversation = conversation.toEntity(
accountId = accountManager.activeAccount!!.id,
poll = poll
)
saveConversationToDb(newConversation)
} catch (e: Exception) {
Log.w(TAG, "failed to vote in poll", e)
}
saveConversationToDb(newConversation)
}, { e ->
Log.w(TAG, "failed to vote in poll", e)
})
}
}
@ -160,7 +156,7 @@ class ConversationsViewModel @Inject constructor(
timelineCases.muteConversation(
conversation.lastStatus.id,
!(conversation.lastStatus.status.muted ?: false)
).await()
)
val newConversation = conversation.toEntity(
accountId = accountManager.activeAccount!!.id,

View File

@ -44,7 +44,7 @@ import javax.inject.Inject
class DraftHelper @Inject constructor(
val context: Context,
val okHttpClient: OkHttpClient,
private val okHttpClient: OkHttpClient,
db: AppDatabase
) {
@ -66,7 +66,7 @@ class DraftHelper @Inject constructor(
failedToSendAlert: Boolean,
scheduledAt: String?,
language: String?,
statusId: String?,
statusId: String?
) = withContext(Dispatchers.IO) {
val externalFilesDir = context.getExternalFilesDir("Tusky")
@ -127,7 +127,7 @@ class DraftHelper @Inject constructor(
failedToSendNew = failedToSendAlert,
scheduledAt = scheduledAt,
language = language,
statusId = statusId,
statusId = statusId
)
draftDao.insertOrReplace(draft)
@ -140,7 +140,7 @@ class DraftHelper @Inject constructor(
}
}
suspend fun deleteDraftAndAttachments(draft: DraftEntity) {
private suspend fun deleteDraftAndAttachments(draft: DraftEntity) {
deleteAttachments(draft)
draftDao.delete(draft.id)
}

View File

@ -51,18 +51,20 @@ class DraftMediaAdapter(
holder.imageView.clearFocus()
holder.imageView.setImageResource(R.drawable.ic_music_box_preview_24dp)
} else {
if (attachment.focus != null)
if (attachment.focus != null) {
holder.imageView.setFocalPoint(attachment.focus)
else
} else {
holder.imageView.clearFocus()
}
var glide = Glide.with(holder.itemView.context)
.load(attachment.uri)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.dontAnimate()
.centerInside()
if (attachment.focus != null)
if (attachment.focus != null) {
glide = glide.addListener(holder.imageView)
}
glide.into(holder.imageView)
}

View File

@ -48,7 +48,6 @@ class DraftsAdapter(
) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemDraftBinding> {
val binding = ItemDraftBinding.inflate(LayoutInflater.from(parent.context), parent, false)
val viewHolder = BindingHolder(binding)

View File

@ -33,7 +33,7 @@ class DraftsViewModel @Inject constructor(
val database: AppDatabase,
val accountManager: AccountManager,
val api: MastodonApi,
val draftHelper: DraftHelper
private val draftHelper: DraftHelper
) : ViewModel() {
val drafts = Pager(

View File

@ -0,0 +1,274 @@
package com.keylesspalace.tusky.components.filters
import android.content.Context
import android.os.Bundle
import android.view.View
import android.widget.AdapterView
import android.widget.ArrayAdapter
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.core.view.size
import androidx.core.widget.doAfterTextChanged
import androidx.lifecycle.lifecycleScope
import com.google.android.material.chip.Chip
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.switchmaterial.SwitchMaterial
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.databinding.ActivityEditFilterBinding
import com.keylesspalace.tusky.databinding.DialogFilterBinding
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.FilterKeyword
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.viewBinding
import kotlinx.coroutines.launch
import java.util.Date
import javax.inject.Inject
class EditFilterActivity : BaseActivity() {
@Inject
lateinit var api: MastodonApi
@Inject
lateinit var eventHub: EventHub
@Inject
lateinit var viewModelFactory: ViewModelFactory
private val binding by viewBinding(ActivityEditFilterBinding::inflate)
private val viewModel: EditFilterViewModel by viewModels { viewModelFactory }
private lateinit var filter: Filter
private var originalFilter: Filter? = null
private lateinit var contextSwitches: Map<SwitchMaterial, Filter.Kind>
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
originalFilter = intent?.getParcelableExtra(FILTER_TO_EDIT)
filter = originalFilter ?: Filter("", "", listOf(), null, Filter.Action.WARN.action, listOf())
binding.apply {
contextSwitches = mapOf(
filterContextHome to Filter.Kind.HOME,
filterContextNotifications to Filter.Kind.NOTIFICATIONS,
filterContextPublic to Filter.Kind.PUBLIC,
filterContextThread to Filter.Kind.THREAD,
filterContextAccount to Filter.Kind.ACCOUNT
)
}
setContentView(binding.root)
setSupportActionBar(binding.includedToolbar.toolbar)
supportActionBar?.run {
// Back button
setDisplayHomeAsUpEnabled(true)
setDisplayShowHomeEnabled(true)
}
setTitle(
if (originalFilter == null) {
R.string.filter_addition_title
} else {
R.string.filter_edit_title
}
)
binding.actionChip.setOnClickListener { showAddKeywordDialog() }
binding.filterSaveButton.setOnClickListener { saveChanges() }
for (switch in contextSwitches.keys) {
switch.setOnCheckedChangeListener { _, isChecked ->
val context = contextSwitches[switch]!!
if (isChecked) {
viewModel.addContext(context)
} else {
viewModel.removeContext(context)
}
validateSaveButton()
}
}
binding.filterTitle.doAfterTextChanged { editable ->
viewModel.setTitle(editable.toString())
validateSaveButton()
}
binding.filterActionWarn.setOnCheckedChangeListener { _, checked ->
viewModel.setAction(
if (checked) {
Filter.Action.WARN
} else {
Filter.Action.HIDE
}
)
}
binding.filterDurationSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
viewModel.setDuration(
if (originalFilter?.expiresAt == null) {
position
} else {
position - 1
}
)
}
override fun onNothingSelected(parent: AdapterView<*>?) {
viewModel.setDuration(0)
}
}
validateSaveButton()
if (originalFilter == null) {
binding.filterActionWarn.isChecked = true
} else {
loadFilter()
}
observeModel()
}
private fun observeModel() {
lifecycleScope.launch {
viewModel.title.collect { title ->
if (title != binding.filterTitle.text.toString()) {
// We also get this callback when typing in the field,
// which messes with the cursor focus
binding.filterTitle.setText(title)
}
}
}
lifecycleScope.launch {
viewModel.keywords.collect { keywords ->
updateKeywords(keywords)
}
}
lifecycleScope.launch {
viewModel.contexts.collect { contexts ->
for (entry in contextSwitches) {
entry.key.isChecked = contexts.contains(entry.value)
}
}
}
lifecycleScope.launch {
viewModel.action.collect { action ->
when (action) {
Filter.Action.HIDE -> binding.filterActionHide.isChecked = true
else -> binding.filterActionWarn.isChecked = true
}
}
}
}
// Populate the UI from the filter's members
private fun loadFilter() {
viewModel.load(filter)
if (filter.expiresAt != null) {
val durationNames = listOf(getString(R.string.duration_no_change)) + resources.getStringArray(R.array.filter_duration_names)
binding.filterDurationSpinner.adapter = ArrayAdapter(this, android.R.layout.simple_list_item_1, durationNames)
}
}
private fun updateKeywords(newKeywords: List<FilterKeyword>) {
newKeywords.forEachIndexed { index, filterKeyword ->
val chip = binding.keywordChips.getChildAt(index).takeUnless {
it.id == R.id.actionChip
} as Chip? ?: Chip(this).apply {
setCloseIconResource(R.drawable.ic_cancel_24dp)
isCheckable = false
binding.keywordChips.addView(this, binding.keywordChips.size - 1)
}
chip.text = if (filterKeyword.wholeWord) {
binding.root.context.getString(
R.string.filter_keyword_display_format,
filterKeyword.keyword
)
} else {
filterKeyword.keyword
}
chip.isCloseIconVisible = true
chip.setOnClickListener {
showEditKeywordDialog(newKeywords[index])
}
chip.setOnCloseIconClickListener {
viewModel.deleteKeyword(newKeywords[index])
}
}
while (binding.keywordChips.size - 1 > newKeywords.size) {
binding.keywordChips.removeViewAt(newKeywords.size)
}
filter = filter.copy(keywords = newKeywords)
validateSaveButton()
}
private fun showAddKeywordDialog() {
val binding = DialogFilterBinding.inflate(layoutInflater)
binding.phraseWholeWord.isChecked = true
AlertDialog.Builder(this)
.setTitle(R.string.filter_keyword_addition_title)
.setView(binding.root)
.setPositiveButton(android.R.string.ok) { _, _ ->
viewModel.addKeyword(
FilterKeyword(
"",
binding.phraseEditText.text.toString(),
binding.phraseWholeWord.isChecked
)
)
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
private fun showEditKeywordDialog(keyword: FilterKeyword) {
val binding = DialogFilterBinding.inflate(layoutInflater)
binding.phraseEditText.setText(keyword.keyword)
binding.phraseWholeWord.isChecked = keyword.wholeWord
AlertDialog.Builder(this)
.setTitle(R.string.filter_edit_keyword_title)
.setView(binding.root)
.setPositiveButton(R.string.filter_dialog_update_button) { _, _ ->
viewModel.modifyKeyword(
keyword,
keyword.copy(
keyword = binding.phraseEditText.text.toString(),
wholeWord = binding.phraseWholeWord.isChecked
)
)
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
private fun validateSaveButton() {
binding.filterSaveButton.isEnabled = viewModel.validate()
}
private fun saveChanges() {
// TODO use a progress bar here (see EditProfileActivity/activity_edit_profile.xml for example)?
lifecycleScope.launch {
if (viewModel.saveChanges(this@EditFilterActivity)) {
finish()
} else {
Snackbar.make(binding.root, "Error saving filter '${viewModel.title.value}'", Snackbar.LENGTH_SHORT).show()
}
}
}
companion object {
const val FILTER_TO_EDIT = "FilterToEdit"
// Mastodon *stores* the absolute date in the filter,
// but create/edit take a number of seconds (relative to the time the operation is posted)
fun getSecondsForDurationIndex(index: Int, context: Context?, default: Date? = null): Int? {
return when (index) {
-1 -> if (default == null) { default } else { ((default.time - System.currentTimeMillis()) / 1000).toInt() }
0 -> null
else -> context?.resources?.getIntArray(R.array.filter_duration_values)?.get(index)
}
}
}
}

View File

@ -0,0 +1,186 @@
package com.keylesspalace.tusky.components.filters
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.connyduck.calladapter.networkresult.fold
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.FilterKeyword
import com.keylesspalace.tusky.network.MastodonApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.withContext
import retrofit2.HttpException
import javax.inject.Inject
class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub: EventHub) : ViewModel() {
private var originalFilter: Filter? = null
val title = MutableStateFlow("")
val keywords = MutableStateFlow(listOf<FilterKeyword>())
val action = MutableStateFlow(Filter.Action.WARN)
val duration = MutableStateFlow(0)
val contexts = MutableStateFlow(listOf<Filter.Kind>())
fun load(filter: Filter) {
originalFilter = filter
title.value = filter.title
keywords.value = filter.keywords
action.value = filter.action
duration.value = if (filter.expiresAt == null) {
0
} else {
-1
}
contexts.value = filter.kinds
}
fun addKeyword(keyword: FilterKeyword) {
keywords.value += keyword
}
fun deleteKeyword(keyword: FilterKeyword) {
keywords.value = keywords.value.filterNot { it == keyword }
}
fun modifyKeyword(original: FilterKeyword, updated: FilterKeyword) {
val index = keywords.value.indexOf(original)
if (index >= 0) {
keywords.value = keywords.value.toMutableList().apply {
set(index, updated)
}
}
}
fun setTitle(title: String) {
this.title.value = title
}
fun setDuration(index: Int) {
duration.value = index
}
fun setAction(action: Filter.Action) {
this.action.value = action
}
fun addContext(context: Filter.Kind) {
if (!contexts.value.contains(context)) {
contexts.value += context
}
}
fun removeContext(context: Filter.Kind) {
contexts.value = contexts.value.filter { it != context }
}
fun validate(): Boolean {
return title.value.isNotBlank() &&
keywords.value.isNotEmpty() &&
contexts.value.isNotEmpty()
}
suspend fun saveChanges(context: Context): Boolean {
val contexts = contexts.value.map { it.kind }
val title = title.value
val durationIndex = duration.value
val action = action.value.action
return withContext(viewModelScope.coroutineContext) {
originalFilter?.let { filter ->
updateFilter(filter, title, contexts, action, durationIndex, context)
} ?: createFilter(title, contexts, action, durationIndex, context)
}
}
private suspend fun createFilter(title: String, contexts: List<String>, action: String, durationIndex: Int, context: Context): Boolean {
val expiresInSeconds = EditFilterActivity.getSecondsForDurationIndex(durationIndex, context)
api.createFilter(
title = title,
context = contexts,
filterAction = action,
expiresInSeconds = expiresInSeconds
).fold(
{ newFilter ->
// This is _terrible_, but the all-in-one update filter api Just Doesn't Work
return keywords.value.map { keyword ->
api.addFilterKeyword(filterId = newFilter.id, keyword = keyword.keyword, wholeWord = keyword.wholeWord)
}.none { it.isFailure }
},
{ throwable ->
return (
throwable is HttpException && throwable.code() == 404 &&
// Endpoint not found, fall back to v1 api
createFilterV1(contexts, expiresInSeconds)
)
}
)
}
private suspend fun updateFilter(originalFilter: Filter, title: String, contexts: List<String>, action: String, durationIndex: Int, context: Context): Boolean {
val expiresInSeconds = EditFilterActivity.getSecondsForDurationIndex(durationIndex, context)
api.updateFilter(
id = originalFilter.id,
title = title,
context = contexts,
filterAction = action,
expiresInSeconds = expiresInSeconds
).fold(
{
// This is _terrible_, but the all-in-one update filter api Just Doesn't Work
val results = keywords.value.map { keyword ->
if (keyword.id.isEmpty()) {
api.addFilterKeyword(filterId = originalFilter.id, keyword = keyword.keyword, wholeWord = keyword.wholeWord)
} else {
api.updateFilterKeyword(keywordId = keyword.id, keyword = keyword.keyword, wholeWord = keyword.wholeWord)
}
} + originalFilter.keywords.filter { keyword ->
// Deleted keywords
keywords.value.none { it.id == keyword.id }
}.map { api.deleteFilterKeyword(it.id) }
return results.none { it.isFailure }
},
{ throwable ->
if (throwable is HttpException && throwable.code() == 404) {
// Endpoint not found, fall back to v1 api
if (updateFilterV1(contexts, expiresInSeconds)) {
return true
}
}
return false
}
)
}
private suspend fun createFilterV1(context: List<String>, expiresInSeconds: Int?): Boolean {
return keywords.value.map { keyword ->
api.createFilterV1(keyword.keyword, context, false, keyword.wholeWord, expiresInSeconds)
}.none { it.isFailure }
}
private suspend fun updateFilterV1(context: List<String>, expiresInSeconds: Int?): Boolean {
val results = keywords.value.map { keyword ->
if (originalFilter == null) {
api.createFilterV1(
phrase = keyword.keyword,
context = context,
irreversible = false,
wholeWord = keyword.wholeWord,
expiresInSeconds = expiresInSeconds
)
} else {
api.updateFilterV1(
id = originalFilter!!.id,
phrase = keyword.keyword,
context = context,
irreversible = false,
wholeWord = keyword.wholeWord,
expiresInSeconds = expiresInSeconds
)
}
}
// Don't handle deleted keywords here because there's only one keyword per v1 filter anyway
return results.none { it.isFailure }
}
}

Some files were not shown because too many files have changed in this diff Show More