Compare commits

...

132 Commits

Author SHA1 Message Date
kyori19 e5d9b56dd0
Merge remote-tracking branch 'tuskyapp/develop'
# Conflicts:
#	app/build.gradle
#	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/trending/TrendingFragment.kt
#	app/src/main/java/com/keylesspalace/tusky/entity/Status.kt
#	app/src/main/res/values-be/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-gd/strings.xml
#	app/src/main/res/values-ja/strings.xml
#	app/src/main/res/values-lv/strings.xml
#	app/src/main/res/values-oc/strings.xml
#	app/src/main/res/values-sv/strings.xml
#	app/src/main/res/values-tr/strings.xml
#	app/src/main/res/values-vi/strings.xml
#	app/src/main/res/values-zh-rCN/strings.xml
#	app/src/main/res/values/donottranslate.xml
#	fastlane/metadata/android/en-US/images/phoneScreenshots/01_timeline.png
#	fastlane/metadata/android/en-US/images/phoneScreenshots/03_profile.png
#	fastlane/metadata/android/en-US/images/phoneScreenshots/04_favourites.png
#	fastlane/metadata/android/fa/full_description.txt
#	fastlane/metadata/android/sv/changelogs/58.txt
#	fastlane/metadata/android/sv/changelogs/67.txt
#	fastlane/metadata/android/sv/changelogs/72.txt
#	fastlane/metadata/android/sv/changelogs/77.txt
#	gradle/libs.versions.toml
2023-07-19 21:14:48 +09:00
kyori19 ce856f4ffe
Bump version to v4.6.0 (56) 2023-07-19 20:48:26 +09:00
kyori19 9968b561c4
Fix crash after resuming app 2023-07-19 20:41:13 +09:00
UlrichKu e4c2476f34
Retain text in search view when switching tabs before first search (#3540)
Fixes #3527
2023-07-19 11:40:08 +02:00
XoseM 1287614c2e Translated using Weblate (Galician)
Currently translated at 100.0% (609 of 609 strings)

Co-authored-by: XoseM <xosem@disroot.org>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/gl/
Translation: Tusky/Tusky
2023-07-19 10:10:57 +02:00
Ümit Solmaz 9f6c86a880 Translated using Weblate (Turkish)
Currently translated at 99.8% (608 of 609 strings)

Co-authored-by: Ümit Solmaz <usnotv@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/tr/
Translation: Tusky/Tusky
2023-07-19 10:10:57 +02:00
Rhoslyn Prys 9ada8bad92 Translated using Weblate (Welsh)
Currently translated at 100.0% (609 of 609 strings)

Co-authored-by: Rhoslyn Prys <post@meddal.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/cy/
Translation: Tusky/Tusky
2023-07-19 10:10:57 +02:00
GunChleoc d500858b00 Translated using Weblate (Gaelic)
Currently translated at 100.0% (609 of 609 strings)

Co-authored-by: GunChleoc <fios@foramnagaidhlig.net>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/gd/
Translation: Tusky/Tusky
2023-07-19 10:10:57 +02:00
renovate[bot] 669c336215
Update dagger to v2.47 (#3851)
[![Mend
Renovate](https://app.renovatebot.com/images/banner.svg)](https://renovatebot.com)

This PR contains the following updates:

| Package | Change | Age | Adoption | Passing | Confidence |
|---|---|---|---|---|---|
| [com.google.dagger:dagger](https://togithub.com/google/dagger) |
`2.46.1` -> `2.47` |
[![age](https://developer.mend.io/api/mc/badges/age/maven/com.google.dagger:dagger/2.47?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![adoption](https://developer.mend.io/api/mc/badges/adoption/maven/com.google.dagger:dagger/2.47?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![passing](https://developer.mend.io/api/mc/badges/compatibility/maven/com.google.dagger:dagger/2.46.1/2.47?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/com.google.dagger:dagger/2.46.1/2.47?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
|
[com.google.dagger:dagger-compiler](https://togithub.com/google/dagger)
| `2.46.1` -> `2.47` |
[![age](https://developer.mend.io/api/mc/badges/age/maven/com.google.dagger:dagger-compiler/2.47?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![adoption](https://developer.mend.io/api/mc/badges/adoption/maven/com.google.dagger:dagger-compiler/2.47?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![passing](https://developer.mend.io/api/mc/badges/compatibility/maven/com.google.dagger:dagger-compiler/2.46.1/2.47?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/com.google.dagger:dagger-compiler/2.46.1/2.47?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
|
[com.google.dagger:dagger-android-support](https://togithub.com/google/dagger)
| `2.46.1` -> `2.47` |
[![age](https://developer.mend.io/api/mc/badges/age/maven/com.google.dagger:dagger-android-support/2.47?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![adoption](https://developer.mend.io/api/mc/badges/adoption/maven/com.google.dagger:dagger-android-support/2.47?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![passing](https://developer.mend.io/api/mc/badges/compatibility/maven/com.google.dagger:dagger-android-support/2.46.1/2.47?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/com.google.dagger:dagger-android-support/2.46.1/2.47?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
|
[com.google.dagger:dagger-android-processor](https://togithub.com/google/dagger)
| `2.46.1` -> `2.47` |
[![age](https://developer.mend.io/api/mc/badges/age/maven/com.google.dagger:dagger-android-processor/2.47?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![adoption](https://developer.mend.io/api/mc/badges/adoption/maven/com.google.dagger:dagger-android-processor/2.47?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![passing](https://developer.mend.io/api/mc/badges/compatibility/maven/com.google.dagger:dagger-android-processor/2.46.1/2.47?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/com.google.dagger:dagger-android-processor/2.46.1/2.47?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
| [com.google.dagger:dagger-android](https://togithub.com/google/dagger)
| `2.46.1` -> `2.47` |
[![age](https://developer.mend.io/api/mc/badges/age/maven/com.google.dagger:dagger-android/2.47?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![adoption](https://developer.mend.io/api/mc/badges/adoption/maven/com.google.dagger:dagger-android/2.47?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![passing](https://developer.mend.io/api/mc/badges/compatibility/maven/com.google.dagger:dagger-android/2.46.1/2.47?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/com.google.dagger:dagger-android/2.46.1/2.47?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about these
updates again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR has been generated by [Mend
Renovate](https://www.mend.io/free-developer-tools/renovate/). View
repository job log
[here](https://developer.mend.io/github/tuskyapp/Tusky).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiIzNi41LjMiLCJ1cGRhdGVkSW5WZXIiOiIzNi44LjExIiwidGFyZ2V0QnJhbmNoIjoiZGV2ZWxvcCJ9-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-07-19 09:41:53 +02:00
Konrad Pozniak f3e0ee56df
Check for empty trending response (#3853)
Fixes #3852
2023-07-19 09:39:07 +02:00
SpaceFox 9a7e456edf
Ensures AbsoluteTimeFormatterTest class is not locale-dependant (#3863)
As tests are run against locale JVM and test does not force
a locale to run, so some tests may fail due to a different result only
due to the locale of the JVM used.

Example here with test `same year formatting` in class
`AbsoluteTimeFormatterTest` line 30 on a French JVM. There may be other
lines to fail with other languages.

Fixes #3859
2023-07-19 08:50:41 +02:00
Levi Bard a75131246f
update rick roll domains (#3850) 2023-07-13 22:21:11 +02:00
Conny Duck 45e604a194 update rick roll domains 2023-07-13 22:07:03 +02:00
Nik Clayton b17d3a5042
Set statusView width/height to match_parent (#3844)
Fixes an issue where the view's width is only enough to wrap the image,
resulting in a very narrow view that obscures the text of the error.
2023-07-13 17:26:12 +02:00
XoseM 799fcec6b0 Translated using Weblate (Galician)
Currently translated at 73.3% (22 of 30 strings)

Translation: Tusky/Tusky description
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky-app/gl/
2023-07-12 13:53:13 +02:00
Hồ Nhất Duy 1221bdf18c Translated using Weblate (Vietnamese)
Currently translated at 100.0% (31 of 31 strings)

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

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (30 of 30 strings)

Translation: Tusky/Tusky description
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky-app/vi/
2023-07-12 13:53:13 +02:00
Luna Jernberg 38ff2dd60f Translated using Weblate (Swedish)
Currently translated at 100.0% (30 of 30 strings)

Translation: Tusky/Tusky description
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky-app/sv/
2023-07-12 13:53:13 +02:00
Danial Behzadi cc5cec1426 Translated using Weblate (Persian)
Currently translated at 100.0% (31 of 31 strings)

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

Translated using Weblate (Persian)

Currently translated at 100.0% (30 of 30 strings)

Translation: Tusky/Tusky description
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky-app/fa/
2023-07-12 13:53:13 +02:00
Nik Clayton a41b81cd70
Allow builds on `develop` to write to the Gradle cache, update task names (#3834) 2023-07-12 13:26:30 +02:00
Oliebol 1421d46797 Translated using Weblate (Dutch)
Currently translated at 96.5% (588 of 609 strings)

Co-authored-by: Oliebol <schrijfmedan@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/nl/
Translation: Tusky/Tusky
2023-07-12 12:54:46 +02:00
Hồ Nhất Duy 828f50d5c4 Translated using Weblate (Vietnamese)
Currently translated at 100.0% (609 of 609 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (609 of 609 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-07-12 12:54:46 +02:00
Nik Clayton 2a04edc69b
Set OldTargetApi to a warning (#3836)
`OldTargetApi` default behaviour is to warn
(https://googlesamples.github.io/android-custom-lint-rules/checks/OldTargetApi.md.html)

Set it back to that, so that CI runs on runners with newer versions of
the SDK installed do not fail.
2023-07-12 11:49:33 +02:00
Sotolf Flasskjegg f5ffe3cb52 Translated using Weblate (Norwegian Bokmål)
Currently translated at 100.0% (609 of 609 strings)

Co-authored-by: Sotolf Flasskjegg <trym.karlsen@protonmail.ch>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/nb_NO/
Translation: Tusky/Tusky
2023-07-11 16:29:06 +02:00
Eric 1ed527ef02 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (609 of 609 strings)

Co-authored-by: Eric <alchemillatruth@purelymail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/zh_Hans/
Translation: Tusky/Tusky
2023-07-11 16:29:06 +02:00
Danial Behzadi cc88e38123 Translated using Weblate (Persian)
Currently translated at 100.0% (609 of 609 strings)

Co-authored-by: Danial Behzadi <dani.behzi@ubuntu.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/fa/
Translation: Tusky/Tusky
2023-07-11 16:29:06 +02:00
Connyduck f53c1c60c6 Translated using Weblate (German)
Currently translated at 100.0% (609 of 609 strings)

Co-authored-by: Connyduck <weblate@connyduck.at>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/de/
Translation: Tusky/Tusky
2023-07-11 16:29:06 +02:00
XoseM 6f2b9db076 Translated using Weblate (Galician)
Currently translated at 100.0% (609 of 609 strings)

Co-authored-by: XoseM <xosem@disroot.org>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/gl/
Translation: Tusky/Tusky
2023-07-11 16:29:06 +02:00
Hồ Nhất Duy 0b4a02a2a9 Translated using Weblate (Vietnamese)
Currently translated at 100.0% (609 of 609 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-07-11 16:29:06 +02:00
Goooler 060513508d
Migrate Glide compiler to KSP (#3791) 2023-07-11 15:34:14 +02:00
Nik Clayton 0a1be7df06
Experiment with linting and testing using Github actions (#3728)
Create an action that lints, tests, and builds the green debug variant.
2023-07-10 21:02:14 +02:00
Nik Clayton bfc84e4b85
Prepare 23.0 (versionCode 113) (#3833) 2023-07-10 19:58:23 +02:00
Konrad Pozniak 0f18253ce2
Clarify that logging out deletes local data (#3832)
Fixes #3417
2023-07-10 12:10:22 +02:00
Konrad Pozniak a56a995b60
update appstore screenshots (#3815) 2023-07-10 10:02:14 +02:00
Nik Clayton 0d88f26bb3
Display the list's title in the array adapter (#3823)
Fixes #3819
2023-07-08 21:08:37 +02:00
Nik Clayton 201f2c757e
Prepare 23.0 beta 2 (versionCode 112) (#3817) 2023-07-07 22:13:19 +02:00
Nik Clayton 3406abcbb3 Translated using Weblate (Italian)
Currently translated at 99.1% (604 of 609 strings)

Co-authored-by: Nik Clayton <nik@ngo.org.uk>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/it/
Translation: Tusky/Tusky
2023-07-07 21:38:23 +02:00
Santiago Kozak 5019b47e0a Translated using Weblate (Spanish)
Currently translated at 99.3% (605 of 609 strings)

Co-authored-by: Santiago Kozak <kozaksantiago@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/es/
Translation: Tusky/Tusky
2023-07-07 21:38:23 +02:00
Sotolf Flasskjegg 8c0f1ef45d Translated using Weblate (Norwegian Bokmål)
Currently translated at 100.0% (609 of 609 strings)

Co-authored-by: Sotolf Flasskjegg <trym.karlsen@protonmail.ch>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/nb_NO/
Translation: Tusky/Tusky
2023-07-07 21:38:23 +02:00
TAKAHASHI Shuuji 6546672bb7 Translated using Weblate (Japanese)
Currently translated at 96.5% (588 of 609 strings)

Co-authored-by: TAKAHASHI Shuuji <shuuji3@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/ja/
Translation: Tusky/Tusky
2023-07-07 21:38:23 +02:00
Ümit Solmaz 3e2e5995e3 Translated using Weblate (Turkish)
Currently translated at 100.0% (609 of 609 strings)

Co-authored-by: Ümit Solmaz <usnotv@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/tr/
Translation: Tusky/Tusky
2023-07-07 21:38:23 +02:00
Gera, Zoltan 6de49e5970 Translated using Weblate (Hungarian)
Currently translated at 100.0% (609 of 609 strings)

Co-authored-by: Gera, Zoltan <gerazo@manioka.hu>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/hu/
Translation: Tusky/Tusky
2023-07-07 21:38:23 +02:00
Deleted User 255629af5a Translated using Weblate (German)
Currently translated at 100.0% (609 of 609 strings)

Co-authored-by: Deleted User <noreply+289@weblate.org>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/de/
Translation: Tusky/Tusky
2023-07-07 21:38:23 +02:00
GunChleoc 5664d3f20a Translated using Weblate (Gaelic)
Currently translated at 100.0% (609 of 609 strings)

Co-authored-by: GunChleoc <fios@foramnagaidhlig.net>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/gd/
Translation: Tusky/Tusky
2023-07-07 21:38:23 +02:00
Luna Jernberg 8b6182464e Translated using Weblate (Swedish)
Currently translated at 100.0% (609 of 609 strings)

Co-authored-by: Luna Jernberg <droidbittin@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/sv/
Translation: Tusky/Tusky
2023-07-07 21:38:23 +02:00
Manuel 58bb6ed403 Translated using Weblate (Italian)
Currently translated at 100.0% (604 of 604 strings)

Co-authored-by: Manuel <mannivuwiki@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/it/
Translation: Tusky/Tusky
2023-07-07 21:38:23 +02:00
Hồ Nhất Duy 518cf505d2 Translated using Weblate (Vietnamese)
Currently translated at 100.0% (609 of 609 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (604 of 604 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-07-07 21:38:23 +02:00
Eric 22c1589a62 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (609 of 609 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (604 of 604 strings)

Co-authored-by: Eric <alchemillatruth@purelymail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/zh_Hans/
Translation: Tusky/Tusky
2023-07-07 21:38:23 +02:00
Oliebol a0fc40874b Translated using Weblate (Dutch)
Currently translated at 97.3% (588 of 604 strings)

Co-authored-by: Oliebol <schrijfmedan@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/nl/
Translation: Tusky/Tusky
2023-07-07 21:38:23 +02:00
Danial Behzadi 03887c9dea Translated using Weblate (Persian)
Currently translated at 100.0% (609 of 609 strings)

Translated using Weblate (Persian)

Currently translated at 100.0% (609 of 609 strings)

Translated using Weblate (Persian)

Currently translated at 100.0% (604 of 604 strings)

Co-authored-by: Danial Behzadi <dani.behzi@ubuntu.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/fa/
Translation: Tusky/Tusky
2023-07-07 21:38:23 +02:00
Nik Clayton 480d412e4c
Set default alt-text to media label when pasting images (#3800)
GBoard and other IME's support pasting images, which are converted to attachments.

Sometimes these have labels that describe the image. If present, set it as the default alt-text.

Fixes #3799
2023-07-07 18:50:19 +02:00
Nik Clayton c29a7dfe03
Convert lint "Typos" errors to warnings (#3811)
The typo database that lint uses may be incorrect, and it's unclear how to add or remove entries to it.
2023-07-07 17:14:57 +02:00
Konrad Pozniak c0cf5b2f0d
Fix caption dialog context menu background (#3787)
Fixes #3770, #3491
2023-07-07 15:10:15 +02:00
Sotolf Flasskjegg 47bf86593e Translated using Weblate (Norwegian Bokmål)
Currently translated at 100.0% (29 of 29 strings)

Translation: Tusky/Tusky description
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky-app/nb_NO/
2023-07-07 15:08:25 +02:00
Nik Clayton 3c90f22b84
Fix ArrayIndexOutOfBoundsException (#3808)
Fixes https://github.com/tuskyapp/Tusky/issues/3807
2023-07-06 19:57:35 +02:00
Nik Clayton 121db1713d
Fix lint issues in AppDatabase.java (#3809) 2023-07-06 19:37:51 +02:00
renovate[bot] a71ed0a813 Update plugin ktlint to v11.5.0 2023-07-05 10:48:56 +02:00
renovate[bot] 481bd513a3 Update coroutines to v1.7.2 2023-07-05 10:48:32 +02:00
renovate[bot] 2b1d707810 Update dependency app.cash.turbine:turbine to v1 2023-07-05 10:47:31 +02:00
Luna Jernberg 6160ddee44 Translated using Weblate (Swedish)
Currently translated at 100.0% (29 of 29 strings)

Translation: Tusky/Tusky description
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky-app/sv/
2023-07-05 00:41:53 +02:00
Danial Behzadi 24b23251d8 Translated using Weblate (Persian)
Currently translated at 100.0% (29 of 29 strings)

Translation: Tusky/Tusky description
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky-app/fa/
2023-07-05 00:41:53 +02:00
Gera, Zoltan 28e1ec8f4b Translated using Weblate (Hungarian)
Currently translated at 100.0% (29 of 29 strings)

Translation: Tusky/Tusky description
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky-app/hu/
2023-07-05 00:41:53 +02:00
Nik Clayton 961de796b7
Prepare 23.0 beta 1 (versionCode 111) (#3786) 2023-07-03 12:56:05 +02:00
Nik Clayton 22a95d927a
Remove obsolete comment re truth 1.1.4 (#3785) 2023-07-02 21:35:28 +02:00
Luna Jernberg 15118a7909 Translated using Weblate (Swedish)
Currently translated at 100.0% (28 of 28 strings)

Translation: Tusky/Tusky description
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky-app/sv/
2023-07-02 15:40:23 +02:00
Nik Clayton 25376170c2
Migrate "room" from "kapt" to "ksp" (#3777)
- Add ksp plugin
- Switch room to use ksp instead of kapt
- `ArrayList` -> `List` in a few places to fix errors about unbound generics
2023-06-29 18:37:46 +02:00
Nik Clayton 1f7a5f626d
Show notifications from workers (#3760)
Fix a crash where workers, in some conditions, should show a notification. These are sent to a dedicated channel with no importance.

Convert NotificationWorker to a CoroutineWorker and remove its use of `runBlocking`.

Fixes #3754
2023-06-29 18:37:27 +02:00
Nik Clayton 7fe4c9f317
Adjust list UX for platform consistency (#3142)
Most lists in the app use (explicitly or implicitly) platform metrics for dimensions, text size, colour, and so on, possibly via styles.

A few don't, inadvertently using the user's setting for status text size

Fix these, and simplify code where possible.

- Use android attributes for padding and height, for consistent UX.

- Remove explicit usage of app:tabTextAppearance, rely on the style.

- Remove ListSelectionAdapter and item_picker_list.xml, and adjust TabPreferenceActivity to use an ArrayAdapter with simple_list_item_1.xml

- Simplify item_followed_hashtag.xml, consistent with item_list.xml.

Fixes https://github.com/tuskyapp/Tusky/issues/3131
2023-06-29 18:36:19 +02:00
Nik Clayton fe7b1529df
Provide a preference to scale all UI text (#3248)
Font scaling is applied in addition to any scaling set in Android system preferences. So if the user set the Android font size to largest (a 1.3x increase) and then sets the preference to 120%, the total change is 1.56x.

Create SliderPreference to adjust the preference.

- Use Slider, which supports float values and step sizes > 1
- Display the selected value in the preference's summary
- Provide buttons to increment / decrement the value

Restart the activity if the preference changes so that the user sees the impact of the change immediately. Fix a bug in PreferencesActivity where the "EXTRA_RESTART_ON_BACK" intent was never processed. Fix this to ensure that other activities are restarted so the new font scale takes effect.

Implement the scaling in BaseActivity by overriding onAttachBaseContext, and providing a wrapped context with the font scaling applied.

Fixes https://github.com/tuskyapp/Tusky/issues/2982, https://github.com/tuskyapp/Tusky/issues/2461
2023-06-29 18:34:56 +02:00
renovate[bot] 93cc1e6410 Update dependency com.google.truth:truth to v1.1.5 2023-06-29 10:26:36 +02:00
renovate[bot] 99a04da845 Update plugin ktlint to v11.4.2 2023-06-29 10:24:21 +02:00
renovate[bot] d57673d921
Update plugin ktlint to v11.4.1 (#3766)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-06-22 17:13:05 +02:00
Nik Clayton 153a9ad9c2
Simplify repeated code that shows errors (#3762)
Instead of repeating the same if/else check on the error type when setting up the background message, move this in to BackgroundMessageView.

Provide different `setup()` variants, including one that just takes a throwable and a handler, and figures out the correct drawables and error message.

Update and simplify call sites.
2023-06-19 23:49:20 +02:00
GunChleoc af7e883165 Translated using Weblate (Gaelic)
Currently translated at 100.0% (604 of 604 strings)

Co-authored-by: GunChleoc <fios@foramnagaidhlig.net>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/gd/
Translation: Tusky/Tusky
2023-06-19 14:26:14 +02:00
XoseM 75d5f247c7 Translated using Weblate (Galician)
Currently translated at 100.0% (604 of 604 strings)

Co-authored-by: XoseM <xosem@disroot.org>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/gl/
Translation: Tusky/Tusky
2023-06-18 13:46:58 +02:00
Hồ Nhất Duy 7942d497b2 Translated using Weblate (Vietnamese)
Currently translated at 99.8% (603 of 604 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-06-18 13:46:58 +02:00
Chaitanya eacdc40268 Translated using Weblate (Hindi)
Currently translated at 58.1% (351 of 604 strings)

Co-authored-by: Chaitanya <hellochaitanya@protonmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/hi/
Translation: Tusky/Tusky
2023-06-18 13:46:58 +02:00
Eric 4de9e1446b Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (604 of 604 strings)

Co-authored-by: Eric <alchemillatruth@purelymail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/zh_Hans/
Translation: Tusky/Tusky
2023-06-18 13:46:58 +02:00
junius 0273beb3bd Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (604 of 604 strings)

Co-authored-by: junius <fifteenkilos@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/zh_Hans/
Translation: Tusky/Tusky
2023-06-18 13:46:58 +02:00
Quentí 7bbc4a9b00 Translated using Weblate (Occitan)
Currently translated at 100.0% (604 of 604 strings)

Co-authored-by: Quentí <quentinantonin@free.fr>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/oc/
Translation: Tusky/Tusky
2023-06-18 13:46:58 +02:00
GunChleoc 87a85fe875 Translated using Weblate (Gaelic)
Currently translated at 100.0% (603 of 603 strings)

Co-authored-by: GunChleoc <fios@foramnagaidhlig.net>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/gd/
Translation: Tusky/Tusky
2023-06-18 13:46:58 +02:00
Oliebol 73ccc81764 Translated using Weblate (Dutch)
Currently translated at 97.3% (587 of 603 strings)

Co-authored-by: Oliebol <schrijfmedan@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/nl/
Translation: Tusky/Tusky
2023-06-18 13:46:58 +02:00
Danial Behzadi 7b344067a9 Translated using Weblate (Persian)
Currently translated at 100.0% (604 of 604 strings)

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-06-18 13:46:58 +02:00
Nik Clayton 100673aa9c
Handle status edit histories with < 2 entries (#3747)
This can happen if the edit history has not been propogated to the user's server.

If the edit history is missing then show an error with a link to the specifc Mastodon issue.

Fixes #3743
2023-06-15 19:59:30 +02:00
Slann Tonic c3ad055092 Translated using Weblate (Spanish)
Currently translated at 75.0% (21 of 28 strings)

Translation: Tusky/Tusky description
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky-app/es/
2023-06-15 12:28:04 +02:00
Konrad Pozniak 9fedb6d5b2
Fix trending tags being cut off (#3745)
Fixes https://github.com/tuskyapp/Tusky/issues/3742
2023-06-15 11:27:57 +02:00
Eric 885b54a683 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-06-15 11:27:03 +02:00
Oliebol cdb7194fec Translated using Weblate (Dutch)
Currently translated at 97.3% (587 of 603 strings)

Co-authored-by: Oliebol <schrijfmedan@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/nl/
Translation: Tusky/Tusky
2023-06-15 11:27:03 +02:00
L.Yo 0efd95b503 Translated using Weblate (Dutch)
Currently translated at 97.3% (587 of 603 strings)

Co-authored-by: L.Yo <pseudolyo@gmx.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/nl/
Translation: Tusky/Tusky
2023-06-15 11:27:03 +02:00
sk 30a2af70ea Translated using Weblate (Japanese)
Currently translated at 92.3% (556 of 602 strings)

Co-authored-by: sk <sk736b@protonmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/ja/
Translation: Tusky/Tusky
2023-06-15 11:27:03 +02:00
Prat 485a4c364e
Improve prompts when draft is empty (#3699)
When the user is closing the compose view,

    if it's new and empty, don't show a prompt.
    if it's an existing draft and now empty, ask if the user wants to delete it or continue editing. I don't think there is much value in saving an empty draft.
---------
2023-06-13 15:45:17 +02:00
Nik Clayton eedf6abf91
Catch missing classes in WorkerFactory (#3744)
When NotificationWorker was moved from ...components.notifications to
...worker.

Installing Tusky with this change doesn't remove any future periodic
jobs queued under the old class name. So when Class.forName() is
called the old class name is not found, and the exception is thrown.

Handle this the same way androidx.work.WorkerFactory does -- catch
the exception, log it, and return null.

Fixes #3740
2023-06-12 22:04:58 +02:00
Eric 5327c9f572 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (602 of 602 strings)

Co-authored-by: Eric <alchemillatruth@purelymail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/zh_Hans/
Translation: Tusky/Tusky
2023-06-12 12:18:02 +02:00
Danial Behzadi ac49222d8c Translated using Weblate (Persian)
Currently translated at 100.0% (602 of 602 strings)

Co-authored-by: Danial Behzadi <dani.behzi@ubuntu.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/fa/
Translation: Tusky/Tusky
2023-06-12 11:36:31 +02:00
Balázs Meskó 78f4954ee3 Translated using Weblate (Hungarian)
Currently translated at 100.0% (601 of 601 strings)

Co-authored-by: Balázs Meskó <mesko.balazs@fsf.hu>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/hu/
Translation: Tusky/Tusky
2023-06-12 11:36:31 +02:00
XoseM 18f83744fd Translated using Weblate (Galician)
Currently translated at 75.0% (21 of 28 strings)

Translation: Tusky/Tusky description
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky-app/gl/
2023-06-12 11:13:50 +02:00
puf f5f415cc76 Translated using Weblate (Welsh)
Currently translated at 75.0% (21 of 28 strings)

Translation: Tusky/Tusky description
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky-app/cy/
2023-06-12 11:13:50 +02:00
Konrad Pozniak 6e06e656b4
Make sure the active account is always correctly saved to database (#3720)
Fixes #3702
2023-06-11 20:24:26 +02:00
Konrad Pozniak 3698c72109
Correctly show non-square custom emojis (#3711)
Instead of forcing all emojis to be square, only the width is fixed now and the emoji scaled to fit.

Fixes #635
2023-06-11 20:06:34 +02:00
Nik Clayton 5755801e6f
Add a menu option to load the newest notifications (#3708)
- Create a flow with new items (arbitrary ints) when a reload from the top should happen
- Combine this flow with notificationFilter, so changes to either of them trigger a reload
- Provide a menu item in NotificationsFragment to initiate the reload
- Handle the action in the view model
2023-06-11 20:04:49 +02:00
Levi Bard 4cddd2c5e6
Add delete button to edit filter activity. (#3553)
Adds workaround for #3545
2023-06-11 19:59:26 +02:00
Nik Clayton 66a394245b
Remove ReplacementSpan, display diffs using CharacterStyle (#3431)
Remove the use of ReplacementSpan. It turns out this span type is incompatible with spans that occupy more than one line, and the result is that a longer diff can run off the end of the screen. The alternative means that the diff'd text doesn't have additional padding and rounded corners, but it's better than not being visible.

Display the most recent version of the status with larger text. Again, consistent with the thread view.

Display the avatar, name, and username of the poster in a pinned header at the top of the screen, instead of duplicating the information on every edit. This reduces the amount of redundant information on the screen.
2023-06-11 19:12:05 +02:00
Nik Clayton 84486c7f13
Ensure textview fields can be copy/pasted (#3707)
The Android libraries have a bug where a TextView can forget that it contains selectable text, can be pasted in to, etc.

See https://issuetracker.google.com/issues/37095917

Fix this with an extension method that toggles the selectable state to re-enable it, and use this on the profile fields when editing an account.

Fixes https://github.com/tuskyapp/Tusky/issues/3706
2023-06-11 18:39:48 +02:00
Nik Clayton 5fd532d69b
Notification tab cleanups (#3692)
- Use NO_POSITION instead of hardcoding 0.
- Don't set a state restoration policy, PagingDataAdapter already does that
- Return the closest item, not just the closest page, in getRefreshKey
2023-06-11 16:23:52 +02:00
Nik Clayton 327254d759
Remove android:textAppearance="@android:style/TextAppearance.DeviceDefault.Small" (#3663)
It caused text size differences between the text in this view and all the other textviews in this layout.

It's not used in other layouts.

Fixes https://github.com/tuskyapp/Tusky/issues/3494
2023-06-11 15:50:34 +02:00
Nik Clayton 2a9ad92e55
Make AccountPreferenceDataStore injectable (#3653)
This will make tests that need it easier.

- Rename from AccountPreferenceHandler
- Inject its dependencies
- Create an injectable CoroutineScope it can use for launching coroutines
- Use it in AccountPreferences
2023-06-11 15:34:58 +02:00
João Alves fc3b3f76bf Translated using Weblate (Portuguese (Portugal))
Currently translated at 84.6% (509 of 601 strings)

Co-authored-by: João Alves <joao.2003.couto+weblate@gmail.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/pt_PT/
Translation: Tusky/Tusky
2023-06-11 15:14:11 +02:00
Hồ Nhất Duy ea064edddf 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-06-11 15:14:11 +02:00
Nik Clayton 152ca710c9 Translated using Weblate (Persian)
Currently translated at 100.0% (601 of 601 strings)

Translated using Weblate (Persian)

Currently translated at 100.0% (601 of 601 strings)

Translated using Weblate (Icelandic)

Currently translated at 100.0% (601 of 601 strings)

Co-authored-by: Nik Clayton <nik@ngo.org.uk>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/fa/
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/is/
Translation: Tusky/Tusky
2023-06-11 15:14:11 +02:00
Danial Behzadi 48c435d499 Translated using Weblate (Persian)
Currently translated at 100.0% (601 of 601 strings)

Co-authored-by: Danial Behzadi <dani.behzi@ubuntu.com>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/fa/
Translation: Tusky/Tusky
2023-06-11 15:14:11 +02:00
Quentí d131df06a4 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-06-11 15:14:11 +02:00
Mārtiņš Bruņenieks c3229760c1 Translated using Weblate (Latvian)
Currently translated at 92.8% (558 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-06-11 15:14:11 +02:00
Sveinn í Felli 97b44228b7 Translated using Weblate (Icelandic)
Currently translated at 100.0% (601 of 601 strings)

Translated using Weblate (Icelandic)

Currently translated at 99.8% (600 of 601 strings)

Co-authored-by: Sveinn í Felli <sv1@fellsnet.is>
Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/is/
Translation: Tusky/Tusky
2023-06-11 15:14:11 +02:00
Nik Clayton 8fec41c2ae
Send UI errors to a channel instead of a shared flow (#3652)
In the previous code any errors that occured *before* a subscriber was
listening to `uiError` would be dropped, so the user would be unware
of them.

By implementing as a channel these errors will be shown to the user,
with an opportunity to retry the operation or report the error.
2023-06-11 14:00:05 +02:00
Nik Clayton 5e8a63a046
Throttle UI actions instead of debouncing (#3651)
Introduce Flow<T>.throttleFirst(). In a flow this emits the first value,
and each value afterwards that is > some timeout after the previous
value.

This prevents accidental double-taps on UI elements from generating
multiple-actions.

The previous code used debounce(). That has a similar effect, but with
debounce() the code has to wait until after the timeout period has
elapsed before it can process the action, leading to an unnecessary
UI delay.

With throttleFirst a value is emitted immediately, there's no need
to wait. It's subsequent values that are potentially throttled.
2023-06-11 13:34:22 +02:00
Nik Clayton 4025ab35ff
Move cache pruning to a WorkManager worker (#3649)
- Extend what was `NotificationWorkerFactory` to `WorkerFactory`. This
  can construct arbitrary Workers as long as they provide their own
  Factory for construction.

  The per-Worker factory contains any injected components just for that
  worker type, keeping `WorkerFactory` clean.

- Move `NotificationWorkerFactory` to the new model.

- Implement `PruneCacheWorker`, and remove the code from
 `CachedTimelineViewModel`.

- Create the periodic worker in `TuskyApplication`, ensuring that the
  database is only pruned when the device is idle.
2023-06-11 13:17:30 +02:00
Konrad Pozniak 85b7caa887
Replace deprecated getParcelable* methods with compat versions (#3633) 2023-06-11 12:58:55 +02:00
Nik Clayton 8e87b5d465
Downgrade Truth library to 1.1.3 (#3733)
It bundles Guava 32.0.0 which has a bug on Windows where temporary directories can't be created, causing tests to fail.

See https://github.com/google/truth/issues/1137 and https://github.com/google/guava/issues/6535
2023-06-10 22:31:59 +02:00
Konrad Pozniak f23c0cc634
Refactor "trending hashtags" code (#3595)
- Fix codeformatting
- Add new refreshing state
- Disable LogConditional lint rule
- Update lint-baseline
2023-06-10 19:47:07 +02:00
Nik Clayton 071e00774e
Replace shortNumber() with formatNumber() (#3519)
formatNumber() was existing code to show numbers with suffixes like K, M, etc, so re-use that code and delete shortNumber().

Update the tests to (a) test formatNumber(), and (b) be parameterised.
2023-06-10 16:29:26 +02:00
renovate[bot] dd1020e48a
Update dependency org.mockito.kotlin:mockito-kotlin to v5 (#3724)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-06-10 16:25:43 +02:00
Weblate 291f0f5bd2
Translations update from Weblate (#3729)
* Translated using Weblate (German)

Currently translated at 100.0% (28 of 28 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% (28 of 28 strings)

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

* Translated using Weblate (Vietnamese)

Currently translated at 100.0% (28 of 28 strings)

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

---------

Co-authored-by: Deleted User <noreply+282@weblate.org>
Co-authored-by: Danial Behzadi <dani.behzi@ubuntu.com>
Co-authored-by: Hồ Nhất Duy <mastoduy@gmail.com>
2023-06-10 16:17:51 +02:00
renovate[bot] 020d427f0a
Update dependency androidx.fragment:fragment-ktx to v1.6.0 (#3722)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-06-10 15:44:25 +02:00
renovate[bot] 1d3e781f14
Update dependency com.google.truth:truth to v1.1.4 (#3721)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-06-10 15:06:36 +02:00
renovate[bot] 1278c5e0ec
Update dependency androidx.core:core-ktx to v1.10.1 (#3704)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-06-09 22:26:52 +02:00
renovate[bot] 129d07c49b
Update dependency androidx.activity:activity-ktx to v1.7.2 (#3703)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-06-09 21:14:15 +02:00
renovate[bot] b5c9fefda8
Update dependency app.cash.turbine:turbine to v0.13.0 (#3677)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-06-09 20:20:21 +02:00
renovate[bot] e4fc80db54
Update coroutines to v1.7.1 (#3627)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-06-09 19:54:03 +02:00
renovate[bot] 8a8c587979
Update dependency com.google.android.material:material to v1.9.0 (#3624)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-06-09 17:23:09 +02:00
renovate[bot] cd2e3038aa
Update dependency org.robolectric:robolectric to v4.10.3 (#3613)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-06-09 15:47:35 +02:00
renovate[bot] bf35c0e36f
Update dagger to v2.46.1 (#3592)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-06-09 15:12:00 +02:00
renovate[bot] 192c6979c6
Update plugin ktlint to v11.4.0 (#3573)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-06-09 14:21:17 +02:00
renovate[bot] c78c06c30b
Update dependency org.jetbrains.kotlin:kotlin-gradle-plugin to v1.8.22 (#3569)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-06-09 14:19:05 +02:00
220 changed files with 3678 additions and 1976 deletions

41
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,41 @@
name: CI
on:
push:
tags:
- '*'
pull_request:
workflow_dispatch:
jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- uses: actions/setup-java@v3
with:
java-version: '11'
distribution: 'temurin'
- name: Gradle Wrapper Validation
uses: gradle/wrapper-validation-action@v1
- name: Gradle Build Action
uses: gradle/gradle-build-action@v2
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/develop' }}
- name: ktlint
run: ./gradlew clean ktlintCheck
- name: Regular lint
run: ./gradlew app:lintGreenDebug
- name: Test
run: ./gradlew app:testGreenDebugUnitTest
- name: Build
run: ./gradlew app:buildGreenDebug

View File

@ -6,6 +6,53 @@
### Significant bug fixes
## v23.0
### New features and other improvements
- **New preference to scale UI text**, [PR#3248](https://github.com/tuskyapp/Tusky/pull/3248) by [@nikclayton](https://mastodon.social/@nikclayton)
### Significant bug fixes
- **Save account information correctly**, [PR#3720](https://github.com/tuskyapp/Tusky/pull/3720) by [@connyduck](https://chaos.social/@ConnyDuck)
- If you were logged in with multiple accounts it was possible to switch accounts in a way that the UI showed the new account, but database operations were happening using the old account.
- **"pull" notifications on devices running Android versions <= 11**, [PR#3649](https://github.com/tuskyapp/Tusky/pull/3649) by [@nikclayton](https://mastodon.social/@nikclayton)
- Pull notifications (i.e., not using ntfy.sh) could silently fail on devices running Android 11 and below
- **Work around Android bug where text fields could "forget" they can copy/paste**, [PR#3707](https://github.com/tuskyapp/Tusky/pull/3707) by [@nikclayton](https://mastodon.social/@nikclayton)
- **Viewing "diffs" in edit history will not extend off screen edge**, [PR#3431](https://github.com/tuskyapp/Tusky/pull/3431) by [@nikclayton](https://mastodon.social/@nikclayton)
- **Don't crash if your server has no post edit history**, [PR#3747](https://github.com/tuskyapp/Tusky/pull/3747) by [@nikclayton](https://mastodon.social/@nikclayton)
- Your Mastodon server might know that a post has been edited, but not know the details of those edits. Trying to view the history of those statuses no longer crashes.
- **Add a "Delete" button when editing a filter**, [PR#3553](https://github.com/tuskyapp/Tusky/pull/3553) by [@Tak](https://mastodon.gamedev.place/@Tak)
- **Show non-square emoji correctly**, [PR#3711](https://github.com/tuskyapp/Tusky/pull/3711) by [@connyduck](https://chaos.social/@ConnyDuck)
- **Potential crash when editing profile fields**, [PR#3808](https://github.com/tuskyapp/Tusky/pull/3808) by [@nikclayton](https://mastodon.social/@nikclayton)
- **Oversized context menu when editing image descriptions**, [PR#3787](https://github.com/tuskyapp/Tusky/pull/3787) by [@connyduck](https://chaos.social/@ConnyDuck)
## v23.0 beta 2
### Significant bug fixes
- **Potential crash when editing profile fields**, [PR#3808](https://github.com/tuskyapp/Tusky/pull/3808) by [@nikclayton](https://mastodon.social/@nikclayton)
- **Oversized context menu when editing image descriptions**, [PR#3787](https://github.com/tuskyapp/Tusky/pull/3787) by [@connyduck](https://chaos.social/@ConnyDuck)
## v23.0 beta 1
### New features and other improvements
- **New preference to scale UI text**, [PR#3248](https://github.com/tuskyapp/Tusky/pull/3248) by [@nikclayton](https://mastodon.social/@nikclayton)
### Significant bug fixes
- **Save account information correctly**, [PR#3720](https://github.com/tuskyapp/Tusky/pull/3720) by [@connyduck](https://chaos.social/@ConnyDuck)
- If you were logged in with multiple accounts it was possible to switch accounts in a way that the UI showed the new account, but database operations were happening using the old account.
- **"pull" notifications on devices running Android versions <= 11**, [PR#3649](https://github.com/tuskyapp/Tusky/pull/3649) by [@nikclayton](https://mastodon.social/@nikclayton)
- Pull notifications (i.e., not using ntfy.sh) could silently fail on devices running Android 11 and below
- **Work around Android bug where text fields could "forget" they can copy/paste**, [PR#3707](https://github.com/tuskyapp/Tusky/pull/3707) by [@nikclayton](https://mastodon.social/@nikclayton)
- **Viewing "diffs" in edit history will not extend off screen edge**, [PR#3431](https://github.com/tuskyapp/Tusky/pull/3431) by [@nikclayton](https://mastodon.social/@nikclayton)
- **Don't crash if your server has no post edit history**, [PR#3747](https://github.com/tuskyapp/Tusky/pull/3747) by [@nikclayton](https://mastodon.social/@nikclayton)
- Your Mastodon server might know that a post has been edited, but not know the details of those edits. Trying to view the history of those statuses no longer crashes.
- **Add a "Delete" button when editing a filter**, [PR#3553](https://github.com/tuskyapp/Tusky/pull/3553) by [@Tak](https://mastodon.gamedev.place/@Tak)
- **Show non-square emoji correctly**, [PR#3711](https://github.com/tuskyapp/Tusky/pull/3711) by [@connyduck](https://chaos.social/@ConnyDuck)
## v22.0
### New features and other improvements

View File

@ -1,5 +1,6 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.google.ksp)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.kapt)
alias(libs.plugins.kotlin.parcelize)
@ -28,8 +29,8 @@ android {
namespace "com.keylesspalace.tusky"
minSdk 23
targetSdk 33
versionCode 55
versionName '4.5.2'
versionCode 56
versionName '4.6.0'
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true
@ -121,11 +122,9 @@ android {
}
}
kapt {
arguments {
arg("room.schemaLocation", "$projectDir/schemas")
arg("room.incremental", "true")
}
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
arg("room.incremental", "true")
}
configurations {
@ -142,7 +141,7 @@ dependencies {
implementation libs.bundles.androidx
implementation libs.bundles.room
kapt libs.androidx.room.compiler
ksp libs.androidx.room.compiler
implementation libs.android.material
@ -156,7 +155,7 @@ dependencies {
implementation libs.conscrypt.android
implementation libs.bundles.glide
kapt libs.glide.compiler
ksp libs.glide.compiler
implementation libs.bundles.rxjava3

File diff suppressed because it is too large Load Diff

View File

@ -36,6 +36,13 @@
<!-- Ensure we are warned about errors in the baseline -->
<issue id="LintBaseline" severity="warning" />
<!-- Warn about typos. The typo database in lint is not exhaustive, and it's unclear
how to add to it when it's wrong. -->
<issue id="Typos" severity="warning" />
<!-- Set OldTargetApi back to warning -->
<issue id="OldTargetApi" severity="warning" />
<!-- Mark all other lint issues as errors -->
<issue id="all" severity="error" />
</lint>

View File

@ -46,7 +46,6 @@ import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel
import com.keylesspalace.tusky.viewmodel.State
import kotlinx.coroutines.launch
import java.io.IOException
import javax.inject.Inject
private typealias AccountInfo = Pair<TimelineAccount, Boolean>
@ -146,23 +145,10 @@ class AccountsInListFragment : DialogFragment(), Injectable {
private fun handleError(error: Throwable) {
binding.messageView.show()
val retryAction = { _: View ->
binding.messageView.setup(error) { _: View ->
binding.messageView.hide()
viewModel.load(listId)
}
if (error is IOException) {
binding.messageView.setup(
R.drawable.elephant_offline,
R.string.error_network,
retryAction
)
} else {
binding.messageView.setup(
R.drawable.elephant_error,
R.string.error_generic,
retryAction
)
}
}
private fun onRemoveFromList(accountId: String) {

View File

@ -16,9 +16,11 @@
package com.keylesspalace.tusky;
import android.app.ActivityManager;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.content.res.Configuration;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Color;
@ -45,6 +47,7 @@ import com.keylesspalace.tusky.db.AccountManager;
import com.keylesspalace.tusky.di.Injectable;
import com.keylesspalace.tusky.interfaces.AccountSelectionListener;
import com.keylesspalace.tusky.interfaces.PermissionRequester;
import com.keylesspalace.tusky.settings.PrefKeys;
import com.keylesspalace.tusky.util.ThemeUtils;
import java.util.ArrayList;
@ -54,6 +57,7 @@ import java.util.List;
import javax.inject.Inject;
public abstract class BaseActivity extends AppCompatActivity implements Injectable {
private static final String TAG = "BaseActivity";
@Inject
public AccountManager accountManager;
@ -93,6 +97,44 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
requesters = new HashMap<>();
}
@Override
protected void attachBaseContext(Context newBase) {
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(newBase);
// Scale text in the UI from PrefKeys.UI_TEXT_SCALE_RATIO
float uiScaleRatio = preferences.getFloat(PrefKeys.UI_TEXT_SCALE_RATIO, 100F);
Configuration configuration = newBase.getResources().getConfiguration();
// Adjust `fontScale` in the configuration.
//
// You can't repeatedly adjust the `fontScale` in `newBase` because that will contain the
// result of previous adjustments. E.g., going from 100% to 80% to 100% does not return
// you to the original 100%, it leaves it at 80%.
//
// Instead, calculate the new scale from the application context. This is unaffected by
// changes to the base context. It does contain contain any changes to the font scale from
// "Settings > Display > Font size" in the device settings, so scaling performed here
// is in addition to any scaling in the device settings.
Configuration appConfiguration = newBase.getApplicationContext().getResources().getConfiguration();
// This only adjusts the fonts, anything measured in `dp` is unaffected by this.
// You can try to adjust `densityDpi` as shown in the commented out code below. This
// works, to a point. However, dialogs do not react well to this. Beyond a certain
// scale (~ 120%) the right hand edge of the dialog will clip off the right of the
// screen.
//
// So for now, just adjust the font scale
//
// val displayMetrics = appContext.resources.displayMetrics
// configuration.densityDpi = ((displayMetrics.densityDpi * uiScaleRatio).toInt())
configuration.fontScale = appConfiguration.fontScale * uiScaleRatio / 100F;
Context fontScaleContext = newBase.createConfigurationContext(configuration);
super.attachBaseContext(fontScaleContext);
}
protected boolean requiresLogin() {
return true;
}
@ -212,15 +254,8 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
}
}
// TODO: This changes the accountManager's activeAccount property, but does not do any
// of the work that AccountManager.setActiveAccount() does. In particular:
//
// - The current active account is not saved
// - The account passed as parameter here goes not have its `isActive` property set
//
// Is that deliberate? Or is this a bug?
public void openAsAccount(@NonNull String url, @NonNull AccountEntity account) {
accountManager.setActiveAccount(account);
accountManager.setActiveAccount(account.getId());
Intent intent = new Intent(this, MainActivity.class);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
intent.putExtra(MainActivity.REDIRECT_URL, url);

View File

@ -301,14 +301,6 @@ 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
@ -732,15 +724,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
super.onSaveInstanceState(binding.mainDrawer.saveInstanceState(outState))
}
private fun tintCheckIcon(item: MenuItem) {
if (item.isChecked) {
@Suppress("DEPRECATION")
item.icon?.setColorFilter(ContextCompat.getColor(this, R.color.tusky_green_light), PorterDuff.Mode.SRC_IN)
} else {
setDrawableTint(this, item.icon!!, android.R.attr.textColorTertiary)
}
}
private fun setupTabs(selectNotificationTab: Boolean) {
val activeTabLayout = if (preferences.getString(PrefKeys.MAIN_NAV_POSITION, "top") == "bottom") {
val actionBarSize = getDimension(this, androidx.appcompat.R.attr.actionBarSize)
@ -794,12 +777,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
if (data.id == LIST) {
menuBuilder.findItem(R.id.tabEditList).isVisible = true
}
if (data.id in arrayOf(HOME, LOCAL, FEDERATED, LIST)) {
menuBuilder.findItem(R.id.tabToggleStreaming).apply {
isVisible = true
isChecked = data.enableStreaming
}
}
if (data.id == NOTIFICATIONS) {
menuBuilder.findItem(R.id.tabToggleNotificationsFilter).isVisible = true
}
@ -813,7 +790,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
setDrawableTint(this, item.icon!!, android.R.attr.textColorPrimary)
}
}
tintCheckIcon(menuBuilder.findItem(R.id.tabToggleStreaming))
}
popup.setOnMenuItemClickListener { item ->
@ -837,20 +813,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
data.arguments.getOrNull(1).orEmpty()
).show(supportFragmentManager, null)
}
R.id.tabToggleStreaming -> {
if (fragment is TimelineFragment) {
val to = !item.isChecked
fragment.setStreamingEnabled(to)
item.isChecked = to
tintCheckIcon(item)
tabs[position] = data.copy(enableStreaming = to)
accountManager.activeAccount?.let {
it.tabPreferences = tabs
accountManager.saveAccount(it)
}
}
}
R.id.tabToggleNotificationsFilter -> {
if (fragment is NotificationsFragment) {
val prefs = PreferenceManager.getDefaultSharedPreferences(this)
@ -915,6 +877,17 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
}
updateProfiles()
streamingManager.setup(
this,
tabs.mapNotNull { it.subscription }.toSet(),
) { active ->
if (active) {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
}
private fun refreshComposeButtonState(adapter: MainPagerAdapter, tabPosition: Int) {

View File

@ -24,6 +24,8 @@ 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.components.trending.TrendingFragment
import net.accelf.yuito.streaming.StreamType
import net.accelf.yuito.streaming.Subscription
import java.util.Objects
/** this would be a good case for a sealed class, but that does not work nice with Room */
@ -48,6 +50,20 @@ data class TabData(
val title: (Context) -> String = { context -> context.getString(text) },
val enableStreaming: Boolean = false,
) {
val subscription by lazy {
if (enableStreaming) {
when (id) {
HOME -> Subscription(StreamType.USER)
LOCAL -> Subscription(StreamType.LOCAL)
FEDERATED -> Subscription(StreamType.PUBLIC)
LIST -> Subscription(StreamType.LIST, arguments[0].toInt())
else -> null
}
} else {
null
}
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false

View File

@ -21,6 +21,8 @@ import android.os.Bundle
import android.util.Log
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.ProgressBar
@ -42,12 +44,12 @@ import com.google.android.material.snackbar.Snackbar
import com.google.android.material.transition.MaterialArcMotion
import com.google.android.material.transition.MaterialContainerTransform
import com.keylesspalace.tusky.adapter.ItemInteractionListener
import com.keylesspalace.tusky.adapter.ListSelectionAdapter
import com.keylesspalace.tusky.adapter.TabAdapter
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.MainTabsChangedEvent
import com.keylesspalace.tusky.databinding.ActivityTabPreferenceBinding
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.MastoList
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.getDimension
import com.keylesspalace.tusky.util.hide
@ -212,6 +214,14 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
currentTabsAdapter.notifyItemChanged(tabPosition)
}
override fun onStreamingChanged(tab: TabData, tabPosition: Int, enabled: Boolean) {
val newTab = tab.copy(enableStreaming = enabled)
currentTabs[tabPosition] = newTab
saveTabs()
currentTabsAdapter.notifyItemChanged(tabPosition)
}
private fun toggleFab(expand: Boolean) {
val transition = MaterialContainerTransform().apply {
startView = if (expand) binding.actionButton else binding.sheet
@ -272,7 +282,13 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
}
private fun showSelectListDialog() {
val adapter = ListSelectionAdapter(this)
val adapter = object : ArrayAdapter<MastoList>(this, android.R.layout.simple_list_item_1) {
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val view = super.getView(position, convertView, parent)
getItem(position)?.let { item -> (view as TextView).text = item.title }
return view
}
}
val statusLayout = LinearLayout(this)
statusLayout.gravity = Gravity.CENTER
@ -298,12 +314,13 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
.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))
currentTabs.add(newTab)
currentTabsAdapter.notifyItemInserted(currentTabs.size - 1)
updateAvailableTabs()
saveTabs()
adapter.getItem(position)?.let { item ->
val newTab = createTabDataFromId(LIST, listOf(item.id, item.title))
currentTabs.add(newTab)
currentTabsAdapter.notifyItemInserted(currentTabs.size - 1)
updateAvailableTabs()
saveTabs()
}
}
val showProgressBarJob = getProgressBarJob(progress, 500)

View File

@ -18,15 +18,20 @@ package com.keylesspalace.tusky
import android.app.Application
import android.content.SharedPreferences
import android.util.Log
import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import autodispose2.AutoDisposePlugins
import com.keylesspalace.tusky.components.notifications.NotificationWorkerFactory
import com.keylesspalace.tusky.components.notifications.NotificationHelper
import com.keylesspalace.tusky.di.AppInjector
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.settings.SCHEMA_VERSION
import com.keylesspalace.tusky.util.APP_THEME_DEFAULT
import com.keylesspalace.tusky.util.LocaleManager
import com.keylesspalace.tusky.util.setAppNightMode
import com.keylesspalace.tusky.worker.PruneCacheWorker
import com.keylesspalace.tusky.worker.WorkerFactory
import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
import de.c1710.filemojicompat_defaults.DefaultEmojiPackList
@ -35,6 +40,7 @@ import de.c1710.filemojicompat_ui.helpers.EmojiPreference
import io.reactivex.rxjava3.plugins.RxJavaPlugins
import org.conscrypt.Conscrypt
import java.security.Security
import java.util.concurrent.TimeUnit
import javax.inject.Inject
class TuskyApplication : Application(), HasAndroidInjector {
@ -42,7 +48,7 @@ class TuskyApplication : Application(), HasAndroidInjector {
lateinit var androidInjector: DispatchingAndroidInjector<Any>
@Inject
lateinit var notificationWorkerFactory: NotificationWorkerFactory
lateinit var workerFactory: WorkerFactory
@Inject
lateinit var localeManager: LocaleManager
@ -90,12 +96,24 @@ class TuskyApplication : Application(), HasAndroidInjector {
Log.w("RxJava", "undeliverable exception", it)
}
NotificationHelper.createWorkerNotificationChannel(this)
WorkManager.initialize(
this,
androidx.work.Configuration.Builder()
.setWorkerFactory(notificationWorkerFactory)
.setWorkerFactory(workerFactory)
.build()
)
// Prune the database every ~ 12 hours when the device is idle.
val pruneCacheWorker = PeriodicWorkRequestBuilder<PruneCacheWorker>(12, TimeUnit.HOURS)
.setConstraints(Constraints.Builder().setRequiresDeviceIdle(true).build())
.build()
WorkManager.getInstance(this).enqueueUniquePeriodicWork(
PruneCacheWorker.PERIODIC_WORK_TAG,
ExistingPeriodicWorkPolicy.KEEP,
pruneCacheWorker
)
}
override fun androidInjector() = androidInjector

View File

@ -39,6 +39,7 @@ import android.webkit.MimeTypeMap
import android.widget.Toast
import androidx.core.app.ShareCompat
import androidx.core.content.FileProvider
import androidx.core.content.IntentCompat
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.Lifecycle
import androidx.viewpager2.adapter.FragmentStateAdapter
@ -96,7 +97,7 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
supportPostponeEnterTransition()
// Gather the parameters.
attachments = intent.getParcelableArrayListExtra(EXTRA_ATTACHMENTS)
attachments = IntentCompat.getParcelableArrayListExtra(intent, EXTRA_ATTACHMENTS, AttachmentViewData::class.java)
val initialPosition = intent.getIntExtra(EXTRA_ATTACHMENT_INDEX, 0)
// Adapter is actually of existential type PageAdapter & SharedElementsTransitionListener

View File

@ -22,6 +22,7 @@ import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.databinding.ItemEditFieldBinding
import com.keylesspalace.tusky.entity.StringField
import com.keylesspalace.tusky.util.BindingHolder
import com.keylesspalace.tusky.util.fixTextSelection
class AccountFieldEditAdapter : RecyclerView.Adapter<BindingHolder<ItemEditFieldBinding>>() {
@ -81,12 +82,16 @@ class AccountFieldEditAdapter : RecyclerView.Adapter<BindingHolder<ItemEditField
}
holder.binding.accountFieldNameText.doAfterTextChanged { newText ->
fieldData[holder.bindingAdapterPosition].first = newText.toString()
fieldData.getOrNull(holder.bindingAdapterPosition)?.first = newText.toString()
}
holder.binding.accountFieldValueText.doAfterTextChanged { newText ->
fieldData[holder.bindingAdapterPosition].second = newText.toString()
fieldData.getOrNull(holder.bindingAdapterPosition)?.second = newText.toString()
}
// Ensure the textview contents are selectable
holder.binding.accountFieldNameText.fixTextSelection()
holder.binding.accountFieldValueText.fixTextSelection()
}
class MutableStringPair(var first: String, var second: String)

View File

@ -1,41 +0,0 @@
/* Copyright 2019 kyori19
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.adapter
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemPickerListBinding
import com.keylesspalace.tusky.entity.MastoList
class ListSelectionAdapter(context: Context) : ArrayAdapter<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 {
ItemPickerListBinding.bind(convertView)
}
getItem(position)?.let { list ->
binding.root.text = list.title
}
return binding.root
}
}

View File

@ -476,7 +476,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
if (replyCountLabel == null) return;
if (fullStats) {
replyCountLabel.setText(NumberUtils.shortNumber(repliesCount));
replyCountLabel.setText(NumberUtils.formatNumber(repliesCount, 1000));
return;
}

View File

@ -114,11 +114,11 @@ public class StatusViewHolder extends StatusBaseViewHolder {
}
protected void setReblogsCount(int reblogsCount) {
reblogsCountLabel.setText(NumberUtils.shortNumber(reblogsCount));
reblogsCountLabel.setText(NumberUtils.formatNumber(reblogsCount, 1000));
}
protected void setFavouritedCount(int favouritedCount) {
favouritedCountLabel.setText(NumberUtils.shortNumber(favouritedCount));
favouritedCountLabel.setText(NumberUtils.formatNumber(favouritedCount, 1000));
}
protected void hideStatusInfo() {

View File

@ -18,12 +18,16 @@ package com.keylesspalace.tusky.adapter
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.ViewGroup
import android.widget.CompoundButton.OnCheckedChangeListener
import androidx.core.view.size
import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
import com.google.android.material.chip.Chip
import com.keylesspalace.tusky.FEDERATED
import com.keylesspalace.tusky.HASHTAG
import com.keylesspalace.tusky.HOME
import com.keylesspalace.tusky.LIST
import com.keylesspalace.tusky.LOCAL
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.TabData
import com.keylesspalace.tusky.databinding.ItemTabPreferenceBinding
@ -40,6 +44,7 @@ interface ItemInteractionListener {
fun onStartDrag(viewHolder: RecyclerView.ViewHolder)
fun onActionChipClicked(tab: TabData, tabPosition: Int)
fun onChipClicked(tab: TabData, tabPosition: Int, chipPosition: Int)
fun onStreamingChanged(tab: TabData, tabPosition: Int, enabled: Boolean)
}
class TabAdapter(
@ -146,6 +151,18 @@ class TabAdapter(
} else {
binding.chipGroup.hide()
}
if (tab.id in arrayOf(HOME, LOCAL, FEDERATED, LIST)) {
binding.switchStreaming.show()
binding.switchStreaming.isChecked = tab.enableStreaming
binding.switchStreaming.setOnCheckedChangeListener { _, isChecked ->
listener.onStreamingChanged(tab, holder.bindingAdapterPosition, isChecked)
binding.switchStreaming.setOnCheckedChangeListener(null)
}
} else {
binding.switchStreaming.hide()
}
}
}

View File

@ -1,94 +0,0 @@
/* 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

@ -25,4 +25,4 @@ 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
data class StreamUpdateEvent(val status: Status, val subscription: Subscription, val streamId: Int) : Event

View File

@ -40,7 +40,6 @@ import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import java.io.IOException
import javax.inject.Inject
class ListsForAccountFragment : DialogFragment(), Injectable {
@ -103,16 +102,7 @@ class ListsForAccountFragment : DialogFragment(), Injectable {
binding.listsView.hide()
binding.messageView.apply {
show()
if (error is IOException) {
setup(R.drawable.elephant_offline, R.string.error_network) {
load()
}
} else {
setup(R.drawable.elephant_error, R.string.error_generic) {
load()
}
}
setup(error) { load() }
}
}
}

View File

@ -51,7 +51,6 @@ 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
/**
@ -133,12 +132,7 @@ class AccountMediaFragment :
}
is LoadState.Error -> {
binding.statusView.show()
if ((loadState.refresh as LoadState.Error).error is IOException) {
binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network, null)
} else {
binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic, null)
}
binding.statusView.setup((loadState.refresh as LoadState.Error).error)
}
is LoadState.Loading -> {
binding.progressBar.show()

View File

@ -393,16 +393,9 @@ class AccountListFragment :
if (adapter.itemCount == 0) {
binding.messageView.show()
if (throwable is IOException) {
binding.messageView.setup(R.drawable.elephant_offline, R.string.error_network) {
binding.messageView.hide()
this.fetchAccounts(null)
}
} else {
binding.messageView.setup(R.drawable.elephant_error, R.string.error_generic) {
binding.messageView.hide()
this.fetchAccounts(null)
}
binding.messageView.setup(throwable) {
binding.messageView.hide()
this.fetchAccounts(null)
}
}
}

View File

@ -54,7 +54,9 @@ import androidx.appcompat.app.AlertDialog
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.core.content.IntentCompat
import androidx.core.content.res.use
import androidx.core.os.BundleCompat
import androidx.core.view.ContentInfoCompat
import androidx.core.view.OnReceiveContentListener
import androidx.core.view.isGone
@ -79,6 +81,7 @@ import com.keylesspalace.tusky.adapter.LocaleAdapter
import com.keylesspalace.tusky.adapter.OnEmojiSelectedListener
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.components.compose.ComposeViewModel.ConfirmationKind
import com.keylesspalace.tusky.components.compose.dialog.CaptionDialog
import com.keylesspalace.tusky.components.compose.dialog.makeFocusDialog
import com.keylesspalace.tusky.components.compose.dialog.showAddPollDialog
@ -256,7 +259,7 @@ class ComposeActivity :
/* If the composer is started up as a reply to another post, override the "starting" state
* based on what the intent from the reply request passes. */
val composeOptions: ComposeOptions? = intent.getParcelableExtra(COMPOSE_OPTIONS_EXTRA)
val composeOptions: ComposeOptions? = IntentCompat.getParcelableExtra(intent, COMPOSE_OPTIONS_EXTRA, ComposeOptions::class.java)
viewModel.setup(composeOptions, useCachedData(preferences, composeOptions?.tootRightNow == true))
setupButtons()
@ -292,7 +295,7 @@ class ComposeActivity :
/* Finally, overwrite state with data from saved instance state. */
savedInstanceState?.let {
photoUploadUri = it.getParcelable(PHOTO_UPLOAD_URI_KEY)
photoUploadUri = BundleCompat.getParcelable(it, PHOTO_UPLOAD_URI_KEY, Uri::class.java)
(it.getSerializable(VISIBILITY_KEY) as Status.Visibility).apply {
setStatusVisibility(this)
@ -338,12 +341,12 @@ class ComposeActivity :
if (type.startsWith("image/") || type.startsWith("video/") || type.startsWith("audio/")) {
when (intent.action) {
Intent.ACTION_SEND -> {
intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)?.let { uri ->
IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)?.let { uri ->
pickMedia(uri)
}
}
Intent.ACTION_SEND_MULTIPLE -> {
intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)?.forEach { uri ->
IntentCompat.getParcelableArrayListExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)?.forEach { uri ->
pickMedia(uri)
}
}
@ -1017,7 +1020,10 @@ class ComposeActivity :
val split = contentInfo.partition { item: ClipData.Item -> item.uri != null }
split.first?.let { content ->
for (i in 0 until content.clip.itemCount) {
pickMedia(content.clip.getItemAt(i).uri)
pickMedia(
content.clip.getItemAt(i).uri,
contentInfo.clip.description.label as String?
)
}
}
return split.second
@ -1142,9 +1148,9 @@ class ComposeActivity :
viewModel.removeMediaFromQueue(item)
}
private fun pickMedia(uri: Uri) {
private fun pickMedia(uri: Uri, description: String? = null) {
lifecycleScope.launch {
viewModel.pickMedia(uri).onFailure { throwable ->
viewModel.pickMedia(uri, description).onFailure { throwable ->
val errorString = when (throwable) {
is FileSizeException -> {
val decimalFormat = DecimalFormat("0.##")
@ -1205,16 +1211,19 @@ class ComposeActivity :
private fun handleCloseButton() {
val contentText = binding.composeEditField.text.toString()
val contentWarning = binding.composeContentWarningField.text.toString()
if (viewModel.didChange(contentText, contentWarning)) {
when (viewModel.composeKind) {
ComposeKind.NEW -> getSaveAsDraftOrDiscardDialog(contentText, contentWarning)
ComposeKind.EDIT_DRAFT -> getUpdateDraftOrDiscardDialog(contentText, contentWarning)
ComposeKind.EDIT_POSTED -> getContinueEditingOrDiscardDialog()
ComposeKind.EDIT_SCHEDULED -> getContinueEditingOrDiscardDialog()
}.show()
} else {
viewModel.stopUploads()
finishWithoutSlideOutAnimation()
when (viewModel.handleCloseButton(contentText, contentWarning)) {
ConfirmationKind.NONE -> {
viewModel.stopUploads()
finishWithoutSlideOutAnimation()
}
ConfirmationKind.SAVE_OR_DISCARD ->
getSaveAsDraftOrDiscardDialog(contentText, contentWarning).show()
ConfirmationKind.UPDATE_OR_DISCARD ->
getUpdateDraftOrDiscardDialog(contentText, contentWarning).show()
ConfirmationKind.CONTINUE_EDITING_OR_DISCARD_CHANGES ->
getContinueEditingOrDiscardDialog().show()
ConfirmationKind.CONTINUE_EDITING_OR_DISCARD_DRAFT ->
getDeleteEmptyDraftOrContinueEditing().show()
}
}
@ -1279,6 +1288,23 @@ class ComposeActivity :
}
}
/**
* User is editing an existing draft and making it empty.
* The user can either delete the empty draft or go back to editing.
*/
private fun getDeleteEmptyDraftOrContinueEditing(): AlertDialog.Builder {
return AlertDialog.Builder(this)
.setMessage(R.string.compose_delete_draft)
.setPositiveButton(R.string.action_delete) { _, _ ->
viewModel.deleteDraft()
viewModel.stopUploads()
finishWithoutSlideOutAnimation()
}
.setNegativeButton(R.string.action_continue_edit) { _, _ ->
// Do nothing, dialog will dismiss, user can continue editing
}
}
private fun deleteDraftAndFinish() {
viewModel.deleteDraft()
finishWithoutSlideOutAnimation()

View File

@ -21,6 +21,7 @@ import androidx.core.net.toUri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.connyduck.calladapter.networkresult.fold
import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeKind
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
import com.keylesspalace.tusky.components.compose.ComposeAutoCompleteAdapter.AutocompleteResult
import com.keylesspalace.tusky.components.drafts.DraftHelper
@ -105,7 +106,7 @@ class ComposeViewModel @Inject constructor(
val domain = accountManager.activeAccount?.domain!!
lateinit var composeKind: ComposeActivity.ComposeKind
lateinit var composeKind: ComposeKind
// Used in ComposeActivity to pass state to result function when cropImage contract inflight
var cropImageItemOld: QueuedMedia? = null
@ -224,7 +225,28 @@ class ComposeViewModel @Inject constructor(
this.markMediaAsSensitive.value = this.markMediaAsSensitive.value != true
}
fun didChange(content: String?, contentWarning: String?): Boolean {
fun handleCloseButton(contentText: String?, contentWarning: String?): ConfirmationKind {
return if (didChange(contentText, contentWarning)) {
when (composeKind) {
ComposeKind.NEW -> if (isEmpty(contentText, contentWarning)) {
ConfirmationKind.NONE
} else {
ConfirmationKind.SAVE_OR_DISCARD
}
ComposeKind.EDIT_DRAFT -> if (isEmpty(contentText, contentWarning)) {
ConfirmationKind.CONTINUE_EDITING_OR_DISCARD_DRAFT
} else {
ConfirmationKind.UPDATE_OR_DISCARD
}
ComposeKind.EDIT_POSTED -> ConfirmationKind.CONTINUE_EDITING_OR_DISCARD_CHANGES
ComposeKind.EDIT_SCHEDULED -> ConfirmationKind.CONTINUE_EDITING_OR_DISCARD_CHANGES
}
} else {
ConfirmationKind.NONE
}
}
private fun didChange(content: String?, contentWarning: String?): Boolean {
val textChanged = content.orEmpty() != startingText.orEmpty()
val contentWarningChanged = contentWarning.orEmpty() != startingContentWarning
val mediaChanged = media.value.isNotEmpty()
@ -234,6 +256,10 @@ class ComposeViewModel @Inject constructor(
return modifiedInitialState || textChanged || contentWarningChanged || mediaChanged || pollChanged || didScheduledTimeChange
}
private fun isEmpty(content: String?, contentWarning: String?): Boolean {
return !modifiedInitialState && (content.isNullOrBlank() && contentWarning.isNullOrBlank() && media.value.isEmpty() && poll.value == null)
}
fun contentWarningChanged(value: Boolean) {
showContentWarning.value = value
contentWarningStateChanged = true
@ -402,7 +428,7 @@ class ComposeViewModel @Inject constructor(
return
}
composeKind = composeOptions?.kind ?: ComposeActivity.ComposeKind.NEW
composeKind = composeOptions?.kind ?: ComposeKind.NEW
val preferredVisibility = accountManager.activeAccount!!.defaultPostPrivacy
@ -507,6 +533,14 @@ class ComposeViewModel @Inject constructor(
private companion object {
const val TAG = "ComposeViewModel"
}
enum class ConfirmationKind {
NONE, // just close
SAVE_OR_DISCARD,
UPDATE_OR_DISCARD,
CONTINUE_EDITING_OR_DISCARD_CHANGES, // editing post
CONTINUE_EDITING_OR_DISCARD_DRAFT // edit draft
}
}
/**

View File

@ -27,6 +27,7 @@ import android.view.ViewGroup
import android.view.WindowManager
import android.widget.EditText
import androidx.appcompat.app.AlertDialog
import androidx.core.os.BundleCompat
import androidx.core.os.bundleOf
import androidx.fragment.app.DialogFragment
import com.bumptech.glide.Glide
@ -73,7 +74,7 @@ class CaptionDialog : DialogFragment() {
val window = dialog.window
window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
val previewUri = arguments?.getParcelable<Uri>(PREVIEW_URI_ARG) ?: error("Preview Uri is null")
val previewUri = BundleCompat.getParcelable(requireArguments(), PREVIEW_URI_ARG, Uri::class.java) ?: error("Preview Uri is null")
// Load the image and manually set it into the ImageView because it doesn't have a fixed size.
Glide.with(this)
.load(previewUri)

View File

@ -15,13 +15,13 @@
package com.keylesspalace.tusky.components.compose.dialog
import android.app.Activity
import android.content.DialogInterface
import android.graphics.drawable.Drawable
import android.net.Uri
import android.view.WindowManager
import android.widget.FrameLayout
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import com.bumptech.glide.Glide
@ -38,7 +38,7 @@ fun <T> T.makeFocusDialog(
existingFocus: Focus?,
previewUri: Uri,
onUpdateFocus: suspend (Focus) -> Unit
) where T : Activity, T : LifecycleOwner {
) where T : AppCompatActivity, T : LifecycleOwner {
val focus = existingFocus ?: Focus(0.0f, 0.0f) // Default to center
val dialogBinding = DialogFocusBinding.inflate(layoutInflater)

View File

@ -89,7 +89,7 @@ data class ConversationStatusEntity(
val bookmarked: Boolean,
val sensitive: Boolean,
val spoilerText: String,
val attachments: ArrayList<Attachment>,
val attachments: List<Attachment>,
val mentions: List<Status.Mention>,
val tags: List<HashTag>?,
val showingHiddenContent: Boolean,

View File

@ -65,7 +65,6 @@ import com.mikepenz.iconics.utils.sizeDp
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import java.io.IOException
import javax.inject.Inject
import kotlin.time.DurationUnit
import kotlin.time.toDuration
@ -141,16 +140,7 @@ class ConversationsFragment :
}
is LoadState.Error -> {
binding.statusView.show()
if ((loadState.refresh as LoadState.Error).error is IOException) {
binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) {
refreshContent()
}
} else {
binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic) {
refreshContent()
}
}
binding.statusView.setup((loadState.refresh as LoadState.Error).error) { refreshContent() }
}
is LoadState.Loading -> {
binding.progressBar.show()

View File

@ -7,9 +7,11 @@ import android.widget.AdapterView
import android.widget.ArrayAdapter
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.core.content.IntentCompat
import androidx.core.view.size
import androidx.core.widget.doAfterTextChanged
import androidx.lifecycle.lifecycleScope
import at.connyduck.calladapter.networkresult.fold
import com.google.android.material.chip.Chip
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.switchmaterial.SwitchMaterial
@ -23,7 +25,9 @@ import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.FilterKeyword
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible
import kotlinx.coroutines.launch
import retrofit2.HttpException
import java.util.Date
import javax.inject.Inject
@ -47,7 +51,7 @@ class EditFilterActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
originalFilter = intent?.getParcelableExtra(FILTER_TO_EDIT)
originalFilter = IntentCompat.getParcelableExtra(intent, FILTER_TO_EDIT, Filter::class.java)
filter = originalFilter ?: Filter("", "", listOf(), null, Filter.Action.WARN.action, listOf())
binding.apply {
contextSwitches = mapOf(
@ -77,6 +81,9 @@ class EditFilterActivity : BaseActivity() {
binding.actionChip.setOnClickListener { showAddKeywordDialog() }
binding.filterSaveButton.setOnClickListener { saveChanges() }
binding.filterDeleteButton.setOnClickListener { deleteFilter() }
binding.filterDeleteButton.visible(originalFilter != null)
for (switch in contextSwitches.keys) {
switch.setOnCheckedChangeListener { _, isChecked ->
val context = contextSwitches[switch]!!
@ -258,6 +265,32 @@ class EditFilterActivity : BaseActivity() {
}
}
private fun deleteFilter() {
originalFilter?.let { filter ->
lifecycleScope.launch {
api.deleteFilter(filter.id).fold(
{
finish()
},
{ throwable ->
if (throwable is HttpException && throwable.code() == 404) {
api.deleteFilterV1(filter.id).fold(
{
finish()
},
{
Snackbar.make(binding.root, "Error deleting filter '${filter.title}'", Snackbar.LENGTH_SHORT).show()
}
)
} else {
Snackbar.make(binding.root, "Error deleting filter '${filter.title}'", Snackbar.LENGTH_SHORT).show()
}
}
)
}
}
}
companion object {
const val FILTER_TO_EDIT = "FilterToEdit"

View File

@ -31,7 +31,6 @@ import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import java.io.IOException
import javax.inject.Inject
class FollowedTagsActivity :
@ -108,11 +107,7 @@ class FollowedTagsActivity :
binding.followedTagsView.hide()
binding.followedTagsMessageView.show()
val errorState = loadState.refresh as LoadState.Error
if (errorState.error is IOException) {
binding.followedTagsMessageView.setup(R.drawable.elephant_offline, R.string.error_network) { retry() }
} else {
binding.followedTagsMessageView.setup(R.drawable.elephant_error, R.string.error_generic) { retry() }
}
binding.followedTagsMessageView.setup(errorState.error) { retry() }
Log.w(TAG, "error loading followed hashtags", errorState.error)
} else {
binding.followedTagsView.show()

View File

@ -26,7 +26,6 @@ import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.view.EndlessOnScrollListener
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import kotlinx.coroutines.launch
import java.io.IOException
import javax.inject.Inject
class InstanceListFragment : Fragment(R.layout.fragment_instance_list), Injectable, InstanceActionListener {
@ -146,16 +145,9 @@ class InstanceListFragment : Fragment(R.layout.fragment_instance_list), Injectab
if (adapter.itemCount == 0) {
binding.messageView.show()
if (throwable is IOException) {
binding.messageView.setup(R.drawable.elephant_offline, R.string.error_network) {
binding.messageView.hide()
this.fetchInstances(null)
}
} else {
binding.messageView.setup(R.drawable.elephant_error, R.string.error_generic) {
binding.messageView.hide()
this.fetchInstances(null)
}
binding.messageView.setup(throwable) {
binding.messageView.hide()
this.fetchInstances(null)
}
}
}

View File

@ -33,6 +33,7 @@ import android.webkit.WebViewClient
import androidx.activity.result.contract.ActivityResultContract
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.core.content.IntentCompat
import androidx.core.net.toUri
import androidx.lifecycle.lifecycleScope
import com.keylesspalace.tusky.BaseActivity
@ -61,7 +62,9 @@ class OauthLogin : ActivityResultContract<LoginData, LoginResult>() {
return if (resultCode == Activity.RESULT_CANCELED) {
LoginResult.Cancel
} else {
intent!!.getParcelableExtra(RESULT_EXTRA)!!
intent?.let {
IntentCompat.getParcelableExtra(it, RESULT_EXTRA, LoginResult::class.java)
} ?: LoginResult.Err("failed parsing LoginWebViewActivity result")
}
}
@ -70,7 +73,7 @@ class OauthLogin : ActivityResultContract<LoginData, LoginResult>() {
private const val DATA_EXTRA = "data"
fun parseData(intent: Intent): LoginData {
return intent.getParcelableExtra(DATA_EXTRA)!!
return IntentCompat.getParcelableExtra(intent, DATA_EXTRA, LoginData::class.java)!!
}
fun makeResultIntent(result: LoginResult): Intent {

View File

@ -11,9 +11,10 @@ import com.keylesspalace.tusky.entity.Marker
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.isLessThan
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.delay
import javax.inject.Inject
import kotlin.math.min
import kotlin.time.Duration.Companion.milliseconds
/**
* Fetch Mastodon notifications and show Android notifications, with summaries, for them.
@ -29,19 +30,17 @@ class NotificationFetcher @Inject constructor(
private val accountManager: AccountManager,
private val context: Context
) {
fun fetchAndShow() {
suspend fun fetchAndShow() {
for (account in accountManager.getAllAccountsOrderedByActive()) {
if (account.notificationsEnabled) {
try {
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
// Create sorted list of new notifications
val notifications = runBlocking { // OK, because in a worker thread
fetchNewNotifications(account)
.filter { filterNotification(notificationManager, account, it) }
.sortedWith(compareBy({ it.id.length }, { it.id })) // oldest notifications first
.toMutableList()
}
val notifications = fetchNewNotifications(account)
.filter { filterNotification(notificationManager, account, it) }
.sortedWith(compareBy({ it.id.length }, { it.id })) // oldest notifications first
.toMutableList()
// There's a maximum limit on the number of notifications an Android app
// can display. If the total number of notifications (current notifications,
@ -82,7 +81,7 @@ class NotificationFetcher @Inject constructor(
// Android will rate limit / drop notifications if they're posted too
// quickly. There is no indication to the user that this happened.
// See https://github.com/tuskyapp/Tusky/pull/3626#discussion_r1192963664
Thread.sleep(1000)
delay(1000.milliseconds)
}
NotificationHelper.updateSummaryNotifications(

View File

@ -37,6 +37,7 @@ import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.core.app.NotificationCompat;
import androidx.core.app.RemoteInput;
import androidx.core.app.TaskStackBuilder;
@ -63,6 +64,7 @@ import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.receiver.SendStatusBroadcastReceiver;
import com.keylesspalace.tusky.util.StringUtils;
import com.keylesspalace.tusky.viewdata.PollViewDataKt;
import com.keylesspalace.tusky.worker.NotificationWorker;
import java.util.ArrayList;
import java.util.Collections;
@ -76,7 +78,12 @@ import java.util.concurrent.TimeUnit;
public class NotificationHelper {
private static int notificationId = 0;
/** ID of notification shown when fetching notifications */
public static final int NOTIFICATION_ID_FETCH_NOTIFICATION = 0;
/** ID of notification shown when pruning the cache */
public static final int NOTIFICATION_ID_PRUNE_CACHE = 1;
/** Dynamic notification IDs start here */
private static int notificationId = NOTIFICATION_ID_PRUNE_CACHE + 1;
/**
* constants used in Intents
@ -120,6 +127,7 @@ public class NotificationHelper {
public static final String CHANNEL_SIGN_UP = "CHANNEL_SIGN_UP";
public static final String CHANNEL_UPDATES = "CHANNEL_UPDATES";
public static final String CHANNEL_REPORT = "CHANNEL_REPORT";
public static final String CHANNEL_BACKGROUND_TASKS = "CHANNEL_BACKGROUND_TASKS";
/**
* WorkManager Tag
@ -471,6 +479,49 @@ public class NotificationHelper {
pendingIntentFlags(false));
}
/**
* Creates a notification channel for notifications for background work that should not
* disturb the user.
*
* @param context context
*/
public static void createWorkerNotificationChannel(@NonNull Context context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return;
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
NotificationChannel channel = new NotificationChannel(
CHANNEL_BACKGROUND_TASKS,
context.getString(R.string.notification_listenable_worker_name),
NotificationManager.IMPORTANCE_NONE
);
channel.setDescription(context.getString(R.string.notification_listenable_worker_description));
channel.enableLights(false);
channel.enableVibration(false);
channel.setShowBadge(false);
notificationManager.createNotificationChannel(channel);
}
/**
* Creates a notification for a background worker.
*
* @param context context
* @param titleResource String resource to use as the notification's title
* @return the notification
*/
@NonNull
public static android.app.Notification createWorkerNotification(@NonNull Context context, @StringRes int titleResource) {
String title = context.getString(titleResource);
return new NotificationCompat.Builder(context, CHANNEL_BACKGROUND_TASKS)
.setContentTitle(title)
.setTicker(title)
.setSmallIcon(R.drawable.ic_notify)
.setOngoing(true)
.build();
}
public static void createNotificationChannelsForAccount(@NonNull AccountEntity account, @NonNull Context context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {

View File

@ -1,51 +0,0 @@
/* Copyright 2020 Tusky Contributors
*
* This file is part of Tusky.
*
* Tusky is free software: you can redistribute it and/or modify it under the terms of the GNU
* Lesser General Public License as published by the Free Software Foundation, either version 3 of
* the License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser
* General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License along with Tusky. If
* not, see <http://www.gnu.org/licenses/>. */
package com.keylesspalace.tusky.components.notifications
import android.content.Context
import androidx.work.ListenableWorker
import androidx.work.Worker
import androidx.work.WorkerFactory
import androidx.work.WorkerParameters
import javax.inject.Inject
class NotificationWorker(
context: Context,
params: WorkerParameters,
private val notificationsFetcher: NotificationFetcher
) : Worker(context, params) {
override fun doWork(): Result {
notificationsFetcher.fetchAndShow()
return Result.success()
}
}
class NotificationWorkerFactory @Inject constructor(
private val notificationsFetcher: NotificationFetcher
) : WorkerFactory() {
override fun createWorker(
appContext: Context,
workerClassName: String,
workerParameters: WorkerParameters
): ListenableWorker? {
if (workerClassName == NotificationWorker::class.java.name) {
return NotificationWorker(appContext, workerParameters, notificationsFetcher)
}
return null
}
}

View File

@ -41,6 +41,7 @@ import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.NO_POSITION
import androidx.recyclerview.widget.RecyclerView.SCROLL_STATE_IDLE
import androidx.recyclerview.widget.SimpleItemAnimator
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener
@ -201,7 +202,7 @@ class NotificationsFragment :
// Save the ID of the first notification visible in the list, so the user's
// reading position is always restorable.
layoutManager.findFirstVisibleItemPosition().takeIf { it >= 0 }?.let { position ->
layoutManager.findFirstVisibleItemPosition().takeIf { it != NO_POSITION }?.let { position ->
adapter.snapshot().getOrNull(position)?.id?.let { id ->
viewModel.accept(InfallibleUiAction.SaveVisibleId(visibleId = id))
}
@ -267,7 +268,7 @@ class NotificationsFragment :
Log.d(TAG, error.toString())
val message = getString(
error.message,
error.exception.localizedMessage
error.throwable.localizedMessage
?: getString(R.string.ui_error_unknown)
)
val snackbar = Snackbar.make(
@ -453,6 +454,10 @@ class NotificationsFragment :
onRefresh()
true
}
R.id.load_newest -> {
viewModel.accept(InfallibleUiAction.LoadNewest)
true
}
else -> false
}
}

View File

@ -117,10 +117,6 @@ class NotificationsPagingAdapter(
)
}
init {
stateRestorationPolicy = StateRestorationPolicy.PREVENT_WHEN_EMPTY
}
override fun getItemViewType(position: Int): Int {
return NotificationViewKind.from(getItem(position)?.type).ordinal
}

View File

@ -204,8 +204,9 @@ class NotificationsPagingSource @Inject constructor(
override fun getRefreshKey(state: PagingState<String, Notification>): String? {
return state.anchorPosition?.let { anchorPosition ->
val anchorPage = state.closestPageToPosition(anchorPosition)
anchorPage?.prevKey ?: anchorPage?.nextKey
val id = state.closestItemToPosition(anchorPosition)?.id
Log.d(TAG, " getRefreshKey returning $id")
return id
}
}

View File

@ -25,6 +25,7 @@ import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
import androidx.paging.map
import at.connyduck.calladapter.networkresult.getOrThrow
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.appstore.BlockEvent
import com.keylesspalace.tusky.appstore.EventHub
@ -40,11 +41,13 @@ import com.keylesspalace.tusky.usecase.TimelineCases
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.util.deserialize
import com.keylesspalace.tusky.util.serialize
import com.keylesspalace.tusky.util.throttleFirst
import com.keylesspalace.tusky.util.toViewData
import com.keylesspalace.tusky.viewdata.NotificationViewData
import com.keylesspalace.tusky.viewdata.StatusViewData
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
@ -52,19 +55,22 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.getAndUpdate
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.await
import retrofit2.HttpException
import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.ExperimentalTime
data class UiState(
/** Filtered notification types */
@ -118,6 +124,12 @@ sealed class InfallibleUiAction : UiAction() {
* can do.
*/
data class SaveVisibleId(val visibleId: String) : InfallibleUiAction()
/** Ignore the saved reading position, load the page with the newest items */
// Resets the account's `lastNotificationId`, which can't fail, which is why this is
// infallible. Reloading the data may fail, but that's handled by the paging system /
// adapter refresh logic.
object LoadNewest : InfallibleUiAction()
}
/** Actions the user can trigger on an individual notification. These may fail. */
@ -218,7 +230,7 @@ sealed class StatusActionSuccess(open val action: StatusAction) : UiSuccess() {
/** Errors from fallible view model actions that the UI will need to show */
sealed class UiError(
/** The exception associated with the error */
open val exception: Exception,
open val throwable: Throwable,
/** String resource with an error message to show the user */
@StringRes val message: Int,
@ -226,55 +238,55 @@ sealed class UiError(
/** The action that failed. Can be resent to retry the action */
open val action: UiAction? = null
) {
data class ClearNotifications(override val exception: Exception) : UiError(
exception,
data class ClearNotifications(override val throwable: Throwable) : UiError(
throwable,
R.string.ui_error_clear_notifications
)
data class Bookmark(
override val exception: Exception,
override val throwable: Throwable,
override val action: StatusAction.Bookmark
) : UiError(exception, R.string.ui_error_bookmark, action)
) : UiError(throwable, R.string.ui_error_bookmark, action)
data class Favourite(
override val exception: Exception,
override val throwable: Throwable,
override val action: StatusAction.Favourite
) : UiError(exception, R.string.ui_error_favourite, action)
) : UiError(throwable, R.string.ui_error_favourite, action)
data class Reblog(
override val exception: Exception,
override val throwable: Throwable,
override val action: StatusAction.Reblog
) : UiError(exception, R.string.ui_error_reblog, action)
) : UiError(throwable, R.string.ui_error_reblog, action)
data class VoteInPoll(
override val exception: Exception,
override val throwable: Throwable,
override val action: StatusAction.VoteInPoll
) : UiError(exception, R.string.ui_error_vote, action)
) : UiError(throwable, R.string.ui_error_vote, action)
data class AcceptFollowRequest(
override val exception: Exception,
override val throwable: Throwable,
override val action: NotificationAction.AcceptFollowRequest
) : UiError(exception, R.string.ui_error_accept_follow_request, action)
) : UiError(throwable, R.string.ui_error_accept_follow_request, action)
data class RejectFollowRequest(
override val exception: Exception,
override val throwable: Throwable,
override val action: NotificationAction.RejectFollowRequest
) : UiError(exception, R.string.ui_error_reject_follow_request, action)
) : UiError(throwable, R.string.ui_error_reject_follow_request, action)
companion object {
fun make(exception: Exception, action: FallibleUiAction) = when (action) {
is StatusAction.Bookmark -> Bookmark(exception, action)
is StatusAction.Favourite -> Favourite(exception, action)
is StatusAction.Reblog -> Reblog(exception, action)
is StatusAction.VoteInPoll -> VoteInPoll(exception, action)
is NotificationAction.AcceptFollowRequest -> AcceptFollowRequest(exception, action)
is NotificationAction.RejectFollowRequest -> RejectFollowRequest(exception, action)
FallibleUiAction.ClearNotifications -> ClearNotifications(exception)
fun make(throwable: Throwable, action: FallibleUiAction) = when (action) {
is StatusAction.Bookmark -> Bookmark(throwable, action)
is StatusAction.Favourite -> Favourite(throwable, action)
is StatusAction.Reblog -> Reblog(throwable, action)
is StatusAction.VoteInPoll -> VoteInPoll(throwable, action)
is NotificationAction.AcceptFollowRequest -> AcceptFollowRequest(throwable, action)
is NotificationAction.RejectFollowRequest -> RejectFollowRequest(throwable, action)
FallibleUiAction.ClearNotifications -> ClearNotifications(throwable)
}
}
}
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class, ExperimentalTime::class)
class NotificationsViewModel @Inject constructor(
private val repository: NotificationsRepository,
private val preferences: SharedPreferences,
@ -295,15 +307,25 @@ class NotificationsViewModel @Inject constructor(
/** Flow of user actions received from the UI */
private val uiAction = MutableSharedFlow<UiAction>()
/** Flow that can be used to trigger a full reload */
private val reload = MutableStateFlow(0)
/** Flow of successful action results */
// Note: These are a SharedFlow instead of a StateFlow because success or error state does not
// need to be retained. A message is shown once to a user and then dismissed. Re-collecting the
// flow (e.g., after a device orientation change) should not re-show the most recent success or
// error message, as it will be confusing to the user.
// Note: This is a SharedFlow instead of a StateFlow because success state does not need to be
// retained. A message is shown once to a user and then dismissed. Re-collecting the flow
// (e.g., after a device orientation change) should not re-show the most recent success
// message, as it will be confusing to the user.
val uiSuccess = MutableSharedFlow<UiSuccess>()
/** Flow of transient errors for the UI to present */
val uiError = MutableSharedFlow<UiError>()
/** Channel for error results */
// Errors are sent to a channel to ensure that any errors that occur *before* there are any
// subscribers are retained. If this was a SharedFlow any errors would be dropped, and if it
// was a StateFlow any errors would be retained, and there would need to be an explicit
// mechanism to dismiss them.
private val _uiErrorChannel = Channel<UiError>()
/** Expose UI errors as a flow */
val uiError = _uiErrorChannel.receiveAsFlow()
/** Accept UI actions in to actionStateFlow */
val accept: (UiAction) -> Unit = { action ->
@ -330,6 +352,18 @@ class NotificationsViewModel @Inject constructor(
)
}
// Reset the last notification ID to "0" to fetch the newest notifications, and
// increment `reload` to trigger creation of a new PagingSource.
viewModelScope.launch {
uiAction
.filterIsInstance<InfallibleUiAction.LoadNewest>()
.collectLatest {
account.lastNotificationId = "0"
accountManager.saveAccount(account)
reload.getAndUpdate { it + 1 }
}
}
// Save the visible notification ID
viewModelScope.launch {
uiAction
@ -378,11 +412,11 @@ class NotificationsViewModel @Inject constructor(
if (this.isSuccessful) {
repository.invalidate()
} else {
uiError.emit(UiError.make(HttpException(this), it))
_uiErrorChannel.send(UiError.make(HttpException(this), it))
}
}
} catch (e: Exception) {
ifExpected(e) { uiError.emit(UiError.make(e, it)) }
ifExpected(e) { _uiErrorChannel.send(UiError.make(e, it)) }
}
}
}
@ -390,7 +424,7 @@ class NotificationsViewModel @Inject constructor(
// Handle NotificationAction.*
viewModelScope.launch {
uiAction.filterIsInstance<NotificationAction>()
.debounce(DEBOUNCE_TIMEOUT_MS)
.throttleFirst(THROTTLE_TIMEOUT)
.collect { action ->
try {
when (action) {
@ -401,7 +435,7 @@ class NotificationsViewModel @Inject constructor(
}
uiSuccess.emit(NotificationActionSuccess.from(action))
} catch (e: Exception) {
ifExpected(e) { uiError.emit(UiError.make(e, action)) }
ifExpected(e) { _uiErrorChannel.send(UiError.make(e, action)) }
}
}
}
@ -409,7 +443,7 @@ class NotificationsViewModel @Inject constructor(
// Handle StatusAction.*
viewModelScope.launch {
uiAction.filterIsInstance<StatusAction>()
.debounce(DEBOUNCE_TIMEOUT_MS) // avoid double-taps
.throttleFirst(THROTTLE_TIMEOUT) // avoid double-taps
.collect { action ->
try {
when (action) {
@ -434,10 +468,10 @@ class NotificationsViewModel @Inject constructor(
action.poll.id,
action.choices
)
}
}.getOrThrow()
uiSuccess.emit(StatusActionSuccess.from(action))
} catch (e: Exception) {
ifExpected(e) { uiError.emit(UiError.make(e, action)) }
} catch (t: Throwable) {
_uiErrorChannel.send(UiError.make(t, action))
}
}
}
@ -453,11 +487,12 @@ class NotificationsViewModel @Inject constructor(
}
}
pagingData = notificationFilter
// Re-fetch notifications if either of `notificationFilter` or `reload` flows have
// new items.
pagingData = combine(notificationFilter, reload) { action, _ -> action }
.flatMapLatest { action ->
getNotifications(filters = action.filter, initialKey = getInitialKey())
}
.cachedIn(viewModelScope)
}.cachedIn(viewModelScope)
uiState = combine(notificationFilter, getUiPrefs()) { filter, prefs ->
UiState(
@ -517,6 +552,6 @@ class NotificationsViewModel @Inject constructor(
companion object {
private const val TAG = "NotificationsViewModel"
private const val DEBOUNCE_TIMEOUT_MS = 500L
private val THROTTLE_TIMEOUT = 500.milliseconds
}
}

View File

@ -21,7 +21,6 @@ import android.os.Build
import android.os.Bundle
import android.util.Log
import androidx.annotation.DrawableRes
import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceFragmentCompat
import com.google.android.material.color.MaterialColors
import com.google.android.material.snackbar.Snackbar
@ -30,7 +29,6 @@ import com.keylesspalace.tusky.BuildConfig
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.TabPreferenceActivity
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.components.accountlist.AccountListActivity
import com.keylesspalace.tusky.components.filters.FiltersActivity
import com.keylesspalace.tusky.components.followedtags.FollowedTagsActivity
@ -42,7 +40,7 @@ import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.settings.AccountPreferenceHandler
import com.keylesspalace.tusky.settings.AccountPreferenceDataStore
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.settings.listPreference
import com.keylesspalace.tusky.settings.makePreferenceScreen
@ -58,7 +56,6 @@ import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizeRes
import kotlinx.coroutines.launch
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
@ -74,6 +71,9 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
@Inject
lateinit var eventHub: EventHub
@Inject
lateinit var accountPreferenceDataStore: AccountPreferenceDataStore
private val iconSize by unsafeLazy { resources.getDimensionPixelSize(R.dimen.preference_icon_size) }
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
@ -245,27 +245,26 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
preferenceCategory(R.string.pref_title_timelines) {
// TODO having no activeAccount in this fragment does not really make sense, enforce it?
// All other locations here make it optional, however.
val accountPreferenceHandler = AccountPreferenceHandler(accountManager.activeAccount!!, accountManager, ::dispatchEvent)
switchPreference {
key = PrefKeys.MEDIA_PREVIEW_ENABLED
setTitle(R.string.pref_title_show_media_preview)
isSingleLineTitle = false
preferenceDataStore = accountPreferenceHandler
preferenceDataStore = accountPreferenceDataStore
}
switchPreference {
key = PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA
setTitle(R.string.pref_title_alway_show_sensitive_media)
isSingleLineTitle = false
preferenceDataStore = accountPreferenceHandler
preferenceDataStore = accountPreferenceDataStore
}
switchPreference {
key = PrefKeys.ALWAYS_OPEN_SPOILER
setTitle(R.string.pref_title_alway_open_spoiler)
isSingleLineTitle = false
preferenceDataStore = accountPreferenceHandler
preferenceDataStore = accountPreferenceDataStore
}
}
}
@ -353,12 +352,6 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
activity?.overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left)
}
private fun dispatchEvent(event: PreferenceChangedEvent) {
lifecycleScope.launch {
eventHub.dispatch(event)
}
}
companion object {
fun newInstance() = AccountPreferencesFragment()
}

View File

@ -95,7 +95,9 @@ class PreferencesActivity :
}
onBackPressedDispatcher.addCallback(this, restartActivitiesOnBackPressedCallback)
restartActivitiesOnBackPressedCallback.isEnabled = savedInstanceState?.getBoolean(EXTRA_RESTART_ON_BACK, false) ?: false
restartActivitiesOnBackPressedCallback.isEnabled = intent.extras?.getBoolean(
EXTRA_RESTART_ON_BACK
) ?: savedInstanceState?.getBoolean(EXTRA_RESTART_ON_BACK, false) ?: false
}
override fun onPreferenceStartFragment(
@ -151,6 +153,10 @@ class PreferencesActivity :
restartActivitiesOnBackPressedCallback.isEnabled = true
this.restartCurrentActivity()
}
PrefKeys.UI_TEXT_SCALE_RATIO -> {
restartActivitiesOnBackPressedCallback.isEnabled = true
this.restartCurrentActivity()
}
"statusTextSize", "absoluteTimeView", "showBotOverlay", "animateGifAvatars", "useBlurhash",
"showSelfUsername", "showCardsInTimelines", "confirmReblogs", "confirmFavourites",
"enableSwipeForTabs", "mainNavPosition", PrefKeys.HIDE_TOP_TOOLBAR, PrefKeys.SHOW_STATS_INLINE, "viewPagerOffScreenLimit" -> {
@ -175,7 +181,8 @@ class PreferencesActivity :
override fun androidInjector() = androidInjector
companion object {
@Suppress("unused")
private const val TAG = "PreferencesActivity"
const val GENERAL_PREFERENCES = 0
const val ACCOUNT_PREFERENCES = 1
const val NOTIFICATION_PREFERENCES = 2

View File

@ -32,6 +32,7 @@ import com.keylesspalace.tusky.settings.listPreference
import com.keylesspalace.tusky.settings.makePreferenceScreen
import com.keylesspalace.tusky.settings.preference
import com.keylesspalace.tusky.settings.preferenceCategory
import com.keylesspalace.tusky.settings.sliderPreference
import com.keylesspalace.tusky.settings.switchPreference
import com.keylesspalace.tusky.util.LocaleManager
import com.keylesspalace.tusky.util.deserialize
@ -105,6 +106,19 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
preferenceDataStore = localeManager
}
sliderPreference {
key = PrefKeys.UI_TEXT_SCALE_RATIO
setDefaultValue(100F)
valueTo = 150F
valueFrom = 50F
stepSize = 5F
setTitle(R.string.pref_ui_text_size)
format = "%.0f%%"
decrementIcon = makeIcon(GoogleMaterial.Icon.gmd_zoom_out)
incrementIcon = makeIcon(GoogleMaterial.Icon.gmd_zoom_in)
icon = makeIcon(GoogleMaterial.Icon.gmd_format_size)
}
listPreference {
setDefaultValue("medium")
setEntries(R.array.post_text_size_names)

View File

@ -47,7 +47,6 @@ 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
class ScheduledStatusActivity :
@ -102,15 +101,7 @@ class ScheduledStatusActivity :
binding.errorMessageView.show()
val errorState = loadState.refresh as LoadState.Error
if (errorState.error is IOException) {
binding.errorMessageView.setup(R.drawable.elephant_offline, R.string.error_network) {
refreshStatuses()
}
} else {
binding.errorMessageView.setup(R.drawable.elephant_error, R.string.error_generic) {
refreshStatuses()
}
}
binding.errorMessageView.setup(errorState.error) { refreshStatuses() }
}
if (loadState.refresh != LoadState.Loading) {
binding.swipeRefreshLayout.isRefreshing = false

View File

@ -40,7 +40,7 @@ import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
import javax.inject.Inject
class SearchActivity : BottomSheetActivity(), HasAndroidInjector, MenuProvider {
class SearchActivity : BottomSheetActivity(), HasAndroidInjector, MenuProvider, SearchView.OnQueryTextListener {
@Inject
lateinit var androidInjector: DispatchingAndroidInjector<Any>
@ -92,8 +92,6 @@ class SearchActivity : BottomSheetActivity(), HasAndroidInjector, MenuProvider {
searchViewMenuItem.expandActionView()
val searchView = searchViewMenuItem.actionView as SearchView
setupSearchView(searchView)
searchView.setQuery(viewModel.currentQuery, false)
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
@ -152,9 +150,23 @@ class SearchActivity : BottomSheetActivity(), HasAndroidInjector, MenuProvider {
val pxBuffer = ((48 * 2) * resources.displayMetrics.density).toInt()
searchView.maxWidth = pxScreenWidth - pxBuffer
// Keep text that was entered also when switching to a different tab (before the search is executed)
searchView.setOnQueryTextListener(this)
searchView.setQuery(viewModel.currentSearchFieldContent ?: "", false)
searchView.requestFocus()
}
override fun onQueryTextSubmit(query: String?): Boolean {
return false
}
override fun onQueryTextChange(newText: String?): Boolean {
viewModel.currentSearchFieldContent = newText
return false
}
override fun androidInjector() = androidInjector
companion object {

View File

@ -49,12 +49,10 @@ class SearchViewModel @Inject constructor(
) : ViewModel() {
var currentQuery: String = ""
var currentSearchFieldContent: String? = null
var activeAccount: AccountEntity?
val activeAccount: AccountEntity?
get() = accountManager.activeAccount
set(value) {
accountManager.activeAccount = value
}
val mediaPreviewEnabled = activeAccount?.mediaPreviewEnabled ?: false
val alwaysShowSensitiveMedia = activeAccount?.alwaysShowSensitiveMedia ?: false

View File

@ -15,15 +15,28 @@
package com.keylesspalace.tusky.components.search.fragments
import android.os.Bundle
import android.view.View
import androidx.paging.PagingData
import androidx.paging.PagingDataAdapter
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.DividerItemDecoration
import com.keylesspalace.tusky.components.search.adapter.SearchAccountsAdapter
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.settings.PrefKeys
import kotlinx.coroutines.flow.Flow
class SearchAccountsFragment : SearchFragment<TimelineAccount>() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.searchRecyclerView.addItemDecoration(
DividerItemDecoration(
binding.searchRecyclerView.context,
DividerItemDecoration.VERTICAL
)
)
}
override fun createAdapter(): PagingDataAdapter<TimelineAccount, *> {
val preferences = PreferenceManager.getDefaultSharedPreferences(binding.searchRecyclerView.context)

View File

@ -13,7 +13,6 @@ import androidx.lifecycle.lifecycleScope
import androidx.paging.LoadState
import androidx.paging.PagingData
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.SimpleItemAnimator
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
@ -129,7 +128,6 @@ abstract class SearchFragment<T : Any> :
}
private fun initAdapter() {
binding.searchRecyclerView.addItemDecoration(DividerItemDecoration(binding.searchRecyclerView.context, DividerItemDecoration.VERTICAL))
binding.searchRecyclerView.layoutManager = LinearLayoutManager(binding.searchRecyclerView.context)
adapter = createAdapter()
binding.searchRecyclerView.adapter = adapter

View File

@ -15,8 +15,11 @@
package com.keylesspalace.tusky.components.search.fragments
import android.os.Bundle
import android.view.View
import androidx.paging.PagingData
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DividerItemDecoration
import com.keylesspalace.tusky.components.search.adapter.SearchHashtagsAdapter
import com.keylesspalace.tusky.entity.HashTag
import kotlinx.coroutines.flow.Flow
@ -26,6 +29,16 @@ class SearchHashtagsFragment : SearchFragment<HashTag>() {
override val data: Flow<PagingData<HashTag>>
get() = viewModel.hashtagsFlow
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.searchRecyclerView.addItemDecoration(
DividerItemDecoration(
binding.searchRecyclerView.context,
DividerItemDecoration.VERTICAL
)
)
}
override fun createAdapter(): PagingDataAdapter<HashTag, *> = SearchHashtagsAdapter(this)
companion object {

View File

@ -82,8 +82,6 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import net.accelf.yuito.streaming.StreamingManager
import java.io.IOException
import java.util.concurrent.TimeUnit
import javax.inject.Inject
@ -103,9 +101,6 @@ class TimelineFragment :
@Inject
lateinit var eventHub: EventHub
@Inject
lateinit var streamingManager: StreamingManager
private val viewModel: TimelineViewModel by unsafeLazy {
if (kind == TimelineViewModel.Kind.HOME) {
ViewModelProvider(this, viewModelFactory)[CachedTimelineViewModel::class.java]
@ -180,10 +175,8 @@ class TimelineFragment :
kind,
id,
tags,
arguments.getBoolean(ARG_ENABLE_STREAMING),
)
if (arguments.getBoolean(ARG_ENABLE_STREAMING)) {
setStreamingEnabled(true)
}
isSwipeToRefreshEnabled = arguments.getBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true)
@ -255,16 +248,7 @@ class TimelineFragment :
}
is LoadState.Error -> {
binding.statusView.show()
if ((loadState.refresh as LoadState.Error).error is IOException) {
binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) {
onRefresh()
}
} else {
binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic) {
onRefresh()
}
}
binding.statusView.setup((loadState.refresh as LoadState.Error).error) { onRefresh() }
}
is LoadState.Loading -> {
binding.progressBar.show()
@ -428,23 +412,6 @@ class TimelineFragment :
binding.recyclerView.adapter = adapter
}
override fun onStart() {
super.onStart()
viewModel.isFirstOfStreaming = true
}
fun setStreamingEnabled(to: Boolean) {
viewModel.isStreamingEnabled = to
if (to) {
streamingManager.subscribe(viewModel.subscription)
viewModel.isFirstOfStreaming = true
} else {
streamingManager.unsubscribe(viewModel.subscription)
}
}
override fun onRefresh() {
binding.statusView.hide()

View File

@ -51,14 +51,11 @@ import com.keylesspalace.tusky.util.EmptyPagingSource
import com.keylesspalace.tusky.viewdata.StatusViewData
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import retrofit2.HttpException
import javax.inject.Inject
import kotlin.time.DurationUnit
import kotlin.time.toDuration
/**
* TimelineViewModel that caches all statuses in a local database
@ -108,16 +105,6 @@ class CachedTimelineViewModel @Inject constructor(
.flowOn(Dispatchers.Default)
.cachedIn(viewModelScope)
init {
viewModelScope.launch {
delay(5.toDuration(DurationUnit.SECONDS)) // delay so the db is not locked during initial ui refresh
accountManager.activeAccount?.id?.let { accountId ->
db.timelineDao().cleanup(accountId, MAX_STATUSES_IN_CACHE)
db.timelineDao().cleanupAccounts(accountId)
}
}
}
override fun updatePoll(newPoll: Poll, status: StatusViewData.Concrete) {
// handled by CacheUpdater
}
@ -283,15 +270,15 @@ class CachedTimelineViewModel @Inject constructor(
// handled by CacheUpdater
}
override fun handleStreamUpdateEvent(status: Status) {
override fun handleStreamUpdateEvent(status: Status, streamId: Int) {
viewModelScope.launch {
val timelineDao = db.timelineDao()
val activeAccount = accountManager.activeAccount!!
db.withTransaction {
if (isFirstOfStreaming) {
if (streamId != currentStreamId) {
timelineDao.insertStatus(Placeholder(status.id, loading = false).toEntity(activeAccount.id))
isFirstOfStreaming = false
currentStreamId = streamId
return@withTransaction
}

View File

@ -243,13 +243,13 @@ class NetworkTimelineViewModel @Inject constructor(
}
}
override fun handleStreamUpdateEvent(status: Status) {
override fun handleStreamUpdateEvent(status: Status, streamId: Int) {
viewModelScope.launch {
val activeAccount = accountManager.activeAccount!!
if (isFirstOfStreaming) {
if (streamId != currentStreamId) {
statusData.add(0, StatusViewData.Placeholder(status.id, isLoading = false))
isFirstOfStreaming = false
currentStreamId = streamId
} else {
statusData.add(
0,

View File

@ -73,6 +73,7 @@ abstract class TimelineViewModel(
private set
var tags: List<String> = emptyList()
private set
private var isStreamingEnabled = false
protected var alwaysShowSensitiveMedia = false
private var alwaysOpenSpoilers = false
@ -97,7 +98,7 @@ abstract class TimelineViewModel(
}
}
var isFirstOfStreaming = false
var currentStreamId: Int = 0
val subscription by lazy {
when (kind) {
Kind.HOME -> Subscription(StreamType.USER)
@ -115,16 +116,17 @@ abstract class TimelineViewModel(
}
}
}
var isStreamingEnabled = false
fun init(
kind: Kind,
id: String?,
tags: List<String>,
isStreamingEnabled: Boolean,
) {
this.kind = kind
this.id = id
this.tags = tags
this.isStreamingEnabled = isStreamingEnabled
filterModel.kind = kind.toFilterKind()
if (kind == Kind.HOME) {
@ -219,7 +221,7 @@ abstract class TimelineViewModel(
abstract fun handlePinEvent(pinEvent: PinEvent)
abstract fun handleStreamUpdateEvent(status: Status)
abstract fun handleStreamUpdateEvent(status: Status, streamId: Int)
abstract fun fullReload()
@ -286,7 +288,7 @@ abstract class TimelineViewModel(
is PinEvent -> handlePinEvent(event)
is StreamUpdateEvent -> {
if (isStreamingEnabled && event.subscription == subscription) {
handleStreamUpdateEvent(event.status)
handleStreamUpdateEvent(event.status, event.streamId)
}
}
is MuteConversationEvent -> fullReload()

View File

@ -19,23 +19,19 @@ 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.BaseActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.databinding.ActivityTrendingBinding
import com.keylesspalace.tusky.util.viewBinding
import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
import javax.inject.Inject
class TrendingActivity : BottomSheetActivity(), HasAndroidInjector {
class TrendingActivity : BaseActivity(), HasAndroidInjector {
@Inject
lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector<Any>
@Inject
lateinit var eventHub: EventHub
private val binding: ActivityTrendingBinding by viewBinding(ActivityTrendingBinding::inflate)
override fun onCreate(savedInstanceState: Bundle?) {
@ -44,10 +40,8 @@ class TrendingActivity : BottomSheetActivity(), HasAndroidInjector {
setSupportActionBar(binding.includedToolbar.toolbar)
val title = getString(R.string.title_public_trending_hashtags)
supportActionBar?.run {
setTitle(title)
setTitle(R.string.title_public_trending_hashtags)
setDisplayHomeAsUpEnabled(true)
setDisplayShowHomeEnabled(true)
}
@ -63,10 +57,6 @@ class TrendingActivity : BottomSheetActivity(), HasAndroidInjector {
override fun androidInjector() = dispatchingAndroidInjector
companion object {
const val TAG = "TrendingActivity"
@JvmStatic
fun getIntent(context: Context) =
Intent(context, TrendingActivity::class.java)
fun getIntent(context: Context) = Intent(context, TrendingActivity::class.java)
}
}

View File

@ -20,15 +20,12 @@ import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.adapter.TrendingDateViewHolder
import com.keylesspalace.tusky.adapter.TrendingTagViewHolder
import com.keylesspalace.tusky.databinding.ItemTrendingCellBinding
import com.keylesspalace.tusky.databinding.ItemTrendingDateBinding
import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.viewdata.TrendingViewData
class TrendingAdapter(
private val trendingListener: LinkListener
private val onViewTag: (String) -> Unit
) : ListAdapter<TrendingViewData, RecyclerView.ViewHolder>(TrendingDifferCallback) {
init {
@ -42,7 +39,6 @@ class TrendingAdapter(
ItemTrendingCellBinding.inflate(LayoutInflater.from(viewGroup.context))
TrendingTagViewHolder(binding)
}
else -> {
val binding =
ItemTrendingDateBinding.inflate(LayoutInflater.from(viewGroup.context))
@ -52,38 +48,15 @@ class TrendingAdapter(
}
override fun onBindViewHolder(viewHolder: RecyclerView.ViewHolder, position: Int) {
bindViewHolder(viewHolder, position, null)
}
override fun onBindViewHolder(
viewHolder: RecyclerView.ViewHolder,
position: Int,
payloads: List<*>
) {
bindViewHolder(viewHolder, position, payloads)
}
private fun bindViewHolder(
viewHolder: RecyclerView.ViewHolder,
position: Int,
payloads: List<*>?
) {
when (val header = getItem(position)) {
when (val viewData = getItem(position)) {
is TrendingViewData.Tag -> {
val maxTrendingValue = currentList
.flatMap { trendingViewData ->
trendingViewData.asTagOrNull()?.tag?.history.orEmpty()
}
.mapNotNull { it.uses.toLongOrNull() }
.maxOrNull() ?: 1
val holder = viewHolder as TrendingTagViewHolder
holder.setup(header, maxTrendingValue, trendingListener)
holder.setup(viewData, onViewTag)
}
is TrendingViewData.Header -> {
val holder = viewHolder as TrendingDateViewHolder
holder.setup(header.start, header.end)
holder.setup(viewData.start, viewData.end)
}
}
}
@ -112,14 +85,7 @@ class TrendingAdapter(
oldItem: TrendingViewData,
newItem: TrendingViewData
): Boolean {
return false
}
override fun getChangePayload(
oldItem: TrendingViewData,
newItem: TrendingViewData
): Any? {
return null
return oldItem == newItem
}
}
}

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.adapter
package com.keylesspalace.tusky.components.trending
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R

View File

@ -15,17 +15,14 @@
package com.keylesspalace.tusky.components.trending
import android.content.Context
import android.content.res.Configuration
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.accessibility.AccessibilityManager
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProvider
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.GridLayoutManager.SpanSizeLookup
@ -33,18 +30,14 @@ import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.SimpleItemAnimator
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener
import at.connyduck.sparkbutton.helpers.Utils
import com.keylesspalace.tusky.BottomSheetActivity
import com.keylesspalace.tusky.PostLookupFallbackBehavior
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.StatusListActivity
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.components.trending.viewmodel.TrendingViewModel
import com.keylesspalace.tusky.databinding.FragmentTrendingBinding
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.interfaces.ActionButtonActivity
import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.interfaces.RefreshableFragment
import com.keylesspalace.tusky.interfaces.ReselectableFragment
import com.keylesspalace.tusky.util.hide
@ -56,48 +49,20 @@ import kotlinx.coroutines.launch
import javax.inject.Inject
class TrendingFragment :
Fragment(),
Fragment(R.layout.fragment_trending),
OnRefreshListener,
LinkListener,
Injectable,
ReselectableFragment,
RefreshableFragment {
private lateinit var bottomSheetActivity: BottomSheetActivity
@Inject
lateinit var viewModelFactory: ViewModelFactory
@Inject
lateinit var accountManager: AccountManager
@Inject
lateinit var eventHub: EventHub
private val viewModel: TrendingViewModel by lazy {
ViewModelProvider(this, viewModelFactory)[TrendingViewModel::class.java]
}
private val viewModel: TrendingViewModel by viewModels { viewModelFactory }
private val binding by viewBinding(FragmentTrendingBinding::bind)
private lateinit var adapter: TrendingAdapter
override fun onAttach(context: Context) {
super.onAttach(context)
bottomSheetActivity = if (context is BottomSheetActivity) {
context
} else {
throw IllegalStateException("Fragment must be attached to a BottomSheetActivity!")
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
adapter = TrendingAdapter(
this
)
}
private val adapter = TrendingAdapter(::onViewTag)
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
@ -106,14 +71,6 @@ class TrendingFragment :
setupLayoutManager(columnCount)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_trending, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
setupSwipeRefreshLayout()
setupRecyclerView()
@ -175,25 +132,19 @@ class TrendingFragment :
}
override fun onRefresh() {
viewModel.invalidate()
viewModel.invalidate(true)
}
override fun onViewUrl(url: String, text: String) {
bottomSheetActivity.viewUrl(url, PostLookupFallbackBehavior.OPEN_IN_BROWSER, text)
}
override fun onViewTag(tag: String) {
bottomSheetActivity.startActivityWithSlideInAnimation(StatusListActivity.newHashtagIntent(requireContext(), tag))
}
override fun onViewAccount(id: String) {
bottomSheetActivity.viewAccount(id)
fun onViewTag(tag: String) {
(requireActivity() as BaseActivity).startActivityWithSlideInAnimation(StatusListActivity.newHashtagIntent(requireContext(), tag))
}
private fun processViewState(uiState: TrendingViewModel.TrendingUiState) {
Log.d(TAG, uiState.loadingState.name)
when (uiState.loadingState) {
TrendingViewModel.LoadingState.INITIAL -> clearLoadingState()
TrendingViewModel.LoadingState.LOADING -> applyLoadingState()
TrendingViewModel.LoadingState.REFRESHING -> applyRefreshingState()
TrendingViewModel.LoadingState.LOADED -> applyLoadedState(uiState.trendingViewData)
TrendingViewModel.LoadingState.ERROR_NETWORK -> networkError()
TrendingViewModel.LoadingState.ERROR_OTHER -> otherError()
@ -203,8 +154,9 @@ class TrendingFragment :
private fun applyLoadedState(viewData: List<TrendingViewData>) {
clearLoadingState()
adapter.submitList(viewData)
if (viewData.isEmpty()) {
adapter.submitList(emptyList())
binding.recyclerView.hide()
binding.messageView.show()
binding.messageView.setup(
@ -213,16 +165,16 @@ class TrendingFragment :
null
)
} else {
val viewDataWithDates = listOf(viewData.first().asHeaderOrNull()) + viewData
adapter.submitList(viewDataWithDates)
binding.recyclerView.show()
binding.messageView.hide()
}
binding.progressBar.hide()
}
private fun applyRefreshingState() {
binding.swipeRefreshLayout.isRefreshing = true
}
private fun applyLoadingState() {
binding.recyclerView.hide()
binding.messageView.hide()
@ -297,8 +249,6 @@ class TrendingFragment :
companion object {
private const val TAG = "TrendingFragment"
fun newInstance(): TrendingFragment {
return TrendingFragment()
}
fun newInstance() = TrendingFragment()
}
}

View File

@ -0,0 +1,57 @@
/* Copyright 2023 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.trending
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemTrendingCellBinding
import com.keylesspalace.tusky.util.formatNumber
import com.keylesspalace.tusky.viewdata.TrendingViewData
class TrendingTagViewHolder(
private val binding: ItemTrendingCellBinding
) : RecyclerView.ViewHolder(binding.root) {
fun setup(
tagViewData: TrendingViewData.Tag,
onViewTag: (String) -> Unit
) {
binding.tag.text = binding.root.context.getString(R.string.title_tag, tagViewData.name)
binding.graph.maxTrendingValue = tagViewData.maxTrendingValue
binding.graph.primaryLineData = tagViewData.usage
binding.graph.secondaryLineData = tagViewData.accounts
binding.totalUsage.text = formatNumber(tagViewData.usage.sum(), 1000)
val totalAccounts = tagViewData.accounts.sum()
binding.totalAccounts.text = formatNumber(totalAccounts, 1000)
binding.currentUsage.text = tagViewData.usage.last().toString()
binding.currentAccounts.text = tagViewData.usage.last().toString()
itemView.setOnClickListener {
onViewTag(tagViewData.name)
}
itemView.contentDescription =
itemView.context.getString(
R.string.accessibility_talking_about_tag,
totalAccounts,
tagViewData.name
)
}
}

View File

@ -15,11 +15,15 @@
package com.keylesspalace.tusky.components.trending.viewmodel
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.connyduck.calladapter.networkresult.fold
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.end
import com.keylesspalace.tusky.entity.start
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.toViewData
import com.keylesspalace.tusky.viewdata.TrendingViewData
@ -28,7 +32,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.launch
import okio.IOException
import java.io.IOException
import javax.inject.Inject
class TrendingViewModel @Inject constructor(
@ -36,7 +40,7 @@ class TrendingViewModel @Inject constructor(
private val eventHub: EventHub
) : ViewModel() {
enum class LoadingState {
INITIAL, LOADING, LOADED, ERROR_NETWORK, ERROR_OTHER
INITIAL, LOADING, REFRESHING, LOADED, ERROR_NETWORK, ERROR_OTHER
}
data class TrendingUiState(
@ -67,37 +71,47 @@ class TrendingViewModel @Inject constructor(
*
* A tag is excluded if it is filtered by the user on their home timeline.
*/
fun invalidate() = viewModelScope.launch {
_uiState.value = TrendingUiState(emptyList(), LoadingState.LOADING)
try {
val deferredFilters = async { mastodonApi.getFilters() }
val response = mastodonApi.trendingTags()
if (!response.isSuccessful) {
_uiState.value = TrendingUiState(emptyList(), LoadingState.ERROR_NETWORK)
return@launch
}
val homeFilters = deferredFilters.await().getOrNull()?.filter {
it.context.contains(Filter.Kind.HOME.kind)
}
val tags = response.body()!!
.filter {
homeFilters?.none { filter ->
filter.keywords.any { keyword -> keyword.keyword.equals(it.name, ignoreCase = true) }
} ?: false
}
.sortedBy { tag -> tag.history.sumOf { it.uses.toLongOrNull() ?: 0 } }
.map { it.toViewData() }
.asReversed()
_uiState.value = TrendingUiState(tags, LoadingState.LOADED)
} catch (e: IOException) {
_uiState.value = TrendingUiState(emptyList(), LoadingState.ERROR_NETWORK)
} catch (e: Exception) {
_uiState.value = TrendingUiState(emptyList(), LoadingState.ERROR_OTHER)
fun invalidate(refresh: Boolean = false) = viewModelScope.launch {
if (refresh) {
_uiState.value = TrendingUiState(emptyList(), LoadingState.REFRESHING)
} else {
_uiState.value = TrendingUiState(emptyList(), LoadingState.LOADING)
}
val deferredFilters = async { mastodonApi.getFilters() }
mastodonApi.trendingTags().fold(
{ tagResponse ->
val firstTag = tagResponse.firstOrNull()
_uiState.value = if (firstTag == null) {
TrendingUiState(emptyList(), LoadingState.LOADED)
} else {
val homeFilters = deferredFilters.await().getOrNull()?.filter { filter ->
filter.context.contains(Filter.Kind.HOME.kind)
}
val tags = tagResponse
.filter { tag ->
homeFilters?.none { filter ->
filter.keywords.any { keyword -> keyword.keyword.equals(tag.name, ignoreCase = true) }
} ?: false
}
.sortedByDescending { tag -> tag.history.sumOf { it.uses.toLongOrNull() ?: 0 } }
.toViewData()
val header = TrendingViewData.Header(firstTag.start(), firstTag.end())
TrendingUiState(listOf(header) + tags, LoadingState.LOADED)
}
},
{ error ->
Log.w(TAG, "failed loading trending tags", error)
if (error is IOException) {
_uiState.value = TrendingUiState(emptyList(), LoadingState.ERROR_NETWORK)
} else {
_uiState.value = TrendingUiState(emptyList(), LoadingState.ERROR_OTHER)
}
}
)
}
companion object {

View File

@ -61,7 +61,6 @@ import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.io.IOException
import javax.inject.Inject
class ViewThreadFragment :
@ -203,21 +202,7 @@ class ViewThreadFragment :
binding.recyclerView.hide()
binding.statusView.show()
if (uiState.throwable is IOException) {
binding.statusView.setup(
R.drawable.elephant_offline,
R.string.error_network
) {
viewModel.retry(thisThreadsStatusId)
}
} else {
binding.statusView.setup(
R.drawable.elephant_error,
R.string.error_generic
) {
viewModel.retry(thisThreadsStatusId)
}
}
binding.statusView.setup(uiState.throwable) { viewModel.retry(thisThreadsStatusId) }
}
is ThreadUiState.Success -> {
if (uiState.statusViewData.none { viewData -> viewData.isDetailed }) {

View File

@ -1,8 +1,6 @@
package com.keylesspalace.tusky.components.viewthread.edits
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Typeface.DEFAULT_BOLD
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable
@ -11,7 +9,9 @@ import android.text.Html
import android.text.Spannable
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.style.ReplacementSpan
import android.text.TextPaint
import android.text.style.CharacterStyle
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@ -33,11 +33,9 @@ import com.keylesspalace.tusky.util.aspectRatios
import com.keylesspalace.tusky.util.decodeBlurHash
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.loadAvatar
import com.keylesspalace.tusky.util.parseAsMastodonHtml
import com.keylesspalace.tusky.util.setClickableText
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.unicodeWrap
import com.keylesspalace.tusky.util.visible
import com.keylesspalace.tusky.viewdata.toViewData
import org.xml.sax.XMLReader
@ -52,13 +50,28 @@ class ViewEditsAdapter(
private val absoluteTimeFormatter = AbsoluteTimeFormatter()
/** Size of large text in this theme, in px */
var largeTextSizePx: Float = 0f
/** Size of medium text in this theme, in px */
var mediumTextSizePx: Float = 0f
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): BindingHolder<ItemStatusEditBinding> {
val binding = ItemStatusEditBinding.inflate(LayoutInflater.from(parent.context), parent, false)
binding.statusEditMediaPreview.clipToOutline = true
val typedValue = TypedValue()
val context = binding.root.context
val displayMetrics = context.resources.displayMetrics
context.theme.resolveAttribute(R.attr.status_text_large, typedValue, true)
largeTextSizePx = typedValue.getDimension(displayMetrics)
context.theme.resolveAttribute(R.attr.status_text_medium, typedValue, true)
mediumTextSizePx = typedValue.getDimension(displayMetrics)
return BindingHolder(binding)
}
@ -69,24 +82,26 @@ class ViewEditsAdapter(
val context = binding.root.context
val avatarRadius: Int = context.resources
.getDimensionPixelSize(R.dimen.avatar_radius_48dp)
loadAvatar(edit.account.avatar, binding.statusEditAvatar, avatarRadius, animateAvatars)
val infoStringRes = if (position == edits.size - 1) {
val infoStringRes = if (position == edits.lastIndex) {
R.string.status_created_info
} else {
R.string.status_edit_info
}
// Show the most recent version of the status using large text to make it clearer for
// the user, and for similarity with thread view.
val variableTextSize = if (position == edits.lastIndex) {
mediumTextSizePx
} else {
largeTextSizePx
}
binding.statusEditContentWarningDescription.setTextSize(TypedValue.COMPLEX_UNIT_PX, variableTextSize)
binding.statusEditContent.setTextSize(TypedValue.COMPLEX_UNIT_PX, variableTextSize)
binding.statusEditMediaSensitivity.setTextSize(TypedValue.COMPLEX_UNIT_PX, variableTextSize)
val timestamp = absoluteTimeFormatter.format(edit.createdAt, false)
binding.statusEditInfo.text = context.getString(
infoStringRes,
edit.account.name.unicodeWrap(),
timestamp
).emojify(edit.account.emojis, binding.statusEditInfo, animateEmojis)
binding.statusEditInfo.text = context.getString(infoStringRes, timestamp)
if (edit.spoilerText.isEmpty()) {
binding.statusEditContentWarningDescription.hide()
@ -198,6 +213,11 @@ class ViewEditsAdapter(
}
override fun getItemCount() = edits.size
companion object {
private const val VIEW_TYPE_EDITS_NEWEST = 0
private const val VIEW_TYPE_EDITS = 1
}
}
/**
@ -266,98 +286,31 @@ class TuskyTagHandler(val context: Context) : Html.TagHandler {
}
}
/**
* A span that draws text with additional padding at the start/end of the text. The padding
* is the width of [separator].
*
* Note: The separator string is not included in the final text, so it will not be included
* if the user cuts or copies the text.
*/
open class LRPaddedSpan(val separator: String = " ") : ReplacementSpan() {
/** The width of the separator string, used as padding */
var paddingWidth = 0f
/** Measured width of the span */
var spanWidth = 0f
override fun getSize(
paint: Paint,
text: CharSequence?,
start: Int,
end: Int,
fm: Paint.FontMetricsInt?
): Int {
paddingWidth = paint.measureText(separator, 0, separator.length)
spanWidth = (paddingWidth * 2) + paint.measureText(text, start, end)
return spanWidth.toInt()
}
override fun draw(
canvas: Canvas,
text: CharSequence?,
start: Int,
end: Int,
x: Float,
top: Int,
y: Int,
bottom: Int,
paint: Paint
) {
canvas.drawText(text?.subSequence(start, end).toString(), x + paddingWidth, y.toFloat(), paint)
}
}
/** Span that signifies deleted text */
class DeletedTextSpan(context: Context) : LRPaddedSpan() {
private val bgPaint = Paint()
val radius: Float
class DeletedTextSpan(context: Context) : CharacterStyle() {
private var bgColor: Int
init {
bgPaint.color = context.getColor(R.color.view_edits_background_delete)
radius = context.resources.getDimension(R.dimen.lrPaddedSpanRadius)
bgColor = context.getColor(R.color.view_edits_background_delete)
}
override fun draw(
canvas: Canvas,
text: CharSequence?,
start: Int,
end: Int,
x: Float,
top: Int,
y: Int,
bottom: Int,
paint: Paint
) {
canvas.drawRoundRect(x, top.toFloat(), x + spanWidth, bottom.toFloat(), radius, radius, bgPaint)
paint.isStrikeThruText = true
super.draw(canvas, text, start, end, x, top, y, bottom, paint)
override fun updateDrawState(tp: TextPaint) {
tp.bgColor = bgColor
tp.isStrikeThruText = true
}
}
/** Span that signifies inserted text */
class InsertedTextSpan(context: Context) : LRPaddedSpan() {
val bgPaint = Paint()
val radius: Float
class InsertedTextSpan(context: Context) : CharacterStyle() {
private var bgColor: Int
init {
bgPaint.color = context.getColor(R.color.view_edits_background_insert)
radius = context.resources.getDimension(R.dimen.lrPaddedSpanRadius)
bgColor = context.getColor(R.color.view_edits_background_insert)
}
override fun draw(
canvas: Canvas,
text: CharSequence?,
start: Int,
end: Int,
x: Float,
top: Int,
y: Int,
bottom: Int,
paint: Paint
) {
canvas.drawRoundRect(x, top.toFloat(), x + spanWidth, bottom.toFloat(), radius, radius, bgPaint)
paint.typeface = DEFAULT_BOLD
super.draw(canvas, text, start, end, x, top, y, bottom, paint)
override fun updateDrawState(tp: TextPaint) {
tp.bgColor = bgColor
tp.typeface = DEFAULT_BOLD
}
}

View File

@ -37,24 +37,26 @@ import com.keylesspalace.tusky.BottomSheetActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.StatusListActivity
import com.keylesspalace.tusky.components.account.AccountActivity
import com.keylesspalace.tusky.databinding.FragmentViewThreadBinding
import com.keylesspalace.tusky.databinding.FragmentViewEditsBinding
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.loadAvatar
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.unicodeWrap
import com.keylesspalace.tusky.util.viewBinding
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizeDp
import kotlinx.coroutines.launch
import java.io.IOException
import javax.inject.Inject
class ViewEditsFragment :
Fragment(R.layout.fragment_view_thread),
Fragment(R.layout.fragment_view_edits),
LinkListener,
OnRefreshListener,
MenuProvider,
@ -65,7 +67,7 @@ class ViewEditsFragment :
private val viewModel: ViewEditsViewModel by viewModels { viewModelFactory }
private val binding by viewBinding(FragmentViewThreadBinding::bind)
private val binding by viewBinding(FragmentViewEditsBinding::bind)
private lateinit var statusId: String
@ -88,6 +90,7 @@ class ViewEditsFragment :
val animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false)
val animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
val useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true)
val avatarRadius: Int = requireContext().resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp)
viewLifecycleOwner.lifecycleScope.launch {
viewModel.uiState.collect { uiState ->
@ -107,13 +110,17 @@ class ViewEditsFragment :
binding.statusView.show()
binding.initialProgressBar.hide()
if (uiState.throwable is IOException) {
binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) {
viewModel.loadEdits(statusId, force = true)
when (uiState.throwable) {
is ViewEditsViewModel.MissingEditsException -> {
binding.statusView.setup(
R.drawable.elephant_friend_empty,
R.string.error_missing_edits
)
}
} else {
binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic) {
viewModel.loadEdits(statusId, force = true)
else -> {
binding.statusView.setup(uiState.throwable) {
viewModel.loadEdits(statusId, force = true)
}
}
}
}
@ -130,6 +137,15 @@ class ViewEditsFragment :
useBlurhash = useBlurhash,
listener = this@ViewEditsFragment
)
// Focus on the most recent version
(binding.recyclerView.layoutManager as LinearLayoutManager).scrollToPosition(0)
val account = uiState.edits.first().account
loadAvatar(account.avatar, binding.statusAvatar, avatarRadius, animateAvatars)
binding.statusDisplayName.text = account.name.unicodeWrap().emojify(account.emojis, binding.statusDisplayName, animateEmojis)
binding.statusUsername.text = account.username
}
}
}

View File

@ -17,7 +17,7 @@ package com.keylesspalace.tusky.components.viewthread.edits
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.connyduck.calladapter.networkresult.fold
import at.connyduck.calladapter.networkresult.getOrElse
import com.keylesspalace.tusky.components.viewthread.edits.TuskyTagHandler.Companion.DELETED_TEXT_EL
import com.keylesspalace.tusky.components.viewthread.edits.TuskyTagHandler.Companion.INSERTED_TEXT_EL
import com.keylesspalace.tusky.entity.StatusEdit
@ -48,6 +48,9 @@ class ViewEditsViewModel @Inject constructor(private val api: MastodonApi) : Vie
private val _uiState: MutableStateFlow<EditsUiState> = MutableStateFlow(EditsUiState.Initial)
val uiState: StateFlow<EditsUiState> = _uiState.asStateFlow()
/** The API call to fetch edit history returned less than two items */
object MissingEditsException : Exception()
fun loadEdits(statusId: String, force: Boolean = false, refreshing: Boolean = false) {
if (!force && _uiState.value !is EditsUiState.Initial) return
@ -58,66 +61,68 @@ class ViewEditsViewModel @Inject constructor(private val api: MastodonApi) : Vie
}
viewModelScope.launch {
api.statusEdits(statusId).fold(
{ edits ->
// Diff each status' content against the previous version, producing new
// content with additional `ins` or `del` elements marking inserted or
// deleted content.
//
// This can be CPU intensive depending on the number of edits and the size
// of each, so don't run this on Dispatchers.Main.
viewModelScope.launch(Dispatchers.Default) {
val sortedEdits = edits.sortedBy { it.createdAt }
.reversed()
.toMutableList()
val edits = api.statusEdits(statusId).getOrElse {
_uiState.value = EditsUiState.Error(it)
return@launch
}
SAXLoader.setXMLReaderClass("org.xmlpull.v1.sax2.Driver")
val loader = SAXLoader()
loader.config = DiffConfig(
false,
WhiteSpaceProcessing.PRESERVE,
TextGranularity.SPACE_WORD
// `edits` might have fewer than the minimum number of entries because of
// https://github.com/mastodon/mastodon/issues/25398.
if (edits.size < 2) {
_uiState.value = EditsUiState.Error(MissingEditsException)
return@launch
}
// Diff each status' content against the previous version, producing new
// content with additional `ins` or `del` elements marking inserted or
// deleted content.
//
// This can be CPU intensive depending on the number of edits and the size
// of each, so don't run this on Dispatchers.Main.
viewModelScope.launch(Dispatchers.Default) {
val sortedEdits = edits.sortedBy { it.createdAt }
.reversed()
.toMutableList()
SAXLoader.setXMLReaderClass("org.xmlpull.v1.sax2.Driver")
val loader = SAXLoader()
loader.config = DiffConfig(
false,
WhiteSpaceProcessing.PRESERVE,
TextGranularity.SPACE_WORD
)
val processor = OptimisticXMLProcessor()
processor.setCoalesce(true)
val output = HtmlDiffOutput()
try {
// The XML processor expects `br` to be closed
var currentContent =
loader.load(sortedEdits[0].content.replace("<br>", "<br/>"))
var previousContent =
loader.load(sortedEdits[1].content.replace("<br>", "<br/>"))
for (i in 1 until sortedEdits.size) {
processor.diff(previousContent, currentContent, output)
sortedEdits[i - 1] = sortedEdits[i - 1].copy(
content = output.xml.toString()
)
val processor = OptimisticXMLProcessor()
processor.setCoalesce(true)
val output = HtmlDiffOutput()
try {
// The XML processor expects `br` to be closed
var currentContent =
loader.load(sortedEdits[0].content.replace("<br>", "<br/>"))
var previousContent =
loader.load(sortedEdits[1].content.replace("<br>", "<br/>"))
for (i in 1 until sortedEdits.size) {
processor.diff(previousContent, currentContent, output)
sortedEdits[i - 1] = sortedEdits[i - 1].copy(
content = output.xml.toString()
)
if (i < sortedEdits.size - 1) {
currentContent = previousContent
previousContent = loader.load(
sortedEdits[i + 1].content.replace(
"<br>",
"<br/>"
)
)
}
}
_uiState.value = EditsUiState.Success(sortedEdits)
} catch (_: LoadingException) {
// Something failed parsing the XML from the server. Rather than
// show an error just return the sorted edits so the user can at
// least visually scan the differences.
_uiState.value = EditsUiState.Success(sortedEdits)
if (i < sortedEdits.size - 1) {
currentContent = previousContent
previousContent = loader.load(
sortedEdits[i + 1].content.replace("<br>", "<br/>")
)
}
}
},
{ throwable ->
_uiState.value = EditsUiState.Error(throwable)
_uiState.value = EditsUiState.Success(sortedEdits)
} catch (_: LoadingException) {
// Something failed parsing the XML from the server. Rather than
// show an error just return the sorted edits so the user can at
// least visually scan the differences.
_uiState.value = EditsUiState.Success(sortedEdits)
}
)
}
}
}

View File

@ -37,9 +37,11 @@ class AccountManager @Inject constructor(db: AppDatabase) {
@Volatile
var activeAccount: AccountEntity? = null
private set
var accounts: MutableList<AccountEntity> = mutableListOf()
private set
private val accountDao: AccountDao = db.accountDao()
init {

View File

@ -16,6 +16,7 @@
package com.keylesspalace.tusky.db;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.room.AutoMigration;
import androidx.room.Database;
import androidx.room.DeleteColumn;
@ -50,11 +51,11 @@ import java.io.File;
)
public abstract class AppDatabase extends RoomDatabase {
public abstract AccountDao accountDao();
public abstract InstanceDao instanceDao();
public abstract ConversationsDao conversationDao();
public abstract TimelineDao timelineDao();
public abstract DraftDao draftDao();
@NonNull public abstract AccountDao accountDao();
@NonNull public abstract InstanceDao instanceDao();
@NonNull public abstract ConversationsDao conversationDao();
@NonNull public abstract TimelineDao timelineDao();
@NonNull public abstract DraftDao draftDao();
public static final Migration MIGRATION_2_3 = new Migration(2, 3) {
@Override
@ -386,7 +387,7 @@ public abstract class AppDatabase extends RoomDatabase {
private final File oldDraftDirectory;
public Migration25_26(File oldDraftDirectory) {
public Migration25_26(@Nullable File oldDraftDirectory) {
super(25, 26);
this.oldDraftDirectory = oldDraftDirectory;
}

View File

@ -106,8 +106,8 @@ class Converters @Inject constructor(
}
@TypeConverter
fun jsonToAttachmentList(attachmentListJson: String?): ArrayList<Attachment>? {
return gson.fromJson(attachmentListJson, object : TypeToken<ArrayList<Attachment>>() {}.type)
fun jsonToAttachmentList(attachmentListJson: String?): List<Attachment>? {
return gson.fromJson(attachmentListJson, object : TypeToken<List<Attachment>>() {}.type)
}
@TypeConverter

View File

@ -56,15 +56,12 @@ abstract class ActivitiesModule {
@ContributesAndroidInjector
abstract fun contributesBaseActivity(): BaseActivity
@ActivityScope
@ContributesAndroidInjector(modules = [FragmentBuildersModule::class])
abstract fun contributesMainActivity(): MainActivity
@ActivityScope
@ContributesAndroidInjector(modules = [FragmentBuildersModule::class])
abstract fun contributesAccountActivity(): AccountActivity
@ActivityScope
@ContributesAndroidInjector(modules = [FragmentBuildersModule::class])
abstract fun contributesListsActivity(): ListsActivity
@ -74,19 +71,15 @@ abstract class ActivitiesModule {
@ContributesAndroidInjector
abstract fun contributesEditProfileActivity(): EditProfileActivity
@ActivityScope
@ContributesAndroidInjector(modules = [FragmentBuildersModule::class])
abstract fun contributesAccountListActivity(): AccountListActivity
@ActivityScope
@ContributesAndroidInjector(modules = [FragmentBuildersModule::class])
abstract fun contributesViewThreadActivity(): ViewThreadActivity
@ActivityScope
@ContributesAndroidInjector(modules = [FragmentBuildersModule::class])
abstract fun contributesStatusListActivity(): StatusListActivity
@ActivityScope
@ContributesAndroidInjector(modules = [FragmentBuildersModule::class])
abstract fun contributesSearchActivity(): SearchActivity
@ -99,7 +92,6 @@ abstract class ActivitiesModule {
@ContributesAndroidInjector
abstract fun contributesLoginWebViewActivity(): LoginWebViewActivity
@ActivityScope
@ContributesAndroidInjector(modules = [FragmentBuildersModule::class])
abstract fun contributesPreferencesActivity(): PreferencesActivity
@ -118,11 +110,9 @@ abstract class ActivitiesModule {
@ContributesAndroidInjector
abstract fun contributesFollowedTagsActivity(): FollowedTagsActivity
@ActivityScope
@ContributesAndroidInjector(modules = [FragmentBuildersModule::class])
abstract fun contributesReportActivity(): ReportActivity
@ActivityScope
@ContributesAndroidInjector(modules = [FragmentBuildersModule::class])
abstract fun contributesInstanceListActivity(): InstanceListActivity
@ -138,7 +128,6 @@ abstract class ActivitiesModule {
@ContributesAndroidInjector
abstract fun contributesSplashActivity(): SplashActivity
@ActivityScope
@ContributesAndroidInjector(modules = [FragmentBuildersModule::class])
abstract fun contributesTrendingActivity(): TrendingActivity

View File

@ -1,7 +0,0 @@
package com.keylesspalace.tusky.di
import javax.inject.Scope
@Scope
@Retention(AnnotationRetention.RUNTIME)
annotation class ActivityScope

View File

@ -29,12 +29,14 @@ import javax.inject.Singleton
@Component(
modules = [
AppModule::class,
CoroutineScopeModule::class,
NetworkModule::class,
AndroidSupportInjectionModule::class,
ActivitiesModule::class,
ServicesModule::class,
BroadcastReceiverModule::class,
ViewModelModule::class
ViewModelModule::class,
WorkerModule::class
]
)
interface AppComponent {

View File

@ -0,0 +1,44 @@
/*
* Copyright 2023 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>.
*/
package com.keylesspalace.tusky.di
import dagger.Module
import dagger.Provides
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import javax.inject.Qualifier
/**
* Scope for potentially long-running tasks that should outlive the viewmodel that
* started them. For example, if the API call to bookmark a status is taking a long
* time, that call should not be cancelled because the user has navigated away from
* the viewmodel that made the call.
*
* @see https://developer.android.com/topic/architecture/data-layer#make_an_operation_live_longer_than_the_screen
*/
@Retention(AnnotationRetention.BINARY)
@Qualifier
annotation class ApplicationScope
@Module
class CoroutineScopeModule {
@ApplicationScope
@Provides
fun providesApplicationScope() = CoroutineScope(SupervisorJob() + Dispatchers.Default)
}

View File

@ -0,0 +1,45 @@
/*
* Copyright 2023 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>.
*/
package com.keylesspalace.tusky.di
import androidx.work.ListenableWorker
import com.keylesspalace.tusky.worker.ChildWorkerFactory
import com.keylesspalace.tusky.worker.NotificationWorker
import com.keylesspalace.tusky.worker.PruneCacheWorker
import dagger.Binds
import dagger.MapKey
import dagger.Module
import dagger.multibindings.IntoMap
import kotlin.reflect.KClass
@Retention(AnnotationRetention.RUNTIME)
@MapKey
annotation class WorkerKey(val value: KClass<out ListenableWorker>)
@Module
abstract class WorkerModule {
@Binds
@IntoMap
@WorkerKey(NotificationWorker::class)
internal abstract fun bindNotificationWorkerFactory(worker: NotificationWorker.Factory): ChildWorkerFactory
@Binds
@IntoMap
@WorkerKey(PruneCacheWorker::class)
internal abstract fun bindPruneCacheWorkerFactory(worker: PruneCacheWorker.Factory): ChildWorkerFactory
}

View File

@ -16,7 +16,6 @@
package com.keylesspalace.tusky.entity
import com.google.gson.annotations.SerializedName
import java.util.ArrayList
import java.util.Date
data class DeletedStatus(
@ -25,7 +24,7 @@ data class DeletedStatus(
@SerializedName("spoiler_text") val spoilerText: String,
val visibility: Status.Visibility,
val sensitive: Boolean,
@SerializedName("media_attachments") val attachments: ArrayList<Attachment>?,
@SerializedName("media_attachments") val attachments: List<Attachment>?,
val poll: Poll?,
@SerializedName("created_at") val createdAt: Date,
val language: String?

View File

@ -41,7 +41,7 @@ data class Status(
val sensitive: Boolean,
@SerializedName("spoiler_text", alternate = ["summary"]) val spoilerText: String,
val visibility: Visibility,
@SerializedName("media_attachments", alternate = ["attachment"]) val attachments: ArrayList<Attachment>,
@SerializedName("media_attachments", alternate = ["attachment"]) val attachments: List<Attachment>,
@SerializedName("mentions", alternate = ["tag"]) val mentions: List<Mention>,
val tags: List<HashTag>?,
val application: Application?,

View File

@ -21,15 +21,13 @@ import java.util.Date
* Mastodon API Documentation: https://docs.joinmastodon.org/methods/trends/#tags
*
* @param name The name of the hashtag (after the #). The "caturday" in "#caturday".
* @param url The URL to your mastodon instance list for this hashtag.
* (@param url The URL to your mastodon instance list for this hashtag.)
* @param history A list of [TrendingTagHistory]. Each element contains metrics per day for this hashtag.
* @param following This is not listed in the APIs at the time of writing, but an instance is delivering it.
* (@param following This is not listed in the APIs at the time of writing, but an instance is delivering it.)
*/
data class TrendingTag(
val name: String,
val url: String,
val history: List<TrendingTagHistory>,
val following: Boolean
val history: List<TrendingTagHistory>
)
/**

View File

@ -26,6 +26,7 @@ import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import androidx.core.os.BundleCompat
import com.bumptech.glide.Glide
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.engine.GlideException
@ -92,7 +93,7 @@ class ViewImageFragment : ViewMediaFragment() {
super.onViewCreated(view, savedInstanceState)
val arguments = this.requireArguments()
val attachment = arguments.getParcelable<Attachment>(ARG_ATTACHMENT)
val attachment = BundleCompat.getParcelable(arguments, ARG_ATTACHMENT, Attachment::class.java)
this.shouldStartTransition = arguments.getBoolean(ARG_START_POSTPONED_TRANSITION)
val url: String?
var description: String? = null

View File

@ -782,5 +782,5 @@ interface MastodonApi {
suspend fun unfollowTag(@Path("name") name: String): NetworkResult<HashTag>
@GET("api/v1/trends/tags")
suspend fun trendingTags(): Response<List<TrendingTag>>
suspend fun trendingTags(): NetworkResult<List<TrendingTag>>
}

View File

@ -20,11 +20,11 @@ import android.content.Intent
import android.util.Log
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager
import com.keylesspalace.tusky.components.notifications.NotificationWorker
import com.keylesspalace.tusky.components.notifications.registerUnifiedPushEndpoint
import com.keylesspalace.tusky.components.notifications.unregisterUnifiedPushEndpoint
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.worker.NotificationWorker
import dagger.android.AndroidInjection
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope

View File

@ -16,6 +16,7 @@ import android.util.Log
import androidx.annotation.StringRes
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.IntentCompat
import at.connyduck.calladapter.networkresult.fold
import com.keylesspalace.tusky.MainActivity
import com.keylesspalace.tusky.R
@ -83,7 +84,7 @@ class SendStatusService : Service(), Injectable {
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
if (intent.hasExtra(KEY_STATUS)) {
val statusToSend: StatusToSend = intent.getParcelableExtra(KEY_STATUS)
val statusToSend: StatusToSend = IntentCompat.getParcelableExtra(intent, KEY_STATUS, StatusToSend::class.java)
?: throw IllegalStateException("SendStatusService started without $KEY_STATUS extra")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {

View File

@ -1,15 +1,21 @@
package com.keylesspalace.tusky.settings
import androidx.preference.PreferenceDataStore
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.di.ApplicationScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
class AccountPreferenceHandler(
private val account: AccountEntity,
class AccountPreferenceDataStore @Inject constructor(
private val accountManager: AccountManager,
private val dispatchEvent: (PreferenceChangedEvent) -> Unit
private val eventHub: EventHub,
@ApplicationScope private val externalScope: CoroutineScope
) : PreferenceDataStore() {
private val account: AccountEntity = accountManager.activeAccount!!
override fun getBoolean(key: String, defValue: Boolean): Boolean {
return when (key) {
@ -29,6 +35,8 @@ class AccountPreferenceHandler(
accountManager.saveAccount(account)
dispatchEvent(PreferenceChangedEvent(key))
externalScope.launch {
eventHub.dispatch(PreferenceChangedEvent(key))
}
}
}

View File

@ -111,4 +111,7 @@ object PrefKeys {
const val TAB_FILTER_HOME_REPLIES = "tabFilterHomeReplies_v2" // This was changed once to reset an unintentionally set default.
const val TAB_FILTER_HOME_BOOSTS = "tabFilterHomeBoosts"
/** UI text scaling factor, stored as float, 100 = 100% = no scaling */
const val UI_TEXT_SCALE_RATIO = "uiTextScaleRatio"
}

View File

@ -14,6 +14,7 @@ import androidx.preference.PreferenceCategory
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.PreferenceScreen
import androidx.preference.SwitchPreference
import com.keylesspalace.tusky.view.SliderPreference
import de.c1710.filemojicompat_ui.views.picker.preference.EmojiPickerPreference
class PreferenceParent(
@ -43,6 +44,15 @@ inline fun <A> PreferenceParent.emojiPreference(activity: A, builder: EmojiPicke
return pref
}
inline fun PreferenceParent.sliderPreference(
builder: SliderPreference.() -> Unit
): SliderPreference {
val pref = SliderPreference(context)
builder(pref)
addPref(pref)
return pref
}
inline fun PreferenceParent.switchPreference(
builder: SwitchPreference.() -> Unit
): SwitchPreference {

View File

@ -84,11 +84,26 @@ class EmojiSpan(val viewWeakReference: WeakReference<View>) : ReplacementSpan()
imageDrawable?.let { drawable ->
canvas.save()
val emojiSize = (paint.textSize * 1.1).toInt()
drawable.setBounds(0, 0, emojiSize, emojiSize)
// start with a width relative to the text size
var emojiWidth = paint.textSize * 1.1
var transY = bottom - drawable.bounds.bottom
transY -= paint.fontMetricsInt.descent / 2
// calculate the height, keeping the aspect ratio correct
val drawableWidth = drawable.intrinsicWidth
val drawableHeight = drawable.intrinsicHeight
var emojiHeight = emojiWidth / drawableWidth * drawableHeight
// how much vertical space there is draw the emoji
val drawableSpace = (bottom - top).toDouble()
// in case the calculated height is bigger than the available space, scale the emoji down, preserving aspect ratio
if (emojiHeight > drawableSpace) {
emojiWidth *= drawableSpace / emojiHeight
emojiHeight = drawableSpace
}
drawable.setBounds(0, 0, emojiWidth.toInt(), emojiHeight.toInt())
// vertically center the emoji in the line
val transY = top + (drawableSpace / 2 - emojiHeight / 2)
canvas.translate(x, transY.toFloat())
drawable.draw(canvas)

View File

@ -0,0 +1,69 @@
/*
* Copyright 2023 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>.
*/
package com.keylesspalace.tusky.util
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlin.time.Duration
import kotlin.time.ExperimentalTime
import kotlin.time.TimeMark
import kotlin.time.TimeSource
/**
* Returns a flow that mirrors the original flow, but filters out values that occur within
* [timeout] of the previously emitted value. The first value is always emitted.
*
* Example:
*
* ```kotlin
* flow {
* emit(1)
* delay(90.milliseconds)
* emit(2)
* delay(90.milliseconds)
* emit(3)
* delay(1010.milliseconds)
* emit(4)
* delay(1010.milliseconds)
* emit(5)
* }.throttleFirst(1000.milliseconds)
* ```
*
* produces the following emissions.
*
* ```text
* 1, 4, 5
* ```
*
* @see kotlinx.coroutines.flow.debounce(Duration)
* @param timeout Emissions within this duration of the last emission are filtered
* @param timeSource Used to measure elapsed time. Normally only overridden in tests
*/
@OptIn(ExperimentalTime::class)
fun <T> Flow<T>.throttleFirst(
timeout: Duration,
timeSource: TimeSource = TimeSource.Monotonic
) = flow {
var marker: TimeMark? = null
collect {
if (marker == null || marker!!.elapsedNow() >= timeout) {
emit(it)
marker = timeSource.markNow()
}
}
}

View File

@ -2,25 +2,27 @@
package com.keylesspalace.tusky.util
import java.text.DecimalFormat
import java.text.NumberFormat
import kotlin.math.abs
import kotlin.math.floor
import kotlin.math.log10
import kotlin.math.ln
import kotlin.math.pow
import kotlin.math.sign
val shortLetters = arrayOf(' ', 'K', 'M', 'B', 'T', 'P', 'E')
private val numberFormatter: NumberFormat = NumberFormat.getInstance()
private val ln_1k = ln(1000.0)
fun shortNumber(number: Number): String {
val numberAsDouble = number.toDouble()
val nonNegativeValue = abs(numberAsDouble)
var sign = ""
if (numberAsDouble.sign < 0) { sign = "-" }
val value = floor(log10(nonNegativeValue)).toInt()
val base = value / 3
if (value >= 3 && base < shortLetters.size) {
return DecimalFormat("$sign#0.0").format(nonNegativeValue / 10.0.pow((base * 3).toDouble())) + shortLetters[base]
} else {
return DecimalFormat("$sign#,##0").format(nonNegativeValue)
}
/**
* Format numbers according to the current locale. Numbers < min have
* separators (',', '.', etc) inserted according to the locale.
*
* Numbers >= min are scaled down to that by multiples of 1,000, and
* a suffix appropriate to the scaling is appended.
*/
fun formatNumber(num: Long, min: Int = 100000): String {
val absNum = abs(num)
if (absNum < min) return numberFormatter.format(num)
val exp = (ln(absNum.toDouble()) / ln_1k).toInt()
// Suffixes here are locale-agnostic
return String.format("%.1f%c", num / 1000.0.pow(exp.toDouble()), "KMGTPE"[exp - 1])
}

View File

@ -1,8 +1,11 @@
package com.keylesspalace.tusky.util
import android.content.Context
import com.keylesspalace.tusky.R
import org.json.JSONException
import org.json.JSONObject
import retrofit2.HttpException
import java.io.IOException
/**
* checks if this throwable indicates an error causes by a 4xx/5xx server response and
@ -24,3 +27,16 @@ fun Throwable.getServerErrorMessage(): String? {
}
return null
}
/** @return A drawable resource to accompany the error message for this throwable */
fun Throwable.getDrawableRes(): Int = when (this) {
is IOException -> R.drawable.elephant_offline
is HttpException -> R.drawable.elephant_offline
else -> R.drawable.elephant_error
}
/** @return A string error message for this throwable */
fun Throwable.getErrorString(context: Context): String = getServerErrorMessage() ?: when (this) {
is IOException -> context.getString(R.string.error_network)
else -> context.getString(R.string.error_generic)
}

View File

@ -40,7 +40,6 @@ import com.keylesspalace.tusky.viewdata.NotificationViewData
import com.keylesspalace.tusky.viewdata.StatusViewData
import com.keylesspalace.tusky.viewdata.TrendingViewData
@JvmName("statusToViewData")
fun Status.toViewData(
isShowingContent: Boolean,
isExpanded: Boolean,
@ -56,7 +55,6 @@ fun Status.toViewData(
)
}
@JvmName("notificationToViewData")
fun Notification.toViewData(
isShowingContent: Boolean,
isExpanded: Boolean,
@ -71,9 +69,20 @@ fun Notification.toViewData(
)
}
@JvmName("tagToViewData")
fun TrendingTag.toViewData(): TrendingViewData.Tag {
return TrendingViewData.Tag(
tag = this
)
fun List<TrendingTag>.toViewData(): List<TrendingViewData.Tag> {
val maxTrendingValue = flatMap { tag -> tag.history }
.mapNotNull { it.uses.toLongOrNull() }
.maxOrNull() ?: 1
return map { tag ->
val reversedHistory = tag.history.asReversed()
TrendingViewData.Tag(
name = tag.name,
usage = reversedHistory.mapNotNull { it.uses.toLongOrNull() },
accounts = reversedHistory.mapNotNull { it.accounts.toLongOrNull() },
maxTrendingValue = maxTrendingValue
)
}
}

View File

@ -18,6 +18,7 @@ package com.keylesspalace.tusky.util
import android.util.Log
import android.view.View
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2
@ -66,3 +67,14 @@ fun ViewPager2.reduceSwipeSensitivity() {
Log.w("reduceSwipeSensitivity", e)
}
}
/**
* TextViews with an ancestor RecyclerView can forget that they are selectable. Toggling
* calls to [TextView.setTextIsSelectable] fixes this.
*
* @see https://issuetracker.google.com/issues/37095917
*/
fun TextView.fixTextSelection() {
setTextIsSelectable(false)
post { setTextIsSelectable(true) }
}

View File

@ -1,6 +1,7 @@
package com.keylesspalace.tusky.view
import android.content.Context
import android.text.method.LinkMovementMethod
import android.util.AttributeSet
import android.view.Gravity
import android.view.LayoutInflater
@ -12,6 +13,8 @@ import androidx.annotation.StringRes
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ViewBackgroundMessageBinding
import com.keylesspalace.tusky.util.addDrawables
import com.keylesspalace.tusky.util.getDrawableRes
import com.keylesspalace.tusky.util.getErrorString
import com.keylesspalace.tusky.util.visible
/**
@ -34,16 +37,27 @@ class BackgroundMessageView @JvmOverloads constructor(
}
}
fun setup(throwable: Throwable, listener: ((v: View) -> Unit)? = null) {
setup(throwable.getDrawableRes(), throwable.getErrorString(context), listener)
}
fun setup(
@DrawableRes imageRes: Int,
@StringRes messageRes: Int,
clickListener: ((v: View) -> Unit)? = null
) = setup(imageRes, context.getString(messageRes), clickListener)
/**
* Setup image, message and button.
* If [clickListener] is `null` then the button will be hidden.
*/
fun setup(
@DrawableRes imageRes: Int,
@StringRes messageRes: Int,
message: String,
clickListener: ((v: View) -> Unit)? = null
) {
binding.messageTextView.setText(messageRes)
binding.messageTextView.text = message
binding.messageTextView.movementMethod = LinkMovementMethod.getInstance()
binding.imageView.setImageResource(imageRes)
binding.button.setOnClickListener(clickListener)
binding.button.visible(clickListener != null)

View File

@ -22,10 +22,9 @@ import android.graphics.Path
import android.graphics.PathMeasure
import android.graphics.Rect
import android.util.AttributeSet
import android.view.View
import androidx.annotation.ColorInt
import androidx.annotation.Dimension
import androidx.appcompat.widget.AppCompatImageView
import androidx.core.content.ContextCompat
import androidx.core.content.res.use
import com.keylesspalace.tusky.R
import kotlin.math.max
@ -33,9 +32,8 @@ import kotlin.math.max
class GraphView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
defStyleRes: Int = 0
) : AppCompatImageView(context, attrs, defStyleAttr) {
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
@get:ColorInt
@ColorInt
var primaryLineColor = 0
@ -55,7 +53,7 @@ class GraphView @JvmOverloads constructor(
@ColorInt
var metaColor = 0
var proportionalTrending = false
private var proportionalTrending = false
private lateinit var primaryLinePaint: Paint
private lateinit var secondaryLinePaint: Paint
@ -129,16 +127,14 @@ class GraphView @JvmOverloads constructor(
private fun initFromXML(attr: AttributeSet?) {
context.obtainStyledAttributes(attr, R.styleable.GraphView).use { a ->
primaryLineColor = ContextCompat.getColor(
context,
primaryLineColor = context.getColor(
a.getResourceId(
R.styleable.GraphView_primaryLineColor,
R.color.tusky_blue
)
)
secondaryLineColor = ContextCompat.getColor(
context,
secondaryLineColor = context.getColor(
a.getResourceId(
R.styleable.GraphView_secondaryLineColor,
R.color.tusky_red
@ -150,16 +146,14 @@ class GraphView @JvmOverloads constructor(
R.dimen.graph_line_thickness
).toFloat()
graphColor = ContextCompat.getColor(
context,
graphColor = context.getColor(
a.getResourceId(
R.styleable.GraphView_graphColor,
R.color.colorBackground
)
)
metaColor = ContextCompat.getColor(
context,
metaColor = context.getColor(
a.getResourceId(
R.styleable.GraphView_metaColor,
R.color.dividerColor

View File

@ -0,0 +1,185 @@
package com.keylesspalace.tusky.view
import android.content.Context
import android.content.res.TypedArray
import android.graphics.drawable.Drawable
import android.util.AttributeSet
import android.view.View.VISIBLE
import androidx.appcompat.content.res.AppCompatResources
import androidx.preference.Preference
import androidx.preference.PreferenceViewHolder
import com.google.android.material.slider.LabelFormatter.LABEL_GONE
import com.google.android.material.slider.Slider
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.PrefSliderBinding
import java.lang.Float.max
import java.lang.Float.min
/**
* Slider preference
*
* Similar to [androidx.preference.SeekBarPreference], but better because:
*
* - Uses a [Slider] instead of a [android.widget.SeekBar]. Slider supports float values, and step sizes
* other than 1.
* - Displays the currently selected value in the Preference's summary, for consistency
* with platform norms.
* - Icon buttons can be displayed at the start/end of the slider. Pressing them will
* increment/decrement the slider by `stepSize`.
* - User can supply a custom formatter to format the summary value
*/
class SliderPreference @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = androidx.preference.R.attr.preferenceStyle,
defStyleRes: Int = 0
) : Preference(context, attrs, defStyleAttr, defStyleRes),
Slider.OnChangeListener,
Slider.OnSliderTouchListener {
/** Backing property for `value` */
private var _value = 0F
/**
* @see Slider.getValue
* @see Slider.setValue
*/
var value: Float = defaultValue
get() = _value
set(v) {
val clamped = max(max(v, valueFrom), min(v, valueTo))
if (clamped == field) return
_value = clamped
persistFloat(v)
notifyChanged()
}
/** @see Slider.setValueFrom */
var valueFrom: Float
/** @see Slider.setValueTo */
var valueTo: Float
/** @see Slider.setStepSize */
var stepSize: Float
/**
* Format string to be applied to values before setting the summary. For more control set
* [SliderPreference.formatter]
*/
var format: String = defaultFormat
/**
* Function that will be used to format the summary. The default formatter formats using the
* value of the [SliderPreference.format] property.
*/
var formatter: (Float) -> String = { format.format(it) }
/**
* Optional icon to show in a button at the start of the slide. If non-null the button is
* shown. Clicking the button decrements the value by one step.
*/
var decrementIcon: Drawable? = null
/**
* Optional icon to show in a button at the end of the slider. If non-null the button is
* shown. Clicking the button increments the value by one step.
*/
var incrementIcon: Drawable? = null
/** View binding */
private lateinit var binding: PrefSliderBinding
init {
// Using `widgetLayoutResource` here would be incorrect, as that tries to put the entire
// preference layout to the right of the title and summary.
layoutResource = R.layout.pref_slider
val a = context.obtainStyledAttributes(attrs, R.styleable.SliderPreference, defStyleAttr, defStyleRes)
value = a.getFloat(R.styleable.SliderPreference_android_value, defaultValue)
valueFrom = a.getFloat(R.styleable.SliderPreference_android_valueFrom, defaultValueFrom)
valueTo = a.getFloat(R.styleable.SliderPreference_android_valueTo, defaultValueTo)
stepSize = a.getFloat(R.styleable.SliderPreference_android_stepSize, defaultStepSize)
format = a.getString(R.styleable.SliderPreference_format) ?: defaultFormat
val decrementIconResource = a.getResourceId(R.styleable.SliderPreference_iconStart, -1)
if (decrementIconResource != -1) {
decrementIcon = AppCompatResources.getDrawable(context, decrementIconResource)
}
val incrementIconResource = a.getResourceId(R.styleable.SliderPreference_iconEnd, -1)
if (incrementIconResource != -1) {
incrementIcon = AppCompatResources.getDrawable(context, incrementIconResource)
}
a.recycle()
}
override fun onGetDefaultValue(a: TypedArray, i: Int): Any {
return a.getFloat(i, defaultValue)
}
override fun onSetInitialValue(defaultValue: Any?) {
value = getPersistedFloat((defaultValue ?: Companion.defaultValue) as Float)
}
override fun onBindViewHolder(holder: PreferenceViewHolder) {
super.onBindViewHolder(holder)
binding = PrefSliderBinding.bind(holder.itemView)
binding.root.isClickable = false
binding.slider.addOnChangeListener(this)
binding.slider.addOnSliderTouchListener(this)
binding.slider.value = value // sliderValue
binding.slider.valueTo = valueTo
binding.slider.valueFrom = valueFrom
binding.slider.stepSize = stepSize
// Disable the label, the value is shown in the preference summary
binding.slider.labelBehavior = LABEL_GONE
binding.slider.isEnabled = isEnabled
binding.summary.visibility = VISIBLE
binding.summary.text = formatter(value)
decrementIcon?.let { icon ->
binding.decrement.icon = icon
binding.decrement.visibility = VISIBLE
binding.decrement.setOnClickListener {
value -= stepSize
}
}
incrementIcon?.let { icon ->
binding.increment.icon = icon
binding.increment.visibility = VISIBLE
binding.increment.setOnClickListener {
value += stepSize
}
}
}
override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) {
if (!fromUser) return
binding.summary.text = formatter(value)
}
override fun onStartTrackingTouch(slider: Slider) {
// Deliberately empty
}
override fun onStopTrackingTouch(slider: Slider) {
value = slider.value
}
companion object {
private const val TAG = "SliderPreference"
private const val defaultValueFrom = 0F
private const val defaultValueTo = 1F
private const val defaultValue = 0.5F
private const val defaultStepSize = 0.1F
private const val defaultFormat = "%3.1f"
}
}

View File

@ -15,9 +15,6 @@
package com.keylesspalace.tusky.viewdata
import com.keylesspalace.tusky.entity.TrendingTag
import com.keylesspalace.tusky.entity.end
import com.keylesspalace.tusky.entity.start
import java.util.Date
sealed class TrendingViewData {
@ -31,18 +28,13 @@ sealed class TrendingViewData {
get() = start.toString() + end.toString()
}
fun asHeaderOrNull(): Header? {
val tag = (this as? Tag)?.tag
?: return null
return Header(tag.start(), tag.end())
}
data class Tag(
val tag: TrendingTag
val name: String,
val usage: List<Long>,
val accounts: List<Long>,
val maxTrendingValue: Long
) : TrendingViewData() {
override val id: String
get() = tag.name
get() = name
}
fun asTagOrNull() = this as? Tag
}

View File

@ -0,0 +1,53 @@
/*
* Copyright 2023 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>.
*/
package com.keylesspalace.tusky.worker
import android.app.Notification
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.ForegroundInfo
import androidx.work.WorkerParameters
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.notifications.NotificationFetcher
import com.keylesspalace.tusky.components.notifications.NotificationHelper
import com.keylesspalace.tusky.components.notifications.NotificationHelper.NOTIFICATION_ID_FETCH_NOTIFICATION
import javax.inject.Inject
/** Fetch and show new notifications. */
class NotificationWorker(
appContext: Context,
params: WorkerParameters,
private val notificationsFetcher: NotificationFetcher
) : CoroutineWorker(appContext, params) {
val notification: Notification = NotificationHelper.createWorkerNotification(applicationContext, R.string.notification_notification_worker)
override suspend fun doWork(): Result {
notificationsFetcher.fetchAndShow()
return Result.success()
}
override suspend fun getForegroundInfo() = ForegroundInfo(NOTIFICATION_ID_FETCH_NOTIFICATION, notification)
class Factory @Inject constructor(
private val notificationsFetcher: NotificationFetcher
) : ChildWorkerFactory {
override fun createWorker(appContext: Context, params: WorkerParameters): CoroutineWorker {
return NotificationWorker(appContext, params, notificationsFetcher)
}
}
}

View File

@ -0,0 +1,67 @@
/*
* Copyright 2023 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>.
*/
package com.keylesspalace.tusky.worker
import android.app.Notification
import android.content.Context
import android.util.Log
import androidx.work.CoroutineWorker
import androidx.work.ForegroundInfo
import androidx.work.ListenableWorker
import androidx.work.WorkerParameters
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.notifications.NotificationHelper
import com.keylesspalace.tusky.components.notifications.NotificationHelper.NOTIFICATION_ID_PRUNE_CACHE
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import javax.inject.Inject
/** Prune the database cache of old statuses. */
class PruneCacheWorker(
appContext: Context,
workerParams: WorkerParameters,
private val appDatabase: AppDatabase,
private val accountManager: AccountManager
) : CoroutineWorker(appContext, workerParams) {
val notification: Notification = NotificationHelper.createWorkerNotification(applicationContext, R.string.notification_prune_cache)
override suspend fun doWork(): Result {
for (account in accountManager.accounts) {
Log.d(TAG, "Pruning database using account ID: ${account.id}")
appDatabase.timelineDao().cleanup(account.id, MAX_STATUSES_IN_CACHE)
}
return Result.success()
}
override suspend fun getForegroundInfo() = ForegroundInfo(NOTIFICATION_ID_PRUNE_CACHE, notification)
companion object {
private const val TAG = "PruneCacheWorker"
private const val MAX_STATUSES_IN_CACHE = 1000
const val PERIODIC_WORK_TAG = "PruneCacheWorker_periodic"
}
class Factory @Inject constructor(
private val appDatabase: AppDatabase,
private val accountManager: AccountManager
) : ChildWorkerFactory {
override fun createWorker(appContext: Context, params: WorkerParameters): ListenableWorker {
return PruneCacheWorker(appContext, params, appDatabase, accountManager)
}
}
}

View File

@ -0,0 +1,71 @@
/*
* Copyright 2023 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>.
*/
package com.keylesspalace.tusky.worker
import android.content.Context
import android.util.Log
import androidx.work.ListenableWorker
import androidx.work.WorkerFactory
import androidx.work.WorkerParameters
import javax.inject.Inject
import javax.inject.Provider
import javax.inject.Singleton
/**
* Workers implement this and are added to the map in [com.keylesspalace.tusky.di.WorkerModule]
* so they can be created by [WorkerFactory.createWorker].
*/
interface ChildWorkerFactory {
/** Create a new instance of the given worker. */
fun createWorker(appContext: Context, params: WorkerParameters): ListenableWorker
}
/**
* Creates workers, delegating to each worker's [ChildWorkerFactory.createWorker] to do the
* creation.
*
* @see [com.keylesspalace.tusky.worker.NotificationWorker]
*/
@Singleton
class WorkerFactory @Inject constructor(
private val workerFactories: Map<Class<out ListenableWorker>, @JvmSuppressWildcards Provider<ChildWorkerFactory>>
) : WorkerFactory() {
override fun createWorker(
appContext: Context,
workerClassName: String,
workerParameters: WorkerParameters
): ListenableWorker? {
val key = try {
Class.forName(workerClassName)
} catch (e: ClassNotFoundException) {
// Class might be missing if it was renamed / moved to a different package, as
// periodic work requests from before the rename might still exist. Catch and
// return null, which should stop future requests.
Log.d(TAG, "Invalid class: $workerClassName", e)
null
}
workerFactories[key]?.let {
return it.get().createWorker(appContext, workerParameters)
}
return null
}
companion object {
private const val TAG = "WorkerFactory"
}
}

View File

@ -10,79 +10,36 @@ import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import net.accelf.yuito.streaming.SubscribeRequest.RequestType.SUBSCRIBE
import net.accelf.yuito.streaming.SubscribeRequest.RequestType.UNSUBSCRIBE
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import kotlin.coroutines.CoroutineContext
class MastodonStream(
parent: Job,
coroutineScope: CoroutineScope,
private val okHttpClient: OkHttpClient,
private val gson: Gson,
private val eventHub: EventHub,
private val onStatusChange: (Boolean) -> Unit,
) : WebSocketListener(), CoroutineScope {
) : WebSocketListener(), CoroutineScope by coroutineScope {
private var webSocket: WebSocket? = null
private val subscribing = mutableSetOf<Subscription>()
private val job = SupervisorJob(parent).apply {
invokeOnCompletion {
webSocket?.let {
closeSocket()
}
}
}
override val coroutineContext: CoroutineContext
get() = job
fun subscribe(subscription: Subscription) {
if (!subscribing.add(subscription)) {
// already subscribed
return
}
if (webSocket == null) {
openSocket()
}
send(SubscribeRequest.fromSubscription(SUBSCRIBE, subscription))
Log.d(TAG, "Subscribed $subscription")
}
fun unsubscribe(subscription: Subscription) {
if (!subscribing.remove(subscription)) {
// already unsubscribed
return
}
if (subscribing.isEmpty()) {
closeSocket()
return
}
send(SubscribeRequest.fromSubscription(UNSUBSCRIBE, subscription))
Log.d(TAG, "Unsubscribed $subscription")
}
private fun openSocket() {
fun openSocket(subscriptions: Set<Subscription>) {
val request = Request.Builder().url(STREAMING_URL).build()
webSocket = okHttpClient.newWebSocket(request, this)
onStatusChange(true)
subscriptions.forEach {
send(SubscribeRequest.fromSubscription(SUBSCRIBE, it))
Log.d(TAG, "Subscribed $it")
}
}
private fun closeSocket() {
fun closeSocket() {
webSocket!!.close(1000, null)
webSocket = null
onStatusChange(false)
}
private fun send(subscribeRequest: SubscribeRequest) {
@ -100,7 +57,7 @@ class MastodonStream(
StreamEvent.EventType.UPDATE -> {
val status = gson.fromJson(payload, Status::class.java)
launch {
eventHub.dispatch(StreamUpdateEvent(status, Subscription.fromStreamList(event.stream)))
eventHub.dispatch(StreamUpdateEvent(status, Subscription.fromStreamList(event.stream), this@MastodonStream.hashCode()))
}
}
StreamEvent.EventType.DELETE -> launch {

View File

@ -1,30 +1,39 @@
package net.accelf.yuito.streaming
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import com.google.gson.Gson
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.di.ActivityScope
import kotlinx.coroutines.Job
import okhttp3.OkHttpClient
import javax.inject.Inject
import javax.inject.Singleton
@ActivityScope
@Singleton
class StreamingManager @Inject constructor(
private val eventHub: EventHub,
private val okHttpClient: OkHttpClient,
private val gson: Gson,
) {
private lateinit var stream: MastodonStream
private var stream: MastodonStream? = null
fun setup(parent: Job, onStatusChange: (Boolean) -> Unit) {
stream = MastodonStream(parent, okHttpClient, gson, eventHub, onStatusChange)
}
fun subscribe(subscription: Subscription) {
stream.subscribe(subscription)
}
fun unsubscribe(subscription: Subscription) {
stream.unsubscribe(subscription)
fun setup(owner: LifecycleOwner, subscriptions: Set<Subscription>, onStatusChange: (Boolean) -> Unit) {
owner.lifecycle.addObserver(LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> {
stream = MastodonStream(owner.lifecycleScope, okHttpClient, gson, eventHub)
stream?.openSocket(subscriptions)
onStatusChange(true)
}
Lifecycle.Event.ON_PAUSE -> {
stream?.closeSocket()
stream = null
onStatusChange(false)
}
else -> {}
}
})
}
}

View File

@ -80,14 +80,13 @@
android:id="@+id/tag"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:ellipsize="none"
android:ellipsize="end"
android:importantForAccessibility="no"
android:singleLine="true"
android:textAlignment="textStart"
android:textAppearance="?android:attr/textAppearanceListItem"
android:textColor="?android:textColorPrimary"
android:textStyle="normal"
app:layout_constrainedWidth="true"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="#itishashtagtuesdayitishashtagtuesday" />

View File

@ -443,8 +443,7 @@
android:layout_height="wrap_content"
android:background="?attr/colorSurface"
app:tabGravity="center"
app:tabMode="scrollable"
app:tabTextAppearance="@style/TuskyTabAppearance" />
app:tabMode="scrollable" />
</com.google.android.material.appbar.AppBarLayout>

View File

@ -144,14 +144,30 @@
android:minHeight="48dp"
android:text="@string/pref_title_account_filter_keywords" />
<Button
android:id="@+id/filter_save_button"
android:layout_width="wrap_content"
android:layout_gravity="end"
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="6dp"
android:layout_marginBottom="16dp"
android:text="@string/action_save" />
android:paddingStart="?android:attr/listPreferredItemPaddingStart"
android:paddingEnd="?android:attr/listPreferredItemPaddingEnd"
android:gravity="end"
style="?android:attr/buttonBarStyle">
<Button
android:id="@+id/filter_delete_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/action_delete"
style="?android:attr/buttonBarButtonStyle" />
<Button
android:id="@+id/filter_save_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:text="@string/action_save"
style="?android:attr/buttonBarButtonStyle" />
</LinearLayout>
</LinearLayout>
</androidx.core.widget.NestedScrollView>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -25,6 +25,7 @@
android:layout_height="wrap_content"
android:layout_gravity="center"
app:layout_behavior="@string/appbar_scrolling_view_behavior"
tools:visibility="gone"
/>
<ProgressBar
@ -33,6 +34,7 @@
android:layout_height="wrap_content"
android:layout_gravity="center"
app:layout_behavior="@string/appbar_scrolling_view_behavior"
tools:visibility="gone"
/>
<com.google.android.material.floatingactionbutton.FloatingActionButton

View File

@ -28,8 +28,7 @@
android:layout_height="wrap_content"
app:tabGravity="fill"
app:tabMaxWidth="0dp"
app:tabMode="fixed"
app:tabTextAppearance="@style/TuskyTabAppearance" />
app:tabMode="fixed" />
</com.google.android.material.appbar.AppBarLayout>

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